diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs index 320aeacda..2ca332265 100644 --- a/.dialyzer_ignore.exs +++ b/.dialyzer_ignore.exs @@ -12,23 +12,6 @@ {"lib/phoenix_kit/install/migration_strategy.ex", :unknown_function}, {"lib/mix/tasks/phoenix_kit.status.ex", :unknown_function}, {"lib/phoenix_kit/migrations/postgres.ex", :unknown_function}, - {"lib/mix/tasks/phoenix_kit/email_cleanup.ex", :unknown_function}, - {"lib/mix/tasks/phoenix_kit/email_export.ex", :unknown_function}, - {"lib/mix/tasks/phoenix_kit/email_stats.ex", :unknown_function}, - {"lib/mix/tasks/phoenix_kit/email_test_webhook.ex", :unknown_function}, - {"lib/mix/tasks/phoenix_kit/email_verify_config.ex", :unknown_function}, - {"lib/mix/tasks/phoenix_kit/entities/export.ex", :unknown_function}, - {"lib/mix/tasks/phoenix_kit/entities/import.ex", :unknown_function}, - {"lib/mix/tasks/phoenix_kit.email.debug_sqs.ex", :unknown_function}, - {"lib/mix/tasks/phoenix_kit.email.process_dlq.ex", :unknown_function}, - {"lib/mix/tasks/phoenix_kit.email.send_test.ex", :unknown_function}, - {"lib/mix/tasks/phoenix_kit.configure_aws_ses.ex", :unknown_function}, - {"lib/mix/tasks/phoenix_kit.process_dlq.ex", :unknown_function}, - {"lib/mix/tasks/phoenix_kit.process_sqs_queue.ex", :unknown_function}, - {"lib/mix/tasks/phoenix_kit.sync_email_status.ex", :unknown_function}, - {"lib/mix/tasks/phoenix_kit.seed_templates.ex", :unknown_function}, - {"lib/mix/tasks/phoenix_kit.fix_missing_events.ex", :unknown_function}, - {"lib/mix/tasks/phoenix_kit.process_sqs.ex", :unknown_function}, {"lib/mix/tasks/phoenix_kit.cleanup_orphaned_files.ex", :unknown_function}, # Mix.Task behaviour callbacks (expected in Mix tasks) @@ -36,7 +19,6 @@ # Adding @impl Mix.Task does not fix this warning {"lib/mix/tasks/phoenix_kit.doctor.ex", :callback_info_missing, 1}, {"lib/mix/tasks/phoenix_kit.gen.migration.ex", :callback_info_missing, 1}, - {"lib/mix/tasks/phoenix_kit.seed_templates.ex", :callback_info_missing, 1}, {"lib/mix/tasks/phoenix_kit.install.ex", :callback_info_missing, 2}, {"lib/mix/tasks/phoenix_kit.update.ex", :callback_info_missing, 3}, {"lib/mix/tasks/phoenix_kit.gen.admin_page.ex", :callback_info_missing}, @@ -44,36 +26,9 @@ {"lib/mix/tasks/phoenix_kit.modernize_layouts.ex", :callback_info_missing, 1}, {"lib/mix/tasks/phoenix_kit.assets.rebuild.ex", :callback_info_missing, 1}, {"lib/mix/tasks/phoenix_kit.status.ex", :callback_info_missing, 1}, - {"lib/mix/tasks/phoenix_kit/email_cleanup.ex", :callback_info_missing, 1}, - {"lib/mix/tasks/phoenix_kit/email_export.ex", :callback_info_missing, 1}, - {"lib/mix/tasks/phoenix_kit/email_stats.ex", :callback_info_missing, 1}, - {"lib/mix/tasks/phoenix_kit/email_test_webhook.ex", :callback_info_missing, 1}, - {"lib/mix/tasks/phoenix_kit/email_verify_config.ex", :callback_info_missing, 1}, - {"lib/mix/tasks/phoenix_kit/entities/export.ex", :callback_info_missing, 1}, - {"lib/mix/tasks/phoenix_kit/entities/import.ex", :callback_info_missing, 1}, - {"lib/mix/tasks/phoenix_kit.email.debug_sqs.ex", :callback_info_missing, 1}, - {"lib/mix/tasks/phoenix_kit.email.process_dlq.ex", :callback_info_missing, 1}, - {"lib/mix/tasks/phoenix_kit.email.send_test.ex", :callback_info_missing, 1}, - {"lib/mix/tasks/phoenix_kit.configure_aws_ses.ex", :callback_info_missing, 1}, - {"lib/mix/tasks/phoenix_kit.process_dlq.ex", :callback_info_missing, 1}, - {"lib/mix/tasks/phoenix_kit.process_sqs_queue.ex", :callback_info_missing, 1}, - {"lib/mix/tasks/phoenix_kit.sync_email_status.ex", :callback_info_missing, 1}, - {"lib/mix/tasks/phoenix_kit.fix_missing_events.ex", :callback_info_missing, 1}, - {"lib/mix/tasks/phoenix_kit.process_sqs.ex", :callback_info_missing, 1}, {"lib/mix/tasks/phoenix_kit.cleanup_orphaned_files.ex", :callback_info_missing, 1}, - # False positive pattern match warnings (runtime behavior differs from static analysis) - {"lib/mix/tasks/phoenix_kit/email_cleanup.ex", :pattern_match, 1}, - - # Publishing module defensive fallbacks and settings_call dynamic dispatch - {"lib/modules/publishing/publishing.ex", :guard_fail}, - {"lib/modules/publishing/publishing.ex", :pattern_match_cov}, - {"lib/modules/publishing/publishing.ex", :pattern_match}, - {"lib/modules/publishing/shared.ex", :guard_fail}, - # ExAws library type definition issues (false positives from incomplete type specs) - ~r/lib\/modules\/emails\/archiver\.ex:.*pattern_match/, - ~r/lib\/modules\/emails\/archiver\.ex:.*unused_fun/, - + # Publishing module (extracted) — dynamic dispatch through publishing_module() helper # Ecto.Multi opaque type false positives (code works correctly) ~r/lib\/phoenix_kit\/users\/auth\.ex:.*call_without_opaque/, @@ -86,11 +41,7 @@ ~r/lib\/modules\/legal\/schemas\/consent_log\.ex:.*no_return/, ~r/lib\/modules\/legal\/schemas\/consent_log\.ex:.*call/, - # Publishing Editor submodules - with-chain type inference false positives - ~r/lib\/modules\/publishing\/web\/editor\/.*\.ex:.*pattern_match/, - ~r/lib\/modules\/publishing\/web\/editor\/.*\.ex:.*pattern_match_cov/, - - # Pages module - same type inference false positives as Publishing (copied codebase) + # Pages module - type inference false positives ~r/lib\/modules\/pages\/listing_cache\.ex:.*pattern_match/, ~r/lib\/modules\/pages\/storage\/.*\.ex:.*pattern_match/, ~r/lib\/modules\/pages\/storage\/.*\.ex:.*call/, @@ -129,7 +80,6 @@ # Entity form - defensive catch-all clauses for mb_to_bytes and parse_accept_list # Dialyzer proves previous clauses cover all actual call-site types but # catch-alls are kept intentionally for safety with dynamic form params - {"lib/modules/entities/web/entity_form.ex", :pattern_match_cov}, # tab_callback_context/1 has a :user_dashboard_tabs clause for future use # but compile_module_admin_routes only passes :admin_tabs and :settings_tabs currently @@ -137,10 +87,21 @@ # External optional modules guarded by Code.ensure_loaded? at runtime {"lib/modules/sitemap/sources/posts.ex", :unknown_function}, + {"lib/modules/sitemap/sources/publishing.ex", :unknown_function}, + {"lib/modules/pages/renderer.ex", :unknown_function}, + {"lib/modules/pages/page_builder/renderer.ex", :unknown_function}, + {"lib/phoenix_kit/dashboard/registry.ex", :unknown_function}, + {"lib/phoenix_kit/install/css_integration.ex", :unknown_function}, {"lib/phoenix_kit/scheduled_jobs/workers/process_scheduled_jobs_worker.ex", :unknown_function}, # ExUnit internal functions — false positives when test/support is compiled in MIX_ENV=test # Dialyzer cannot resolve ExUnit private macros expanded at compile time {"test/support/conn_case.ex", :unknown_function}, - {"test/support/data_case.ex", :unknown_function} + {"test/support/data_case.ex", :unknown_function}, + + # Sync connections_live - MapSet opaque type false positives in topo_sort/visit_node + # Same pattern as context_selector.ex - MapSet.t() opaque type through recursive functions + # Matches both standard Dialyxir format (call_without_opaque) and legacy format (opaque term) + ~r/lib\/modules\/sync\/web\/connections_live\.ex:.*call_without_opaque/, + ~r/lib\/modules\/sync\/web\/connections_live\.ex:.*opaque term/ ] diff --git a/AGENTS.md b/AGENTS.md index 7ff71633e..97c417407 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,7 +52,7 @@ git status PhoenixKit has two levels of tests: 1. **Unit tests** (`test/phoenix_kit/`, `test/modules/`) — Pure logic, no DB required -2. **Integration tests** (`test/integration/`, `test/modules/publishing/integration/`) — Real PostgreSQL via Ecto sandbox +2. **Integration tests** (`test/integration/`) — Real PostgreSQL via Ecto sandbox #### Test database setup diff --git a/config/test.exs b/config/test.exs index a66de4585..fd451e7f7 100644 --- a/config/test.exs +++ b/config/test.exs @@ -46,10 +46,6 @@ config :phoenix_kit, session_fingerprint_enabled: true, session_fingerprint_strict: false -# Future: Configure FakeSettings when publishing tests are implemented -# config :phoenix_kit, -# publishing_settings_module: PhoenixKit.Test.FakeSettings - # Configure logger for tests config :logger, level: :warning diff --git a/dev_docs/guides/2026-02-23-plugin-system-architecture-guide.md b/dev_docs/guides/2026-02-23-plugin-system-architecture-guide.md index 1d69ef6c7..d3551260a 100644 --- a/dev_docs/guides/2026-02-23-plugin-system-architecture-guide.md +++ b/dev_docs/guides/2026-02-23-plugin-system-architecture-guide.md @@ -11,7 +11,7 @@ PhoenixKit modules can be independently installable as separate hex packages. Th ### PhoenixKit.Module Behaviour -All 21 internal modules implement `use PhoenixKit.Module`, which provides: +All internal modules implement `use PhoenixKit.Module`, which provides: **Required callbacks:** - `module_key/0` — unique string identifier (e.g., `"tickets"`) @@ -33,14 +33,14 @@ All 21 internal modules implement `use PhoenixKit.Module`, which provides: ### PhoenixKit.ModuleRegistry GenServer using `:persistent_term` for zero-cost reads. Loads modules from two sources: -1. **Internal modules** — hardcoded list in `internal_modules/0` (the ONE place that enumerates all 21 bundled modules) +1. **Internal modules** — hardcoded list in `internal_modules/0` (the ONE place that enumerates all bundled modules) 2. **External modules** — auto-discovered from beam files via `PhoenixKit.ModuleDiscovery`, with `Application.get_env(:phoenix_kit, :modules, [])` as fallback Provides aggregated queries: `all_admin_tabs/0`, `all_settings_tabs/0`, `all_permission_metadata/0`, `feature_enabled_checks/0`, `get_by_key/1`, etc. ### Core Files Refactored -Seven core files that previously hardcoded references to all 21 modules now use the registry: +Seven core files that previously hardcoded references to all modules now use the registry: | File | What Changed | |------|-------------| diff --git a/dev_docs/pull_requests/2026/443-ai-system-prompt-playground/CLAUDE_REVIEW.md b/dev_docs/pull_requests/2026/443-ai-system-prompt-playground/CLAUDE_REVIEW.md new file mode 100644 index 000000000..24dbb58a7 --- /dev/null +++ b/dev_docs/pull_requests/2026/443-ai-system-prompt-playground/CLAUDE_REVIEW.md @@ -0,0 +1,72 @@ +# Claude Review — PR #443: AI System Prompt & Playground + +**Verdict:** Approve with minor observations + +## What's Good + +1. **Clean system_prompt integration.** The field slots naturally into the existing Prompt schema. Variable extraction from both fields is handled by combining strings before regex, which keeps the logic simple and avoids duplicate extraction paths. + +2. **`render_system_prompt/2` follows existing patterns.** Pattern-matched on `nil` and `""` returning `{:ok, nil}`, consistent with how `render/2` works. The `with` chain in `ask_with_prompt` integrates cleanly. + +3. **Playground LiveView is well-structured.** Separation of concerns is clear: `apply_form_changes` delegates to focused `maybe_update_*` helpers, request execution is split into `execute_prompt_request` and `execute_freeform_request`, and the async `handle_info(:do_send, ...)` pattern avoids blocking the LiveView process. + +4. **Migration is idempotent.** V85 checks both table and column existence with `information_schema` queries before altering — safe for re-runs and partial migrations. + +5. **Good test coverage.** 44 unit tests covering the Prompt module's public API comprehensively, including edge cases (nil, empty strings, atom vs string keys, deduplication). + +## Issues Found + +### Minor: Missing `tab-active` class on Playground tab in endpoints page + +In `endpoints.html.heex`, the Playground tab link (lines 145-150 of the diff) lacks the conditional `tab-active` class that other tabs use: + +```heex +# Current (missing active state): +<.link navigate={...} class="tab"> + +# Expected pattern (matching other tabs): +<.link navigate={...} class={"tab #{if @active_tab == "playground", do: "tab-active"}"}> +``` + +The same issue exists in `prompts.html.heex` — the Playground tab there also has no active state conditional. Only `playground.html.heex` itself correctly shows the active state (hardcoded as `tab tab-active`). + +**Impact:** The Playground tab won't highlight in the navigation when navigating from Endpoints or Prompts pages. Cosmetic only. + +### Minor: Playground doesn't extract variables from system_prompt when switching prompts + +In `maybe_update_prompt/2` (playground.ex), when a prompt is selected, variables are extracted only from `edited_content`: + +```elixir +variables = if prompt, do: Prompt.extract_variables(edited_content || ""), else: [] +``` + +If the prompt's `system_prompt` also contains variables, they won't appear as input fields until the user edits the content template. Should combine both fields: + +```elixir +variables = if prompt do + system_vars = Prompt.extract_variables(prompt.system_prompt || "") + content_vars = Prompt.extract_variables(edited_content || "") + Enum.uniq(system_vars ++ content_vars) +else + [] +end +``` + +**Impact:** Variables defined only in the system prompt won't have input fields in the Playground. They'll be sent as-is with `{{VarName}}` unreplaced. + +### Observation: `phx-update="ignore"` on content textarea + +The edited content textarea uses `phx-update="ignore"` to preserve user edits across re-renders. This is correct for the use case, but means the textarea won't update if the user selects a different prompt — the `maybe_update_prompt` code sets `edited_content` in assigns but the DOM won't reflect it because of the ignore directive. + +This is mitigated by the wrapping div having a dynamic ID (`edited-content-wrap-#{@selected_prompt.uuid}`), which forces a new DOM element when the prompt changes. Clever solution. + +### Observation: Blocking AI call in handle_info + +The `handle_info(:do_send, ...)` handler calls `execute_request/1` synchronously. While this is better than doing it in `handle_event` (the UI updates with the loading state first), a very slow AI response will still block other messages to this LiveView process. + +For an admin-only tool this is acceptable. If it becomes an issue, wrapping in `Task.Supervisor.async_nolink` would be the standard improvement. + +## Testing Notes + +- Tests are all unit-level (no DB required) — they test `Prompt` struct functions directly +- No integration tests for the Playground LiveView itself, which is reasonable for an initial PR diff --git a/dev_docs/pull_requests/2026/443-ai-system-prompt-playground/README.md b/dev_docs/pull_requests/2026/443-ai-system-prompt-playground/README.md new file mode 100644 index 000000000..490849f53 --- /dev/null +++ b/dev_docs/pull_requests/2026/443-ai-system-prompt-playground/README.md @@ -0,0 +1,58 @@ +# PR #443 — Add system_prompt field to AI prompts and AI Playground page + +**Author:** Max (mdon) +**Base:** dev +**Date:** 2026-03-23 +**Impact:** +1,099 lines / -7 lines + +## Summary + +Adds a `system_prompt` field to AI prompts (enabling separate system-level instructions) and introduces a new AI Playground LiveView for interactively testing endpoints and prompts from the admin dashboard. + +## Key Changes + +### 1. System Prompt Field + +| File | Change | +|------|--------| +| `migrations/postgres/v85.ex` | New migration: adds `system_prompt` TEXT column to `phoenix_kit_ai_prompts` | +| `prompt.ex` | Schema field, changeset cast, `render_system_prompt/2` function | +| `ai.ex` | `ask_with_prompt/4` auto-includes system prompt via `:system` opt | +| `prompt_form.html.heex` | System prompt textarea in prompt create/edit form | + +- Variables are now extracted from **both** `system_prompt` and `content` fields (combined then deduplicated) +- `render_system_prompt/2` returns `{:ok, nil}` for nil/empty, `{:ok, rendered}` otherwise +- `ask_with_prompt` uses `Keyword.put_new` so callers can still override `:system` + +### 2. AI Playground LiveView + +| File | Change | +|------|--------| +| `web/playground.ex` | New LiveView (300 lines) — endpoint/prompt selection, variable inputs, freeform mode | +| `web/playground.html.heex` | Template (336 lines) — configuration card, input card, response area with usage stats | +| `integration.ex` | Route: `/admin/ai/playground` | +| `ai.ex` | Playground tab registered in navigation (priority 553) | + +**Two modes:** +- **Prompt mode** — Select a prompt, fill in variables, edit the template inline, send +- **Freeform mode** — Type a message + optional system prompt directly + +**Features:** +- Loading skeleton with auto-scroll to response +- Usage stats display (tokens in/out, total, cost) +- Async request via `handle_info(:do_send, ...)` to avoid blocking the LiveView + +### 3. Navigation Updates + +Playground tab added to all AI admin page tab bars: +- `endpoints.html.heex` +- `prompts.html.heex` +- `playground.html.heex` (active state) + +### 4. Tests + +- 44 unit tests in `test/modules/ai/prompt_test.exs` covering `extract_variables`, `render`, `render_system_prompt`, changeset variable extraction, `validate_variables`, `has_variables?`, `valid_content?`, `content_preview`, `generate_slug`, and `format_variables_for_display` + +## Migration + +**V85** — Idempotent `ALTER TABLE` adding `system_prompt` TEXT column. Checks for both table and column existence before executing. Bumps version comment to 85. diff --git a/dev_docs/pull_requests/2026/445-db-connection-check/CLAUDE_REVIEW.md b/dev_docs/pull_requests/2026/445-db-connection-check/CLAUDE_REVIEW.md new file mode 100644 index 000000000..46415d525 --- /dev/null +++ b/dev_docs/pull_requests/2026/445-db-connection-check/CLAUDE_REVIEW.md @@ -0,0 +1,82 @@ +# PR #445: Add database connection check to install and update tasks + +**Author**: @construct-d +**Reviewer**: @claude +**Status**: ✅ Reviewed & Fixed +**Commit**: `5153873..2bd4ec7` (3 commits) + review fixes +**Date**: 2026-03-23 + +## Goal + +Add an early database connectivity check to the `install` and `update` Mix tasks so users get a clear error message when PostgreSQL is unreachable, rather than a cryptic Ecto/Postgrex crash deeper in the task. + +## Original PR — What Was Changed + +| File | Change | +|------|--------| +| `lib/phoenix_kit/install/db_connection_check.ex` | New module with `check?/0` and `check!/0` functions | +| `lib/mix/tasks/phoenix_kit.install.ex` | Alias + call `DbConnectionCheck.check!()` after `super(argv)` | +| `lib/mix/tasks/phoenix_kit.update.ex` | Alias + call `DbConnectionCheck.check!()` after `app.start` | +| `lib/mix/tasks/phoenix_kit.status.ex` | Alias + call `DbConnectionCheck.check!()` after ensuring repo is started | + +### What Was Good + +1. **Clean extraction** — Check logic in its own module, reused across tasks. +2. **Defensive checks** — `Code.ensure_loaded?` and `function_exported?` before `repo.query/3` prevents crashes when the repo module isn't available. +3. **User-friendly error** — Clear actionable message with numbered steps. +4. **`log: false`** — Keeps `SELECT 1` out of Ecto query logs. + +## Issues Found + +### 1. Wrong typespec — `@spec check!() :: no_return()` + +The `unless` block falls through to `nil` on success, but `no_return()` tells Dialyzer the function *never* returns. This makes Dialyzer treat all code after the call as unreachable dead code. + +**Fixed:** `@spec ensure_connected!() :: :ok | no_return()` with explicit `:ok` return via `if/else`. + +### 2. `check?`/`check!` naming + +`check?` reads awkwardly as a function name in Elixir. Boolean functions are conventionally named after the condition they test. + +**Fixed:** Renamed to `connected?/0` and `ensure_connected!/0` — both read naturally and communicate intent clearly. + +### 3. No explicit return value on success + +The `unless` block returned `nil` on the happy path. Conventional `!` functions return `:ok` or a meaningful value. + +**Fixed:** Rewrote as `if connected?(), do: :ok, else: ...` — returns `:ok` explicitly. + +### 4. Hardcoded `config/dev.exs` in error message + +The error said "Configuration in config/dev.exs is correct" but the task could run in any Mix env. + +**Fixed:** Changed to `config/#{Mix.env()}.exs` so the message reflects the actual environment. + +### 5. No query timeout + +`repo.query("SELECT 1")` with no timeout could hang indefinitely if PostgreSQL accepts connections but is unresponsive. + +**Fixed:** Added `timeout: 5_000` to the query options. + +### 6. `status.ex` — hard exit defeats the purpose of a status task + +This was the most significant issue. The `status` task already handles DB failures gracefully — `get_database_status/1` catches connection errors and displays them in the status tree as "Connection failed". Adding `check!()` that calls `exit({:shutdown, 1})` *before* the status display means `mix phoenix_kit.status` crashes instead of showing what's wrong. The whole point of a status command is to report state, not abort on bad state. + +The PR also added a redundant repo startup block (lines 74–81) that duplicated logic already in `get_database_status` → `test_repo_and_tables` → `ensure_repo_started`. + +**Fixed:** Removed `DbConnectionCheck` from `status.ex` entirely — both the alias, the repo startup block, and the `check!()` call. The task's existing error handling is the correct approach. + +## Final State After Review Fixes + +| File | Final State | +|------|-------------| +| `lib/phoenix_kit/install/db_connection_check.ex` | `connected?/0` + `ensure_connected!/0`, correct spec, timeout, env-aware error | +| `lib/mix/tasks/phoenix_kit.install.ex` | Calls `DbConnectionCheck.ensure_connected!()` (gates migrations) | +| `lib/mix/tasks/phoenix_kit.update.ex` | Calls `DbConnectionCheck.ensure_connected!()` (gates Igniter + post-tasks) | +| `lib/mix/tasks/phoenix_kit.status.ex` | `DbConnectionCheck` removed — uses its own graceful error handling | + +## Verification + +- [x] `mix compile` — clean, no warnings +- [x] `mix format` — no changes needed +- [x] `mix credo --strict` — no issues found diff --git a/dev_docs/pull_requests/2026/446-leaf-editor-025-zindex-fix/CLAUDE_REVIEW.md b/dev_docs/pull_requests/2026/446-leaf-editor-025-zindex-fix/CLAUDE_REVIEW.md new file mode 100644 index 000000000..53cb90d9c --- /dev/null +++ b/dev_docs/pull_requests/2026/446-leaf-editor-025-zindex-fix/CLAUDE_REVIEW.md @@ -0,0 +1,41 @@ +# PR #446: Fix/Update to leaf editor 0.2.5 + +**Author**: @alexdont (Sasha Don) +**Reviewer**: @claude +**Status**: ✅ Merged +**Commit**: `4073072..a36e300` (2 commits) +**Date**: 2026-03-23 + +## Goal + +Two unrelated fixes bundled together: bump the Leaf editor dependency to v0.2.5 and fix the media selector modal being hidden behind other overlays. + +## What Was Changed + +### Files Modified + +| File | Change | +|------|--------| +| `lib/phoenix_kit_web/live/components/media_selector_modal.html.heex` | `z-50` → `z-[10001]` on backdrop | +| `mix.exs` | `{:leaf, "~> 0.2"}` → `{:leaf, "~> 0.2.5"}` | +| `priv/static/assets/phoenix_kit.js` | CDN URL `leaf@v0.2.4` → `leaf@v0.2.5` | + +## Review Notes + +### Looks Good + +1. **CDN URL and mix.exs are in sync** — both point to v0.2.5. +2. **Commit separation** — z-index fix and dep bump are in separate commits with clear messages. + +### Issues + +1. **`z-[10001]` is a magic number with no z-index strategy** — The codebase uses `z-50` (Tailwind's highest named tier, = 50) across ~15 elements: headers, sidebars, dropdowns, cookie consent banners, and other modals. Flash toasts use `z-[1000]`. Now the media selector jumps to `z-[10001]`. This creates an ad-hoc layering hierarchy (`50` → `1000` → `10001`) with no documented convention. The next modal that needs to be "on top" will pick `z-[10002]` or `z-[20000]`, and the escalation continues. A better approach would be to define a z-index scale (e.g., in a Tailwind config or CSS custom properties) so all overlays use consistent, predictable layers. At minimum, a comment explaining *why* 10001 (what specific element was it behind?) would help future developers. + +2. **`{:leaf, "~> 0.2.5"}` is more restrictive than intended** — Hex version matching: `~> 0.2` allows `>= 0.2.0 and < 1.0.0`, while `~> 0.2.5` allows `>= 0.2.5 and < 0.3.0`. This means future `0.3.x` releases of Leaf will not resolve. If Leaf follows semver, `~> 0.2` was the more appropriate constraint (accepting patches and minor bumps). If the intent was to set a minimum patch version, `~> 0.2.5` works but is tighter than before. Worth confirming this was intentional. + +3. **Committed JS bundle** — `priv/static/assets/phoenix_kit.js` is a built artifact with a CDN URL change. The comment on line 1765 still says `{:leaf, "~> 0.1.0"}` which is stale (should be `~> 0.2.5` or just removed). Minor, but stale comments in committed bundles accumulate. + +### Non-blocking Suggestions + +- Consider whether the media selector modal should use a Phoenix-level modal component (like the core `modal` component) which would centralize z-index management rather than each modal picking its own. +- The two changes (z-index fix + dep bump) are unrelated — cleaner as separate PRs for easier revert if either causes issues. diff --git a/dev_docs/pull_requests/2026/447-extract-emails-module/CLAUDE_REVIEW.md b/dev_docs/pull_requests/2026/447-extract-emails-module/CLAUDE_REVIEW.md new file mode 100644 index 000000000..6dd29fd5b --- /dev/null +++ b/dev_docs/pull_requests/2026/447-extract-emails-module/CLAUDE_REVIEW.md @@ -0,0 +1,184 @@ +# PR #447: Extract Emails Module to Standalone Package + +**Author**: @timujinne (Tymofii Shapovalov) +**Reviewer**: @claude +**Status**: Merged +**Commits**: `e7b0ef6..4c38e91` (12 commits) +**Date**: 2026-03-23 + +## Goal + +Extract the Emails module (~29k lines) from PhoenixKit core into a standalone `phoenix_kit_emails` hex package. This follows the same pattern as the earlier Posts (#442) and Newsletters extractions — making PhoenixKit slimmer by moving heavyweight, optional functionality into separate packages that can be added as dependencies when needed. + +## What Was Changed + +### New Files + +| File | Purpose | +|------|---------| +| `lib/phoenix_kit/email/provider.ex` | Unified `PhoenixKit.Email.Provider` behaviour (14 callbacks) | +| `lib/phoenix_kit/email/default_provider.ex` | No-op implementation — used when `phoenix_kit_emails` is not installed | +| `lib/phoenix_kit_web/helpers/admin_edit_helper.ex` | Universal admin edit URL helper for controllers and LiveViews | + +### Modified Files + +| File | Change | +|------|--------| +| `lib/phoenix_kit/mailer.ex` | Replaced hard `Emails.*` aliases with `email_provider()` dynamic dispatch; removed `send_test_tracking_email`, HTML fallback templates, and `handle_delivery_result` private functions (~320 lines removed) | +| `lib/phoenix_kit/users/auth/user_notifier.ex` | Same `email_provider()` pattern; stripped HTML fallbacks for confirmation/reset/update emails (~230 lines removed) | +| `lib/phoenix_kit/migrations/postgres/v15.ex` | Dynamic dispatch for `SeedTemplates` via `Code.ensure_loaded` + `apply/3` | +| `lib/phoenix_kit/migrations/postgres/v31.ex` | Dynamic dispatch for `Emails.Templates` via `Code.ensure_loaded` + `apply/3` | +| `lib/phoenix_kit_web/integration.ex` | Removed `EmailsRoutes` alias and `safe_route_call` references | +| `lib/phoenix_kit_web/live/dashboard.html.heex` | Guarded `Emails.enabled?()` with `Code.ensure_loaded?` + `apply` | +| `lib/phoenix_kit_web/live/modules.ex` | Enriched external module cards with config stats, settings links; `extract_admin_links/1` now skips parent tabs and deduplicates | +| `lib/phoenix_kit_web/live/modules.html.heex` | Removed hardcoded Emails card; external modules now render as enriched cards | +| `lib/phoenix_kit_web/components/core/module_card.ex` | Fixed hero-* icon rendering | +| `lib/phoenix_kit_web/components/core/cookie_consent.ex` | Dynamic legal links, theme-aware backdrop, daisyUI toggle | +| `lib/phoenix_kit_web/components/layout_wrapper.ex` | Minor addition | +| `lib/phoenix_kit_web/users/auth.ex` | Minor addition | +| `lib/modules/billing/billing.ex` | Uses `Mailer.send_from_template` instead of `Templates.send_email` | +| `lib/modules/legal/legal.ex` | Added `legal_links` for cookie consent widget | +| `lib/modules/publishing/web/controller.ex` | Admin edit links via `AdminEditHelper` | +| `lib/modules/publishing/web/templates/*.html.heex` | Conditional Edit buttons for admins | +| `lib/modules/shop/web/catalog_*.ex` | Admin edit URL only assigned for admin users (was assigned to all) | +| `lib/modules/sitemap/sources/publishing.ex` | `lastmod` dates from most recent published post | +| `lib/modules/sitemap/sources/static.ex` | `lastmod` for homepage and static pages | +| `.dialyzer_ignore.exs` | Cleaned up entries for removed files | +| `test/phoenix_kit/module_test.exs` | Removed 1 line | + +### Deleted (35+ source files, 15 mix tasks, 1 route module) + +All files under `lib/modules/emails/` (emails core, web views, templates, SQS workers, rate limiter, archiver, metrics, blocklist, webhook controller, etc.) and `lib/phoenix_kit_web/routes/emails.ex` plus 15 email-related mix tasks. + +## Architecture + +The extraction uses a **behaviour + provider** pattern: + +``` +PhoenixKit.Email.Provider (behaviour — 14 callbacks) +├── PhoenixKit.Email.DefaultProvider (no-op, ships with core) +└── PhoenixKit.Modules.Emails.Provider (full impl, ships with phoenix_kit_emails) +``` + +The active provider is resolved at runtime via: +```elixir +Application.get_env(:phoenix_kit, :email_provider, PhoenixKit.Email.DefaultProvider) +``` + +This is clean and consistent. The `phoenix_kit_emails` package registers its provider on startup, and the core gracefully falls back to no-op when the package is absent. + +## Review Notes + +### Looks Good + +1. **Provider behaviour is well-designed** — 14 callbacks covering interception, templates, AWS config, and provider detection. The `DefaultProvider` returns safe no-op values (nil for templates triggers fallback, false for `aws_configured?` skips AWS override). Clean separation of concerns. + +2. **Migration safety** — V15 and V31 use `Code.ensure_loaded` + `apply/3` for optional template seeding, so migrations won't fail when the emails package is absent. This is the correct approach for optional dependencies in migrations. + +3. **Dashboard guard** — The `dashboard.html.heex` Emails card uses `Code.ensure_loaded?(PhoenixKit.Modules.Emails) and apply(...)`, preventing crashes when the module is not available. + +4. **AdminEditHelper is a nice addition** — Polymorphic over `Plug.Conn` and `LiveView.Socket`, cleanly guarded by admin role check. Good reusable pattern. + +5. **Shop catalog fix** — Admin edit URL was previously assigned to all visitors; now correctly scoped to admin users only. This is a security-relevant improvement. + +6. **Commit organization** — Logical progression: behaviour first, then core refactor, then file deletion, then UI cleanup, then fixes. Each commit is independently reviewable. + +### Issues + +1. **`PhoenixKit.Modules.Emails` still in `internal_modules` list** (module_registry.ex:409) — The PR description says "Remove PhoenixKit.Modules.Emails from module_registry internal_modules" but the module is still listed in the `internal_modules/0` function. This was likely lost during the merge conflict resolution (commit `6b43427` resolves conflicts in `module_registry.ex`). Since the source files are deleted, this module will fail `Code.ensure_loaded?` at runtime and be silently skipped by most registry queries, but it's incorrect and should be removed. **Additionally, `Emails` is not listed in `known_external_packages/0` either** — so it won't appear as an installable package on the admin Modules page. Both need fixing. + +2. **Email/SQS queues still in ObanConfig** — `lib/phoenix_kit/install/oban_config.ex` still includes `emails: 50` and `sqs_polling: 1` in the default queue configuration (line ~119-124), and `ensure_sqs_polling_queue/2` still exists. The PR description says "removed email queues from core install" but this was also likely lost in the merge conflict (commit `6b43427` resolves conflicts in `oban_config.ex`). New installations will create email queues that have no workers in core. The `add_oban_queue/3` helper mentioned in the PR description is also missing. + +3. **Double `get_active_template_by_name` calls** — In `mailer.ex` (magic link) and `user_notifier.ex` (register, reset_password, update_email), the template is fetched once for rendering and then fetched *again* for `track_usage`. This was inherited from the pre-extraction code, but the extraction was a good opportunity to fix it. Example from the magic link flow: + ```elixir + case email_provider().get_active_template_by_name("magic_link") do ...end + # ... later ... + case email_provider().get_active_template_by_name("magic_link") do + nil -> :ok + template -> email_provider().track_usage(template) + end + ``` + The template variable from the first match could simply be reused. + +4. **`email_provider()` is duplicated** — Both `Mailer` and `UserNotifier` define their own private `email_provider/0` function with identical implementation. This could be a single function in `PhoenixKit.Email.Provider` (e.g., `Provider.current/0`) to avoid drift. + +5. **Bundled unrelated changes** — This PR includes several changes unrelated to the Emails extraction: + - Sitemap `lastmod` improvements (commit `e7b0ef6`) + - AdminEditHelper + publishing/shop edit links (commit `af6af2ea`) + - Cookie consent overhaul (commit `04510813`) + - Legal module `legal_links` addition + + These would be cleaner as separate PRs for independent review and easier revert. + +### Non-blocking Suggestions + +- The `DefaultProvider.render_template/2,3` returns `%{subject: "", html_body: "", text_body: ""}` — empty strings rather than nil. Since the template path is guarded by `get_active_template_by_name` returning nil first, these functions should never be called on the DefaultProvider. But if they somehow are, sending an email with empty subject/body would be worse than raising. Consider raising `RuntimeError` instead, since reaching this code path indicates a bug. + +- The `extract_admin_links/1` filter `tab.parent != nil` (line 497) means only subtabs are shown as admin links on module cards. This is probably intentional (top-level tabs are already in the sidebar), but it's worth a comment since the filter reads counterintuitively. + +- Consider adding a `@type t :: module()` to `PhoenixKit.Email.Provider` and using it in the `email_provider()` return spec for better dialyzer coverage. + +--- + +## Post-merge Fixes + +**Date**: 2026-03-23 +**Fixed by**: @claude + +Issues 1–4 from the review above were fixed after merge. All changes pass `mix compile`, `mix format`, `mix credo --strict`, and 200 unit tests (0 failures). + +### Fix 1: ModuleRegistry — remove Emails from internal, add to external + +**Problem**: Merge conflict resolution (commit `6b43427`) re-added `PhoenixKit.Modules.Emails` to `internal_modules/0`. Since the source files were deleted, the module silently failed `Code.ensure_loaded?` at runtime — harmless but incorrect. Additionally, Emails was missing from `known_external_packages/0`, so it wouldn't appear on the admin Modules page as an installable package. + +**Files changed**: +- `lib/phoenix_kit/module_registry.ex` — removed Emails from `internal_modules`, added entry to `known_external_packages` +- `test/phoenix_kit/module_registry_test.exs` — updated counts: 20→18 modules, 19→17 feature keys (reflects both Emails and Posts extractions from PRs #447 and #442) +- `test/phoenix_kit/users/permissions_test.exs` — updated counts: 19→17 feature keys, 24→22 total keys; removed `assert "emails" in keys` + +### Fix 2: ObanConfig — remove email/SQS queues from core install + +**Problem**: Same merge conflict re-added `emails: 50` and `sqs_polling: 1` to the default Oban queue config. New installations would create queues with no workers in core. + +**Files changed**: +- `lib/phoenix_kit/install/oban_config.ex`: + - Removed `emails: 50` and `sqs_polling: 1` from default queue list + - Removed `ensure_sqs_polling_queue/2` function and its call in the update pipeline + - Removed its `@dialyzer {:nowarn_function, ensure_sqs_polling_queue: 2}` suppress + - Updated moduledoc, comments, manual config notice, and footer warning to no longer reference email/SQS queues + +### Fix 3: Eliminate double template fetches + +**Problem**: In `mailer.ex` (magic_link) and `user_notifier.ex` (register, reset_password, update_email, magic_link_registration), the template was fetched via `Provider.current().get_active_template_by_name(name)` once for rendering, then fetched *again* identically just to call `track_usage`. This was inherited from pre-extraction code. + +**Fix**: Refactored all 5 functions to capture the template in a 4-element tuple from the first fetch: +```elixir +# Before: two separate fetches +{subject, html_body, text_body} = case Provider.current().get_active_template_by_name("register") do ...end +case Provider.current().get_active_template_by_name("register") do + nil -> :ok + template -> Provider.current().track_usage(template) +end + +# After: single fetch, reuse template +{subject, html_body, text_body, db_template} = case Provider.current().get_active_template_by_name("register") do + nil -> {"Confirm your account", nil, fallback_text, nil} + template -> {rendered.subject, rendered.html_body, rendered.text_body, template} +end +if db_template, do: Provider.current().track_usage(db_template) +``` + +**Files changed**: +- `lib/phoenix_kit/mailer.ex` — `send_magic_link_email/2` +- `lib/phoenix_kit/users/auth/user_notifier.ex` — `deliver_confirmation_instructions/2`, `deliver_reset_password_instructions/2`, `deliver_update_email_instructions/2`, `deliver_magic_link_registration/2` + +### Fix 4: Centralize `email_provider/0` as `Provider.current/0` + +**Problem**: Both `Mailer` and `UserNotifier` had identical private `email_provider/0` functions calling `Application.get_env(:phoenix_kit, :email_provider, PhoenixKit.Email.DefaultProvider)`. Duplication risks drift. + +**Fix**: Added `PhoenixKit.Email.Provider.current/0` as a public function with `@spec current() :: module()`. Replaced private functions in both modules with `alias PhoenixKit.Email.Provider` + `Provider.current()`. + +**Files changed**: +- `lib/phoenix_kit/email/provider.ex` — added `current/0` +- `lib/phoenix_kit/mailer.ex` — removed private `email_provider/0`, alias `Provider`, use `Provider.current()` +- `lib/phoenix_kit/users/auth/user_notifier.ex` — same diff --git a/guides/entities-guide.md b/guides/entities-guide.md deleted file mode 100644 index bb784c6bb..000000000 --- a/guides/entities-guide.md +++ /dev/null @@ -1,516 +0,0 @@ -# PhoenixKit Entities Guide - -**Dynamic content types without database migrations.** - -The Entities system lets you create custom content types (like blog posts, products, forms) programmatically with flexible field schemas. No migrations required. - ---- - -## Table of Contents - -1. [Quick Start](#quick-start) -2. [Enable the System](#enable-the-system) -3. [Creating Entities](#creating-entities) -4. [Field Types](#field-types) -5. [Field Builder Helpers](#field-builder-helpers) -6. [Managing Data Records](#managing-data-records) -7. [Public Forms](#public-forms) -8. [API Reference](#api-reference) -9. [Common Patterns](#common-patterns) - ---- - -## Quick Start - -```elixir -# 1. Enable the Entities system -PhoenixKit.Modules.Entities.enable_system() - -# 2. Create an entity -alias PhoenixKit.Modules.Entities -alias PhoenixKit.Modules.Entities.FieldTypes - -{:ok, entity} = Entities.create_entity(%{ - name: "contact_form", - display_name: "Contact Form", - status: "published", - fields_definition: [ - FieldTypes.text_field("name", "Name", required: true), - FieldTypes.email_field("email", "Email", required: true), - FieldTypes.textarea_field("message", "Message", required: true) - ] -}) - -# 3. Create data records -{:ok, record} = PhoenixKit.Modules.Entities.EntityData.create(%{ - entity_uuid: entity.uuid, - title: "New Submission", - status: "published", - data: %{ - "name" => "John Doe", - "email" => "john@example.com", - "message" => "Hello!" - } -}) -``` - ---- - -## Enable the System - -### Via Code - -```elixir -PhoenixKit.Modules.Entities.enable_system() -``` - -### Via Admin UI - -Visit `/phoenix_kit/admin/modules` and enable the Entities module. - ---- - -## Creating Entities - -### Basic Entity - -```elixir -{:ok, entity} = PhoenixKit.Modules.Entities.create_entity(%{ - name: "article", - display_name: "Article", - display_name_plural: "Articles", - description: "Blog articles and posts", - icon: "hero-document-text", - status: "published", - created_by_uuid: admin_user.uuid, - fields_definition: [ - %{"type" => "text", "key" => "title", "label" => "Title", "required" => true}, - %{"type" => "rich_text", "key" => "content", "label" => "Content"}, - %{"type" => "select", "key" => "status", "label" => "Status", - "options" => ["Draft", "Published", "Archived"]} - ] -}) -``` - -### Entity with Auto-filled Creator - -```elixir -# Note: created_by_uuid is optional - it auto-fills with first admin user if not provided -{:ok, entity} = PhoenixKit.Modules.Entities.create_entity(%{ - name: "product", - display_name: "Product", - status: "published", - # created_by_uuid: admin.uuid, # Optional! Auto-filled if omitted - fields_definition: [ - FieldTypes.text_field("name", "Name", required: true), - FieldTypes.number_field("price", "Price"), - FieldTypes.textarea_field("description", "Description") - ] -}) -``` - -### Getting Admin User for created_by_uuid - -If you need to explicitly set `created_by_uuid`, use these helpers: - -```elixir -# Get first admin (Owner or Admin role) - recommended -admin_uuid = PhoenixKit.Users.Auth.get_first_admin_uuid() - -# Get first user (any role) -user_uuid = PhoenixKit.Users.Auth.get_first_user_uuid() - -# Get full user struct if needed -admin = PhoenixKit.Users.Auth.get_first_admin() -``` - -**Note:** `created_by_uuid` is now auto-filled for both `Entities.create_entity/1` and `EntityData.create/1` if not provided. It uses the first admin, or falls back to the first user. - ---- - -## Field Types - -### Available Field Types - -| Type | Description | Requires Options | Status | -|------|-------------|------------------|--------| -| `text` | Single-line text | No | ✅ | -| `textarea` | Multi-line text | No | ✅ | -| `email` | Email with validation | No | ✅ | -| `url` | URL with validation | No | ✅ | -| `number` | Numeric input | No | ✅ | -| `boolean` | True/false toggle | No | ✅ | -| `date` | Date picker | No | ✅ | -| `rich_text` | WYSIWYG editor | No | ✅ | -| `select` | Dropdown | Yes | ✅ | -| `radio` | Radio buttons | Yes | ✅ | -| `checkbox` | Multiple checkboxes | Yes | ✅ | -| `file` | File upload | No | ✅ | -| `image` | Image upload | No | 🚧 Coming soon | -| `relation` | Link to other entity | Yes | 🚧 Coming soon | - -> **Note**: `image` and `relation` fields render "Coming Soon" placeholders in forms. The `file` field type is fully implemented. - -### Raw Field Definition - -```elixir -%{ - "type" => "text", - "key" => "name", - "label" => "Full Name", - "required" => true, - "default" => nil, - "validation" => %{}, - "options" => [] -} -``` - ---- - -## Field Builder Helpers - -Use these helpers to create field definitions more easily: - -```elixir -alias PhoenixKit.Modules.Entities.FieldTypes - -# Text fields -FieldTypes.text_field("name", "Full Name", required: true) -FieldTypes.textarea_field("bio", "Biography") -FieldTypes.email_field("email", "Email Address", required: true) -FieldTypes.url_field("website", "Website") -FieldTypes.rich_text_field("content", "Content") - -# Numeric and boolean -FieldTypes.number_field("age", "Age") -FieldTypes.boolean_field("active", "Is Active", default: true) - -# Date field -FieldTypes.date_field("published_on", "Published On") - -# File upload -FieldTypes.file_field("attachment", "Attachment") -FieldTypes.file_field("documents", "Documents", - max_entries: 5, - max_file_size: 10_485_760, # 10MB - accept: [".pdf", ".doc", ".docx"] -) - -# Choice fields with options -FieldTypes.select_field("category", "Category", ["Tech", "Business", "Other"]) -FieldTypes.radio_field("priority", "Priority", ["Low", "Medium", "High"], required: true) -FieldTypes.checkbox_field("tags", "Tags", ["Featured", "Popular", "New"]) - -# Generic with options -FieldTypes.new_field("select", "status", "Status", options: ["Active", "Inactive"], required: true) -``` - -### Creating Entity with Choice Fields - -```elixir -alias PhoenixKit.Modules.Entities -alias PhoenixKit.Modules.Entities.FieldTypes - -{:ok, entity} = Entities.create_entity(%{ - name: "survey_response", - display_name: "Survey Response", - status: "published", - fields_definition: [ - FieldTypes.text_field("name", "Name", required: true), - FieldTypes.email_field("email", "Email", required: true), - FieldTypes.select_field("subject", "Subject", [ - "General Inquiry", - "Support", - "Sales", - "Partnership" - ], required: true), - FieldTypes.textarea_field("message", "Message", required: true), - FieldTypes.checkbox_field("interests", "Interests", [ - "Product Updates", - "Newsletter", - "Events" - ]) - ] -}) -``` - ---- - -## Managing Data Records - -### Create a Data Record - -```elixir -{:ok, record} = PhoenixKit.Modules.Entities.EntityData.create(%{ - entity_uuid: entity.uuid, - title: "New Contact", - status: "published", - created_by_uuid: user.uuid, - data: %{ - "name" => "John Doe", - "email" => "john@example.com", - "message" => "Hello!" - } -}) -``` - -### Query Records - -```elixir -# All records for an entity -records = PhoenixKit.Modules.Entities.EntityData.list_by_entity(entity.uuid) - -# Search by title (search_term first, entity_uuid optional second) -results = PhoenixKit.Modules.Entities.EntityData.search_by_title("John", entity.uuid) - -# Get entity by name -entity = PhoenixKit.Modules.Entities.get_entity_by_name("contact_form") - -# Get by UUID -record = PhoenixKit.Modules.Entities.EntityData.get(record_uuid) - -# Filter by status -records = PhoenixKit.Modules.Entities.EntityData.list_by_entity_and_status(entity.uuid, "published") - -# Get by slug -record = PhoenixKit.Modules.Entities.EntityData.get_by_slug(entity.uuid, "my-record-slug") -``` - -### Update and Delete - -```elixir -# Update -{:ok, updated} = PhoenixKit.Modules.Entities.EntityData.update(record, %{ - title: "Updated Title", - data: Map.put(record.data, "new_field", "value") -}) - -# Delete -{:ok, deleted} = PhoenixKit.Modules.Entities.EntityData.delete(record) -``` - ---- - -## Public Forms - -Embed entity-based forms on public pages for contact forms, surveys, lead capture, etc. - -### Enable Public Form for an Entity - -```elixir -# Via admin UI: /phoenix_kit/admin/entities/:id/edit -# Or programmatically: -PhoenixKit.Modules.Entities.update_entity(entity, %{ - settings: %{ - "public_form_enabled" => true, - "public_form_fields" => ["name", "email", "message"], - "public_form_title" => "Contact Us", - "public_form_description" => "We'll get back to you within 24 hours.", - "public_form_submit_text" => "Send Message", - "public_form_success_message" => "Thank you! We received your message." - } -}) -``` - -### Embed in Your Templates - -The EntityForm is a function component (not a LiveComponent), so use it directly: - -```heex -<%# In .phk publishing pages (recommended) %> - - -<%# Or call the render function directly in regular .heex templates %> - "contact_form"}} -/> -``` - -> **Note**: Do not use `live_component` - EntityForm uses `Phoenix.Component`, not `Phoenix.LiveComponent`. - -### Security Options - -Configure in entity settings or admin UI: - -| Setting | Default | Description | -|---------|---------|-------------| -| `public_form_honeypot` | false | Hidden field to catch bots | -| `public_form_time_check` | false | Reject submissions < 3 seconds | -| `public_form_rate_limit` | false | 5 submissions/minute per IP | -| `public_form_debug_mode` | false | Show detailed error messages | -| `public_form_collect_metadata` | true | Capture IP, browser, device | - -### Security Actions - -Each security check can be configured with an action: - -| Action | Behavior | -|--------|----------| -| `reject_silent` | Show fake success, don't save | -| `reject_error` | Show error message, don't save | -| `save_suspicious` | Save with "draft" status, flag in metadata | -| `save_log` | Save normally, log warning | - -### Form Submission Route - -Forms POST to: `POST /phoenix_kit/entities/:entity_slug/submit` - -This is handled by `PhoenixKitWeb.EntityFormController`. - ---- - -## API Reference - -### PhoenixKit.Modules.Entities - -```elixir -# Check if system is enabled -PhoenixKit.Modules.Entities.enabled?() :: boolean() - -# Enable/disable -PhoenixKit.Modules.Entities.enable_system() :: {:ok, Setting.t()} -PhoenixKit.Modules.Entities.disable_system() :: {:ok, Setting.t()} - -# Get by ID -PhoenixKit.Modules.Entities.get_entity(id) :: Entity.t() | nil # Returns nil if not found -PhoenixKit.Modules.Entities.get_entity!(id) :: Entity.t() # Raises if not found -PhoenixKit.Modules.Entities.get_entity_by_name(name) :: Entity.t() | nil - -# List -PhoenixKit.Modules.Entities.list_entities() :: [Entity.t()] -PhoenixKit.Modules.Entities.list_active_entities() :: [Entity.t()] # Only status: "published" - -# Create/Update/Delete -PhoenixKit.Modules.Entities.create_entity(attrs) :: {:ok, Entity.t()} | {:error, Changeset.t()} -PhoenixKit.Modules.Entities.update_entity(entity, attrs) :: {:ok, Entity.t()} | {:error, Changeset.t()} -PhoenixKit.Modules.Entities.delete_entity(entity) :: {:ok, Entity.t()} | {:error, Changeset.t()} - -# Changeset (for forms) -PhoenixKit.Modules.Entities.change_entity(entity, attrs \\ %{}) :: Changeset.t() - -# Stats -PhoenixKit.Modules.Entities.get_system_stats() :: %{ - total_entities: integer(), - active_entities: integer(), - total_data_records: integer() -} -``` - -### PhoenixKit.Modules.Entities.EntityData - -```elixir -# Get by ID -EntityData.get(id) :: EntityData.t() | nil # Returns nil if not found -EntityData.get!(id) :: EntityData.t() # Raises if not found -EntityData.get_by_slug(entity_uuid, slug) :: EntityData.t() | nil - -# List/Query -EntityData.list_all() :: [EntityData.t()] -EntityData.list_by_entity(entity_uuid) :: [EntityData.t()] -EntityData.list_by_entity_and_status(entity_uuid, status) :: [EntityData.t()] -EntityData.search_by_title(search_term, entity_uuid \\ nil) :: [EntityData.t()] - -# Create/Update/Delete -EntityData.create(attrs) :: {:ok, EntityData.t()} | {:error, Changeset.t()} -EntityData.update(record, attrs) :: {:ok, EntityData.t()} | {:error, Changeset.t()} -EntityData.delete(record) :: {:ok, EntityData.t()} | {:error, Changeset.t()} - -# Changeset (for forms) -EntityData.change(record, attrs \\ %{}) :: Changeset.t() -``` - -### PhoenixKit.Modules.Entities.FieldTypes - -```elixir -# Field builder helpers (recommended for programmatic entity creation) -FieldTypes.text_field(key, label, opts \\ []) :: map() -FieldTypes.textarea_field(key, label, opts \\ []) :: map() -FieldTypes.email_field(key, label, opts \\ []) :: map() -FieldTypes.url_field(key, label, opts \\ []) :: map() -FieldTypes.number_field(key, label, opts \\ []) :: map() -FieldTypes.boolean_field(key, label, opts \\ []) :: map() -FieldTypes.date_field(key, label, opts \\ []) :: map() -FieldTypes.rich_text_field(key, label, opts \\ []) :: map() -FieldTypes.file_field(key, label, opts \\ []) :: map() - -# Choice field helpers (options required) -FieldTypes.select_field(key, label, options, opts \\ []) :: map() -FieldTypes.radio_field(key, label, options, opts \\ []) :: map() -FieldTypes.checkbox_field(key, label, options, opts \\ []) :: map() - -# Generic field builder -FieldTypes.new_field(type, key, label, opts \\ []) :: map() -# opts: [required: bool, default: any, options: list] - -# Field type info -FieldTypes.all() :: map() -FieldTypes.requires_options?(type) :: boolean() -FieldTypes.validate_field(field_map) :: {:ok, map()} | {:error, String.t()} -``` - ---- - -## Common Patterns - -### Create a Contact Form Entity - -```elixir -# In a migration or seeds.exs -admin = PhoenixKit.Users.Auth.get_user_by_email("admin@example.com") - -{:ok, _entity} = PhoenixKit.Modules.Entities.create_entity(%{ - name: "contact", - display_name: "Contact Submission", - status: "published", - created_by_uuid: admin.uuid, - fields_definition: [ - %{"type" => "text", "key" => "name", "label" => "Name", "required" => true}, - %{"type" => "email", "key" => "email", "label" => "Email", "required" => true}, - %{"type" => "select", "key" => "subject", "label" => "Subject", "required" => true, - "options" => ["General Inquiry", "Support", "Sales", "Partnership"]}, - %{"type" => "textarea", "key" => "message", "label" => "Message", "required" => true} - ], - settings: %{ - "public_form_enabled" => true, - "public_form_fields" => ["name", "email", "subject", "message"], - "public_form_title" => "Contact Us", - "public_form_honeypot" => true, - "public_form_time_check" => true, - "public_form_rate_limit" => true - } -}) -``` - -### List All Contact Submissions - -```elixir -entity = PhoenixKit.Modules.Entities.get_entity_by_name("contact") -submissions = PhoenixKit.Modules.Entities.EntityData.list_by_entity(entity.uuid) - -for submission <- submissions do - IO.puts("#{submission.data["name"]} - #{submission.data["email"]}") -end -``` - -### Export Entity Data - -```elixir -entity = PhoenixKit.Modules.Entities.get_entity_by_name("contact") -records = PhoenixKit.Modules.Entities.EntityData.list_by_entity(entity.uuid) - -# Convert to list of maps -data = Enum.map(records, fn r -> - Map.merge(r.data, %{ - "uuid" => r.uuid, - "created_at" => r.date_created, - "status" => r.status - }) -end) - -# Export as JSON -Jason.encode!(data) -``` - ---- - -**Last Updated**: 2026-03-02 diff --git a/lib/mix/tasks/phoenix_kit.configure_aws_ses.ex b/lib/mix/tasks/phoenix_kit.configure_aws_ses.ex deleted file mode 100644 index 32829e1d4..000000000 --- a/lib/mix/tasks/phoenix_kit.configure_aws_ses.ex +++ /dev/null @@ -1,242 +0,0 @@ -defmodule Mix.Tasks.PhoenixKit.ConfigureAwsSes do - @moduledoc """ - Mix task to configure AWS SES integration for PhoenixKit email system. - - This task helps set up AWS SES configuration set, SNS topics, and SQS queues - for tracking email delivery events and processing email status updates. - - ## Usage - - # Check current configuration status - mix phoenix_kit.configure_aws_ses --status - - # Configure basic SES settings - mix phoenix_kit.configure_aws_ses --config-set my-app-tracking - - # Set up SNS topic for events - mix phoenix_kit.configure_aws_ses --sns-topic arn:aws:sns:eu-north-1:123456789012:email-events - - # Configure SQS queue for processing - mix phoenix_kit.configure_aws_ses --sqs-queue https://sqs.eu-north-1.amazonaws.com/123456789012/email-events - - # Set DLQ URL for failed messages - mix phoenix_kit.configure_aws_ses --dlq-url https://sqs.eu-north-1.amazonaws.com/123456789012/email-events-dlq - - # Specify AWS region - mix phoenix_kit.configure_aws_ses --region eu-north-1 - - # Reset configuration - mix phoenix_kit.configure_aws_ses --reset - - # Show help - mix phoenix_kit.configure_aws_ses --help - """ - @shortdoc "Configure AWS SES integration for email system" - - use Mix.Task - - alias PhoenixKit.Config.AWS - alias PhoenixKit.Modules.Emails - - @dialyzer {:nowarn_function, prompt_boolean: 2} - - @impl Mix.Task - def run(args) do - {opts, _, _} = - OptionParser.parse(args, - strict: [ - config_set: :string, - sns_topic: :string, - sqs_queue: :string, - dlq_url: :string, - region: :string, - status: :boolean, - enable_polling: :boolean, - help: :boolean - ], - aliases: [h: :help, s: :status] - ) - - if opts[:help] do - print_help() - else - # Start the application - Mix.Task.run("app.start") - - cond do - opts[:status] -> - show_current_status() - - opts[:config_set] || opts[:sns_topic] || opts[:sqs_queue] -> - configure_from_options(opts) - - true -> - interactive_configuration() - end - end - end - - defp show_current_status do - IO.puts("\n📊 Current AWS SES Integration Status") - IO.puts("═══════════════════════════════════") - - config = Emails.get_config() - - IO.puts("📧 Email:") - IO.puts(" Enabled: #{format_boolean(config.enabled)}") - IO.puts(" SES Events: #{format_boolean(config.ses_events)}") - - IO.puts("\n🔧 AWS Configuration:") - IO.puts(" Configuration Set: #{config.ses_configuration_set || "❌ Not set"}") - IO.puts(" AWS Region: #{config.aws_region || "❌ Not set"}") - - IO.puts("\n🔑 AWS Credentials:") - IO.puts(" Access Key ID: #{format_credential(AWS.access_key_id())}") - IO.puts(" Secret Access Key: #{format_credential(AWS.secret_access_key())}") - - complete = - config.enabled && config.ses_configuration_set && - credential_present?(AWS.access_key_id()) && - credential_present?(AWS.secret_access_key()) - - IO.puts( - "\n#{if complete, do: "✅", else: "⚠️"} Configuration Status: #{if complete, do: "Complete", else: "Incomplete"}" - ) - end - - defp configure_from_options(opts) do - IO.puts("🔧 Configuring AWS SES integration from options...") - - updates = [] - - updates = - if config_set = opts[:config_set] do - case Emails.set_ses_configuration_set(config_set) do - {:ok, _} -> - IO.puts("✅ Set configuration set: #{config_set}") - ["configuration_set" | updates] - - {:error, reason} -> - IO.puts("❌ Failed to set configuration set: #{inspect(reason)}") - updates - end - else - updates - end - - region = opts[:region] || AWS.region() - - updates = - case Emails.set_aws_region(region) do - {:ok, _} -> - IO.puts("✅ Set AWS region: #{region}") - ["region" | updates] - - {:error, reason} -> - IO.puts("❌ Failed to set region: #{inspect(reason)}") - updates - end - - if Enum.empty?(updates) do - IO.puts("\n❌ No configuration changes made") - else - IO.puts("\n✅ Configuration updated: #{Enum.join(updates, ", ")}") - IO.puts("💡 Run 'mix phoenix_kit.configure_aws_ses --status' to verify") - end - end - - defp interactive_configuration do - IO.puts("🚀 PhoenixKit AWS SES Integration Setup") - IO.puts("=====================================") - - IO.puts("\nThis wizard will help you configure AWS SES integration.") - IO.puts("You can press Enter to skip optional settings.\n") - - config_set = prompt("AWS SES Configuration Set name", "phoenixkit-system") - - if config_set != "" do - Emails.set_ses_configuration_set(config_set) - end - - region = prompt("AWS Region", AWS.region()) - Emails.set_aws_region(region) - - enable_ses = prompt_boolean("Enable SES event management?", true) - Emails.set_ses_events(enable_ses) - - IO.puts("\n✅ Configuration completed!") - IO.puts("💡 Run 'mix phoenix_kit.configure_aws_ses --status' to verify") - end - - # Helper functions - defp format_credential(credential) when is_binary(credential) and credential != "" do - "✅ Configured" - end - - defp format_credential(_), do: "❌ Missing" - - defp credential_present?(credential) when is_binary(credential) and credential != "" do - true - end - - defp credential_present?(_), do: false - - defp format_boolean(value) do - case value do - true -> "✅ Yes" - false -> "❌ No" - end - end - - defp prompt(message, default) do - default_text = format_default(default) - input = IO.gets("#{message}#{default_text}: ") |> String.trim() - - if input == "", do: default, else: input - end - - defp format_default(""), do: "" - defp format_default(default), do: " [#{default}]" - - defp prompt_boolean(message, default) do - prompt_text = - case default do - true -> " [Y/n]" - false -> " [y/N]" - end - - case IO.gets("#{message}#{prompt_text}: ") |> String.trim() |> String.downcase() do - "" -> default - "y" -> true - "yes" -> true - "n" -> false - "no" -> false - _ -> default - end - end - - defp print_help do - IO.puts(""" - 🔧 PhoenixKit AWS SES Configuration - - USAGE: - mix phoenix_kit.configure_aws_ses [options] - - OPTIONS: - --config-set NAME Set AWS SES configuration set name - --region REGION Set AWS region (default: eu-north-1) - --enable-polling Enable SQS polling - --status, -s Show current configuration status - --help, -h Show this help - - EXAMPLES: - mix phoenix_kit.configure_aws_ses --status - mix phoenix_kit.configure_aws_ses --config-set "phoenixkit-system" - - DESCRIPTION: - This task helps configure AWS SES integration for comprehensive email - management including delivery status, bounces, complaints, and engagement - events. Supports interactive setup or automated configuration. - """) - end -end diff --git a/lib/mix/tasks/phoenix_kit.email.debug_sqs.ex b/lib/mix/tasks/phoenix_kit.email.debug_sqs.ex deleted file mode 100644 index db7e379f6..000000000 --- a/lib/mix/tasks/phoenix_kit.email.debug_sqs.ex +++ /dev/null @@ -1,504 +0,0 @@ -defmodule Mix.Tasks.PhoenixKit.Email.DebugSqs do - @shortdoc "Debug SQS queue messages and analyze message ID matching" - - @moduledoc """ - Mix task to debug SQS queue messages and analyze message ID matching issues. - - This task helps diagnose problems with email status updates by: - - Retrieving all messages from the SQS queue - - Analyzing message IDs from AWS SES events - - Comparing with existing EmailLog records in the database - - Identifying mismatches and providing recommendations - - ## Usage - - # Basic debug - analyze all messages in queue - mix phoenix_kit.email.debug_sqs - - # Retrieve and analyze specific number of messages - mix phoenix_kit.email.debug_sqs --max-messages 20 - - # Include DLQ analysis - mix phoenix_kit.email.debug_sqs --include-dlq - - # Process and delete messages after analysis - mix phoenix_kit.email.debug_sqs --process --delete - - # Verbose output with full message details - mix phoenix_kit.email.debug_sqs --verbose - - ## Options - - --max-messages N Maximum number of messages to retrieve (default: 50) - --include-dlq Also analyze Dead Letter Queue - --process Process messages through SQSProcessor - --delete Delete messages after processing (requires --process) - --verbose Show detailed message content - --message-id ID Focus on specific message ID - - ## Output - - The task provides detailed analysis including: - - Total messages found in queue - - Message ID format analysis - - EmailLog matching statistics - - Specific mismatch details - - Recommendations for fixing issues - - ## Examples - - # Quick analysis - mix phoenix_kit.email.debug_sqs - - # Full analysis with DLQ - mix phoenix_kit.email.debug_sqs --include-dlq --verbose - - # Process and clean up queue - mix phoenix_kit.email.debug_sqs --process --delete --max-messages 10 - """ - - use Mix.Task - require Logger - - alias PhoenixKit.Modules.Emails - alias PhoenixKit.Modules.Emails.SQSProcessor - - @impl Mix.Task - def run(args) do - Mix.Task.run("app.start") - - {options, _remaining} = parse_options(args) - - unless Emails.enabled?() do - Mix.shell().error("Email system is not enabled.") - exit({:shutdown, 1}) - end - - Mix.shell().info(IO.ANSI.cyan() <> "\n🔍 SQS Queue Debug Analysis" <> IO.ANSI.reset()) - Mix.shell().info(String.duplicate("=", 50)) - - sqs_config = Emails.get_sqs_config() - - unless sqs_config.queue_url do - Mix.shell().error("SQS queue URL is not configured.") - exit({:shutdown, 1}) - end - - analyze_sqs_queue(sqs_config, options) - - if options[:include_dlq] do - analyze_dlq_queue(options) - end - end - - defp parse_options(args) do - {options, remaining, _errors} = - OptionParser.parse(args, - strict: [ - max_messages: :integer, - include_dlq: :boolean, - process: :boolean, - delete: :boolean, - verbose: :boolean, - message_id: :string - ] - ) - - # Set defaults - options = - options - |> Keyword.put_new(:max_messages, 50) - |> Keyword.put_new(:include_dlq, false) - |> Keyword.put_new(:process, false) - |> Keyword.put_new(:delete, false) - |> Keyword.put_new(:verbose, false) - - {options, remaining} - end - - defp analyze_sqs_queue(sqs_config, options) do - Mix.shell().info("📥 Analyzing main SQS queue:") - Mix.shell().info(" Queue URL: #{sqs_config.queue_url}") - Mix.shell().info(" Max messages: #{options[:max_messages]}") - Mix.shell().info("") - - messages = retrieve_messages(sqs_config.queue_url, options[:max_messages]) - - if Enum.empty?(messages) do - Mix.shell().info("✅ No messages found in the main queue") - else - Mix.shell().info("📨 Found #{length(messages)} messages") - - analysis = analyze_messages(messages, options) - display_analysis(analysis, options) - - if options[:process] do - process_messages(messages, options) - end - end - end - - defp analyze_dlq_queue(options) do - dlq_url = PhoenixKit.Settings.get_setting("aws_sqs_dlq_url") - - if dlq_url do - Mix.shell().info("\n💀 Analyzing Dead Letter Queue:") - Mix.shell().info(" DLQ URL: #{dlq_url}") - - dlq_messages = retrieve_messages(dlq_url, options[:max_messages]) - - if Enum.empty?(dlq_messages) do - Mix.shell().info("✅ No messages found in the DLQ") - else - Mix.shell().info("📨 Found #{length(dlq_messages)} messages in DLQ") - dlq_analysis = analyze_messages(dlq_messages, options) - display_analysis(dlq_analysis, options, "DLQ") - end - else - Mix.shell().info("\n💀 DLQ URL not configured, skipping DLQ analysis") - end - end - - defp retrieve_messages(queue_url, max_messages) do - Mix.shell().info("🔄 Retrieving messages from queue...") - - # Retrieve messages in batches to get up to max_messages - retrieve_messages_recursive(queue_url, max_messages, []) - end - - defp retrieve_messages_recursive(_queue_url, remaining, acc) when remaining <= 0 do - acc - end - - defp retrieve_messages_recursive(queue_url, remaining, acc) do - # AWS SQS max is 10 per request - batch_size = min(remaining, 10) - - request = - ExAws.SQS.receive_message(queue_url, - max_number_of_messages: batch_size, - # Short polling for debug - wait_time_seconds: 1, - visibility_timeout: 30, - message_attribute_names: [:all], - attribute_names: [:all] - ) - - case ExAws.request(request) do - {:ok, %{body: %{messages: messages}}} when is_list(messages) and messages != [] -> - new_acc = acc ++ messages - new_remaining = remaining - length(messages) - - Mix.shell().info(" Retrieved #{length(messages)} messages (#{length(new_acc)} total)") - - # Continue retrieving if we haven't reached the limit and there might be more - if new_remaining > 0 do - retrieve_messages_recursive(queue_url, new_remaining, new_acc) - else - new_acc - end - - {:ok, _} -> - # No more messages - acc - - {:error, error} -> - Mix.shell().error("❌ Error retrieving messages: #{inspect(error)}") - acc - end - end - - defp analyze_messages(messages, options) do - Mix.shell().info("🔍 Analyzing message content...") - - results = - Enum.map(messages, fn message -> - analyze_single_message(message, options) - end) - - compile_analysis(results) - end - - defp analyze_single_message(message, options) do - message_id = message["MessageId"] - - # Parse the SNS message - case SQSProcessor.parse_sns_message(message) do - {:ok, event_data} -> - aws_message_id = get_in(event_data, ["mail", "messageId"]) - event_type = event_data["eventType"] - timestamp = get_in(event_data, ["mail", "timestamp"]) - - # Check if we can find matching email log - log_match = - if is_binary(aws_message_id), - do: check_email_log_match(aws_message_id), - else: :invalid_message_id - - if options[:verbose] do - display_verbose_message(message, event_data, log_match) - end - - %{ - sqs_message_id: message_id, - aws_message_id: aws_message_id, - event_type: event_type, - timestamp: timestamp, - log_match: log_match, - parsed: true, - error: nil - } - - {:error, reason} -> - if options[:verbose] do - Mix.shell().info("❌ Failed to parse message #{message_id}: #{inspect(reason)}") - end - - %{ - sqs_message_id: message_id, - aws_message_id: nil, - event_type: nil, - timestamp: nil, - log_match: :invalid_message_id, - parsed: false, - error: reason - } - end - end - - defp check_email_log_match(aws_message_id) when is_binary(aws_message_id) do - # Try to find by AWS message ID - case Emails.get_log_by_message_id(aws_message_id) do - {:ok, log} -> - {:found_by_aws_id, log} - - {:error, :not_found} -> - # Try to find by internal message ID pattern (if AWS ID was stored as internal) - case Emails.Log.find_by_aws_message_id(aws_message_id) do - {:ok, log} -> - {:found_by_aws_field, log} - - {:error, :not_found} -> - # Try partial matches or similar IDs - find_similar_logs(aws_message_id) - end - end - end - - defp check_email_log_match(_), do: :invalid_message_id - - defp find_similar_logs(_aws_message_id) do - # Look for logs created around the same time or with similar patterns - # This is a simplified implementation - :not_found - end - - defp display_verbose_message(message, event_data, log_match) do - Mix.shell().info("\n📋 Message Details:") - Mix.shell().info(" SQS Message ID: #{message["MessageId"]}") - Mix.shell().info(" AWS Message ID: #{get_in(event_data, ["mail", "messageId"])}") - Mix.shell().info(" Event Type: #{event_data["eventType"]}") - Mix.shell().info(" Timestamp: #{get_in(event_data, ["mail", "timestamp"])}") - - case log_match do - {:found_by_aws_id, log} -> - Mix.shell().info(" ✅ Match: Found by AWS ID in database (ID: #{log.id})") - - {:found_by_aws_field, log} -> - Mix.shell().info(" ✅ Match: Found by AWS field (ID: #{log.id})") - - :not_found -> - Mix.shell().info(" ❌ Match: No matching email log found") - - :invalid_message_id -> - Mix.shell().info(" ❌ Match: Invalid message ID format") - end - end - - defp compile_analysis(results) do - total_messages = length(results) - parsed_messages = Enum.count(results, & &1.parsed) - parse_errors = total_messages - parsed_messages - - match_stats = - Enum.frequencies_by(results, fn result -> - case result.log_match do - {:found_by_aws_id, _} -> :found_by_aws_id - {:found_by_aws_field, _} -> :found_by_aws_field - :not_found -> :not_found - :invalid_message_id -> :invalid_message_id - end - end) - - event_types = - results - |> Enum.filter(& &1.parsed) - |> Enum.frequencies_by(& &1.event_type) - - aws_message_ids = - results - |> Enum.filter(& &1.aws_message_id) - |> Enum.map(& &1.aws_message_id) - - %{ - total_messages: total_messages, - parsed_messages: parsed_messages, - parse_errors: parse_errors, - match_stats: match_stats, - event_types: event_types, - aws_message_ids: aws_message_ids, - results: results - } - end - - defp display_analysis(analysis, options, prefix \\ "Main Queue") do - Mix.shell().info("\n📊 #{prefix} Analysis Results:") - Mix.shell().info(" Total messages: #{analysis.total_messages}") - Mix.shell().info(" Successfully parsed: #{analysis.parsed_messages}") - Mix.shell().info(" Parse errors: #{analysis.parse_errors}") - - if analysis.total_messages > 0 do - Mix.shell().info("\n🎯 Match Statistics:") - - Enum.each(analysis.match_stats, fn {match_type, count} -> - percentage = Float.round(count / analysis.total_messages * 100, 1) - icon = match_icon(match_type) - description = match_description(match_type) - Mix.shell().info(" #{icon} #{description}: #{count} (#{percentage}%)") - end) - - if not Enum.empty?(analysis.event_types) do - Mix.shell().info("\n📧 Event Types:") - - Enum.each(analysis.event_types, fn {event_type, count} -> - Mix.shell().info(" 📨 #{event_type || "unknown"}: #{count}") - end) - end - - # Show sample AWS message IDs for investigation - if not Enum.empty?(analysis.aws_message_ids) and options[:verbose] do - Mix.shell().info("\n🔍 Sample AWS Message IDs:") - - analysis.aws_message_ids - |> Enum.take(5) - |> Enum.each(fn aws_id -> - Mix.shell().info(" • #{aws_id}") - end) - end - - display_recommendations(analysis) - end - end - - defp match_icon(:found_by_aws_id), do: "✅" - defp match_icon(:found_by_aws_field), do: "✅" - defp match_icon(:not_found), do: "❌" - defp match_icon(:invalid_message_id), do: "🔧" - - defp match_description(:found_by_aws_id), do: "Found by AWS Message ID" - defp match_description(:found_by_aws_field), do: "Found by AWS field lookup" - defp match_description(:not_found), do: "No matching email log" - defp match_description(:invalid_message_id), do: "Invalid message ID format" - - defp display_recommendations(analysis) do - Mix.shell().info("\n💡 Recommendations:") - - not_found_count = Map.get(analysis.match_stats, :not_found, 0) - total_parsed = analysis.parsed_messages - - cond do - not_found_count == 0 and total_parsed > 0 -> - Mix.shell().info(" ✅ All messages have matching emails. System is working correctly.") - - not_found_count > 0 and total_parsed > 0 -> - percentage = Float.round(not_found_count / total_parsed * 100, 1) - Mix.shell().info(" ⚠️ #{percentage}% of events cannot find matching emails.") - Mix.shell().info(" 🔧 Check if message_id is being updated after email send.") - - Mix.shell().info( - " 🔧 Verify AWS SES message ID is stored in email_log.message_id field." - ) - - Mix.shell().info( - " 🔧 Consider running: mix phoenix_kit.email.debug_sqs --process --delete" - ) - - analysis.parse_errors > 0 -> - Mix.shell().info(" 🔧 Some messages failed to parse. Check SQS message format.") - Mix.shell().info(" 🔧 Verify SNS topic is correctly configured.") - - true -> - Mix.shell().info(" ℹ️ No specific recommendations at this time.") - end - end - - defp process_messages(messages, options) do - Mix.shell().info("\n🔄 Processing messages through SQSProcessor...") - - results = - Enum.map(messages, fn message -> - case SQSProcessor.parse_sns_message(message) do - {:ok, event_data} -> - case SQSProcessor.process_email_event(event_data) do - {:ok, result} -> - {:ok, result} - - {:error, reason} -> - {:error, reason} - end - - {:error, reason} -> - {:error, reason} - end - end) - - successful = - Enum.count(results, fn - {:ok, _} -> true - _ -> false - end) - - Mix.shell().info(" ✅ Successfully processed: #{successful}/#{length(messages)}") - - if options[:delete] do - delete_processed_messages(messages, results, options) - end - end - - defp delete_processed_messages(messages, results, _options) do - Mix.shell().info("\n🗑️ Deleting processed messages...") - - successful_messages = - Enum.zip(messages, results) - |> Enum.filter(fn {_message, result} -> - case result do - {:ok, _} -> true - _ -> false - end - end) - |> Enum.map(fn {message, _result} -> message end) - - if Enum.empty?(successful_messages) do - Mix.shell().info(" ℹ️ No successfully processed messages to delete.") - else - sqs_config = Emails.get_sqs_config() - - deleted_count = - Enum.count(successful_messages, fn message -> - receipt_handle = message["ReceiptHandle"] - - case ExAws.SQS.delete_message(sqs_config.queue_url, receipt_handle) - |> ExAws.request() do - {:ok, _} -> - true - - {:error, error} -> - Mix.shell().error(" ❌ Failed to delete message: #{inspect(error)}") - false - end - end) - - Mix.shell().info(" 🗑️ Deleted #{deleted_count}/#{length(successful_messages)} messages") - end - end -end diff --git a/lib/mix/tasks/phoenix_kit.email.process_dlq.ex b/lib/mix/tasks/phoenix_kit.email.process_dlq.ex deleted file mode 100644 index 642d489bf..000000000 --- a/lib/mix/tasks/phoenix_kit.email.process_dlq.ex +++ /dev/null @@ -1,263 +0,0 @@ -defmodule Mix.Tasks.PhoenixKit.Email.ProcessDlq do - @moduledoc """ - Process accumulated messages from AWS SQS Dead Letter Queue (DLQ). - - This task retrieves all messages from the DLQ, processes them through - the SQS processor to update email statuses, and optionally deletes - successfully processed messages. - - ## Usage - - mix phoenix_kit.email.process_dlq [--batch-size 10] [--delete-after] [--dry-run] - - ## Options - - * `--batch-size` - Number of messages to process in each batch (default: 10) - * `--delete-after` - Delete successfully processed messages from DLQ (default: false) - * `--dry-run` - Show what would be processed without making changes (default: false) - - ## Examples - - # Process all DLQ messages without deleting them - mix phoenix_kit.email.process_dlq - - # Process in small batches and delete successful ones - mix phoenix_kit.email.process_dlq --batch-size 5 --delete-after - - # See what would be processed (no changes) - mix phoenix_kit.email.process_dlq --dry-run - - ## Requirements - - - Email system must be enabled - - AWS credentials must be configured - - DLQ URL must be set in settings - """ - - use Mix.Task - - require Logger - - alias PhoenixKit.Modules.Emails - alias PhoenixKit.Modules.Emails.SQSProcessor - alias PhoenixKit.Settings - - @shortdoc "Process accumulated DLQ messages" - - @impl Mix.Task - def run(args) do - # Start the application to ensure repo and settings are available - Mix.Task.run("app.start") - - {options, [], []} = - OptionParser.parse(args, - strict: [ - batch_size: :integer, - delete_after: :boolean, - dry_run: :boolean - ], - aliases: [ - b: :batch_size, - d: :delete_after, - n: :dry_run - ] - ) - - batch_size = Keyword.get(options, :batch_size, 10) - delete_after = Keyword.get(options, :delete_after, false) - dry_run = Keyword.get(options, :dry_run, false) - - if not Emails.enabled?() do - Mix.shell().error("❌ Email system is not enabled") - exit(:shutdown) - end - - dlq_url = Settings.get_setting("aws_sqs_dlq_url") - - unless is_binary(dlq_url) and dlq_url != "" do - Mix.shell().error("❌ DLQ URL not configured") - exit(:shutdown) - end - - Mix.shell().info("🔄 Processing DLQ messages...") - Mix.shell().info("📋 Configuration:") - Mix.shell().info(" • DLQ URL: #{dlq_url}") - Mix.shell().info(" • Batch size: #{batch_size}") - Mix.shell().info(" • Delete after: #{delete_after}") - Mix.shell().info(" • Dry run: #{dry_run}") - Mix.shell().info("") - - total_processed = 0 - total_successful = 0 - total_errors = 0 - - {final_processed, final_successful, final_errors} = - process_dlq_batches( - dlq_url, - batch_size, - delete_after, - dry_run, - total_processed, - total_successful, - total_errors - ) - - Mix.shell().info("") - Mix.shell().info("✅ DLQ processing completed!") - Mix.shell().info("📊 Summary:") - Mix.shell().info(" • Total messages processed: #{final_processed}") - Mix.shell().info(" • Successful: #{final_successful}") - Mix.shell().info(" • Errors: #{final_errors}") - - if dry_run do - Mix.shell().info("ℹ️ This was a dry run - no changes were made") - end - end - - # Recursively process message batches from DLQ - defp process_dlq_batches( - dlq_url, - batch_size, - delete_after, - dry_run, - total_processed, - total_successful, - total_errors - ) do - messages = - ExAws.SQS.receive_message(dlq_url, - max_number_of_messages: batch_size, - wait_time_seconds: 1 - ) - |> ExAws.request() - |> case do - {:ok, %{body: %{messages: messages}}} -> messages - _ -> [] - end - - if Enum.empty?(messages) do - Mix.shell().info("📦 No more messages in DLQ") - {total_processed, total_successful, total_errors} - else - Mix.shell().info("📦 Processing batch of #{length(messages)} messages...") - - {batch_successful, batch_errors, processed_receipts} = - process_message_batch(messages, dry_run) - - new_processed = total_processed + length(messages) - new_successful = total_successful + batch_successful - new_errors = total_errors + batch_errors - - Mix.shell().info("✅ Batch completed: #{batch_successful}/#{length(messages)} successful") - - # Delete successfully processed messages if required - if delete_after and not dry_run and not Enum.empty?(processed_receipts) do - delete_processed_messages(dlq_url, processed_receipts) - end - - # Continue processing next batch - process_dlq_batches( - dlq_url, - batch_size, - delete_after, - dry_run, - new_processed, - new_successful, - new_errors - ) - end - rescue - error -> - Mix.shell().error("❌ Error processing DLQ batch: #{inspect(error)}") - {total_processed, total_successful, total_errors + 1} - end - - # Process one message batch - defp process_message_batch(messages, dry_run) do - results = - Enum.map(messages, fn message -> - if dry_run do - case analyze_message(message) do - {:ok, info} -> - Mix.shell().info(" Would process: #{info.event_type} for #{info.message_id}") - {:ok, message["ReceiptHandle"]} - - {:error, reason} -> - Mix.shell().info(" Would skip: #{reason}") - {:error, reason} - end - else - process_single_message(message) - end - end) - - successful_results = - Enum.filter(results, fn - {:ok, _} -> true - _ -> false - end) - - error_results = - Enum.filter(results, fn - {:error, _} -> true - _ -> false - end) - - processed_receipts = Enum.map(successful_results, fn {:ok, receipt} -> receipt end) - - {length(successful_results), length(error_results), processed_receipts} - end - - # Analyze message without processing it (for dry-run) - defp analyze_message(message) do - case SQSProcessor.parse_sns_message(message) do - {:ok, event_data} -> - message_id = get_in(event_data, ["mail", "messageId"]) - event_type = event_data["eventType"] - {:ok, %{message_id: message_id, event_type: event_type}} - - {:error, reason} -> - {:error, "Invalid message format: #{reason}"} - end - end - - # Process single message - defp process_single_message(message) do - case SQSProcessor.parse_sns_message(message) do - {:ok, event_data} -> - message_id = get_in(event_data, ["mail", "messageId"]) - event_type = event_data["eventType"] - - case SQSProcessor.process_email_event(event_data) do - {:ok, result} -> - Mix.shell().info(" ✅ #{event_type} for #{message_id}: #{inspect(result)}") - {:ok, message["ReceiptHandle"]} - - {:error, reason} -> - Mix.shell().info(" ❌ Failed #{event_type} for #{message_id}: #{reason}") - {:error, reason} - end - - {:error, reason} -> - Mix.shell().info(" ❌ Failed to parse message: #{reason}") - {:error, reason} - end - end - - # Delete successfully processed messages from DLQ - defp delete_processed_messages(dlq_url, receipt_handles) do - Mix.shell().info("🗑️ Deleting #{length(receipt_handles)} processed messages from DLQ...") - - Enum.each(receipt_handles, fn receipt_handle -> - try do - ExAws.SQS.delete_message(dlq_url, receipt_handle) - |> ExAws.request() - rescue - error -> - Mix.shell().error(" ❌ Failed to delete message: #{inspect(error)}") - end - end) - - Mix.shell().info(" ✅ Deleted #{length(receipt_handles)} messages") - end -end diff --git a/lib/mix/tasks/phoenix_kit.email.send_test.ex b/lib/mix/tasks/phoenix_kit.email.send_test.ex deleted file mode 100644 index a3e2580f9..000000000 --- a/lib/mix/tasks/phoenix_kit.email.send_test.ex +++ /dev/null @@ -1,281 +0,0 @@ -defmodule Mix.Tasks.PhoenixKit.Email.SendTest do - @shortdoc "Send test email to specific address" - - @moduledoc """ - Mix task to send a test email to verify email system functionality. - - This task sends a test email to a specific address without requiring - email system system configuration. Useful for basic email delivery testing. - - ## Usage - - # Send to specific email address - mix phoenix_kit.email.send_test --to admin@example.com - - # Send to multiple addresses - mix phoenix_kit.email.send_test --to admin@example.com,user@example.com - - # Send with custom subject - mix phoenix_kit.email.send_test --to admin@example.com --subject "Custom Test Subject" - - # Include system (requires configured repo) - mix phoenix_kit.email.send_test --to admin@example.com --track - - ## Options - - --to EMAIL Email address to send to (required) - --subject SUBJECT Custom email subject (optional) - --track Enable email system (requires repo configuration) - --from EMAIL From email address (optional) - - ## Examples - - # Basic test email - mix phoenix_kit.email.send_test --to admin@example.com - - # Multiple recipients with custom subject - mix phoenix_kit.email.send_test --to "admin@example.com,user@example.com" --subject "PhoenixKit Test Email" - - # Test with system enabled - mix phoenix_kit.email.send_test --to test@example.com --track - """ - - use Mix.Task - import Swoosh.Email - alias PhoenixKit.Utils.Date, as: UtilsDate - - @impl Mix.Task - def run(args) do - Mix.Task.run("app.start") - - {options, _remaining} = parse_options(args) - - recipient = options[:to] - - unless recipient do - Mix.shell().error("--to option is required. Please specify recipient email address.") - exit({:shutdown, 1}) - end - - Mix.shell().info(IO.ANSI.cyan() <> "\n📧 Sending Test Email" <> IO.ANSI.reset()) - Mix.shell().info(String.duplicate("=", 30)) - - send_test_email(options) - end - - defp parse_options(args) do - {options, remaining, _errors} = - OptionParser.parse(args, - strict: [ - to: :string, - subject: :string, - track: :boolean, - from: :string - ] - ) - - # Set defaults - options = - options - |> Keyword.put_new(:subject, "PhoenixKit Test Email") - |> Keyword.put_new(:track, false) - |> Keyword.put_new(:from, "noreply@phoenixkit.dev") - - {options, remaining} - end - - defp send_test_email(options) do - recipients = parse_recipients(options[:to]) - subject = options[:subject] - from_email = options[:from] - track_email = options[:track] - - Mix.shell().info("Recipients: #{Enum.join(recipients, ", ")}") - Mix.shell().info("Subject: #{subject}") - Mix.shell().info("From: #{from_email}") - Mix.shell().info("Tracking: #{if track_email, do: "enabled", else: "disabled"}") - Mix.shell().info("") - - timestamp = UtilsDate.utc_now() |> DateTime.to_string() - - Enum.each(recipients, fn recipient -> - Mix.shell().info("📤 Sending email to #{recipient}...") - - email = - new() - |> to(recipient) - |> from({from_email, "PhoenixKit Test"}) - |> subject(subject) - |> html_body(html_email_body(recipient, timestamp, track_email)) - |> text_body(text_email_body(recipient, timestamp, track_email)) - - result = - if track_email do - # Use PhoenixKit.Mailer with system - PhoenixKit.Mailer.deliver_email(email, - template_name: "test_email", - campaign_id: "manual_test" - ) - else - # Use basic Swoosh delivery without system - send_via_configured_mailer(email) - end - - case result do - {:ok, _} -> - Mix.shell().info("✅ Email sent successfully to #{recipient}") - - {:error, reason} -> - Mix.shell().error("❌ Failed to send email to #{recipient}: #{inspect(reason)}") - end - - # Small delay between emails - Process.sleep(500) - end) - - Mix.shell().info("\n🎉 Test email sending completed!") - end - - defp parse_recipients(recipient_string) do - recipient_string - |> String.split(",") - |> Enum.map(&String.trim/1) - |> Enum.reject(&(&1 == "")) - end - - defp send_via_configured_mailer(email) do - # Try to use PhoenixKit.Mailer directly, falling back to simple delivery - mailer = PhoenixKit.Mailer.get_mailer() - - if mailer == PhoenixKit.Mailer do - PhoenixKit.Mailer.deliver(email) - else - mailer.deliver(email) - end - rescue - error -> - Mix.shell().error("Mailer delivery failed: #{inspect(error)}") - Mix.shell().info("Attempting basic Swoosh delivery...") - - # Fallback to basic SMTP if available - deliver_with_basic_config(email) - end - - defp deliver_with_basic_config(email) do - # Simple SMTP configuration for testing - # This is just for development/testing purposes - adapter_config = [ - adapter: Swoosh.Adapters.SMTP, - relay: "smtp.gmail.com", - port: 587, - username: System.get_env("SMTP_USERNAME"), - password: System.get_env("SMTP_PASSWORD"), - tls: :if_available, - retries: 1, - no_mx_lookups: false - ] - - if adapter_config[:username] && adapter_config[:password] do - Swoosh.Mailer.deliver(email, adapter_config) - else - {:error, - "No mailer configuration found. Please configure PhoenixKit.Mailer or set SMTP_USERNAME and SMTP_PASSWORD environment variables."} - end - end - - defp html_email_body(recipient, timestamp, track_enabled) do - """ - - - - - - PhoenixKit Test Email - - - -
-
-

📧 PhoenixKit Test Email

-

Email System Verification

-
- -
-
- ✅ Success! This email was sent successfully through the PhoenixKit email system. -
- -

Hello,

- -

This is a test email to verify your email system configuration. If you received this email, it indicates:

- -
    -
  • ✅ Email delivery is working correctly
  • -
  • ✅ SMTP/Email provider configuration is valid
  • -
  • ✅ PhoenixKit mailer is functioning properly
  • - #{if track_enabled, do: "
  • ✅ Email system system is operational
  • ", else: "
  • ℹ️ Email system was not enabled for this test
  • "} -
- -
- 📊 Email Details: -
- Recipient: #{recipient}
- Sent at: #{timestamp}
- Tracking: #{if track_enabled, do: "Enabled", else: "Disabled"}
- Type: Manual Test Email -
-
- -

You can safely ignore this email - it's just for testing purposes.

- -
- - -
- - - """ - end - - defp text_email_body(recipient, timestamp, track_enabled) do - """ - PHOENIXKIT TEST EMAIL - EMAIL SYSTEM VERIFICATION - ================================================ - - Success! This email was sent successfully through the PhoenixKit email system. - - Hello, - - This is a test email to verify your email system configuration. If you received this email, it indicates: - - ✅ Email delivery is working correctly - ✅ SMTP/Email provider configuration is valid - ✅ PhoenixKit mailer is functioning properly - #{if track_enabled, do: "✅ Email system system is operational", else: "ℹ️ Email system was not enabled for this test"} - - EMAIL DETAILS: - -------------- - Recipient: #{recipient} - Sent at: #{timestamp} - Tracking: #{if track_enabled, do: "Enabled", else: "Disabled"} - Type: Manual Test Email - - You can safely ignore this email - it's just for testing purposes. - - --- - This is an automated test email from PhoenixKit Email System. - Generated at: #{timestamp} - """ - end -end diff --git a/lib/mix/tasks/phoenix_kit.fix_missing_events.ex b/lib/mix/tasks/phoenix_kit.fix_missing_events.ex deleted file mode 100644 index 4ed00d9a7..000000000 --- a/lib/mix/tasks/phoenix_kit.fix_missing_events.ex +++ /dev/null @@ -1,253 +0,0 @@ -defmodule Mix.Tasks.PhoenixKit.FixMissingEvents do - @moduledoc """ - Finds and fixes email logs with missing bounce/complaint/reject events. - - This task scans the email logs table for records that have a bounce/complaint/reject - status but are missing the corresponding EmailEvent record in the timeline. - - ## Usage - - # Fix all missing events - mix phoenix_kit.fix_missing_events - - # Dry run (show what would be fixed without making changes) - mix phoenix_kit.fix_missing_events --dry-run - - # Fix specific log by UUID - mix phoenix_kit.fix_missing_events --log-uuid 018f1234-5678-7890-abcd-ef1234567890 - - ## Options - - * `--dry-run` - Show missing events without creating them - * `--log-uuid` - Fix specific log UUID only - * `--help` - Show this help - - ## Examples - - # Find and fix all missing events - mix phoenix_kit.fix_missing_events - - # Check what would be fixed - mix phoenix_kit.fix_missing_events --dry-run - - # Fix specific email log - mix phoenix_kit.fix_missing_events --log-uuid 018f1234-5678-7890-abcd-ef1234567890 - - """ - - use Mix.Task - - import Ecto.Query - alias PhoenixKit.Modules.Emails - alias PhoenixKit.Modules.Emails.Log - alias PhoenixKit.Utils.Date, as: UtilsDate - - @shortdoc "Fix email logs with missing bounce/complaint/reject events" - - @switches [ - dry_run: :boolean, - log_uuid: :string, - help: :boolean - ] - - @aliases [ - d: :dry_run, - l: :log_uuid, - h: :help - ] - - @impl Mix.Task - def run(args) do - {opts, _} = OptionParser.parse!(args, strict: @switches, aliases: @aliases) - - cond do - opts[:help] -> - show_help() - - opts[:log_uuid] -> - Mix.Task.run("app.start") - fix_single_log(opts[:log_uuid], opts[:dry_run] || false) - - true -> - Mix.Task.run("app.start") - fix_all_logs(opts[:dry_run] || false) - end - end - - defp show_help do - Mix.shell().info(@moduledoc) - end - - defp fix_single_log(log_uuid, dry_run) do - Mix.shell().info("\n=== Checking Log UUID #{log_uuid} ===\n") - - log = repo().get_by!(Log, uuid: log_uuid) |> repo().preload(:events) - - case find_missing_event_type(log) do - nil -> - Mix.shell().info("✅ Log #{log_uuid} has all required events") - - event_type -> - Mix.shell().info("❌ Missing #{event_type} event") - - if dry_run do - Mix.shell().info(" [DRY RUN] Would create #{event_type} event") - else - create_missing_event(log, event_type) - end - end - end - - defp fix_all_logs(dry_run) do - Mix.shell().info("\n=== Scanning for Missing Events ===\n") - - # Find logs with bounce status but no bounce event - bounced_logs = - repo().all( - from l in Log, - where: l.status in ["hard_bounced", "soft_bounced", "bounced"], - preload: :events - ) - - # Find logs with complaint status but no complaint event - complaint_logs = - repo().all( - from l in Log, - where: l.status == "complaint", - preload: :events - ) - - # Find logs with rejected status but no reject event - rejected_logs = - repo().all( - from l in Log, - where: l.status == "rejected", - preload: :events - ) - - all_logs = bounced_logs ++ complaint_logs ++ rejected_logs - - missing_events = - Enum.filter(all_logs, fn log -> - find_missing_event_type(log) != nil - end) - - if Enum.empty?(missing_events) do - Mix.shell().info("✅ No missing events found") - else - Mix.shell().info("Found #{length(missing_events)} logs with missing events:\n") - - Enum.each(missing_events, fn log -> - event_type = find_missing_event_type(log) - Mix.shell().info(" Log #{log.id}: Missing #{event_type} event (status: #{log.status})") - - if dry_run do - Mix.shell().info(" [DRY RUN] Would create #{event_type} event\n") - else - create_missing_event(log, event_type) - Mix.shell().info(" ✅ Created #{event_type} event\n") - end - end) - - if dry_run do - Mix.shell().info("\nRun without --dry-run to fix these events") - else - Mix.shell().info("\n✅ Fixed #{length(missing_events)} missing events") - end - end - end - - defp find_missing_event_type(log) do - event_types = Enum.map(log.events, & &1.event_type) - - cond do - log.status in ["hard_bounced", "soft_bounced", "bounced"] and - "bounce" not in event_types -> - "bounce" - - log.status == "complaint" and "complaint" not in event_types -> - "complaint" - - log.status == "rejected" and "reject" not in event_types -> - "reject" - - true -> - nil - end - end - - defp create_missing_event(log, "bounce") do - bounce_type = - case log.status do - "hard_bounced" -> "hard" - "soft_bounced" -> "soft" - _ -> "hard" - end - - event_attrs = %{ - email_log_uuid: log.uuid, - event_type: "bounce", - occurred_at: log.bounced_at || UtilsDate.utc_now(), - bounce_type: bounce_type, - event_data: %{ - bounceType: bounce_type, - timestamp: DateTime.to_iso8601(log.bounced_at || UtilsDate.utc_now()), - diagnosticCode: log.error_message - } - } - - case Emails.create_event(event_attrs) do - {:ok, event} -> - Mix.shell().info(" ✅ Created bounce event (ID: #{event.id})") - - {:error, changeset} -> - Mix.shell().error(" ❌ Failed to create event: #{inspect(changeset.errors)}") - end - end - - defp create_missing_event(log, "complaint") do - event_attrs = %{ - email_log_uuid: log.uuid, - event_type: "complaint", - occurred_at: log.complained_at || UtilsDate.utc_now(), - complaint_type: "abuse", - event_data: %{ - complaintFeedbackType: "abuse", - timestamp: DateTime.to_iso8601(log.complained_at || UtilsDate.utc_now()) - } - } - - case Emails.create_event(event_attrs) do - {:ok, event} -> - Mix.shell().info(" ✅ Created complaint event (ID: #{event.id})") - - {:error, changeset} -> - Mix.shell().error(" ❌ Failed to create event: #{inspect(changeset.errors)}") - end - end - - defp create_missing_event(log, "reject") do - event_attrs = %{ - email_log_uuid: log.uuid, - event_type: "reject", - occurred_at: log.rejected_at || UtilsDate.utc_now(), - reject_reason: log.error_message, - event_data: %{ - reason: log.error_message, - timestamp: DateTime.to_iso8601(log.rejected_at || UtilsDate.utc_now()) - } - } - - case Emails.create_event(event_attrs) do - {:ok, event} -> - Mix.shell().info(" ✅ Created reject event (ID: #{event.id})") - - {:error, changeset} -> - Mix.shell().error(" ❌ Failed to create event: #{inspect(changeset.errors)}") - end - end - - defp repo do - PhoenixKit.RepoHelper.repo() - end -end diff --git a/lib/mix/tasks/phoenix_kit.install.ex b/lib/mix/tasks/phoenix_kit.install.ex index be555df80..62475557a 100644 --- a/lib/mix/tasks/phoenix_kit.install.ex +++ b/lib/mix/tasks/phoenix_kit.install.ex @@ -52,6 +52,7 @@ if Code.ensure_loaded?(Igniter.Mix.Task) do BasicConfiguration, BrowserPipelineIntegration, CssIntegration, + DbConnectionCheck, DemoFiles, EndpointIntegration, LayoutConfig, @@ -174,6 +175,9 @@ if Code.ensure_loaded?(Igniter.Mix.Task) do # Run standard igniter process result = super(argv) + # Verify database is reachable before running migrations + DbConnectionCheck.ensure_connected!() + # After igniter is done, handle interactive migration MigrationStrategy.handle_interactive_migration_after_config(elem(opts, 1)) diff --git a/lib/mix/tasks/phoenix_kit.process_dlq.ex b/lib/mix/tasks/phoenix_kit.process_dlq.ex deleted file mode 100644 index ebf2b26e1..000000000 --- a/lib/mix/tasks/phoenix_kit.process_dlq.ex +++ /dev/null @@ -1,166 +0,0 @@ -defmodule Mix.Tasks.PhoenixKit.ProcessDlq do - @moduledoc """ - Mix task to process failed email events from AWS SQS Dead Letter Queue (DLQ). - - This task retrieves messages that failed to process in the main queue and attempts - to reprocess them. Useful for recovering from temporary failures or processing - issues that sent messages to the DLQ. - - ## Usage - - # Process up to 100 messages (default) - mix phoenix_kit.process_dlq - - # Process specific number of messages - mix phoenix_kit.process_dlq --limit 50 - - # Process with verbose logging - mix phoenix_kit.process_dlq --verbose - - # Delete processed messages from DLQ - mix phoenix_kit.process_dlq --delete - - # Show summary of DLQ contents - mix phoenix_kit.process_dlq --summary - - # Force processing of all messages - mix phoenix_kit.process_dlq --force - """ - @shortdoc "Process failed email events from Dead Letter Queue" - - use Mix.Task - - alias PhoenixKit.Modules.Emails - alias PhoenixKit.Settings - - @default_limit 100 - - @impl Mix.Task - def run(args) do - {opts, _, _} = - OptionParser.parse(args, - strict: [ - limit: :integer, - delete: :boolean, - verbose: :boolean, - summary: :boolean, - force: :boolean, - dry_run: :boolean, - help: :boolean - ], - aliases: [l: :limit, d: :delete, v: :verbose, s: :summary, f: :force, h: :help] - ) - - if opts[:help] do - print_help() - else - # Start the application - Mix.Task.run("app.start") - - limit = opts[:limit] || @default_limit - delete_after = opts[:delete] || false - verbose = opts[:verbose] || false - summary_only = opts[:summary] || false - - case process_dlq(limit, delete_after, verbose, summary_only) do - {:ok, result} -> - print_success_result(result, verbose) - - {:error, reason} -> - print_error(reason, verbose) - System.halt(1) - end - end - end - - defp process_dlq(_limit, _delete_after, verbose, summary_only) do - if Emails.enabled?() do - if verbose, do: IO.puts("✅ Email module is enabled") - - dlq_url = Settings.get_setting("aws_sqs_dlq_url") - - if dlq_url do - if verbose, do: IO.puts("🔗 Connected to DLQ: #{dlq_url}") - - if summary_only do - {:ok, - %{ - summary_only: true, - total_messages: 0, - processing_messages: 0, - delayed_messages: 0, - dlq_url: dlq_url - }} - else - {:ok, - %{ - processed: 0, - successful: 0, - errors: 0, - deleted: 0, - error_types: %{}, - dry_run: false - }} - end - else - {:error, "Dead Letter Queue URL not configured"} - end - else - {:error, "Email tracking is not enabled"} - end - end - - defp print_success_result(result, _verbose) do - IO.puts("\n📊 Dead Letter Queue Processing Results") - IO.puts("═══════════════════════════════════════") - - if result[:summary_only] do - IO.puts("📈 DLQ Summary (no processing performed):") - IO.puts(" 📬 Total messages in DLQ: #{result.total_messages}") - - if result.total_messages > 0 do - IO.puts("\n💡 Run without --summary to process these messages") - else - IO.puts("\n✅ DLQ is empty!") - end - else - IO.puts("📈 Processing Statistics:") - IO.puts(" 📥 Messages processed: #{result.processed}") - IO.puts(" ✅ Successfully recovered: #{result.successful}") - IO.puts(" ❌ Still failing: #{result.errors}") - end - - IO.puts("\n✅ DLQ processing completed!") - end - - defp print_error(reason, _verbose) do - IO.puts("\n❌ DLQ Processing Failed") - IO.puts("═══════════════════════") - IO.puts("Error: #{reason}") - end - - defp print_help do - IO.puts(""" - 🔄 PhoenixKit Dead Letter Queue Processor - - USAGE: - mix phoenix_kit.process_dlq [options] - - OPTIONS: - --limit NUM Maximum messages to process (default: 100) - --delete Delete successfully processed messages - --verbose, -v Show detailed processing information - --summary, -s Show DLQ statistics only - --dry-run Preview messages without processing - --help, -h Show this help - - EXAMPLES: - mix phoenix_kit.process_dlq --summary - mix phoenix_kit.process_dlq --limit 20 --verbose - - DESCRIPTION: - This task processes failed email events from the Dead Letter Queue, - attempting to recover messages that failed during initial processing. - """) - end -end diff --git a/lib/mix/tasks/phoenix_kit.process_sqs.ex b/lib/mix/tasks/phoenix_kit.process_sqs.ex deleted file mode 100644 index 813636907..000000000 --- a/lib/mix/tasks/phoenix_kit.process_sqs.ex +++ /dev/null @@ -1,257 +0,0 @@ -defmodule Mix.Tasks.PhoenixKit.ProcessSqs do - @moduledoc """ - Processes pending messages from AWS SQS queue for email events. - - ## Usage - - # Process all messages in queue - mix phoenix_kit.process_sqs - - # Process specific number of messages - mix phoenix_kit.process_sqs --count 10 - - # Show status without processing - mix phoenix_kit.process_sqs --status - - ## Options - - * `--count` - Number of messages to process (default: all) - * `--status` - Show queue status without processing - * `--help` - Show this help - - ## Examples - - # Process all pending messages - mix phoenix_kit.process_sqs - - # Process up to 10 messages - mix phoenix_kit.process_sqs --count 10 - - # Check queue status - mix phoenix_kit.process_sqs --status - - """ - - use Mix.Task - - alias PhoenixKit.Config.AWS - alias PhoenixKit.Modules.Emails.SQSProcessor - alias PhoenixKit.Settings - - @shortdoc "Process AWS SQS email event messages" - - @switches [ - count: :integer, - status: :boolean, - help: :boolean - ] - - @aliases [ - c: :count, - s: :status, - h: :help - ] - - @impl Mix.Task - def run(args) do - {opts, _} = OptionParser.parse!(args, strict: @switches, aliases: @aliases) - - cond do - opts[:help] -> - show_help() - - opts[:status] -> - show_status() - - true -> - count = opts[:count] - process_messages(count) - end - end - - defp show_help do - Mix.shell().info(@moduledoc) - end - - defp show_status do - Mix.Task.run("app.start") - - queue_url = get_queue_url() - - if queue_url do - Mix.shell().info("\n=== SQS Queue Status ===\n") - - case get_queue_attributes(queue_url) do - {:ok, attrs} -> - available = attrs["ApproximateNumberOfMessages"] || "0" - in_flight = attrs["ApproximateNumberOfMessagesNotVisible"] || "0" - - Mix.shell().info("Queue URL: #{queue_url}") - Mix.shell().info("Available messages: #{available}") - Mix.shell().info("In-flight messages: #{in_flight}") - Mix.shell().info("") - - {:error, reason} -> - Mix.shell().error("Failed to get queue status: #{inspect(reason)}") - end - else - Mix.shell().error("AWS SQS configuration not found in Settings") - end - end - - defp process_messages(count) do - Mix.Task.run("app.start") - - queue_url = get_queue_url() - - if queue_url do - Mix.shell().info("\n=== Processing SQS Messages ===\n") - - max_count = count || 999_999 - process_loop(queue_url, 0, max_count) - else - Mix.shell().error("AWS SQS configuration not found in Settings") - end - end - - defp process_loop(_queue_url, processed, max_count) when processed >= max_count do - Mix.shell().info("\n✅ Processed #{processed} messages (limit reached)") - end - - defp process_loop(queue_url, processed, max_count) do - case receive_message(queue_url) do - {:ok, nil} -> - if processed == 0 do - Mix.shell().info("No messages in queue") - else - Mix.shell().info("\n✅ Processed #{processed} messages total") - end - - {:ok, message} -> - process_single_message(message, queue_url) - process_loop(queue_url, processed + 1, max_count) - - {:error, reason} -> - Mix.shell().error("Failed to receive message: #{inspect(reason)}") - end - end - - defp process_single_message(message, queue_url) do - body = message["Body"] - receipt_handle = message["ReceiptHandle"] - - # Parse SNS message - case Jason.decode(body) do - {:ok, sns_body} -> - sns_message = Jason.decode!(sns_body["Message"]) - event_type = sns_message["eventType"] - message_id = get_in(sns_message, ["mail", "messageId"]) - - Mix.shell().info("Processing: #{event_type} for #{message_id}") - - # Process event - with {:ok, sns_data} <- SQSProcessor.parse_sns_message(%{"Body" => body}), - {:ok, result} <- SQSProcessor.process_email_event(sns_data) do - Mix.shell().info(" ✅ #{inspect(result)}") - - # Delete message from queue - delete_message(queue_url, receipt_handle) - else - {:error, reason} -> - Mix.shell().error(" ❌ Failed: #{inspect(reason)}") - end - - {:error, reason} -> - Mix.shell().error(" ❌ Failed to parse: #{inspect(reason)}") - end - end - - defp get_queue_url do - region = Settings.get_setting("aws_region", AWS.region()) - account_id = Settings.get_setting("aws_account_id") - queue_name = Settings.get_setting("aws_sqs_queue_name", "phoenixkit-email-queue") - - if account_id do - "https://sqs.#{region}.amazonaws.com/#{account_id}/#{queue_name}" - else - nil - end - end - - defp get_queue_attributes(queue_url) do - region = Settings.get_setting("aws_region", AWS.region()) - - case System.cmd( - "aws", - [ - "sqs", - "get-queue-attributes", - "--queue-url", - queue_url, - "--attribute-names", - "ApproximateNumberOfMessages", - "ApproximateNumberOfMessagesNotVisible", - "--region", - region - ], - stderr_to_stdout: true - ) do - {output, 0} -> - case Jason.decode(output) do - {:ok, %{"Attributes" => attrs}} -> {:ok, attrs} - {:error, reason} -> {:error, reason} - end - - {output, _code} -> - {:error, output} - end - end - - defp receive_message(queue_url) do - region = Settings.get_setting("aws_region", AWS.region()) - - case System.cmd( - "aws", - [ - "sqs", - "receive-message", - "--queue-url", - queue_url, - "--max-number-of-messages", - "1", - "--region", - region - ], - stderr_to_stdout: true - ) do - {output, 0} -> - case Jason.decode(output) do - {:ok, %{"Messages" => [message | _]}} -> {:ok, message} - {:ok, _} -> {:ok, nil} - {:error, reason} -> {:error, reason} - end - - {output, _code} -> - {:error, output} - end - end - - defp delete_message(queue_url, receipt_handle) do - region = Settings.get_setting("aws_region", AWS.region()) - - System.cmd( - "aws", - [ - "sqs", - "delete-message", - "--queue-url", - queue_url, - "--receipt-handle", - receipt_handle, - "--region", - region - ], - stderr_to_stdout: true - ) - end -end diff --git a/lib/mix/tasks/phoenix_kit.process_sqs_queue.ex b/lib/mix/tasks/phoenix_kit.process_sqs_queue.ex deleted file mode 100644 index 774bb5c02..000000000 --- a/lib/mix/tasks/phoenix_kit.process_sqs_queue.ex +++ /dev/null @@ -1,148 +0,0 @@ -defmodule Mix.Tasks.PhoenixKit.ProcessSqsQueue do - @moduledoc """ - Mix task to process email events from AWS SQS queue. - - This task polls the configured SQS queue for email status updates from AWS SES - and processes them to update email tracking records in the database. - - ## Usage - - # Process up to 50 messages (default) - mix phoenix_kit.process_sqs_queue - - # Process specific number of messages - mix phoenix_kit.process_sqs_queue --limit 100 - - # Process with verbose logging - mix phoenix_kit.process_sqs_queue --verbose - - # Delete processed messages from queue - mix phoenix_kit.process_sqs_queue --delete - - # Filter by event type - mix phoenix_kit.process_sqs_queue --filter delivery - - # Dry run (don't actually process) - mix phoenix_kit.process_sqs_queue --dry-run - """ - @shortdoc "Process email events from AWS SQS queue" - - use Mix.Task - - alias PhoenixKit.Modules.Emails - alias PhoenixKit.Settings - - @default_limit 50 - - @impl Mix.Task - def run(args) do - {opts, _, _} = - OptionParser.parse(args, - strict: [ - limit: :integer, - delete: :boolean, - verbose: :boolean, - filter: :string, - dry_run: :boolean, - help: :boolean - ], - aliases: [l: :limit, d: :delete, v: :verbose, f: :filter, h: :help] - ) - - if opts[:help] do - print_help() - else - # Start the application - Mix.Task.run("app.start") - - limit = opts[:limit] || @default_limit - delete_after = opts[:delete] || false - verbose = opts[:verbose] || false - filter = opts[:filter] - dry_run = opts[:dry_run] || false - - case process_sqs_queue(limit, delete_after, verbose, filter, dry_run) do - {:ok, result} -> - print_success_result(result, verbose) - - {:error, reason} -> - print_error(reason, verbose) - System.halt(1) - end - end - end - - defp process_sqs_queue(_limit, _delete_after, verbose, _filter, dry_run) do - if Emails.enabled?() do - if verbose, do: IO.puts("✅ Email module is enabled") - - queue_url = Settings.get_setting("aws_sqs_queue_url") - - if queue_url do - if verbose, do: IO.puts("🔗 Connected to SQS queue: #{queue_url}") - - {:ok, - %{ - total_processed: 0, - successful: 0, - errors: 0, - deleted: 0, - filtered_out: 0, - queue_url: queue_url, - dry_run: dry_run - }} - else - {:error, "SQS queue URL not configured"} - end - else - {:error, "Email tracking is not enabled"} - end - end - - defp print_success_result(result, _verbose) do - IO.puts("\n📊 SQS Queue Processing Results") - IO.puts("═══════════════════════════════") - - if result.dry_run do - IO.puts("👁️ DRY RUN MODE - No changes made") - end - - IO.puts("📈 Processing Statistics:") - IO.puts(" 📥 Messages processed: #{result.total_processed}") - IO.puts(" ✅ Successfully processed: #{result.successful}") - IO.puts(" ❌ Processing errors: #{result.errors}") - - IO.puts("\n✅ Queue processing completed!") - end - - defp print_error(reason, _verbose) do - IO.puts("\n❌ SQS Queue Processing Failed") - IO.puts("═════════════════════════════") - IO.puts("Error: #{reason}") - end - - defp print_help do - IO.puts(""" - 📥 PhoenixKit SQS Queue Processor - - USAGE: - mix phoenix_kit.process_sqs_queue [options] - - OPTIONS: - --limit NUM Maximum messages to process (default: 50) - --delete Delete successfully processed messages - --verbose, -v Show detailed processing information - --filter TYPE Only process specific event type - --dry-run Preview messages without processing - --help, -h Show this help - - EXAMPLES: - mix phoenix_kit.process_sqs_queue - mix phoenix_kit.process_sqs_queue --limit 10 --verbose - - DESCRIPTION: - This task processes email events from AWS SQS queue to update email - delivery statuses in the database. - """) - end -end diff --git a/lib/mix/tasks/phoenix_kit.seed_templates.ex b/lib/mix/tasks/phoenix_kit.seed_templates.ex deleted file mode 100644 index 949d43fb3..000000000 --- a/lib/mix/tasks/phoenix_kit.seed_templates.ex +++ /dev/null @@ -1,239 +0,0 @@ -defmodule Mix.Tasks.PhoenixKit.SeedTemplates do - @moduledoc """ - Seeds the database with system email templates. - - This task creates the default system templates for authentication and core functionality. - It's safe to run multiple times as it will not create duplicate templates. - - ## Usage - - mix phoenix_kit.seed_templates - - ## Options - - --force Force recreation of existing templates (will update content) - --quiet Run without output - - ## Examples - - # Seed templates normally - mix phoenix_kit.seed_templates - - # Force update existing templates - mix phoenix_kit.seed_templates --force - - # Run quietly without output - mix phoenix_kit.seed_templates --quiet - - ## System Templates - - This task will create the following system templates: - - - **magic_link** - Magic link authentication email - - **register** - User registration confirmation email - - **reset_password** - Password reset email - - **test_email** - Test email for tracking verification - - **update_email** - Email change confirmation - - All templates are marked as system templates and cannot be deleted through the UI. - """ - - use Mix.Task - - alias PhoenixKit.Modules.Emails.Templates - - @requirements ["app.start"] - - @impl Mix.Task - def run(args) do - {opts, _args, _invalid} = - OptionParser.parse(args, switches: [force: :boolean, quiet: :boolean]) - - force = Keyword.get(opts, :force, false) - quiet = Keyword.get(opts, :quiet, false) - - unless quiet do - IO.puts("Seeding system email templates...") - end - - # Ensure the repo is available and started - case PhoenixKit.Config.get(:repo) do - {:ok, repo_module} -> - try do - _ = repo_module.start_link() - rescue - # Repo might already be started - _ -> :ok - end - - :not_found -> - unless quiet do - IO.puts("❌ PhoenixKit repository not configured.") - IO.puts("Please configure PhoenixKit in your application:") - IO.puts("") - IO.puts(" config :phoenix_kit,") - IO.puts(" repo: YourApp.Repo") - IO.puts("") - IO.puts("Then run this command from your application directory.") - end - - System.halt(1) - end - - case seed_templates(force, quiet) do - {:ok, templates} -> - print_success_message(templates, quiet) - :ok - - {:error, :seed_failed} -> - IO.puts("❌ Failed to seed some system templates. Check the logs for details.") - raise "Failed to seed some system templates" - end - end - - defp print_success_message(_templates, true), do: :ok - - defp print_success_message(templates, false) do - IO.puts("✅ Successfully seeded #{length(templates)} system email templates:") - - Enum.each(templates, fn template -> - status_icon = if template.status == "active", do: "🟢", else: "🟡" - IO.puts(" #{status_icon} #{template.name} (#{template.display_name})") - end) - - IO.puts("") - IO.puts("Templates are now available in the admin panel at:") - IO.puts(" {your_app_url}/phoenix_kit/admin/emails/templates") - end - - defp seed_templates(force, quiet) do - if force do - # Force mode: update existing templates - seed_with_force(quiet) - else - # Normal mode: only create missing templates - Templates.seed_system_templates() - end - end - - defp seed_with_force(quiet) do - unless quiet do - IO.puts("🔄 Force mode: Updating existing system templates...") - end - - system_templates = [ - %{ - name: "magic_link", - slug: "magic-link", - display_name: "Magic Link Authentication", - description: "Secure login link email for passwordless authentication", - subject: "Your secure login link", - html_body: Templates.magic_link_html_template(), - text_body: Templates.magic_link_text_template(), - category: "system", - status: "active", - is_system: true, - variables: %{ - "user_email" => "User's email address", - "magic_link_url" => "URL for magic link authentication" - }, - metadata: %{"source_module" => "users"} - }, - %{ - name: "register", - slug: "register", - display_name: "Account Confirmation", - description: "Email sent to confirm user registration", - subject: "Confirm your account", - html_body: Templates.register_html_template(), - text_body: Templates.register_text_template(), - category: "system", - status: "active", - is_system: true, - variables: %{ - "user_email" => "User's email address", - "confirmation_url" => "URL for account confirmation" - }, - metadata: %{"source_module" => "users"} - }, - %{ - name: "reset_password", - slug: "reset-password", - display_name: "Password Reset", - description: "Email sent for password reset requests", - subject: "Reset your password", - html_body: Templates.reset_password_html_template(), - text_body: Templates.reset_password_text_template(), - category: "system", - status: "active", - is_system: true, - variables: %{ - "user_email" => "User's email address", - "reset_url" => "URL for password reset" - }, - metadata: %{"source_module" => "users"} - }, - %{ - name: "test_email", - slug: "test-email", - display_name: "Test Email", - description: "Test email for verifying email tracking system", - subject: "Test Tracking Email - {{timestamp}}", - html_body: Templates.test_email_html_template(), - text_body: Templates.test_email_text_template(), - category: "system", - status: "active", - is_system: true, - variables: %{ - "recipient_email" => "Recipient's email address", - "timestamp" => "Current timestamp", - "test_link_url" => "URL for testing link tracking" - }, - metadata: %{"source_module" => "admin"} - }, - %{ - name: "update_email", - slug: "update-email", - display_name: "Email Change Confirmation", - description: "Email sent to confirm email address changes", - subject: "Confirm your email change", - html_body: Templates.update_email_html_template(), - text_body: Templates.update_email_text_template(), - category: "system", - status: "active", - is_system: true, - variables: %{ - "user_email" => "User's email address", - "update_url" => "URL for email update confirmation" - }, - metadata: %{"source_module" => "users"} - } - ] - - results = - Enum.map(system_templates, fn template_attrs -> - case Templates.get_template_by_name(template_attrs.name) do - nil -> - # Template doesn't exist, create it - Templates.create_template(template_attrs) - - existing_template -> - # Template exists, update it - Templates.update_template(existing_template, template_attrs) - end - end) - - if Enum.all?(results, fn {status, _} -> status == :ok end) do - templates = Enum.map(results, fn {:ok, template} -> template end) - {:ok, templates} - else - errors = Enum.filter(results, fn {status, _} -> status == :error end) - - unless quiet do - Mix.shell().error("Errors occurred during seeding: #{inspect(errors)}") - end - - {:error, :seed_failed} - end - end -end diff --git a/lib/mix/tasks/phoenix_kit.sync_email_status.ex b/lib/mix/tasks/phoenix_kit.sync_email_status.ex deleted file mode 100644 index 89abb2013..000000000 --- a/lib/mix/tasks/phoenix_kit.sync_email_status.ex +++ /dev/null @@ -1,287 +0,0 @@ -defmodule Mix.Tasks.PhoenixKit.SyncEmailStatus do - @shortdoc "Sync email status by AWS SES message ID" - - @moduledoc """ - Mix task to manually sync email status by AWS SES message ID. - - This task searches for a specific email by message ID and synchronizes - its delivery status by fetching events from AWS SQS queues. - - ## Usage - - # Sync status for specific message ID - mix phoenix_kit.sync_email_status MESSAGE_ID - - # With verbose output - mix phoenix_kit.sync_email_status MESSAGE_ID --verbose - - ## Examples - - # Sync email with AWS SES message ID - mix phoenix_kit.sync_email_status "01000189971abc123-fed456-4e89-b012-defg345678hi" - - # With detailed logging - mix phoenix_kit.sync_email_status "01000189971abc123-fed456-4e89-b012-defg345678hi" --verbose - - ## What this task does: - - 1. **Find Email Log**: Searches for email log by message_id - 2. **Check SQS Queue**: Looks for events in main SQS queue - 3. **Check DLQ**: Looks for events in Dead Letter Queue - 4. **Process Events**: Updates email status based on found events - 5. **Report Results**: Shows summary of synchronization - - ## Output - - The task will show: - - Email log found/not found status - - Number of events found in SQS - - Number of events found in DLQ - - Processing results - - Final email status - - ## Requirements - - - Email system must be enabled - - AWS SES integration must be configured - - SQS queue configuration must be set up - """ - - use Mix.Task - - alias PhoenixKit.Modules.Emails - alias PhoenixKit.Modules.Emails.{Log, SQSProcessor} - - @impl Mix.Task - def run(args) do - {opts, args_list, _} = - OptionParser.parse(args, - strict: [verbose: :boolean, help: :boolean], - aliases: [v: :verbose, h: :help] - ) - - case args_list do - [] -> - print_help() - - [message_id | _] -> - if opts[:help] do - print_help() - else - # Start the application - Mix.Task.run("app.start") - - verbose = opts[:verbose] || false - - if verbose do - IO.puts("🔍 Starting email status sync for message ID: #{message_id}") - end - - case sync_email_status(message_id, verbose) do - {:ok, result} -> - print_success_result(result, verbose) - - {:error, reason} -> - print_error(reason, verbose) - System.halt(1) - end - end - end - end - - ## --- Private Functions --- - - defp sync_email_status(message_id, verbose) do - # Check if tracking is enabled - if Emails.enabled?() do - if verbose, do: IO.puts("✅ Email system is enabled") - - # Step 1: Find existing email log - {existing_log, log_status} = find_existing_log(message_id, verbose) - - # Step 2: Search for events in SQS and DLQ - {sqs_events, dlq_events} = fetch_events_from_queues(message_id, verbose) - - total_events = length(sqs_events) + length(dlq_events) - - if verbose do - IO.puts( - "📊 Found #{length(sqs_events)} events in SQS, #{length(dlq_events)} events in DLQ" - ) - end - - if total_events == 0 do - {:ok, - %{ - log_found: existing_log != nil, - log_status: log_status, - events_found: 0, - events_processed: 0, - message: "No events found for this message ID", - final_status: existing_log && existing_log.status - }} - else - # Step 3: Process all events - process_results = process_events(sqs_events ++ dlq_events, verbose) - - # Step 4: Get final status - final_log_status = - case find_existing_log(message_id, false) do - {log, _} when not is_nil(log) -> log.status - _ -> nil - end - - {:ok, - %{ - log_found: existing_log != nil, - log_status: log_status, - events_found: total_events, - events_processed: length(process_results[:successful]), - failed_events: length(process_results[:failed]), - message: "Synchronization completed", - final_status: final_log_status, - process_details: process_results - }} - end - else - {:error, "Email system is not enabled"} - end - end - - defp find_existing_log(message_id, verbose) do - case Log.get_log_by_message_id(message_id) do - %PhoenixKit.Modules.Emails.Log{} = log -> - if verbose do - IO.puts("📧 Found existing email log: ID=#{log.uuid}, Status=#{log.status}") - end - - {log, log.status} - - nil -> - if verbose, do: IO.puts("❌ No existing email log found") - {nil, nil} - end - end - - defp fetch_events_from_queues(message_id, verbose) do - if verbose, do: IO.puts("🔍 Searching for events in SQS queues...") - - sqs_events = Emails.fetch_sqs_events_for_message(message_id) - dlq_events = Emails.fetch_dlq_events_for_message(message_id) - - {sqs_events, dlq_events} - end - - defp process_events(events, verbose) do - if verbose, do: IO.puts("⚡ Processing #{length(events)} events...") - - results = %{successful: [], failed: []} - - Enum.reduce(events, results, fn event, acc -> - case SQSProcessor.process_email_event(event) do - {:ok, result} -> - if verbose do - IO.puts(" ✅ Processed #{event["eventType"]} event successfully") - end - - %{acc | successful: [result | acc.successful]} - - {:error, reason} -> - if verbose do - IO.puts(" ❌ Failed to process #{event["eventType"]} event: #{inspect(reason)}") - end - - %{acc | failed: [{event, reason} | acc.failed]} - end - end) - end - - defp print_success_result(result, verbose) do - IO.puts("\n📊 Email Status Sync Results") - IO.puts("════════════════════════════") - - IO.puts("📧 Message ID Status:") - - if result.log_found do - IO.puts(" ✅ Email log found") - IO.puts(" 📍 Initial status: #{result.log_status || "unknown"}") - IO.puts(" 📍 Final status: #{result.final_status || "unknown"}") - else - IO.puts(" ❌ No email log found") - end - - IO.puts("\n📈 Event Processing:") - IO.puts(" 🔍 Events found: #{result.events_found}") - IO.puts(" ✅ Events processed: #{result.events_processed}") - - if result[:failed_events] && result.failed_events > 0 do - IO.puts(" ❌ Failed events: #{result.failed_events}") - end - - IO.puts("\n💬 Result: #{result.message}") - - if verbose && result[:process_details] do - print_process_details(result.process_details) - end - - IO.puts("\n✅ Synchronization completed successfully!") - end - - defp print_process_details(details) do - IO.puts("\n🔍 Processing Details:") - - if not Enum.empty?(details.successful) do - IO.puts(" ✅ Successful events:") - - Enum.each(details.successful, fn result -> - IO.puts(" • #{result[:type]} (Log UUID: #{result[:log_uuid]})") - end) - end - - if not Enum.empty?(details.failed) do - IO.puts(" ❌ Failed events:") - - Enum.each(details.failed, fn {event, reason} -> - IO.puts(" • #{event["eventType"]}: #{inspect(reason)}") - end) - end - end - - defp print_error(reason, verbose) do - IO.puts("\n❌ Email Status Sync Failed") - IO.puts("══════════════════════════") - IO.puts("Error: #{reason}") - - if verbose do - IO.puts("\n🔍 Troubleshooting:") - IO.puts("• Check if email system is enabled") - IO.puts("• Verify AWS SES and SQS configuration") - IO.puts("• Ensure message ID is correct") - IO.puts("• Check AWS credentials and permissions") - end - end - - defp print_help do - IO.puts(""" - 📧 PhoenixKit Email Status Sync - - USAGE: - mix phoenix_kit.sync_email_status MESSAGE_ID [options] - - ARGUMENTS: - MESSAGE_ID AWS SES message ID to sync - - OPTIONS: - --verbose, -v Show detailed output - --help, -h Show this help - - EXAMPLES: - mix phoenix_kit.sync_email_status "01000189971abc123-fed456-4e89-b012-defg345678hi" - mix phoenix_kit.sync_email_status "01000189971abc123-fed456-4e89-b012-defg345678hi" --verbose - - DESCRIPTION: - This task synchronizes email delivery status by fetching events from AWS SQS - queues and updating the corresponding email log in the database. - """) - end -end diff --git a/lib/mix/tasks/phoenix_kit.update.ex b/lib/mix/tasks/phoenix_kit.update.ex index 221d421dc..63b9454e1 100644 --- a/lib/mix/tasks/phoenix_kit.update.ex +++ b/lib/mix/tasks/phoenix_kit.update.ex @@ -88,6 +88,7 @@ if Code.ensure_loaded?(Igniter.Mix.Task) do BasicConfiguration, Common, CssIntegration, + DbConnectionCheck, IgniterHelpers, ObanConfig, RateLimiterConfig @@ -221,6 +222,10 @@ if Code.ensure_loaded?(Igniter.Mix.Task) do Application.put_env(:phoenix_kit, :update_mode, true) Mix.Task.run("app.start") + + # Verify database is reachable before running update + DbConnectionCheck.ensure_connected!() + result = super(argv) post_igniter_tasks(elem(opts, 0)) diff --git a/lib/mix/tasks/phoenix_kit/email_cleanup.ex b/lib/mix/tasks/phoenix_kit/email_cleanup.ex deleted file mode 100644 index fe016948d..000000000 --- a/lib/mix/tasks/phoenix_kit/email_cleanup.ex +++ /dev/null @@ -1,190 +0,0 @@ -defmodule Mix.Tasks.PhoenixKit.Email.Cleanup do - @shortdoc "Clean up old email system logs" - - @moduledoc """ - Mix task to clean up old email system logs and optimize storage. - - ## Usage - - # Clean logs older than default retention period (90 days) - mix phoenix_kit.email.cleanup - - # Clean logs older than specific number of days - mix phoenix_kit.email.cleanup --older-than 30d - - # Show what would be deleted without actually deleting - mix phoenix_kit.email.cleanup --dry-run - - # Compress old bodies instead of deleting - mix phoenix_kit.email.cleanup --compress-only - - # Archive to S3 before deleting - mix phoenix_kit.email.cleanup --archive - - ## Options - - --older-than PERIOD Delete logs older than period (e.g., 30d, 60d, 90d) - --dry-run Show what would be deleted without deleting - --compress-only Only compress old email bodies, don't delete - --archive Archive logs to S3 before deleting - --force Skip confirmation prompts - - ## Examples - - # Safe dry run to see what would be cleaned - mix phoenix_kit.email.cleanup --dry-run - - # Clean logs older than 30 days with archive - mix phoenix_kit.email.cleanup --older-than 30d --archive - - # Compress bodies for logs older than 7 days - mix phoenix_kit.email.cleanup --older-than 7d --compress-only - """ - - use Mix.Task - alias PhoenixKit.Modules.Emails - - @impl Mix.Task - def run(args) do - Mix.Task.run("app.start") - - {options, _remaining} = parse_options(args) - - # Note: Emails.enabled?() check omitted as Dialyzer determines it's always true - - days_old = parse_days(options[:older_than]) - - Mix.shell().info(IO.ANSI.cyan() <> "\n🧹 Email Cleanup" <> IO.ANSI.reset()) - Mix.shell().info(String.duplicate("=", 40)) - - if options[:compress_only] do - run_compression(days_old, options) - else - run_cleanup(days_old, options) - end - end - - defp parse_options(args) do - {options, remaining, _errors} = - OptionParser.parse(args, - strict: [ - older_than: :string, - dry_run: :boolean, - compress_only: :boolean, - archive: :boolean, - force: :boolean - ] - ) - - # Set defaults - options = - options - |> Keyword.put_new(:dry_run, false) - |> Keyword.put_new(:compress_only, false) - |> Keyword.put_new(:archive, false) - |> Keyword.put_new(:force, false) - - {options, remaining} - end - - defp parse_days(nil) do - # Use system retention setting or default to 90 days - Emails.get_retention_days() - end - - defp parse_days(period_string) do - case Regex.run(~r/^(\d+)d?$/, period_string) do - [_, days_str] -> - String.to_integer(days_str) - - _ -> - Mix.shell().error("Invalid period format. Use format like '30d' or '90d'") - exit({:shutdown, 1}) - end - end - - defp run_compression(days_old, options) do - Mix.shell().info("🗜️ Compressing email bodies older than #{days_old} days...") - - if options[:dry_run] do - # Show what would be compressed - count = count_compressible_logs(days_old) - Mix.shell().info("Would compress #{count} email log bodies") - else - {compressed_count, _} = Emails.compress_old_bodies(days_old) - - if compressed_count > 0 do - Mix.shell().info("✅ Compressed #{compressed_count} email log bodies") - else - Mix.shell().info("ℹ️ No email bodies found to compress") - end - end - end - - defp run_cleanup(days_old, options) do - Mix.shell().info("🗑️ Cleaning up emails older than #{days_old} days...") - - if options[:archive] do - Mix.shell().info("📦 Archiving to S3 before deletion...") - - if not options[:dry_run] do - case Emails.archive_to_s3(days_old) do - {:ok, :skipped} -> - Mix.shell().info("ℹ️ Archive skipped (email system disabled)") - - {:ok, result} -> - archived_count = Keyword.get(result, :archived_count, 0) - Mix.shell().info("✅ Archived #{archived_count} logs to S3") - end - end - end - - if options[:dry_run] do - count = count_deletable_logs(days_old) - Mix.shell().info("Would delete #{count} emails and their events") - else - if not options[:force] do - confirm_deletion_or_exit(days_old) - end - - {deleted_count, _} = Emails.cleanup_old_logs(days_old) - - if deleted_count > 0 do - Mix.shell().info("✅ Deleted #{deleted_count} old emails") - Mix.shell().info("💾 Storage space has been freed up") - else - Mix.shell().info("ℹ️ No old emails found to delete") - end - end - end - - defp count_compressible_logs(days_old) do - _cutoff_date = Date.utc_today() |> Date.add(-days_old) - - # This would need to be implemented in Email module - # For now, return 0 - 0 - end - - defp count_deletable_logs(days_old) do - _cutoff_date = Date.utc_today() |> Date.add(-days_old) - - # This would need to be implemented in Email module - # For now, return a mock count for demonstration - 42 - end - - defp confirm_deletion_or_exit(days_old) do - count = count_deletable_logs(days_old) - - if count > 0 do - message = - "This will permanently delete #{count} emails older than #{days_old} days. Continue?" - - unless Mix.shell().yes?(message) do - Mix.shell().info("Cleanup cancelled") - exit({:shutdown, 0}) - end - end - end -end diff --git a/lib/mix/tasks/phoenix_kit/email_export.ex b/lib/mix/tasks/phoenix_kit/email_export.ex deleted file mode 100644 index 416cd4a2a..000000000 --- a/lib/mix/tasks/phoenix_kit/email_export.ex +++ /dev/null @@ -1,339 +0,0 @@ -defmodule Mix.Tasks.PhoenixKit.Email.Export do - @shortdoc "Export email tracking data to CSV or JSON" - - @moduledoc """ - Mix task to export email tracking data to various formats. - - ## Usage - - # Export all logs to CSV - mix phoenix_kit.email.export --format csv - - # Export specific campaign to JSON - mix phoenix_kit.email.export --format json --campaign newsletter - - # Export logs from date range - mix phoenix_kit.email.export --from 2025-01-01 --to 2025-01-31 - - # Export with custom filters - mix phoenix_kit.email.export --status delivered --tag authentication --provider aws_ses - - ## Options - - --format FORMAT Export format: csv, json (default: csv) - --output FILE Output file path (default: stdout) - --from DATE Start date (YYYY-MM-DD) - --to DATE End date (YYYY-MM-DD) - --campaign ID Filter by campaign ID - --status STATUS Filter by status (sent, delivered, bounced, etc.) - --tag TAG Filter by message tag/type (authentication, marketing, etc.) - --provider PROVIDER Filter by email provider (aws_ses, smtp, local, etc.) - --limit NUMBER Limit number of records (default: no limit) - --include-events Include email events (opens, clicks) in export - - ## Output Formats - - ### CSV Format - Exports logs with columns: id, message_id, to, from, subject, status, sent_at, delivered_at, provider, campaign_id - - ### JSON Format - Exports complete log objects with all fields and optional events array - - ## Examples - - # Basic CSV export - mix phoenix_kit.email.export --format csv > email_logs.csv - - # Campaign analysis with events - mix phoenix_kit.email.export --campaign welcome-series --include-events --format json > campaign_analysis.json - - # Recent bounced emails for investigation - mix phoenix_kit.email.export --status bounced --from $(date -d '7 days ago' '+%Y-%m-%d') --format csv > recent_bounces.csv - - # Authentication emails analysis - mix phoenix_kit.email.export --tag authentication --from 2025-01-01 --include-events > auth_emails.csv - - # Provider performance comparison - mix phoenix_kit.email.export --provider aws_ses --from 2025-01-01 --include-events > ses_performance.csv - """ - - use Mix.Task - alias PhoenixKit.Modules.Emails - alias PhoenixKit.Utils.Date, as: UtilsDate - - @impl Mix.Task - def run(args) do - Mix.Task.run("app.start") - - {options, _remaining} = parse_options(args) - - unless Emails.enabled?() do - Mix.shell().error("Email is not enabled.") - exit({:shutdown, 1}) - end - - # Build filters from options - filters = build_filters(options) - - # Get data - logs = fetch_logs(filters, options) - - # Export in requested format - case options[:format] do - "json" -> export_json(logs, options) - _ -> export_csv(logs, options) - end - - log_export_summary(logs, options) - end - - defp parse_options(args) do - {options, remaining, _errors} = - OptionParser.parse(args, - strict: [ - format: :string, - output: :string, - from: :string, - to: :string, - campaign: :string, - status: :string, - tag: :string, - provider: :string, - limit: :integer, - include_events: :boolean - ] - ) - - # Set defaults - options = - options - |> Keyword.put_new(:format, "csv") - |> Keyword.put_new(:include_events, false) - - {options, remaining} - end - - defp build_filters(options) do - filters = %{} - - filters = - if options[:from] do - Map.put(filters, :sent_after, Date.from_iso8601!(options[:from])) - else - filters - end - - filters = - if options[:to] do - Map.put(filters, :sent_before, Date.from_iso8601!(options[:to])) - else - filters - end - - filters = - if options[:campaign] do - Map.put(filters, :campaign_id, options[:campaign]) - else - filters - end - - filters = - if options[:status] do - Map.put(filters, :status, options[:status]) - else - filters - end - - filters = - if options[:tag] do - Map.put(filters, :message_tag, options[:tag]) - else - filters - end - - filters = - if options[:provider] do - Map.put(filters, :provider, options[:provider]) - else - filters - end - - filters = - if options[:limit] do - Map.put(filters, :limit, options[:limit]) - else - filters - end - - filters - end - - defp fetch_logs(filters, options) do - logs = Emails.list_logs(filters) - - if options[:include_events] do - # Load events for each log - Enum.map(logs, fn log -> - events = Emails.list_events_for_log(log.uuid) - Map.put(log, :events, events) - end) - else - logs - end - end - - defp export_csv(logs, options) do - output_stream = get_output_stream(options[:output]) - - # CSV Header - header = build_csv_header(options) - IO.puts(output_stream, header) - - # CSV Rows - Enum.each(logs, fn log -> - row = build_csv_row(log, options) - IO.puts(output_stream, row) - end) - - if options[:output] do - File.close(output_stream) - end - end - - defp export_json(logs, options) do - json_data = %{ - exported_at: UtilsDate.utc_now(), - total_records: length(logs), - filters: build_filters(options), - logs: logs - } - - json_output = Jason.encode!(json_data, pretty: true) - - if options[:output] do - File.write!(options[:output], json_output) - else - Mix.shell().info(json_output) - end - end - - defp get_output_stream(nil), do: :stdio - - defp get_output_stream(file_path) do - {:ok, file} = File.open(file_path, [:write]) - file - end - - defp build_csv_header(options) do - base_headers = [ - "id", - "message_id", - "to", - "from", - "subject", - "status", - "sent_at", - "delivered_at", - "provider", - "campaign_id", - "template_name", - "size_bytes", - "retry_count" - ] - - if options[:include_events] do - base_headers ++ ["events_count", "last_opened", "total_clicks"] - else - base_headers - end - |> Enum.join(",") - end - - defp build_csv_row(log, options) do - base_values = [ - log.uuid, - escape_csv(log.message_id), - escape_csv(log.to), - escape_csv(log.from), - escape_csv(log.subject), - escape_csv(log.status), - format_datetime(log.sent_at), - format_datetime(log.delivered_at), - escape_csv(log.provider), - escape_csv(log.campaign_id), - escape_csv(log.template_name), - log.size_bytes || 0, - log.retry_count || 0 - ] - - values = - if options[:include_events] && Map.has_key?(log, :events) do - events = log.events || [] - opens = Enum.filter(events, &(&1.event_type == "open")) - clicks = Enum.filter(events, &(&1.event_type == "click")) - - last_opened = - opens - |> Enum.map(& &1.occurred_at) - |> Enum.max(DateTime, fn -> nil end) - - base_values ++ - [ - length(events), - format_datetime(last_opened), - length(clicks) - ] - else - base_values - end - - values - |> Enum.map_join(",", &to_string/1) - end - - defp escape_csv(nil), do: "" - - defp escape_csv(value) when is_binary(value) do - if String.contains?(value, [",", "\"", "\n"]) do - "\"#{String.replace(value, "\"", "\"\"")}\"" - else - value - end - end - - defp escape_csv(value), do: to_string(value) - - defp format_datetime(nil), do: "" - - defp format_datetime(datetime) do - DateTime.to_iso8601(datetime) - end - - defp log_export_summary(logs, options) do - count = length(logs) - format = options[:format] || "csv" - - Mix.shell().error("✅ Exported #{count} emails to #{format} format") - - if options[:output] do - Mix.shell().error("📄 Output saved to: #{options[:output]}") - end - - # Basic stats summary - if count > 0 do - status_counts = - logs - |> Enum.group_by(& &1.status) - |> Enum.map(fn {status, logs} -> {status, length(logs)} end) - |> Enum.sort_by(fn {_status, count} -> count end, :desc) - - Mix.shell().error("📊 Status breakdown:") - - for {status, status_count} <- status_counts do - percentage = Float.round(status_count / count * 100, 1) - - Mix.shell().error(" #{String.pad_trailing(status, 12)} #{status_count} (#{percentage}%)") - end - end - end -end diff --git a/lib/mix/tasks/phoenix_kit/email_stats.ex b/lib/mix/tasks/phoenix_kit/email_stats.ex deleted file mode 100644 index b58727b2c..000000000 --- a/lib/mix/tasks/phoenix_kit/email_stats.ex +++ /dev/null @@ -1,236 +0,0 @@ -defmodule Mix.Tasks.PhoenixKit.Email.Stats do - @shortdoc "Display email system statistics" - - @moduledoc """ - Mix task to display comprehensive email system statistics. - - ## Usage - - # Show default stats (last 30 days) - mix phoenix_kit.email.stats - - # Show stats for specific date range - mix phoenix_kit.email.stats --from 2025-01-01 --to 2025-01-31 - - # Show stats for specific campaign - mix phoenix_kit.email.stats --campaign welcome-series - - # Show detailed breakdown - mix phoenix_kit.email.stats --detailed - - ## Options - - --from DATE Start date (YYYY-MM-DD format) - --to DATE End date (YYYY-MM-DD format) - --campaign ID Show stats for specific campaign - --detailed Show detailed breakdown by provider/template - --format FORMAT Output format: table (default), csv, json - - ## Examples - - # Last 7 days summary - mix phoenix_kit.email.stats --from $(date -d '7 days ago' '+%Y-%m-%d') - - # Export campaign stats to CSV - mix phoenix_kit.email.stats --campaign newsletter --format csv > campaign_stats.csv - - # Detailed breakdown with provider performance - mix phoenix_kit.email.stats --detailed - """ - - use Mix.Task - alias PhoenixKit.Modules.Emails - - @impl Mix.Task - def run(args) do - Mix.Task.run("app.start") - - {options, _remaining} = parse_options(args) - - unless Emails.enabled?() do - Mix.shell().error("Email is not enabled. Enable it first with:") - Mix.shell().info(" PhoenixKit.Modules.Emails.enable_system()") - exit({:shutdown, 1}) - end - - case options[:format] do - "csv" -> show_csv_stats(options) - "json" -> show_json_stats(options) - _ -> show_table_stats(options) - end - end - - defp parse_options(args) do - {options, remaining, _errors} = - OptionParser.parse(args, - strict: [ - from: :string, - to: :string, - campaign: :string, - detailed: :boolean, - format: :string - ] - ) - - # Set defaults - options = - options - |> Keyword.put_new(:format, "table") - |> Keyword.put_new(:detailed, false) - - {options, remaining} - end - - defp show_table_stats(options) do - stats = get_stats_data(options) - - Mix.shell().info(IO.ANSI.cyan() <> "\n📧 Email Statistics" <> IO.ANSI.reset()) - Mix.shell().info(String.duplicate("=", 50)) - - # General stats - Mix.shell().info("\n📊 Overview:") - Mix.shell().info(" Total Sent: #{format_number(stats.total_sent)}") - - Mix.shell().info( - " Delivered: #{format_number(stats.delivered)} (#{format_percentage(stats.delivery_rate)})" - ) - - Mix.shell().info( - " Bounced: #{format_number(stats.bounced)} (#{format_percentage(stats.bounce_rate)})" - ) - - Mix.shell().info( - " Complaints: #{format_number(stats.complaints)} (#{format_percentage(stats.complaint_rate)})" - ) - - if stats.total_opened > 0 do - Mix.shell().info( - " Opened: #{format_number(stats.total_opened)} (#{format_percentage(stats.open_rate)})" - ) - end - - if stats.total_clicked > 0 do - Mix.shell().info( - " Clicked: #{format_number(stats.total_clicked)} (#{format_percentage(stats.click_rate)})" - ) - end - - if options[:detailed] do - show_detailed_breakdown(stats) - end - - if options[:campaign] do - Mix.shell().info("\n📈 Campaign: #{options[:campaign]}") - end - - Mix.shell().info("\n✅ Statistics generated successfully") - end - - defp show_detailed_breakdown(stats) do - if Map.has_key?(stats, :by_provider) and stats.by_provider != [] do - Mix.shell().info("\n🔧 By Provider:") - - for {provider, provider_stats} <- stats.by_provider do - Mix.shell().info( - " #{String.pad_trailing(provider, 15)} #{format_number(provider_stats.sent)} sent, #{format_percentage(provider_stats.delivery_rate)} delivery" - ) - end - end - - if Map.has_key?(stats, :by_template) and stats.by_template != [] do - Mix.shell().info("\n📝 Top Templates:") - - stats.by_template - |> Enum.take(5) - |> Enum.each(fn {template, template_stats} -> - Mix.shell().info( - " #{String.pad_trailing(template || "unknown", 20)} #{format_number(template_stats.sent)} sent" - ) - end) - end - end - - defp show_csv_stats(options) do - stats = get_stats_data(options) - - # CSV Header - Mix.shell().info("metric,value,percentage") - - # CSV Data - Mix.shell().info("total_sent,#{stats.total_sent},") - Mix.shell().info("delivered,#{stats.delivered},#{stats.delivery_rate}") - Mix.shell().info("bounced,#{stats.bounced},#{stats.bounce_rate}") - Mix.shell().info("complaints,#{stats.complaints},#{stats.complaint_rate}") - Mix.shell().info("opened,#{stats.total_opened || 0},#{stats.open_rate || 0}") - Mix.shell().info("clicked,#{stats.total_clicked || 0},#{stats.click_rate || 0}") - end - - defp show_json_stats(options) do - stats = get_stats_data(options) - - json_output = Jason.encode!(stats, pretty: true) - Mix.shell().info(json_output) - end - - defp get_stats_data(options) do - period = determine_period(options) - - base_stats = Emails.get_system_stats(period) - - stats = - if options[:detailed] do - Map.merge(base_stats, %{ - by_provider: Emails.get_provider_performance(period), - by_template: get_template_stats(period) - }) - else - base_stats - end - - if options[:campaign] do - Emails.get_campaign_stats(options[:campaign]) - else - stats - end - end - - defp determine_period(options) do - cond do - options[:from] && options[:to] -> - {:date_range, Date.from_iso8601!(options[:from]), Date.from_iso8601!(options[:to])} - - options[:from] -> - {:date_range, Date.from_iso8601!(options[:from]), Date.utc_today()} - - true -> - :last_30_days - end - end - - defp get_template_stats(period) do - Emails.get_template_stats(period) - end - - defp format_number(number) when is_integer(number) do - number - |> to_string() - |> String.graphemes() - |> Enum.reverse() - |> Enum.chunk_every(3) - |> Enum.map(&Enum.reverse/1) - |> Enum.reverse() - |> Enum.map_join(",", &Enum.join/1) - end - - defp format_number(number), do: to_string(number) - - defp format_percentage(rate) when is_float(rate) do - "#{:erlang.float_to_binary(rate, decimals: 1)}%" - end - - defp format_percentage(rate) when is_integer(rate) do - "#{rate}%" - end - - defp format_percentage(_), do: "0%" -end diff --git a/lib/mix/tasks/phoenix_kit/email_test_webhook.ex b/lib/mix/tasks/phoenix_kit/email_test_webhook.ex deleted file mode 100644 index 23de4955a..000000000 --- a/lib/mix/tasks/phoenix_kit/email_test_webhook.ex +++ /dev/null @@ -1,322 +0,0 @@ -defmodule Mix.Tasks.PhoenixKit.Email.TestWebhook do - @shortdoc "Test email webhook functionality" - - @moduledoc """ - Mix task to test email webhook functionality with sample events. - - ## Usage - - # Test bounce event - mix phoenix_kit.email.test_webhook --event bounce - - # Test open event with specific message UUID - mix phoenix_kit.email.test_webhook --event open --message-uuid abc123 - - # Test delivery event - mix phoenix_kit.email.test_webhook --event delivery - - # Test all event types - mix phoenix_kit.email.test_webhook --all - - ## Options - - --event TYPE Event type to test: bounce, delivery, send, open, click, complaint - --message-uuid UUID Use specific message UUID (uses random if not provided) - --all Test all event types - --endpoint URL Custom webhook endpoint URL - --no-verify Skip signature verification (for testing) - - ## Event Types - - send - Email send confirmation - delivery - Successful delivery - bounce - Hard/soft bounce - complaint - Spam complaint - open - Email opened (AWS SES) - click - Link clicked - - ## Examples - - # Test bounce handling - mix phoenix_kit.email.test_webhook --event bounce --message-id test-123 - - # Test all events with custom endpoint - mix phoenix_kit.email.test_webhook --all --endpoint {prefix}/webhooks/email - - # Where {prefix} is your configured PhoenixKit URL prefix - - # Quick delivery test - mix phoenix_kit.email.test_webhook --event delivery - """ - - use Mix.Task - alias PhoenixKit.Modules.Emails - alias PhoenixKit.Utils.Date, as: UtilsDate - - @impl Mix.Task - def run(args) do - Mix.Task.run("app.start") - - {options, _remaining} = parse_options(args) - - unless Emails.enabled?() do - Mix.shell().error("Email is not enabled.") - exit({:shutdown, 1}) - end - - Mix.shell().info(IO.ANSI.cyan() <> "\n🧪 Email Webhook Testing" <> IO.ANSI.reset()) - Mix.shell().info(String.duplicate("=", 40)) - - if options[:all] do - test_all_events(options) - else - event_type = options[:event] || "delivery" - test_single_event(event_type, options) - end - end - - defp parse_options(args) do - {options, remaining, _errors} = - OptionParser.parse(args, - strict: [ - event: :string, - message_uuid: :string, - all: :boolean, - endpoint: :string, - no_verify: :boolean - ] - ) - - # Set defaults - options = - options - |> Keyword.put_new(:all, false) - |> Keyword.put_new(:no_verify, false) - - {options, remaining} - end - - defp test_all_events(options) do - event_types = ["send", "delivery", "bounce", "complaint", "open", "click"] - - Mix.shell().info("Testing all event types...\n") - - results = - Enum.map(event_types, fn event_type -> - result = test_single_event(event_type, options) - # Small delay between tests - Process.sleep(500) - {event_type, result} - end) - - # Summary - Mix.shell().info("\n📋 Test Summary:") - - for {event_type, result} <- results do - status_icon = if result == :ok, do: "✅", else: "❌" - Mix.shell().info(" #{status_icon} #{String.pad_trailing(event_type, 12)} #{result}") - end - end - - defp test_single_event(event_type, options) do - Mix.shell().info("🧪 Testing #{event_type} event...") - - # Create test email log if needed - message_uuid = options[:message_uuid] || generate_test_message_uuid() - test_log = ensure_test_log(message_uuid) - - # Generate test event - test_event = generate_test_event(event_type, message_uuid, test_log) - - # Test webhook processing - case test_webhook_processing(test_event, options) do - :ok -> - Mix.shell().info("✅ #{event_type} event processed successfully") - :ok - - {:error, reason} -> - Mix.shell().error("❌ #{event_type} event failed: #{reason}") - {:error, reason} - end - end - - defp generate_test_message_uuid do - timestamp = UtilsDate.utc_now() |> DateTime.to_unix() - "test-webhook-#{timestamp}-#{:rand.uniform(9999)}" - end - - defp ensure_test_log(message_uuid) do - case Emails.get_log_by_message_id(message_uuid) do - {:error, :not_found} -> - # Create a test log - {:ok, log} = - Emails.create_log(%{ - message_id: message_uuid, - to: "test@example.com", - from: "noreply@phoenixkit.dev", - subject: "Test Email for Webhook", - status: "sent", - provider: "test_provider", - sent_at: UtilsDate.utc_now() - }) - - Mix.shell().info("📧 Created test email log: #{message_uuid}") - log - - {:ok, log} -> - Mix.shell().info("📧 Using existing email log: #{message_uuid}") - log - - {:error, reason} -> - Mix.shell().error("❌ Error getting email log: #{inspect(reason)}") - nil - end - end - - defp generate_test_event(event_type, message_uuid, _log) do - base_event = %{ - "Type" => "Notification", - "MessageId" => "webhook-test-#{:rand.uniform(99999)}", - "Timestamp" => UtilsDate.utc_now() |> DateTime.to_iso8601(), - "Message" => Jason.encode!(generate_ses_message(event_type, message_uuid)) - } - - base_event - end - - defp generate_ses_message("send", message_uuid) do - %{ - "eventType" => "send", - "mail" => %{ - "timestamp" => UtilsDate.utc_now() |> DateTime.to_iso8601(), - "messageId" => message_uuid, - "source" => "test@phoenixkit.dev", - "destination" => ["test@example.com"] - }, - "send" => %{} - } - end - - defp generate_ses_message("delivery", message_uuid) do - %{ - "eventType" => "delivery", - "mail" => %{ - "timestamp" => UtilsDate.utc_now() |> DateTime.to_iso8601(), - "messageId" => message_uuid, - "source" => "test@phoenixkit.dev", - "destination" => ["test@example.com"] - }, - "delivery" => %{ - "timestamp" => UtilsDate.utc_now() |> DateTime.to_iso8601(), - "processingTimeMillis" => 2000, - "recipients" => ["test@example.com"], - "smtpResponse" => "250 2.0.0 OK" - } - } - end - - defp generate_ses_message("bounce", message_uuid) do - %{ - "eventType" => "bounce", - "mail" => %{ - "timestamp" => UtilsDate.utc_now() |> DateTime.to_iso8601(), - "messageId" => message_uuid, - "source" => "test@phoenixkit.dev", - "destination" => ["bounce@example.com"] - }, - "bounce" => %{ - "bounceType" => "Permanent", - "bounceSubType" => "General", - "timestamp" => UtilsDate.utc_now() |> DateTime.to_iso8601(), - "feedbackId" => "test-bounce-#{:rand.uniform(9999)}", - "bouncedRecipients" => [ - %{ - "emailAddress" => "bounce@example.com", - "status" => "5.1.1", - "action" => "failed", - "diagnosticCode" => "smtp; 550 5.1.1 User unknown" - } - ] - } - } - end - - defp generate_ses_message("complaint", message_uuid) do - %{ - "eventType" => "complaint", - "mail" => %{ - "timestamp" => UtilsDate.utc_now() |> DateTime.to_iso8601(), - "messageId" => message_uuid, - "source" => "test@phoenixkit.dev", - "destination" => ["complaint@example.com"] - }, - "complaint" => %{ - "complainedRecipients" => [ - %{ - "emailAddress" => "complaint@example.com" - } - ], - "timestamp" => UtilsDate.utc_now() |> DateTime.to_iso8601(), - "feedbackId" => "test-complaint-#{:rand.uniform(9999)}", - "complaintFeedbackType" => "abuse" - } - } - end - - defp generate_ses_message("open", message_uuid) do - %{ - "eventType" => "open", - "mail" => %{ - "timestamp" => UtilsDate.utc_now() |> DateTime.to_iso8601(), - "messageId" => message_uuid, - "source" => "test@phoenixkit.dev", - "destination" => ["test@example.com"] - }, - "open" => %{ - "timestamp" => UtilsDate.utc_now() |> DateTime.to_iso8601(), - "userAgent" => "Mozilla/5.0 (Test Webhook)", - "ipAddress" => "192.0.2.1" - } - } - end - - defp generate_ses_message("click", message_uuid) do - %{ - "eventType" => "click", - "mail" => %{ - "timestamp" => UtilsDate.utc_now() |> DateTime.to_iso8601(), - "messageId" => message_uuid, - "source" => "test@phoenixkit.dev", - "destination" => ["test@example.com"] - }, - "click" => %{ - "timestamp" => UtilsDate.utc_now() |> DateTime.to_iso8601(), - "userAgent" => "Mozilla/5.0 (Test Webhook)", - "ipAddress" => "192.0.2.1", - "link" => "https://example.com/test-link", - "linkTags" => %{ - "campaign" => "test" - } - } - } - end - - defp test_webhook_processing(webhook_data, _options) do - # Process the webhook event using EmailTracking - case Emails.process_webhook_event(webhook_data) do - {:ok, _event} -> - :ok - - {:error, :email_log_not_found} -> - # This might be expected for some test cases - Mix.shell().info("ℹ️ Note: Email log not found (this may be expected for test events)") - :ok - - {:error, reason} -> - {:error, reason} - end - rescue - error -> - {:error, Exception.message(error)} - end -end diff --git a/lib/mix/tasks/phoenix_kit/email_verify_config.ex b/lib/mix/tasks/phoenix_kit/email_verify_config.ex deleted file mode 100644 index 8f0dee010..000000000 --- a/lib/mix/tasks/phoenix_kit/email_verify_config.ex +++ /dev/null @@ -1,365 +0,0 @@ -defmodule Mix.Tasks.PhoenixKit.Email.VerifyConfig do - @shortdoc "Verify email tracking configuration" - - @moduledoc """ - Mix task to verify email tracking system configuration. - - ## Usage - - # Verify basic configuration - mix phoenix_kit.email.verify_config - - # Verify AWS SES setup - mix phoenix_kit.email.verify_aws_ses - - # Detailed verification with connectivity tests - mix phoenix_kit.email.verify_config --detailed - - # Check specific configuration aspect - mix phoenix_kit.email.verify_config --check database - mix phoenix_kit.email.verify_config --check mailer - mix phoenix_kit.email.verify_config --check settings - - ## Options - - --detailed Run detailed checks including connectivity - --check ASPECT Check specific aspect: database, mailer, settings, aws - --fix-issues Attempt to fix common configuration issues - --quiet Only show errors and warnings - - ## Checks Performed - - ### Basic Checks - - Email tracking system enabled/disabled status - - Database tables and schemas exist - - Required settings are configured - - Mailer configuration is valid - - ### AWS SES Checks (with --detailed) - - AWS credentials are configured - - SES configuration set exists - - SNS topic and subscriptions are set up - - Webhook endpoint is accessible - - ### Database Checks - - Tables exist and have correct schema - - Indexes are properly created - - Sample data can be inserted and retrieved - - ## Examples - - # Quick config check - mix phoenix_kit.email.verify_config - - # Full AWS SES verification - mix phoenix_kit.email.verify_config --detailed --check aws - - # Database-only check with fix attempt - mix phoenix_kit.email.verify_config --check database --fix-issues - """ - - use Mix.Task - alias PhoenixKit.Modules.Emails - alias PhoenixKit.Settings - - @impl Mix.Task - def run(args) do - Mix.Task.run("app.start") - - {options, _remaining} = parse_options(args) - - Mix.shell().info(IO.ANSI.cyan() <> "\n🔍 Email Configuration Verification" <> IO.ANSI.reset()) - - Mix.shell().info(String.duplicate("=", 55)) - - case options[:check] do - "database" -> - run_database_checks(options) - - "mailer" -> - run_mailer_checks(options) - - "settings" -> - run_settings_checks(options) - - "aws" -> - run_aws_checks(options) - - nil -> - run_all_checks(options) - - check_type -> - Mix.shell().error("Unknown check type: #{check_type}") - exit({:shutdown, 1}) - end - end - - defp parse_options(args) do - {options, remaining, _errors} = - OptionParser.parse(args, - strict: [ - detailed: :boolean, - check: :string, - fix_issues: :boolean, - quiet: :boolean - ] - ) - - # Set defaults - options = - options - |> Keyword.put_new(:detailed, false) - |> Keyword.put_new(:fix_issues, false) - |> Keyword.put_new(:quiet, false) - - {options, remaining} - end - - defp run_all_checks(options) do - checks = [ - {"System Status", &check_system_status/1}, - {"Database Setup", &check_database_setup/1}, - {"Settings Configuration", &check_settings_config/1}, - {"Mailer Integration", &check_mailer_integration/1} - ] - - checks = - if options[:detailed] do - checks ++ [{"AWS SES Integration", &check_aws_integration/1}] - else - checks - end - - results = - Enum.map(checks, fn {name, check_func} -> - Mix.shell().info("\n🔍 #{name}...") - result = check_func.(options) - {name, result} - end) - - show_summary(results, options) - end - - defp run_database_checks(options) do - Mix.shell().info("\n🗄️ Database Checks...") - result = check_database_setup(options) - show_single_result("Database Setup", result, options) - end - - defp run_mailer_checks(options) do - Mix.shell().info("\n📧 Mailer Checks...") - result = check_mailer_integration(options) - show_single_result("Mailer Integration", result, options) - end - - defp run_settings_checks(options) do - Mix.shell().info("\n⚙️ Settings Checks...") - result = check_settings_config(options) - show_single_result("Settings Configuration", result, options) - end - - defp run_aws_checks(options) do - Mix.shell().info("\n☁️ AWS SES Checks...") - result = check_aws_integration(options) - show_single_result("AWS SES Integration", result, options) - end - - defp check_system_status(_options) do - checks = [ - {"Email tracking enabled", Emails.enabled?()}, - {"Module loaded", Code.ensure_loaded?(Emails)}, - {"Migration applied", migration_applied?()}, - {"Tables exist", tables_exist?()} - ] - - issues = - checks - |> Enum.filter(fn {_name, status} -> not status end) - |> Enum.map(fn {name, _} -> name end) - - if issues == [] do - {:ok, "System is properly configured"} - else - {:warning, "Issues found: #{Enum.join(issues, ", ")}"} - end - end - - defp check_database_setup(options) do - # Check table existence - if tables_exist?() do - # Test basic operations - case test_database_operations() do - :ok -> {:ok, "Database setup is correct"} - {:error, reason} -> {:error, "Database operations failed: #{reason}"} - end - else - if options[:fix_issues] do - Mix.shell().info(" 🔧 Attempting to run migration...") - {:error, "Migration needs to be run manually: mix ecto.migrate"} - else - {:error, "Email tracking tables do not exist. Run: mix ecto.migrate"} - end - end - rescue - error -> - {:error, "Database check failed: #{Exception.message(error)}"} - end - - defp check_settings_config(_options) do - required_settings = [ - "email_enabled", - "email_save_body", - "email_retention_days" - ] - - missing_settings = - required_settings - |> Enum.filter(fn setting -> - Settings.get_setting(setting) == nil - end) - - if missing_settings == [] do - retention_days = Emails.get_retention_days() - - cond do - retention_days < 1 -> - {:error, "Invalid retention days: #{retention_days}"} - - retention_days > 365 -> - {:warning, "Very long retention period: #{retention_days} days"} - - true -> - {:ok, "Settings properly configured (#{retention_days} days retention)"} - end - else - {:warning, "Missing settings: #{Enum.join(missing_settings, ", ")}"} - end - end - - defp check_mailer_integration(_options) do - mailer_config = PhoenixKit.Config.get_mailer() - - case mailer_config do - nil -> - {:warning, "No mailer configured. Set up your mailer in config.exs"} - - module when is_atom(module) -> - if Code.ensure_loaded?(module) do - {:ok, "Mailer #{module} is configured"} - else - {:warning, "Configured mailer module #{module} not found"} - end - end - end - - defp check_aws_integration(_options) do - ses_config = Emails.get_ses_configuration_set() - - if ses_config && ses_config != "" do - {:ok, "AWS SES configuration set configured: #{ses_config}"} - else - {:warning, "AWS SES configuration set not configured"} - end - end - - defp migration_applied? do - # Check if V07 migration has been applied - # This would check the schema_migrations table - true - rescue - _ -> false - end - - defp tables_exist? do - # Check if email tracking tables exist - case repo().query("SELECT 1 FROM phoenix_kit_email_logs LIMIT 1") do - {:ok, _} -> - case repo().query("SELECT 1 FROM phoenix_kit_email_events LIMIT 1") do - {:ok, _} -> true - _ -> false - end - - _ -> - false - end - end - - defp test_database_operations do - test_log = %{ - message_id: "config-test-#{:rand.uniform(9999)}", - to: "test@example.com", - from: "config@test.com", - subject: "Configuration Test", - status: "sent" - } - - with {:ok, log} <- Emails.create_log(test_log), - retrieved_log <- Emails.get_log!(log.uuid), - _ <- repo().delete!(retrieved_log) do - :ok - else - error when is_exception(error) -> {:error, Exception.message(error)} - {:error, reason} -> {:error, inspect(reason)} - _ -> {:error, "Database operation failed"} - end - rescue - error -> {:error, Exception.message(error)} - end - - defp show_summary(results, _options) do - Mix.shell().info("\n📋 Configuration Summary:") - - ok_count = Enum.count(results, fn {_name, {status, _}} -> status == :ok end) - warning_count = Enum.count(results, fn {_name, {status, _}} -> status == :warning end) - error_count = Enum.count(results, fn {_name, {status, _}} -> status == :error end) - - for {name, {status, message}} <- results do - icon = - case status do - :ok -> "✅" - :warning -> "⚠️ " - :error -> "❌" - end - - Mix.shell().info(" #{icon} #{name}: #{message}") - end - - Mix.shell().info( - "\n📊 Results: #{ok_count} OK, #{warning_count} warnings, #{error_count} errors" - ) - - cond do - error_count > 0 -> - Mix.shell().error("\n❌ Configuration has errors that need to be fixed") - exit({:shutdown, 1}) - - warning_count > 0 -> - Mix.shell().info("\n⚠️ Configuration has warnings but should work") - - true -> - Mix.shell().info("\n✅ Email tracking is properly configured!") - end - end - - defp show_single_result(name, result, _options) do - {status, message} = result - - icon = - case status do - :ok -> "✅" - :warning -> "⚠️ " - :error -> "❌" - end - - Mix.shell().info(" #{icon} #{name}: #{message}") - - if status == :error do - exit({:shutdown, 1}) - end - end - - # Gets the configured repository for database operations - defp repo do - PhoenixKit.RepoHelper.repo() - end -end diff --git a/lib/mix/tasks/phoenix_kit/entities/export.ex b/lib/mix/tasks/phoenix_kit/entities/export.ex deleted file mode 100644 index 843e3827b..000000000 --- a/lib/mix/tasks/phoenix_kit/entities/export.ex +++ /dev/null @@ -1,236 +0,0 @@ -defmodule Mix.Tasks.PhoenixKit.Entities.Export do - @shortdoc "Export entities and entity data to JSON files" - - @moduledoc """ - Mix task to export entity definitions and data to JSON files. - - Each entity is exported as a single file containing both the definition - and all its data records. - - Exports are stored in the configured mirror path (default: priv/entities/). - - ## Usage - - # Export all entities (definitions + data if data mirroring enabled) - mix phoenix_kit.entities.export - - # Export specific entity - mix phoenix_kit.entities.export --entity brand - - # Export with data included (regardless of setting) - mix phoenix_kit.entities.export --with-data - - # Export without data (regardless of setting) - mix phoenix_kit.entities.export --no-data - - # Custom output path - mix phoenix_kit.entities.export --output /path/to/export - - ## Options - - --entity NAME Export specific entity only - --with-data Include data records in export - --no-data Exclude data records from export - --output PATH Custom output directory (overrides settings) - --quiet Suppress output messages - - ## Output Structure - - priv/entities/ - brand.json # Contains definition + all data records - product.json # Contains definition + all data records - - ## JSON Format - - { - "export_version": "1.0", - "exported_at": "2025-12-11T10:30:00Z", - "definition": { - "name": "brand", - "display_name": "Brand", - ... - }, - "data": [ - {"title": "Acme Corp", "slug": "acme-corp", ...}, - {"title": "Globex", "slug": "globex", ...} - ] - } - - ## Examples - - # Full export for version control backup - mix phoenix_kit.entities.export --with-data - - # Export just the brand entity - mix phoenix_kit.entities.export --entity brand - - # Export to a specific directory - mix phoenix_kit.entities.export --output ./backup/entities - """ - - use Mix.Task - - alias PhoenixKit.Modules.Entities - alias PhoenixKit.Modules.Entities.EntityData - alias PhoenixKit.Modules.Entities.Mirror.Storage - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Date, as: UtilsDate - - @export_version "1.0" - - @impl Mix.Task - def run(args) do - Mix.Task.run("app.start") - - {options, _remaining} = parse_options(args) - - # Override path if specified - if output_path = options[:output] do - Settings.update_setting("entities_mirror_path", output_path) - end - - # Ensure directory exists - Storage.ensure_directory() - - if options[:entity] do - export_single_entity(options[:entity], options) - else - export_all(options) - end - end - - defp parse_options(args) do - {options, remaining, _errors} = - OptionParser.parse(args, - strict: [ - entity: :string, - with_data: :boolean, - no_data: :boolean, - output: :string, - quiet: :boolean - ], - aliases: [ - e: :entity, - o: :output, - q: :quiet - ] - ) - - {Enum.into(options, %{}), remaining} - end - - defp include_data?(options) do - cond do - options[:with_data] -> true - options[:no_data] -> false - true -> Storage.data_enabled?() - end - end - - defp export_single_entity(entity_name, options) do - case Entities.get_entity_by_name(entity_name) do - nil -> - Mix.shell().error("Entity '#{entity_name}' not found.") - exit({:shutdown, 1}) - - entity -> - include_data = include_data?(options) - data_records = if include_data, do: EntityData.list_data_by_entity(entity.uuid), else: [] - - content = build_export_content(entity, data_records) - result = Storage.write_entity(entity.name, content) - - log_result(entity_name, length(data_records), result, options) - log_summary([result], options) - end - end - - defp export_all(options) do - unless options[:quiet] do - Mix.shell().info("Exporting all entities...") - end - - include_data = include_data?(options) - - results = - Entities.list_entities() - |> Enum.map(fn entity -> - data_records = if include_data, do: EntityData.list_data_by_entity(entity.uuid), else: [] - content = build_export_content(entity, data_records) - result = Storage.write_entity(entity.name, content) - - log_result(entity.name, length(data_records), result, options) - result - end) - - log_summary(results, options) - end - - defp build_export_content(entity, data_records) do - %{ - "export_version" => @export_version, - "exported_at" => UtilsDate.utc_now() |> DateTime.to_iso8601(), - "definition" => serialize_entity(entity), - "data" => Enum.map(data_records, &serialize_entity_data/1) - } - end - - defp serialize_entity(entity) do - %{ - "name" => entity.name, - "display_name" => entity.display_name, - "display_name_plural" => entity.display_name_plural, - "description" => entity.description, - "icon" => entity.icon, - "status" => to_string(entity.status), - "fields_definition" => entity.fields_definition, - "settings" => entity.settings, - "date_created" => format_datetime(entity.date_created), - "date_updated" => format_datetime(entity.date_updated) - } - end - - defp serialize_entity_data(record) do - %{ - "title" => record.title, - "slug" => record.slug, - "status" => to_string(record.status), - "data" => record.data, - "metadata" => record.metadata, - "date_created" => format_datetime(record.date_created), - "date_updated" => format_datetime(record.date_updated) - } - end - - defp format_datetime(nil), do: nil - defp format_datetime(%DateTime{} = dt), do: DateTime.to_iso8601(dt) - defp format_datetime(%NaiveDateTime{} = ndt), do: NaiveDateTime.to_iso8601(ndt) - defp format_datetime(other), do: to_string(other) - - defp log_result(_name, _data_count, _result, %{quiet: true}), do: :ok - - defp log_result(name, data_count, {:ok, path}, _options) do - data_info = if data_count > 0, do: " (#{data_count} records)", else: "" - Mix.shell().info(" #{name}#{data_info} -> #{path}") - end - - defp log_result(name, _data_count, {:error, reason}, _options) do - Mix.shell().error(" #{name} failed: #{inspect(reason)}") - end - - defp log_summary(_results, %{quiet: true}), do: :ok - - defp log_summary(results, _options) do - success_count = Enum.count(results, &match?({:ok, _}, &1)) - error_count = Enum.count(results, &match?({:error, _}, &1)) - - Mix.shell().info("\n--- Summary ---") - Mix.shell().info("Exported: #{success_count} entities") - - if error_count > 0 do - Mix.shell().error("Errors: #{error_count}") - end - - Mix.shell().info("Output path: #{Storage.root_path()}") - end -end diff --git a/lib/mix/tasks/phoenix_kit/entities/import.ex b/lib/mix/tasks/phoenix_kit/entities/import.ex deleted file mode 100644 index 3ebbf3231..000000000 --- a/lib/mix/tasks/phoenix_kit/entities/import.ex +++ /dev/null @@ -1,368 +0,0 @@ -defmodule Mix.Tasks.PhoenixKit.Entities.Import do - @shortdoc "Import entities and entity data from JSON files" - - @moduledoc """ - Mix task to import entity definitions and data from JSON files. - - Each JSON file contains both the entity definition and all its data records. - - Imports from the configured mirror path (default: priv/entities/). - - ## Usage - - # Import from default path (priv/entities/) - mix phoenix_kit.entities.import - - # Import with specific conflict resolution - mix phoenix_kit.entities.import --on-conflict skip - mix phoenix_kit.entities.import --on-conflict overwrite - mix phoenix_kit.entities.import --on-conflict merge - - # Dry-run to preview changes - mix phoenix_kit.entities.import --dry-run - - # Import specific entity - mix phoenix_kit.entities.import --entity brand - - # Import from custom path - mix phoenix_kit.entities.import --input /path/to/import - - ## Options - - --on-conflict STRATEGY How to handle conflicts: skip, overwrite, merge (default: skip) - --dry-run Preview what would be imported without making changes - --entity NAME Import specific entity only - --input PATH Custom input directory - --quiet Suppress output messages - -y Skip confirmation prompts - - ## Conflict Strategies - - - **skip** (default): Skip import if record already exists - - **overwrite**: Replace existing record with imported data - - **merge**: Merge imported data with existing record - - ## Conflict Detection - - - Entity definitions: matched by `name` field - - Entity data records: matched by `entity_name` + `slug` - - ## Examples - - # Preview what would be imported - mix phoenix_kit.entities.import --dry-run - - # Import and overwrite any conflicts - mix phoenix_kit.entities.import --on-conflict overwrite - - # Import from a backup directory - mix phoenix_kit.entities.import --input ./backup/entities - - # Import just the brand entity without confirmation - mix phoenix_kit.entities.import --entity brand -y - """ - - use Mix.Task - - alias PhoenixKit.Modules.Entities.Mirror.{Importer, Storage} - alias PhoenixKit.Settings - - @impl Mix.Task - def run(args) do - Mix.Task.run("app.start") - - {options, _remaining} = parse_options(args) - - # Override path if specified - if input_path = options[:input] do - Settings.update_setting("entities_mirror_path", input_path) - end - - # Parse conflict strategy - strategy = parse_strategy(options[:on_conflict]) - - cond do - options[:dry_run] -> - run_dry_run(options) - - options[:entity] -> - import_single_entity(options[:entity], strategy, options) - - true -> - import_all(strategy, options) - end - end - - defp parse_options(args) do - {options, remaining, _errors} = - OptionParser.parse(args, - strict: [ - on_conflict: :string, - dry_run: :boolean, - entity: :string, - input: :string, - quiet: :boolean, - yes: :boolean - ], - aliases: [ - c: :on_conflict, - e: :entity, - i: :input, - q: :quiet, - y: :yes - ] - ) - - {Enum.into(options, %{}), remaining} - end - - defp parse_strategy(nil), do: :skip - defp parse_strategy("skip"), do: :skip - defp parse_strategy("overwrite"), do: :overwrite - defp parse_strategy("merge"), do: :merge - - defp parse_strategy(invalid) do - Mix.shell().error("Invalid conflict strategy: #{invalid}") - Mix.shell().error("Valid options: skip, overwrite, merge") - exit({:shutdown, 1}) - end - - defp run_dry_run(options) do - unless options[:quiet] do - Mix.shell().info("Previewing import (dry-run)...") - Mix.shell().info("Source path: #{Storage.root_path()}\n") - end - - preview = Importer.preview_import() - - log_definition_summary(preview.summary.definitions, options) - log_entity_details(preview.entities, options) - log_data_summary(preview.summary.data, options) - log_data_details(preview.entities, preview.summary.data.total, options) - log_dry_run_footer(options) - end - - defp log_definition_summary(summary, %{quiet: true}), do: summary - - defp log_definition_summary(summary, _options) do - Mix.shell().info("--- Entity Definitions ---") - Mix.shell().info("Total files: #{summary.total}") - Mix.shell().info("New entities: #{summary.new}") - Mix.shell().info("Identical: #{summary.identical}") - Mix.shell().info("Changed: #{summary.conflicts}") - summary - end - - defp log_entity_details(_entities, %{quiet: true}), do: :ok - defp log_entity_details([], _options), do: :ok - - defp log_entity_details(entities, _options) do - Mix.shell().info("\nDetails:") - Enum.each(entities, &log_entity_definition/1) - end - - defp log_entity_definition(entity) do - case entity.definition.action do - :create -> - Mix.shell().info(" [NEW] #{entity.name}") - - :identical -> - Mix.shell().info(" [IDENTICAL] #{entity.name}") - - :conflict -> - Mix.shell().info( - " [CHANGED] #{entity.name} (existing id: #{entity.definition.existing_id})" - ) - - :error -> - Mix.shell().error(" [ERROR] #{entity.name}") - end - end - - defp log_data_summary(_summary, %{quiet: true}), do: :ok - - defp log_data_summary(summary, _options) do - Mix.shell().info("\n--- Entity Data Records ---") - Mix.shell().info("Total records: #{summary.total}") - Mix.shell().info("New records: #{summary.new}") - Mix.shell().info("Identical: #{summary.identical}") - Mix.shell().info("Changed: #{summary.conflicts}") - end - - defp log_data_details(_entities, _total, %{quiet: true}), do: :ok - defp log_data_details(_entities, 0, _options), do: :ok - - defp log_data_details(entities, _total, _options) do - Mix.shell().info("\nDetails:") - Enum.each(entities, &log_entity_data_records/1) - end - - defp log_entity_data_records(entity) do - Enum.each(entity.data, fn record -> log_data_record(entity.name, record) end) - end - - defp log_data_record(entity_name, record) do - case record.action do - :create -> - Mix.shell().info(" [NEW] #{entity_name}/#{record.slug}") - - :identical -> - Mix.shell().info(" [IDENTICAL] #{entity_name}/#{record.slug}") - - :conflict -> - Mix.shell().info( - " [CHANGED] #{entity_name}/#{record.slug} (existing id: #{record.existing_id})" - ) - - :error -> - Mix.shell().error(" [ERROR] #{entity_name}/#{record.slug}") - end - end - - defp log_dry_run_footer(%{quiet: true}), do: :ok - - defp log_dry_run_footer(_options) do - Mix.shell().info("\n--- Summary ---") - Mix.shell().info("To proceed with import, run without --dry-run") - Mix.shell().info("Use --on-conflict to specify how to handle conflicts") - end - - defp import_single_entity(entity_name, strategy, options) do - unless Storage.entity_exists?(entity_name) do - Mix.shell().error("File not found for entity '#{entity_name}'") - exit({:shutdown, 1}) - end - - unless options[:yes] do - if not confirm_import(entity_name, strategy) do - Mix.shell().info("Import cancelled.") - exit({:shutdown, 0}) - end - end - - case Importer.import_entity(entity_name, strategy) do - {:ok, %{definition: def_result, data: data_results}} -> - log_definition_result(entity_name, def_result, options) - - Enum.each(data_results, fn result -> - log_data_result(entity_name, result, options) - end) - - log_summary([def_result | data_results], options) - - {:error, reason} -> - Mix.shell().error("Import failed: #{inspect(reason)}") - exit({:shutdown, 1}) - end - end - - defp import_all(strategy, options) do - preview = Importer.preview_import() - summary = preview.summary - - unless options[:quiet] do - Mix.shell().info( - "Found #{summary.definitions.total} entities with #{summary.data.total} total data records" - ) - - Mix.shell().info("Conflict strategy: #{strategy}") - end - - unless options[:yes] do - if not confirm_import("all entities", strategy) do - Mix.shell().info("Import cancelled.") - exit({:shutdown, 0}) - end - end - - unless options[:quiet] do - Mix.shell().info("\nImporting...") - end - - {:ok, %{definitions: def_results, data: data_results}} = Importer.import_all(strategy) - - unless options[:quiet] do - Mix.shell().info("\n--- Results ---") - end - - Enum.each(def_results, fn result -> - case result do - {:ok, _action, entity} -> - log_definition_result(entity.name, result, options) - - {:error, _} = err -> - log_definition_result("unknown", err, options) - end - end) - - Enum.each(data_results, fn result -> - case result do - {:ok, _action, _record} -> - log_data_result("", result, options) - - {:error, _} = err -> - log_data_result("", err, options) - end - end) - - log_summary(def_results ++ data_results, options) - end - - defp confirm_import(entity_name, strategy) do - Mix.shell().yes?("Import #{entity_name} with strategy '#{strategy}'? [y/N]") - end - - defp log_definition_result(_name, _result, %{quiet: true}), do: :ok - - defp log_definition_result(name, {:ok, :created, _}, _options) do - Mix.shell().info(" Definition '#{name}' created") - end - - defp log_definition_result(name, {:ok, :updated, _}, _options) do - Mix.shell().info(" Definition '#{name}' updated") - end - - defp log_definition_result(name, {:ok, :skipped, _}, _options) do - Mix.shell().info(" Definition '#{name}' skipped (already exists)") - end - - defp log_definition_result(name, {:error, reason}, _options) do - Mix.shell().error(" Definition '#{name}' failed: #{inspect(reason)}") - end - - defp log_data_result(_entity, _result, %{quiet: true}), do: :ok - - defp log_data_result(_entity, {:ok, :created, record}, _options) do - Mix.shell().info(" Data '#{record.slug}' created") - end - - defp log_data_result(_entity, {:ok, :updated, record}, _options) do - Mix.shell().info(" Data '#{record.slug}' updated") - end - - defp log_data_result(_entity, {:ok, :skipped, record}, _options) do - Mix.shell().info(" Data '#{record.slug}' skipped (already exists)") - end - - defp log_data_result(_entity, {:error, reason}, _options) do - Mix.shell().error(" Data record failed: #{inspect(reason)}") - end - - defp log_summary(_results, %{quiet: true}), do: :ok - - defp log_summary(results, _options) do - created = Enum.count(results, &match?({:ok, :created, _}, &1)) - updated = Enum.count(results, &match?({:ok, :updated, _}, &1)) - skipped = Enum.count(results, &match?({:ok, :skipped, _}, &1)) - errors = Enum.count(results, &match?({:error, _}, &1)) - - Mix.shell().info("\n--- Summary ---") - Mix.shell().info("Created: #{created}") - Mix.shell().info("Updated: #{updated}") - Mix.shell().info("Skipped: #{skipped}") - - if errors > 0 do - Mix.shell().error("Errors: #{errors}") - end - end -end diff --git a/lib/modules/ai/README.md b/lib/modules/ai/README.md deleted file mode 100644 index 8a04a9e09..000000000 --- a/lib/modules/ai/README.md +++ /dev/null @@ -1,661 +0,0 @@ -# AI Module - -The PhoenixKit AI module provides a complete AI integration system with unified endpoint management, usage tracking, and a simple API for making AI calls. Currently supports OpenRouter as the AI provider gateway, giving access to hundreds of models from various providers. - -## Quick Links - -- **Admin Interface**: `/{prefix}/admin/ai/endpoints` -- **Prompt Templates**: `/{prefix}/admin/ai/prompts` -- **Usage Statistics**: `/{prefix}/admin/ai/usage` -- **Create Endpoint**: `/{prefix}/admin/ai/endpoints/new` - -## Architecture Overview - -The AI module uses a unified **Endpoint** architecture where each endpoint contains everything needed to make AI calls: - -- **Provider credentials** (API key, base URL) -- **Model selection** (e.g., `anthropic/claude-3-haiku`) -- **Generation parameters** (temperature, max_tokens, etc.) - -### Core Modules - -- **PhoenixKit.Modules.AI** – Main API module with completion functions and endpoint management -- **PhoenixKit.Modules.AI.Endpoint** – Endpoint schema combining credentials + model + parameters -- **PhoenixKit.Modules.AI.Prompt** – Reusable prompt templates with variable substitution -- **PhoenixKit.Modules.AI.Request** – Request logging schema for usage tracking -- **PhoenixKit.Modules.AI.Completion** – HTTP client for making API calls -- **PhoenixKit.Modules.AI.OpenRouterClient** – Model discovery and API key validation - -## Core Features - -- **Unified Endpoints** – Each endpoint is a complete AI configuration -- **Unlimited Endpoints** – Create as many endpoints as needed -- **Prompt Templates** – Reusable prompts with `{{Variable}}` substitution -- **Usage Tracking** – All requests logged with tokens, latency, and cost -- **Parameter Overrides** – Override endpoint parameters per-request -- **Model Discovery** – Dynamic model fetching from OpenRouter API -- **Sortable Lists** – Sort endpoints by ID, name, usage, cost, last used, etc. -- **Filterable History** – Filter request history by endpoint, model, status, source - -## Database Tables - -- **phoenix_kit_ai_endpoints** – Endpoint storage (credentials, model, parameters) -- **phoenix_kit_ai_prompts** – Reusable prompt templates with variables -- **phoenix_kit_ai_requests** – Request logging with usage statistics - -## ID System - -The AI module uses **UUIDs** as primary keys: - -| Context | ID Type | Field | Example | -|---------|---------|-------|---------| -| Primary key | UUID | `.uuid` | `endpoint.uuid` → `"018f1234-..."` | -| External references (URLs, APIs) | UUID | `.uuid` | `/endpoints/{uuid}/edit` | -| Foreign keys (requests → endpoints) | UUID | `.uuid` | `endpoint.uuid` | -| Usage stats keys | UUID | `.uuid` | `stats[endpoint.uuid]` | -| Lookups | UUID | - | `get_endpoint("uuid-string")` | - -**Rule of thumb:** -- Use `endpoint.uuid` for database operations, FKs, and stats -- Use `endpoint.uuid` for URLs and external API references -- The `get_endpoint/1` function accepts UUID strings - -## API Usage - -### Simple Chat Completion - -```elixir -# Using endpoint ID (UUID or legacy integer) -{:ok, response} = PhoenixKit.Modules.AI.ask(endpoint.uuid, "What is 2+2?") -{:ok, text} = PhoenixKit.Modules.AI.extract_content(response) -# => "4" -``` - -### With System Message - -```elixir -{:ok, response} = PhoenixKit.Modules.AI.ask(endpoint.uuid, "Hello", - system: "You are a pirate. Always respond like a pirate." -) -``` - -### Multi-Turn Conversation - -```elixir -{:ok, response} = PhoenixKit.Modules.AI.complete(endpoint.uuid, [ - %{role: "system", content: "You are a helpful assistant."}, - %{role: "user", content: "What's the weather like?"}, - %{role: "assistant", content: "I don't have real-time weather data..."}, - %{role: "user", content: "That's okay, just make something up."} -]) -``` - -### Parameter Overrides - -```elixir -# Override temperature and max_tokens for this request only -{:ok, response} = PhoenixKit.Modules.AI.ask(endpoint.uuid, "Write a creative poem", - temperature: 1.5, - max_tokens: 500 -) -``` - -### Embeddings - -```elixir -# Single text -{:ok, response} = PhoenixKit.Modules.AI.embed(endpoint.uuid, "Hello, world!") - -# Multiple texts (batch) -{:ok, response} = PhoenixKit.Modules.AI.embed(endpoint.uuid, ["Text 1", "Text 2", "Text 3"]) - -# With dimension override -{:ok, response} = PhoenixKit.Modules.AI.embed(endpoint.uuid, "Hello", dimensions: 512) -``` - -### Extracting Response Data - -```elixir -{:ok, response} = PhoenixKit.Modules.AI.ask(endpoint.uuid, "Hello!") - -# Get just the text content -{:ok, text} = PhoenixKit.Modules.AI.extract_content(response) - -# Get usage statistics (includes cost in nanodollars) -usage = PhoenixKit.Modules.AI.extract_usage(response) -# => %{prompt_tokens: 10, completion_tokens: 15, total_tokens: 25, cost_cents: 30} - -# Full response includes latency -response["latency_ms"] # => 850 -``` - -## Source Tracking & Debugging - -All AI requests automatically capture caller information for analytics and debugging. - -### Automatic Tracking - -Every request automatically stores: - -- **Source** - Clean identifier like `PhoenixKitWeb.Live.Modules.Languages.translate` -- **Stacktrace** - Full call stack (up to 20 frames) for debugging -- **Caller Context** - Additional debug info: - - `request_id` - Phoenix request ID (if in HTTP/LiveView context) - - `node` - Node name (useful for distributed systems) - - `pid` - Process ID - - `memory_bytes` - Process memory at call time - -```elixir -# Automatic detection - no code changes needed -{:ok, response} = PhoenixKit.Modules.AI.ask(endpoint.uuid, "Hello!") -# Source automatically detected from caller: "MyApp.ContentGenerator.summarize" -``` - -### Manual Source Override - -Override the auto-detected source when needed: - -```elixir -{:ok, response} = PhoenixKit.Modules.AI.ask(endpoint.uuid, "Hello!", - source: "CustomLabel" -) -# Manual source used, but stacktrace and caller context still captured -``` - -### Viewing Debug Info - -In the Usage tab request details modal: -- **Source** is displayed prominently for quick identification -- **Caller Context** shows request ID, node, PID, and memory usage -- **Stacktrace** is in a collapsible section for debugging - -This information is stored in the request's `metadata` field (JSONB) and requires no database migration. - -## Endpoint Management - -### Creating Endpoints - -```elixir -{:ok, endpoint} = PhoenixKit.Modules.AI.create_endpoint(%{ - name: "Claude Fast", - provider: "openrouter", - api_key: "sk-or-v1-...", - model: "anthropic/claude-3-haiku", - temperature: 0.7, - max_tokens: 1000 -}) -``` - -### Listing Endpoints - -```elixir -# List all endpoints -endpoints = PhoenixKit.Modules.AI.list_endpoints() - -# With sorting -endpoints = PhoenixKit.Modules.AI.list_endpoints(sort_by: :usage, sort_dir: :desc) - -# Filter by provider or status -endpoints = PhoenixKit.Modules.AI.list_endpoints(provider: "openrouter", enabled: true) -``` - -### Updating Endpoints - -```elixir -endpoint = PhoenixKit.Modules.AI.get_endpoint!("550e8400-e29b-41d4-a716-446655440000") -{:ok, updated} = PhoenixKit.Modules.AI.update_endpoint(endpoint, %{temperature: 0.5}) -``` - -### Enabling/Disabling Endpoints - -```elixir -# Disabled endpoints return an error when called -endpoint = PhoenixKit.Modules.AI.get_endpoint!("550e8400-e29b-41d4-a716-446655440000") -{:ok, _} = PhoenixKit.Modules.AI.update_endpoint(endpoint, %{enabled: false}) - -# Calling a disabled endpoint -{:error, "Endpoint is disabled"} = PhoenixKit.Modules.AI.ask("550e8400-e29b-41d4-a716-446655440000", "Hello") -``` - -## Prompt Templates - -Prompts are reusable templates with variable substitution using `{{VariableName}}` syntax. - -### Creating Prompts - -```elixir -{:ok, prompt} = PhoenixKit.Modules.AI.create_prompt(%{ - name: "Email Writer", - slug: "email-writer", - content: "Write a professional email about {{Topic}} to {{Recipient}}.", - description: "Generates professional emails", - enabled: true -}) -``` - -### Using Prompts with AI Calls - -```elixir -# Simple: render prompt and make AI call -{:ok, response} = PhoenixKit.Modules.AI.ask_with_prompt( - endpoint_id, - "email-writer", # Can use ID, slug, or Prompt struct - %{"Topic" => "project update", "Recipient" => "the team"} -) - -# Advanced: use prompt as system message with user input -{:ok, response} = PhoenixKit.Modules.AI.complete_with_system_prompt( - endpoint_id, - "email-writer", - %{"Topic" => "Q4 results", "Recipient" => "stakeholders"}, - "Make it concise and include key metrics.", - temperature: 0.7 -) -``` - -### Variable Management - -```elixir -# Get variables from a prompt -{:ok, variables} = PhoenixKit.Modules.AI.get_prompt_variables("email-writer") -# => ["Topic", "Recipient"] - -# Preview rendered prompt -{:ok, rendered} = PhoenixKit.Modules.AI.preview_prompt("email-writer", %{ - "Topic" => "meeting notes", - "Recipient" => "the manager" -}) -# => "Write a professional email about meeting notes to the manager." - -# Validate variables before use -case PhoenixKit.Modules.AI.validate_prompt_variables("email-writer", %{"Topic" => "test"}) do - :ok -> # All required variables provided - {:error, missing} -> # Handle missing: ["Recipient"] -end -``` - -### Prompt Discovery - -```elixir -# Search prompts by name or content -prompts = PhoenixKit.Modules.AI.search_prompts("email", enabled_only: true) - -# Find prompts using a specific variable -prompts = PhoenixKit.Modules.AI.get_prompts_with_variable("Recipient") - -# Validate prompt content syntax -:ok = PhoenixKit.Modules.AI.validate_prompt_content("Hello {{Name}}") -``` - -### Prompt Management - -```elixir -# List all prompts -prompts = PhoenixKit.Modules.AI.list_prompts() - -# List enabled prompts only -prompts = PhoenixKit.Modules.AI.list_enabled_prompts() - -# Get by UUID or slug -prompt = PhoenixKit.Modules.AI.get_prompt!("660e8400-e29b-41d4-a716-446655440001") -prompt = PhoenixKit.Modules.AI.get_prompt_by_slug("email-writer") - -# Enable/disable -{:ok, prompt} = PhoenixKit.Modules.AI.enable_prompt(prompt_uuid) -{:ok, prompt} = PhoenixKit.Modules.AI.disable_prompt(prompt_uuid) - -# Duplicate a prompt -{:ok, new_prompt} = PhoenixKit.Modules.AI.duplicate_prompt(prompt_uuid, "Email Writer v2") - -# Delete -{:ok, _} = PhoenixKit.Modules.AI.delete_prompt(prompt) -``` - -### Usage Statistics - -```elixir -# Get usage stats for all prompts -stats = PhoenixKit.Modules.AI.get_prompt_usage_stats() -# => [%{prompt: %{uuid: "660e8400-...", name: "Email Writer", ...}, usage_count: 150, ...}, ...] - -# Reset usage counter -{:ok, prompt} = PhoenixKit.Modules.AI.reset_prompt_usage(prompt_uuid) -``` - -### Prompt Schema - -| Field | Type | Description | -|-------|------|-------------| -| `name` | string | Display name (required) | -| `slug` | string | URL-friendly identifier (auto-generated) | -| `content` | text | Prompt template with `{{Variables}}` | -| `description` | string | Optional description | -| `enabled` | boolean | Whether prompt is active | -| `usage_count` | integer | Number of times used | -| `sort_order` | integer | Display order | - -## Endpoint Schema - -Each endpoint contains: - -| Field | Type | Description | -|-------|------|-------------| -| `name` | string | Display name (required) | -| `description` | string | Optional description | -| `provider` | string | Provider type ("openrouter") | -| `api_key` | string | Provider API key (required) | -| `base_url` | string | Custom API base URL | -| `provider_settings` | map | Provider-specific settings | -| `model` | string | Model identifier (required) | -| `temperature` | float | Sampling temperature (0-2) | -| `max_tokens` | integer | Maximum tokens to generate | -| `top_p` | float | Nucleus sampling (0-1) | -| `top_k` | integer | Top-k sampling | -| `frequency_penalty` | float | Frequency penalty (-2 to 2) | -| `presence_penalty` | float | Presence penalty (-2 to 2) | -| `repetition_penalty` | float | Repetition penalty (0-2) | -| `stop` | array | Stop sequences | -| `seed` | integer | Random seed for reproducibility | -| `image_size` | string | Image generation size | -| `image_quality` | string | Image generation quality | -| `dimensions` | integer | Embeddings dimensions | -| `enabled` | boolean | Whether endpoint is active | -| `sort_order` | integer | Display order | -| `last_validated_at` | datetime | Last API key validation time | - -## Configuration via Admin UI - -### Creating an Endpoint - -1. Navigate to `/{prefix}/admin/ai/endpoints` -2. Click "New Endpoint" -3. Enter endpoint details: - - Name and optional description - - OpenRouter API key (must start with `sk-or-v1-`) - - Select a model from the dropdown - - Adjust parameters as needed -4. Click "Create Endpoint" - -> **Note**: Get your API key from https://openrouter.ai/keys - -### Sorting Endpoints - -The endpoints list supports sorting by: -- **ID** – Endpoint ID (default) -- **Name** – Alphabetical -- **Status** – Enabled/Disabled -- **Model** – Model name -- **Requests** – Total request count -- **Tokens** – Total tokens used -- **Cost** – Total cost -- **Last Used** – Most recent request time - -Sort parameters are preserved in the URL for bookmarking. - -## Usage Tracking - -All requests are automatically logged to `phoenix_kit_ai_requests`. - -### Dashboard Statistics - -```elixir -# Get dashboard stats (today, last 30 days, all time) -stats = PhoenixKit.Modules.AI.get_dashboard_stats() -# => %{ -# today: %{total_requests: 50, total_tokens: 25000, ...}, -# last_30_days: %{...}, -# all_time: %{total_requests: 1234, success_rate: 98.5, ...} -# } -``` - -### Endpoint Usage Statistics - -```elixir -# Get usage stats per endpoint (keyed by UUID) -stats = PhoenixKit.Modules.AI.get_endpoint_usage_stats() -# => %{ -# "018f1234-..." => %{request_count: 100, total_tokens: 50000, total_cost: 150000, last_used_at: ~U[...]}, -# "018f5678-..." => %{...} -# } - -# Access stats for an endpoint -endpoint_stats = Map.get(stats, endpoint.uuid, %{request_count: 0}) -``` - -### Request History - -```elixir -# List requests with pagination -{requests, total} = PhoenixKit.Modules.AI.list_requests(page: 1, page_size: 20) - -# With filters -{requests, total} = PhoenixKit.Modules.AI.list_requests( - endpoint_uuid: endpoint.uuid, - model: "anthropic/claude-3-haiku", - status: "success", - source: "MyApp.ContentGenerator" -) - -# With sorting -{requests, total} = PhoenixKit.Modules.AI.list_requests( - sort_by: :cost_cents, - sort_dir: :desc -) - -# Get filter options (for UI dropdowns) -options = PhoenixKit.Modules.AI.get_request_filter_options() -# => %{endpoints: [{"018f1234-...", "Claude Fast"}, {"018f5678-...", "GPT-4"}], models: [...], statuses: [...], sources: [...]} -``` - -## Response Structure - -### Chat Completion Response - -```elixir -%{ - "id" => "gen-...", - "model" => "anthropic/claude-3-haiku", - "choices" => [ - %{ - "message" => %{ - "role" => "assistant", - "content" => "Hello! How can I help you today?" - }, - "finish_reason" => "stop" - } - ], - "usage" => %{ - "prompt_tokens" => 10, - "completion_tokens" => 15, - "total_tokens" => 25, - "cost" => 0.00003 # Cost in dollars from OpenRouter - }, - "latency_ms" => 850 -} -``` - -### Embeddings Response - -```elixir -%{ - "data" => [ - %{ - "embedding" => [0.123, -0.456, ...], - "index" => 0 - } - ], - "usage" => %{ - "prompt_tokens" => 5, - "total_tokens" => 5 - }, - "latency_ms" => 120 -} -``` - -## Error Handling - -All functions return `{:ok, result}` or `{:error, reason}`: - -```elixir -case PhoenixKit.Modules.AI.ask(endpoint.uuid, "Hello") do - {:ok, response} -> - {:ok, text} = PhoenixKit.Modules.AI.extract_content(response) - IO.puts(text) - - {:error, "Endpoint not found"} -> - IO.puts("Endpoint doesn't exist") - - {:error, "Endpoint is disabled"} -> - IO.puts("Enable the endpoint first") - - {:error, "Invalid API key"} -> - IO.puts("Check your OpenRouter API key") - - {:error, "Rate limited"} -> - Process.sleep(1000) - # Retry... - - {:error, reason} -> - IO.puts("Error: #{reason}") -end -``` - -### Common Error Messages - -| Error | Cause | Solution | -|-------|-------|----------| -| `"Endpoint not found"` | Invalid endpoint ID | Check endpoint exists | -| `"Endpoint is disabled"` | Endpoint not active | Enable endpoint in settings | -| `"Invalid API key"` | API key rejected | Update API key | -| `"Insufficient credits"` | OpenRouter balance empty | Add credits to OpenRouter | -| `"Rate limited"` | Too many requests | Implement backoff/retry | -| `"Request timeout"` | Slow response | Use faster model | - -## LiveView Interfaces - -> **Note**: The AI module must be enabled before accessing admin pages. Enable via Admin UI at `/{prefix}/admin/modules` or programmatically with `PhoenixKit.Modules.AI.enable_system()`. - -### Endpoints Page (`/{prefix}/admin/ai/endpoints`) - -- List all endpoints with usage statistics -- Sort by ID, name, status, usage, cost, last used -- Quick actions: edit, enable/disable, delete -- Each card shows: model, temperature, request count, tokens, cost - -### Prompts Page (`/{prefix}/admin/ai/prompts`) - -- List all prompt templates with usage counts -- Create, edit, duplicate, and delete prompts -- Variable preview with live substitution -- Enable/disable prompts -- Drag-and-drop reordering - -### Usage Page (`/{prefix}/admin/ai/usage`) - -- Dashboard statistics (today, 30 days, all time) -- Recent requests table with filtering and sorting -- Filter by endpoint, model, status, source (filters only appear when there are 2+ options) -- Sort by time, endpoint, model, tokens, latency, cost, status -- Request details modal with full request/response JSON, source, and debug info -- Responsive table design (columns adapt to screen size) - -### Endpoint Form (`/{prefix}/admin/ai/endpoints/new` or `.../edit`) - -- Name and description -- API key configuration -- Model selection from dropdown -- Parameter configuration (temperature, max_tokens, etc.) - -## Cost Tracking - -Costs are tracked in **nanodollars** (1/1,000,000 of a dollar) for precision with cheap API calls. - -```elixir -# In database: cost_cents stores nanodollars -# Example: $0.00003 = 30 nanodollars - -# Format for display -PhoenixKit.Modules.AI.Request.format_cost(30) -# => "$0.000030" - -PhoenixKit.Modules.AI.Request.format_cost(1_500_000) -# => "$1.50" -``` - -## Supported Models - -OpenRouter provides access to models from: - -- **Anthropic** – Claude 3.5 Sonnet, Claude 3 Opus/Sonnet/Haiku -- **OpenAI** – GPT-4o, GPT-4 Turbo, GPT-3.5 Turbo -- **Google** – Gemini Pro, Gemini Flash -- **Meta** – Llama 3.1, Llama 3 -- **Mistral** – Mistral Large, Mixtral -- **And many more** – DeepSeek, Qwen, Cohere, etc. - -Models are dynamically fetched from OpenRouter's API. - -## Troubleshooting - -### Models Not Loading - -1. Check API key is valid in endpoint settings -2. Verify account has credits on OpenRouter -3. Check browser console for network errors -4. Try refreshing the page - -### Slow Responses - -1. Use a faster model (e.g., Haiku instead of Opus) -2. Reduce `max_tokens` parameter -3. Check OpenRouter status page for outages - -### High Costs - -1. Monitor usage in the Usage tab -2. Use cheaper models for simple tasks -3. Reduce `max_tokens` to limit output length -4. Implement caching for repeated queries - -## Getting Help - -1. Check this README for API documentation -2. Review OpenRouter docs: https://openrouter.ai/docs -3. Enable debug logging: `Logger.configure(level: :debug)` -4. Check request logs in `phoenix_kit_ai_requests` table - -## Future Plans - -### Usage Charts - -We plan to add interactive charts to the Usage page showing: - -- **Requests Over Time** – Line/area chart of daily request volume (30 days) -- **Tokens by Model** – Donut/pie chart showing token distribution across models -- **Cost Trends** – Cost breakdown over time - -**Implementation Notes:** - -Since PhoenixKit is a library dependency, charts must be self-contained without requiring parent app changes. Two approaches were evaluated: - -1. **Server-side SVG (Contex)** – Pure Elixir charting library that generates SVG. No JavaScript required. Works but adds a dependency and has limited interactivity. - -2. **Client-side (ApexCharts)** – Modern JavaScript charting with rich interactivity (tooltips, click events, animations). Challenges: - - LiveView strips `

Hello

") -# => "

Hello

" - -# Sanitize all rich_text fields in data map -PhoenixKit.Modules.Entities.HtmlSanitizer.sanitize_rich_text_fields(fields_definition, data) -``` - ---- - -## Real-Time Collaboration - -The entity form editor supports real-time collaboration with FIFO (First In, First Out) locking. - -### Presence System - -**Files**: -- `lib/modules/entities/presence.ex` - Phoenix.Presence wrapper -- `lib/modules/entities/presence_helpers.ex` - Helper functions - -### How It Works - -1. **First user** to open an entity form becomes the **lock owner** (can edit) -2. **Subsequent users** become **spectators** (read-only view) -3. **Spectators see live updates** as the owner makes changes -4. **When owner leaves**, the next spectator is automatically promoted to owner - -### Presence Tracking - -```elixir -# Track user presence when mounting (in LiveView mount) -PresenceHelpers.track_editing_session(:entity, entity.uuid, socket, current_user) -# => {:ok, ref} - -# Get sorted presences (FIFO order) -presences = PresenceHelpers.get_sorted_presences(:entity, entity.uuid) -# => [{socket_id, %{user: %User{}, joined_at: timestamp}}, ...] - -# Determine if current socket is owner or spectator -case PresenceHelpers.get_editing_role(:entity, entity.uuid, socket.id, current_user.uuid) do - {:owner, all_presences} -> - # This socket can edit - - {:spectator, owner_metadata, all_presences} -> - # Read-only mode, sync with owner's state -end -``` - -### UI Indicators - -The entity form shows: -- **Lock owner badge**: "Editing" with user name -- **Spectator list**: Shows all spectators with "Spectating" label -- **Read-only notice**: When viewing as spectator -- **Live updates**: Changes broadcast to all viewers - -### Event Broadcasting - -**File**: `lib/modules/entities/events.ex` - -Changes are broadcast via Phoenix PubSub: - -```elixir -# Subscribe to entity definition lifecycle events (create/update/delete) -Events.subscribe_to_entities() - -# Subscribe to data lifecycle events for a specific entity -Events.subscribe_to_entity_data(entity.uuid) - -# Subscribe to collaborative form events -Events.subscribe_to_entity_form(form_key) -Events.subscribe_to_data_form(entity_uuid, record_key) - -# Broadcast entity lifecycle events -Events.broadcast_entity_created(entity.uuid) -Events.broadcast_entity_updated(entity.uuid) -Events.broadcast_entity_deleted(entity.uuid) - -# Broadcast data lifecycle events -Events.broadcast_data_created(entity_uuid, data_uuid) -Events.broadcast_data_updated(entity_uuid, data_uuid) - -# Handle incoming updates in LiveView -def handle_info({:entity_updated, entity_uuid}, socket) -def handle_info({:data_updated, entity_uuid, data_uuid}, socket) -``` - ---- - -## Multi-Language Support - -The Entities system integrates with the **Languages module** to provide multilang content storage. When the Languages module is enabled with 2+ languages, all entities automatically support multilang data — no per-entity configuration needed. - -### Architecture - -The multilang system is built around three principles: - -1. **Override-only storage** — Secondary languages only store fields that differ from primary. This minimizes storage and makes it clear what's been translated. -2. **Lazy migration** — Existing flat records are automatically wrapped into multilang structure on first edit. No bulk migration needed. -3. **Embedded primary** — Each record stores its own `_primary_language` key, allowing records created under different primary languages to coexist. - -### Core Module: `PhoenixKit.Modules.Entities.Multilang` - -Pure-function module with zero side effects. All functions operate on data maps without touching the database. - -| Function | Purpose | -|----------|---------| -| `enabled?/0` | Checks Languages module has 2+ enabled languages | -| `primary_language/0` | Gets global default language code | -| `enabled_languages/0` | Lists all enabled language codes | -| `multilang_data?/1` | Detects `_primary_language` key in data map | -| `get_language_data/2` | Returns merged data for a language (primary base + overrides) | -| `get_primary_data/1` | Returns primary language data only | -| `get_raw_language_data/2` | Returns raw overrides only (for UI inherited-vs-override detection) | -| `put_language_data/3` | Merges form data into multilang JSONB (primary: all fields, secondary: overrides only) | -| `migrate_to_multilang/2` | Wraps flat data into multilang structure | -| `flatten_to_primary/1` | Extracts primary language data from multilang structure | -| `rekey_primary/2` | Changes primary language, promotes new primary to full data | -| `maybe_rekey_data/1` | Auto-rekeys if embedded primary differs from global | -| `build_language_tabs/0` | Builds language tab UI data with adaptive short codes | - -### JSONB Data Structure - -``` -# Flat (single language) -data: {"name": "Acme", "category": "Tech"} - -# Multilang -data: { - "_primary_language": "en-US", - "en-US": {"name": "Acme", "category": "Tech", "desc": "A company"}, - "es-ES": {"name": "Acme España"} ← override only -} -``` - -The `_primary_language` key cannot collide with user field keys because field keys must match `^[a-z][a-z0-9_]*$` (start with lowercase letter). - -### Translation Storage Locations - -| Content | Primary language | Secondary languages | -|---------|-----------------|---------------------| -| Data custom fields | `data[primary_lang]` | `data[lang_code]` (overrides) | -| Record title | `title` column + `data[primary]["_title"]` | `data[lang_code]["_title"]` (overrides) | -| Entity display_name | `display_name` column | `settings["translations"][lang_code]["display_name"]` | -| Entity description | `description` column | `settings["translations"][lang_code]["description"]` | - -### Primary Language Re-keying - -When the global primary language changes (via Languages admin), existing records have stale `_primary_language` values. The system handles this lazily: - -1. **Read paths** (navigator, data view) use the **embedded** `_primary_language` — old records display correctly without any migration. -2. **Edit paths** (data form) detect the mismatch on mount and silently restructure: - - Update `_primary_language` to the new global primary - - Promote new primary to have all fields (missing fields filled from old primary) - - Recompute all secondary language overrides against new primary (including `_title`) - - Changes persist when the user saves - -This approach avoids bulk migrations and is idempotent — if the user doesn't save, re-keying happens again on next edit. - -### Convenience API - -The translation API provides high-level functions so that scripts and AI agents can manage translations without understanding the internal JSONB structure: - -**Entity definitions** (`PhoenixKit.Modules.Entities`): -```elixir -Entities.set_entity_translation(entity, "es-ES", %{"display_name" => "Productos"}) -Entities.get_entity_translation(entity, "es-ES") -Entities.get_entity_translations(entity) -Entities.remove_entity_translation(entity, "es-ES") -Entities.multilang_enabled?() -``` - -**Data records** (`PhoenixKit.Modules.Entities.EntityData`): -```elixir -EntityData.set_translation(record, "es-ES", %{"name" => "Acme España"}) -EntityData.get_translation(record, "es-ES") -EntityData.get_all_translations(record) -EntityData.get_raw_translation(record, "es-ES") -EntityData.remove_translation(record, "es-ES") - -EntityData.set_title_translation(record, "es-ES", "Mi Producto") -EntityData.get_title_translation(record, "es-ES") -EntityData.get_all_title_translations(record) -``` - -### Admin UI Behavior - -- **Language tabs** appear in entity form and data form when multilang is enabled -- Translatable fields (display_name, title, custom fields) are inside the language tab area -- Non-translatable fields (slug, icon, status) are in a separate card -- Secondary language fields show primary values as ghost text (placeholders) -- Required field indicators (`*`) are hidden on secondary language tabs -- When >5 languages, tabs show adaptive short codes (EN, ES) with full names on hover -- Tabs wrap and use `|` separators in compact mode - -### Known Limitations - -| Limitation | Details | Workaround | -|------------|---------|------------| -| **Search is primary-language only** | The data navigator search queries the primary language data. Secondary language content is not included in search results. | Use the convenience API (`get_translation/2`) for programmatic cross-language search. | -| **Public form builder creates flat data** | The public-facing entity form (`EntityFormBuilder`) writes flat JSONB (no multilang structure). Records created via public forms only contain one language. | Edit the record in the admin UI to add translations, or use `set_translation/3` programmatically. | -| **Clearing a secondary field inherits from primary** | When a secondary language field is cleared (empty string), the display falls back to the primary language value. There is no way to set a field to explicitly empty. | This is by design — override-only storage treats empty as "not overridden". | -| **Entity definition translations are manual** | When the global primary language changes, entity definition translations (display_name, description) are not automatically re-keyed. | Edit the entity definition to enter the new primary language values manually. This is acceptable since entity definitions are low-volume. | -| **Un-saved re-keying is repeated** | Lazy re-keying on edit is not persisted until the user saves. If the user opens and closes without saving, re-keying happens again on next edit. | This is idempotent and by design. | - ---- - -## Usage Examples - -### Creating a Blog Post Entity - -```elixir -# 1. Create the entity definition -{:ok, blog_entity} = PhoenixKit.Modules.Entities.create_entity(%{ - name: "blog_post", - display_name: "Blog Post", - description: "Blog post content type with rich text and categories", - icon: "hero-document-text", - status: "published", - created_by_uuid: admin_user.uuid, - fields_definition: [ - %{ - "type" => "text", - "key" => "title", - "label" => "Post Title", - "required" => true - }, - %{ - "type" => "textarea", - "key" => "excerpt", - "label" => "Excerpt", - "required" => false - }, - %{ - "type" => "rich_text", - "key" => "content", - "label" => "Post Content", - "required" => true - }, - %{ - "type" => "select", - "key" => "category", - "label" => "Category", - "required" => true, - "options" => ["Tech", "Business", "Lifestyle", "Tutorial"] - }, - %{ - "type" => "boolean", - "key" => "featured", - "label" => "Featured Post", - "required" => false, - "default" => "false" - }, - %{ - "type" => "date", - "key" => "publish_date", - "label" => "Publish Date", - "required" => true - }, - %{ - "type" => "image", - "key" => "featured_image", - "label" => "Featured Image", - "required" => false - } - ] -}) - -# 2. Create blog post data records -{:ok, post} = PhoenixKit.Modules.Entities.EntityData.create(%{ - entity_uuid: blog_entity.uuid, - title: "Getting Started with PhoenixKit Entities", - slug: "getting-started-phoenixkit-entities", - status: "published", - created_by_uuid: author_user.uuid, - data: %{ - "title" => "Getting Started with PhoenixKit Entities", - "excerpt" => "Learn how to create dynamic content types...", - "content" => "

Introduction

PhoenixKit Entities...

", - "category" => "Tutorial", - "featured" => true, - "publish_date" => "2025-01-15", - "featured_image" => "/uploads/blog-post-1.jpg" - }, - metadata: %{ - "tags" => ["phoenixkit", "tutorial", "elixir"], - "views" => 0, - "likes" => 0 - } -}) - -# 3. Query published blog posts -published_posts = - PhoenixKit.Modules.Entities.EntityData.list_by_entity(blog_entity.uuid) - |> Enum.filter(&(&1.status == "published")) - |> Enum.sort_by(&(&1.data["publish_date"]), :desc) -``` - -### Creating a Product Catalog - -```elixir -{:ok, product_entity} = PhoenixKit.Modules.Entities.create_entity(%{ - name: "product", - display_name: "Product", - description: "Product catalog with pricing and inventory", - icon: "hero-shopping-bag", - status: "published", - created_by_uuid: admin_user.uuid, - fields_definition: [ - %{"type" => "text", "key" => "name", "label" => "Product Name", "required" => true}, - %{"type" => "textarea", "key" => "description", "label" => "Description", "required" => true}, - %{"type" => "number", "key" => "price", "label" => "Price (USD)", "required" => true}, - %{"type" => "text", "key" => "sku", "label" => "SKU", "required" => true}, - %{"type" => "number", "key" => "inventory", "label" => "Stock Quantity", "required" => true}, - %{"type" => "select", "key" => "category", "label" => "Category", "required" => true, - "options" => ["Electronics", "Clothing", "Home & Garden", "Books"]}, - %{"type" => "image", "key" => "image", "label" => "Product Image", "required" => false}, - %{"type" => "boolean", "key" => "on_sale", "label" => "On Sale", "required" => false} - ] -}) -``` - -### Creating Team Members - -```elixir -{:ok, team_entity} = PhoenixKit.Modules.Entities.create_entity(%{ - name: "team_member", - display_name: "Team Member", - description: "Team member profiles with bio and social links", - icon: "hero-user-group", - status: "published", - created_by_uuid: admin_user.uuid, - fields_definition: [ - %{"type" => "text", "key" => "name", "label" => "Full Name", "required" => true}, - %{"type" => "text", "key" => "role", "label" => "Job Title", "required" => true}, - %{"type" => "email", "key" => "email", "label" => "Email Address", "required" => true}, - %{"type" => "textarea", "key" => "bio", "label" => "Biography", "required" => false}, - %{"type" => "image", "key" => "photo", "label" => "Profile Photo", "required" => false}, - %{"type" => "url", "key" => "linkedin", "label" => "LinkedIn URL", "required" => false}, - %{"type" => "url", "key" => "twitter", "label" => "Twitter URL", "required" => false}, - %{"type" => "boolean", "key" => "active", "label" => "Currently Active", "required" => false} - ] -}) -``` - ---- - -## Implementation Details - -### Status System Unification - -Both entities and entity data use the same three-status workflow: - -- **Draft**: Work in progress, not visible to public -- **Published**: Active and available for use -- **Archived**: Hidden but preserved for historical purposes - -**Migration Change**: Originally, entity status was a boolean. Changed to string-based status in V17 migration to unify with entity_data status system. - -### Field Key Uniqueness - -**Problem**: Field keys are used as map keys in the JSONB `data` column. Duplicate keys would cause data loss and confusion. - -**Solution**: Added `validate_unique_field_key/3` function in `entity_form_live.ex` that checks for duplicates before saving a field: - -```elixir -defp validate_unique_field_key(field_params, existing_fields, editing_index) do - new_key = field_params["key"] - - duplicate? = - existing_fields - |> Enum.with_index() - |> Enum.any?(fn {field, index} -> - field["key"] == new_key && index != editing_index - end) - - if duplicate? do - {:error, "Field key '#{new_key}' already exists. Please use a unique key."} - else - :ok - end -end -``` - -**Enforcement**: Validation occurs in `handle_event("save_field", ...)` before calling `FieldTypes.validate_field/1`. - -### Field Type Select Preservation - -**Problem**: Field type dropdown was resetting during form validation due to LiveView re-rendering. - -**Solution**: Added `selected={@field_form["type"] == type.name}` attribute to option tags to preserve selection: - -```heex - -``` - -### Form State Management - -**Challenge**: Maintaining form state during real-time validation without losing user input. - -**Solution**: Separate `field_form` assign that updates via `phx-change="update_field_form"` event, merging new params with existing state: - -```elixir -def handle_event("update_field_form", %{"field" => field_params}, socket) do - current_form = socket.assigns.field_form - updated_form = Map.merge(current_form, field_params) - socket = assign(socket, :field_form, updated_form) - {:noreply, socket} -end -``` - -### Navigation Hierarchy - -**Challenge**: Keeping "Entities" nav item highlighted when viewing entity data or editing entities. - -**Solution**: Implemented hierarchical path matching in `admin_nav.ex`: - -```elixir -defp hierarchical_match?(current_parts, href_parts) do - String.starts_with?(current_parts.base_path, href_parts.base_path <> "/") -end - -defp parse_admin_path(path) do - base_path = path - |> String.replace_prefix(admin_prefix, "") - |> String.trim_trailing("/") # Fix trailing slash issue - |> case do - "" -> "dashboard" - "/" -> "dashboard" - path -> String.trim_leading(path, "/") - end - %{base_path: base_path} -end -``` - -### Conditional Navigation - -**Feature**: Entities navigation menu items only appear when the system is enabled. - -**Implementation**: Used `PhoenixKit.Modules.Entities.enabled?()` check in `layout_wrapper.ex`: - -```heex -<%= if PhoenixKit.Modules.Entities.enabled?() do %> - <.admin_nav_item - href={Routes.locale_aware_path(assigns, "/admin/entities")} - icon="entities" - label="Entities" - current_path={@current_path || ""} - /> - - <%= if submenu_open?(@current_path, ["/admin/entities"]) do %> - <%!-- Dynamically list each published entity --%> - <%= for entity <- PhoenixKit.Modules.Entities.list_entities() do %> - <%= if entity.status == "published" do %> - <.admin_nav_item - href={Routes.locale_aware_path(assigns, "/admin/entities/#{entity.name}/data")} - icon={entity.icon || "hero-cube"} - label={entity.display_name_plural || entity.display_name} - nested={true} - /> - <% end %> - <% end %> - <% end %> -<% end %> -``` - -> **Note**: The sidebar dynamically lists each published entity with a link to its data navigator. There is no global `/admin/entities/data` route. - -### Cascade Delete Protection - -**Database Constraint**: Entity deletion cascades to all entity_data records via `on_delete: :delete_all` foreign key constraint. - -**UI Confirmation**: Delete button includes data-confirm attribute: - -```heex - -``` - ---- - -## Settings Integration - -### System Settings - -The entities system integrates with PhoenixKit's Settings module using the `"entities"` module namespace. - -**Settings Keys:** - -| Key | Type | Default | Description | -|-----------------------------|---------|---------|------------------------------------------------| -| `entities_enabled` | boolean | false | Master toggle for entire entities system | -| `entities_max_per_user` | integer | 100 | Maximum entities a single user can create | -| `entities_allow_relations` | boolean | true | Allow relation field type | -| `entities_file_upload` | boolean | false | Enable file/image upload functionality | - -**Created by V17 Migration:** - -```sql -INSERT INTO phoenix_kit_settings (key, value, module, date_added, date_updated) -VALUES - ('entities_enabled', 'false', 'entities', NOW(), NOW()), - ('entities_max_per_user', '100', 'entities', NOW(), NOW()), - ('entities_allow_relations', 'true', 'entities', NOW(), NOW()), - ('entities_file_upload', 'false', 'entities', NOW(), NOW()) -ON CONFLICT (key) DO NOTHING -``` - -### API Functions - -```elixir -# Check if system is enabled -PhoenixKit.Modules.Entities.enabled?() -# => false - -# Enable system -PhoenixKit.Modules.Entities.enable_system() -# => {:ok, %Setting{}} - -# Disable system -PhoenixKit.Modules.Entities.disable_system() -# => {:ok, %Setting{}} - -# Get max entities per user -PhoenixKit.Modules.Entities.get_max_per_user() -# => 100 - -# Validate user hasn't exceeded limit -PhoenixKit.Modules.Entities.validate_user_entity_limit(user_id) -# => {:ok, :valid} | {:error, "You have reached the maximum limit of 100 entities"} - -# Get full config -PhoenixKit.Modules.Entities.get_config() -# => %{ -# enabled: false, -# max_per_user: 100, -# allow_relations: true, -# file_upload: false -# } -``` - -### Modules System Integration - -The entities system is integrated as a module in PhoenixKit's modules page at `/phoenix_kit/admin/modules`. - -**Icon**: Uses the existing `hero-cube` icon provided by the core icon helper. - ---- - -## Technical Decisions - -### 1. JSONB vs Normalized Tables - -**Decision**: Use JSONB for field definitions and data storage -**Rationale**: Schema flexibility outweighs 1.5-2x performance cost for admin interfaces -**Trade-off**: Accepted slower write performance for rapid development and zero-migration schema changes - -### 2. Two-Table Architecture - -**Decision**: Separate entity definitions from entity data -**Rationale**: Clean separation of concerns, efficient queries, proper normalization -**Alternative Considered**: Single table with entity definitions embedded in each record (rejected due to redundancy) - -### 3. Status System Unification - -**Decision**: Use draft/published/archived for both entities and entity_data -**Rationale**: Consistent workflow, clearer intent than boolean -**Change**: Rolled back V13 migration to convert boolean to string - -### 4. Field Key Uniqueness - -**Decision**: Enforce uniqueness at application level in LiveView -**Rationale**: JSONB doesn't support database-level key uniqueness constraints -**Implementation**: Validation in `validate_unique_field_key/3` before save - -### 5. No Settings Page for Entities - -**Decision**: Removed dedicated entities settings page -**Rationale**: System-wide settings sufficient, entity-specific settings deferred -**Future**: May add per-entity settings later if needed - -### 6. Field Reordering - -**Decision**: Manual up/down buttons instead of drag-and-drop -**Rationale**: Simpler implementation, no JavaScript required -**Future**: Could add drag-and-drop with LiveView JS hooks - -### 7. Title Field Duplication - -**Decision**: Duplicate title in both `title` column and `data["title"]` -**Rationale**: Indexed column for efficient sorting/searching while maintaining JSONB flexibility -**Trade-off**: Slight data redundancy for query performance - ---- - -## Future Enhancements - -### Planned Features - -1. **Per-Entity Settings**: Custom settings for each entity (permissions, display options, API access) -2. **Validation Rules**: Min/max length, regex patterns, custom validation functions -3. **Field Dependencies**: Show/hide fields based on other field values -4. **Bulk Operations**: Import/export data, bulk status changes -5. **Revisions**: Version history for entity definitions and data -6. **API Generation**: Auto-generate REST/GraphQL APIs for entities -7. **Webhooks**: Trigger webhooks on create/update/delete events -8. **Media Library**: Centralized asset management for image/file fields -9. **Permissions**: Granular entity and field-level permissions -10. **Templates**: Pre-built entity templates (Blog, E-commerce, CRM, etc.) - -### Technical Improvements - -1. **JSONB Indexing**: Add GIN indexes for frequently queried JSONB paths -2. **Query Optimization**: Add list/search/filter helpers for entity data -3. **Caching**: Cache entity definitions to reduce database queries -4. **Validation Refinement**: More comprehensive field validation rules -5. **Type Coercion**: Automatic type conversion for field values -6. **Relations Implementation**: Complete relation field type functionality -7. **File Upload**: Implement actual file/image upload handlers -8. **Rich Text Editor**: Integrate actual WYSIWYG editor (TipTap, Quill, etc.) - ---- - -## Performance Considerations - -### JSONB Performance - -**Write Performance**: 1.5-2x slower than normalized tables -**Read Performance**: Similar with proper indexing -**Query Performance**: Complex JSONB queries can be slower - -**Mitigation Strategies**: -1. Index frequently queried columns (title, slug, status, created_by_uuid) -2. Duplicate critical fields outside JSONB for indexing (e.g., title) -3. Use JSONB operators and functions for efficient queries -4. Add GIN indexes on JSONB columns for contains operations - -### Recommended Indexes - -```sql --- Already included in V13 migration -CREATE INDEX phoenix_kit_entities_status_idx ON phoenix_kit_entities(status); -CREATE INDEX phoenix_kit_entities_created_by_uuid_idx ON phoenix_kit_entities(created_by_uuid); -CREATE UNIQUE INDEX phoenix_kit_entities_name_uidx ON phoenix_kit_entities(name); - -CREATE INDEX phoenix_kit_entity_data_entity_uuid_idx ON phoenix_kit_entity_data(entity_uuid); -CREATE INDEX phoenix_kit_entity_data_status_idx ON phoenix_kit_entity_data(status); -CREATE INDEX phoenix_kit_entity_data_title_idx ON phoenix_kit_entity_data(title); -CREATE INDEX phoenix_kit_entity_data_slug_idx ON phoenix_kit_entity_data(slug); -CREATE INDEX phoenix_kit_entity_data_created_by_uuid_idx ON phoenix_kit_entity_data(created_by_uuid); - --- Future: Add GIN indexes for JSONB queries -CREATE INDEX phoenix_kit_entity_data_data_gin_idx ON phoenix_kit_entity_data USING GIN (data); -``` - -### Query Examples - -```sql --- Efficient: Uses entity_uuid index -SELECT * FROM phoenix_kit_entity_data -WHERE entity_uuid = '018f1234-5678-7890-abcd-ef1234567890' AND status = 'published' -ORDER BY date_created DESC; - --- Efficient: Uses slug index -SELECT * FROM phoenix_kit_entity_data -WHERE slug = 'my-blog-post'; - --- Less Efficient: JSONB field query (add GIN index) -SELECT * FROM phoenix_kit_entity_data -WHERE data @> '{"category": "Tech"}'; - --- Efficient: Title column index -SELECT * FROM phoenix_kit_entity_data -WHERE title ILIKE '%phoenix%' -ORDER BY date_created DESC; -``` - ---- - -## Security Considerations - -### Authentication & Authorization - -- All entity admin routes require admin authentication via `on_mount: [{PhoenixKitWeb.Users.Auth, :phoenix_kit_ensure_admin}]` -- Entity creation tracks `created_by_uuid` user UUID -- Future: Add granular permissions per entity - -### Input Validation - -- Entity names validated with regex: `^[a-z][a-z0-9_]*$` -- Field keys validated for uniqueness -- Field types validated against allowed list -- JSONB data validated against entity field definitions -- SQL injection prevented via Ecto parameterized queries - -### Data Integrity - -- Foreign key constraint ensures data deletion when entity deleted -- Unique constraints on entity names and field keys -- Required field validation enforced at application level -- Status validation prevents invalid states - -### Best Practices - -1. **Always validate field definitions** before saving entities -2. **Sanitize user input** for rich text fields (✅ implemented via HtmlSanitizer) -3. **Use parameterized queries** for all database operations (Ecto handles this) -4. **Audit trail**: Track who created/modified entities and data -5. **Rate limiting**: Consider rate limits on entity/data creation (✅ implemented for public forms) -6. **File uploads**: Validate file types and sizes (when implemented) - ---- - -## Testing Strategy - -### Unit Tests - -Test core business logic: - -```elixir -# Test entity CRUD -test "creates entity with valid attributes" -test "validates required fields" -test "enforces unique entity names" -test "validates status values" - -# Test field validation -test "validates field type" -test "requires options for choice fields" -test "enforces field key uniqueness" - -# Test entity data -test "creates data record" -test "validates against entity definition" -test "enforces required fields" -``` - -### Integration Tests - -Test LiveView interactions: - -```elixir -# Test entity form -test "creates entity through form", %{conn: conn} -test "validates entity form inputs" -test "adds field to entity" -test "prevents duplicate field keys" - -# Test data form -test "creates data record through form" -test "validates data against entity definition" -test "displays validation errors" -``` - -### Database Tests - -Test migrations and constraints: - -```elixir -test "V13 migration creates tables" -test "cascade delete removes entity data" -test "unique constraint on entity name" -``` - ---- - -## Troubleshooting - -### Common Issues - -**Issue**: "Field key already exists" error -**Solution**: Each field key must be unique within an entity. Change the field key to a unique value. - -**Issue**: "Field type requires options array" error -**Solution**: Select, radio, checkbox, and relation fields must have at least one option defined. - -**Issue**: Entity not appearing in data navigator -**Solution**: Ensure entity status is "published" - only published entities can have data created. - -**Issue**: Navigation not highlighting -**Solution**: Check for trailing slashes in URLs - navigation matching handles this automatically. - -**Issue**: Form state resetting during validation -**Solution**: Ensure `phx-change="update_field_form"` is set and `field_form` assign is properly merged. - -**Issue**: Entities menu not appearing -**Solution**: Enable the entities system via Settings or run `PhoenixKit.Modules.Entities.enable_system()`. - ---- - -## API Reference - -### PhoenixKit.Modules.Entities - -```elixir -@type t :: %PhoenixKit.Modules.Entities{ - uuid: String.t(), - name: String.t(), - display_name: String.t(), - description: String.t() | nil, - icon: String.t() | nil, - status: String.t(), - fields_definition: [map()], - settings: map() | nil, - created_by_uuid: String.t(), - date_created: DateTime.t(), - date_updated: DateTime.t() -} - -@spec list_entities() :: [t()] -@spec list_active_entities() :: [t()] -@spec get_entity!(String.t()) :: t() -@spec get_entity(String.t()) :: t() | nil -@spec get_entity_by_name(String.t()) :: t() | nil -@spec create_entity(map()) :: {:ok, t()} | {:error, Ecto.Changeset.t()} -@spec update_entity(t(), map()) :: {:ok, t()} | {:error, Ecto.Changeset.t()} -@spec delete_entity(t()) :: {:ok, t()} | {:error, Ecto.Changeset.t()} -@spec change_entity(t(), map()) :: Ecto.Changeset.t() -@spec enabled?() :: boolean() -@spec enable_system() :: {:ok, Setting.t()} -@spec disable_system() :: {:ok, Setting.t()} -@spec get_system_stats() :: map() - -# Translation API -@spec multilang_enabled?() :: boolean() -@spec get_entity_translations(t()) :: map() -@spec get_entity_translation(t(), String.t()) :: map() -@spec set_entity_translation(t(), String.t(), map()) :: {:ok, t()} | {:error, Ecto.Changeset.t()} -@spec remove_entity_translation(t(), String.t()) :: {:ok, t()} | {:error, Ecto.Changeset.t()} -``` - -Note: `create_entity/1` auto-fills `created_by_uuid` with the first admin user if not provided. - -### PhoenixKit.Modules.Entities.EntityData - -```elixir -@type t :: %PhoenixKit.Modules.Entities.EntityData{ - uuid: String.t(), - entity_uuid: String.t(), - title: String.t(), - slug: String.t() | nil, - status: String.t(), - data: map(), - metadata: map() | nil, - created_by_uuid: String.t(), - date_created: DateTime.t(), - date_updated: DateTime.t() -} - -@spec list_by_entity(String.t()) :: [t()] -@spec list_all() :: [t()] -@spec get!(String.t()) :: t() -@spec get(String.t()) :: t() | nil -@spec create(map()) :: {:ok, t()} | {:error, Ecto.Changeset.t()} -@spec update(t(), map()) :: {:ok, t()} | {:error, Ecto.Changeset.t()} -@spec delete(t()) :: {:ok, t()} | {:error, Ecto.Changeset.t()} -@spec change(t(), map()) :: Ecto.Changeset.t() - -# Translation API -@spec get_translation(t(), String.t()) :: map() -@spec get_raw_translation(t(), String.t()) :: map() -@spec get_all_translations(t()) :: map() -@spec set_translation(t(), String.t(), map()) :: {:ok, t()} | {:error, Ecto.Changeset.t()} -@spec remove_translation(t(), String.t()) :: {:ok, t()} | {:error, :cannot_remove_primary} | {:error, :not_multilang} -@spec get_title_translation(t(), String.t()) :: String.t() | nil -@spec set_title_translation(t(), String.t(), String.t()) :: {:ok, t()} | {:error, Ecto.Changeset.t()} -@spec get_all_title_translations(t()) :: map() -``` - -Note: `create/1` auto-fills `created_by_uuid` with the first admin user if not provided. - -### PhoenixKit.Modules.Entities.Multilang - -```elixir -@spec enabled?() :: boolean() -@spec primary_language() :: String.t() -@spec enabled_languages() :: [String.t()] -@spec multilang_data?(map() | nil) :: boolean() -@spec get_language_data(map() | nil, String.t()) :: map() -@spec get_primary_data(map() | nil) :: map() -@spec get_raw_language_data(map() | nil, String.t()) :: map() -@spec put_language_data(map() | nil, String.t(), map()) :: map() -@spec migrate_to_multilang(map() | nil, String.t()) :: map() -@spec flatten_to_primary(map() | nil) :: map() -@spec rekey_primary(map() | nil, String.t()) :: map() -@spec maybe_rekey_data(map() | nil) :: map() | nil -@spec build_language_tabs() :: [map()] -``` - -### PhoenixKit.Modules.Entities.FieldTypes - -```elixir -@spec all() :: map() -@spec by_category(atom()) :: [map()] -@spec category_list() :: [{atom(), String.t()}] -@spec get_type(String.t()) :: map() | nil -@spec requires_options?(String.t()) :: boolean() -@spec validate_field(map()) :: {:ok, map()} | {:error, String.t()} -@spec for_picker() :: map() - -# Field Builder Helpers -@spec new_field(String.t(), String.t(), String.t(), keyword()) :: map() -@spec select_field(String.t(), String.t(), [String.t()], keyword()) :: map() -@spec radio_field(String.t(), String.t(), [String.t()], keyword()) :: map() -@spec checkbox_field(String.t(), String.t(), [String.t()], keyword()) :: map() -@spec text_field(String.t(), String.t(), keyword()) :: map() -@spec textarea_field(String.t(), String.t(), keyword()) :: map() -@spec email_field(String.t(), String.t(), keyword()) :: map() -@spec number_field(String.t(), String.t(), keyword()) :: map() -@spec boolean_field(String.t(), String.t(), keyword()) :: map() -@spec rich_text_field(String.t(), String.t(), keyword()) :: map() -``` - ---- - -## Changelog - -### V17 Migration (Initial Entities System) - -**Added:** -- `phoenix_kit_entities` table for entity definitions -- `phoenix_kit_entity_data` table for data records -- JSONB support for flexible schemas -- Status system (draft/published/archived) -- Field types system with 11 functional types (+ 3 placeholder types for future) -- Admin interfaces for entity and data management -- Dynamic form generation -- Settings integration -- Navigation integration -- Field key uniqueness validation - -**Database Schema:** -- Two main tables with indexes -- Foreign key cascade delete -- Unique constraints -- Four system settings keys - -**Routes Added:** -- `/admin/entities` - List entities -- `/admin/entities/new` - Create entity -- `/admin/entities/:id/edit` - Edit entity -- `/admin/entities/:entity_slug/data` - Data navigator for entity -- `/admin/entities/:entity_slug/data/new` - Create data record -- `/admin/entities/:entity_slug/data/:id` - View data record -- `/admin/entities/:entity_slug/data/:id/edit` - Edit data record -- `/admin/settings/entities` - Entities module settings - -### Multi-Language Support (2026-02) - -**Added:** -- `Multilang` module — pure-function helpers for multilang JSONB data -- Language tabs in entity form, data form, and data view -- Override-only storage for secondary languages -- Ghost-text placeholders showing primary values on secondary tabs -- Adaptive compact tabs (short codes) for >5 languages -- Lazy re-keying when global primary language changes -- Translation convenience API on `Entities` and `EntityData` modules -- Multilang-aware category extraction and bulk operations - -### Recent Updates (2025-12) - -**Added:** -- Public Form Builder with embeddable forms -- Security options: honeypot, time-based validation, rate limiting -- Configurable security actions -- Form submission statistics tracking -- Debug mode for security troubleshooting -- HTML sanitization for rich_text fields (XSS prevention) -- Real-time collaboration with FIFO locking -- Presence tracking via Phoenix.Presence - ---- - -## Credits - -**Built with**: Elixir, Phoenix, Phoenix LiveView, PostgreSQL, Ecto, DaisyUI, Tailwind CSS -**Part of**: PhoenixKit — A Foundation for Building Your Elixir Phoenix Apps - ---- - -## License - -This entities system is part of PhoenixKit and follows the same license. - ---- - -## Support - -For issues, questions, or contributions related to the entities system: - -1. Check this documentation first -2. Review the code examples and usage patterns -3. Test in your PhoenixKit installation -4. Report issues via PhoenixKit's issue tracker - ---- - -**Last Updated**: 2026-02-18 -**Version**: V17+ with Public Form Builder & Multi-Language Support -**Status**: Production Ready diff --git a/lib/modules/entities/OVERVIEW.md b/lib/modules/entities/OVERVIEW.md deleted file mode 100644 index 59b646d55..000000000 --- a/lib/modules/entities/OVERVIEW.md +++ /dev/null @@ -1,563 +0,0 @@ -# PhoenixKit Entities System - -PhoenixKit's Entities layer is a dynamic content type engine. It lets administrators define custom content types at runtime, attach structured fields, and manage records without writing migrations or shipping new code. This README gives a full overview so a developer (or AI teammate) can understand what exists, how it fits together, and how to extend it safely. - ---- - -## High-level capabilities - -- **Entity blueprints** – Define reusable content types (`phoenix_kit_entities`) with metadata, singular/plural labels, icon, status, JSON field schema, and optional custom settings. -- **Dynamic fields** – 12 built-in field types (text, textarea, number, boolean, date, email, URL, select, radio, checkbox, rich text, file). Field definitions live in JSONB and are validated at creation time. *(Note: image and relation fields are defined but not yet fully implemented—UI shows "coming soon" placeholders.)* -- **Entity data records** – Store instances of an entity (`phoenix_kit_entity_data`) with slug support, status workflow (draft/published/archived), JSONB data payload, metadata, creator tracking, and timestamps. -- **Admin UI** – LiveView dashboards for managing blueprints, browsing/creating data, filtering, and adjusting module settings. -- **Settings + security** – Feature toggle and max entities per user are enforced; additional settings (relation/file flags, auto slugging, etc.) are persisted in `phoenix_kit_settings` but reserved for future use. All surfaces are gated behind the admin scope. -- **Statistics** – Counts and summaries for dashboards and monitoring. -- **Public Form Builder** – Create embeddable forms for public-facing pages with security features (honeypot, time-based validation, rate limiting), configurable actions, and submission statistics. - ---- - -## Folder structure - -``` -lib/modules/entities/ -├── entities.ex # Entity schema + business logic -├── entity_data.ex # Data record schema + CRUD helpers -├── field_types.ex # Registry of supported field types -├── form_builder.ex # Dynamic form rendering + validation helpers -├── multilang.ex # Multi-language data transformation helpers -├── html_sanitizer.ex # XSS prevention for rich_text fields -├── presence.ex # Phoenix.Presence for real-time collaboration -├── presence_helpers.ex # FIFO locking and presence utilities -├── events.ex # PubSub event broadcasting -├── OVERVIEW.md # High-level guide (this file) -├── DEEP_DIVE.md # Architectural deep dive -├── mirror/ # Entity definition/data mirroring to filesystem -│ ├── exporter.ex -│ ├── importer.ex -│ └── storage.ex -└── web/ - ├── entities.ex / .html.heex # Entity dashboard - ├── entity_form.ex / .html.heex # Create/update entity definitions + public form config - ├── data_navigator.ex / .html.heex # Browse/filter records per entity - ├── data_form.ex / .html.heex # Create/update individual records - ├── data_view.ex # Read-only view component - ├── entities_settings.ex / .html.heex# System configuration - └── hooks.ex # LiveView hooks for entity pages - -lib/phoenix_kit_web/controllers/ -└── entity_form_controller.ex # Public form submission handler - -lib/modules/publishing/components/ -└── entity_form.ex # Embeddable public form component - -lib/phoenix_kit/migrations/postgres/ -├── v17.ex # Creates entities + entity_data tables, seeds settings -└── v81.ex # Adds position column for manual record ordering -``` - ---- - -## Database schema (migration V17, V81) - -### `phoenix_kit_entities` -- `uuid` – primary key (UUIDv7) -- `name` – unique slug (snake_case) -- `display_name` – singular UI label -- `display_name_plural` – plural label (for menus/navigation and entity listing page) -- `description` – optional help text -- `icon` – hero icon identifier -- `status` – `draft | published | archived` -- `fields_definition` – JSONB array describing fields -- `settings` – optional JSONB for entity-specific config (includes `sort_mode`, `mirror_definitions`, `mirror_data`, `translations`, public form settings) -- `created_by_uuid` – admin user UUID -- `date_created`, `date_updated` – UTC timestamps - -Indexes cover `name`, `status`, `created_by_uuid`. A comment block documents JSON columns. - -**Entity settings keys:** -- `sort_mode` – `"auto"` (default, sort records by creation date) or `"manual"` (sort by position) -- `mirror_definitions` / `mirror_data` – filesystem mirroring toggles -- `translations` – nested map of language translations for display_name, etc. -- `public_form_*` – public form builder configuration - -### `phoenix_kit_entity_data` -- `uuid` – primary key (UUIDv7) -- `entity_uuid` – foreign key → `phoenix_kit_entities` -- `title` – record label -- `slug` – optional unique slug per entity -- `status` – `draft | published | archived` -- `position` – integer for manual ordering (V81, auto-populated on create) -- `data` – JSONB map keyed by field definition (or multilang structure, see below) -- `metadata` – optional JSONB extras (tags, categories, etc.) -- `created_by_uuid` – admin user UUID -- `date_created`, `date_updated` - -Indexes cover `entity_uuid`, `slug`, `status`, `created_by_uuid`, `title`, `(entity_uuid, position)`. FK cascades on delete. - -### Seeded settings -- `entities_enabled` – boolean toggle (default `false`) -- `entities_max_per_user` – integer limit (default `100`) -- `entities_allow_relations` – boolean (default `true`) -- `entities_file_upload` – boolean (default `false`) - ---- - -## Core modules - -### `PhoenixKit.Modules.Entities` -Responsible for entity blueprints: -- Schema + changeset enforcing unique names, valid field definitions, timestamps, etc. -- CRUD helpers (`list_entities/1`, `get_entity!/2`, `get_entity/2`, `get_entity_by_name/2`, `create_entity/1`, `update_entity/2`, `delete_entity/1`, `change_entity/2`). All query functions accept an optional `lang:` keyword option for language-aware results. -- Statistics (`get_system_stats/0`, `count_entities/0`, `count_user_entities/1`). -- Settings helpers (`enabled?/0`, `enable_system/0`, `disable_system/0`, `get_config/0`). -- Sort mode helpers (`get_sort_mode/1`, `get_sort_mode_by_uuid/1`, `manual_sort?/1`, `update_sort_mode/2`). -- Limit enforcement (`validate_user_entity_limit/1`). -- Language resolution (`resolve_language/2`, `resolve_languages/2`) for applying translations to entity structs. - -Note: `create_entity/1` auto-fills `created_by_uuid` with the first admin user if not provided. - -Field validation pipeline ensures every entry in `fields_definition` has `type/key/label` and uses a supported type. Note: the changeset validates but does not enrich field definitions—use `FieldTypes.new_field/4` to apply default properties. - -### `PhoenixKit.Modules.Entities.EntityData` -Manages actual records: -- Schema + changeset verifying required fields, slug format, status, and cross-checking submitted JSON against the entity definition. -- CRUD and query helpers (`list_all/1`, `list_by_entity/2`, `get!/2`, `get/2`, `search_by_title/3`, `create/1`, `update/2`, `delete/1`, `change/2`). All query functions accept an optional `lang:` keyword option for language-aware results. -- Ordering helpers (`update_position/2`, `move_to_position/2`, `reorder/2`, `bulk_update_positions/1`, `next_position/1`). Queries automatically respect the parent entity's sort mode. -- Language resolution (`resolve_language/2`, `resolve_languages/2`) for applying translations to data record structs. -- Field-level validation ensures required fields are present, numbers are numeric, booleans are booleans, options exist, etc. - -Note: `create/1` auto-fills `created_by_uuid` with the first admin user if not provided. It also auto-populates `position` with the next sequential value for the entity. - -### `PhoenixKit.Modules.Entities.FieldTypes` -Registry of supported field types with metadata: -- `all/0`, `list_types/0`, `for_picker/0` – introspection for UI builders. -- Category helpers, default properties, and `validate_field/1` to ensure field definitions are complete. -- Field builder helpers for programmatic creation: - - `new_field/4` – Create any field type with options - - `select_field/4`, `radio_field/4`, `checkbox_field/4` – Choice fields with options list - - `text_field/3`, `textarea_field/3`, `email_field/3`, `number_field/3`, `boolean_field/3`, `rich_text_field/3` – Common field types -- Used both when saving entity definitions and when rendering forms. - -### `PhoenixKit.Modules.Entities.Multilang` -Pure-function module for multi-language data transformations. No database calls — used by LiveViews and the convenience API. -- Global helpers: `enabled?/0`, `primary_language/0`, `enabled_languages/0`. -- Data reading: `get_language_data/2`, `get_primary_data/1`, `get_raw_language_data/2`, `multilang_data?/1`. -- Data writing: `put_language_data/3`, `migrate_to_multilang/2`, `flatten_to_primary/1`. -- Re-keying: `rekey_primary/2`, `maybe_rekey_data/1` — handles primary language changes. -- UI: `build_language_tabs/0` — builds tab data for language switcher UI. - -### `PhoenixKit.Modules.Entities.FormBuilder` -- Renders form inputs dynamically based on field definitions (`build_fields/3`, `build_field/3`). -- Provides `validate_data/2` and lower-level helpers to check payloads before they reach `EntityData.changeset/2`. -- Language-aware: accepts `lang_code` option to render fields for a specific language, with ghost-text placeholders showing primary language values on secondary tabs. -- Produces consistent labels, placeholders, and helper text aligned with Tailwind/daisyUI styling. - ---- - -## LiveView surfaces - -| Route | LiveView | Purpose | -|-------|----------|---------| -| `/admin/entities` | `entities.ex` | Dashboard listing entities with table/card views (card view auto-selected on small screens) | -| `/admin/entities/new` / `/:id/edit` | `entity_form.ex` | Create/update entity definitions | -| `/admin/entities/:slug/data` | `data_navigator.ex` | Table & card views of records, search, status filters | -| `/admin/entities/:slug/data/new` / `/:id/edit` | `data_form.ex` | Create/update individual records | -| `/admin/settings/entities` | `entities_settings.ex` | Toggle module, configure behaviour | - -LiveViews share a layout wrapper that expects these assigns: -- `@current_locale` – required for locale-aware paths -- `@current_path` – for sidebar highlighting -- `@project_title` – used in layout/head - -All navigation helpers use `Routes.locale_aware_path/2` (or `PhoenixKit.Utils.Routes.path/2`) so URLs keep the active locale prefix (e.g., `/phoenix_kit/ru/admin/entities`). - ---- - -## Field types at a glance - -- **Basic**: `text`, `textarea`, `rich_text`, `email`, `url` -- **Numeric**: `number` -- **Boolean**: `boolean` -- **Date/Time**: `date` -- **Choice**: `select`, `radio`, `checkbox` -- **Media** *(coming soon)*: `image`, `file` – defined in schema but renders placeholder UI -- **Relations** *(coming soon)*: `relation` – defined in schema but not yet functional - -Each field definition is a map like: -```elixir -%{ - "type" => "select", - "key" => "category", - "label" => "Category", - "required" => true, - "options" => ["Tech", "Business", "Lifestyle"], - "validation" => %{} -} -``` - -`FormBuilder` merges default props (placeholder, rows, etc.) and renders the correct component. Validation ensures options exist when required and types match. - ---- - -## Settings & configuration - -| Setting | Description | Exposed via | Status | -|---------|-------------|-------------|--------| -| `entities_enabled` | Master on/off switch for the module | `/admin/modules`, `Entities.enable_system/0` | ✅ Active | -| `entities_max_per_user` | Blueprint limit per creator | Settings UI & `Entities.get_max_per_user/0` | ✅ Active | -| `entities_allow_relations` | Reserved for future relation field toggle | Settings UI | 🚧 Not yet enforced | -| `entities_file_upload` | Reserved for future file/image upload toggle | Settings UI | 🚧 Not yet enforced | -| `entities_auto_generate_slugs` | Reserved for optional slug generation control | Settings UI | 🚧 Not yet enforced (slugs always auto-generate) | -| `entities_default_status` | Reserved for default status on new records | Settings UI | 🚧 Not yet enforced (defaults to "published") | -| `entities_require_approval` | Reserved for approval workflow | Settings UI | 🚧 Not yet enforced | -| `entities_data_retention_days` | Reserved for data retention policy | Settings UI | 🚧 Not yet enforced | -| `entities_enable_revisions` | Reserved for revision history | Settings UI | 🚧 Not yet enforced | -| `entities_enable_comments` | Reserved for commenting system | Settings UI | 🚧 Not yet enforced | - -**Per-entity settings** (stored in entity `settings` JSONB, not in `phoenix_kit_settings`): - -| Setting | Description | API | Status | -|---------|-------------|-----|--------| -| `sort_mode` | Record ordering: `"auto"` or `"manual"` | `Entities.get_sort_mode/1`, `update_sort_mode/2` | ✅ Active | -| `mirror_definitions` | Filesystem mirroring of entity definition | `Entities.mirror_definitions_enabled?/1` | ✅ Active | -| `mirror_data` | Filesystem mirroring of entity data | `Entities.mirror_data_enabled?/1` | ✅ Active | -| `translations` | Translated display_name/description per language | `Entities.get_entity_translations/1` | ✅ Active | -| `public_form_*` | Public form builder configuration | Entity form UI | ✅ Active | - -> **Note**: System-level settings marked "Not yet enforced" are persisted in the database and visible in the admin UI, but the underlying functionality is not yet implemented. They are placeholders for future features. - -`PhoenixKit.Modules.Entities.get_config/0` returns a map: -```elixir -%{ - enabled: boolean, - max_per_user: integer, - allow_relations: boolean, - file_upload: boolean, - entity_count: integer, - total_data_count: integer -} -``` - ---- - -## Common workflows - -### Enabling the module -```elixir -{:ok, _setting} = PhoenixKit.Modules.Entities.enable_system() -PhoenixKit.Modules.Entities.enabled?() -# => true/false -``` - -### Creating an entity blueprint -```elixir -# Note: created_by_uuid is optional - auto-fills with first admin user if omitted -{:ok, blog_entity} = - PhoenixKit.Modules.Entities.create_entity(%{ - name: "blog_post", - display_name: "Blog Post", - display_name_plural: "Blog Posts", - icon: "hero-document-text", - # created_by_uuid: admin.uuid, # Optional! - fields_definition: [ - %{"type" => "text", "key" => "title", "label" => "Title", "required" => true}, - %{"type" => "rich_text", "key" => "content", "label" => "Content"} - ] - }) -``` - -### Creating fields with builder helpers -```elixir -alias PhoenixKit.Modules.Entities.FieldTypes - -# Build fields programmatically -fields = [ - FieldTypes.text_field("title", "Title", required: true), - FieldTypes.textarea_field("excerpt", "Excerpt"), - FieldTypes.select_field("category", "Category", ["Tech", "Business", "Lifestyle"]), - FieldTypes.checkbox_field("tags", "Tags", ["Featured", "Popular", "New"]), - FieldTypes.boolean_field("featured", "Featured Post", default: false) -] - -{:ok, entity} = PhoenixKit.Modules.Entities.create_entity(%{ - name: "article", - display_name: "Article", - fields_definition: fields -}) -``` - -### Creating a record -```elixir -# Note: created_by_uuid is optional - auto-fills with first admin user if omitted -{:ok, _record} = - PhoenixKit.Modules.Entities.EntityData.create(%{ - entity_uuid: blog_entity.uuid, - title: "My First Post", - status: "published", - # created_by_uuid: admin.uuid, # Optional! - data: %{"title" => "My First Post", "content" => "

Hello

"} - }) -``` - -### Counting statistics -```elixir -PhoenixKit.Modules.Entities.get_system_stats() -# => %{total_entities: 5, active_entities: 4, total_data_records: 23} -``` - -### Enforcing limits -```elixir -PhoenixKit.Modules.Entities.validate_user_entity_limit(admin.uuid) -# {:ok, :valid} or {:error, "You have reached the maximum limit of 100 entities"} -``` - -### Language-aware queries - -All list/get functions accept an optional `lang:` keyword to return structs with translated fields already resolved. When omitted, raw data is returned (backward compatible). - -```elixir -alias PhoenixKit.Modules.Entities -alias PhoenixKit.Modules.Entities.EntityData - -# Entity definitions — resolves display_name, display_name_plural, description -entities = Entities.list_entities(lang: "es-ES") -entity = Entities.get_entity!(uuid, lang: "es-ES") -entity = Entities.get_entity_by_name("products", lang: "fr-FR") -active = Entities.list_active_entities(lang: "ja-JP") - -# Entity data — resolves title from _title, data to merged language fields -records = EntityData.list_by_entity(entity_uuid, lang: "es-ES") -record = EntityData.get!(uuid, lang: "fr-FR") -results = EntityData.search_by_title("Acme", entity_uuid, lang: "es-ES") -published = EntityData.published_records(entity_uuid, lang: "ja-JP") -record = EntityData.get_by_slug(entity_uuid, "acme", lang: "es-ES") - -# Manual resolution (without opts) -resolved = Entities.resolve_language(entity, "es-ES") -resolved_list = EntityData.resolve_languages(records, "es-ES") -``` - -For the primary language (or when no translation exists for a field), the original value is returned unchanged. For secondary languages, overrides are merged onto primary values. - -### Record ordering - -Each entity has a sort mode (`"auto"` or `"manual"`) stored in `settings["sort_mode"]`. All listing queries respect this automatically. - -```elixir -alias PhoenixKit.Modules.Entities -alias PhoenixKit.Modules.Entities.EntityData - -# Check and change sort mode -Entities.get_sort_mode(entity) # => "auto" -Entities.manual_sort?(entity) # => false -{:ok, entity} = Entities.update_sort_mode(entity, "manual") - -# Convenience lookup by UUID -Entities.get_sort_mode_by_uuid(entity_uuid) # => "manual" - -# Queries automatically use the right order: -# - "auto" → ORDER BY date_created DESC -# - "manual" → ORDER BY position ASC, date_created DESC -records = EntityData.list_by_entity(entity_uuid) - -# Position is auto-populated on create (next sequential value) -{:ok, record} = EntityData.create(%{entity_uuid: entity_uuid, title: "New", ...}) -# record.position => 5 (auto-assigned) - -# Reordering operations (for drag-and-drop UI) -EntityData.move_to_position(record, 2) # shift others to make room -EntityData.reorder(entity_uuid, ["uuid3", "uuid1", "uuid2"]) # full reorder -EntityData.update_position(record, 10) # set position directly -EntityData.bulk_update_positions([{"uuid1", 1}, {"uuid2", 2}]) # raw bulk -``` - ---- - -## Multi-Language Support - -When the **Languages module** is enabled with 2+ languages, all entities automatically support multilang content. There is no per-entity toggle — languages are configured system-wide. - -### Data Structure - -**Flat (single language or multilang disabled):** -```json -{"name": "Acme", "category": "Tech"} -``` - -**Multilang (Languages module has 2+ languages):** -```json -{ - "_primary_language": "en-US", - "en-US": {"name": "Acme", "category": "Tech", "desc": "A company"}, - "es-ES": {"name": "Acme España"} -} -``` - -- `_primary_language` signals the multilang structure (cannot collide with field keys — they must match `^[a-z][a-z0-9_]*$`) -- Primary language stores ALL fields -- Secondary languages store ONLY overrides (fields that differ from primary) -- Display merges: `Map.merge(primary_data, language_overrides)` -- `title` and `slug` DB columns remain primary-language-only; secondary title translations are stored as `_title` overrides in the JSONB `data` column alongside other fields -- Entity definition translations (display_name, etc.) are in `entity.settings["translations"]` - -### Translation Storage Summary - -| What | Primary language | Secondary languages | -|------|-----------------|---------------------| -| Entity data (custom fields) | `data["en-US"]` | `data["es-ES"]` (overrides only) | -| Record title | `title` column + `data[primary]["_title"]` | `data["es-ES"]["_title"]` (overrides) | -| Entity display_name | `display_name` column | `settings["translations"]["es-ES"]["display_name"]` | - -### Enabling Multilang - -```elixir -# 1. Enable Languages module -PhoenixKit.Modules.Languages.enable_system() - -# 2. Add secondary languages -PhoenixKit.Modules.Languages.add_language("es-ES") -PhoenixKit.Modules.Languages.add_language("fr-FR") - -# 3. Multilang is now active for all entities -PhoenixKit.Modules.Entities.multilang_enabled?() -# => true -``` - -### Translation API (Programmatic) - -```elixir -alias PhoenixKit.Modules.Entities -alias PhoenixKit.Modules.Entities.EntityData - -# --- Entity definition translations --- -entity = Entities.get_entity_by_name("products") - -Entities.set_entity_translation(entity, "es-ES", %{ - "display_name" => "Productos", - "display_name_plural" => "Productos", - "description" => "Catálogo de productos" -}) - -Entities.get_entity_translation(entity, "es-ES") -# => %{"display_name" => "Productos", "display_name_plural" => "Productos", ...} - -Entities.get_entity_translations(entity) -# => %{"es-ES" => %{...}, "fr-FR" => %{...}} - -# --- Entity data translations --- -record = EntityData.get(uuid) - -EntityData.set_translation(record, "es-ES", %{"name" => "Acme España", "desc" => "Una empresa"}) -EntityData.set_title_translation(record, "es-ES", "Mi Producto") - -EntityData.get_translation(record, "es-ES") -# => %{"name" => "Acme España", "category" => "Tech", "desc" => "Una empresa"} - -EntityData.get_all_translations(record) -# => %{"en-US" => %{...}, "es-ES" => %{...}} - -EntityData.get_all_title_translations(record) -# => %{"en-US" => "My Product", "es-ES" => "Mi Producto"} - -# Remove a language's translations -EntityData.remove_translation(record, "fr-FR") -Entities.remove_entity_translation(entity, "fr-FR") -``` - -### Primary Language Changes - -When the global primary language changes (via Languages admin), existing records lazily re-key on edit: - -1. User opens an existing record for editing -2. System detects embedded `_primary_language` differs from global primary -3. The new primary is promoted to have all fields (missing fields filled from old primary) -4. All secondary languages are recomputed against the new primary; `_title` is re-keyed with other fields -5. Changes persist when the user saves - -Records that are never edited continue to work — read paths use the embedded primary for correct display. - -### Admin UI - -- **Entity form** (`/admin/entities/:id/edit`): Language tabs above translatable fields (display_name, display_name_plural, description). Non-translatable fields (slug, icon, status) in a separate card. -- **Data form** (`/admin/entities/:slug/data/:id/edit`): Language tabs for title and custom fields. Slug, status, and entity type in a separate card. -- **Data view**: Read-only language tabs for viewing translations. -- **Compact mode**: When >5 languages, tabs show short codes (EN, ES) instead of full names. - -### Limitations - -- **Search** queries primary language data only; secondary translations are not searched. -- **Public form builder** creates flat (non-multilang) data. Use the admin UI or API to add translations afterward. -- **Clearing a secondary field** makes it inherit the primary value (by design — override-only storage). -- See `DEEP_DIVE.md § Known Limitations` for the full table. - ---- - -## Extending the system - -1. **New field type** – update `FieldTypes` (definition + defaults), extend `FormBuilder`, and add validation handling to `EntityData` if needed. -2. **New settings** – add to `phoenix_kit_settings` (migration + defaults), expose in the settings LiveView, and document in `get_config/0`. -3. **API surface** – add helper functions in `Entities` or `EntityData` if they’re reused across LiveViews or future REST/GraphQL endpoints. -4. **LiveView changes** – keep locale and nav rules in mind, reuse existing slots/components for consistency, and add tests where possible. - ---- - -## Public Form Builder - -The Entities system includes a public form builder for creating embeddable forms on public-facing pages. - -### Features - -- **Embeddable Component**: Use `` in publishing pages -- **Field Selection**: Choose which entity fields appear on the public form -- **Security Options**: Honeypot, time-based validation (3s minimum), rate limiting (5/min) -- **Configurable Actions**: reject_silent, reject_error, save_suspicious, save_log -- **Statistics**: Track submissions, rejections, and security triggers -- **Debug Mode**: Detailed error messages for troubleshooting -- **Metadata Collection**: IP address, browser, device, referrer, timing data -- **HTML Sanitization**: Rich text fields automatically sanitized to prevent XSS - -### Configuration (entity settings) - -| Setting | Description | -|---------|-------------| -| `public_form_enabled` | Master toggle | -| `public_form_fields` | List of field keys to include | -| `public_form_title` | Form title | -| `public_form_description` | Form description | -| `public_form_submit_text` | Submit button text | -| `public_form_success_message` | Success message | -| `public_form_honeypot` | Enable honeypot protection | -| `public_form_time_check` | Enable time-based validation | -| `public_form_rate_limit` | Enable rate limiting | -| `public_form_debug_mode` | Show detailed error messages | -| `public_form_collect_metadata` | Collect submission metadata | - -### Embedding in pages - -```heex - -``` - -The component checks if the form is enabled AND has fields selected before rendering. Submissions go to `/phoenix_kit/entities/{slug}/submit`. - -### Real-Time Collaboration - -The entity form editor supports real-time collaboration with FIFO locking: -- First user becomes the lock owner (can edit) -- Subsequent users become spectators (read-only) -- Live updates broadcast to all viewers -- Automatic promotion when owner leaves - ---- - -## Related documentation - -- `DEEP_DIVE.md` – long-form analysis, rationale, and implementation notes (in this directory) -- `lib/phoenix_kit/migrations/postgres/v17.ex` – initial entities database migration -- `lib/phoenix_kit/migrations/postgres/v81.ex` – adds `position` column for manual record ordering -- `lib/phoenix_kit/utils/routes.ex` – locale-aware path helpers -- `lib/phoenix_kit_web/components/layout_wrapper.ex` – navigation wrapper that consumes the assigns set by these LiveViews - ---- - -With this overview you should have everything needed to work on the Entities system—whether that’s building new UI affordances, adding field types, or integrating entities into other PhoenixKit features. For deeper rationale and implementation notes, open `DEEP_DIVE.md` in the same directory. diff --git a/lib/modules/entities/README.md b/lib/modules/entities/README.md deleted file mode 100644 index 333da9eef..000000000 --- a/lib/modules/entities/README.md +++ /dev/null @@ -1,65 +0,0 @@ -# Entities Module - -The Entities module delivers PhoenixKit's dynamic content type system. It allows administrators -to design structured content types with custom fields without writing migrations or code. This README gives a quick orientation for contributors working on the LiveView -layer; the business logic lives in the `PhoenixKit.Modules.Entities` context. - -## LiveViews & Components - -- `entities.ex` / `.html.heex` – Main dashboard listing entities with table/card views (card view auto-selected on small screens). -- `entity_form.ex` / `.html.heex` – Schema builder for creating and editing entity definitions (with presence locking). -- `entities_settings.ex` / `.html.heex` – Module settings (enable/disable system, defaults). -- `data_navigator.ex` / `.html.heex` – Explorer for entity records with filtering, search, and status management. -- `data_form.ex` / `.html.heex` – Dynamic form renderer for entity entries (with presence locking). -- `hooks.ex` – LiveView hooks (presence, authorization guards, shared assigns). - -All templates follow Phoenix 1.8 layout conventions (`` with `@current_scope`). - -## Feature Highlights - -- **Entity Designer** – Build custom fields, validations, and display ordering for each entity type. -- **JSONB Storage** – Field definitions stored as JSONB, no database migrations needed for schema changes. -- **Multi-Language Support** – Language tabs in forms, override-only storage for secondary languages, lazy re-keying on primary language change. Driven globally by the Languages module. -- **Language-Aware API** – All list/get functions accept an optional `lang:` option to return translated fields resolved for a specific language. -- **Record Ordering** – Per-entity sort mode (auto by creation date or manual by position). Manual mode supports drag-and-drop reordering via the `position` column (V81 migration). -- **Data Navigator** – Browse, search, and filter entity data with status filters and archive/restore workflow. -- **Collaborative Editing** – Presence helpers in entity_form and data_form prevent overwrites when multiple admins edit the same record. -- **Settings Guardrails** – Module can be toggled on/off via PhoenixKit Settings (`entities_enabled`). -- **Event Broadcasting** – Hooks integrate with `PhoenixKit.Modules.Entities.Events` for lifecycle tracking. - -## Integration Points - -- Context modules: `PhoenixKit.Modules.Entities`, `PhoenixKit.Modules.Entities.EntityData`, `PhoenixKit.Modules.Entities.FieldTypes`. -- Multilang module: `PhoenixKit.Modules.Entities.Multilang` – pure-function helpers for multilang JSONB. -- Supporting modules: `PhoenixKit.Modules.Entities.Events`, `PhoenixKit.Modules.Entities.PresenceHelpers`. -- Languages integration: multilang is auto-enabled when `PhoenixKit.Modules.Languages` has 2+ enabled languages. -- Enabling flag: `PhoenixKit.Settings.get_setting("entities_enabled", "false")`. -- Router: available under `{prefix}/admin/entities/*` via `phoenix_kit_routes()`. - -## Customizing the Data View - -The admin route `/admin/entities/:entity_slug/data/:id` is handled by -`PhoenixKit.Modules.Entities.Web.DataView`. To replace it with your own LiveView, -declare a route at the same path **before** `phoenix_kit_routes()` in your router: - -```elixir -# In your app's router.ex — MUST be declared before phoenix_kit_routes() -scope "/phoenix_kit", MyAppWeb do - pipe_through [:browser, :phoenix_kit_authenticated] - live "/admin/entities/:entity_slug/data/:id", MyCustomDataView, :show -end - -phoenix_kit_routes() -``` - -Phoenix matches routes in declaration order, so the custom route wins and -`DataView` is never reached. - -## Additional Reading - -- Overview: `OVERVIEW.md` (in this directory) -- Deep dive: `DEEP_DIVE.md` (in this directory) -- Languages module: `lib/modules/languages/README.md` - -Keep this README in sync whenever new submodules or major workflows are added to the Entities -LiveView stack. diff --git a/lib/modules/entities/entities.ex b/lib/modules/entities/entities.ex deleted file mode 100644 index fa80a4766..000000000 --- a/lib/modules/entities/entities.ex +++ /dev/null @@ -1,1315 +0,0 @@ -defmodule PhoenixKit.Modules.Entities do - @moduledoc """ - Dynamic entity system for PhoenixKit. - - This module provides both the Ecto schema definition and business logic for - managing custom content types (entities) with flexible field schemas. - - ## Schema Fields - - - `name`: Unique identifier for the entity (e.g., "brand", "product") - - `display_name`: Human-readable singular name shown in UI (e.g., "Brand") - - `display_name_plural`: Human-readable plural name (e.g., "Brands") - - `description`: Description of what this entity represents - - `icon`: Icon identifier for UI display (hero icons) - - `status`: Workflow status string - one of "draft", "published", or "archived" - - `fields_definition`: JSONB array of field definitions - - `settings`: JSONB map of entity-specific settings - - `created_by`: User ID of the admin who created the entity - - `date_created`: When the entity was created - - `date_updated`: When the entity was last modified - - ## Field Definition Structure - - Each field in `fields_definition` is a map with: - - `type`: Field type (text, textarea, number, boolean, date, select, etc.) - - `key`: Unique field identifier (snake_case) - - `label`: Display label for the field - - `required`: Whether the field is required - - `default`: Default value - - `validation`: Map of validation rules - - `options`: Array of options (for select, radio, checkbox types) - - ## Core Functions - - ### Entity Management - - `list_entities/0` - Get all entities - - `list_active_entities/0` - Get only active entities - - `get_entity!/1` - Get an entity by ID (raises if not found) - - `get_entity_by_name/1` - Get an entity by its name - - `create_entity/1` - Create a new entity - - `update_entity/2` - Update an existing entity - - `delete_entity/1` - Delete an entity (and all its data) - - `change_entity/2` - Get changeset for forms - - ### System Settings - - `enabled?/0` - Check if entities system is enabled - - `enable_system/0` - Enable the entities system - - `disable_system/0` - Disable the entities system - - `get_config/0` - Get current system configuration - - `get_max_per_user/0` - Get max entities per user limit - - `validate_user_entity_limit/1` - Check if user can create more entities - - ## Usage Examples - - # Check if system is enabled - if PhoenixKit.Modules.Entities.enabled?() do - # System is active - end - - # Create a brand entity - # Note: fields_definition requires string keys, not atom keys - {:ok, entity} = PhoenixKit.Modules.Entities.create_entity(%{ - name: "brand", - display_name: "Brand", - display_name_plural: "Brands", - description: "Brand content type for company profiles", - icon: "hero-building-office", - created_by_uuid: admin_user.uuid, - fields_definition: [ - %{"type" => "text", "key" => "name", "label" => "Name", "required" => true}, - %{"type" => "textarea", "key" => "tagline", "label" => "Tagline"}, - %{"type" => "rich_text", "key" => "description", "label" => "Description", "required" => true}, - %{"type" => "select", "key" => "industry", "label" => "Industry", - "options" => ["Technology", "Manufacturing", "Retail"]}, - %{"type" => "date", "key" => "founded_date", "label" => "Founded Date"}, - %{"type" => "boolean", "key" => "featured", "label" => "Featured Brand"} - ] - }) - - # Get entity by name - entity = PhoenixKit.Modules.Entities.get_entity_by_name("brand") - - # List all active entities - entities = PhoenixKit.Modules.Entities.list_active_entities() - """ - - use Ecto.Schema - use PhoenixKit.Module - - import Ecto.Changeset - import Ecto.Query, warn: false - - alias PhoenixKit.Dashboard.Tab - alias PhoenixKit.Modules.Entities.EntityData - alias PhoenixKit.Modules.Entities.Events - alias PhoenixKit.Modules.Entities.Mirror.Exporter - alias PhoenixKit.Modules.Entities.Mirror.Storage - alias PhoenixKit.Modules.Entities.Multilang - alias PhoenixKit.Settings - alias PhoenixKit.Users.Auth - alias PhoenixKit.Users.Auth.User - alias PhoenixKit.Utils.Date, as: UtilsDate - alias PhoenixKit.Utils.UUID, as: UUIDUtils - @type t :: %__MODULE__{} - - @primary_key {:uuid, UUIDv7, autogenerate: true} - @valid_statuses ~w(draft published archived) - - @derive {Jason.Encoder, - only: [ - :uuid, - :name, - :display_name, - :display_name_plural, - :description, - :icon, - :status, - :fields_definition, - :settings, - :date_created, - :date_updated - ]} - - schema "phoenix_kit_entities" do - field :name, :string - field :display_name, :string - field :display_name_plural, :string - field :description, :string - field :icon, :string - field :status, :string, default: "published" - field :fields_definition, {:array, :map} - field :settings, :map - field :created_by_uuid, UUIDv7 - field :date_created, :utc_datetime - field :date_updated, :utc_datetime - - belongs_to :creator, User, - foreign_key: :created_by_uuid, - references: :uuid, - define_field: false, - type: UUIDv7 - - has_many :entity_data, PhoenixKit.Modules.Entities.EntityData, - foreign_key: :entity_uuid, - references: :uuid - end - - @doc """ - Creates a changeset for entity creation and updates. - - Validates that name is unique, fields_definition is valid, and all required fields are present. - Automatically sets date_created on new records. - """ - def changeset(entity, attrs) do - entity - |> cast(attrs, [ - :name, - :display_name, - :display_name_plural, - :description, - :icon, - :status, - :fields_definition, - :settings, - :created_by_uuid, - :date_created, - :date_updated - ]) - |> validate_required([:name, :display_name, :display_name_plural]) - |> validate_creator_reference() - |> validate_length(:name, min: 2, max: 50) - |> validate_length(:display_name, min: 2, max: 100) - |> validate_length(:display_name_plural, min: 2, max: 100) - |> validate_length(:description, max: 500) - |> validate_inclusion(:status, @valid_statuses) - |> validate_format(:name, ~r/^[a-z][a-z0-9_]*$/, - message: - "must start with a letter and contain only lowercase letters, numbers, and underscores" - ) - |> validate_name_uniqueness() - |> validate_fields_definition() - |> unique_constraint(:name) - |> maybe_set_timestamps() - end - - defp validate_creator_reference(changeset) do - created_by_uuid = get_field(changeset, :created_by_uuid) - - if is_nil(created_by_uuid) do - add_error( - changeset, - :created_by_uuid, - "created_by_uuid must be present" - ) - else - changeset - end - end - - defp validate_name_uniqueness(changeset) do - case get_field(changeset, :name) do - nil -> - changeset - - "" -> - changeset - - name -> - case get_entity_by_name(name) do - nil -> - changeset - - existing_entity -> - current_uuid = get_field(changeset, :uuid) - - if current_uuid && existing_entity.uuid == current_uuid do - changeset - else - add_error(changeset, :name, "has already been taken") - end - end - end - end - - defp validate_fields_definition(changeset) do - case get_field(changeset, :fields_definition) do - nil -> - put_change(changeset, :fields_definition, []) - - fields when is_list(fields) -> - validate_each_field_definition(changeset, fields) - - _invalid -> - add_error(changeset, :fields_definition, "must be a list of field definitions") - end - end - - defp validate_each_field_definition(changeset, fields) do - Enum.reduce(fields, changeset, fn field, acc -> - validate_single_field_definition(acc, field) - end) - end - - defp validate_single_field_definition(changeset, field) when is_map(field) do - required_keys = ["type", "key", "label"] - missing_keys = required_keys -- Map.keys(field) - - if Enum.empty?(missing_keys) do - validate_field_type(changeset, field) - else - add_error( - changeset, - :fields_definition, - "field missing required keys: #{Enum.join(missing_keys, ", ")}" - ) - end - end - - defp validate_single_field_definition(changeset, _invalid) do - add_error(changeset, :fields_definition, "each field must be a map") - end - - defp validate_field_type(changeset, field) do - valid_types = - ~w(text textarea number boolean date email url select radio checkbox rich_text image file relation) - - if field["type"] in valid_types do - changeset - else - add_error( - changeset, - :fields_definition, - "invalid field type '#{field["type"]}' for field '#{field["key"]}'" - ) - end - end - - defp maybe_set_timestamps(changeset) do - now = UtilsDate.utc_now() - - case changeset.data.__meta__.state do - :built -> - changeset - |> put_change(:date_created, now) - |> put_change(:date_updated, now) - - :loaded -> - put_change(changeset, :date_updated, now) - end - end - - defp notify_entity_event({:ok, %__MODULE__{} = entity}, :created) do - Events.broadcast_entity_created(entity.uuid) - maybe_mirror_entity(entity) - {:ok, entity} - end - - defp notify_entity_event({:ok, %__MODULE__{} = entity}, :updated) do - Events.broadcast_entity_updated(entity.uuid) - maybe_mirror_entity(entity) - {:ok, entity} - end - - defp notify_entity_event({:ok, %__MODULE__{} = entity}, :deleted) do - Events.broadcast_entity_deleted(entity.uuid) - maybe_delete_mirrored_entity(entity) - {:ok, entity} - end - - defp notify_entity_event(result, _event), do: result - - # Mirror export helpers for auto-sync (per-entity settings) - defp maybe_mirror_entity(entity) do - if mirror_definitions_enabled?(entity) do - Task.start(fn -> Exporter.export_entity(entity) end) - end - end - - defp maybe_delete_mirrored_entity(entity) do - # Delete the file if it exists (regardless of current setting) - # This ensures cleanup when entity is deleted - if Storage.entity_exists?(entity.name) do - Task.start(fn -> - Storage.delete_entity(entity.name) - end) - end - end - - @doc """ - Returns the list of entities ordered by creation date. - - ## Examples - - iex> PhoenixKit.Modules.Entities.list_entities() - [%PhoenixKit.Entities{}, ...] - """ - def list_entities(opts \\ []) do - __MODULE__ - |> order_by([e], desc: e.date_created) - |> preload([:creator]) - |> repo().all() - |> maybe_resolve_langs(opts) - end - - @doc """ - Returns the list of active (published) entities. - - ## Examples - - iex> PhoenixKit.Modules.Entities.list_active_entities() - [%PhoenixKit.Entities{status: "published"}, ...] - """ - def list_active_entities(opts \\ []) do - from(e in __MODULE__, - where: e.status == "published", - order_by: [desc: e.date_created], - preload: [:creator] - ) - |> repo().all() - |> maybe_resolve_langs(opts) - end - - @doc """ - Returns a lightweight list of published entity summaries for sidebar display. - - Selects only sidebar-relevant fields without preloading associations. - """ - @spec list_entity_summaries() :: [map()] - def list_entity_summaries do - from(e in __MODULE__, - where: e.status == "published", - order_by: [desc: e.date_created], - select: %{ - name: e.name, - display_name: e.display_name, - display_name_plural: e.display_name_plural, - icon: e.icon - } - ) - |> repo().all() - end - - @doc """ - Gets a single entity by integer ID or UUID. - - Returns the entity if found, nil otherwise. - - Accepts: - - Integer ID (e.g., 123) - - UUID string (e.g., "550e8400-e29b-41d4-a716-446655440000") - - Integer string (e.g., "123") - - ## Examples - - iex> PhoenixKit.Modules.Entities.get_entity(123) - %PhoenixKit.Entities{} - - iex> PhoenixKit.Modules.Entities.get_entity("550e8400-e29b-41d4-a716-446655440000") - %PhoenixKit.Entities{} - - iex> PhoenixKit.Modules.Entities.get_entity(456) - nil - """ - def get_entity(uuid, opts \\ []) - - def get_entity(uuid, opts) when is_binary(uuid) do - if UUIDUtils.valid?(uuid) do - case repo().get_by(__MODULE__, uuid: uuid) do - nil -> nil - entity -> entity |> repo().preload(:creator) |> maybe_resolve_lang(opts) - end - else - nil - end - end - - def get_entity(_, _opts), do: nil - - @doc """ - Gets a single entity by integer ID or UUID. - - Raises `Ecto.NoResultsError` if the entity does not exist. - - ## Examples - - iex> PhoenixKit.Modules.Entities.get_entity!(123) - %PhoenixKit.Entities{} - - iex> PhoenixKit.Modules.Entities.get_entity!(456) - ** (Ecto.NoResultsError) - """ - def get_entity!(id, opts \\ []) do - case get_entity(id, opts) do - nil -> raise Ecto.NoResultsError, queryable: __MODULE__ - entity -> entity - end - end - - @doc """ - Gets a single entity by its unique name. - - Returns the entity if found, nil otherwise. - - ## Examples - - iex> PhoenixKit.Modules.Entities.get_entity_by_name("brand") - %PhoenixKit.Entities{} - - iex> PhoenixKit.Modules.Entities.get_entity_by_name("invalid") - nil - """ - def get_entity_by_name(name, opts \\ []) when is_binary(name) do - case repo().get_by(__MODULE__, name: name) do - nil -> nil - entity -> maybe_resolve_lang(entity, opts) - end - end - - @doc """ - Creates an entity. - - ## Examples - - iex> PhoenixKit.Modules.Entities.create_entity(%{name: "brand", display_name: "Brand"}) - {:ok, %PhoenixKit.Entities{}} - - iex> PhoenixKit.Modules.Entities.create_entity(%{name: ""}) - {:error, %Ecto.Changeset{}} - - Note: `created_by` is auto-filled with the first admin or user ID if not provided, - but only if at least one user exists in the system. If no users exist, the changeset - will fail with a validation error on `created_by`. - """ - def create_entity(attrs \\ %{}) do - attrs = maybe_add_created_by(attrs) - - %__MODULE__{} - |> changeset(attrs) - |> repo().insert() - |> notify_entity_event(:created) - end - - # Auto-fill created_by_uuid with first admin if not provided - defp maybe_add_created_by(attrs) when is_map(attrs) do - has_created_by_uuid = - Map.has_key?(attrs, :created_by_uuid) or Map.has_key?(attrs, "created_by_uuid") - - if has_created_by_uuid do - attrs - else - case Auth.get_first_admin_uuid() do - nil -> - # Fall back to first user if no admin exists - case Auth.get_first_user_uuid() do - nil -> attrs - user_uuid -> Map.put(attrs, :created_by_uuid, user_uuid) - end - - admin_uuid -> - Map.put(attrs, :created_by_uuid, admin_uuid) - end - end - end - - @doc """ - Updates an entity. - - ## Examples - - iex> PhoenixKit.Modules.Entities.update_entity(entity, %{display_name: "Updated"}) - {:ok, %PhoenixKit.Entities{}} - - iex> PhoenixKit.Modules.Entities.update_entity(entity, %{name: ""}) - {:error, %Ecto.Changeset{}} - """ - def update_entity(%__MODULE__{} = entity, attrs) do - entity - |> changeset(attrs) - |> repo().update() - |> notify_entity_event(:updated) - end - - @doc """ - Deletes an entity. - - Note: This will also delete all associated entity_data records due to the - ON DELETE CASCADE constraint defined in the database migration (V17). - - ## Examples - - iex> PhoenixKit.Modules.Entities.delete_entity(entity) - {:ok, %PhoenixKit.Entities{}} - - iex> PhoenixKit.Modules.Entities.delete_entity(entity) - {:error, %Ecto.Changeset{}} - """ - def delete_entity(%__MODULE__{} = entity) do - repo().delete(entity) - |> notify_entity_event(:deleted) - end - - @doc """ - Returns an `%Ecto.Changeset{}` for tracking entity changes. - - ## Examples - - iex> PhoenixKit.Modules.Entities.change_entity(entity) - %Ecto.Changeset{data: %PhoenixKit.Entities{}} - """ - def change_entity(%__MODULE__{} = entity, attrs \\ %{}) do - changeset(entity, attrs) - end - - @doc """ - Gets summary statistics for the entities system. - - Returns counts and metrics useful for admin dashboards. - - ## Examples - - iex> PhoenixKit.Modules.Entities.get_system_stats() - %{total_entities: 5, active_entities: 4, total_data_records: 150} - """ - def get_system_stats do - entities_query = from(e in __MODULE__) - data_query = from(d in PhoenixKit.Modules.Entities.EntityData) - - total_entities = repo().aggregate(entities_query, :count) - - active_entities = - repo().aggregate(from(e in entities_query, where: e.status == "published"), :count) - - total_data_records = repo().aggregate(data_query, :count) - - %{ - total_entities: total_entities, - active_entities: active_entities, - total_data_records: total_data_records - } - end - - @doc """ - Counts the total number of entities created by a user. - - ## Examples - - iex> PhoenixKit.Modules.Entities.count_user_entities(1) - 5 - """ - def count_user_entities(user_uuid) when is_binary(user_uuid) do - from(e in __MODULE__, where: e.created_by_uuid == ^user_uuid, select: count(e.uuid)) - |> repo().one() - end - - @doc """ - Counts the total number of entities in the system. - - ## Examples - - iex> PhoenixKit.Modules.Entities.count_entities() - 15 - """ - def count_entities do - from(e in __MODULE__, select: count(e.uuid)) - |> repo().one() - end - - @doc """ - Counts the total number of entity data records across all entities. - - ## Examples - - iex> PhoenixKit.Modules.Entities.count_all_entity_data() - 243 - """ - def count_all_entity_data do - from(d in PhoenixKit.Modules.Entities.EntityData, select: count(d.uuid)) - |> repo().one() - end - - @doc """ - Validates that a user hasn't exceeded their entity creation limit. - - Checks the current number of entities created by the user against the system limit. - Returns `{:ok, :valid}` if within limits, `{:error, reason}` if limit exceeded. - - ## Examples - - iex> PhoenixKit.Modules.Entities.validate_user_entity_limit(1) - {:ok, :valid} - - iex> PhoenixKit.Modules.Entities.validate_user_entity_limit(1) - {:error, "You have reached the maximum limit of 100 entities"} - """ - def validate_user_entity_limit(user_uuid) when is_binary(user_uuid) do - max_entities = get_max_per_user() - current_count = count_user_entities(user_uuid) - - if current_count < max_entities do - {:ok, :valid} - else - {:error, "You have reached the maximum limit of #{max_entities} entities"} - end - end - - @impl PhoenixKit.Module - @doc """ - Checks if the entities system is enabled. - - Returns true if the "entities_enabled" setting is true. - - ## Examples - - iex> PhoenixKit.Modules.Entities.enabled?() - false - """ - def enabled? do - Settings.get_boolean_setting("entities_enabled", false) - end - - @impl PhoenixKit.Module - @doc """ - Enables the entities system. - - Sets the "entities_enabled" setting to true. - - ## Examples - - iex> PhoenixKit.Modules.Entities.enable_system() - {:ok, %Setting{}} - """ - def enable_system do - Settings.update_boolean_setting_with_module("entities_enabled", true, "entities") - end - - @impl PhoenixKit.Module - @doc """ - Disables the entities system. - - Sets the "entities_enabled" setting to false. - - ## Examples - - iex> PhoenixKit.Modules.Entities.disable_system() - {:ok, %Setting{}} - """ - def disable_system do - Settings.update_boolean_setting_with_module("entities_enabled", false, "entities") - end - - @doc """ - Gets the maximum number of entities a single user can create. - - Returns the system-wide limit for entity creation per user. - Defaults to 100 if not set. - - ## Examples - - iex> PhoenixKit.Modules.Entities.get_max_per_user() - 100 - """ - def get_max_per_user do - Settings.get_integer_setting("entities_max_per_user", 100) - end - - @impl PhoenixKit.Module - @doc """ - Gets the current entities system configuration. - - Returns a map with the current settings. - - ## Examples - - iex> PhoenixKit.Modules.Entities.get_config() - %{enabled: false, max_per_user: 100, allow_relations: true, file_upload: false, entity_count: 0, total_data_count: 0} - """ - def get_config do - %{ - enabled: enabled?(), - max_per_user: get_max_per_user(), - allow_relations: Settings.get_boolean_setting("entities_allow_relations", true), - file_upload: Settings.get_boolean_setting("entities_file_upload", false), - entity_count: count_entities(), - total_data_count: count_all_entity_data() - } - end - - # ============================================================================ - # Module Behaviour Callbacks - # ============================================================================ - - @impl PhoenixKit.Module - def module_key, do: "entities" - - @impl PhoenixKit.Module - def module_name, do: "Entities" - - @impl PhoenixKit.Module - def permission_metadata do - %{ - key: "entities", - label: "Entities", - icon: "hero-cube-transparent", - description: "Dynamic content types and custom data structures" - } - end - - @impl PhoenixKit.Module - def admin_tabs do - [ - Tab.new!( - id: :admin_entities, - label: "Entities", - icon: "hero-cube", - path: "entities", - priority: 540, - level: :admin, - permission: "entities", - match: :prefix, - group: :admin_modules, - subtab_display: :when_active, - highlight_with_subtabs: false, - dynamic_children: &__MODULE__.entities_children/1 - ) - ] - end - - # ETS cache TTL for entity summaries (30 seconds) - @entities_cache_ttl_ms 30_000 - @entities_cache_key :entities_children_cache - - @doc """ - Invalidates the cached entity summaries in the Dashboard Registry's ETS table. - Called when entity lifecycle PubSub events are received. - """ - @spec invalidate_entities_cache() :: :ok - def invalidate_entities_cache do - alias PhoenixKit.Dashboard.Registry, as: DashboardRegistry - - if DashboardRegistry.initialized?() do - :ets.delete(DashboardRegistry.ets_table(), @entities_cache_key) - end - - :ok - end - - @doc "Dynamic children function for Entities sidebar tabs." - def entities_children(_scope) do - cached_entity_summaries() - |> Enum.with_index() - |> Enum.map(fn {entity, idx} -> - %Tab{ - id: - String.to_atom( - "admin_entity_#{entity.name}_#{:erlang.phash2(entity.name) |> Integer.to_string(16) |> String.downcase()}" - ), - label: entity.display_name_plural || entity.display_name, - icon: entity.icon || "hero-cube", - path: "entities/#{entity.name}/data", - priority: 541 + idx, - level: :admin, - permission: "entities", - match: :prefix, - parent: :admin_entities - } - end) - rescue - _ -> [] - end - - defp cached_entity_summaries do - alias PhoenixKit.Dashboard.Registry, as: DashboardRegistry - - if DashboardRegistry.initialized?() do - case :ets.lookup(DashboardRegistry.ets_table(), @entities_cache_key) do - [{@entities_cache_key, entities, timestamp}] - when is_integer(timestamp) -> - if System.monotonic_time(:millisecond) - timestamp < @entities_cache_ttl_ms do - entities - else - fetch_and_cache_entities() - end - - _ -> - fetch_and_cache_entities() - end - else - list_entity_summaries() - end - end - - defp fetch_and_cache_entities do - alias PhoenixKit.Dashboard.Registry, as: DashboardRegistry - entities = list_entity_summaries() - - if DashboardRegistry.initialized?() do - :ets.insert( - DashboardRegistry.ets_table(), - {@entities_cache_key, entities, System.monotonic_time(:millisecond)} - ) - end - - entities - end - - @impl PhoenixKit.Module - def settings_tabs do - [ - Tab.new!( - id: :admin_settings_entities, - label: "Entities", - icon: "hero-cube", - path: "entities", - priority: 935, - level: :admin, - parent: :admin_settings, - permission: "entities", - match: :prefix - ) - ] - end - - @impl PhoenixKit.Module - def children, do: [PhoenixKit.Modules.Entities.Presence] - - # ============================================================================ - # Sort Mode Settings - # ============================================================================ - - @valid_sort_modes ~w(auto manual) - - @doc """ - Gets the sort mode for an entity. - - Returns `"auto"` (sort by creation date, default) or `"manual"` (sort by position). - - ## Examples - - iex> PhoenixKit.Modules.Entities.get_sort_mode(entity) - "auto" - """ - def get_sort_mode(%__MODULE__{settings: settings}) do - (settings || %{}) |> Map.get("sort_mode", "auto") - end - - @doc """ - Gets the sort mode for an entity by UUID. - - Convenience wrapper that looks up the entity first. - Returns `"auto"` if the entity is not found. - - ## Examples - - iex> PhoenixKit.Modules.Entities.get_sort_mode_by_uuid(entity_uuid) - "manual" - """ - def get_sort_mode_by_uuid(entity_uuid) when is_binary(entity_uuid) do - case get_entity(entity_uuid) do - nil -> "auto" - entity -> get_sort_mode(entity) - end - end - - @doc """ - Checks if an entity uses manual sorting. - - ## Examples - - iex> PhoenixKit.Modules.Entities.manual_sort?(entity) - true - """ - def manual_sort?(%__MODULE__{} = entity), do: get_sort_mode(entity) == "manual" - - @doc """ - Updates the sort mode for an entity. - - Valid modes: `"auto"` (sort by creation date) or `"manual"` (sort by position). - - When switching to manual mode, existing records retain their auto-populated - positions from creation order. Admins can then reorder as needed. - - ## Examples - - iex> PhoenixKit.Modules.Entities.update_sort_mode(entity, "manual") - {:ok, %PhoenixKit.Modules.Entities{}} - """ - def update_sort_mode(%__MODULE__{} = entity, mode) when mode in @valid_sort_modes do - current_settings = entity.settings || %{} - new_settings = Map.put(current_settings, "sort_mode", mode) - update_entity(entity, %{settings: new_settings}) - end - - # ============================================================================ - # Per-Entity Mirror Settings - # ============================================================================ - - @doc """ - Gets the mirror settings for an entity. - - Returns a map with mirror_definitions and mirror_data booleans. - Defaults to false if not explicitly set. - - ## Examples - - iex> PhoenixKit.Modules.Entities.get_mirror_settings(entity) - %{mirror_definitions: true, mirror_data: false} - """ - def get_mirror_settings(%__MODULE__{settings: settings}) do - settings = settings || %{} - - %{ - mirror_definitions: Map.get(settings, "mirror_definitions", false), - mirror_data: Map.get(settings, "mirror_data", false) - } - end - - @doc """ - Checks if definition mirroring is enabled for this entity. - - ## Examples - - iex> PhoenixKit.Modules.Entities.mirror_definitions_enabled?(entity) - true - """ - def mirror_definitions_enabled?(%__MODULE__{settings: settings}) do - settings = settings || %{} - Map.get(settings, "mirror_definitions", false) == true - end - - @doc """ - Checks if data mirroring is enabled for this entity. - - ## Examples - - iex> PhoenixKit.Modules.Entities.mirror_data_enabled?(entity) - false - """ - def mirror_data_enabled?(%__MODULE__{settings: settings}) do - settings = settings || %{} - Map.get(settings, "mirror_data", false) == true - end - - @doc """ - Updates the mirror settings for an entity. - - ## Parameters - - `entity` - The entity to update - - `mirror_settings` - Map with keys "mirror_definitions" and/or "mirror_data" - - ## Examples - - iex> PhoenixKit.Modules.Entities.update_mirror_settings(entity, %{"mirror_definitions" => true}) - {:ok, %PhoenixKit.Entities{}} - """ - def update_mirror_settings(%__MODULE__{} = entity, mirror_settings) - when is_map(mirror_settings) do - current_settings = entity.settings || %{} - new_settings = Map.merge(current_settings, mirror_settings) - update_entity(entity, %{settings: new_settings}) - end - - # ============================================================================ - @doc """ - Lists all entities with their mirror status and data counts. - - Returns a list of maps suitable for the settings UI. - - ## Examples - - iex> PhoenixKit.Modules.Entities.list_entities_with_mirror_status() - [%{id: 1, name: "test", display_name: "Test", data_count: 8, mirror_definitions: true, mirror_data: false}, ...] - """ - def list_entities_with_mirror_status do - entities = list_entities() - - Enum.map(entities, fn entity -> - mirror_settings = get_mirror_settings(entity) - data_count = EntityData.count_by_entity(entity.uuid) - file_exists = Storage.entity_exists?(entity.name) - - %{ - uuid: entity.uuid, - name: entity.name, - display_name: entity.display_name, - data_count: data_count, - mirror_definitions: mirror_settings.mirror_definitions, - mirror_data: mirror_settings.mirror_data, - file_exists: file_exists - } - end) - end - - @doc """ - Enables definition mirroring for all entities. - - ## Examples - - iex> PhoenixKit.Modules.Entities.enable_all_definitions_mirror() - {:ok, count} - """ - def enable_all_definitions_mirror do - entities = list_entities() - - results = - Enum.map(entities, fn entity -> - update_mirror_settings(entity, %{"mirror_definitions" => true}) - end) - - success_count = Enum.count(results, &match?({:ok, _}, &1)) - {:ok, success_count} - end - - @doc """ - Disables definition mirroring for all entities. - - ## Examples - - iex> PhoenixKit.Modules.Entities.disable_all_definitions_mirror() - {:ok, count} - """ - def disable_all_definitions_mirror do - entities = list_entities() - - results = - Enum.map(entities, fn entity -> - update_mirror_settings(entity, %{"mirror_definitions" => false}) - end) - - success_count = Enum.count(results, &match?({:ok, _}, &1)) - {:ok, success_count} - end - - @doc """ - Enables data mirroring for all entities. - - ## Examples - - iex> PhoenixKit.Modules.Entities.enable_all_data_mirror() - {:ok, count} - """ - def enable_all_data_mirror do - entities = list_entities() - - results = - Enum.map(entities, fn entity -> - update_mirror_settings(entity, %{"mirror_data" => true}) - end) - - success_count = Enum.count(results, &match?({:ok, _}, &1)) - {:ok, success_count} - end - - @doc """ - Disables data mirroring for all entities. - - ## Examples - - iex> PhoenixKit.Modules.Entities.disable_all_data_mirror() - {:ok, count} - """ - def disable_all_data_mirror do - entities = list_entities() - - results = - Enum.map(entities, fn entity -> - update_mirror_settings(entity, %{"mirror_data" => false}) - end) - - success_count = Enum.count(results, &match?({:ok, _}, &1)) - {:ok, success_count} - end - - # ============================================================================ - # Translation convenience API - # ============================================================================ - - @doc """ - Gets all translations for an entity definition. - - Returns a map of language codes to translated fields. - Only includes languages that have at least one translated field. - - ## Examples - - iex> get_entity_translations(entity) - %{ - "es-ES" => %{"display_name" => "Productos", "display_name_plural" => "Productos"}, - "fr-FR" => %{"display_name" => "Produits"} - } - - iex> get_entity_translations(entity_without_translations) - %{} - """ - def get_entity_translations(%__MODULE__{settings: settings}) do - (settings || %{}) - |> Map.get("translations", %{}) - end - - @doc """ - Gets the translation for a specific language on an entity definition. - - Returns the translated fields merged with the primary language values - as defaults. Returns primary language values if no translation exists. - - ## Examples - - iex> get_entity_translation(entity, "es-ES") - %{"display_name" => "Productos", "display_name_plural" => "Productos", "description" => "..."} - """ - def get_entity_translation(%__MODULE__{} = entity, lang_code) when is_binary(lang_code) do - primary = %{ - "display_name" => entity.display_name, - "display_name_plural" => entity.display_name_plural, - "description" => entity.description - } - - translations = get_entity_translations(entity) - lang_overrides = Map.get(translations, lang_code, %{}) - - Map.merge(primary, lang_overrides) - end - - @doc """ - Sets the translation for a specific language on an entity definition. - - Merges the provided fields into the existing translation for that language. - Empty string values are treated as "remove override" (field falls back to primary). - - ## Examples - - iex> set_entity_translation(entity, "es-ES", %{ - ...> "display_name" => "Productos", - ...> "display_name_plural" => "Productos" - ...> }) - {:ok, %PhoenixKit.Modules.Entities{}} - """ - def set_entity_translation(%__MODULE__{} = entity, lang_code, attrs) - when is_binary(lang_code) and is_map(attrs) do - current_settings = entity.settings || %{} - translations = Map.get(current_settings, "translations", %{}) - - existing = Map.get(translations, lang_code, %{}) - merged = Map.merge(existing, attrs) - - # Remove empty values (fall back to primary) - cleaned = - merged - |> Enum.reject(fn {_k, v} -> is_nil(v) or v == "" end) - |> Map.new() - - updated_translations = - if map_size(cleaned) == 0 do - Map.delete(translations, lang_code) - else - Map.put(translations, lang_code, cleaned) - end - - new_settings = - if map_size(updated_translations) == 0 do - Map.delete(current_settings, "translations") - else - Map.put(current_settings, "translations", updated_translations) - end - - update_entity(entity, %{settings: new_settings}) - end - - @doc """ - Removes all translations for a specific language from an entity definition. - - ## Examples - - iex> remove_entity_translation(entity, "es-ES") - {:ok, %PhoenixKit.Modules.Entities{}} - """ - def remove_entity_translation(%__MODULE__{} = entity, lang_code) - when is_binary(lang_code) do - current_settings = entity.settings || %{} - translations = Map.get(current_settings, "translations", %{}) - updated = Map.delete(translations, lang_code) - - new_settings = - if map_size(updated) == 0 do - Map.delete(current_settings, "translations") - else - Map.put(current_settings, "translations", updated) - end - - update_entity(entity, %{settings: new_settings}) - end - - @doc """ - Checks if multilang is globally enabled (Languages module has 2+ languages). - - Convenience wrapper around `Multilang.enabled?/0`. - - ## Examples - - iex> PhoenixKit.Modules.Entities.multilang_enabled?() - true - """ - def multilang_enabled?, do: Multilang.enabled?() - - # ============================================================================ - # Language-aware API - # ============================================================================ - - @doc """ - Resolves translated fields on an entity struct for a given language. - - Merges translations from `settings["translations"][lang_code]` onto the - entity's `display_name`, `display_name_plural`, and `description` fields. - - For the primary language (or when no translation exists), returns the entity - unchanged. For secondary languages, applies override values where they exist - and keeps primary values as defaults. - - ## Examples - - iex> resolve_language(entity, "es-ES") - %PhoenixKit.Modules.Entities{display_name: "Productos", ...} - - iex> resolve_language(entity, "en-US") # primary language - %PhoenixKit.Modules.Entities{display_name: "Products", ...} - """ - @spec resolve_language(t(), String.t()) :: t() - def resolve_language(%__MODULE__{} = entity, lang_code) when is_binary(lang_code) do - translation = get_entity_translation(entity, lang_code) - - entity - |> maybe_apply_translation(:display_name, translation["display_name"]) - |> maybe_apply_translation(:display_name_plural, translation["display_name_plural"]) - |> maybe_apply_translation(:description, translation["description"]) - end - - defp maybe_apply_translation(entity, _field, nil), do: entity - defp maybe_apply_translation(entity, _field, ""), do: entity - - defp maybe_apply_translation(entity, field, value) do - Map.put(entity, field, value) - end - - @doc """ - Resolves translations on a list of entity structs. - - ## Examples - - iex> resolve_languages(entities, "es-ES") - [%PhoenixKit.Modules.Entities{display_name: "Productos"}, ...] - """ - @spec resolve_languages([t()], String.t()) :: [t()] - def resolve_languages(entities, lang_code) when is_list(entities) and is_binary(lang_code) do - Enum.map(entities, &resolve_language(&1, lang_code)) - end - - # Applies :lang option to a single entity if present in opts - defp maybe_resolve_lang(entity, opts) when is_list(opts) do - case Keyword.get(opts, :lang) do - nil -> entity - lang -> resolve_language(entity, lang) - end - end - - # Applies :lang option to a list of entities if present in opts - defp maybe_resolve_langs(entities, opts) when is_list(entities) and is_list(opts) do - case Keyword.get(opts, :lang) do - nil -> entities - lang -> resolve_languages(entities, lang) - end - end - - defp repo do - PhoenixKit.RepoHelper.repo() - end -end diff --git a/lib/modules/entities/entity_data.ex b/lib/modules/entities/entity_data.ex deleted file mode 100644 index 80f29937d..000000000 --- a/lib/modules/entities/entity_data.ex +++ /dev/null @@ -1,1392 +0,0 @@ -defmodule PhoenixKit.Modules.Entities.EntityData do - @moduledoc """ - Entity data records for PhoenixKit entities system. - - This module manages actual data records that follow entity blueprints. - Each record is associated with an entity type and stores its field values - in a JSONB column for flexibility. - - ## Schema Fields - - - `entity_uuid`: Foreign key to the entity blueprint - - `title`: Display title/name for the record - - `slug`: URL-friendly identifier (optional) - - `status`: Record status ("draft", "published", "archived") - - `data`: JSONB map of all field values based on entity definition - - `metadata`: JSONB map for additional information (tags, categories, etc.) - - `created_by`: User UUID who created the record - - `date_created`: When the record was created - - `date_updated`: When the record was last modified - - ## Core Functions - - ### Data Management - - `list_all/0` - Get all entity data records - - `list_by_entity/1` - Get all records for a specific entity - - `list_by_entity_and_status/2` - Filter records by entity and status - - `get!/1` - Get a record by ID (raises if not found) - - `get_by_slug/2` - Get a record by entity and slug - - `create/1` - Create a new record - - `update/2` - Update an existing record - - `delete/1` - Delete a record - - `change/2` - Get changeset for forms - - ### Query Helpers - - `search_by_title/2` - Search records by title - - `filter_by_status/1` - Get records by status - - `count_by_entity/1` - Count records for an entity - - `published_records/1` - Get all published records for an entity - - ## Usage Examples - - # Create a brand data record - {:ok, data} = PhoenixKit.Modules.Entities.EntityData.create(%{ - entity_uuid: brand_entity.uuid, - title: "Acme Corporation", - slug: "acme-corporation", - status: "published", - created_by_uuid: user.uuid, - data: %{ - "name" => "Acme Corporation", - "tagline" => "Quality products since 1950", - "description" => "

Leading manufacturer of innovative products

", - "industry" => "Manufacturing", - "founded_date" => "1950-03-15", - "featured" => true - }, - metadata: %{ - "tags" => ["manufacturing", "industrial"], - "contact_email" => "info@acme.com" - } - }) - - # Get all records for an entity - records = PhoenixKit.Modules.Entities.EntityData.list_by_entity(brand_entity.uuid) - - # Search by title - results = PhoenixKit.Modules.Entities.EntityData.search_by_title("Acme", brand_entity.uuid) - """ - - use Ecto.Schema - use Gettext, backend: PhoenixKitWeb.Gettext - import Ecto.Changeset - import Ecto.Query, warn: false - - alias PhoenixKit.Modules.Entities - alias PhoenixKit.Modules.Entities.Events - alias PhoenixKit.Modules.Entities.HtmlSanitizer - alias PhoenixKit.Modules.Entities.Mirror.Exporter - alias PhoenixKit.Modules.Entities.Multilang - alias PhoenixKit.Users.Auth - alias PhoenixKit.Users.Auth.User - alias PhoenixKit.Utils.Date, as: UtilsDate - alias PhoenixKit.Utils.UUID, as: UUIDUtils - @type t :: %__MODULE__{} - - @primary_key {:uuid, UUIDv7, autogenerate: true} - - @derive {Jason.Encoder, - only: [ - :uuid, - :title, - :slug, - :status, - :position, - :data, - :metadata, - :date_created, - :date_updated - ]} - - schema "phoenix_kit_entity_data" do - field :title, :string - field :slug, :string - field :status, :string, default: "published" - field :data, :map - field :metadata, :map - field :position, :integer - field :created_by_uuid, UUIDv7 - field :date_created, :utc_datetime - field :date_updated, :utc_datetime - - belongs_to :entity, Entities, foreign_key: :entity_uuid, references: :uuid, type: UUIDv7 - - belongs_to :creator, User, - foreign_key: :created_by_uuid, - references: :uuid, - define_field: false, - type: UUIDv7 - end - - @valid_statuses ~w(draft published archived) - - @doc """ - Creates a changeset for entity data creation and updates. - - Validates that entity exists, title is present, and data validates against entity definition. - Automatically sets date_created on new records. - """ - def changeset(entity_data, attrs) do - entity_data - |> cast(attrs, [ - :entity_uuid, - :title, - :slug, - :status, - :position, - :data, - :metadata, - :created_by_uuid, - :date_created, - :date_updated - ]) - |> validate_required([:title]) - |> validate_entity_reference() - |> validate_length(:title, min: 1, max: 255) - |> validate_length(:slug, max: 255) - |> validate_inclusion(:status, @valid_statuses) - |> validate_slug_format() - |> sanitize_rich_text_data() - |> validate_data_against_entity() - |> foreign_key_constraint(:entity_uuid) - |> maybe_set_timestamps() - end - - defp validate_entity_reference(changeset) do - entity_uuid = get_field(changeset, :entity_uuid) - - if is_nil(entity_uuid) do - add_error(changeset, :entity_uuid, "entity_uuid must be present") - else - changeset - end - end - - defp validate_slug_format(changeset) do - case get_field(changeset, :slug) do - nil -> - changeset - - "" -> - changeset - - slug -> - if Regex.match?(~r/^[a-z0-9]+(?:-[a-z0-9]+)*$/, slug) do - changeset - else - add_error( - changeset, - :slug, - gettext("must contain only lowercase letters, numbers, and hyphens") - ) - end - end - end - - defp sanitize_rich_text_data(changeset) do - entity_uuid = get_field(changeset, :entity_uuid) - data = get_field(changeset, :data) - - case {entity_uuid, data} do - {nil, _} -> - changeset - - {_, nil} -> - changeset - - {id, data} -> - try do - entity = Entities.get_entity!(id) - fields_definition = entity.fields_definition || [] - - sanitized_data = - if Multilang.multilang_data?(data) do - # Sanitize each language's data independently - Enum.reduce(data, %{}, fn - {"_primary_language", value}, acc -> - Map.put(acc, "_primary_language", value) - - {lang_code, lang_data}, acc when is_map(lang_data) -> - sanitized = - HtmlSanitizer.sanitize_rich_text_fields(fields_definition, lang_data) - - Map.put(acc, lang_code, sanitized) - - {key, value}, acc -> - Map.put(acc, key, value) - end) - else - HtmlSanitizer.sanitize_rich_text_fields(fields_definition, data) - end - - put_change(changeset, :data, sanitized_data) - rescue - Ecto.NoResultsError -> changeset - end - end - end - - defp validate_data_against_entity(changeset) do - entity_uuid = get_field(changeset, :entity_uuid) - data = get_field(changeset, :data) - - case entity_uuid do - nil -> - changeset - - uuid -> - case Entities.get_entity!(uuid) do - nil -> - add_error(changeset, :entity_uuid, gettext("does not exist")) - - entity -> - validate_data_fields(changeset, entity, data || %{}) - end - end - rescue - Ecto.NoResultsError -> - add_error(changeset, :entity_uuid, gettext("does not exist")) - end - - defp validate_data_fields(changeset, entity, data) do - fields_definition = entity.fields_definition || [] - - # For multilang data, validate the primary language data (which must be complete) - validation_data = - if Multilang.multilang_data?(data) do - Multilang.get_primary_data(data) - else - data - end - - Enum.reduce(fields_definition, changeset, fn field_def, acc -> - validate_single_data_field(acc, field_def, validation_data) - end) - end - - defp validate_single_data_field(changeset, field_def, data) do - field_key = field_def["key"] - field_value = data[field_key] - is_required = field_def["required"] || false - - cond do - is_required && (is_nil(field_value) || field_value == "") -> - add_error( - changeset, - :data, - gettext("field '%{label}' is required", label: field_def["label"]) - ) - - !is_nil(field_value) && field_value != "" -> - validate_field_type(changeset, field_def, field_value) - - true -> - changeset - end - end - - defp validate_field_type(changeset, field_def, value) do - case field_def["type"] do - "number" -> validate_number_field(changeset, field_def, value) - "boolean" -> validate_boolean_field(changeset, field_def, value) - "email" -> validate_email_field(changeset, field_def, value) - "url" -> validate_url_field(changeset, field_def, value) - "date" -> validate_date_field(changeset, field_def, value) - "select" -> validate_select_field(changeset, field_def, value) - _ -> changeset - end - end - - defp validate_number_field(changeset, field_def, value) do - if is_number(value) || (is_binary(value) && Regex.match?(~r/^\d+(\.\d+)?$/, value)) do - changeset - else - add_error( - changeset, - :data, - gettext("field '%{label}' must be a number", label: field_def["label"]) - ) - end - end - - defp validate_boolean_field(changeset, field_def, value) do - if is_boolean(value) do - changeset - else - add_error( - changeset, - :data, - gettext("field '%{label}' must be true or false", label: field_def["label"]) - ) - end - end - - defp validate_email_field(changeset, field_def, value) do - if is_binary(value) && Regex.match?(~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/, value) do - changeset - else - add_error( - changeset, - :data, - gettext("field '%{label}' must be a valid email", label: field_def["label"]) - ) - end - end - - defp validate_url_field(changeset, field_def, value) do - if is_binary(value) && String.starts_with?(value, ["http://", "https://"]) do - changeset - else - add_error( - changeset, - :data, - gettext("field '%{label}' must be a valid URL", label: field_def["label"]) - ) - end - end - - defp validate_date_field(changeset, field_def, value) do - if is_binary(value) && Regex.match?(~r/^\d{4}-\d{2}-\d{2}$/, value) do - changeset - else - add_error( - changeset, - :data, - gettext("field '%{label}' must be a valid date (YYYY-MM-DD)", label: field_def["label"]) - ) - end - end - - defp validate_select_field(changeset, field_def, value) do - options = field_def["options"] || [] - - if value in options do - changeset - else - add_error( - changeset, - :data, - gettext("field '%{label}' must be one of: %{options}", - label: field_def["label"], - options: Enum.join(options, ", ") - ) - ) - end - end - - defp maybe_set_timestamps(changeset) do - now = UtilsDate.utc_now() - - case changeset.data.__meta__.state do - :built -> - changeset - |> put_change(:date_created, now) - |> put_change(:date_updated, now) - - :loaded -> - put_change(changeset, :date_updated, now) - end - end - - defp notify_data_event({:ok, %__MODULE__{} = entity_data}, :created) do - Events.broadcast_data_created(entity_data.entity_uuid, entity_data.uuid) - maybe_mirror_data(entity_data) - {:ok, entity_data} - end - - defp notify_data_event({:ok, %__MODULE__{} = entity_data}, :updated) do - Events.broadcast_data_updated(entity_data.entity_uuid, entity_data.uuid) - maybe_mirror_data(entity_data) - {:ok, entity_data} - end - - defp notify_data_event({:ok, %__MODULE__{} = entity_data}, :deleted) do - Events.broadcast_data_deleted(entity_data.entity_uuid, entity_data.uuid) - maybe_delete_mirrored_data(entity_data) - {:ok, entity_data} - end - - defp notify_data_event(result, _event), do: result - - # Broadcast a reorder event for an entity so live views refresh. - defp notify_reorder_event(entity_uuid) when is_binary(entity_uuid) do - Events.broadcast_data_reordered(entity_uuid) - end - - defp notify_reorder_event(_), do: :ok - - # Resolve entity_uuid from the first record in a bulk update list - defp resolve_entity_uuid_from_pairs([{uuid, _} | _]) do - from(d in __MODULE__, where: d.uuid == ^uuid, select: d.entity_uuid) - |> repo().one() - end - - defp resolve_entity_uuid_from_pairs(_), do: nil - - # Mirror export helpers for auto-sync (per-entity settings) - defp maybe_mirror_data(entity_data) do - # Check if the parent entity has data mirroring enabled - case Entities.get_entity(entity_data.entity_uuid) do - nil -> - :ok - - entity -> - if Entities.mirror_data_enabled?(entity) do - Task.start(fn -> Exporter.export_entity_data(entity_data) end) - end - end - end - - defp maybe_delete_mirrored_data(entity_data) do - # Re-export the entity file to update the data array - # Only if the entity has data mirroring enabled - case Entities.get_entity(entity_data.entity_uuid) do - nil -> - :ok - - entity -> - if Entities.mirror_data_enabled?(entity) do - Task.start(fn -> Exporter.export_entity(entity) end) - end - end - end - - @doc """ - Returns all entity data records ordered by creation date. - - ## Examples - - iex> PhoenixKit.Modules.Entities.EntityData.list_all() - [%PhoenixKit.Modules.Entities.EntityData{}, ...] - """ - def list_all(opts \\ []) do - from(d in __MODULE__, - order_by: [desc: d.date_created], - preload: [:entity, :creator] - ) - |> repo().all() - |> maybe_resolve_langs(opts) - end - - @doc """ - Returns all entity data records for a specific entity. - - ## Examples - - iex> PhoenixKit.Modules.Entities.EntityData.list_by_entity(entity_uuid) - [%PhoenixKit.Modules.Entities.EntityData{}, ...] - """ - def list_by_entity(entity_uuid, opts \\ []) when is_binary(entity_uuid) do - order = resolve_sort_order(entity_uuid, opts) - - from(d in __MODULE__, - where: d.entity_uuid == ^entity_uuid, - order_by: ^order, - preload: [:entity, :creator] - ) - |> repo().all() - |> maybe_resolve_langs(opts) - end - - @doc """ - Returns entity data records filtered by entity and status. - - ## Examples - - iex> PhoenixKit.Modules.Entities.EntityData.list_by_entity_and_status(entity_uuid, "published") - [%PhoenixKit.Modules.Entities.EntityData{status: "published"}, ...] - """ - def list_by_entity_and_status(entity_uuid, status, opts \\ []) - when is_binary(entity_uuid) and status in @valid_statuses do - order = resolve_sort_order(entity_uuid, opts) - - from(d in __MODULE__, - where: d.entity_uuid == ^entity_uuid and d.status == ^status, - order_by: ^order, - preload: [:entity, :creator] - ) - |> repo().all() - |> maybe_resolve_langs(opts) - end - - @doc """ - Gets a single entity data record by UUID. - - Returns the record if found, nil otherwise. - - ## Examples - - iex> PhoenixKit.Modules.Entities.EntityData.get("550e8400-e29b-41d4-a716-446655440000") - %PhoenixKit.Modules.Entities.EntityData{} - - iex> PhoenixKit.Modules.Entities.EntityData.get("invalid") - nil - """ - def get(uuid, opts \\ []) - - def get(uuid, opts) when is_binary(uuid) do - if UUIDUtils.valid?(uuid) do - case repo().get_by(__MODULE__, uuid: uuid) do - nil -> nil - record -> record |> repo().preload([:entity, :creator]) |> maybe_resolve_lang(opts) - end - else - nil - end - end - - def get(_, _opts), do: nil - - @doc """ - Gets a single entity data record by UUID. - - Raises `Ecto.NoResultsError` if the record does not exist. - - ## Examples - - iex> PhoenixKit.Modules.Entities.EntityData.get!("550e8400-e29b-41d4-a716-446655440000") - %PhoenixKit.Modules.Entities.EntityData{} - - iex> PhoenixKit.Modules.Entities.EntityData.get!("nonexistent-uuid") - ** (Ecto.NoResultsError) - """ - def get!(id, opts \\ []) do - case get(id, opts) do - nil -> raise Ecto.NoResultsError, queryable: __MODULE__ - record -> record - end - end - - @doc """ - Gets a single entity data record by entity and slug. - - Returns the record if found, nil otherwise. - - ## Examples - - iex> PhoenixKit.Modules.Entities.EntityData.get_by_slug(entity_uuid, "acme-corporation") - %PhoenixKit.Modules.Entities.EntityData{} - - iex> PhoenixKit.Modules.Entities.EntityData.get_by_slug(entity_uuid, "invalid") - nil - """ - def get_by_slug(entity_uuid, slug, opts \\ []) - when is_binary(entity_uuid) and is_binary(slug) do - case repo().get_by(__MODULE__, entity_uuid: entity_uuid, slug: slug) do - nil -> nil - record -> record |> repo().preload([:entity, :creator]) |> maybe_resolve_lang(opts) - end - end - - @doc """ - Checks if a secondary language slug exists for another record within the same entity. - - Queries the JSONB `data` column for `data->lang_code->>'_slug'` matches. - Used for uniqueness checks on translated slugs. - """ - def secondary_slug_exists?(entity_uuid, lang_code, slug, exclude_record_uuid) - when is_binary(entity_uuid) do - query = - from(ed in __MODULE__, - where: fragment("(? -> ? ->> '_slug') = ?", ed.data, ^lang_code, ^slug), - where: ed.entity_uuid == ^entity_uuid, - select: ed.uuid - ) - - query = - if exclude_record_uuid do - from(ed in query, where: ed.uuid != ^exclude_record_uuid) - else - query - end - - repo().exists?(query) - end - - @doc """ - Creates an entity data record. - - ## Examples - - iex> PhoenixKit.Modules.Entities.EntityData.create(%{entity_uuid: entity_uuid, title: "Test"}) - {:ok, %PhoenixKit.Modules.Entities.EntityData{}} - - iex> PhoenixKit.Modules.Entities.EntityData.create(%{title: ""}) - {:error, %Ecto.Changeset{}} - - Note: `created_by` is auto-filled with the first admin or user ID if not provided, - but only if at least one user exists in the system. If no users exist, the changeset - will fail with a validation error on `created_by`. - """ - def create(attrs \\ %{}) do - # Transaction ensures next_position read + insert are atomic - repo().transaction(fn -> - attrs = - attrs - |> maybe_add_created_by() - |> maybe_add_position() - - case %__MODULE__{} |> changeset(attrs) |> repo().insert() do - {:ok, record} -> record - {:error, changeset} -> repo().rollback(changeset) - end - end) - |> notify_data_event(:created) - end - - # Auto-fill created_by_uuid with first admin if not provided - defp maybe_add_created_by(attrs) when is_map(attrs) do - has_created_by_uuid = - Map.has_key?(attrs, :created_by_uuid) or Map.has_key?(attrs, "created_by_uuid") - - if has_created_by_uuid do - attrs - else - key = if Map.has_key?(attrs, :entity_uuid), do: :created_by_uuid, else: "created_by_uuid" - - case Auth.get_first_admin_uuid() do - nil -> - # Fall back to first user if no admin exists - case Auth.get_first_user_uuid() do - nil -> attrs - user_uuid -> Map.put(attrs, key, user_uuid) - end - - admin_uuid -> - Map.put(attrs, key, admin_uuid) - end - end - end - - # Auto-fill position with next value for the entity if not provided - defp maybe_add_position(attrs) when is_map(attrs) do - has_position = Map.has_key?(attrs, :position) or Map.has_key?(attrs, "position") - - if has_position do - attrs - else - entity_uuid = - Map.get(attrs, :entity_uuid) || Map.get(attrs, "entity_uuid") - - if entity_uuid do - next_pos = next_position(entity_uuid) - key = if Map.has_key?(attrs, :entity_uuid), do: :position, else: "position" - Map.put(attrs, key, next_pos) - else - attrs - end - end - end - - @doc """ - Gets the next available position for an entity's data records. - - ## Examples - - iex> next_position(entity_uuid) - 6 - """ - def next_position(entity_uuid) when is_binary(entity_uuid) do - # FOR UPDATE locks matching rows within a transaction to prevent - # concurrent creates from reading the same max position. - # NOTE: The lock only takes effect inside a repo().transaction/1 block. - # Called internally by create/1 which wraps in a transaction. - # Fetch individual positions with row-level lock, then compute max in Elixir. - # PostgreSQL does not allow FOR UPDATE with aggregate functions. - positions = - from(d in __MODULE__, - where: d.entity_uuid == ^entity_uuid, - select: d.position, - lock: "FOR UPDATE" - ) - |> repo().all() - - Enum.max(positions, fn -> 0 end) + 1 - end - - @doc """ - Updates the position of a single entity data record. - - ## Examples - - iex> update_position(record, 3) - {:ok, %EntityData{position: 3}} - """ - def update_position(%__MODULE__{} = entity_data, position) when is_integer(position) do - __MODULE__.update(entity_data, %{position: position}) - end - - @doc """ - Bulk updates positions for multiple records. - - Accepts a list of `{uuid, position}` tuples. Each record is updated - individually to trigger events and maintain consistency. - - ## Examples - - iex> bulk_update_positions([{"uuid1", 1}, {"uuid2", 2}, {"uuid3", 3}]) - :ok - """ - def bulk_update_positions(uuid_position_pairs, opts \\ []) - when is_list(uuid_position_pairs) do - result = - repo().transaction(fn -> - now = UtilsDate.utc_now() - - Enum.each(uuid_position_pairs, fn {uuid, position} -> - from(d in __MODULE__, where: d.uuid == ^uuid) - |> repo().update_all(set: [position: position, date_updated: now]) - end) - end) - - case result do - {:ok, _} -> - entity_uuid = - Keyword.get(opts, :entity_uuid) || resolve_entity_uuid_from_pairs(uuid_position_pairs) - - notify_reorder_event(entity_uuid) - :ok - - {:error, reason} -> - {:error, reason} - end - end - - @doc """ - Moves a record to a specific position within its entity, shifting other records. - - Records between the old and new positions are shifted up or down by 1 to - make room. This is the operation that a drag-and-drop UI would call. - - ## Examples - - iex> move_to_position(record, 3) - :ok - """ - def move_to_position(%__MODULE__{} = record, new_position) when is_integer(new_position) do - entity_uuid = record.entity_uuid - - result = - repo().transaction(fn -> - # Re-read position inside transaction to avoid stale data - current = repo().get!(__MODULE__, record.uuid) - old_position = current.position - - cond do - is_nil(old_position) -> - do_update_position!(current, new_position) - - old_position == new_position -> - :noop - - true -> - now = UtilsDate.utc_now() - shift_neighbors(entity_uuid, current.uuid, old_position, new_position, now) - do_update_position!(current, new_position) - end - end) - - case result do - {:ok, :noop} -> - :ok - - {:ok, _} -> - notify_reorder_event(entity_uuid) - :ok - - {:error, reason} -> - {:error, reason} - end - end - - # Update position inside a transaction, rolling back on failure - defp do_update_position!(record, position) do - case update_position(record, position) do - {:ok, updated} -> updated - {:error, changeset} -> repo().rollback(changeset) - end - end - - defp shift_neighbors(entity_uuid, record_uuid, old_pos, new_pos, now) - when old_pos < new_pos do - # Moving down: shift records in (old, new] up by 1 - from(d in __MODULE__, - where: - d.entity_uuid == ^entity_uuid and - d.position > ^old_pos and - d.position <= ^new_pos and - d.uuid != ^record_uuid - ) - |> repo().update_all(inc: [position: -1], set: [date_updated: now]) - end - - defp shift_neighbors(entity_uuid, record_uuid, old_pos, new_pos, now) do - # Moving up: shift records in [new, old) down by 1 - from(d in __MODULE__, - where: - d.entity_uuid == ^entity_uuid and - d.position >= ^new_pos and - d.position < ^old_pos and - d.uuid != ^record_uuid - ) - |> repo().update_all(inc: [position: 1], set: [date_updated: now]) - end - - @doc """ - Reorders all records for an entity based on a list of UUIDs in the desired order. - - This is the full reorder operation — takes a list of UUIDs representing the - new order and assigns positions 1, 2, 3, ... accordingly. - - ## Examples - - iex> reorder(entity_uuid, ["uuid3", "uuid1", "uuid2"]) - :ok - """ - def reorder(entity_uuid, ordered_uuids) - when is_binary(entity_uuid) and is_list(ordered_uuids) do - pairs = - ordered_uuids - |> Enum.with_index(1) - |> Enum.map(fn {uuid, pos} -> {uuid, pos} end) - - bulk_update_positions(pairs, entity_uuid: entity_uuid) - end - - @doc """ - Updates an entity data record. - - ## Examples - - iex> PhoenixKit.Modules.Entities.EntityData.update(record, %{title: "Updated"}) - {:ok, %PhoenixKit.Modules.Entities.EntityData{}} - - iex> PhoenixKit.Modules.Entities.EntityData.update(record, %{title: ""}) - {:error, %Ecto.Changeset{}} - """ - def update(%__MODULE__{} = entity_data, attrs) do - entity_data - |> changeset(attrs) - |> repo().update() - |> notify_data_event(:updated) - end - - @doc """ - Deletes an entity data record. - - ## Examples - - iex> PhoenixKit.Modules.Entities.EntityData.delete(record) - {:ok, %PhoenixKit.Modules.Entities.EntityData{}} - - iex> PhoenixKit.Modules.Entities.EntityData.delete(record) - {:error, %Ecto.Changeset{}} - """ - def delete(%__MODULE__{} = entity_data) do - repo().delete(entity_data) - |> notify_data_event(:deleted) - end - - @doc """ - Returns an `%Ecto.Changeset{}` for tracking entity data changes. - - ## Examples - - iex> PhoenixKit.Modules.Entities.EntityData.change(record) - %Ecto.Changeset{data: %PhoenixKit.Modules.Entities.EntityData{}} - """ - def change(%__MODULE__{} = entity_data, attrs \\ %{}) do - changeset(entity_data, attrs) - end - - @doc """ - Searches entity data records by title. - - ## Examples - - iex> PhoenixKit.Modules.Entities.EntityData.search_by_title("Acme") - [%PhoenixKit.Modules.Entities.EntityData{}, ...] - - iex> PhoenixKit.Modules.Entities.EntityData.search_by_title("Acme", entity_uuid) - [%PhoenixKit.Modules.Entities.EntityData{}, ...] - - iex> PhoenixKit.Modules.Entities.EntityData.search_by_title("Acme", entity_uuid, lang: "es") - [%PhoenixKit.Modules.Entities.EntityData{}, ...] - """ - def search_by_title(search_term) when is_binary(search_term), - do: search_by_title(search_term, nil, []) - - def search_by_title(search_term, entity_uuid, opts \\ []) - - def search_by_title(search_term, entity_uuid, opts) - when is_binary(search_term) do - search_pattern = "%#{search_term}%" - order = if entity_uuid, do: resolve_sort_order(entity_uuid, opts), else: [desc: :date_created] - - query = - from(d in __MODULE__, - where: ilike(d.title, ^search_pattern), - order_by: ^order, - preload: [:entity, :creator] - ) - - query = - case entity_uuid do - nil -> - query - - uuid when is_binary(uuid) -> - from(d in query, where: d.entity_uuid == ^uuid) - end - - repo().all(query) - |> maybe_resolve_langs(opts) - end - - @doc """ - Gets all published records for a specific entity. - - ## Examples - - iex> PhoenixKit.Modules.Entities.EntityData.published_records(entity_uuid) - [%PhoenixKit.Modules.Entities.EntityData{status: "published"}, ...] - """ - def published_records(entity_uuid, opts \\ []) when is_binary(entity_uuid) do - list_by_entity_and_status(entity_uuid, "published", opts) - end - - @doc """ - Counts the total number of records for an entity. - - ## Examples - - iex> PhoenixKit.Modules.Entities.EntityData.count_by_entity(entity_uuid) - 42 - """ - def count_by_entity(entity_uuid) when is_binary(entity_uuid) do - from(d in __MODULE__, where: d.entity_uuid == ^entity_uuid, select: count(d.uuid)) - |> repo().one() - end - - @doc """ - Gets records filtered by status across all entities. - - ## Examples - - iex> PhoenixKit.Modules.Entities.EntityData.filter_by_status("draft") - [%PhoenixKit.Modules.Entities.EntityData{status: "draft"}, ...] - """ - def filter_by_status(status, opts \\ []) when status in @valid_statuses do - from(d in __MODULE__, - where: d.status == ^status, - order_by: [desc: d.date_created], - preload: [:entity, :creator] - ) - |> repo().all() - |> maybe_resolve_langs(opts) - end - - @doc """ - Alias for list_all/1 for consistency with LiveView naming. - """ - def list_all_data(opts \\ []), do: list_all(opts) - - @doc """ - Alias for list_by_entity/2 for consistency with LiveView naming. - """ - def list_data_by_entity(entity_uuid, opts \\ []), do: list_by_entity(entity_uuid, opts) - - @doc """ - Alias for filter_by_status/2 for consistency with LiveView naming. - """ - def list_data_by_status(status, opts \\ []), do: filter_by_status(status, opts) - - @doc """ - Alias for search_by_title for consistency with LiveView naming. - """ - def search_data(search_term) when is_binary(search_term), - do: search_by_title(search_term, nil, []) - - def search_data(search_term, entity_uuid, opts \\ []), - do: search_by_title(search_term, entity_uuid, opts) - - @doc """ - Alias for get!/2 for consistency with LiveView naming. - """ - def get_data!(id, opts \\ []), do: get!(id, opts) - - @doc """ - Alias for delete/1 for consistency with LiveView naming. - """ - def delete_data(entity_data), do: __MODULE__.delete(entity_data) - - @doc """ - Alias for update/2 for consistency with LiveView naming. - """ - def update_data(entity_data, attrs), do: __MODULE__.update(entity_data, attrs) - - @doc """ - Bulk updates the status of multiple records by UUIDs. - - Returns a tuple with the count of updated records and nil. - - ## Examples - - iex> PhoenixKit.Modules.Entities.EntityData.bulk_update_status(["uuid1", "uuid2"], "archived") - {2, nil} - """ - def bulk_update_status(uuids, status) when is_list(uuids) and status in @valid_statuses do - now = UtilsDate.utc_now() - - from(d in __MODULE__, where: d.uuid in ^uuids) - |> repo().update_all(set: [status: status, date_updated: now]) - end - - @doc """ - Bulk deletes multiple records by UUIDs. - - Returns a tuple with the count of deleted records and nil. - - ## Examples - - iex> PhoenixKit.Modules.Entities.EntityData.bulk_delete(["uuid1", "uuid2"]) - {2, nil} - """ - def bulk_delete(uuids) when is_list(uuids) do - from(d in __MODULE__, where: d.uuid in ^uuids) - |> repo().delete_all() - end - - @doc """ - Gets statistical data about entity data records. - - Returns statistics about total records, published, draft, and archived counts. - Optionally filters by entity_uuid if provided. - - ## Examples - - iex> PhoenixKit.Modules.Entities.EntityData.get_data_stats() - %{ - total_records: 150, - published_records: 120, - draft_records: 25, - archived_records: 5 - } - - iex> PhoenixKit.Modules.Entities.EntityData.get_data_stats("018e3c4a-9f6b-7890-abcd-ef1234567890") - %{ - total_records: 15, - published_records: 12, - draft_records: 2, - archived_records: 1 - } - """ - def get_data_stats(entity_uuid \\ nil) do - query = - from(d in __MODULE__, - select: { - count(d.uuid), - count(fragment("CASE WHEN ? = 'published' THEN 1 END", d.status)), - count(fragment("CASE WHEN ? = 'draft' THEN 1 END", d.status)), - count(fragment("CASE WHEN ? = 'archived' THEN 1 END", d.status)) - } - ) - - query = - case entity_uuid do - nil -> - query - - uuid when is_binary(uuid) -> - from(d in query, where: d.entity_uuid == ^uuid) - end - - {total, published, draft, archived} = repo().one(query) - - %{ - total_records: total, - published_records: published, - draft_records: draft, - archived_records: archived - } - end - - # ============================================================================ - # Translation convenience API - # ============================================================================ - - @doc """ - Gets the data fields for a specific language, merged with primary language defaults. - - For multilang records, returns `Map.merge(primary_data, language_overrides)`. - For flat (non-multilang) records, returns the data as-is. - - ## Examples - - iex> get_translation(record, "es-ES") - %{"name" => "Acme España", "category" => "Tech"} - - iex> get_translation(flat_record, "en-US") - %{"name" => "Acme", "category" => "Tech"} - """ - def get_translation(%__MODULE__{data: data}, lang_code) when is_binary(lang_code) do - Multilang.get_language_data(data, lang_code) - end - - @doc """ - Gets the raw (non-merged) data for a specific language. - - For secondary languages, returns only the override fields (not merged with primary). - Useful for seeing which fields have explicit translations. - - ## Examples - - iex> get_raw_translation(record, "es-ES") - %{"name" => "Acme España"} - """ - def get_raw_translation(%__MODULE__{data: data}, lang_code) when is_binary(lang_code) do - Multilang.get_raw_language_data(data, lang_code) - end - - @doc """ - Gets translations for all languages in a record. - - Returns a map of language codes to their merged data. - For flat records, returns the data under the primary language key. - - ## Examples - - iex> get_all_translations(record) - %{ - "en-US" => %{"name" => "Acme", "category" => "Tech"}, - "es-ES" => %{"name" => "Acme España", "category" => "Tech"} - } - """ - def get_all_translations(%__MODULE__{data: data}) do - if Multilang.multilang_data?(data) do - Multilang.enabled_languages() - |> Map.new(fn lang -> {lang, Multilang.get_language_data(data, lang)} end) - else - primary = Multilang.primary_language() - %{primary => data || %{}} - end - end - - @doc """ - Sets the data translation for a specific language on a record. - - For the primary language, stores all fields. - For secondary languages, only stores fields that differ from primary (overrides). - Persists to the database. - - ## Examples - - iex> set_translation(record, "es-ES", %{"name" => "Acme España"}) - {:ok, %EntityData{}} - - iex> set_translation(record, "en-US", %{"name" => "Acme Corp", "category" => "Tech"}) - {:ok, %EntityData{}} - """ - def set_translation(%__MODULE__{} = entity_data, lang_code, field_data) - when is_binary(lang_code) and is_map(field_data) do - updated_data = Multilang.put_language_data(entity_data.data, lang_code, field_data) - __MODULE__.update(entity_data, %{data: updated_data}) - end - - @doc """ - Removes all data for a specific language from a record. - - Cannot remove the primary language. Returns `{:error, :cannot_remove_primary}` - if the primary language is targeted. - - ## Examples - - iex> remove_translation(record, "es-ES") - {:ok, %EntityData{}} - - iex> remove_translation(record, "en-US") - {:error, :cannot_remove_primary} - """ - def remove_translation(%__MODULE__{data: data} = entity_data, lang_code) - when is_binary(lang_code) do - if Multilang.multilang_data?(data) do - primary = data["_primary_language"] - - if lang_code == primary do - {:error, :cannot_remove_primary} - else - updated_data = Map.delete(data, lang_code) - __MODULE__.update(entity_data, %{data: updated_data}) - end - else - {:error, :not_multilang} - end - end - - @doc """ - Gets the title translation for a specific language. - - Reads from `data[lang]["_title"]` (unified JSONB storage). Falls back to - the old `metadata["translations"]` location for unmigrated records, and - finally to the `title` column. - - ## Examples - - iex> get_title_translation(record, "en-US") - "My Product" - - iex> get_title_translation(record, "es-ES") - "Mi Producto" - """ - def get_title_translation(%__MODULE__{} = entity_data, lang_code) - when is_binary(lang_code) do - case Multilang.get_language_data(entity_data.data, lang_code) do - %{"_title" => title} when is_binary(title) and title != "" -> - title - - _ -> - # Transitional fallback: check old metadata location for unmigrated records - case get_in(entity_data.metadata || %{}, ["translations", lang_code, "title"]) do - title when is_binary(title) and title != "" -> title - _ -> entity_data.title - end - end - end - - @doc """ - Sets the title translation for a specific language. - - Stores `_title` in the JSONB `data` column using `put_language_data`. - For the primary language, also updates the `title` DB column. - - ## Examples - - iex> set_title_translation(record, "es-ES", "Mi Producto") - {:ok, %EntityData{}} - - iex> set_title_translation(record, "en-US", "My Product") - {:ok, %EntityData{}} - """ - def set_title_translation(%__MODULE__{} = entity_data, lang_code, title) - when is_binary(lang_code) and is_binary(title) do - # Merge _title into existing raw overrides to preserve other fields - existing_lang_data = Multilang.get_raw_language_data(entity_data.data, lang_code) - merged = Map.put(existing_lang_data, "_title", title) - updated_data = Multilang.put_language_data(entity_data.data, lang_code, merged) - - # If setting primary language, also update the DB column - primary = (entity_data.data || %{})["_primary_language"] || Multilang.primary_language() - attrs = %{data: updated_data} - attrs = if lang_code == primary, do: Map.put(attrs, :title, title), else: attrs - - __MODULE__.update(entity_data, attrs) - end - - @doc """ - Gets all title translations for a record. - - Returns a map of language codes to title strings. - - ## Examples - - iex> get_all_title_translations(record) - %{"en-US" => "My Product", "es-ES" => "Mi Producto", "fr-FR" => "Mon Produit"} - """ - def get_all_title_translations(%__MODULE__{} = entity_data) do - Multilang.enabled_languages() - |> Map.new(fn lang -> - {lang, get_title_translation(entity_data, lang)} - end) - end - - # ============================================================================ - # Language-aware API - # ============================================================================ - - @doc """ - Resolves translated fields on an entity data record for a given language. - - Resolves the `title` from `_title` in the language's data, and replaces - the `data` field with the merged language data (primary as base + overrides). - - For the primary language or flat (non-multilang) data, the struct is - returned with the primary language data resolved. When no translation - exists for a field, the primary language value is used as fallback. - - ## Examples - - iex> resolve_language(record, "es-ES") - %EntityData{title: "Mi Producto", data: %{"name" => "Acme España", ...}} - - iex> resolve_language(record, "en-US") # primary language - %EntityData{title: "My Product", data: %{"name" => "Acme", ...}} - """ - @spec resolve_language(t(), String.t()) :: t() - def resolve_language(%__MODULE__{} = record, lang_code) when is_binary(lang_code) do - resolved_title = get_title_translation(record, lang_code) - resolved_data = Multilang.get_language_data(record.data, lang_code) - - %{record | title: resolved_title, data: resolved_data} - end - - @doc """ - Resolves translations on a list of entity data records. - - ## Examples - - iex> resolve_languages(records, "es-ES") - [%EntityData{title: "Mi Producto"}, ...] - """ - @spec resolve_languages([t()], String.t()) :: [t()] - def resolve_languages(records, lang_code) when is_list(records) and is_binary(lang_code) do - Enum.map(records, &resolve_language(&1, lang_code)) - end - - # Returns the Ecto order_by clause based on the entity's sort_mode setting. - # "manual" mode sorts by position ASC (with nulls last via date_created fallback). - # "auto" mode (default) sorts by date_created DESC. - # - # Accepts opts with :sort_mode to skip the entity lookup when the caller - # already has the entity loaded. Falls back to a DB lookup by entity_uuid. - defp resolve_sort_order(entity_uuid, opts) do - mode = - case Keyword.get(opts, :sort_mode) do - nil -> entity_sort_mode_from_db(entity_uuid) - mode -> mode - end - - sort_order_for_mode(mode) - end - - defp entity_sort_mode_from_db(entity_uuid) when is_binary(entity_uuid) do - case Entities.get_entity(entity_uuid) do - %{settings: %{"sort_mode" => mode}} -> mode - _ -> "auto" - end - end - - defp entity_sort_mode_from_db(_), do: "auto" - - defp sort_order_for_mode("manual"), do: [asc_nulls_last: :position, desc: :date_created] - defp sort_order_for_mode(_), do: [desc: :date_created] - - # Applies :lang option to a single record if present in opts - defp maybe_resolve_lang(record, opts) when is_list(opts) do - case Keyword.get(opts, :lang) do - nil -> record - lang -> resolve_language(record, lang) - end - end - - # Applies :lang option to a list of records if present in opts - defp maybe_resolve_langs(records, opts) when is_list(records) and is_list(opts) do - case Keyword.get(opts, :lang) do - nil -> records - lang -> resolve_languages(records, lang) - end - end - - defp repo do - PhoenixKit.RepoHelper.repo() - end -end diff --git a/lib/modules/entities/events.ex b/lib/modules/entities/events.ex deleted file mode 100644 index d434a15c8..000000000 --- a/lib/modules/entities/events.ex +++ /dev/null @@ -1,157 +0,0 @@ -defmodule PhoenixKit.Modules.Entities.Events do - @moduledoc """ - PubSub helpers for coordinating real-time entity updates. - - Provides broadcast and subscribe helpers for: - - * Entity definition lifecycle (create/update/delete) - * Entity data lifecycle (create/update/delete) - * Collaborative editing signals for entity + data forms - - All events are broadcast through `PhoenixKit.PubSub.Manager` so the library - remains self-contained when embedded into host applications. - """ - - alias PhoenixKit.PubSub.Manager - - # Base topics - @topic_entities "phoenix_kit:entities:definitions" - @topic_data "phoenix_kit:entities:data" - @topic_entity_forms "phoenix_kit:entities:entity_forms" - @topic_data_forms "phoenix_kit:entities:data_forms" - - ## Subscription helpers - - @doc "Subscribe to entity definition lifecycle events." - def subscribe_to_entities, do: Manager.subscribe(@topic_entities) - - @doc "Subscribe to entity data lifecycle events (all entities)." - def subscribe_to_all_data, do: Manager.subscribe(@topic_data) - - @doc "Subscribe to data lifecycle events for a specific entity." - def subscribe_to_entity_data(entity_uuid), do: Manager.subscribe(data_topic(entity_uuid)) - - @doc "Subscribe to collaborative events for a specific entity form." - def subscribe_to_entity_form(form_key), - do: Manager.subscribe(entity_form_topic(form_key)) - - @doc "Subscribe to collaborative events for a specific data record form." - def subscribe_to_data_form(entity_uuid, record_key), - do: Manager.subscribe(data_form_topic(entity_uuid, record_key)) - - @doc "Subscribe to presence updates for an entity." - def subscribe_to_entity_presence(entity_uuid), - do: Manager.subscribe(entity_presence_topic(entity_uuid)) - - @doc "Subscribe to presence updates for a data record." - def subscribe_to_data_presence(entity_uuid, data_uuid), - do: Manager.subscribe(data_presence_topic(entity_uuid, data_uuid)) - - ## Entity definition lifecycle - - def broadcast_entity_created(entity_uuid), - do: broadcast(@topic_entities, {:entity_created, entity_uuid}) - - def broadcast_entity_updated(entity_uuid), - do: broadcast(@topic_entities, {:entity_updated, entity_uuid}) - - def broadcast_entity_deleted(entity_uuid), - do: broadcast(@topic_entities, {:entity_deleted, entity_uuid}) - - ## Entity data lifecycle - - def broadcast_data_created(entity_uuid, data_uuid) do - message = {:data_created, entity_uuid, data_uuid} - broadcast(@topic_data, message) - broadcast(data_topic(entity_uuid), message) - end - - def broadcast_data_updated(entity_uuid, data_uuid) do - message = {:data_updated, entity_uuid, data_uuid} - broadcast(@topic_data, message) - broadcast(data_topic(entity_uuid), message) - end - - def broadcast_data_deleted(entity_uuid, data_uuid) do - message = {:data_deleted, entity_uuid, data_uuid} - broadcast(@topic_data, message) - broadcast(data_topic(entity_uuid), message) - end - - def broadcast_data_reordered(entity_uuid) do - message = {:data_reordered, entity_uuid} - broadcast(@topic_data, message) - broadcast(data_topic(entity_uuid), message) - end - - ## Collaborative form editing - - def broadcast_entity_form_change(form_key, payload, opts \\ []) do - broadcast( - entity_form_topic(form_key), - {:entity_form_change, form_key, payload, Keyword.get(opts, :source)} - ) - end - - def broadcast_data_form_change(entity_uuid, record_key, payload, opts \\ []) do - broadcast( - data_form_topic(entity_uuid, record_key), - {:data_form_change, entity_uuid, normalize_record_key(record_key), payload, - Keyword.get(opts, :source)} - ) - end - - ## State synchronization for new joiners - - def broadcast_entity_form_sync_request(form_key, requester_socket_id) do - broadcast( - entity_form_topic(form_key), - {:entity_form_sync_request, form_key, requester_socket_id} - ) - end - - def broadcast_entity_form_sync_response(form_key, requester_socket_id, state) do - broadcast( - entity_form_topic(form_key), - {:entity_form_sync_response, form_key, requester_socket_id, state} - ) - end - - def broadcast_data_form_sync_request(entity_uuid, record_key, requester_socket_id) do - broadcast( - data_form_topic(entity_uuid, record_key), - {:data_form_sync_request, entity_uuid, normalize_record_key(record_key), - requester_socket_id} - ) - end - - def broadcast_data_form_sync_response(entity_uuid, record_key, requester_socket_id, state) do - broadcast( - data_form_topic(entity_uuid, record_key), - {:data_form_sync_response, entity_uuid, normalize_record_key(record_key), - requester_socket_id, state} - ) - end - - ## Topic helpers - - defp data_topic(entity_uuid), do: "#{@topic_data}:#{entity_uuid}" - - defp entity_form_topic(form_key), do: "#{@topic_entity_forms}:#{form_key}" - - defp data_form_topic(entity_uuid, record_key), - do: "#{@topic_data_forms}:#{entity_uuid}:#{normalize_record_key(record_key)}" - - defp entity_presence_topic(entity_uuid), - do: "phoenix_kit:entities:presence:entity:#{entity_uuid}" - - defp data_presence_topic(entity_uuid, data_uuid), - do: "phoenix_kit:entities:presence:data:#{entity_uuid}:#{data_uuid}" - - defp normalize_record_key({:new, slug}), do: "new-#{slug}" - defp normalize_record_key(record_key) when is_atom(record_key), do: Atom.to_string(record_key) - - defp normalize_record_key(record_key), do: to_string(record_key) - - defp broadcast(topic, payload), do: Manager.broadcast(topic, payload) -end diff --git a/lib/modules/entities/field_type.ex b/lib/modules/entities/field_type.ex deleted file mode 100644 index 43a56b3a4..000000000 --- a/lib/modules/entities/field_type.ex +++ /dev/null @@ -1,52 +0,0 @@ -defmodule PhoenixKit.Modules.Entities.FieldType do - @moduledoc """ - Struct representing an entity field type definition. - - ## Fields - - - `name` - Field type identifier (e.g., `"text"`, `"select"`) - - `label` - Human-readable label (e.g., `"Text"`, `"Select Dropdown"`) - - `description` - Short description of the field type - - `category` - Category atom (`:basic`, `:numeric`, `:boolean`, `:datetime`, `:choice`, `:advanced`) - - `icon` - Heroicon name for rendering - - `requires_options` - Whether the field type requires options to be defined - - `default_props` - Default properties for new fields of this type - """ - - @enforce_keys [:name, :label, :category] - defstruct [ - :name, - :label, - :description, - :category, - :icon, - requires_options: false, - default_props: %{} - ] - - @type t :: %__MODULE__{ - name: String.t(), - label: String.t(), - description: String.t() | nil, - category: :basic | :numeric | :boolean | :datetime | :choice | :advanced, - icon: String.t() | nil, - requires_options: boolean(), - default_props: map() - } - - @doc """ - Converts a plain map to a `%FieldType{}` struct. - """ - @spec from_map(map()) :: t() - def from_map(map) when is_map(map) do - %__MODULE__{ - name: map[:name] || map["name"], - label: map[:label] || map["label"], - description: map[:description] || map["description"], - category: map[:category] || map["category"], - icon: map[:icon] || map["icon"], - requires_options: map[:requires_options] || map["requires_options"] || false, - default_props: map[:default_props] || map["default_props"] || %{} - } - end -end diff --git a/lib/modules/entities/field_types.ex b/lib/modules/entities/field_types.ex deleted file mode 100644 index fe1c043c4..000000000 --- a/lib/modules/entities/field_types.ex +++ /dev/null @@ -1,629 +0,0 @@ -defmodule PhoenixKit.Modules.Entities.FieldTypes do - @moduledoc """ - Field type definitions and utilities for the Entities system. - - This module defines all supported field types for entity definitions, - including their properties, validation rules, and rendering information. - - ## Supported Field Types - - ### Basic Text Types - - **text**: Single-line text input - - **textarea**: Multi-line text area - - **email**: Email address with validation - - **url**: URL with validation - - **rich_text**: Rich HTML editor (TinyMCE/CKEditor-like) - - ### Numeric Types - - **number**: Numeric input (integer or decimal) - - ### Boolean Types - - **boolean**: True/false toggle or checkbox - - ### Date/Time Types - - **date**: Date picker (YYYY-MM-DD format) - - ### Choice Types - - **select**: Dropdown selection (single choice) - - **radio**: Radio button group (single choice) - - **checkbox**: Checkbox group (multiple choices) - - ## Usage Examples - - # Get all field types - field_types = PhoenixKit.Modules.Entities.FieldTypes.all() - - # Get field type info - text_info = PhoenixKit.Modules.Entities.FieldTypes.get_type("text") - - # Get field types by category - basic_types = PhoenixKit.Modules.Entities.FieldTypes.by_category(:basic) - - # Check if field type requires options - PhoenixKit.Modules.Entities.FieldTypes.requires_options?("select") # => true - """ - - alias PhoenixKit.Modules.Entities.FieldType - - @type field_type :: String.t() - @type field_category :: - :basic | :numeric | :boolean | :datetime | :choice | :advanced - - @field_types %{ - "text" => %{ - name: "text", - label: "Text", - description: "Single-line text input", - category: :basic, - icon: "hero-pencil", - requires_options: false, - default_props: %{ - "placeholder" => "", - "max_length" => 255 - } - }, - "textarea" => %{ - name: "textarea", - label: "Text Area", - description: "Multi-line text input", - category: :basic, - icon: "hero-document-text", - requires_options: false, - default_props: %{ - "placeholder" => "", - "rows" => 4, - "max_length" => 5000 - } - }, - "email" => %{ - name: "email", - label: "Email", - description: "Email address with validation", - category: :basic, - icon: "hero-envelope", - requires_options: false, - default_props: %{ - "placeholder" => "user@example.com" - } - }, - "url" => %{ - name: "url", - label: "URL", - description: "Website URL with validation", - category: :basic, - icon: "hero-link", - requires_options: false, - default_props: %{ - "placeholder" => "https://example.com" - } - }, - "rich_text" => %{ - name: "rich_text", - label: "Rich Text Editor", - description: "WYSIWYG HTML editor", - category: :basic, - icon: "hero-document-text", - requires_options: false, - default_props: %{ - "toolbar" => "basic" - } - }, - "number" => %{ - name: "number", - label: "Number", - description: "Numeric input (integer or decimal)", - category: :numeric, - icon: "hero-hashtag", - requires_options: false, - default_props: %{ - "min" => nil, - "max" => nil, - "step" => 1 - } - }, - "boolean" => %{ - name: "boolean", - label: "Boolean", - description: "True/false toggle", - category: :boolean, - icon: "hero-check-circle", - requires_options: false, - default_props: %{ - "default" => false - } - }, - "date" => %{ - name: "date", - label: "Date", - description: "Date picker", - category: :datetime, - icon: "hero-calendar", - requires_options: false, - default_props: %{ - "format" => "Y-m-d" - } - }, - "select" => %{ - name: "select", - label: "Select Dropdown", - description: "Dropdown selection (single choice)", - category: :choice, - icon: "hero-chevron-down", - requires_options: true, - default_props: %{ - "placeholder" => "Select an option...", - "allow_empty" => true - } - }, - "radio" => %{ - name: "radio", - label: "Radio Buttons", - description: "Radio button group (single choice)", - category: :choice, - icon: "hero-check-circle", - requires_options: true, - default_props: %{} - }, - "checkbox" => %{ - name: "checkbox", - label: "Checkboxes", - description: "Checkbox group (multiple choices)", - category: :choice, - icon: "hero-check", - requires_options: true, - default_props: %{ - "allow_multiple" => true - } - }, - "file" => %{ - name: "file", - label: "File Upload", - description: "File upload field with configurable constraints", - category: :advanced, - icon: "hero-document-arrow-up", - requires_options: false, - default_props: %{ - "max_entries" => 5, - "max_file_size" => 15_728_640, - "accept" => [".pdf", ".jpg", ".jpeg", ".png"] - } - } - } - - @doc """ - Returns all field types as a map of `%FieldType{}` structs. - - ## Examples - - iex> PhoenixKit.Modules.Entities.FieldTypes.all() - %{"text" => %FieldType{name: "text", ...}, ...} - """ - @spec all() :: %{String.t() => FieldType.t()} - def all do - Map.new(@field_types, fn {key, map} -> {key, FieldType.from_map(map)} end) - end - - @doc """ - Returns a list of all field type names. - - ## Examples - - iex> PhoenixKit.Modules.Entities.FieldTypes.list_types() - ["text", "textarea", "number", ...] - """ - def list_types do - Map.keys(@field_types) - end - - @doc """ - Gets information about a specific field type. - - Returns nil if the type doesn't exist. - - ## Examples - - iex> PhoenixKit.Modules.Entities.FieldTypes.get_type("text") - %FieldType{name: "text", label: "Text", ...} - - iex> PhoenixKit.Modules.Entities.FieldTypes.get_type("invalid") - nil - """ - @spec get_type(String.t()) :: FieldType.t() | nil - def get_type(type_name) when is_binary(type_name) do - case Map.get(@field_types, type_name) do - nil -> nil - map -> FieldType.from_map(map) - end - end - - @doc """ - Checks if a field type exists. - - ## Examples - - iex> PhoenixKit.Modules.Entities.FieldTypes.valid_type?("text") - true - - iex> PhoenixKit.Modules.Entities.FieldTypes.valid_type?("invalid") - false - """ - def valid_type?(type_name) when is_binary(type_name) do - Map.has_key?(@field_types, type_name) - end - - @doc """ - Returns field types grouped by category. - - ## Examples - - iex> PhoenixKit.Modules.Entities.FieldTypes.by_category(:basic) - [%FieldType{name: "text", ...}, %FieldType{name: "textarea", ...}, ...] - """ - @spec by_category(field_category()) :: [FieldType.t()] - def by_category(category) when is_atom(category) do - @field_types - |> Map.values() - |> Enum.filter(fn type -> type.category == category end) - |> Enum.map(&FieldType.from_map/1) - end - - @doc """ - Returns all categories with their field types. - - ## Examples - - iex> PhoenixKit.Modules.Entities.FieldTypes.categories() - %{ - basic: [%{name: "text", ...}, ...], - numeric: [%{name: "number", ...}], - ... - } - """ - def categories do - @field_types - |> Map.values() - |> Enum.map(&FieldType.from_map/1) - |> Enum.group_by(& &1.category) - end - - @doc """ - Returns a list of category names with labels. - - ## Examples - - iex> PhoenixKit.Modules.Entities.FieldTypes.category_list() - [ - {:basic, "Basic"}, - {:numeric, "Numeric"}, - ... - ] - """ - def category_list do - [ - {:basic, "Basic"}, - {:numeric, "Numeric"}, - {:boolean, "Boolean"}, - {:datetime, "Date & Time"}, - {:choice, "Choice"}, - {:advanced, "Advanced"} - ] - end - - @doc """ - Checks if a field type requires options to be defined. - - ## Examples - - iex> PhoenixKit.Modules.Entities.FieldTypes.requires_options?("select") - true - - iex> PhoenixKit.Modules.Entities.FieldTypes.requires_options?("text") - false - """ - def requires_options?(type_name) when is_binary(type_name) do - case get_type(type_name) do - nil -> false - type_info -> Map.get(type_info, :requires_options, false) - end - end - - @doc """ - Gets the default properties for a field type. - - ## Examples - - iex> PhoenixKit.Modules.Entities.FieldTypes.default_props("text") - %{"placeholder" => "", "max_length" => 255} - """ - def default_props(type_name) when is_binary(type_name) do - case get_type(type_name) do - nil -> %{} - type_info -> Map.get(type_info, :default_props, %{}) - end - end - - @doc """ - Returns field types suitable for a field picker UI. - - Formats the data for use in select dropdowns or type choosers. - - ## Examples - - iex> PhoenixKit.Modules.Entities.FieldTypes.for_picker() - [ - %{value: "text", label: "Text", category: "Basic", icon: "hero-pencil"}, - ... - ] - """ - def for_picker do - category_labels = Map.new(category_list()) - - @field_types - |> Map.values() - |> Enum.map(fn type -> - %{ - value: type.name, - label: type.label, - description: type.description, - category: Map.get(category_labels, type.category, "Other"), - icon: type.icon, - requires_options: type.requires_options - } - end) - |> Enum.sort_by(& &1.category) - end - - @doc """ - Validates a field definition map. - - Checks that the field has all required properties and valid values. - - ## Examples - - iex> field = %{"type" => "text", "key" => "title", "label" => "Title"} - iex> PhoenixKit.Modules.Entities.FieldTypes.validate_field(field) - {:ok, field} - - iex> invalid_field = %{"type" => "invalid", "key" => "test"} - iex> PhoenixKit.Modules.Entities.FieldTypes.validate_field(invalid_field) - {:error, "Invalid field type: invalid"} - """ - def validate_field(field) when is_map(field) do - with {:ok, field} <- validate_required_keys(field), - {:ok, field} <- validate_type(field) do - validate_options(field) - end - end - - defp validate_required_keys(field) do - required = ["type", "key", "label"] - missing = required -- Map.keys(field) - - if Enum.empty?(missing) do - {:ok, field} - else - {:error, "Missing required keys: #{Enum.join(missing, ", ")}"} - end - end - - defp validate_type(field) do - if valid_type?(field["type"]) do - {:ok, field} - else - {:error, "Invalid field type: #{field["type"]}"} - end - end - - defp validate_options(field) do - if requires_options?(field["type"]) do - options = Map.get(field, "options", []) - - if is_list(options) && not Enum.empty?(options) do - {:ok, field} - else - {:error, "Field type '#{field["type"]}' requires options"} - end - else - {:ok, field} - end - end - - @doc """ - Creates a new field definition with default values. - - ## Examples - - iex> PhoenixKit.Modules.Entities.FieldTypes.new_field("text", "my_field", "My Field") - %{ - "type" => "text", - "key" => "my_field", - "label" => "My Field", - "required" => false, - "default" => "", - "validation" => %{}, - "placeholder" => "", - "max_length" => 255 - } - - # With options for choice fields - iex> PhoenixKit.Modules.Entities.FieldTypes.new_field("select", "category", "Category", options: ["Tech", "Business"]) - %{ - "type" => "select", - "key" => "category", - "label" => "Category", - "required" => false, - "options" => ["Tech", "Business"], - ... - } - - # With required flag - iex> PhoenixKit.Modules.Entities.FieldTypes.new_field("text", "name", "Name", required: true) - %{"type" => "text", "key" => "name", "label" => "Name", "required" => true, ...} - """ - def new_field(type, key, label, opts \\ []) - - def new_field(type, key, label, opts) - when is_binary(type) and is_binary(key) and is_binary(label) do - options = Keyword.get(opts, :options, []) - required = Keyword.get(opts, :required, false) - default = Keyword.get(opts, :default, nil) - - base_field = %{ - "type" => type, - "key" => key, - "label" => label, - "required" => required, - "default" => default, - "validation" => %{} - } - - # Add options for choice fields - base_field = - if requires_options?(type) or options != [] do - Map.put(base_field, "options", options) - else - base_field - end - - # Merge with type-specific default props - props = default_props(type) - Map.merge(base_field, props) - end - - @doc """ - Helper to create a select field with options. - - ## Examples - - iex> PhoenixKit.Modules.Entities.FieldTypes.select_field("category", "Category", ["Tech", "Business", "Other"]) - %{"type" => "select", "key" => "category", "label" => "Category", "options" => ["Tech", "Business", "Other"], ...} - - iex> PhoenixKit.Modules.Entities.FieldTypes.select_field("status", "Status", ["Active", "Inactive"], required: true) - %{"type" => "select", "key" => "status", "label" => "Status", "options" => ["Active", "Inactive"], "required" => true, ...} - """ - def select_field(key, label, options, opts \\ []) when is_list(options) do - new_field("select", key, label, Keyword.put(opts, :options, options)) - end - - @doc """ - Helper to create a radio button field with options. - - ## Examples - - iex> PhoenixKit.Modules.Entities.FieldTypes.radio_field("priority", "Priority", ["Low", "Medium", "High"]) - %{"type" => "radio", "key" => "priority", "label" => "Priority", "options" => ["Low", "Medium", "High"], ...} - """ - def radio_field(key, label, options, opts \\ []) when is_list(options) do - new_field("radio", key, label, Keyword.put(opts, :options, options)) - end - - @doc """ - Helper to create a checkbox field with options. - - ## Examples - - iex> PhoenixKit.Modules.Entities.FieldTypes.checkbox_field("tags", "Tags", ["Featured", "Popular", "New"]) - %{"type" => "checkbox", "key" => "tags", "label" => "Tags", "options" => ["Featured", "Popular", "New"], ...} - """ - def checkbox_field(key, label, options, opts \\ []) when is_list(options) do - new_field("checkbox", key, label, Keyword.put(opts, :options, options)) - end - - @doc """ - Helper to create a text field. - - ## Examples - - iex> PhoenixKit.Modules.Entities.FieldTypes.text_field("name", "Full Name", required: true) - %{"type" => "text", "key" => "name", "label" => "Full Name", "required" => true, ...} - """ - def text_field(key, label, opts \\ []) do - new_field("text", key, label, opts) - end - - @doc """ - Helper to create a textarea field. - - ## Examples - - iex> PhoenixKit.Modules.Entities.FieldTypes.textarea_field("bio", "Biography") - %{"type" => "textarea", "key" => "bio", "label" => "Biography", ...} - """ - def textarea_field(key, label, opts \\ []) do - new_field("textarea", key, label, opts) - end - - @doc """ - Helper to create an email field. - - ## Examples - - iex> PhoenixKit.Modules.Entities.FieldTypes.email_field("email", "Email Address", required: true) - %{"type" => "email", "key" => "email", "label" => "Email Address", "required" => true, ...} - """ - def email_field(key, label, opts \\ []) do - new_field("email", key, label, opts) - end - - @doc """ - Helper to create a number field. - - ## Examples - - iex> PhoenixKit.Modules.Entities.FieldTypes.number_field("age", "Age") - %{"type" => "number", "key" => "age", "label" => "Age", ...} - """ - def number_field(key, label, opts \\ []) do - new_field("number", key, label, opts) - end - - @doc """ - Helper to create a boolean field. - - ## Examples - - iex> PhoenixKit.Modules.Entities.FieldTypes.boolean_field("active", "Is Active", default: true) - %{"type" => "boolean", "key" => "active", "label" => "Is Active", "default" => true, ...} - """ - def boolean_field(key, label, opts \\ []) do - new_field("boolean", key, label, opts) - end - - @doc """ - Helper to create a rich text field. - - ## Examples - - iex> PhoenixKit.Modules.Entities.FieldTypes.rich_text_field("content", "Content", required: true) - %{"type" => "rich_text", "key" => "content", "label" => "Content", "required" => true, ...} - """ - def rich_text_field(key, label, opts \\ []) do - new_field("rich_text", key, label, opts) - end - - @doc """ - Helper to create a file upload field. - - ## Examples - - iex> PhoenixKit.Modules.Entities.FieldTypes.file_field("attachments", "Attachments") - %{"type" => "file", "key" => "attachments", "label" => "Attachments", ...} - - iex> PhoenixKit.Modules.Entities.FieldTypes.file_field("docs", "Documents", - max_entries: 10, max_file_size: 52428800, accept: [".pdf", ".docx"]) - %{"type" => "file", "key" => "docs", "label" => "Documents", - "max_entries" => 10, "max_file_size" => 52428800, "accept" => [".pdf", ".docx"], ...} - """ - def file_field(key, label, opts \\ []) do - base_field = new_field("file", key, label, opts) - - # Override with specific max_entries, max_file_size, accept if provided - base_field - |> maybe_put("max_entries", Keyword.get(opts, :max_entries)) - |> maybe_put("max_file_size", Keyword.get(opts, :max_file_size)) - |> maybe_put("accept", Keyword.get(opts, :accept)) - end - - defp maybe_put(map, _key, nil), do: map - defp maybe_put(map, key, value), do: Map.put(map, key, value) -end diff --git a/lib/modules/entities/form_builder.ex b/lib/modules/entities/form_builder.ex deleted file mode 100644 index 55af1912f..000000000 --- a/lib/modules/entities/form_builder.ex +++ /dev/null @@ -1,967 +0,0 @@ -defmodule PhoenixKit.Modules.Entities.FormBuilder do - @moduledoc """ - Dynamic form builder for entity data forms. - - This module generates Phoenix.Component forms based on entity field definitions, - enabling dynamic data entry forms that adapt to the entity's schema. - - ## Usage - - # Generate form fields for an entity - fields_html = PhoenixKit.Modules.Entities.FormBuilder.build_fields(entity, changeset) - - # Generate a single field - field_html = PhoenixKit.Modules.Entities.FormBuilder.build_field(field_definition, changeset) - - # Validate entity data against field definitions - {:ok, validated_data} = PhoenixKit.Modules.Entities.FormBuilder.validate_data(entity, data_params) - - ## Field Type Support - - The FormBuilder supports all field types defined in `PhoenixKit.Modules.Entities.FieldTypes`: - - - **Basic Types**: text, textarea, email, url, rich_text - - **Numeric Types**: number - - **Boolean Types**: boolean (toggle/checkbox) - - **Date Types**: date - - **Choice Types**: select, radio, checkbox (with options) - - **Media Types**: image, file (upload) - - **Relational Types**: relation (entity references) - - ## Form Generation - - Forms are generated as Phoenix.Component HTML with proper validation, - error handling, and styling consistent with the PhoenixKit design system. - """ - - import Phoenix.Component - import PhoenixKitWeb.Components.Core.Icon, only: [icon: 1] - import PhoenixKitWeb.Components.Core.FormFieldLabel, only: [label: 1] - use Gettext, backend: PhoenixKitWeb.Gettext - - alias PhoenixKit.Modules.Entities.Multilang - - @doc """ - Builds form fields HTML for an entire entity. - - Takes an entity with its field definitions and generates the complete - form HTML for data entry. - - ## Parameters - - - `entity` - The entity struct with fields_definition - - `changeset` - The changeset for the entity data - - `opts` - Optional configuration (default: []) - - ## Options - - - `:wrapper_class` - CSS class for field wrapper divs - - `:input_class` - CSS class for input elements - - `:label_class` - CSS class for label elements - - ## Examples - - iex> entity = %Entities{fields_definition: [ - ...> %{"type" => "text", "key" => "title", "label" => "Title", "required" => true} - ...> ]} - iex> changeset = Ecto.Changeset.cast(%{}, %{}, []) - iex> PhoenixKit.Modules.Entities.FormBuilder.build_fields(entity, changeset) - # Returns Phoenix.Component form HTML - """ - def build_fields(entity, changeset, opts \\ []) do - fields_definition = entity.fields_definition || [] - lang_code = opts[:lang_code] - - # For secondary languages, extract primary data for placeholder text - opts = maybe_add_primary_placeholders(opts, changeset, entity, lang_code) - - # When multilang: extract language-specific data into a view changeset - # so all existing build_field/get_field_value calls work unchanged. - changeset = maybe_apply_language_view(changeset, entity, lang_code) - - assigns = %{ - fields_definition: fields_definition, - changeset: changeset, - opts: opts - } - - ~H""" -
- <%= for field <- @fields_definition do %> -
- {build_field(field, @changeset, @opts)} -
- <% end %> -
- """ - end - - # When a lang_code is provided, extract that language's data (merged with - # primary) and replace the :data field in the changeset so downstream - # build_field calls read the correct values via get_field_value/2. - defp maybe_apply_language_view(changeset, _entity, nil), do: changeset - - defp maybe_apply_language_view(%Phoenix.HTML.Form{} = form, _entity, lang_code) do - data = Ecto.Changeset.get_field(form.source, :data) - - if Multilang.multilang_data?(data) do - lang_data = Multilang.get_language_data(data, lang_code) - updated_changeset = Ecto.Changeset.put_change(form.source, :data, lang_data) - %{form | source: updated_changeset} - else - form - end - end - - defp maybe_apply_language_view(changeset, _entity, lang_code) do - data = Ecto.Changeset.get_field(changeset, :data) - - if Multilang.multilang_data?(data) do - lang_data = Multilang.get_language_data(data, lang_code) - Ecto.Changeset.put_change(changeset, :data, lang_data) - else - changeset - end - end - - # ── Multilang placeholder helpers ────────────────────────────── - - defp maybe_add_primary_placeholders(opts, _changeset, _entity, nil), do: opts - - defp maybe_add_primary_placeholders(opts, changeset, _entity, lang_code) do - primary = Multilang.primary_language() - - if lang_code == primary do - opts - else - data = extract_data_from_changeset(changeset) - - if Multilang.multilang_data?(data) do - primary_data = Multilang.get_primary_data(data) - Keyword.put(opts, :primary_placeholders, primary_data) - else - opts - end - end - end - - defp extract_data_from_changeset(%Phoenix.HTML.Form{} = form), - do: Ecto.Changeset.get_field(form.source, :data) - - defp extract_data_from_changeset(changeset), - do: Ecto.Changeset.get_field(changeset, :data) - - defp get_effective_placeholder(field, opts, default \\ "") do - case opts[:primary_placeholders] do - %{} = primary_data -> - primary_value = Map.get(primary_data, field["key"]) - - if primary_value != nil and to_string(primary_value) != "" do - to_string(primary_value) - else - field["placeholder"] || default - end - - _ -> - field["placeholder"] || default - end - end - - # For text-like fields on secondary languages, show empty when value - # matches primary (inherited) — the primary value appears as placeholder. - defp get_effective_text_value(changeset, field_key, opts) do - current = get_field_value(changeset, field_key) - - case opts[:primary_placeholders] do - %{} = primary_data -> - primary_value = Map.get(primary_data, field_key) - if inherited_value?(current, primary_value), do: nil, else: current - - _ -> - current - end - end - - defp inherited_value?(nil, _), do: true - defp inherited_value?("", _), do: true - defp inherited_value?(a, b), do: to_string(a) == to_string(b) - - @doc """ - Builds a single form field based on field definition. - - ## Parameters - - - `field` - Field definition map - - `changeset` - The changeset for validation and values - - `opts` - Optional configuration - - ## Examples - - iex> field = %{"type" => "text", "key" => "title", "label" => "Title"} - iex> changeset = Ecto.Changeset.cast(%{}, %{}, []) - iex> PhoenixKit.Modules.Entities.FormBuilder.build_field(field, changeset) - # Returns Phoenix.Component field HTML - """ - def build_field(field, changeset, opts \\ []) - - # Text Input - def build_field(%{"type" => "text"} = field, changeset, opts) do - placeholder = get_effective_placeholder(field, opts) - value = get_effective_text_value(changeset, field["key"], opts) - - assigns = %{ - field: field, - changeset: changeset, - opts: opts, - placeholder: placeholder, - value: value - } - - ~H""" -
- <.label> - {@field["label"]}{if @field["required"] && !@opts[:primary_placeholders], do: " *"} - - - <%= if @field["description"] do %> - <.label class="label"> - {@field["description"]} - - <% end %> -
- """ - end - - # Textarea - def build_field(%{"type" => "textarea"} = field, changeset, opts) do - placeholder = get_effective_placeholder(field, opts) - value = get_effective_text_value(changeset, field["key"], opts) - - assigns = %{ - field: field, - changeset: changeset, - opts: opts, - placeholder: placeholder, - value: value - } - - ~H""" -
- <.label for={@field["key"]}> - {@field["label"]}{if @field["required"] && !@opts[:primary_placeholders], do: " *"} - - - <%= if @field["description"] do %> - <.label class="label"> - {@field["description"]} - - <% end %> -
- """ - end - - # Email Input - def build_field(%{"type" => "email"} = field, changeset, opts) do - placeholder = get_effective_placeholder(field, opts, gettext("user@example.com")) - value = get_effective_text_value(changeset, field["key"], opts) - - assigns = %{ - field: field, - changeset: changeset, - opts: opts, - placeholder: placeholder, - value: value - } - - ~H""" -
- <.label for={@field["key"]}> - {@field["label"]}{if @field["required"] && !@opts[:primary_placeholders], do: " *"} - - - <%= if @field["description"] do %> - <.label class="label"> - {@field["description"]} - - <% end %> -
- """ - end - - # URL Input - def build_field(%{"type" => "url"} = field, changeset, opts) do - placeholder = get_effective_placeholder(field, opts, gettext("https://example.com")) - value = get_effective_text_value(changeset, field["key"], opts) - - assigns = %{ - field: field, - changeset: changeset, - opts: opts, - placeholder: placeholder, - value: value - } - - ~H""" -
- <.label for={@field["key"]}> - {@field["label"]}{if @field["required"] && !@opts[:primary_placeholders], do: " *"} - - - <%= if @field["description"] do %> - <.label class="label"> - {@field["description"]} - - <% end %> -
- """ - end - - # Rich Text Editor - def build_field(%{"type" => "rich_text"} = field, changeset, opts) do - placeholder = get_effective_placeholder(field, opts, gettext("Enter rich text content...")) - value = get_effective_text_value(changeset, field["key"], opts) - - assigns = %{ - field: field, - changeset: changeset, - opts: opts, - placeholder: placeholder, - value: value - } - - ~H""" -
- <.label for={@field["key"]}> - {@field["label"]}{if @field["required"] && !@opts[:primary_placeholders], do: " *"} - - - <.label class="label"> - {gettext("Rich text editor (HTML supported)")} - - <%= if @field["description"] do %> - <.label class="label"> - {@field["description"]} - - <% end %> -
- """ - end - - # Number Input - def build_field(%{"type" => "number"} = field, changeset, opts) do - placeholder = get_effective_placeholder(field, opts) - value = get_effective_text_value(changeset, field["key"], opts) - - assigns = %{ - field: field, - changeset: changeset, - opts: opts, - placeholder: placeholder, - value: value - } - - ~H""" -
- <.label for={@field["key"]}> - {@field["label"]}{if @field["required"] && !@opts[:primary_placeholders], do: " *"} - - - <%= if @field["description"] do %> - <.label class="label"> - {@field["description"]} - - <% end %> -
- """ - end - - # Boolean Toggle - def build_field(%{"type" => "boolean"} = field, changeset, opts) do - field_value = get_field_value(changeset, field["key"]) - is_checked = field_value in [true, "true", "1", 1] - - assigns = %{field: field, changeset: changeset, opts: opts, is_checked: is_checked} - - ~H""" -
- <.label> - {@field["label"]}{if @field["required"] && !@opts[:primary_placeholders], do: " *"} - -
- -
- <%= if @field["description"] do %> - <.label class="label"> - {@field["description"]} - - <% end %> -
- """ - end - - # Date Input - def build_field(%{"type" => "date"} = field, changeset, opts) do - assigns = %{field: field, changeset: changeset, opts: opts} - - ~H""" -
- <.label for={@field["key"]}> - {@field["label"]}{if @field["required"] && !@opts[:primary_placeholders], do: " *"} - - - <%= if @field["description"] do %> - <.label class="label"> - {@field["description"]} - - <% end %> -
- """ - end - - # Select Dropdown - def build_field(%{"type" => "select"} = field, changeset, opts) do - assigns = %{field: field, changeset: changeset, opts: opts} - - ~H""" -
- <.label for={@field["key"]}> - {@field["label"]}{if @field["required"] && !@opts[:primary_placeholders], do: " *"} - - - <%= if @field["description"] do %> - <.label class="label"> - {@field["description"]} - - <% end %> -
- """ - end - - # Radio Buttons - def build_field(%{"type" => "radio"} = field, changeset, opts) do - assigns = %{field: field, changeset: changeset, opts: opts} - - ~H""" -
- <.label> - {@field["label"]}{if @field["required"] && !@opts[:primary_placeholders], do: " *"} - -
- <%= for {option, index} <- Enum.with_index(@field["options"] || []) do %> - - <% end %> -
- <%= if @field["description"] do %> - <.label class="label"> - {@field["description"]} - - <% end %> -
- """ - end - - # Checkbox Group - def build_field(%{"type" => "checkbox"} = field, changeset, opts) do - assigns = %{field: field, changeset: changeset, opts: opts} - - ~H""" -
- <.label> - {@field["label"]}{if @field["required"] && !@opts[:primary_placeholders], do: " *"} - -
- <%= for {option, index} <- Enum.with_index(@field["options"] || []) do %> - - <% end %> -
- <%= if @field["description"] do %> - <.label class="label"> - {@field["description"]} - - <% end %> -
- """ - end - - # Image Upload (placeholder - not yet implemented) - def build_field(%{"type" => "image"} = field, changeset, opts) do - assigns = %{field: field, changeset: changeset, opts: opts} - - ~H""" -
- <.label> - {@field["label"]}{if @field["required"] && !@opts[:primary_placeholders], do: " *"} - -
- <.icon name="hero-photo" class="w-12 h-12 mx-auto text-base-content/40 mb-3" /> -

- {gettext("Image upload coming soon")} -

-

- {gettext("This feature is not yet available")} -

-
- <%= if @field["description"] do %> - <.label class="label"> - {@field["description"]} - - <% end %> -
- """ - end - - # File Upload (admin entity forms - requires LiveView upload configuration) - def build_field(%{"type" => "file"} = field, changeset, opts) do - # Get current value from changeset (array of file metadata) - current_files = get_field_value(changeset, field["key"]) || [] - - # Extract upload configuration - max_entries = field["max_entries"] || 5 - max_file_size_mb = Float.round((field["max_file_size"] || 15_728_640) / 1_048_576, 1) - accept_list = field["accept"] || [".pdf", ".jpg", ".jpeg", ".png"] - - accept_display = - Enum.map_join(accept_list, ", ", fn ext -> - ext |> String.replace_prefix(".", "") |> String.upcase() - end) - - assigns = %{ - field: field, - changeset: changeset, - opts: opts, - current_files: current_files, - max_entries: max_entries, - max_file_size_mb: max_file_size_mb, - accept_display: accept_display - } - - ~H""" -
- <.label> - {@field["label"]}{if @field["required"] && !@opts[:primary_placeholders], do: " *"} - - - <%!-- Display current files if any --%> - <%= if @current_files != [] and is_list(@current_files) do %> -
-

- {gettext("Current files:")} -

- <%= for file <- @current_files do %> -
- <.icon name="hero-document" class="w-4 h-4 text-base-content/60" /> - {file["filename"] || gettext("Unknown file")} - <%= if file["size"] do %> - - {format_bytes(file["size"])} - - <% end %> -
- <% end %> -
- <% end %> - - <%!-- File upload placeholder for admin forms --%> -
- <.icon name="hero-document-arrow-up" class="w-12 h-12 mx-auto text-base-content/40 mb-3" /> -

- {gettext("File upload in admin forms requires LiveView upload configuration")} -

-

- {gettext("File uploads work in public forms (contact forms, etc.)")} -

- - <%!-- Show field configuration --%> -
-

{gettext("Field Configuration:")}

-

- • {gettext("Accepted types:")} {@accept_display} -

-

- • {gettext("Max files:")} {@max_entries} -

-

- • {gettext("Max size:")} {@max_file_size_mb} MB {gettext("per file")} -

-
-
- - <%= if @field["description"] do %> - <.label class="label"> - {@field["description"]} - - <% end %> -
- """ - end - - # Relation Field (placeholder - not yet implemented) - def build_field(%{"type" => "relation"} = field, changeset, opts) do - assigns = %{field: field, changeset: changeset, opts: opts} - - ~H""" -
- <.label> - {@field["label"]}{if @field["required"] && !@opts[:primary_placeholders], do: " *"} - -
- <.icon name="hero-link" class="w-12 h-12 mx-auto text-base-content/40 mb-3" /> -

- {gettext("Entity relations coming soon")} -

-

- {gettext("This feature is not yet available")} -

-
- <%= if @field["description"] do %> - <.label class="label"> - {@field["description"]} - - <% end %> -
- """ - end - - # Fallback for unknown field types - def build_field(field, changeset, opts) do - assigns = %{field: field, changeset: changeset, opts: opts} - - ~H""" -
- <.icon name="hero-exclamation-triangle" class="w-5 h-5" /> - {gettext("Unknown field type: %{type}", type: @field["type"])} -
- """ - end - - # Helper function to format file sizes - defp format_bytes(bytes) when bytes < 1024, do: "#{bytes} B" - - defp format_bytes(bytes) when bytes < 1_048_576 do - "#{Float.round(bytes / 1024, 1)} KB" - end - - defp format_bytes(bytes) do - "#{Float.round(bytes / 1_048_576, 1)} MB" - end - - @doc """ - Validates entity data against field definitions. - - Takes entity field definitions and validates submitted data parameters - according to the field types, requirements, and constraints. - - ## Parameters - - - `entity` - The entity with field definitions - - `data_params` - Map of submitted data parameters - - ## Returns - - - `{:ok, validated_data}` - Successfully validated data - - `{:error, errors}` - Validation errors - - ## Examples - - iex> entity = %Entities{fields_definition: [ - ...> %{"type" => "text", "key" => "title", "required" => true} - ...> ]} - iex> PhoenixKit.Modules.Entities.FormBuilder.validate_data(entity, %{"title" => "Test"}) - {:ok, %{"title" => "Test"}} - - iex> PhoenixKit.Modules.Entities.FormBuilder.validate_data(entity, %{}) - {:error, %{"title" => ["is required"]}} - """ - def validate_data(entity, data_params, lang_code \\ nil) - - def validate_data(entity, data_params, nil) do - fields_definition = entity.fields_definition || [] - errors = %{} - validated_data = %{} - - result = - Enum.reduce(fields_definition, {validated_data, errors}, fn field, {data_acc, errors_acc} -> - field_key = field["key"] - field_value = Map.get(data_params, field_key) - - case validate_field_value(field, field_value) do - {:ok, validated_value} -> - {Map.put(data_acc, field_key, validated_value), errors_acc} - - {:error, field_errors} -> - {data_acc, Map.put(errors_acc, field_key, field_errors)} - end - end) - - case result do - {validated_data, errors} when map_size(errors) == 0 -> - {:ok, validated_data} - - {_data, errors} -> - {:error, errors} - end - end - - def validate_data(entity, data_params, lang_code) do - primary = Multilang.primary_language() - - if lang_code == primary do - # Primary language: full validation (same as default) - validate_data(entity, data_params, nil) - else - # Secondary language: type validation only, no required checks. - # Empty values are stripped (not stored as overrides). - validate_secondary_data(entity, data_params) - end - end - - defp validate_secondary_data(entity, data_params) do - fields_definition = entity.fields_definition || [] - - result = - Enum.reduce(fields_definition, {%{}, %{}}, fn field, {data_acc, errors_acc} -> - field_key = field["key"] - field_value = Map.get(data_params, field_key) - - case field_value do - nil -> - {data_acc, errors_acc} - - "" -> - {data_acc, errors_acc} - - value -> - case validate_type(field, value) do - {:ok, validated_value} -> - {Map.put(data_acc, field_key, validated_value), errors_acc} - - {:error, field_errors} -> - {data_acc, Map.put(errors_acc, field_key, field_errors)} - end - end - end) - - case result do - {validated_data, errors} when map_size(errors) == 0 -> - {:ok, validated_data} - - {_data, errors} -> - {:error, errors} - end - end - - @doc """ - Gets the current value of a field from a changeset. - - Helper function to extract field values from changesets or forms for form rendering. - """ - def get_field_value(%Phoenix.HTML.Form{} = form, field_key) do - # When passed a form, access the underlying changeset - # Use Ecto.Changeset.get_field to get the value from changes or fallback to struct - case Ecto.Changeset.get_field(form.source, :data) do - nil -> nil - data when is_map(data) -> Map.get(data, field_key) - _ -> nil - end - end - - def get_field_value(changeset, field_key) do - # When passed a changeset directly - case Ecto.Changeset.get_field(changeset, :data) do - nil -> nil - data when is_map(data) -> Map.get(data, field_key) - _ -> nil - end - end - - # Private Functions - - defp validate_field_value(field, value) do - with {:ok, value} <- validate_required(field, value) do - validate_type(field, value) - end - end - - defp validate_required(%{"required" => true}, value) when value in [nil, ""] do - {:error, [gettext("is required")]} - end - - defp validate_required(_field, value), do: {:ok, value} - - defp validate_type(%{"type" => "email"}, value) when is_binary(value) and value != "" do - if String.contains?(value, "@") do - {:ok, value} - else - {:error, [gettext("must be a valid email address")]} - end - end - - defp validate_type(%{"type" => "url"}, value) when is_binary(value) and value != "" do - normalized_value = - if String.starts_with?(value, ["http://", "https://"]) do - value - else - "https://#{value}" - end - - {:ok, normalized_value} - end - - defp validate_type(%{"type" => "number"}, value) when is_binary(value) and value != "" do - case Float.parse(value) do - {num, ""} -> {:ok, num} - _ -> {:error, [gettext("must be a valid number")]} - end - end - - defp validate_type(%{"type" => "boolean"}, value) do - cond do - value in [true, "true", "1", 1] -> {:ok, true} - value in [false, "false", "0", 0, nil, ""] -> {:ok, false} - true -> {:error, [gettext("must be true or false")]} - end - end - - defp validate_type(%{"type" => "select", "options" => options}, value) when is_list(options) do - cond do - value in [nil, ""] -> {:ok, nil} - value in options -> {:ok, value} - true -> {:error, [gettext("must be one of: %{options}", options: Enum.join(options, ", "))]} - end - end - - defp validate_type(%{"type" => "radio", "options" => options}, value) when is_list(options) do - cond do - value in [nil, ""] -> {:ok, nil} - value in options -> {:ok, value} - true -> {:error, [gettext("must be one of: %{options}", options: Enum.join(options, ", "))]} - end - end - - defp validate_type(%{"type" => "checkbox", "options" => options}, values) - when is_list(options) and is_list(values) do - invalid_values = values -- options - - if Enum.empty?(invalid_values) do - {:ok, values} - else - {:error, - [gettext("contains invalid options: %{invalid}", invalid: Enum.join(invalid_values, ", "))]} - end - end - - defp validate_type(_field, value), do: {:ok, value} -end diff --git a/lib/modules/entities/mirror/exporter.ex b/lib/modules/entities/mirror/exporter.ex deleted file mode 100644 index ce5378eb7..000000000 --- a/lib/modules/entities/mirror/exporter.ex +++ /dev/null @@ -1,218 +0,0 @@ -defmodule PhoenixKit.Modules.Entities.Mirror.Exporter do - @moduledoc """ - Exports entities and their data to JSON files. - - Each entity is exported as a single file containing: - - The entity definition (schema) - - All data records for that entity (when data mirroring is enabled) - - ## File Format - - { - "export_version": "1.0", - "exported_at": "2025-12-11T10:30:00Z", - "definition": { - "name": "brand", - "display_name": "Brand", - ... - }, - "data": [ - {"title": "Acme Corp", "slug": "acme-corp", ...}, - {"title": "Globex", "slug": "globex", ...} - ] - } - - """ - - alias PhoenixKit.Modules.Entities - alias PhoenixKit.Modules.Entities.EntityData - alias PhoenixKit.Modules.Entities.Mirror.Storage - alias PhoenixKit.Modules.Entities.Multilang - alias PhoenixKit.Utils.Date, as: UtilsDate - - @export_version "1.0" - - # ============================================================================ - # Export Operations - # ============================================================================ - - @doc """ - Exports a single entity with its definition and optionally data. - - ## Parameters - - `entity` - Entity struct or entity name string - - ## Returns - - `{:ok, file_path}` on success - - `{:error, reason}` on failure - """ - @spec export_entity(struct() | String.t()) :: - {:ok, String.t(), :with_data | :definition_only} | {:error, term()} - def export_entity(%{name: name} = entity) do - # Check per-entity mirror_data setting - include_data = Entities.mirror_data_enabled?(entity) - data_records = if include_data, do: get_entity_data(entity), else: [] - - content = build_export_content(entity, data_records) - - case Storage.write_entity(name, content) do - {:ok, path} -> {:ok, path, if(include_data, do: :with_data, else: :definition_only)} - {:error, reason} -> {:error, reason} - end - end - - def export_entity(entity_name) when is_binary(entity_name) do - case Entities.get_entity_by_name(entity_name) do - nil -> {:error, :entity_not_found} - entity -> export_entity(entity) - end - end - - @doc """ - Exports a single entity data record. - - This re-exports the entire entity file with updated data. - """ - @spec export_entity_data(struct()) :: {:ok, String.t()} | {:error, term()} - def export_entity_data(%{entity_uuid: entity_uuid} = _entity_data) do - case Entities.get_entity(entity_uuid) do - nil -> {:error, :entity_not_found} - entity -> export_entity(entity) - end - end - - @doc """ - Exports all entities (definitions only, no data). - """ - @spec export_all_entities() :: {:ok, [result]} when result: {:ok, String.t()} | {:error, term()} - def export_all_entities do - results = - Entities.list_entities() - |> Enum.map(fn entity -> - content = build_export_content(entity, []) - Storage.write_entity(entity.name, content) - end) - - {:ok, results} - end - - @doc """ - Exports all data for all entities. - - Re-exports each entity file with its data included. - """ - @spec export_all_data() :: {:ok, [result]} when result: {:ok, String.t()} | {:error, term()} - def export_all_data do - results = - Entities.list_entities() - |> Enum.map(fn entity -> - data_records = get_entity_data(entity) - content = build_export_content(entity, data_records) - Storage.write_entity(entity.name, content) - end) - - {:ok, results} - end - - @doc """ - Exports all entities with their data (full export). - - Returns definition count and data record count. - """ - @spec export_all() :: {:ok, %{definitions: non_neg_integer(), data: non_neg_integer()}} - def export_all do - include_data = Storage.data_enabled?() - - {def_count, data_count} = - Entities.list_entities() - |> Enum.reduce({0, 0}, fn entity, {defs, data} -> - data_records = if include_data, do: get_entity_data(entity), else: [] - content = build_export_content(entity, data_records) - - case Storage.write_entity(entity.name, content) do - {:ok, _} -> {defs + 1, data + length(data_records)} - {:error, _} -> {defs, data} - end - end) - - {:ok, %{definitions: def_count, data: data_count}} - end - - # ============================================================================ - # Serialization - # ============================================================================ - - @doc """ - Serializes an entity struct to a map suitable for JSON export. - """ - @spec serialize_entity(struct()) :: map() - def serialize_entity(entity) do - %{ - "name" => entity.name, - "display_name" => entity.display_name, - "display_name_plural" => entity.display_name_plural, - "description" => entity.description, - "icon" => entity.icon, - "status" => to_string(entity.status), - "fields_definition" => entity.fields_definition, - "settings" => entity.settings, - "date_created" => format_datetime(entity.date_created), - "date_updated" => format_datetime(entity.date_updated) - } - end - - @doc """ - Serializes an entity data record to a map suitable for JSON export. - """ - @spec serialize_entity_data(struct()) :: map() - def serialize_entity_data(record) do - %{ - "title" => record.title, - "slug" => record.slug, - "status" => to_string(record.status), - "data" => record.data, - "metadata" => record.metadata, - "date_created" => format_datetime(record.date_created), - "date_updated" => format_datetime(record.date_updated) - } - end - - # ============================================================================ - # Private Functions - # ============================================================================ - - defp build_export_content(entity, data_records) do - base = %{ - "export_version" => @export_version, - "exported_at" => UtilsDate.utc_now() |> DateTime.to_iso8601(), - "definition" => serialize_entity(entity), - "data" => Enum.map(data_records, &serialize_entity_data/1) - } - - if Multilang.enabled?() do - Map.put(base, "multilang", %{ - "enabled" => true, - "primary_language" => Multilang.primary_language(), - "languages" => Multilang.enabled_languages() - }) - else - base - end - end - - defp get_entity_data(entity) do - EntityData.list_data_by_entity(entity.uuid) - end - - defp format_datetime(nil), do: nil - - defp format_datetime(%DateTime{} = dt) do - DateTime.to_iso8601(dt) - end - - defp format_datetime(%NaiveDateTime{} = ndt) do - NaiveDateTime.to_iso8601(ndt) - end - - defp format_datetime(other), do: to_string(other) -end diff --git a/lib/modules/entities/mirror/importer.ex b/lib/modules/entities/mirror/importer.ex deleted file mode 100644 index 8d9f06614..000000000 --- a/lib/modules/entities/mirror/importer.ex +++ /dev/null @@ -1,767 +0,0 @@ -defmodule PhoenixKit.Modules.Entities.Mirror.Importer do - @moduledoc """ - Handles import of entities and entity data from JSON files with conflict resolution. - - Each JSON file contains both the entity definition and all its data records. - - ## File Format - - { - "export_version": "1.0", - "exported_at": "2025-12-11T10:30:00Z", - "definition": { ... entity schema ... }, - "data": [ ... array of data records ... ] - } - - ## Conflict Strategies - - - `:skip` - Skip import if record already exists (default) - - `:overwrite` - Replace existing record with imported data - - `:merge` - Merge imported data with existing record (keeps existing values where new is nil) - - ## Conflict Detection - - - Entity definitions: matched by `name` field - - Entity data records: matched by `entity_name` + `slug` - """ - - alias PhoenixKit.Modules.Entities - alias PhoenixKit.Modules.Entities.EntityData - alias PhoenixKit.Modules.Entities.Mirror.Storage - alias PhoenixKit.Users.Auth - alias PhoenixKit.Utils.Slug - - @type conflict_strategy :: :skip | :overwrite | :merge - @type import_result :: - {:ok, :created, any()} - | {:ok, :updated, any()} - | {:ok, :skipped, any()} - | {:error, term()} - - # ============================================================================ - # Import Operations - # ============================================================================ - - @doc """ - Imports an entity (definition + data) from a JSON file. - - ## Parameters - - `entity_name` - The entity name (file name without .json) - - `strategy` - Conflict resolution strategy (default: :skip) - - ## Returns - - `{:ok, %{definition: result, data: [results]}}` on success - - `{:error, reason}` on failure - """ - @spec import_entity(String.t(), conflict_strategy()) :: {:ok, map()} | {:error, term()} - def import_entity(entity_name, strategy \\ :skip) do - case Storage.read_entity(entity_name) do - {:ok, json_data} -> - import_from_data(json_data, strategy) - - {:error, :not_found} -> - {:error, {:file_not_found, entity_name}} - - {:error, reason} -> - {:error, reason} - end - end - - @doc """ - Imports from parsed JSON data (definition + data). - """ - @spec import_from_data(map(), conflict_strategy()) :: {:ok, map()} | {:error, term()} - def import_from_data(%{"definition" => definition, "data" => data}, strategy) - when is_map(definition) and is_list(data) do - # Import definition first - definition_result = import_definition(definition, strategy) - - # Get the entity for data import - entity_name = definition["name"] - - data_results = - case Entities.get_entity_by_name(entity_name) do - nil -> - # Entity doesn't exist, can't import data - Enum.map(data, fn record -> - {:error, {:entity_not_found, entity_name, record["slug"]}} - end) - - entity -> - Enum.map(data, fn record_data -> - import_data_record(entity, record_data, strategy) - end) - end - - {:ok, %{definition: definition_result, data: data_results}} - end - - def import_from_data(_, _), do: {:error, :invalid_format} - - # ============================================================================ - # Definition Import - # ============================================================================ - - defp import_definition(definition, strategy) do - entity_name = definition["name"] - - case Entities.get_entity_by_name(entity_name) do - nil -> - create_entity_from_import(definition) - - existing_entity -> - handle_entity_conflict(existing_entity, definition, strategy) - end - end - - defp create_entity_from_import(definition) do - attrs = %{ - name: definition["name"], - display_name: definition["display_name"], - display_name_plural: definition["display_name_plural"], - description: definition["description"], - icon: definition["icon"], - status: definition["status"] || "published", - fields_definition: definition["fields_definition"] || [], - settings: definition["settings"] || %{}, - created_by_uuid: get_default_user_uuid() - } - - case Entities.create_entity(attrs) do - {:ok, entity} -> {:ok, :created, entity} - {:error, changeset} -> {:error, {:validation_failed, changeset}} - end - end - - defp handle_entity_conflict(existing_entity, _definition, :skip) do - {:ok, :skipped, existing_entity} - end - - defp handle_entity_conflict(existing_entity, definition, :overwrite) do - attrs = %{ - display_name: definition["display_name"], - display_name_plural: definition["display_name_plural"], - description: definition["description"], - icon: definition["icon"], - status: definition["status"] || existing_entity.status, - fields_definition: definition["fields_definition"] || [], - settings: definition["settings"] || %{} - } - - case Entities.update_entity(existing_entity, attrs) do - {:ok, entity} -> {:ok, :updated, entity} - {:error, changeset} -> {:error, {:validation_failed, changeset}} - end - end - - defp handle_entity_conflict(existing_entity, definition, :merge) do - attrs = %{ - display_name: definition["display_name"] || existing_entity.display_name, - display_name_plural: - definition["display_name_plural"] || existing_entity.display_name_plural, - description: definition["description"] || existing_entity.description, - icon: definition["icon"] || existing_entity.icon, - status: definition["status"] || existing_entity.status, - fields_definition: - merge_fields_definition( - existing_entity.fields_definition, - definition["fields_definition"] - ), - settings: deep_merge(existing_entity.settings || %{}, definition["settings"] || %{}) - } - - case Entities.update_entity(existing_entity, attrs) do - {:ok, entity} -> {:ok, :updated, entity} - {:error, changeset} -> {:error, {:validation_failed, changeset}} - end - end - - # ============================================================================ - # Data Import - # ============================================================================ - - defp import_data_record(entity, record_data, strategy) do - slug = record_data["slug"] - - if is_nil(slug) or slug == "" do - # Records without slugs can't be matched to existing records, so always create new - create_data_from_import(entity, record_data) - else - case EntityData.get_by_slug(entity.uuid, slug) do - nil -> - create_data_from_import(entity, record_data) - - existing_record -> - handle_data_conflict(existing_record, record_data, strategy) - end - end - end - - defp create_data_from_import(entity, record_data) do - # Generate slug from title if not provided - slug = generate_slug_if_missing(entity.uuid, record_data["slug"], record_data["title"]) - - attrs = %{ - entity_uuid: entity.uuid, - title: record_data["title"], - slug: slug, - status: record_data["status"] || "published", - data: record_data["data"] || %{}, - metadata: record_data["metadata"] || %{}, - created_by_uuid: get_default_user_uuid() - } - - case EntityData.create(attrs) do - {:ok, record} -> {:ok, :created, record} - {:error, changeset} -> {:error, {:validation_failed, changeset}} - end - end - - defp generate_slug_if_missing(_entity_uuid, slug, _title) when is_binary(slug) and slug != "", - do: slug - - defp generate_slug_if_missing(entity_uuid, _slug, title) - when is_binary(title) and title != "" do - base_slug = Slug.slugify(title) - - if base_slug == "" do - # Title couldn't be slugified, generate a random one - "record-#{:rand.uniform(9999)}" - else - Slug.ensure_unique(base_slug, &slug_exists?(entity_uuid, &1)) - end - end - - defp generate_slug_if_missing(_entity_uuid, _slug, _title) do - # No slug and no title, generate a random slug - "record-#{:rand.uniform(9999)}" - end - - defp slug_exists?(entity_uuid, slug) do - EntityData.get_by_slug(entity_uuid, slug) != nil - end - - # Preview what slug would be generated (without uniqueness check) - defp preview_generated_slug(title) when is_binary(title) and title != "" do - base_slug = Slug.slugify(title) - if base_slug == "", do: "(auto-generated)", else: base_slug - end - - defp preview_generated_slug(_), do: "(auto-generated)" - - # Find the next available slug for preview, considering DB and batch - defp find_next_available_slug_preview(base_slug, _entity_uuid, _batch_counts) - when base_slug in ["(auto-generated)", ""] do - # Can't predict for auto-generated slugs - "(auto-generated)" - end - - defp find_next_available_slug_preview(base_slug, entity_uuid, batch_counts) do - batch_count = Map.get(batch_counts, base_slug, 0) - - # Start checking from base_slug, then -2, -3, etc. - # But account for how many we've already "claimed" in this batch - find_available_slug_candidate(base_slug, entity_uuid, batch_count, 1) - end - - defp find_available_slug_candidate(base_slug, entity_uuid, batch_offset, counter) do - candidate = if counter == 1, do: base_slug, else: "#{base_slug}-#{counter}" - - # Check if this candidate exists in DB - db_exists = entity_uuid && slug_exists?(entity_uuid, candidate) - - cond do - db_exists -> - # Slug exists in DB, try next number - find_available_slug_candidate(base_slug, entity_uuid, batch_offset, counter + 1) - - batch_offset > 0 -> - # This slot is taken by a previous record in this batch - find_available_slug_candidate(base_slug, entity_uuid, batch_offset - 1, counter + 1) - - true -> - # Found an available slot - candidate - end - end - - defp handle_data_conflict(existing_record, _record_data, :skip) do - {:ok, :skipped, existing_record} - end - - defp handle_data_conflict(existing_record, record_data, :overwrite) do - attrs = %{ - title: record_data["title"], - slug: record_data["slug"], - status: record_data["status"] || existing_record.status, - data: record_data["data"] || %{}, - metadata: record_data["metadata"] || %{} - } - - case EntityData.update(existing_record, attrs) do - {:ok, record} -> {:ok, :updated, record} - {:error, changeset} -> {:error, {:validation_failed, changeset}} - end - end - - defp handle_data_conflict(existing_record, record_data, :merge) do - attrs = %{ - title: record_data["title"] || existing_record.title, - slug: record_data["slug"] || existing_record.slug, - status: record_data["status"] || existing_record.status, - data: deep_merge(existing_record.data || %{}, record_data["data"] || %{}), - metadata: deep_merge(existing_record.metadata || %{}, record_data["metadata"] || %{}) - } - - case EntityData.update(existing_record, attrs) do - {:ok, record} -> {:ok, :updated, record} - {:error, changeset} -> {:error, {:validation_failed, changeset}} - end - end - - # ============================================================================ - # Bulk Import - # ============================================================================ - - @doc """ - Imports all entities from the mirror directory. - - ## Parameters - - `strategy` - Conflict resolution strategy (default: :skip) - - ## Returns - - `{:ok, %{definitions: [...], data: [...]}}` - """ - @spec import_all(conflict_strategy()) :: {:ok, map()} - def import_all(strategy \\ :skip) do - all_results = - Storage.list_entities() - |> Enum.map(fn entity_name -> - case import_entity(entity_name, strategy) do - {:ok, result} -> result - {:error, reason} -> %{definition: {:error, reason}, data: []} - end - end) - - definition_results = Enum.map(all_results, & &1.definition) - data_results = Enum.flat_map(all_results, & &1.data) - - {:ok, - %{ - definitions: definition_results, - data: data_results - }} - end - - @doc """ - Imports selected entities and records based on user selections. - - ## Parameters - - `selections` - Map of entity_name => %{definition: action, data: %{slug => action}} - where action is :skip, :overwrite, or :merge - - ## Example - - selections = %{ - "brand" => %{ - definition: :overwrite, - data: %{ - "acme-corp" => :overwrite, - "globex" => :skip - } - } - } - - ## Returns - - `{:ok, %{definitions: [...], data: [...]}}` - """ - @spec import_selected(map()) :: {:ok, map()} - def import_selected(selections) when is_map(selections) do - all_results = - selections - |> Enum.map(fn {entity_name, entity_selections} -> - import_entity_selective(entity_name, entity_selections) - end) - - definition_results = Enum.map(all_results, & &1.definition) - data_results = Enum.flat_map(all_results, & &1.data) - - {:ok, - %{ - definitions: definition_results, - data: data_results - }} - end - - defp import_entity_selective(entity_name, %{definition: def_action, data: data_actions}) do - case Storage.read_entity(entity_name) do - {:ok, %{"definition" => definition, "data" => data}} -> - definition_result = import_definition_selective(definition, def_action) - data_results = import_data_selective(definition["name"], data, data_actions) - %{definition: definition_result, data: data_results} - - {:error, reason} -> - %{definition: {:error, reason}, data: []} - end - end - - defp import_definition_selective(_definition, :skip), do: {:ok, :skipped, nil} - defp import_definition_selective(definition, action), do: import_definition(definition, action) - - defp import_data_selective(entity_name, data, data_actions) do - case Entities.get_entity_by_name(entity_name) do - nil -> - Enum.map(data, fn record -> - {:error, {:entity_not_found, entity_name, record["slug"]}} - end) - - entity -> - import_data_records_with_actions(entity, data, data_actions) - end - end - - defp import_data_records_with_actions(entity, data, data_actions) do - data - |> Enum.with_index() - |> Enum.map(fn {record_data, index} -> - import_single_data_record(entity, record_data, index, data_actions) - end) - end - - defp import_single_data_record(entity, record_data, index, data_actions) do - slug = record_data["slug"] - selection_key = if is_nil(slug) or slug == "", do: "new-#{index}", else: slug - action = Map.get(data_actions, selection_key, :skip) - - if action == :skip do - {:ok, :skipped, nil} - else - import_data_record(entity, record_data, action) - end - end - - # ============================================================================ - # Preview / Dry Run - # ============================================================================ - - @doc """ - Previews what would be imported without making any changes. - - Returns data grouped by entity for the import UI, with each entity containing - its definition preview and all data record previews. - - ## Returns - - %{ - entities: [ - %{ - name: "brand", - definition: %{name: "brand", action: :create | :identical | :conflict}, - data: [%{slug: "acme", action: :create | :identical | :conflict}, ...] - }, - ... - ], - summary: %{ - definitions: %{total: N, new: N, identical: N, conflicts: N}, - data: %{total: N, new: N, identical: N, conflicts: N} - } - } - """ - @spec preview_import() :: map() - def preview_import do - entity_names = Storage.list_entities() - - entities = - entity_names - |> Enum.map(fn entity_name -> - case Storage.read_entity(entity_name) do - {:ok, %{"definition" => definition, "data" => data}} -> - preview = preview_entity_file(entity_name, definition, data) - - %{ - name: entity_name, - definition: preview.definition, - data: preview.data - } - - _ -> - %{ - name: entity_name, - definition: %{name: entity_name, action: :error}, - data: [] - } - end - end) - - # Calculate summary stats - definition_previews = Enum.map(entities, & &1.definition) - data_previews = Enum.flat_map(entities, & &1.data) - - %{ - entities: entities, - summary: %{ - definitions: %{ - total: length(definition_previews), - new: Enum.count(definition_previews, &(&1.action == :create)), - identical: Enum.count(definition_previews, &(&1.action == :identical)), - conflicts: Enum.count(definition_previews, &(&1.action == :conflict)), - errors: Enum.count(definition_previews, &(&1.action == :error)) - }, - data: %{ - total: length(data_previews), - new: Enum.count(data_previews, &(&1.action == :create)), - identical: Enum.count(data_previews, &(&1.action == :identical)), - conflicts: Enum.count(data_previews, &(&1.action == :conflict)), - errors: Enum.count(data_previews, &(&1.action == :error)) - } - } - } - end - - defp preview_entity_file(entity_name, definition, data) do - existing_entity = Entities.get_entity_by_name(definition["name"]) - definition_preview = preview_definition(entity_name, existing_entity, definition) - entity_uuid_for_slugs = if existing_entity, do: existing_entity.uuid, else: nil - - data_previews = - preview_data_records(entity_name, existing_entity, entity_uuid_for_slugs, data) - - %{definition: definition_preview, data: data_previews} - end - - defp preview_definition(entity_name, nil, _definition) do - %{name: entity_name, action: :create} - end - - defp preview_definition(entity_name, existing, definition) do - if entity_definitions_match?(existing, definition) do - %{name: entity_name, action: :identical, existing_uuid: existing.uuid} - else - %{name: entity_name, action: :conflict, existing_uuid: existing.uuid} - end - end - - defp preview_data_records(entity_name, existing_entity, entity_uuid_for_slugs, data) do - {data_previews, _slug_counts} = - data - |> Enum.with_index() - |> Enum.reduce({[], %{}}, fn {record, index}, {previews, slug_counts} -> - preview = - preview_single_record( - entity_name, - existing_entity, - entity_uuid_for_slugs, - record, - index, - slug_counts - ) - - new_counts = - if preview[:_base_slug] do - Map.update(slug_counts, preview[:_base_slug], 1, &(&1 + 1)) - else - slug_counts - end - - {previews ++ [Map.delete(preview, :_base_slug)], new_counts} - end) - - data_previews - end - - defp preview_single_record( - entity_name, - _existing_entity, - entity_uuid_for_slugs, - record, - index, - slug_counts - ) do - slug = record["slug"] - title = record["title"] - - if is_nil(slug) or slug == "" do - preview_new_record_without_slug( - entity_name, - entity_uuid_for_slugs, - title, - index, - slug_counts - ) - else - preview_record_with_slug( - entity_name, - entity_uuid_for_slugs, - record, - slug, - title, - slug_counts - ) - end - end - - defp preview_new_record_without_slug( - entity_name, - entity_uuid_for_slugs, - title, - index, - slug_counts - ) do - base_slug = preview_generated_slug(title) - import_key = "new-#{index}" - - display_generated = - find_next_available_slug_preview(base_slug, entity_uuid_for_slugs, slug_counts) - - %{ - entity_name: entity_name, - slug: import_key, - display_slug: "(no slug)", - title: title, - generated_slug: display_generated, - action: :create, - is_new_record: true, - _base_slug: base_slug - } - end - - defp preview_record_with_slug(entity_name, nil, _record, slug, title, _slug_counts) do - # Entity will be created, so all data records will be new - %{entity_name: entity_name, slug: slug, title: title, action: :create} - end - - defp preview_record_with_slug(entity_name, entity_uuid, record, slug, title, slug_counts) do - case EntityData.get_by_slug(entity_uuid, slug) do - nil -> - %{entity_name: entity_name, slug: slug, title: title, action: :create} - - existing -> - new_slug_if_imported = find_next_available_slug_preview(slug, entity_uuid, slug_counts) - action = if data_records_match?(existing, record), do: :identical, else: :conflict - - %{ - entity_name: entity_name, - slug: slug, - title: title, - action: action, - existing_uuid: existing.uuid, - generated_slug: new_slug_if_imported - } - end - end - - @doc """ - Detects all conflicts that would occur during import. - - ## Returns - - `%{entity_conflicts: [...], data_conflicts: [...]}` - """ - @spec detect_conflicts() :: map() - def detect_conflicts do - preview = preview_import() - - entity_conflicts = - preview.entities - |> Enum.filter(&(&1.definition.action == :conflict)) - |> Enum.map(& &1.name) - - data_conflicts = - preview.entities - |> Enum.flat_map(fn entity -> - entity.data - |> Enum.filter(&(&1.action == :conflict)) - |> Enum.map(&{entity.name, &1.slug}) - end) - - %{ - entity_conflicts: entity_conflicts, - data_conflicts: data_conflicts - } - end - - # ============================================================================ - # Helpers - # ============================================================================ - - defp get_default_user_uuid do - case get_default_user() do - nil -> nil - user -> user.uuid - end - end - - defp get_default_user do - case Auth.get_first_admin() do - nil -> Auth.get_first_user() - admin -> admin - end - end - - defp merge_fields_definition(existing, new) when is_list(existing) and is_list(new) do - existing_map = - existing - |> Enum.map(fn field -> {field["key"], field} end) - |> Map.new() - - new - |> Enum.reduce(existing_map, fn new_field, acc -> - key = new_field["key"] - - case Map.get(acc, key) do - nil -> - Map.put(acc, key, new_field) - - existing_field -> - merged = Map.merge(existing_field, new_field) - Map.put(acc, key, merged) - end - end) - |> Map.values() - end - - defp merge_fields_definition(_, new) when is_list(new), do: new - defp merge_fields_definition(existing, _) when is_list(existing), do: existing - defp merge_fields_definition(_, _), do: [] - - defp deep_merge(left, right) when is_map(left) and is_map(right) do - Map.merge(left, right, fn - _k, left_val, right_val when is_map(left_val) and is_map(right_val) -> - deep_merge(left_val, right_val) - - _k, _left_val, right_val -> - right_val - end) - end - - defp deep_merge(_left, right), do: right - - # Check if existing entity definition matches imported definition - defp entity_definitions_match?(existing, imported) do - existing.display_name == imported["display_name"] and - existing.display_name_plural == imported["display_name_plural"] and - existing.description == imported["description"] and - existing.icon == imported["icon"] and - to_string(existing.status) == (imported["status"] || "published") and - normalize_list(existing.fields_definition) == normalize_list(imported["fields_definition"]) and - normalize_map(existing.settings) == normalize_map(imported["settings"]) - end - - # Check if existing data record matches imported record - defp data_records_match?(existing, imported) do - existing.title == imported["title"] and - existing.slug == imported["slug"] and - to_string(existing.status) == (imported["status"] || "published") and - normalize_map(existing.data) == normalize_map(imported["data"]) and - normalize_map(existing.metadata) == normalize_map(imported["metadata"]) - end - - # Normalize nil/null to empty map for comparison - defp normalize_map(nil), do: %{} - defp normalize_map(map) when is_map(map), do: map - defp normalize_map(_), do: %{} - - # Normalize nil/null to empty list for comparison - defp normalize_list(nil), do: [] - defp normalize_list(list) when is_list(list), do: list - defp normalize_list(_), do: [] -end diff --git a/lib/modules/entities/mirror/storage.ex b/lib/modules/entities/mirror/storage.ex deleted file mode 100644 index 36dfe0f93..000000000 --- a/lib/modules/entities/mirror/storage.ex +++ /dev/null @@ -1,337 +0,0 @@ -defmodule PhoenixKit.Modules.Entities.Mirror.Storage do - @moduledoc """ - Filesystem storage operations for entity mirror/export system. - - Stores exported JSON files in the parent app's priv/entities/ directory. - Each entity is stored as a single file containing both the definition and all data records. - - ## Directory Structure - - priv/entities/ - brand.json # Contains definition + all data records - product.json # Contains definition + all data records - - ## File Format - - { - "export_version": "1.0", - "exported_at": "2025-12-11T10:30:00Z", - "definition": { ... entity schema ... }, - "data": [ ... array of data records ... ] - } - - ## Configuration - - The export path can be configured via settings: - - `entities_mirror_path` - Custom path (empty = use default priv/entities/) - - """ - - alias PhoenixKit.Config - alias PhoenixKit.Settings - - # ============================================================================ - # Settings Helpers - # ============================================================================ - - @doc """ - Checks if entity definitions mirroring is enabled. - """ - @spec definitions_enabled?() :: boolean() - def definitions_enabled? do - Settings.get_setting("entities_mirror_definitions_enabled", "false") == "true" - end - - @doc """ - Checks if entity data mirroring is enabled. - """ - @spec data_enabled?() :: boolean() - def data_enabled? do - Settings.get_setting("entities_mirror_data_enabled", "false") == "true" - end - - @doc """ - Enables entity definitions mirroring. - """ - @spec enable_definitions() :: {:ok, any()} | {:error, any()} - def enable_definitions do - Settings.update_setting("entities_mirror_definitions_enabled", "true") - end - - @doc """ - Disables entity definitions mirroring. - """ - @spec disable_definitions() :: {:ok, any()} | {:error, any()} - def disable_definitions do - Settings.update_setting("entities_mirror_definitions_enabled", "false") - end - - @doc """ - Enables entity data mirroring. - """ - @spec enable_data() :: {:ok, any()} | {:error, any()} - def enable_data do - Settings.update_setting("entities_mirror_data_enabled", "true") - end - - @doc """ - Disables entity data mirroring. - """ - @spec disable_data() :: {:ok, any()} | {:error, any()} - def disable_data do - Settings.update_setting("entities_mirror_data_enabled", "false") - end - - # ============================================================================ - # Path Resolution - # ============================================================================ - - @doc """ - Returns the root path for entity mirror storage. - - Uses custom path from settings if configured, otherwise defaults to - the parent app's priv/entities/ directory. - """ - @spec root_path() :: String.t() - def root_path do - case Settings.get_setting("entities_mirror_path", "") do - path when is_binary(path) and byte_size(path) > 0 -> path - _ -> default_path() - end - end - - @doc """ - Returns the default storage path in the parent app's priv directory. - """ - @spec default_path() :: String.t() - def default_path do - case Config.get_parent_app() do - nil -> Path.join([File.cwd!(), "priv", "entities"]) - app -> Application.app_dir(app, Path.join("priv", "entities")) - end - end - - @doc """ - Returns the file path for a specific entity. - """ - @spec entity_path(String.t()) :: String.t() - def entity_path(entity_name) when is_binary(entity_name) do - Path.join(root_path(), "#{entity_name}.json") - end - - # ============================================================================ - # Directory Management - # ============================================================================ - - @doc """ - Ensures the root directory exists. - """ - @spec ensure_directory() :: :ok | {:error, term()} - def ensure_directory do - path = root_path() - - case File.mkdir_p(path) do - :ok -> :ok - {:error, reason} -> {:error, {:mkdir_failed, path, reason}} - end - end - - # ============================================================================ - # Write Operations - # ============================================================================ - - @doc """ - Writes an entity file containing definition and optionally data. - - ## Parameters - - `entity_name` - The entity name (used as filename) - - `content` - The full content map with definition and data - - ## Returns - - `{:ok, file_path}` on success - - `{:error, reason}` on failure - """ - @spec write_entity(String.t(), map()) :: {:ok, String.t()} | {:error, term()} - def write_entity(entity_name, content) when is_binary(entity_name) and is_map(content) do - with :ok <- ensure_directory() do - file_path = entity_path(entity_name) - write_json_file(file_path, content) - end - end - - defp write_json_file(file_path, content) when is_map(content) do - case Jason.encode(content, pretty: true) do - {:ok, json} -> - case File.write(file_path, json) do - :ok -> {:ok, file_path} - {:error, reason} -> {:error, {:write_failed, file_path, reason}} - end - - {:error, reason} -> - {:error, {:encode_failed, reason}} - end - end - - # ============================================================================ - # Read Operations - # ============================================================================ - - @doc """ - Reads an entity file containing definition and data. - - ## Parameters - - `entity_name` - The entity name - - ## Returns - - `{:ok, map}` with decoded JSON on success - - `{:error, reason}` on failure - """ - @spec read_entity(String.t()) :: {:ok, map()} | {:error, term()} - def read_entity(entity_name) when is_binary(entity_name) do - file_path = entity_path(entity_name) - read_json_file(file_path) - end - - defp read_json_file(file_path) do - case File.read(file_path) do - {:ok, content} -> - case Jason.decode(content) do - {:ok, data} -> {:ok, data} - {:error, reason} -> {:error, {:decode_failed, file_path, reason}} - end - - {:error, :enoent} -> - {:error, :not_found} - - {:error, reason} -> - {:error, {:read_failed, file_path, reason}} - end - end - - # ============================================================================ - # Delete Operations - # ============================================================================ - - @doc """ - Deletes an entity file. - """ - @spec delete_entity(String.t()) :: :ok | {:error, term()} - def delete_entity(entity_name) when is_binary(entity_name) do - file_path = entity_path(entity_name) - - case File.rm(file_path) do - :ok -> :ok - {:error, :enoent} -> :ok - {:error, reason} -> {:error, {:delete_failed, file_path, reason}} - end - end - - # ============================================================================ - # List Operations - # ============================================================================ - - @doc """ - Lists all exported entity names. - - Returns a list of entity names (without .json extension). - """ - @spec list_entities() :: [String.t()] - def list_entities do - path = root_path() - - if File.exists?(path) do - path - |> File.ls!() - |> Enum.filter(&String.ends_with?(&1, ".json")) - |> Enum.map(&String.replace_trailing(&1, ".json", "")) - |> Enum.sort() - else - [] - end - end - - # ============================================================================ - # Stats - # ============================================================================ - - @doc """ - Returns statistics about exported files. - - ## Returns - Map with: - - `definitions_count` - Number of exported entity files - - `data_count` - Total number of data records across all entities - - `entities_with_data` - List of entity names that have data records - - `last_export` - Timestamp of most recent export (nil if no files) - """ - @spec get_stats() :: map() - def get_stats do - entities = list_entities() - - {data_count, entities_with_data} = - entities - |> Enum.reduce({0, []}, fn entity_name, {count, with_data} -> - case read_entity(entity_name) do - {:ok, %{"data" => data}} when is_list(data) and data != [] -> - {count + length(data), [entity_name | with_data]} - - {:ok, _} -> - {count, with_data} - - {:error, _} -> - {count, with_data} - end - end) - - last_export = get_last_export_time(entities) - - %{ - definitions_count: length(entities), - data_count: data_count, - entities_with_data: Enum.reverse(entities_with_data), - last_export: last_export - } - end - - defp get_last_export_time([]), do: nil - - defp get_last_export_time(entities) do - entities - |> Enum.map(fn name -> entity_path(name) end) - |> Enum.map(&get_file_mtime/1) - |> Enum.reject(&is_nil/1) - |> Enum.max(fn -> nil end) - |> format_last_export() - end - - defp get_file_mtime(path) do - case File.stat(path) do - {:ok, %{mtime: mtime}} -> mtime - _ -> nil - end - end - - defp format_last_export(nil), do: nil - - defp format_last_export({{year, month, day}, {hour, minute, _second}}) do - "#{year}-#{pad(month)}-#{pad(day)} #{pad(hour)}:#{pad(minute)}" - end - - defp pad(num) when num < 10, do: "0#{num}" - defp pad(num), do: "#{num}" - - @doc """ - Checks if a file exists for the given entity. - """ - @spec entity_exists?(String.t()) :: boolean() - def entity_exists?(entity_name) do - file_path = entity_path(entity_name) - File.exists?(file_path) - end - - # Legacy compatibility aliases - @doc false - def list_definitions, do: list_entities() - @doc false - def definition_exists?(name), do: entity_exists?(name) -end diff --git a/lib/modules/entities/presence.ex b/lib/modules/entities/presence.ex deleted file mode 100644 index 7b2e49c3c..000000000 --- a/lib/modules/entities/presence.ex +++ /dev/null @@ -1,42 +0,0 @@ -defmodule PhoenixKit.Modules.Entities.Presence do - @moduledoc """ - Presence tracking for collaborative entity editing. - - Uses Phoenix.Presence to track who is currently editing an entity or data record. - The first person to join a topic becomes the "owner" (can edit), and everyone else - becomes "spectators" (read-only mode). - - ## How It Works - - 1. When a user opens an edit form, they join a Presence topic (e.g., "entity_edit:5") - 2. Presence tracks all connected users with metadata (user info, joined_at timestamp) - 3. Users are sorted by joined_at to determine order (FIFO) - 4. First user in the sorted list = owner (readonly?: false) - 5. All other users = spectators (readonly?: true) - 6. When owner leaves, Presence removes them automatically - 7. All connected users receive presence_diff event - 8. Each user re-evaluates: "Am I first now?" - 9. New first user auto-promotes to owner - - ## Automatic Cleanup - - Phoenix.Presence automatically detects when LiveView processes die and removes - them immediately via process monitoring. When a user closes a tab or navigates away: - - 1. WebSocket disconnects - 2. LiveView process terminates - 3. Presence automatically cleaned up (via process monitoring) - 4. All other users receive presence_diff event instantly - - No manual cleanup or timeout configuration needed! - - ## Topics - - - Entity editing: "entity_edit:" - - Data editing: "data_edit:" - """ - - use Phoenix.Presence, - otp_app: :phoenix_kit, - pubsub_server: :phoenix_kit_internal_pubsub -end diff --git a/lib/modules/entities/presence_helpers.ex b/lib/modules/entities/presence_helpers.ex deleted file mode 100644 index e36d23276..000000000 --- a/lib/modules/entities/presence_helpers.ex +++ /dev/null @@ -1,193 +0,0 @@ -defmodule PhoenixKit.Modules.Entities.PresenceHelpers do - @moduledoc """ - Helper functions for collaborative editing with Phoenix.Presence. - - Provides utilities for tracking editing sessions, determining owner/spectator roles, - and syncing state between users. - """ - - alias PhoenixKit.Modules.Entities.Presence - - @doc """ - Tracks the current LiveView process in a Presence topic. - - ## Parameters - - - `type`: The resource type (`:entity` or `:data`) - - `id`: The resource ID - - `socket`: The LiveView socket - - `user`: The current user struct - - ## Examples - - track_editing_session(:entity, 5, socket, user) - # => {:ok, ref} - """ - def track_editing_session(type, id, socket, user) do - topic = editing_topic(type, id) - - Presence.track(self(), topic, socket.id, %{ - user_uuid: user.uuid, - user_email: user.email, - user: user, - joined_at: System.system_time(:millisecond), - phx_ref: socket.id, - # For diagnostics and dead process detection - pid: self(), - transport_pid: socket.transport_pid - }) - end - - @doc """ - Determines if the current socket is the owner (first in the presence list). - - Returns `{:owner, presences}` if this socket is the owner (or same user in different tab), or - `{:spectator, owner_meta, presences}` if a different user is the owner. - - ## Examples - - case get_editing_role(:entity, 5, socket.id, current_user.uuid) do - {:owner, all_presences} -> - # I can edit! - - {:spectator, owner_metadata, all_presences} -> - # I'm read-only, sync with owner's state - end - """ - def get_editing_role(type, id, socket_id, current_user_uuid) do - presences = get_sorted_presences(type, id) - - case presences do - [] -> - # No one here (shouldn't happen since caller is here) - # But treat as owner to avoid blocking - {:owner, []} - - [{^socket_id, _meta} | _rest] -> - # I'm first! I'm the owner - {:owner, presences} - - [{_other_socket_id, owner_meta} | _rest] -> - # Check if same user (different tab) or different user - if owner_meta.user_uuid == current_user_uuid do - # Same user, different tab - treat as owner so both tabs can edit - {:owner, presences} - else - # Different user - spectator mode (FIFO locking) - {:spectator, owner_meta, presences} - end - end - end - - @doc """ - Gets all presences for a resource, sorted by join time (FIFO). - - Returns a list of tuples: `[{socket_id, metadata}, ...]` - - ## Examples - - get_sorted_presences(:entity, "019...") - # => [ - # {"phx-abc123", %{user_uuid: "019...", joined_at: 123456, ...}}, - # {"phx-def456", %{user_uuid: "019...", joined_at: 123458, ...}} - # ] - """ - def get_sorted_presences(type, id) do - topic = editing_topic(type, id) - raw_presences = Presence.list(topic) - - raw_presences - |> Enum.flat_map(fn {socket_id, %{metas: metas}} -> - # Filter out metas with dead PIDs - valid_metas = - Enum.filter(metas, fn meta -> - case Map.get(meta, :pid) do - pid when is_pid(pid) -> Process.alive?(pid) - # Keep metas without PID for backward compatibility - _ -> true - end - end) - - # Take the first valid meta (most recent) - case valid_metas do - [meta | _] -> [{socket_id, meta}] - [] -> [] - end - end) - |> Enum.sort_by(fn {_socket_id, meta} -> meta.joined_at end) - end - - @doc """ - Gets the lock owner's metadata, or nil if no one is editing. - - ## Examples - - case get_lock_owner(:entity, 5) do - nil -> # No one editing - meta -> # meta.user, meta.joined_at, etc. - end - """ - def get_lock_owner(type, id) do - case get_sorted_presences(type, id) do - [{_socket_id, meta} | _] -> meta - [] -> nil - end - end - - @doc """ - Gets all spectators (everyone except the first person). - - Returns a list of metadata for spectators only. - - ## Examples - - get_spectators(:entity, "019...") - # => [ - # %{user_uuid: "019...", user_email: "user@example.com", joined_at: 123458, ...}, - # %{user_uuid: "019...", user_email: "other@example.com", joined_at: 123460, ...} - # ] - """ - def get_spectators(type, id) do - case get_sorted_presences(type, id) do - [] -> [] - [_owner | spectators] -> Enum.map(spectators, fn {_id, meta} -> meta end) - end - end - - @doc """ - Counts total number of people editing (owner + spectators). - """ - def count_editors(type, id) do - get_sorted_presences(type, id) |> length() - end - - @doc """ - Subscribes the current process to presence events for a resource. - - After subscribing, the process will receive: - - `%Phoenix.Socket.Broadcast{event: "presence_diff", ...}` when users join/leave - - ## Examples - - subscribe_to_editing(:entity, 5) - # Now will receive presence_diff messages - """ - def subscribe_to_editing(type, id) do - topic = editing_topic(type, id) - Phoenix.PubSub.subscribe(:phoenix_kit_internal_pubsub, topic) - end - - @doc """ - Generates the Presence topic name for a resource. - - ## Examples - - editing_topic(:entity, 5) - # => "entity_edit:5" - - editing_topic(:data, 10) - # => "data_edit:10" - """ - def editing_topic(:entity, id), do: "entity_edit:#{id}" - def editing_topic(:data, id), do: "data_edit:#{id}" -end diff --git a/lib/modules/entities/web/data_form.ex b/lib/modules/entities/web/data_form.ex deleted file mode 100644 index 307578c74..000000000 --- a/lib/modules/entities/web/data_form.ex +++ /dev/null @@ -1,1120 +0,0 @@ -defmodule PhoenixKit.Modules.Entities.Web.DataForm do - @moduledoc """ - LiveView for creating and editing entity data records. - Provides dynamic form interface based on entity schema definition. - """ - - use PhoenixKitWeb, :live_view - on_mount PhoenixKit.Modules.Entities.Web.Hooks - - import PhoenixKitWeb.Components.MultilangForm - - alias PhoenixKit.Modules.Entities - alias PhoenixKit.Modules.Entities.EntityData - alias PhoenixKit.Modules.Entities.Events - alias PhoenixKit.Modules.Entities.FormBuilder - alias PhoenixKit.Modules.Entities.Multilang - alias PhoenixKit.Modules.Entities.Presence - alias PhoenixKit.Modules.Entities.PresenceHelpers - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Routes - alias PhoenixKit.Utils.Slug - - # Fields that should keep their primary-language DB column value on secondary tabs. - @preserve_fields %{"title" => :title, "slug" => :slug, "status" => :status} - - @impl true - def mount(%{"entity_slug" => entity_slug, "uuid" => uuid} = params, _session, socket) do - # Set locale for LiveView process - locale = - params["locale"] || socket.assigns[:current_locale] - - # Edit mode with slug - entity = Entities.get_entity_by_name(entity_slug) - data_record = EntityData.get!(uuid) - changeset = EntityData.change(data_record) - - mount_data_form(socket, entity, data_record, changeset, gettext("Edit Data"), locale) - end - - def mount(%{"entity_id" => entity_uuid, "id" => id} = params, _session, socket) do - # Set locale for LiveView process - locale = - params["locale"] || socket.assigns[:current_locale] - - # Edit mode with ID (backwards compat) - entity = Entities.get_entity!(entity_uuid) - data_record = EntityData.get!(id) - changeset = EntityData.change(data_record) - - mount_data_form(socket, entity, data_record, changeset, gettext("Edit Data"), locale) - end - - def mount(%{"entity_slug" => entity_slug} = params, _session, socket) do - # Set locale for LiveView process - locale = - params["locale"] || socket.assigns[:current_locale] - - # Create mode with slug - entity = Entities.get_entity_by_name(entity_slug) - data_record = %EntityData{entity_uuid: entity.uuid} - changeset = EntityData.change(data_record) - - mount_data_form(socket, entity, data_record, changeset, gettext("New Data"), locale) - end - - def mount(%{"entity_id" => entity_uuid} = params, _session, socket) do - # Set locale for LiveView process - locale = - params["locale"] || socket.assigns[:current_locale] - - # Create mode with ID (backwards compat) - entity = Entities.get_entity!(entity_uuid) - data_record = %EntityData{entity_uuid: entity.uuid} - changeset = EntityData.change(data_record) - - mount_data_form(socket, entity, data_record, changeset, gettext("New Data"), locale) - end - - defp mount_data_form(socket, entity, data_record, changeset, page_title, locale) do - project_title = Settings.get_project_title() - current_user = socket.assigns[:phoenix_kit_current_user] - - # For new records, set default status to "published" to avoid validation errors - changeset = - if is_nil(data_record.uuid) do - Ecto.Changeset.put_change(changeset, :status, "published") - else - changeset - end - - form_record_key = - case data_record.uuid do - nil -> {:new, entity.name} - uuid -> uuid - end - - live_source = ensure_live_source(socket) - - # Multilang state (driven by Languages module globally) - multilang_enabled = multilang_enabled?() - - # Lazy re-key: if global primary changed since this record was saved, - # restructure data around the new primary language. - # Also seed _title into JSONB data for backwards compat. - changeset = - if multilang_enabled and data_record.uuid do - changeset - |> rekey_data_on_mount() - |> seed_translatable_fields(data_record) - else - changeset - end - - socket = - socket - |> assign(:current_locale, locale) - |> assign(:page_title, page_title) - |> assign(:project_title, project_title) - |> assign(:entity, entity) - |> assign(:data_record, data_record) - |> assign(:changeset, changeset) - |> assign(:current_user, current_user) - |> assign(:form_record_key, form_record_key) - |> assign(:form_record_topic_key, normalize_record_key(form_record_key)) - |> assign(:live_source, live_source) - |> assign(:has_unsaved_changes, false) - |> mount_multilang() - - socket = - if connected?(socket) do - Events.subscribe_to_entity_data(entity.uuid) - Events.subscribe_to_data_form(entity.uuid, form_record_key) - - socket = - if data_record.uuid do - # Track this user in Presence - {:ok, _ref} = - PresenceHelpers.track_editing_session(:data, data_record.uuid, socket, current_user) - - # Subscribe to presence changes - PresenceHelpers.subscribe_to_editing(:data, data_record.uuid) - - # Determine our role (owner or spectator) - socket = assign_editing_role(socket, data_record.uuid) - - # Load spectator state if we're not the owner - if socket.assigns.readonly? do - load_spectator_state(socket, data_record.uuid) - else - socket - end - else - # New record - no lock needed - socket - |> assign(:lock_owner?, true) - |> assign(:readonly?, false) - |> assign(:lock_owner_user, nil) - |> assign(:spectators, []) - end - - socket - else - # Not connected - no lock logic - socket - |> assign(:lock_owner?, true) - |> assign(:readonly?, false) - |> assign(:lock_owner_user, nil) - |> assign(:spectators, []) - end - - {:ok, socket} - end - - defp assign_editing_role(socket, data_uuid) do - current_user = socket.assigns[:current_user] - - case PresenceHelpers.get_editing_role(:data, data_uuid, socket.id, current_user.uuid) do - {:owner, _presences} -> - # I'm the owner - I can edit (or same user in different tab) - socket - |> assign(:lock_owner?, true) - |> assign(:readonly?, false) - |> populate_presence_info(:data, data_uuid) - - {:spectator, _owner_meta, _presences} -> - # Different user is the owner - I'm read-only - socket - |> assign(:lock_owner?, false) - |> assign(:readonly?, true) - |> populate_presence_info(:data, data_uuid) - end - end - - defp load_spectator_state(socket, data_uuid) do - # Owner might have unsaved changes - sync from their Presence metadata - case PresenceHelpers.get_lock_owner(:data, data_uuid) do - %{form_state: form_state} when not is_nil(form_state) -> - # Apply owner's form state - params = Map.get(form_state, :params) || Map.get(form_state, "params") - - if params do - socket - |> apply_remote_data_params(params) - |> assign(:has_unsaved_changes, true) - else - socket - end - - _ -> - # No form state to sync - socket - end - end - - @impl true - def terminate(_reason, _socket) do - :ok - end - - @impl true - def handle_event("switch_language", %{"lang" => lang_code}, socket) do - {:noreply, handle_switch_language(socket, lang_code)} - end - - def handle_event("validate", %{"phoenix_kit_entity_data" => data_params}, socket) do - if socket.assigns[:lock_owner?] do - do_validate(data_params, socket) - else - # Spectator - ignore local changes, wait for broadcasts - {:noreply, socket} - end - rescue - e -> - require Logger - - Logger.error( - "Entity data validate failed: #{Exception.message(e)}\n#{Exception.format_stacktrace(__STACKTRACE__)}" - ) - - {:noreply, put_flash(socket, :error, gettext("Validation error — your data is preserved."))} - end - - def handle_event("save", %{"phoenix_kit_entity_data" => data_params}, socket) do - if socket.assigns[:lock_owner?] do - do_save(data_params, socket) - else - {:noreply, put_flash(socket, :error, gettext("Cannot save - you are spectating"))} - end - rescue - e -> - require Logger - - Logger.error( - "Entity data save failed: #{Exception.message(e)}\n#{Exception.format_stacktrace(__STACKTRACE__)}" - ) - - {:noreply, put_flash(socket, :error, gettext("Something went wrong. Please try again."))} - end - - def handle_event("reset", _params, socket) do - if socket.assigns[:lock_owner?] do - # Reload data record from database or reset to empty state - {data_record, changeset} = - if socket.assigns.data_record.uuid do - # Reload from database - reloaded_data = EntityData.get_data!(socket.assigns.data_record.uuid) - {reloaded_data, EntityData.change(reloaded_data)} - else - # Reset to empty new data record - empty_data = %EntityData{ - entity_uuid: socket.assigns.entity.uuid - } - - changeset = - empty_data - |> EntityData.change() - |> Ecto.Changeset.put_change(:status, "published") - - {empty_data, changeset} - end - - socket = - socket - |> assign(:data_record, data_record) - |> assign(:changeset, changeset) - |> put_flash(:info, gettext("Changes reset to last saved state")) - |> broadcast_data_form_state(extract_changeset_params(changeset)) - - {:noreply, socket} - else - {:noreply, put_flash(socket, :error, gettext("Cannot reset - you are spectating"))} - end - end - - def handle_event("generate_slug", _params, socket) do - if socket.assigns[:lock_owner?] do - do_generate_slug(socket) - else - {:noreply, socket} - end - end - - # ── Validate/Save helpers (below all handle_event clauses to avoid grouping warnings) ── - - defp do_validate(data_params, socket) do - entity_uuid = socket.assigns.entity.uuid - record_uuid = socket.assigns.data_record.uuid - form_data = Map.get(data_params, "data", %{}) - - data_params = - if socket.assigns.data_record.uuid do - data_params - else - data_params - |> Map.put("created_by_uuid", socket.assigns.current_user.uuid) - end - - data_params = - if is_nil(record_uuid) do - # Always use the primary language title for slug generation - current_data = Ecto.Changeset.apply_changes(socket.assigns.changeset) - previous_title = current_data.title || "" - - title = - if data_params["title"] do - data_params["title"] - else - # On secondary language tab, title param is absent — keep the existing title - previous_title - end - - current_slug = data_params["slug"] || "" - - auto_generated_slug = - auto_generate_entity_slug(entity_uuid, record_uuid, previous_title) - - if current_slug == "" || current_slug == auto_generated_slug do - Map.put( - data_params, - "slug", - auto_generate_entity_slug(entity_uuid, record_uuid, title) - ) - else - data_params - end - else - data_params - end - - current_lang = socket.assigns[:current_lang] - - # Inject _title and _slug into form data so they flow through multilang merge - form_data = - form_data - |> inject_db_field_into_data("title", data_params, current_lang, socket.assigns) - |> inject_db_field_into_data("slug", data_params, current_lang, socket.assigns) - - # On secondary language tabs, preserve primary-language fields that aren't in the form - data_params = - preserve_primary_fields( - data_params, - socket.assigns.changeset, - socket.assigns, - @preserve_fields - ) - - case FormBuilder.validate_data(socket.assigns.entity, form_data, current_lang) do - {:ok, validated_data} -> - validated_data = - validated_data - |> inject_db_field_into_data("title", data_params, current_lang, socket.assigns) - |> inject_db_field_into_data("slug", data_params, current_lang, socket.assigns) - - data_params = strip_lang_params(data_params) - - final_data = - merge_multilang_data( - socket.assigns.changeset, - current_lang, - validated_data, - socket.assigns - ) - - params = Map.put(data_params, "data", final_data) - - changeset = - socket.assigns.data_record - |> EntityData.change(params) - |> Map.put(:action, :validate) - - socket = - socket - |> assign(:changeset, changeset) - |> broadcast_data_form_state(params) - - {:noreply, socket} - - {:error, errors} -> - # Preserve full multilang data in both changeset and broadcast - error_data = - merge_multilang_data( - socket.assigns.changeset, - current_lang, - form_data, - socket.assigns - ) - - data_params = - data_params - |> Map.delete("lang_title") - |> Map.delete("lang_slug") - - error_params = Map.put(data_params, "data", error_data) - - changeset = - socket.assigns.data_record - |> EntityData.change(error_params) - |> add_form_errors(errors) - |> Map.put(:action, :validate) - - socket = - socket - |> assign(:changeset, changeset) - |> broadcast_data_form_state(error_params) - - {:noreply, socket} - end - end - - defp do_save(data_params, socket) do - # Extract the data field from params - form_data = Map.get(data_params, "data", %{}) - - current_lang = socket.assigns[:current_lang] - - # Inject _title and _slug into form data so they flow through multilang merge - form_data = - form_data - |> inject_db_field_into_data("title", data_params, current_lang, socket.assigns) - |> inject_db_field_into_data("slug", data_params, current_lang, socket.assigns) - - # On secondary language tabs, preserve primary-language fields that aren't in the form - data_params = - preserve_primary_fields( - data_params, - socket.assigns.changeset, - socket.assigns, - @preserve_fields - ) - - # Validate the form data against entity field definitions - case FormBuilder.validate_data(socket.assigns.entity, form_data, current_lang) do - {:ok, validated_data} -> - validated_data = - validated_data - |> inject_db_field_into_data("title", data_params, current_lang, socket.assigns) - |> inject_db_field_into_data("slug", data_params, current_lang, socket.assigns) - - data_params = strip_lang_params(data_params) - - final_data = - merge_multilang_data( - socket.assigns.changeset, - current_lang, - validated_data, - socket.assigns - ) - - # Add metadata to params - params = - data_params - |> Map.put("data", final_data) - |> maybe_add_creator_uuid(socket.assigns.current_user, socket.assigns.data_record) - - case save_data_record(socket, params) do - {:ok, saved_record} -> - if socket.assigns.data_record.uuid do - # Update — stay on page, refresh changeset from saved record - changeset = EntityData.change(saved_record) - - socket = - socket - |> assign(:data_record, saved_record) - |> assign(:changeset, changeset) - |> put_flash(:info, gettext("Data record saved successfully")) - |> broadcast_data_form_state(params) - - {:noreply, socket} - else - # Create — navigate to the edit page for the new record - entity_name = socket.assigns.entity.name - - socket = - socket - |> put_flash(:info, gettext("Data record created successfully")) - |> push_navigate( - to: - Routes.path( - "/admin/entities/#{entity_name}/data/#{saved_record.uuid}/edit", - locale: socket.assigns.current_locale_base - ) - ) - - {:noreply, socket} - end - - {:error, %Ecto.Changeset{} = changeset} -> - socket = - socket - |> assign(:changeset, changeset) - |> broadcast_data_form_state(params) - - {:noreply, socket} - end - - {:error, errors} -> - # Preserve full multilang data in both changeset and broadcast - error_data = - merge_multilang_data( - socket.assigns.changeset, - current_lang, - form_data, - socket.assigns - ) - - data_params = - data_params - |> Map.delete("lang_title") - |> Map.delete("lang_slug") - - error_params = Map.put(data_params, "data", error_data) - - changeset = - socket.assigns.data_record - |> EntityData.change(error_params) - |> add_form_errors(errors) - - error_list = - Enum.map_join(errors, "; ", fn {k, v} -> "#{k}: #{Enum.join(v, ", ")}" end) - - socket = - socket - |> assign(:changeset, changeset) - |> put_flash( - :error, - gettext("Field validation errors: %{errors}", errors: error_list) - ) - |> broadcast_data_form_state(error_params) - - {:noreply, socket} - end - end - - ## Live updates - - @impl true - def handle_info({:data_form_change, entity_uuid, record_key, payload, source}, socket) do - cond do - source == socket.assigns.live_source -> - {:noreply, socket} - - entity_uuid != socket.assigns.entity.uuid -> - {:noreply, socket} - - normalize_record_key(record_key) != socket.assigns.form_record_topic_key -> - {:noreply, socket} - - true -> - params = Map.get(payload, :params) || Map.get(payload, "params") || %{} - - socket = - socket - |> apply_remote_data_params(params) - - {:noreply, socket} - end - end - - def handle_info({:data_updated, entity_uuid, data_uuid}, socket) do - cond do - entity_uuid != socket.assigns.entity.uuid -> - {:noreply, socket} - - socket.assigns.data_record.uuid != data_uuid -> - {:noreply, socket} - - # Ignore our own saves — the save handler already refreshes state - socket.assigns[:lock_owner?] -> - {:noreply, socket} - - true -> - data_record = EntityData.get_data!(data_uuid) - changeset = EntityData.change(data_record) - - socket = - socket - |> assign(:data_record, data_record) - |> assign(:form_record_key, data_record.uuid) - |> assign(:form_record_topic_key, normalize_record_key(data_record.uuid)) - |> assign(:changeset, changeset) - |> put_flash( - :info, - gettext("Record updated in another session. Showing latest changes.") - ) - - {:noreply, socket} - end - end - - def handle_info({:data_deleted, entity_uuid, data_uuid}, socket) do - cond do - entity_uuid != socket.assigns.entity.uuid -> - {:noreply, socket} - - socket.assigns.data_record.uuid != data_uuid -> - {:noreply, socket} - - true -> - socket = - socket - |> put_flash(:error, gettext("This record was removed in another session.")) - |> push_navigate( - to: - Routes.path("/admin/entities/#{socket.assigns.entity.name}/data", - locale: socket.assigns.current_locale_base - ) - ) - - {:noreply, socket} - end - end - - def handle_info({:entity_created, _}, socket), do: {:noreply, socket} - - def handle_info({:entity_updated, entity_uuid}, socket) do - if entity_uuid == socket.assigns.entity.uuid do - entity = Entities.get_entity!(entity_uuid) - - # If entity was archived or unpublished, redirect to entities list - if entity.status != "published" do - {:noreply, - socket - |> put_flash( - :warning, - gettext("Entity '%{name}' was %{status} in another session.", - name: entity.display_name, - status: entity.status - ) - ) - |> redirect( - to: Routes.path("/admin/entities", locale: socket.assigns.current_locale_base) - )} - else - socket = - socket - |> refresh_entity_assignment(entity) - |> put_flash(:info, gettext("Entity schema updated. Form revalidated.")) - - {:noreply, socket} - end - else - {:noreply, socket} - end - end - - def handle_info({:entity_deleted, entity_uuid}, socket) do - if entity_uuid == socket.assigns.entity.uuid do - socket = - socket - |> put_flash(:error, gettext("Entity was deleted in another session.")) - |> push_navigate( - to: Routes.path("/admin/entities", locale: socket.assigns.current_locale_base) - ) - - {:noreply, socket} - else - {:noreply, socket} - end - end - - def handle_info(%Phoenix.Socket.Broadcast{event: "presence_diff"}, socket) do - # Someone joined or left - check if our role changed - if socket.assigns.data_record && socket.assigns.data_record.uuid do - data_uuid = socket.assigns.data_record.uuid - was_owner = socket.assigns[:lock_owner?] - - # Re-evaluate our role - socket = assign_editing_role(socket, data_uuid) - - # If we were promoted from spectator to owner, reload fresh data - if !was_owner && socket.assigns[:lock_owner?] do - data_record = EntityData.get_data!(data_uuid) - - socket - |> assign(:data_record, data_record) - |> assign(:changeset, EntityData.change(data_record)) - |> assign(:has_unsaved_changes, false) - |> then(&{:noreply, &1}) - else - # Just a presence update (someone joined/left as spectator) - {:noreply, socket} - end - else - {:noreply, socket} - end - end - - # Strip lang_title/lang_slug from params — these are translation input names - # that shouldn't be passed to the changeset as DB fields. - defp strip_lang_params(params) do - params - |> Map.delete("lang_title") - |> Map.delete("lang_slug") - end - - # ── Lazy re-keying helpers (primary language change) ──────── - - # Re-keys JSONB data in changeset if embedded primary != global primary. - defp rekey_data_on_mount(changeset) do - current_data = Ecto.Changeset.get_field(changeset, :data) - rekeyed = Multilang.maybe_rekey_data(current_data) - - if rekeyed != current_data do - Ecto.Changeset.put_change(changeset, :data, rekeyed) - else - changeset - end - end - - # Seeds `_title` and `_slug` into the JSONB data column for existing records on mount. - # Handles backwards compat: migrates from metadata["translations"] to data[lang]["_title"]. - defp seed_translatable_fields(changeset, data_record) do - data = Ecto.Changeset.get_field(changeset, :data) || %{} - - if Multilang.multilang_data?(data) do - primary = data["_primary_language"] - primary_data = Map.get(data, primary, %{}) - - changeset = - if Map.has_key?(primary_data, "_title") do - changeset - else - title = Ecto.Changeset.get_field(changeset, :title) - do_seed_title(changeset, data, data_record, primary, primary_data, title) - end - - # Also seed _slug if not already present - seed_slug_in_data(changeset) - else - changeset - end - end - - defp seed_slug_in_data(changeset) do - data = Ecto.Changeset.get_field(changeset, :data) || %{} - primary = data["_primary_language"] - primary_data = Map.get(data, primary, %{}) - - if Map.has_key?(primary_data, "_slug") do - changeset - else - slug = Ecto.Changeset.get_field(changeset, :slug) - - if is_binary(slug) and slug != "" do - updated_primary = Map.put(primary_data, "_slug", slug) - data = Map.put(data, primary, updated_primary) - Ecto.Changeset.put_change(changeset, :data, data) - else - changeset - end - end - end - - defp do_seed_title(changeset, data, data_record, primary, primary_data, title) do - # Seed primary _title from the title column - updated_primary = Map.put(primary_data, "_title", title || "") - data = Map.put(data, primary, updated_primary) - - # Migrate secondary titles from metadata["translations"] - metadata = Ecto.Changeset.get_field(changeset, :metadata) || %{} - {data, metadata} = migrate_title_translations(data, metadata, title) - - changeset = Ecto.Changeset.put_change(changeset, :data, data) - - # Update title column if primary was rekeyed - changeset = maybe_sync_rekeyed_title(changeset, data, data_record, primary, title) - - if metadata != (Ecto.Changeset.get_field(changeset, :metadata) || %{}) do - Ecto.Changeset.put_change(changeset, :metadata, metadata) - else - changeset - end - end - - defp migrate_title_translations(data, metadata, primary_title) do - translations = metadata["translations"] || %{} - - Enum.reduce(translations, {data, metadata}, fn - {lang_code, %{"title" => lang_title}}, {d, m} - when is_binary(lang_title) and lang_title != "" -> - d = put_secondary_title(d, lang_code, lang_title, primary_title) - m = clean_title_translation(m, lang_code) - {d, m} - - _, acc -> - acc - end) - end - - defp put_secondary_title(data, _lang_code, lang_title, primary_title) - when lang_title == primary_title, - do: data - - defp put_secondary_title(data, lang_code, lang_title, _primary_title) do - lang_data = Map.get(data, lang_code, %{}) - Map.put(data, lang_code, Map.put(lang_data, "_title", lang_title)) - end - - defp clean_title_translation(metadata, lang_code) do - cleaned = metadata |> Map.get("translations", %{}) |> Map.delete(lang_code) - - if map_size(cleaned) == 0, - do: Map.delete(metadata, "translations"), - else: Map.put(metadata, "translations", cleaned) - end - - defp maybe_sync_rekeyed_title(changeset, data, data_record, primary, title) do - old_embedded = get_in(data_record.data || %{}, ["_primary_language"]) - - if old_embedded && old_embedded != primary do - new_title = get_in(data, [primary, "_title"]) - - if is_binary(new_title) and new_title != "" and new_title != title do - Ecto.Changeset.put_change(changeset, :title, new_title) - else - changeset - end - else - changeset - end - end - - # Helper Functions - - defp do_generate_slug(socket) do - changeset = socket.assigns.changeset - current_lang = socket.assigns[:current_lang] - primary = socket.assigns[:primary_language] - is_secondary = socket.assigns[:multilang_enabled] && current_lang != primary - title = slug_source_title(changeset, is_secondary, current_lang) - - if title == "" do - {:noreply, socket} - else - {params, changeset} = build_slug_params(socket, title, is_secondary, current_lang) - - socket = - socket - |> assign(:changeset, changeset) - |> broadcast_data_form_state(params) - - {:noreply, socket} - end - end - - defp slug_source_title(changeset, true = _secondary, current_lang) do - data = Ecto.Changeset.get_field(changeset, :data) || %{} - - case Multilang.get_language_data(data, current_lang) do - %{"_title" => lang_title} when is_binary(lang_title) and lang_title != "" -> lang_title - _ -> Ecto.Changeset.get_field(changeset, :title) || "" - end - end - - defp slug_source_title(changeset, _primary, _current_lang) do - Ecto.Changeset.get_field(changeset, :title) || "" - end - - defp build_slug_params(socket, title, is_secondary, current_lang) do - changeset = socket.assigns.changeset - db_entity_uuid = Ecto.Changeset.get_field(changeset, :entity_uuid) - db_title = Ecto.Changeset.get_field(changeset, :title) || "" - status = Ecto.Changeset.get_field(changeset, :status) || "draft" - data = Ecto.Changeset.get_field(changeset, :data) || %{} - created_by = Ecto.Changeset.get_field(changeset, :created_by) - - {slug, data} = - compute_slug_and_data(socket, title, is_secondary, current_lang, changeset, data) - - params = %{ - "entity_uuid" => db_entity_uuid, - "title" => db_title, - "slug" => slug, - "status" => status, - "data" => data, - "created_by" => created_by - } - - changeset = - socket.assigns.data_record - |> EntityData.change(params) - |> Map.put(:action, :validate) - - {params, changeset} - end - - defp compute_slug_and_data(socket, title, true = _secondary, current_lang, changeset, data) do - entity_uuid = socket.assigns.entity.uuid - record_uuid = socket.assigns.data_record.uuid - - slug_text = - title - |> Slug.slugify() - |> Slug.ensure_unique( - &EntityData.secondary_slug_exists?(entity_uuid, current_lang, &1, record_uuid) - ) - - lang_data = Multilang.get_raw_language_data(data, current_lang) - updated_lang = Map.put(lang_data, "_slug", slug_text) - updated_data = Multilang.put_language_data(data, current_lang, updated_lang) - {Ecto.Changeset.get_field(changeset, :slug), updated_data} - end - - defp compute_slug_and_data(socket, title, _primary, _current_lang, _changeset, data) do - entity_uuid = socket.assigns.entity.uuid - record_uuid = socket.assigns.data_record.uuid - slug_text = auto_generate_entity_slug(entity_uuid, record_uuid, title) - {slug_text, data} - end - - defp broadcast_data_form_state(socket, params) when is_map(params) do - socket = - if connected?(socket) && - socket.assigns[:form_record_key] && - socket.assigns[:entity] && - socket.assigns.data_record.uuid && - socket.assigns[:lock_owner?] do - data_uuid = socket.assigns.data_record.uuid - topic = PresenceHelpers.editing_topic(:data, data_uuid) - - payload = %{params: params} - - # Update Presence metadata with form state (for spectators to sync) - Presence.update(self(), topic, socket.id, fn meta -> - Map.put(meta, :form_state, payload) - end) - - # Also broadcast for real-time sync to spectators - Events.broadcast_data_form_change( - socket.assigns.entity.uuid, - socket.assigns.form_record_key, - payload, - source: socket.assigns.live_source - ) - - socket - else - socket - end - - # Mark that we have unsaved changes - assign(socket, :has_unsaved_changes, true) - end - - defp apply_remote_data_params(socket, params) when is_map(params) do - # Build the changeset WITHOUT enforcing validations yet - # This ensures we capture the exact remote state, even invalid values - changeset = - socket.assigns.data_record - |> Ecto.Changeset.cast(params, [ - :entity_uuid, - :title, - :slug, - :status, - :data, - :metadata, - :created_by - ]) - |> Map.put(:action, :validate) - - # Apply changes to get the updated record with remote values - updated_record = Ecto.Changeset.apply_changes(changeset) - - # Now create a validated changeset for display - # This will show validation errors but preserve the remote values - validated_changeset = EntityData.change(updated_record) - - socket - |> assign(:data_record, updated_record) - |> assign(:changeset, validated_changeset) - |> assign(:has_unsaved_changes, true) - end - - defp refresh_entity_assignment(socket, entity) do - params = extract_changeset_params(socket.assigns.changeset) - - data_record = %{ - socket.assigns.data_record - | entity: entity, - entity_uuid: entity.uuid - } - - changeset = - data_record - |> EntityData.change(params) - |> Map.put(:action, :validate) - - socket - |> assign(:entity, entity) - |> assign(:data_record, data_record) - |> assign(:changeset, changeset) - |> refresh_multilang() - end - - defp extract_changeset_params(changeset) do - changeset - |> Ecto.Changeset.apply_changes() - |> Map.from_struct() - |> Map.take([:entity_uuid, :title, :slug, :status, :data, :metadata, :created_by]) - |> Enum.into(%{}, fn {key, value} -> {to_string(key), value} end) - end - - defp save_data_record(socket, data_params) do - if socket.assigns.data_record.uuid do - EntityData.update(socket.assigns.data_record, data_params) - else - EntityData.create(data_params) - end - end - - defp maybe_add_creator_uuid(params, current_user, data_record) do - if data_record.uuid do - # Editing existing record - don't change creator - params - else - # Creating new record - set creator - params - |> Map.put("created_by_uuid", current_user.uuid) - end - end - - defp add_form_errors(changeset, errors) do - Enum.reduce(errors, changeset, fn {field_key, field_errors}, acc -> - Enum.reduce(field_errors, acc, fn error, inner_acc -> - Ecto.Changeset.add_error(inner_acc, :data, "#{field_key}: #{error}") - end) - end) - end - - defp ensure_live_source(socket) do - socket.assigns[:live_source] || - (socket.id || - "entities-data-" <> Base.url_encode64(:crypto.strong_rand_bytes(6), padding: false)) - end - - defp normalize_record_key({:new, key}) when is_atom(key), do: "new-#{Atom.to_string(key)}" - defp normalize_record_key({:new, key}) when is_binary(key), do: "new-#{key}" - defp normalize_record_key({:new, key}), do: "new-#{to_string(key)}" - defp normalize_record_key(key) when is_integer(key), do: Integer.to_string(key) - defp normalize_record_key(key) when is_atom(key), do: Atom.to_string(key) - defp normalize_record_key(key) when is_binary(key), do: key - defp normalize_record_key(key), do: to_string(key) - - defp auto_generate_entity_slug(_entity_uuid, _record_uuid, title) when title in [nil, ""], - do: "" - - defp auto_generate_entity_slug(entity_uuid, current_record_uuid, title) do - title - |> Slug.slugify() - |> Slug.ensure_unique(&slug_taken_by_other?(entity_uuid, &1, current_record_uuid)) - end - - defp slug_taken_by_other?(_entity_uuid, "", _current_record_uuid), do: false - - defp slug_taken_by_other?(entity_uuid, candidate, current_record_uuid) do - case EntityData.get_by_slug(entity_uuid, candidate) do - nil -> - false - - %EntityData{uuid: uuid} -> - is_nil(current_record_uuid) || uuid != current_record_uuid - end - end - - defp populate_presence_info(socket, type, id) do - # Get all presences sorted by joined_at (FIFO order) - presences = PresenceHelpers.get_sorted_presences(type, id) - - # Extract owner (first in list) and spectators (rest of list) - {lock_owner_user, lock_info, spectators} = - case presences do - [] -> - {nil, nil, []} - - [{owner_socket_id, owner_meta} | spectator_list] -> - # Build owner info - lock_info = %{ - socket_id: owner_socket_id, - user_uuid: owner_meta.user_uuid - } - - # Map spectators to expected format - spectators = - Enum.map(spectator_list, fn {spectator_socket_id, meta} -> - %{ - socket_id: spectator_socket_id, - user: meta.user, - user_uuid: meta.user_uuid - } - end) - - {owner_meta.user, lock_info, spectators} - end - - socket - |> assign(:lock_owner_user, lock_owner_user) - |> assign(:lock_info, lock_info) - |> assign(:spectators, spectators) - end -end diff --git a/lib/modules/entities/web/data_form.html.heex b/lib/modules/entities/web/data_form.html.heex deleted file mode 100644 index 3ee887620..000000000 --- a/lib/modules/entities/web/data_form.html.heex +++ /dev/null @@ -1,402 +0,0 @@ - -
- <%!-- Header Section --%> - <.admin_page_header back={ - PhoenixKit.Utils.Routes.path("/admin/entities/#{@entity.name}/data") - }> -

- <%= if @data_record.uuid do %> - {gettext("Edit %{entity}", entity: @entity.display_name)} - <% else %> - {gettext("Create New %{entity}", entity: @entity.display_name)} - <% end %> -

-

- <%= if @data_record.uuid do %> - {gettext("Update data for the %{entity} entity", entity: @entity.display_name)} - <% else %> - {gettext("Add data for the %{entity} entity", entity: @entity.display_name)} - <% end %> -

- - - <%!-- Readonly Banner --%> - <%= if @readonly? do %> -
- <.icon name="hero-eye" class="w-5 h-5" /> - - {gettext( - "This record is currently being edited by another user. You are in view-only mode." - )} - -
- <% end %> - - <%!-- Edit Mode Form --%> - <.form - :let={f} - for={@changeset} - phx-change="validate" - phx-debounce="500" - phx-submit="save" - class="space-y-8" - > -
- -
- - <%= if @show_multilang_tabs do %> - <%!-- Multilang: unified card with language tabs wrapping all content --%> - <% lang_data = get_lang_data(@changeset, @current_lang, @multilang_enabled) %> -
- <.multilang_tabs - multilang_enabled={@multilang_enabled} - language_tabs={@language_tabs} - current_lang={@current_lang} - /> - - <.multilang_fields_wrapper - multilang_enabled={@multilang_enabled} - current_lang={@current_lang} - > - <:skeleton> -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- - -
- - <%!-- Tab content: Title & Slug (translatable) --%> -
-

- <.icon name="hero-information-circle" class="w-4 h-4 inline -mt-0.5" /> - {gettext("Basic Information")} -

- -
- <.translatable_field - field_name="title" - form_prefix="phoenix_kit_entity_data" - changeset={@changeset} - schema_field={:title} - multilang_enabled={@multilang_enabled} - current_lang={@current_lang} - primary_language={@primary_language} - lang_data={lang_data} - label={gettext("Title")} - placeholder={gettext("Enter a title for this record")} - required - disabled={@readonly?} - class="w-full" - /> - - <.translatable_field - field_name="slug" - form_prefix="phoenix_kit_entity_data" - changeset={@changeset} - schema_field={:slug} - multilang_enabled={@multilang_enabled} - current_lang={@current_lang} - primary_language={@primary_language} - lang_data={lang_data} - label={gettext("Slug (URL-friendly identifier)")} - placeholder={gettext("auto-generated-slug")} - disabled={@readonly?} - class="w-full" - pattern="[a-z0-9]+(?:-[a-z0-9]+)*" - title={gettext("Use lowercase letters, numbers, and hyphens only.")} - hint={gettext("Leave empty to auto-generate from title")} - secondary_hint={gettext("Leave empty to use the primary language slug")} - > - <:label_extra> - - - -
-
- - <%= if @entity.fields_definition != nil and @entity.fields_definition != [] do %> -
- - <%!-- Tab content: Custom Fields (translatable) --%> -
-

- <.icon name="hero-list-bullet" class="w-4 h-4 inline -mt-0.5" /> - {gettext("Custom Fields")} -

- - <%!-- Dynamic form fields generated by FormBuilder --%> - {PhoenixKit.Modules.Entities.FormBuilder.build_fields(@entity, f, - wrapper_class: "mb-6", - disabled: @readonly?, - lang_code: if(@multilang_enabled, do: @current_lang, else: nil) - )} -
- <% end %> - -
- - <%!-- Record Settings (non-translatable, separate card) --%> -
-
-

- <.icon name="hero-cog-6-tooth" class="w-5 h-5" /> - {gettext("Record Settings")} -

- -
- <%!-- Status --%> -
- <.label for="phoenix_kit_entity_data_status">{gettext("Status")} - -
- - <%!-- Entity Type (Read-only) --%> -
- <.label>{gettext("Entity Type")} -
- <%= if @entity.icon do %> - <.icon name={@entity.icon} class="w-4 h-4 mr-2" /> - <% end %> - {@entity.display_name} -
- -
-
-
-
- <% else %> - <%!-- Non-multilang: separate cards (original layout) --%> -
-
-

- <.icon name="hero-information-circle" class="w-6 h-6" /> - {gettext("Basic Information")} -

- -
- <%!-- Title --%> -
- <.label for="phoenix_kit_entity_data_title">{gettext("Title")} * - -
- - <%!-- Slug with Generator --%> -
- <.label for="phoenix_kit_entity_data_slug"> - {gettext("Slug (URL-friendly identifier)")} - - - - <.label class="label"> - - {gettext("Leave empty to auto-generate from title")} - - -
- - <%!-- Status --%> -
- <.label for="phoenix_kit_entity_data_status">{gettext("Status")} - -
- - <%!-- Entity Type (Read-only) --%> -
- <.label>{gettext("Entity Type")} -
- <%= if @entity.icon do %> - <.icon name={@entity.icon} class="w-4 h-4 mr-2" /> - <% end %> - {@entity.display_name} -
- -
-
-
-
- - <%= if @entity.fields_definition != nil and @entity.fields_definition != [] do %> -
-
-

- <.icon name="hero-list-bullet" class="w-6 h-6" /> {gettext("Custom Fields")} -

- - <%!-- Dynamic form fields generated by FormBuilder --%> - {PhoenixKit.Modules.Entities.FormBuilder.build_fields(@entity, f, - wrapper_class: "mb-6", - disabled: @readonly?, - lang_code: nil - )} -
-
- <% end %> - <% end %> - - <%!-- Form Actions --%> -
-
- <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/entities/#{@entity.name}/data")} - class="btn btn-outline" - > - {gettext("Cancel")} - - - -
- - -
- -
-
diff --git a/lib/modules/entities/web/data_navigator.ex b/lib/modules/entities/web/data_navigator.ex deleted file mode 100644 index 29393b0a8..000000000 --- a/lib/modules/entities/web/data_navigator.ex +++ /dev/null @@ -1,645 +0,0 @@ -defmodule PhoenixKit.Modules.Entities.Web.DataNavigator do - @moduledoc """ - LiveView for browsing and managing entity data records. - Provides table view with pagination, search, filtering, and bulk operations. - """ - - use PhoenixKitWeb, :live_view - on_mount PhoenixKit.Modules.Entities.Web.Hooks - - alias PhoenixKit.Modules.Entities - alias PhoenixKit.Modules.Entities.EntityData - alias PhoenixKit.Modules.Entities.Events - alias PhoenixKit.Modules.Entities.Multilang - alias PhoenixKit.Settings - alias PhoenixKit.Users.Auth.Scope - alias PhoenixKit.Utils.Routes - - def mount(_params, _session, socket) do - project_title = Settings.get_project_title() - entities = Entities.list_entities() - - # Subscribe to entity definition events so we know about creates/updates/deletes - if connected?(socket) do - Events.subscribe_to_entities() - Events.subscribe_to_all_data() - end - - # Set defaults only — entity resolution and data loading deferred to handle_params - socket = - socket - |> assign(:page_title, gettext("Data Navigator")) - |> assign(:project_title, project_title) - |> assign(:entities, entities) - |> assign(:total_records, 0) - |> assign(:published_records, 0) - |> assign(:draft_records, 0) - |> assign(:archived_records, 0) - |> assign(:selected_entity, nil) - |> assign(:selected_entity_uuid, nil) - |> assign(:selected_status, "all") - |> assign(:selected_uuids, MapSet.new()) - |> assign(:search_term, "") - |> assign(:view_mode, "table") - |> assign(:entity_data_records, []) - - {:ok, socket} - end - - def handle_params(params, _url, socket) do - # Resolve entity from slug in params - {entity, entity_uuid} = resolve_entity_from_params(params, socket) - - # Update stats if entity changed - socket = maybe_update_entity_stats(socket, entity_uuid) - - # Set page title based on entity - page_title = - if entity, do: entity.display_name, else: gettext("Data Navigator") - - # Extract filter params with defaults - status = params["status"] || "all" - search_term = params["search"] || "" - view_mode = params["view"] || "table" - - socket = - socket - |> assign(:page_title, page_title) - |> assign(:selected_entity, entity) - |> assign(:selected_entity_uuid, entity_uuid) - |> assign(:selected_status, status) - |> assign(:search_term, search_term) - |> assign(:view_mode, view_mode) - |> apply_filters() - - {:noreply, socket} - end - - # Resolve entity and entity_uuid from URL params - defp resolve_entity_from_params(params, socket) do - case params["entity_slug"] || params["entity_id"] do - nil -> - {socket.assigns.selected_entity, socket.assigns.selected_entity_uuid} - - "" -> - {nil, nil} - - slug when is_binary(slug) -> - resolve_entity_by_slug(slug) - end - end - - # Look up entity by slug/name - defp resolve_entity_by_slug(slug) do - case Entities.get_entity_by_name(slug) do - nil -> {nil, nil} - entity -> {entity, entity.uuid} - end - end - - # Update entity stats if entity changed - defp maybe_update_entity_stats(socket, new_entity_uuid) do - if new_entity_uuid != socket.assigns.selected_entity_uuid do - update_entity_stats(socket, new_entity_uuid) - else - socket - end - end - - # Update socket with fresh entity statistics - defp update_entity_stats(socket, entity_uuid) do - stats = EntityData.get_data_stats(entity_uuid) - - socket - |> assign(:total_records, stats.total_records) - |> assign(:published_records, stats.published_records) - |> assign(:draft_records, stats.draft_records) - |> assign(:archived_records, stats.archived_records) - end - - def handle_event("toggle_view_mode", %{"mode" => mode}, socket) do - params = - build_url_params( - socket.assigns.selected_entity_uuid, - socket.assigns.selected_status, - socket.assigns.search_term, - mode - ) - - path = build_base_path(socket.assigns.selected_entity_uuid) - full_path = if params != "", do: "#{path}?#{params}", else: path - - socket = - socket - |> assign(:view_mode, mode) - |> assign(:selected_uuids, MapSet.new()) - |> push_patch(to: Routes.path(full_path, locale: socket.assigns.current_locale_base)) - - {:noreply, socket} - end - - def handle_event("filter_by_entity", %{"entity_uuid" => ""}, socket) do - # No entity selected - redirect to entities list since global data view no longer exists - socket = - socket - |> put_flash(:info, gettext("Please select an entity to view its data")) - |> redirect(to: Routes.path("/admin/entities", locale: socket.assigns.current_locale_base)) - - {:noreply, socket} - end - - def handle_event("filter_by_entity", %{"entity_uuid" => entity_uuid}, socket) do - params = - build_url_params( - entity_uuid, - socket.assigns.selected_status, - socket.assigns.search_term, - socket.assigns.view_mode - ) - - path = build_base_path(entity_uuid) - full_path = if params != "", do: "#{path}?#{params}", else: path - - socket = - socket - |> assign(:selected_uuids, MapSet.new()) - |> push_patch(to: Routes.path(full_path, locale: socket.assigns.current_locale_base)) - - {:noreply, socket} - end - - def handle_event("filter_by_status", %{"status" => status}, socket) do - params = - build_url_params( - socket.assigns.selected_entity_uuid, - status, - socket.assigns.search_term, - socket.assigns.view_mode - ) - - path = build_base_path(socket.assigns.selected_entity_uuid) - full_path = if params != "", do: "#{path}?#{params}", else: path - - socket = - socket - |> assign(:selected_uuids, MapSet.new()) - |> push_patch(to: Routes.path(full_path, locale: socket.assigns.current_locale_base)) - - {:noreply, socket} - end - - def handle_event("search", %{"search" => %{"term" => term}}, socket) do - params = - build_url_params( - socket.assigns.selected_entity_uuid, - socket.assigns.selected_status, - term, - socket.assigns.view_mode - ) - - path = build_base_path(socket.assigns.selected_entity_uuid) - full_path = if params != "", do: "#{path}?#{params}", else: path - - socket = - socket - |> assign(:selected_uuids, MapSet.new()) - |> push_patch(to: Routes.path(full_path, locale: socket.assigns.current_locale_base)) - - {:noreply, socket} - end - - def handle_event("clear_filters", _params, socket) do - params = - build_url_params( - socket.assigns.selected_entity_uuid, - "all", - "", - socket.assigns.view_mode - ) - - path = build_base_path(socket.assigns.selected_entity_uuid) - full_path = if params != "", do: "#{path}?#{params}", else: path - - socket = - socket - |> assign(:selected_uuids, MapSet.new()) - |> push_patch(to: Routes.path(full_path, locale: socket.assigns.current_locale_base)) - - {:noreply, socket} - end - - def handle_event("archive_data", %{"uuid" => uuid}, socket) do - if Scope.admin?(socket.assigns.phoenix_kit_current_scope) do - data_record = EntityData.get!(uuid) - - case EntityData.update_data(data_record, %{status: "archived"}) do - {:ok, _data} -> - socket = - socket - |> apply_filters() - |> put_flash(:info, gettext("Data record archived successfully")) - - {:noreply, socket} - - {:error, _changeset} -> - socket = put_flash(socket, :error, gettext("Failed to archive data record")) - {:noreply, socket} - end - else - {:noreply, put_flash(socket, :error, gettext("Not authorized"))} - end - end - - def handle_event("restore_data", %{"uuid" => uuid}, socket) do - if Scope.admin?(socket.assigns.phoenix_kit_current_scope) do - data_record = EntityData.get!(uuid) - - case EntityData.update_data(data_record, %{status: "published"}) do - {:ok, _data} -> - socket = - socket - |> apply_filters() - |> put_flash(:info, gettext("Data record restored successfully")) - - {:noreply, socket} - - {:error, _changeset} -> - socket = put_flash(socket, :error, gettext("Failed to restore data record")) - {:noreply, socket} - end - else - {:noreply, put_flash(socket, :error, gettext("Not authorized"))} - end - end - - def handle_event("toggle_status", %{"uuid" => uuid}, socket) do - if Scope.admin?(socket.assigns.phoenix_kit_current_scope) do - data_record = EntityData.get!(uuid) - - new_status = - case data_record.status do - "draft" -> "published" - "published" -> "archived" - "archived" -> "draft" - end - - case EntityData.update_data(data_record, %{status: new_status}) do - {:ok, _updated_data} -> - socket = - socket - |> refresh_data_stats() - |> apply_filters() - |> put_flash( - :info, - gettext("Status updated to %{status}", status: status_label(new_status)) - ) - - {:noreply, socket} - - {:error, _changeset} -> - socket = put_flash(socket, :error, gettext("Failed to update status")) - {:noreply, socket} - end - else - {:noreply, put_flash(socket, :error, gettext("Not authorized"))} - end - end - - def handle_event("toggle_select", %{"uuid" => uuid}, socket) do - selected = socket.assigns.selected_uuids - - selected = - if MapSet.member?(selected, uuid), - do: MapSet.delete(selected, uuid), - else: MapSet.put(selected, uuid) - - {:noreply, assign(socket, :selected_uuids, selected)} - end - - def handle_event("select_all", _params, socket) do - all_uuids = socket.assigns.entity_data_records |> Enum.map(& &1.uuid) |> MapSet.new() - {:noreply, assign(socket, :selected_uuids, all_uuids)} - end - - def handle_event("deselect_all", _params, socket) do - {:noreply, assign(socket, :selected_uuids, MapSet.new())} - end - - def handle_event("bulk_action", %{"action" => "archive"}, socket) do - if Scope.admin?(socket.assigns.phoenix_kit_current_scope) do - uuids = socket.assigns.selected_uuids - - if MapSet.size(uuids) == 0 do - {:noreply, put_flash(socket, :error, gettext("No records selected"))} - else - {count, _} = EntityData.bulk_update_status(MapSet.to_list(uuids), "archived") - - {:noreply, - socket - |> assign(:selected_uuids, MapSet.new()) - |> refresh_data_stats() - |> apply_filters() - |> put_flash(:info, gettext("%{count} records archived", count: count))} - end - else - {:noreply, put_flash(socket, :error, gettext("Not authorized"))} - end - end - - def handle_event("bulk_action", %{"action" => "restore"}, socket) do - if Scope.admin?(socket.assigns.phoenix_kit_current_scope) do - uuids = socket.assigns.selected_uuids - - if MapSet.size(uuids) == 0 do - {:noreply, put_flash(socket, :error, gettext("No records selected"))} - else - {count, _} = EntityData.bulk_update_status(MapSet.to_list(uuids), "published") - - {:noreply, - socket - |> assign(:selected_uuids, MapSet.new()) - |> refresh_data_stats() - |> apply_filters() - |> put_flash(:info, gettext("%{count} records restored", count: count))} - end - else - {:noreply, put_flash(socket, :error, gettext("Not authorized"))} - end - end - - def handle_event("bulk_action", %{"action" => "delete"}, socket) do - if Scope.admin?(socket.assigns.phoenix_kit_current_scope) do - uuids = socket.assigns.selected_uuids - - if MapSet.size(uuids) == 0 do - {:noreply, put_flash(socket, :error, gettext("No records selected"))} - else - {count, _} = EntityData.bulk_delete(MapSet.to_list(uuids)) - - {:noreply, - socket - |> assign(:selected_uuids, MapSet.new()) - |> refresh_data_stats() - |> apply_filters() - |> put_flash(:info, gettext("%{count} records deleted", count: count))} - end - else - {:noreply, put_flash(socket, :error, gettext("Not authorized"))} - end - end - - def handle_event("bulk_action", %{"action" => "change_status", "status" => status}, socket) do - if Scope.admin?(socket.assigns.phoenix_kit_current_scope) do - uuids = socket.assigns.selected_uuids - - if MapSet.size(uuids) == 0 do - {:noreply, put_flash(socket, :error, gettext("No records selected"))} - else - {count, _} = EntityData.bulk_update_status(MapSet.to_list(uuids), status) - - {:noreply, - socket - |> assign(:selected_uuids, MapSet.new()) - |> refresh_data_stats() - |> apply_filters() - |> put_flash(:info, gettext("%{count} records updated", count: count))} - end - else - {:noreply, put_flash(socket, :error, gettext("Not authorized"))} - end - end - - ## Live updates - - def handle_info({:entity_created, _entity_uuid}, socket) do - {:noreply, refresh_entities_and_data(socket)} - end - - def handle_info({:entity_updated, entity_uuid}, socket) do - # If the currently viewed entity was updated, check if it was archived - if socket.assigns.selected_entity_uuid && entity_uuid == socket.assigns.selected_entity_uuid do - entity = Entities.get_entity!(entity_uuid) - - # If entity was archived or unpublished, redirect to entities list - if entity.status != "published" do - {:noreply, - socket - |> put_flash( - :warning, - gettext("Entity '%{name}' was %{status} in another session.", - name: entity.display_name, - status: entity.status - ) - ) - |> redirect( - to: Routes.path("/admin/entities", locale: socket.assigns.current_locale_base) - )} - else - # Update the selected entity and page title with fresh data - socket = - socket - |> assign(:selected_entity, entity) - |> assign(:page_title, entity.display_name) - |> refresh_entities_and_data() - - {:noreply, socket} - end - else - {:noreply, refresh_entities_and_data(socket)} - end - end - - def handle_info({:entity_deleted, entity_uuid}, socket) do - # If the currently viewed entity was deleted, redirect to entities list - if socket.assigns.selected_entity_uuid && entity_uuid == socket.assigns.selected_entity_uuid do - {:noreply, - socket - |> put_flash(:error, gettext("Entity was deleted in another session.")) - |> redirect(to: Routes.path("/admin/entities", locale: socket.assigns.current_locale_base))} - else - {:noreply, refresh_entities_and_data(socket)} - end - end - - def handle_info({event, _entity_uuid, _data_uuid}, socket) - when event in [:data_created, :data_updated, :data_deleted] do - socket = - socket - |> refresh_data_stats() - |> apply_filters() - - {:noreply, socket} - end - - def handle_info({:data_reordered, _entity_uuid}, socket) do - {:noreply, apply_filters(socket)} - end - - # Helper Functions - - defp build_base_path(nil), do: "/admin/entities" - - defp build_base_path(entity_uuid) when is_binary(entity_uuid) do - case Entities.get_entity(entity_uuid) do - nil -> "/admin/entities" - entity -> "/admin/entities/#{entity.name}/data" - end - end - - defp build_url_params(_entity_uuid, status, search_term, view_mode) do - params = [] - - # Don't include entity_uuid in query params since it's in the path - - params = - if status && status != "all" do - [{"status", status} | params] - else - params - end - - params = - if search_term && String.trim(search_term) != "" do - [{"search", search_term} | params] - else - params - end - - params = - if view_mode && view_mode != "table" do - [{"view", view_mode} | params] - else - params - end - - URI.encode_query(params) - end - - defp apply_filters(socket) do - entity = socket.assigns[:selected_entity] - entity_uuid = socket.assigns[:selected_entity_uuid] - status = socket.assigns[:selected_status] || "all" - search_term = socket.assigns[:search_term] || "" - - # Pass sort_mode from the already-loaded entity to avoid redundant DB lookups - sort_opts = - if entity, do: [sort_mode: Entities.get_sort_mode(entity)], else: [] - - entity_data_records = - fetch_records(entity_uuid, status, sort_opts) - |> filter_by_search(search_term) - - assign(socket, :entity_data_records, entity_data_records) - end - - # When an entity is selected, use sort-mode-aware queries - defp fetch_records(nil, "all", _opts), do: EntityData.list_all_data() - defp fetch_records(nil, status, _opts), do: EntityData.list_data_by_status(status) - - defp fetch_records(entity_uuid, "all", opts), - do: EntityData.list_by_entity(entity_uuid, opts) - - defp fetch_records(entity_uuid, status, opts), - do: EntityData.list_by_entity_and_status(entity_uuid, status, opts) - - defp filter_by_search(records, ""), do: records - - defp filter_by_search(records, search_term) do - search_term_lower = String.downcase(String.trim(search_term)) - - Enum.filter(records, fn record -> - title_match = String.contains?(String.downcase(record.title || ""), search_term_lower) - slug_match = String.contains?(String.downcase(record.slug || ""), search_term_lower) - - title_match || slug_match - end) - end - - defp refresh_data_stats(socket) do - stats = EntityData.get_data_stats(socket.assigns.selected_entity_uuid) - - socket - |> assign(:total_records, stats.total_records) - |> assign(:published_records, stats.published_records) - |> assign(:draft_records, stats.draft_records) - |> assign(:archived_records, stats.archived_records) - end - - defp refresh_entities_and_data(socket) do - socket - |> assign(:entities, Entities.list_entities()) - |> refresh_data_stats() - |> apply_filters() - end - - def status_badge_class(status) do - case status do - "published" -> "badge-success" - "draft" -> "badge-warning" - "archived" -> "badge-neutral" - _ -> "badge-outline" - end - end - - def status_label(status) do - case status do - "published" -> gettext("Published") - "draft" -> gettext("Draft") - "archived" -> gettext("Archived") - _ -> gettext("Unknown") - end - end - - def status_icon(status) do - case status do - "published" -> "hero-check-circle" - "draft" -> "hero-pencil" - "archived" -> "hero-archive-box" - _ -> "hero-question-mark-circle" - end - end - - def get_entity_name(entities, entity_uuid) do - case Enum.find(entities, &(&1.uuid == entity_uuid)) do - nil -> gettext("Unknown") - entity -> entity.display_name - end - end - - def get_entity_slug(entities, entity_uuid) do - case Enum.find(entities, &(&1.uuid == entity_uuid)) do - nil -> "" - entity -> entity.name - end - end - - def truncate_text(text, length \\ 100) - - def truncate_text(text, length) when is_binary(text) do - if String.length(text) > length do - String.slice(text, 0, length) <> "..." - else - text - end - end - - def truncate_text(_, _), do: "" - - def format_data_preview(data) when is_map(data) do - # For multilang data, show primary language fields - display_data = - if Multilang.multilang_data?(data) do - Multilang.flatten_to_primary(data) - else - data - end - - display_data - |> Enum.take(3) - |> Enum.map_join(" • ", fn {key, value} -> - "#{key}: #{truncate_text(to_string(value), 30)}" - end) - end - - def format_data_preview(_), do: "" -end diff --git a/lib/modules/entities/web/data_navigator.html.heex b/lib/modules/entities/web/data_navigator.html.heex deleted file mode 100644 index 429df7982..000000000 --- a/lib/modules/entities/web/data_navigator.html.heex +++ /dev/null @@ -1,714 +0,0 @@ - -
- <%!-- Header Section --%> - <.admin_page_header back={PhoenixKit.Utils.Routes.path("/admin/entities")}> - <%= if @selected_entity do %> -

- {@selected_entity.display_name_plural || @selected_entity.display_name} -

-

- {gettext("Browse and manage your %{entity}", - entity: - String.downcase( - @selected_entity.display_name_plural || @selected_entity.display_name - ) - )} -

- <% else %> -

- {gettext("Data Navigator")} -

-

- {gettext("Browse and manage all entity data records across your system")} -

- <% end %> - - - <%!-- Stats Cards --%> -
-
-
-
- <.icon name="hero-circle-stack" class="w-6 h-6" /> -
-
-
{@total_records}
-
{gettext("Total Records")}
-
{gettext("All data records")}
-
- -
-
-
- <.icon name="hero-bolt" class="w-6 h-6" /> -
-
-
{@published_records}
-
{gettext("Published")}
-
{gettext("Live content")}
-
- -
-
-
- <.icon name="hero-pencil" class="w-6 h-6" /> -
-
-
{@draft_records}
-
{gettext("Drafts")}
-
{gettext("Work in progress")}
-
- -
-
-
- <.icon name="hero-archive-box" class="w-6 h-6" /> -
-
-
{@archived_records}
-
{gettext("Archived")}
-
{gettext("Stored content")}
-
-
- - <%!-- Action Bar --%> -
-
- <%!-- View Mode Toggle --%> -
- - -
- - <%= if @selected_entity do %> - <.link - navigate={ - PhoenixKit.Utils.Routes.path("/admin/entities/#{@selected_entity.uuid}/edit") - } - class="btn btn-outline" - > - <.icon name="hero-cog-6-tooth" class="w-4 h-4 mr-2" /> {gettext("Edit Entity")} - - <% end %> - <%= if not Enum.empty?(@entities) do %> - <%= if @selected_entity do %> - <%!-- Direct add button when viewing specific entity --%> - <.link - navigate={ - PhoenixKit.Utils.Routes.path("/admin/entities/#{@selected_entity.name}/data/new") - } - class="btn btn-primary" - > - <.icon name="hero-plus" class="w-4 h-4 mr-2" /> {gettext("Add")} - - <% else %> - <%!-- Dropdown to select entity when viewing all data --%> - - <% end %> - <% end %> -
-
- - <%!-- Filters Section --%> -
-
-

- <.icon name="hero-funnel" class="w-5 h-5" /> {gettext("Filters & Search")} -

- -
- <%!-- Status Filter --%> -
- - <.form for={%{}} phx-change="filter_by_status"> - - -
- - <%!-- Search --%> -
- - <.form for={%{}} phx-change="search" phx-submit="search" class="join w-full"> - - - -
-
- - <%!-- Clear Filters --%> - <%= if @selected_status != "all" || @search_term != "" do %> -
- -
- <% end %> -
-
- - <%!-- Bulk Actions Bar --%> - <%= if MapSet.size(@selected_uuids) > 0 do %> -
-
-
- - {MapSet.size(@selected_uuids)} {gettext("selected")} - -
- <%!-- Quick Actions --%> - - - - -
- - <%!-- Change Status Dropdown --%> - -
- -
-
-
- <% end %> - - <%!-- Results Section --%> - <%= if Enum.empty?(@entity_data_records) do %> - <%!-- Empty State --%> -
-
-
📄
- <%= if Enum.empty?(@entities) do %> - <%!-- No entities exist --%> -

- {gettext("No Entities Created Yet")} -

-

- {gettext("Create your first entity to start managing data records.")} -

- <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/entities")} - class="btn btn-primary btn-lg" - > - <.icon name="hero-plus" class="w-5 h-5 mr-2" /> {gettext("Create Your First Entity")} - - <% else %> - <%= if @total_records == 0 do %> - <%!-- Entities exist but no data records at all --%> -

- {gettext("No Data Records Yet")} -

-

- {gettext("Get started by adding your first data record.")} -

- <%= if @selected_entity do %> - <%!-- Direct add button when viewing specific entity --%> - <.link - navigate={ - PhoenixKit.Utils.Routes.path( - "/admin/entities/#{@selected_entity.name}/data/new" - ) - } - class="btn btn-primary btn-lg" - > - <.icon name="hero-plus" class="w-5 h-5 mr-2" /> {gettext("Add")} - - <% else %> - <%!-- Dropdown to select entity when viewing all data --%> - - <% end %> - <% else %> - <%= if @selected_entity_uuid || @selected_status != "all" || @search_term != "" do %> - <%!-- Data exists but filters exclude everything --%> -

- {gettext("No Data Records Found")} -

-

- {gettext( - "No data records match your current filters. Try adjusting your search criteria or clearing the filters." - )} -

- - <% end %> - <% end %> - <% end %> -
-
- <% else %> - <%!-- Data Records View --%> - <%= if @view_mode == "table" do %> - <%!-- Table View --%> - <.table_default variant="zebra" size="sm"> - <.table_default_header> - <.table_default_row> - <.table_default_header_cell class="w-12"> - <%= if length(@entity_data_records) > 0 do %> - 0 - } - phx-click={ - if MapSet.size(@selected_uuids) == length(@entity_data_records), - do: "deselect_all", - else: "select_all" - } - title={gettext("Select all")} - /> - <% end %> - - <.table_default_header_cell>{gettext("Title")} - <%= if !@selected_entity do %> - <.table_default_header_cell>{gettext("Entity")} - <% end %> - <.table_default_header_cell>{gettext("Status")} - <.table_default_header_cell>{gettext("Created")} - <.table_default_header_cell>{gettext("Actions")} - - - <.table_default_body> - <%= for data_record <- @entity_data_records do %> - <.table_default_row> - <.table_default_cell> - - - <.table_default_cell> - <.link - navigate={ - PhoenixKit.Utils.Routes.path( - "/admin/entities/#{get_entity_slug(@entities, data_record.entity_uuid)}/data/#{data_record.uuid}" - ) - } - class="block hover:text-primary transition-colors cursor-pointer" - > -
{data_record.title}
- <%= if data_record.slug do %> -
- <.icon name="hero-link" class="w-3 h-3 inline" /> - {data_record.slug} -
- <% end %> - - - <%= if !@selected_entity do %> - <.table_default_cell> - - {get_entity_name(@entities, data_record.entity_uuid)} - - - <% end %> - <.table_default_cell> - - <.icon name={status_icon(data_record.status)} class="w-3 h-3 mr-1" /> - {status_label(data_record.status)} - - - <.table_default_cell> -
- {PhoenixKit.Utils.Date.format_date_with_user_format(data_record.date_created)} -
- <%= if data_record.creator do %> -
- {data_record.creator.email} -
- <% end %> - - <.table_default_cell> -
- <.link - navigate={ - PhoenixKit.Utils.Routes.path( - "/admin/entities/#{get_entity_slug(@entities, data_record.entity_uuid)}/data/#{data_record.uuid}" - ) - } - class="btn btn-outline btn-xs tooltip tooltip-bottom" - data-tip={gettext("View")} - > - <.icon name="hero-eye" class="w-4 h-4 hidden sm:inline" /> - {gettext("View")} - - <.link - navigate={ - PhoenixKit.Utils.Routes.path( - "/admin/entities/#{get_entity_slug(@entities, data_record.entity_uuid)}/data/#{data_record.uuid}/edit" - ) - } - class="btn btn-outline btn-xs tooltip tooltip-bottom" - data-tip={gettext("Edit")} - > - <.icon name="hero-pencil" class="w-4 h-4 hidden sm:inline" /> - {gettext("Edit")} - - <%= if data_record.status == "archived" do %> - - <% else %> - - <% end %> -
- - - <% end %> - - - <% else %> - <%!-- Card View --%> -
- <%= for data_record <- @entity_data_records do %> -
-
-
- -
-
- <.link - navigate={ - PhoenixKit.Utils.Routes.path( - "/admin/entities/#{get_entity_slug(@entities, data_record.entity_uuid)}/data/#{data_record.uuid}" - ) - } - class="flex-1 hover:text-primary transition-colors cursor-pointer" - > - <%!-- Title and Entity Info --%> -
-

{data_record.title}

- <%= if !@selected_entity do %> - - {get_entity_name(@entities, data_record.entity_uuid)} - - <% end %> -
- - <%!-- Slug --%> - <%= if data_record.slug do %> -

- <.icon name="hero-link" class="w-4 h-4 inline mr-1" /> - {data_record.slug} -

- <% end %> - - <%!-- Data Preview --%> - <%= if data_record.data && map_size(data_record.data) > 0 do %> -

- {format_data_preview(data_record.data)} -

- <% end %> - - - <%!-- Status Badge --%> -
- - <.icon name={status_icon(data_record.status)} class="w-3 h-3 mr-1" /> - {status_label(data_record.status)} - - - <%!-- Status Toggle Button --%> - -
-
- - <%!-- Metadata Row --%> -
- <%= if data_record.creator do %> - - <.icon name="hero-user" class="w-3 h-3 inline mr-1" /> - {data_record.creator.email} - - <% end %> - - <.icon name="hero-calendar" class="w-3 h-3 inline mr-1" /> - {gettext("Created")} {PhoenixKit.Utils.Date.format_date_with_user_format( - data_record.date_created - )} - - <%= if data_record.date_updated != data_record.date_created do %> - - <.icon name="hero-clock" class="w-3 h-3 inline mr-1" /> - {gettext("Updated")} {PhoenixKit.Utils.Date.format_date_with_user_format( - data_record.date_updated - )} - - <% end %> -
-
-
- - <%!-- Actions --%> -
- <.link - navigate={ - PhoenixKit.Utils.Routes.path( - "/admin/entities/#{get_entity_slug(@entities, data_record.entity_uuid)}/data/#{data_record.uuid}" - ) - } - class="btn btn-outline btn-sm" - > - <.icon name="hero-eye" class="w-4 h-4 mr-1" /> {gettext("View")} - - <.link - navigate={ - PhoenixKit.Utils.Routes.path( - "/admin/entities/#{get_entity_slug(@entities, data_record.entity_uuid)}/data/#{data_record.uuid}/edit" - ) - } - class="btn btn-primary btn-sm" - > - <.icon name="hero-pencil" class="w-4 h-4 mr-1" /> {gettext("Edit")} - - - <%!-- Archive/Restore Button --%> - <%= if data_record.status == "archived" do %> - - <% else %> - - <% end %> -
-
-
- <% end %> -
- <% end %> - <% end %> -
-
diff --git a/lib/modules/entities/web/data_view.ex b/lib/modules/entities/web/data_view.ex deleted file mode 100644 index a72c970af..000000000 --- a/lib/modules/entities/web/data_view.ex +++ /dev/null @@ -1,493 +0,0 @@ -defmodule PhoenixKit.Modules.Entities.Web.DataView do - @moduledoc """ - LiveView for viewing entity data records. - Displays data with public form fields separated from other fields. - Uses FormBuilder with disabled fields for the form section. - """ - - # Extension point: declare a route at the same path BEFORE phoenix_kit_routes() - # in your router to override this view. See lib/modules/entities/README.md. - - use PhoenixKitWeb, :live_view - on_mount PhoenixKit.Modules.Entities.Web.Hooks - - import PhoenixKitWeb.Components.MultilangForm - - alias PhoenixKit.Modules.Entities - alias PhoenixKit.Modules.Entities.EntityData - alias PhoenixKit.Modules.Entities.FormBuilder - alias PhoenixKit.Settings - - @impl true - def mount(%{"entity_slug" => entity_slug, "id" => id} = params, _session, socket) do - locale = - params["locale"] || socket.assigns[:current_locale] - - entity = Entities.get_entity_by_name(entity_slug) - data_record = EntityData.get!(id) - - mount_data_view(socket, entity, data_record, locale) - end - - def mount(%{"entity_id" => entity_uuid, "id" => id} = params, _session, socket) do - locale = - params["locale"] || socket.assigns[:current_locale] - - entity = Entities.get_entity!(entity_uuid) - data_record = EntityData.get!(id) - - mount_data_view(socket, entity, data_record, locale) - end - - defp mount_data_view(socket, entity, data_record, locale) do - project_title = Settings.get_project_title() - - # Get public form configuration - settings = entity.settings || %{} - public_form_enabled = Map.get(settings, "public_form_enabled", false) - public_form_fields = Map.get(settings, "public_form_fields", []) - - # Check if this record was submitted via public form - is_public_submission = public_submission?(data_record.metadata) - - # Get all field definitions - fields_definition = entity.fields_definition || [] - - # Separate fields into form fields and other fields - # Show form fields separately if: - # 1. Public form is currently enabled, OR - # 2. This record was submitted via public form (even if form is now disabled) - {form_fields, other_fields} = - if public_form_enabled || is_public_submission do - Enum.split_with(fields_definition, fn field -> - field["key"] in public_form_fields - end) - else - # If public form not enabled and not a public submission, all fields go to "other" - {[], fields_definition} - end - - # Get data values - data = data_record.data || %{} - - # Create changeset for FormBuilder (readonly display) - changeset = EntityData.change(data_record) - - # Create a modified entity with only form fields for FormBuilder - form_entity = %{entity | fields_definition: form_fields} - - # Create a modified entity with only other fields for display - other_entity = %{entity | fields_definition: other_fields} - - socket = - socket - |> assign(:current_locale, locale) - |> assign(:page_title, gettext("View Data")) - |> assign(:project_title, project_title) - |> assign(:entity, entity) - |> assign(:form_entity, form_entity) - |> assign(:other_entity, other_entity) - |> assign(:data_record, data_record) - |> assign(:changeset, changeset) - |> assign(:data, data) - |> assign(:public_form_enabled, public_form_enabled) - |> assign(:form_fields, form_fields) - |> assign(:other_fields, other_fields) - |> assign(:public_form_title, Map.get(settings, "public_form_title", "")) - |> assign(:public_form_description, Map.get(settings, "public_form_description", "")) - |> assign(:metadata, data_record.metadata || %{}) - |> assign(:is_public_submission, public_submission?(data_record.metadata)) - |> mount_multilang() - - {:ok, socket} - end - - defp public_submission?(nil), do: false - defp public_submission?(metadata), do: Map.get(metadata, "source") == "public_form" - - @impl true - def handle_event("switch_language", %{"lang" => lang_code}, socket) do - {:noreply, handle_switch_language(socket, lang_code)} - end - - @impl true - def render(assigns) do - ~H""" - -
- <%!-- Header Section --%> -
- <%!-- Back Button --%> - <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/entities/#{@entity.name}/data")} - class="btn btn-ghost btn-sm absolute left-0 top-0 -mb-12" - > - <.icon name="hero-arrow-left" class="w-4 h-4" /> - - - <%!-- Title Section --%> -
-

- {@data_record.title} -

-

- {gettext("Viewing %{entity} record", entity: @entity.display_name)} -

- <%= if @data_record.slug do %> -

- <.icon name="hero-link" class="w-4 h-4 inline" /> - {@data_record.slug} -

- <% end %> -
-
- - <%!-- Record Metadata --%> -
-
-

- <.icon name="hero-information-circle" class="w-5 h-5" /> - {gettext("Record Information")} -

-
-
- {gettext("Status")} -
- - {@data_record.status} - -
-
-
- {gettext("Created")} -
- {PhoenixKit.Utils.Date.format_datetime_with_user_format(@data_record.date_created)} -
-
-
- {gettext("Updated")} -
- {PhoenixKit.Utils.Date.format_datetime_with_user_format(@data_record.date_updated)} -
-
-
-
-
- - <%!-- Language Selector (only when multilang enabled) --%> - <%= if @show_multilang_tabs do %> -
- <.multilang_tabs - multilang_enabled={@multilang_enabled} - language_tabs={@language_tabs} - current_lang={@current_lang} - show_info={false} - /> -
- <%= if @current_lang == @primary_language do %> -

- <.icon name="hero-information-circle" class="w-3.5 h-3.5 inline -mt-0.5" /> - {gettext("This is the primary language.")} -

- <% else %> -

- <.icon name="hero-information-circle" class="w-3.5 h-3.5 inline -mt-0.5" /> - {gettext("Fields without a value show the primary language value.")} -

- <% end %> -
-
- <% end %> - - <%= if (@public_form_enabled || @is_public_submission) && length(@form_fields) > 0 do %> - <%!-- Public Form Fields Section - Using FormBuilder with disabled inputs --%> -
-
-

- <.icon name="hero-document-text" class="w-5 h-5" /> - <%= if @public_form_title != "" do %> - {@public_form_title} - <% else %> - {gettext("Form Submission")} - <% end %> -

- <%= if @public_form_description != "" do %> -

{@public_form_description}

- <% end %> - - <%!-- Use FormBuilder with disabled fields --%> - {FormBuilder.build_fields(@form_entity, @changeset, - wrapper_class: "mb-4", - disabled: true, - lang_code: if(@multilang_enabled, do: @current_lang, else: nil) - )} -
-
- <% end %> - - <%= if @is_public_submission do %> - <%!-- Security Warnings Section (if any) --%> - <%= if @metadata["security_warnings"] && length(@metadata["security_warnings"]) > 0 do %> -
- <.icon name="hero-exclamation-triangle" class="w-6 h-6" /> -
-

{gettext("Security Flags")}

-
- <%= for warning <- @metadata["security_warnings"] do %> -
- - {security_warning_label(warning["type"])} - - - {security_action_label(warning["action"])} - -
- <% end %> -
-
-
- <% end %> - - <%!-- Submission Metadata Section --%> -
-
-

- <.icon name="hero-globe-alt" class="w-5 h-5" /> - {gettext("Submission Details")} -

-
- <%= if @metadata["ip_address"] do %> -
-
- <.icon name="hero-signal" class="w-5 h-5 text-base-content/60" /> -
-
- {gettext("IP Address")} -
{@metadata["ip_address"]}
-
-
- <% end %> - - <%= if @metadata["browser"] do %> -
-
- <.icon name="hero-window" class="w-5 h-5 text-base-content/60" /> -
-
- {gettext("Browser")} -
{@metadata["browser"]}
-
-
- <% end %> - - <%= if @metadata["os"] do %> -
-
- <.icon name="hero-computer-desktop" class="w-5 h-5 text-base-content/60" /> -
-
- {gettext("Operating System")} -
{@metadata["os"]}
-
-
- <% end %> - - <%= if @metadata["device"] do %> -
-
- <.icon - name={device_icon(@metadata["device"])} - class="w-5 h-5 text-base-content/60" - /> -
-
- {gettext("Device")} -
{@metadata["device"]}
-
-
- <% end %> - - <%= if @metadata["submitted_at"] do %> -
-
- <.icon name="hero-clock" class="w-5 h-5 text-base-content/60" /> -
-
- {gettext("Submitted At")} -
{format_submitted_at(@metadata["submitted_at"])}
-
-
- <% end %> - - <%= if @metadata["time_to_submit_seconds"] do %> -
-
- <.icon name="hero-stopwatch" class="w-5 h-5 text-base-content/60" /> -
-
- {gettext("Time to Submit")} -
- {format_duration(@metadata["time_to_submit_seconds"])} -
-
-
- <% end %> - - <%= if @metadata["referer"] do %> -
-
- <.icon - name="hero-arrow-top-right-on-square" - class="w-5 h-5 text-base-content/60" - /> -
-
- {gettext("Referrer")} -
- {@metadata["referer"]} -
-
-
- <% end %> -
- - <%= if @metadata["user_agent"] do %> -
-
- - {gettext("Full User Agent")} - -
- {@metadata["user_agent"]} -
-
-
- <% end %> -
-
- <% end %> - - <%= if length(@other_fields) > 0 do %> - <%!-- Other Fields Section - Using FormBuilder with disabled inputs --%> -
-
-

- <.icon name="hero-squares-2x2" class="w-5 h-5" /> - <%= if (@public_form_enabled || @is_public_submission) && length(@form_fields) > 0 do %> - {gettext("Additional Data")} - <% else %> - {gettext("Data Fields")} - <% end %> -

- - <%!-- Use FormBuilder with disabled fields --%> - {FormBuilder.build_fields(@other_entity, @changeset, - wrapper_class: "mb-4", - disabled: true, - lang_code: if(@multilang_enabled, do: @current_lang, else: nil) - )} -
-
- <% end %> - - <%= if map_size(@data) == 0 && length(@form_fields) == 0 && length(@other_fields) == 0 do %> - <%!-- Empty State --%> -
-
-
📄
-

{gettext("No data fields have been filled in yet.")}

-
-
- <% end %> - - <%!-- Actions --%> -
- <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/entities/#{@entity.name}/data")} - class="btn btn-outline" - > - {gettext("Back")} - - - <.link - navigate={ - PhoenixKit.Utils.Routes.path( - "/admin/entities/#{@entity.name}/data/#{@data_record.uuid}/edit" - ) - } - class="btn btn-primary" - > - <.icon name="hero-pencil" class="w-4 h-4 mr-2" /> - {gettext("Edit %{entity}", entity: @entity.display_name)} - -
-
-
- """ - end - - defp status_badge_class("published"), do: "badge-success" - defp status_badge_class("draft"), do: "badge-warning" - defp status_badge_class("archived"), do: "badge-ghost" - defp status_badge_class(_), do: "badge-ghost" - - defp device_icon("mobile"), do: "hero-device-phone-mobile" - defp device_icon("tablet"), do: "hero-device-tablet" - defp device_icon(_), do: "hero-computer-desktop" - - defp format_submitted_at(iso_string) when is_binary(iso_string) do - case DateTime.from_iso8601(iso_string) do - {:ok, datetime, _offset} -> - PhoenixKit.Utils.Date.format_datetime_with_user_format(datetime) - - _ -> - iso_string - end - end - - defp format_submitted_at(_), do: "-" - - defp format_duration(seconds) when is_integer(seconds) do - cond do - seconds < 60 -> - ngettext("%{count} second", "%{count} seconds", seconds, count: seconds) - - seconds < 3600 -> - minutes = div(seconds, 60) - ngettext("%{count} minute", "%{count} minutes", minutes, count: minutes) - - true -> - hours = div(seconds, 3600) - minutes = div(rem(seconds, 3600), 60) - - if minutes > 0 do - "#{ngettext("%{count} hour", "%{count} hours", hours, count: hours)}, #{ngettext("%{count} minute", "%{count} minutes", minutes, count: minutes)}" - else - ngettext("%{count} hour", "%{count} hours", hours, count: hours) - end - end - end - - defp format_duration(_), do: "-" - - defp security_warning_label("honeypot"), do: gettext("Honeypot triggered") - defp security_warning_label("too_fast"), do: gettext("Submitted too fast") - defp security_warning_label("rate_limited"), do: gettext("Rate limited") - defp security_warning_label(type), do: type - - defp security_action_label("save_suspicious"), do: gettext("Marked as suspicious") - defp security_action_label("save_log"), do: gettext("Logged warning") - defp security_action_label(action), do: action -end diff --git a/lib/modules/entities/web/entities.ex b/lib/modules/entities/web/entities.ex deleted file mode 100644 index 728d83dc5..000000000 --- a/lib/modules/entities/web/entities.ex +++ /dev/null @@ -1,102 +0,0 @@ -defmodule PhoenixKit.Modules.Entities.Web.Entities do - @moduledoc """ - LiveView for listing and managing all entities. - Provides interface for viewing, publishing, and deleting entity schemas. - """ - - use PhoenixKitWeb, :live_view - on_mount PhoenixKit.Modules.Entities.Web.Hooks - - alias PhoenixKit.Modules.Entities - alias PhoenixKit.Settings - - def mount(params, _session, socket) do - # Set locale for LiveView process - locale = - params["locale"] || socket.assigns[:current_locale] - - project_title = Settings.get_project_title() - - socket = - socket - |> assign(:current_locale, locale) - |> assign(:page_title, gettext("Entities")) - |> assign(:project_title, project_title) - |> assign(:view_mode, "table") - |> assign(:entities, Entities.list_entities()) - - {:ok, socket} - end - - def handle_params(params, _url, socket) do - view_mode = Map.get(params, "view", "table") - - socket = - socket - |> assign(:view_mode, view_mode) - - {:noreply, socket} - end - - def handle_event("toggle_view_mode", %{"mode" => mode}, socket) do - base_path = current_base_path(socket) - query = if mode != "table", do: "?view=#{mode}", else: "" - - {:noreply, push_patch(socket, to: "#{base_path}#{query}")} - end - - def handle_event("archive_entity", %{"uuid" => uuid}, socket) do - entity = Entities.get_entity!(uuid) - - case Entities.update_entity(entity, %{status: "archived"}) do - {:ok, _entity} -> - socket = - socket - |> assign(:entities, Entities.list_entities()) - |> put_flash( - :info, - gettext("Entity '%{name}' archived successfully", name: entity.display_name) - ) - - {:noreply, socket} - - {:error, _changeset} -> - {:noreply, put_flash(socket, :error, gettext("Failed to archive entity"))} - end - end - - def handle_event("restore_entity", %{"uuid" => uuid}, socket) do - entity = Entities.get_entity!(uuid) - - case Entities.update_entity(entity, %{status: "published"}) do - {:ok, _entity} -> - socket = - socket - |> assign(:entities, Entities.list_entities()) - |> put_flash( - :info, - gettext("Entity '%{name}' restored successfully", name: entity.display_name) - ) - - {:noreply, socket} - - {:error, _changeset} -> - {:noreply, put_flash(socket, :error, gettext("Failed to restore entity"))} - end - end - - ## Live updates - - def handle_info({event, _entity_uuid}, socket) - when event in [:entity_created, :entity_updated, :entity_deleted] do - {:noreply, assign(socket, :entities, Entities.list_entities())} - end - - # Helper Functions - - # Extracts the base path (without query string) from the current URL, - # which already includes the correct locale and prefix segments. - defp current_base_path(socket) do - (socket.assigns[:url_path] || "") |> URI.parse() |> Map.get(:path) || "/" - end -end diff --git a/lib/modules/entities/web/entities.html.heex b/lib/modules/entities/web/entities.html.heex deleted file mode 100644 index 4b1b3746e..000000000 --- a/lib/modules/entities/web/entities.html.heex +++ /dev/null @@ -1,314 +0,0 @@ - -
- <%!-- Header Section --%> - <.admin_page_header - back={PhoenixKit.Utils.Routes.path("/admin/modules")} - title={gettext("Entity Manager")} - subtitle={gettext("Create and manage custom content types with dynamic fields")} - /> - - <%!-- Action Bar --%> -
-
-

{gettext("All Entities")}

-

- {gettext("Manage custom content types and field definitions")} -

-
- -
- <%!-- View Mode Toggle (hidden on small screens — cards are forced) --%> - - - <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/entities/new")} - class="btn btn-primary" - > - <.icon name="hero-plus" class="w-4 h-4 mr-2" /> {gettext("New Entity")} - -
-
- - <%!-- Entities Grid --%> - <%= if Enum.empty?(@entities) do %> - <%!-- Empty State --%> -
-
-
<.icon name="hero-cube" class="w-6 h-6" />
-

- {gettext("No Entities Yet")} -

-

- {gettext( - "Get started by creating your first custom content type. Think brands, products, team members, or any structured content you need." - )} -

- <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/entities/new")} - class="btn btn-primary btn-lg" - > - <.icon name="hero-plus" class="w-5 h-5 mr-2" /> {gettext("Create Your First Entity")} - -
-
- <% else %> - <%!-- Table View: hidden on small screens, shown on md+ when table mode selected --%> - <%= if @view_mode == "table" do %> - - <% end %> - - <%!-- Card View: always shown on small screens, shown on md+ when card mode selected --%> -
-
- <%= for entity <- @entities do %> -
-
-
- <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/entities/#{entity.name}/data")} - class="flex items-center hover:text-primary transition-colors cursor-pointer group" - > -
- <%= if entity.icon do %> - <.icon name={entity.icon} class="w-6 h-6" /> - <% else %> - <.icon name="hero-cube" class="w-6 h-6" /> - <% end %> -
-
-

- {entity.display_name_plural || entity.display_name} -

-

- <.icon name="hero-link" class="w-3 h-3 inline" /> - {entity.name} -

-
- - - <%!-- Status Badge --%> - <.content_status_badge status={entity.status} /> -
- - <%= if entity.description do %> -

- {entity.description} -

- <% end %> - - <%!-- Field Count --%> -
-
- <.icon name="hero-list-bullet" class="w-4 h-4 mr-1" /> - - {length(entity.fields_definition || [])} - {if length(entity.fields_definition || []) == 1, - do: gettext("field"), - else: gettext("fields")} - -
- - <%= if entity.creator do %> - - {gettext("by")} {entity.creator.email} - - <% end %> -
- - <%!-- Actions --%> -
- <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/entities/#{entity.name}/data")} - class="btn btn-outline btn-sm" - > - <.icon name="hero-arrow-right" class="w-4 h-4 mr-1" /> {gettext("Go to Data")} - - <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/entities/#{entity.uuid}/edit")} - class="btn btn-primary btn-sm" - > - <.icon name="hero-pencil" class="w-4 h-4 mr-1" /> {gettext("Edit")} - - - <%!-- Archive/Restore Button --%> - <%= if entity.status == "archived" do %> - - <% else %> - - <% end %> -
- - <%!-- Created Date --%> -
- {gettext("Created")} {PhoenixKit.Utils.Date.format_date_with_user_format( - entity.date_created - )} -
-
-
- <% end %> -
-
- <% end %> -
-
diff --git a/lib/modules/entities/web/entities_settings.ex b/lib/modules/entities/web/entities_settings.ex deleted file mode 100644 index 360bb6f9e..000000000 --- a/lib/modules/entities/web/entities_settings.ex +++ /dev/null @@ -1,737 +0,0 @@ -defmodule PhoenixKit.Modules.Entities.Web.EntitiesSettings do - @moduledoc """ - LiveView for managing entities system settings and configuration. - Provides interface for enabling/disabling entities module and viewing statistics. - """ - - use PhoenixKitWeb, :live_view - on_mount PhoenixKit.Modules.Entities.Web.Hooks - - alias PhoenixKit.Modules.Entities - alias PhoenixKit.Modules.Entities.EntityData - alias PhoenixKit.Modules.Entities.Events - alias PhoenixKit.Modules.Entities.Mirror.{Exporter, Importer, Storage} - alias PhoenixKit.Settings - - def mount(_params, _session, socket) do - project_title = Settings.get_project_title() - - # Load current entities settings - settings = %{ - entities_enabled: Entities.enabled?(), - auto_generate_slugs: Settings.get_setting("entities_auto_generate_slugs", "true"), - default_status: Settings.get_setting("entities_default_status", "draft"), - require_approval: Settings.get_setting("entities_require_approval", "false"), - max_entities_per_user: Settings.get_setting("entities_max_per_user", "100"), - data_retention_days: Settings.get_setting("entities_data_retention_days", "365"), - enable_revisions: Settings.get_setting("entities_enable_revisions", "false"), - enable_comments: Settings.get_setting("entities_enable_comments", "false") - } - - changeset = build_changeset(settings) - - socket = - socket - |> assign(:page_title, gettext("Entities Settings")) - |> assign(:project_title, project_title) - |> assign(:settings, settings) - |> assign(:changeset, changeset) - |> assign(:entities_stats, get_entities_stats()) - # Per-entity mirror settings - |> assign(:entities_list, Entities.list_entities_with_mirror_status()) - |> assign(:mirror_path, Storage.root_path()) - |> assign(:export_stats, Storage.get_stats()) - |> assign(:import_preview, nil) - |> assign(:import_selections, %{}) - |> assign(:import_active_tab, nil) - |> assign(:show_import_modal, false) - |> assign(:importing, false) - |> assign(:exporting, false) - - if connected?(socket) do - Events.subscribe_to_all_data() - end - - {:ok, socket} - end - - def handle_event("validate", %{"settings" => settings_params}, socket) do - changeset = build_changeset(settings_params, :validate) - {:noreply, assign(socket, :changeset, changeset)} - end - - def handle_event("save", %{"settings" => settings_params}, socket) do - changeset = build_changeset(settings_params, :save) - - if changeset.valid? do - try do - case save_settings(settings_params) do - :ok -> - # Refresh settings and stats - new_settings = %{ - entities_enabled: Entities.enabled?(), - auto_generate_slugs: Settings.get_setting("entities_auto_generate_slugs", "true"), - default_status: Settings.get_setting("entities_default_status", "draft"), - require_approval: Settings.get_setting("entities_require_approval", "false"), - max_entities_per_user: Settings.get_setting("entities_max_per_user", "100"), - data_retention_days: Settings.get_setting("entities_data_retention_days", "365"), - enable_revisions: Settings.get_setting("entities_enable_revisions", "false"), - enable_comments: Settings.get_setting("entities_enable_comments", "false") - } - - socket = - socket - |> assign(:settings, new_settings) - |> assign(:changeset, build_changeset(new_settings)) - |> assign(:entities_stats, get_entities_stats()) - |> put_flash(:info, gettext("Entities settings saved successfully")) - - {:noreply, socket} - - {:error, reason} -> - socket = - put_flash( - socket, - :error, - gettext("Failed to save settings: %{reason}", reason: reason) - ) - - {:noreply, socket} - end - rescue - e -> - require Logger - Logger.error("Entities settings save failed: #{Exception.message(e)}") - - {:noreply, - put_flash(socket, :error, gettext("Something went wrong. Please try again."))} - end - else - {:noreply, assign(socket, :changeset, changeset)} - end - end - - def handle_event("enable_entities", _params, socket) do - case Entities.enable_system() do - {:ok, _setting} -> - settings = Map.put(socket.assigns.settings, :entities_enabled, true) - - socket = - socket - |> assign(:settings, settings) - |> assign(:changeset, build_changeset(settings)) - |> assign(:entities_stats, get_entities_stats()) - |> put_flash(:info, gettext("Entities system enabled successfully")) - - {:noreply, socket} - - {:error, reason} -> - socket = - put_flash( - socket, - :error, - gettext("Failed to enable entities: %{reason}", reason: reason) - ) - - {:noreply, socket} - end - end - - def handle_event("disable_entities", _params, socket) do - case Entities.disable_system() do - {:ok, _setting} -> - settings = Map.put(socket.assigns.settings, :entities_enabled, false) - - socket = - socket - |> assign(:settings, settings) - |> assign(:changeset, build_changeset(settings)) - |> assign(:entities_stats, get_entities_stats()) - |> put_flash(:info, gettext("Entities system disabled successfully")) - - {:noreply, socket} - - {:error, reason} -> - socket = - put_flash( - socket, - :error, - gettext("Failed to disable entities: %{reason}", reason: reason) - ) - - {:noreply, socket} - end - end - - def handle_event("reset_to_defaults", _params, socket) do - default_settings = %{ - entities_enabled: true, - auto_generate_slugs: "true", - default_status: "draft", - require_approval: "false", - max_entities_per_user: "unlimited", - data_retention_days: "365", - enable_revisions: "false", - enable_comments: "false" - } - - changeset = build_changeset(default_settings) - - socket = - socket - |> assign(:settings, default_settings) - |> assign(:changeset, changeset) - |> put_flash(:info, gettext("Settings reset to defaults (not saved yet)")) - - {:noreply, socket} - end - - ## Per-Entity Mirror Events - - def handle_event("toggle_entity_definitions", %{"uuid" => entity_uuid}, socket) do - with {:ok, entity} <- fetch_entity(entity_uuid), - {:ok, updated_entity} <- toggle_definitions_setting(entity) do - maybe_export_entity(updated_entity, Entities.mirror_definitions_enabled?(updated_entity)) - {:noreply, refresh_entities_list(socket)} - else - {:error, :not_found} -> - {:noreply, put_flash(socket, :error, gettext("Entity not found"))} - - {:error, _changeset} -> - {:noreply, put_flash(socket, :error, gettext("Failed to update mirror settings"))} - end - end - - def handle_event("toggle_entity_data", %{"uuid" => entity_uuid}, socket) do - with {:ok, entity} <- fetch_entity(entity_uuid), - {:ok, updated_entity} <- toggle_data_setting(entity) do - maybe_export_entity(updated_entity, Entities.mirror_data_enabled?(updated_entity)) - {:noreply, refresh_entities_list(socket)} - else - {:error, :not_found} -> - {:noreply, put_flash(socket, :error, gettext("Entity not found"))} - - {:error, _changeset} -> - {:noreply, put_flash(socket, :error, gettext("Failed to update mirror settings"))} - end - end - - def handle_event("export_entity_now", %{"uuid" => entity_uuid}, socket) do - case Entities.get_entity(entity_uuid) do - nil -> - {:noreply, put_flash(socket, :error, gettext("Entity not found"))} - - entity -> - message = - case Exporter.export_entity(entity) do - {:ok, _path, :with_data} -> - gettext("Exported %{name} (definition + records)", name: entity.display_name) - - {:ok, _path, :definition_only} -> - gettext("Exported %{name} (definition only)", name: entity.display_name) - - {:error, _reason} -> - nil - end - - socket = - socket - |> assign(:export_stats, Storage.get_stats()) - |> then(fn s -> - if message, - do: put_flash(s, :info, message), - else: put_flash(s, :error, gettext("Export failed")) - end) - - {:noreply, socket} - end - end - - ## Bulk Mirror Actions - - def handle_event("enable_all_definitions", _params, socket) do - {:ok, count} = Entities.enable_all_definitions_mirror() - - # Export all entities - socket = assign(socket, :exporting, true) - send(self(), :do_full_export) - - socket = - socket - |> assign(:entities_list, Entities.list_entities_with_mirror_status()) - |> put_flash(:info, gettext("Enabled definition sync for %{count} entities", count: count)) - - {:noreply, socket} - end - - def handle_event("disable_all_definitions", _params, socket) do - # Disabling definitions also disables data - {:ok, _} = Entities.disable_all_data_mirror() - {:ok, count} = Entities.disable_all_definitions_mirror() - - socket = - socket - |> assign(:entities_list, Entities.list_entities_with_mirror_status()) - |> put_flash(:info, gettext("Disabled definition sync for %{count} entities", count: count)) - - {:noreply, socket} - end - - def handle_event("enable_all_data", _params, socket) do - # Enabling data also requires definitions to be enabled - {:ok, _} = Entities.enable_all_definitions_mirror() - {:ok, count} = Entities.enable_all_data_mirror() - - # Export all entities with data - socket = assign(socket, :exporting, true) - send(self(), :do_full_export) - - socket = - socket - |> assign(:entities_list, Entities.list_entities_with_mirror_status()) - |> put_flash(:info, gettext("Enabled data sync for %{count} entities", count: count)) - - {:noreply, socket} - end - - def handle_event("disable_all_data", _params, socket) do - {:ok, count} = Entities.disable_all_data_mirror() - - socket = - socket - |> assign(:entities_list, Entities.list_entities_with_mirror_status()) - |> put_flash(:info, gettext("Disabled data sync for %{count} entities", count: count)) - - {:noreply, socket} - end - - def handle_event("export_now", _params, socket) do - socket = assign(socket, :exporting, true) - send(self(), :do_full_export) - {:noreply, socket} - end - - def handle_event("show_import_modal", _params, socket) do - preview = Importer.preview_import() - - # Initialize selections based on preview - default to appropriate action - selections = build_default_selections(preview) - first_entity = List.first(preview.entities) - - socket = - socket - |> assign(:import_preview, preview) - |> assign(:import_selections, selections) - |> assign(:import_active_tab, first_entity && first_entity.name) - |> assign(:show_import_modal, true) - - {:noreply, socket} - end - - def handle_event("hide_import_modal", _params, socket) do - socket = - socket - |> assign(:show_import_modal, false) - |> assign(:import_preview, nil) - |> assign(:import_selections, %{}) - |> assign(:import_active_tab, nil) - - {:noreply, socket} - end - - def handle_event("set_import_tab", %{"entity" => entity_name}, socket) do - {:noreply, assign(socket, :import_active_tab, entity_name)} - end - - def handle_event( - "set_definition_action", - %{"entity" => entity_name, "action" => action}, - socket - ) do - action_atom = String.to_existing_atom(action) - selections = put_in(socket.assigns.import_selections, [entity_name, :definition], action_atom) - {:noreply, assign(socket, :import_selections, selections)} - end - - def handle_event( - "set_record_action", - %{"entity" => entity_name, "slug" => slug, "action" => action}, - socket - ) do - action_atom = String.to_existing_atom(action) - selections = put_in(socket.assigns.import_selections, [entity_name, :data, slug], action_atom) - {:noreply, assign(socket, :import_selections, selections)} - end - - def handle_event( - "set_all_records_action", - %{"entity" => entity_name, "action" => action}, - socket - ) do - action_atom = String.to_existing_atom(action) - - # Find the entity in preview to get all slugs - entity = Enum.find(socket.assigns.import_preview.entities, &(&1.name == entity_name)) - - if entity do - new_data_selections = - entity.data - |> Enum.map(fn record -> {record.slug, action_atom} end) - |> Map.new() - - selections = - put_in(socket.assigns.import_selections, [entity_name, :data], new_data_selections) - - {:noreply, assign(socket, :import_selections, selections)} - else - {:noreply, socket} - end - end - - def handle_event("do_import_entity", %{"entity" => entity_name}, socket) do - # Only import selections for the specified entity - entity_selections = Map.get(socket.assigns.import_selections, entity_name, %{}) - filtered_selections = %{entity_name => entity_selections} - - socket = - socket - |> assign(:importing, true) - |> assign(:show_import_modal, false) - - send(self(), {:do_import, filtered_selections}) - {:noreply, socket} - end - - def handle_event("do_import", _params, socket) do - socket = - socket - |> assign(:importing, true) - |> assign(:show_import_modal, false) - - send(self(), {:do_import, socket.assigns.import_selections}) - {:noreply, socket} - end - - def handle_event("refresh_export_stats", _params, socket) do - socket = - socket - |> assign(:export_stats, Storage.get_stats()) - - {:noreply, socket} - end - - ## Per-entity mirror helpers - - defp fetch_entity(entity_uuid) do - case Entities.get_entity(entity_uuid) do - nil -> {:error, :not_found} - entity -> {:ok, entity} - end - end - - defp toggle_definitions_setting(entity) do - new_value = !Entities.mirror_definitions_enabled?(entity) - - new_settings = - if new_value, - do: %{"mirror_definitions" => true}, - else: %{"mirror_definitions" => false, "mirror_data" => false} - - Entities.update_mirror_settings(entity, new_settings) - end - - defp toggle_data_setting(entity) do - new_value = !Entities.mirror_data_enabled?(entity) - Entities.update_mirror_settings(entity, %{"mirror_data" => new_value}) - end - - defp maybe_export_entity(entity, true), do: Task.start(fn -> Exporter.export_entity(entity) end) - defp maybe_export_entity(_entity, false), do: :ok - - defp refresh_entities_list(socket) do - socket - |> assign(:entities_list, Entities.list_entities_with_mirror_status()) - |> assign(:export_stats, Storage.get_stats()) - end - - ## Live updates - - def handle_info({event, _entity_uuid}, socket) - when event in [:entity_created, :entity_updated, :entity_deleted] do - socket = - socket - |> assign(:entities_stats, get_entities_stats()) - |> assign(:entities_list, Entities.list_entities_with_mirror_status()) - |> assign(:export_stats, Storage.get_stats()) - - {:noreply, socket} - end - - def handle_info({event, _entity_uuid, _data_uuid}, socket) - when event in [:data_created, :data_updated, :data_deleted] do - socket = - socket - |> assign(:entities_stats, get_entities_stats()) - |> assign(:entities_list, Entities.list_entities_with_mirror_status()) - |> assign(:export_stats, Storage.get_stats()) - - {:noreply, socket} - end - - ## Mirror background operations - - def handle_info(:do_full_export, socket) do - {:ok, %{definitions: def_count, data: data_count}} = Exporter.export_all() - - socket = - socket - |> assign(:exporting, false) - |> assign(:export_stats, Storage.get_stats()) - |> assign(:entities_list, Entities.list_entities_with_mirror_status()) - |> put_flash( - :info, - gettext("Export complete. %{defs} definitions, %{data} records.", - defs: def_count, - data: data_count - ) - ) - - {:noreply, socket} - end - - def handle_info({:do_import, selections}, socket) do - {:ok, %{definitions: def_results, data: data_results}} = Importer.import_selected(selections) - - def_created = Enum.count(def_results, &match?({:ok, :created, _}, &1)) - def_updated = Enum.count(def_results, &match?({:ok, :updated, _}, &1)) - def_skipped = Enum.count(def_results, &match?({:ok, :skipped, _}, &1)) - - data_created = Enum.count(data_results, &match?({:ok, :created, _}, &1)) - data_updated = Enum.count(data_results, &match?({:ok, :updated, _}, &1)) - data_skipped = Enum.count(data_results, &match?({:ok, :skipped, _}, &1)) - - socket = - socket - |> assign(:importing, false) - |> assign(:import_preview, nil) - |> assign(:import_selections, %{}) - |> assign(:export_stats, Storage.get_stats()) - |> assign(:entities_stats, get_entities_stats()) - |> put_flash( - :info, - gettext( - "Import complete. Definitions: %{dc} created, %{du} updated, %{ds} skipped. Data: %{rc} created, %{ru} updated, %{rs} skipped.", - dc: def_created, - du: def_updated, - ds: def_skipped, - rc: data_created, - ru: data_updated, - rs: data_skipped - ) - ) - - {:noreply, socket} - end - - # Private Functions - - defp build_changeset(settings, action \\ nil) do - types = %{ - entities_enabled: :boolean, - auto_generate_slugs: :string, - default_status: :string, - require_approval: :string, - max_entities_per_user: :string, - data_retention_days: :string, - enable_revisions: :string, - enable_comments: :string - } - - required = [:auto_generate_slugs, :default_status] - - changeset = - {settings, types} - |> Ecto.Changeset.cast(settings, Map.keys(types)) - |> Ecto.Changeset.validate_required(required) - |> Ecto.Changeset.validate_inclusion(:default_status, ["draft", "published", "archived"]) - |> Ecto.Changeset.validate_inclusion(:auto_generate_slugs, ["true", "false"]) - |> Ecto.Changeset.validate_inclusion(:require_approval, ["true", "false"]) - |> Ecto.Changeset.validate_inclusion(:enable_revisions, ["true", "false"]) - |> Ecto.Changeset.validate_inclusion(:enable_comments, ["true", "false"]) - |> validate_max_entities_per_user() - |> validate_data_retention_days() - - if action do - Map.put(changeset, :action, action) - else - changeset - end - end - - defp validate_max_entities_per_user(changeset) do - case Ecto.Changeset.get_field(changeset, :max_entities_per_user) do - "unlimited" -> - changeset - - value when is_binary(value) -> - case Integer.parse(value) do - {num, ""} when num > 0 -> - changeset - - _ -> - Ecto.Changeset.add_error( - changeset, - :max_entities_per_user, - gettext("must be 'unlimited' or a positive integer") - ) - end - - _ -> - Ecto.Changeset.add_error( - changeset, - :max_entities_per_user, - gettext("must be 'unlimited' or a positive integer") - ) - end - end - - defp validate_data_retention_days(changeset) do - case Ecto.Changeset.get_field(changeset, :data_retention_days) do - value when is_binary(value) -> - case Integer.parse(value) do - {num, ""} when num > 0 -> - changeset - - _ -> - Ecto.Changeset.add_error( - changeset, - :data_retention_days, - gettext("must be a positive integer") - ) - end - - _ -> - Ecto.Changeset.add_error( - changeset, - :data_retention_days, - gettext("must be a positive integer") - ) - end - end - - defp save_settings(settings_params) do - settings_to_save = [ - {"entities_auto_generate_slugs", Map.get(settings_params, "auto_generate_slugs", "true")}, - {"entities_default_status", Map.get(settings_params, "default_status", "draft")}, - {"entities_require_approval", Map.get(settings_params, "require_approval", "false")}, - {"entities_max_per_user", Map.get(settings_params, "max_entities_per_user", "100")}, - {"entities_data_retention_days", Map.get(settings_params, "data_retention_days", "365")}, - {"entities_enable_revisions", Map.get(settings_params, "enable_revisions", "false")}, - {"entities_enable_comments", Map.get(settings_params, "enable_comments", "false")} - ] - - try do - Enum.each(settings_to_save, fn {key, value} -> - Settings.update_setting(key, value) - end) - - :ok - rescue - e -> - {:error, Exception.message(e)} - end - end - - defp get_entities_stats do - if Entities.enabled?() do - entities_stats = Entities.get_system_stats() - data_stats = EntityData.get_data_stats() - - Map.merge(entities_stats, data_stats) - else - %{ - total_entities: 0, - active_entities: 0, - total_data_records: 0, - published_records: 0, - draft_records: 0, - archived_records: 0 - } - end - end - - # Helper functions for templates - - def setting_status_class(enabled) do - if enabled, do: "badge-success", else: "badge-error" - end - - def setting_status_text(enabled) do - if enabled, do: gettext("Enabled"), else: gettext("Disabled") - end - - def format_retention_period(days) do - case Integer.parse(days) do - {num, ""} when num >= 365 -> - years = div(num, 365) - remainder = rem(num, 365) - - if remainder == 0 do - ngettext("%{count} year", "%{count} years", years, count: years) - else - gettext("%{years} year(s), %{days} day(s)", years: years, days: remainder) - end - - {num, ""} when num >= 30 -> - months = div(num, 30) - remainder = rem(num, 30) - - if remainder == 0 do - ngettext("%{count} month", "%{count} months", months, count: months) - else - gettext("%{months} month(s), %{days} day(s)", months: months, days: remainder) - end - - {num, ""} -> - ngettext("%{count} day", "%{count} days", num, count: num) - - _ -> - days - end - end - - # Build default import selections based on preview - # - NEW items default to :overwrite (will create) - # - IDENTICAL items default to :skip (nothing to do) - # - CHANGED items default to :skip (safe default) - defp build_default_selections(%{entities: entities}) do - entities - |> Enum.map(fn entity -> - def_action = default_action_for(entity.definition.action) - - data_selections = - entity.data - |> Enum.map(fn record -> - {record.slug, default_action_for(record.action)} - end) - |> Map.new() - - {entity.name, %{definition: def_action, data: data_selections}} - end) - |> Map.new() - end - - defp default_action_for(:create), do: :overwrite - defp default_action_for(:identical), do: :skip - defp default_action_for(:conflict), do: :skip - defp default_action_for(_), do: :skip - - # Helper to get current action for a record from selections - def get_record_action(selections, entity_name, slug) do - get_in(selections, [entity_name, :data, slug]) || :skip - end - - def get_definition_action(selections, entity_name) do - get_in(selections, [entity_name, :definition]) || :skip - end -end diff --git a/lib/modules/entities/web/entities_settings.html.heex b/lib/modules/entities/web/entities_settings.html.heex deleted file mode 100644 index 22dfc6c16..000000000 --- a/lib/modules/entities/web/entities_settings.html.heex +++ /dev/null @@ -1,639 +0,0 @@ - -
- <%!-- Header Section --%> - <.admin_page_header - back={PhoenixKit.Utils.Routes.path("/admin/modules")} - title={gettext("Entities Settings")} - subtitle={gettext("Configure the entities system behavior and preferences")} - /> - - <%!-- System Status Card --%> -
-
-

- <.icon name="hero-cog-6-tooth" class="w-6 h-6" /> {gettext("System Status")} -

- -
- <%!-- System Toggle --%> -
-
-
-

{gettext("Entities System")}

-

- {gettext("Enable or disable the entire entities module")} -

-
- - {setting_status_text(@settings.entities_enabled)} - -
- -
- <%= if @settings.entities_enabled do %> - - <% else %> - - <% end %> -
-
- - <%!-- Quick Stats --%> -
-

{gettext("Quick Stats")}

-
-
-
{gettext("Entities")}
-
{@entities_stats.total_entities}
-
-
-
{gettext("Data Records")}
-
{@entities_stats.total_data_records}
-
-
-
{gettext("Published")}
-
{@entities_stats.published_records}
-
-
-
{gettext("Drafts")}
-
{@entities_stats.draft_records}
-
-
-
-
-
-
- - <%!-- Mirror & Export Card --%> -
-
-

- <.icon name="hero-arrow-path" class="w-6 h-6" /> {gettext("Mirror & Export")} -

-

- {gettext( - "Sync entity definitions and data to filesystem for version control and backup." - )} -

- - <%!-- Bulk Actions --%> -
- - - - - - - -
- - <%!-- Entities Table --%> - <%= if length(@entities_list) > 0 do %> -
- - - - - - - - - - - <%= for entity <- @entities_list do %> - - - - - - - <% end %> - -
{gettext("Entity")}{gettext("Records")}{gettext("Live Sync")}{gettext("Actions")}
-
- {entity.display_name} - ({entity.name}) - <%= if entity.file_exists do %> - - <.icon name="hero-document-check" class="w-3 h-3" /> - - <% end %> -
-
- {entity.data_count} - -
- <%!-- Definition toggle --%> -
- - {gettext("Definition")} - - <%= if entity.mirror_definitions do %> - - <% else %> - - <% end %> -
- - <%!-- Records toggle (label greyed out when definition sync is disabled) --%> -
- - {gettext("Records")} - - <%= if entity.mirror_definitions do %> - <%= if entity.mirror_data do %> - - <% else %> - - <% end %> - <% else %> - - <% end %> -
-
-
- -
-
- <% else %> -
- <.icon name="hero-inbox" class="w-12 h-12 mx-auto mb-2" /> -

{gettext("No entities defined yet")}

-
- <% end %> - - <%!-- Export Info Footer --%> -
- <%!-- Export Path --%> -
-

{gettext("Export Path")}

- {@mirror_path} -
- - <%!-- Export Stats --%> -
-

{gettext("Exported Files")}

-

- {gettext("%{defs} definitions, %{data} data records", - defs: @export_stats.definitions_count, - data: @export_stats.data_count - )} -

-
- - <%!-- Last Export --%> -
-

{gettext("Last Export")}

-

- <%= if @export_stats.last_export do %> - {@export_stats.last_export} - <% else %> - {gettext("Never")} - <% end %> -

-
-
-
-
- - <%!-- Import Modal --%> - <%= if @show_import_modal do %> - - <% end %> -
-
diff --git a/lib/modules/entities/web/entity_form.ex b/lib/modules/entities/web/entity_form.ex deleted file mode 100644 index 60b964240..000000000 --- a/lib/modules/entities/web/entity_form.ex +++ /dev/null @@ -1,1625 +0,0 @@ -defmodule PhoenixKit.Modules.Entities.Web.EntityForm do - @moduledoc """ - LiveView for creating and editing entity schemas. - Provides form interface for defining entity fields, types, and validation rules. - """ - - use PhoenixKitWeb, :live_view - on_mount PhoenixKit.Modules.Entities.Web.Hooks - - require Logger - - import PhoenixKitWeb.Components.MultilangForm - - alias PhoenixKit.Modules.Entities - alias PhoenixKit.Modules.Entities.Events - alias PhoenixKit.Modules.Entities.FieldTypes - alias PhoenixKit.Modules.Entities.Mirror.Exporter - alias PhoenixKit.Modules.Entities.Mirror.Storage - alias PhoenixKit.Modules.Entities.Presence - alias PhoenixKit.Modules.Entities.PresenceHelpers - alias PhoenixKit.Settings - alias PhoenixKit.Utils.HeroIcons - alias PhoenixKit.Utils.Routes - alias PhoenixKit.Utils.Slug - - @impl true - def mount(%{"id" => id} = _params, _session, socket) do - # Edit mode - entity = Entities.get_entity!(id) - changeset = Entities.change_entity(entity) - - mount_entity_form(socket, entity, changeset, gettext("Edit Entity")) - end - - def mount(_params, _session, socket) do - # Create mode - entity = %Entities{} - changeset = Entities.change_entity(entity) - - mount_entity_form(socket, entity, changeset, gettext("New Entity")) - end - - defp mount_entity_form(socket, entity, _changeset, page_title) do - project_title = Settings.get_project_title() - current_user = socket.assigns[:phoenix_kit_current_user] - - # Get current fields or initialize empty - current_fields = entity.fields_definition || [] - - # Initialize settings if nil - entity = Map.update!(entity, :settings, fn settings -> settings || %{} end) - - # Regenerate changeset with initialized entity - changeset = Entities.change_entity(entity) - - form_key = - case entity.uuid do - nil -> nil - uuid -> "entity-#{uuid}" - end - - live_source = ensure_live_source(socket) - - socket = - socket - |> assign(:page_title, page_title) - |> assign(:project_title, project_title) - |> assign(:entity, entity) - |> assign(:changeset, changeset) - |> assign(:current_user, current_user) - |> assign(:fields, current_fields) - |> assign(:field_types, FieldTypes.for_picker()) - |> assign(:show_field_form, false) - |> assign(:editing_field_index, nil) - |> assign(:field_form, new_field_form()) - |> assign(:field_error, nil) - |> assign(:field_key_manually_set, false) - |> assign(:show_icon_picker, false) - |> assign(:icon_search, "") - |> assign(:selected_category, "All") - |> assign(:icon_categories, ["All" | HeroIcons.list_categories()]) - |> assign(:available_icons, HeroIcons.list_all_icons()) - |> assign(:form_key, form_key) - |> assign(:live_source, live_source) - |> assign(:delete_confirm_index, nil) - |> assign(:has_unsaved_changes, false) - |> assign(:mirror_path, Storage.root_path()) - |> assign(:sort_mode, Entities.get_sort_mode(entity)) - |> mount_multilang() - - socket = - if connected?(socket) do - if form_key && entity.uuid do - # Track this user in Presence - {:ok, _ref} = - PresenceHelpers.track_editing_session(:entity, entity.uuid, socket, current_user) - - # Subscribe to presence changes and form events - PresenceHelpers.subscribe_to_editing(:entity, entity.uuid) - Events.subscribe_to_entity_form(form_key) - - # Determine our role (owner or spectator) - socket = assign_editing_role(socket, entity.uuid) - - # Load spectator state if we're not the owner - if socket.assigns.readonly? do - load_spectator_state(socket, entity.uuid) - else - socket - end - else - # New entity (no lock needed) or no form_key - socket - |> assign(:lock_owner?, true) - |> assign(:readonly?, false) - |> assign(:lock_owner_user, nil) - |> assign(:spectators, []) - end - else - # Not connected - no lock logic - socket - |> assign(:lock_owner?, true) - |> assign(:readonly?, false) - |> assign(:lock_owner_user, nil) - |> assign(:spectators, []) - end - - {:ok, socket} - end - - defp assign_editing_role(socket, entity_uuid) do - current_user = socket.assigns[:current_user] - - case PresenceHelpers.get_editing_role(:entity, entity_uuid, socket.id, current_user.uuid) do - {:owner, _presences} -> - # I'm the owner - I can edit (or same user in different tab) - socket - |> assign(:lock_owner?, true) - |> assign(:readonly?, false) - |> populate_presence_info(:entity, entity_uuid) - - {:spectator, _owner_meta, _presences} -> - # Different user is the owner - I'm read-only - socket - |> assign(:lock_owner?, false) - |> assign(:readonly?, true) - |> populate_presence_info(:entity, entity_uuid) - end - end - - defp load_spectator_state(socket, entity_uuid) do - # Owner might have unsaved changes - sync from their Presence metadata - case PresenceHelpers.get_lock_owner(:entity, entity_uuid) do - %{form_state: form_state} when not is_nil(form_state) -> - # Apply owner's form state - changeset_params = - Map.get(form_state, :changeset_params) || Map.get(form_state, "changeset_params") - - fields = Map.get(form_state, :fields) || Map.get(form_state, "fields") - - if changeset_params && fields do - changeset = Entities.change_entity(socket.assigns.entity, changeset_params) - - socket - |> assign(:changeset, changeset) - |> assign(:fields, fields) - |> assign(:has_unsaved_changes, true) - else - socket - end - - _ -> - # No form state to sync - socket - end - end - - def handle_event("switch_language", %{"lang" => lang_code}, socket) do - {:noreply, handle_switch_language(socket, lang_code)} - end - - @impl true - def handle_event("validate", %{"entities" => entity_params}, socket) do - if socket.assigns[:lock_owner?] do - # Get all current data from the changeset (both changes and original data) - current_data = Ecto.Changeset.apply_changes(socket.assigns.changeset) - - # Convert struct to map and merge with incoming params - existing_data = - current_data - |> Map.from_struct() - |> Map.drop([:__meta__, :creator, :entity_data, :id, :uuid, :date_created, :date_updated]) - |> Enum.into(%{}, fn {k, v} -> {to_string(k), v} end) - - # Merge existing data with new params (new params override existing) - entity_params = Map.merge(existing_data, entity_params) - - # Auto-generate slug from display_name during creation (but not editing) - entity_params = - if is_nil(socket.assigns.entity.uuid) do - # Only auto-generate if display_name changed and slug wasn't manually edited - display_name = entity_params["display_name"] || "" - current_slug = entity_params["name"] || "" - - # Check if the current slug was auto-generated from the previous display_name - previous_display_name = existing_data["display_name"] || "" - auto_generated_slug = generate_slug_from_name(previous_display_name) - - # If slug matches the auto-generated one or is empty, update it - if current_slug == "" || current_slug == auto_generated_slug do - Map.put(entity_params, "name", generate_slug_from_name(display_name)) - else - # User manually edited the slug, don't overwrite it - entity_params - end - else - # In edit mode, don't auto-generate - entity_params - end - - # Add fields_definition to params for validation - entity_params = Map.put(entity_params, "fields_definition", socket.assigns.fields) - - # Add current settings with merged translations and sort mode to params - settings = merge_translation_params(socket, entity_params) - - settings = - case entity_params["sort_mode"] do - mode when mode in ~w(auto manual) -> Map.put(settings, "sort_mode", mode) - _ -> settings - end - - entity_params = Map.put(entity_params, "settings", settings) - entity_params = Map.delete(entity_params, "translations") - - # Add created_by for new entities during validation so changeset can be valid - entity_params = - if socket.assigns.entity.uuid do - entity_params - else - entity_params - |> Map.put("created_by_uuid", socket.assigns.current_user.uuid) - end - - changeset = - socket.assigns.entity - |> Entities.change_entity(entity_params) - - # Keep entity in sync with updated settings - entity = %{socket.assigns.entity | settings: settings} - - socket = - socket - |> assign(:changeset, changeset) - |> assign(:entity, entity) - |> assign(:sort_mode, settings["sort_mode"] || "auto") - - reply_with_broadcast(socket) - else - # Spectator - ignore local changes, wait for broadcasts - {:noreply, socket} - end - end - - def handle_event("save", %{"entities" => entity_params}, socket) do - if socket.assigns[:lock_owner?] do - # Merge existing changeset data into params to preserve fields not on current tab - current_data = Ecto.Changeset.apply_changes(socket.assigns.changeset) - - existing_data = - current_data - |> Map.from_struct() - |> Map.drop([:__meta__, :creator, :entity_data, :id, :uuid, :date_created, :date_updated]) - |> Enum.into(%{}, fn {k, v} -> {to_string(k), v} end) - - entity_params = Map.merge(existing_data, entity_params) - - # Add current fields to entity params - entity_params = Map.put(entity_params, "fields_definition", socket.assigns.fields) - - # Add current settings with merged translations and sort mode - settings = merge_translation_params(socket, entity_params) - - settings = - case entity_params["sort_mode"] do - mode when mode in ~w(auto manual) -> Map.put(settings, "sort_mode", mode) - _ -> settings - end - - entity_params = Map.put(entity_params, "settings", settings) - entity_params = Map.delete(entity_params, "translations") - - # Add created_by for new entities - entity_params = - if socket.assigns.entity.uuid do - entity_params - else - entity_params - |> Map.put("created_by_uuid", socket.assigns.current_user.uuid) - end - - try do - case save_entity(socket, entity_params) do - {:ok, saved_entity} -> - if socket.assigns.entity.uuid do - # Update — stay on page, refresh changeset from saved entity - changeset = Entities.change_entity(saved_entity) - - socket = - socket - |> assign(:entity, saved_entity) - |> assign(:changeset, changeset) - |> assign(:fields, saved_entity.fields_definition || []) - |> assign(:sort_mode, Entities.get_sort_mode(saved_entity)) - |> put_flash(:info, gettext("Entity saved successfully")) - - reply_with_broadcast(socket) - else - # Create — navigate to the edit page for the new entity - locale = socket.assigns[:current_locale] || "en" - - socket = - socket - |> put_flash(:info, gettext("Entity created successfully")) - |> push_navigate( - to: Routes.path("/admin/entities/#{saved_entity.uuid}/edit", locale: locale) - ) - - {:noreply, socket} - end - - {:error, %Ecto.Changeset{} = changeset} -> - socket = assign(socket, :changeset, changeset) - reply_with_broadcast(socket) - end - rescue - e -> - require Logger - Logger.error("Entity save failed: #{Exception.message(e)}") - - {:noreply, - put_flash(socket, :error, gettext("Something went wrong. Please try again."))} - end - else - {:noreply, put_flash(socket, :error, gettext("Cannot save - you are spectating"))} - end - end - - def handle_event("reset", _params, socket) do - if socket.assigns[:lock_owner?] do - # Reload entity from database or reset to empty state - {entity, fields} = - if socket.assigns.entity.uuid do - # Reload from database - reloaded_entity = Entities.get_entity!(socket.assigns.entity.uuid) - {reloaded_entity, reloaded_entity.fields_definition || []} - else - # Reset to empty new entity - {%Entities{}, []} - end - - changeset = Entities.change_entity(entity) - - socket = - socket - |> assign(:entity, entity) - |> assign(:changeset, changeset) - |> assign(:fields, fields) - |> assign(:show_field_form, false) - |> assign(:editing_field_index, nil) - |> assign(:field_form, new_field_form()) - |> assign(:field_error, nil) - |> assign(:show_icon_picker, false) - |> assign(:delete_confirm_index, nil) - |> put_flash(:info, gettext("Changes reset to last saved state")) - - reply_with_broadcast(socket) - else - {:noreply, put_flash(socket, :error, gettext("Cannot reset - you are spectating"))} - end - end - - # Icon Picker Events - - def handle_event("open_icon_picker", _params, socket) do - socket = assign(socket, :show_icon_picker, true) - reply_with_broadcast(socket) - end - - def handle_event("close_icon_picker", _params, socket) do - socket = - assign(socket, - show_icon_picker: false, - icon_search: "", - selected_category: "All" - ) - - reply_with_broadcast(socket) - end - - def handle_event("stop_propagation", _params, socket) do - # This event does nothing - it just prevents the click from propagating to the backdrop - {:noreply, socket} - end - - def handle_event("generate_entity_slug", _params, socket) do - if socket.assigns[:lock_owner?] do - changeset = socket.assigns.changeset - - # Get display_name from changeset - display_name = Ecto.Changeset.get_field(changeset, :display_name) || "" - - # Don't generate if display_name is empty - if display_name == "" do - {:noreply, socket} - else - # Generate slug from display_name (snake_case) - slug = generate_slug_from_name(display_name) - - # Update changeset with generated slug while preserving all other data - changeset = update_changeset_field(socket, %{"name" => slug}) - - socket = assign(socket, :changeset, changeset) - reply_with_broadcast(socket) - end - else - {:noreply, socket} - end - end - - def handle_event("select_icon", %{"icon" => icon_name}, socket) do - if socket.assigns[:lock_owner?] do - # Update the changeset with the selected icon while preserving all other data - changeset = update_changeset_field(socket, %{"icon" => icon_name}) - - socket = - socket - |> assign(:changeset, changeset) - |> assign(:show_icon_picker, false) - |> assign(:icon_search, "") - |> assign(:selected_category, "All") - - reply_with_broadcast(socket) - else - {:noreply, socket} - end - end - - def handle_event("clear_icon", _params, socket) do - if socket.assigns[:lock_owner?] do - # Clear the icon field while preserving all other data - changeset = update_changeset_field(socket, %{"icon" => nil}) - - socket = assign(socket, :changeset, changeset) - reply_with_broadcast(socket) - else - {:noreply, socket} - end - end - - def handle_event("search_icons", %{"search" => search_term}, socket) do - filtered_icons = - if String.trim(search_term) == "" do - if socket.assigns.selected_category == "All" do - HeroIcons.list_all_icons() - else - HeroIcons.list_icons_by_category()[socket.assigns.selected_category] || [] - end - else - HeroIcons.search_icons(search_term) - end - - socket = - socket - |> assign(:icon_search, search_term) - |> assign(:available_icons, filtered_icons) - - reply_with_broadcast(socket) - end - - def handle_event("filter_by_category", %{"category" => category}, socket) do - filtered_icons = - if category == "All" do - HeroIcons.list_all_icons() - else - HeroIcons.list_icons_by_category()[category] || [] - end - - socket = - socket - |> assign(:selected_category, category) - |> assign(:available_icons, filtered_icons) - |> assign(:icon_search, "") - - reply_with_broadcast(socket) - end - - # Field Management Events - - def handle_event("add_field", _params, socket) do - if socket.assigns[:lock_owner?] do - socket = - socket - |> assign(:show_field_form, true) - |> assign(:editing_field_index, nil) - |> assign(:field_form, new_field_form()) - |> assign(:field_key_manually_set, false) - |> assign(:field_error, nil) - |> assign(:delete_confirm_index, nil) - - reply_with_broadcast(socket) - else - {:noreply, put_flash(socket, :error, gettext("Cannot edit - you are spectating"))} - end - end - - def handle_event("edit_field", %{"index" => index}, socket) do - if socket.assigns[:lock_owner?] do - index = String.to_integer(index) - field = Enum.at(socket.assigns.fields, index) - - socket = - socket - |> assign(:show_field_form, true) - |> assign(:editing_field_index, index) - |> assign(:field_form, normalize_field_form(field) || %{}) - |> assign(:field_key_manually_set, true) - |> assign(:field_error, nil) - - reply_with_broadcast(socket) - else - {:noreply, put_flash(socket, :error, gettext("Cannot edit - you are spectating"))} - end - end - - def handle_event("cancel_field", _params, socket) do - socket = - socket - |> assign(:show_field_form, false) - |> assign(:editing_field_index, nil) - |> assign(:field_form, new_field_form()) - |> assign(:field_key_manually_set, false) - |> assign(:field_error, nil) - - reply_with_broadcast(socket) - end - - def handle_event("save_field", %{"field" => field_params}, socket) do - if socket.assigns[:lock_owner?] do - field_form = socket.assigns.field_form || %{} - merged_params = Map.merge(field_form, field_params) - sanitized_options = sanitize_field_options(merged_params) - merged_params = Map.put(merged_params, "options", sanitized_options) - - # Process file-specific fields - merged_params = process_file_upload_settings(merged_params) - - with :ok <- validate_field_requirements(merged_params, sanitized_options), - :ok <- - validate_unique_field_key( - merged_params, - socket.assigns.fields, - socket.assigns.editing_field_index - ), - {:ok, validated_field} <- FieldTypes.validate_field(merged_params) do - socket = save_validated_field(socket, validated_field) - reply_with_broadcast(socket) - else - {:error, error_message} -> - socket = assign(socket, :field_error, error_message) - reply_with_broadcast(socket) - end - else - {:noreply, put_flash(socket, :error, gettext("Cannot save field - you are spectating"))} - end - end - - def handle_event("confirm_delete_field", %{"index" => index}, socket) do - index = String.to_integer(index) - {:noreply, assign(socket, :delete_confirm_index, index)} - end - - def handle_event("cancel_delete_field", _params, socket) do - {:noreply, assign(socket, :delete_confirm_index, nil)} - end - - def handle_event("delete_field", %{"index" => index}, socket) do - if socket.assigns[:lock_owner?] do - index = String.to_integer(index) - fields = List.delete_at(socket.assigns.fields, index) - - socket = - socket - |> assign(:fields, fields) - |> assign(:delete_confirm_index, nil) - - reply_with_broadcast(socket) - else - {:noreply, put_flash(socket, :error, gettext("Cannot delete field - you are spectating"))} - end - end - - def handle_event("move_field_up", %{"index" => index}, socket) do - if socket.assigns[:lock_owner?] do - index = String.to_integer(index) - - if index > 0 do - fields = move_field(socket.assigns.fields, index, index - 1) - socket = assign(socket, :fields, fields) - reply_with_broadcast(socket) - else - {:noreply, socket} - end - else - {:noreply, socket} - end - end - - def handle_event("move_field_down", %{"index" => index}, socket) do - if socket.assigns[:lock_owner?] do - index = String.to_integer(index) - - if index < length(socket.assigns.fields) - 1 do - fields = move_field(socket.assigns.fields, index, index + 1) - socket = assign(socket, :fields, fields) - reply_with_broadcast(socket) - else - {:noreply, socket} - end - else - {:noreply, socket} - end - end - - def handle_event("update_field_form", %{"field" => field_params} = params, socket) do - if socket.assigns[:lock_owner?] do - target = Map.get(params, "_target", []) - manual_key? = manual_key_target?(target) - - field_params = - if manual_key?, do: field_params, else: Map.delete(field_params, "key") - - # Update field form with live changes - current_form = normalize_field_form(socket.assigns.field_form) - - updated_form = - current_form - |> Map.merge(field_params) - |> maybe_auto_update_field_key(current_form, socket.assigns.field_key_manually_set, - editing?: socket.assigns.editing_field_index != nil - ) - - socket = - socket - |> assign(:field_form, updated_form) - |> assign(:field_key_manually_set, manual_key? || socket.assigns.field_key_manually_set) - # Clear error when user makes changes - |> assign(:field_error, nil) - - reply_with_broadcast(socket) - else - {:noreply, socket} - end - end - - def handle_event("add_option", _params, socket) do - if socket.assigns[:lock_owner?] do - current_options = Map.get(socket.assigns.field_form, "options", []) - updated_options = current_options ++ [""] - - field_form = Map.put(socket.assigns.field_form, "options", updated_options) - socket = assign(socket, :field_form, field_form) - - reply_with_broadcast(socket) - else - {:noreply, socket} - end - end - - def handle_event("remove_option", %{"index" => index}, socket) do - if socket.assigns[:lock_owner?] do - index = String.to_integer(index) - current_options = Map.get(socket.assigns.field_form, "options", []) - updated_options = List.delete_at(current_options, index) - - field_form = Map.put(socket.assigns.field_form, "options", updated_options) - socket = assign(socket, :field_form, field_form) - - reply_with_broadcast(socket) - else - {:noreply, socket} - end - end - - def handle_event("update_option", %{"index" => index} = params, socket) do - if socket.assigns[:lock_owner?] do - index = String.to_integer(index) - - # Extract value from phx-change format: %{"option" => %{"0" => "value"}} - value = - case params do - %{"option" => option_map} when is_map(option_map) -> - Map.get(option_map, to_string(index), "") - - %{"value" => v} -> - v - - _ -> - "" - end - - current_options = Map.get(socket.assigns.field_form, "options", []) - updated_options = List.replace_at(current_options, index, value) - - field_form = Map.put(socket.assigns.field_form, "options", updated_options) - socket = assign(socket, :field_form, field_form) - - reply_with_broadcast(socket) - else - {:noreply, socket} - end - end - - def handle_event("generate_field_key", _params, socket) do - if socket.assigns[:lock_owner?] do - # Get label from field form - label = Map.get(socket.assigns.field_form, "label", "") - - # Don't generate if label is empty - if label == "" do - {:noreply, socket} - else - # Generate key from label (snake_case) - key = generate_slug_from_name(label) - - # Update field form with generated key - field_form = Map.put(socket.assigns.field_form, "key", key) - socket = assign(socket, :field_form, field_form) - - reply_with_broadcast(socket) - end - else - {:noreply, socket} - end - end - - # Public Form Configuration Events - - def handle_event("toggle_public_form", _params, socket) do - if socket.assigns[:lock_owner?] do - current_settings = socket.assigns.entity.settings || %{} - current_enabled = Map.get(current_settings, "public_form_enabled", false) - - updated_settings = Map.put(current_settings, "public_form_enabled", !current_enabled) - - # Initialize default fields when enabling - updated_settings = - if current_enabled do - updated_settings - else - Map.put(updated_settings, "public_form_fields", []) - end - - # Update the entity with new settings - updated_entity = Map.put(socket.assigns.entity, :settings, updated_settings) - changeset = Entities.change_entity(updated_entity) - - socket = - socket - |> assign(:entity, updated_entity) - |> assign(:changeset, changeset) - - reply_with_broadcast(socket) - else - {:noreply, put_flash(socket, :error, gettext("Cannot edit - you are spectating"))} - end - end - - def handle_event("update_public_form_setting", params, socket) do - if socket.assigns[:lock_owner?] do - current_settings = socket.assigns.entity.settings || %{} - - # Extract the setting name and value from params - {setting_name, value} = - cond do - Map.has_key?(params, "public_form_title") -> - {"public_form_title", params["public_form_title"]} - - Map.has_key?(params, "public_form_description") -> - {"public_form_description", params["public_form_description"]} - - Map.has_key?(params, "public_form_submit_text") -> - {"public_form_submit_text", params["public_form_submit_text"]} - - Map.has_key?(params, "public_form_success_message") -> - {"public_form_success_message", params["public_form_success_message"]} - - true -> - {nil, nil} - end - - if setting_name do - updated_settings = Map.put(current_settings, setting_name, value) - updated_entity = Map.put(socket.assigns.entity, :settings, updated_settings) - changeset = Entities.change_entity(updated_entity) - - socket = - socket - |> assign(:entity, updated_entity) - |> assign(:changeset, changeset) - - reply_with_broadcast(socket) - else - {:noreply, socket} - end - else - {:noreply, socket} - end - end - - def handle_event("toggle_public_form_field", %{"field" => field_key}, socket) do - if socket.assigns[:lock_owner?] do - current_settings = socket.assigns.entity.settings || %{} - current_fields = Map.get(current_settings, "public_form_fields", []) - - # Toggle the field in the list - updated_fields = - if field_key in current_fields do - List.delete(current_fields, field_key) - else - current_fields ++ [field_key] - end - - updated_settings = Map.put(current_settings, "public_form_fields", updated_fields) - updated_entity = Map.put(socket.assigns.entity, :settings, updated_settings) - changeset = Entities.change_entity(updated_entity) - - socket = - socket - |> assign(:entity, updated_entity) - |> assign(:changeset, changeset) - - reply_with_broadcast(socket) - else - {:noreply, put_flash(socket, :error, gettext("Cannot edit - you are spectating"))} - end - end - - def handle_event("toggle_security_setting", %{"setting" => setting_key}, socket) do - if socket.assigns[:lock_owner?] do - current_settings = socket.assigns.entity.settings || %{} - - # For metadata, default is true (enabled), so we check != false - current_value = - if setting_key == "public_form_collect_metadata" do - Map.get(current_settings, setting_key) != false - else - Map.get(current_settings, setting_key, false) - end - - updated_settings = Map.put(current_settings, setting_key, !current_value) - updated_entity = Map.put(socket.assigns.entity, :settings, updated_settings) - changeset = Entities.change_entity(updated_entity) - - socket = - socket - |> assign(:entity, updated_entity) - |> assign(:changeset, changeset) - - reply_with_broadcast(socket) - else - {:noreply, put_flash(socket, :error, gettext("Cannot edit - you are spectating"))} - end - end - - def handle_event("update_security_action", params, socket) do - if socket.assigns[:lock_owner?] do - # Extract the setting key and value from params - # The select sends the setting name in phx-value-setting and value in the form field - setting_key = params["setting"] - # The value comes from the select with the same name as the setting - value = params[setting_key] - - current_settings = socket.assigns.entity.settings || %{} - updated_settings = Map.put(current_settings, setting_key, value) - updated_entity = Map.put(socket.assigns.entity, :settings, updated_settings) - changeset = Entities.change_entity(updated_entity) - - socket = - socket - |> assign(:entity, updated_entity) - |> assign(:changeset, changeset) - - reply_with_broadcast(socket) - else - {:noreply, put_flash(socket, :error, gettext("Cannot edit - you are spectating"))} - end - end - - def handle_event("reset_form_stats", _params, socket) do - if socket.assigns[:lock_owner?] do - current_settings = socket.assigns.entity.settings || %{} - updated_settings = Map.delete(current_settings, "public_form_stats") - updated_entity = Map.put(socket.assigns.entity, :settings, updated_settings) - changeset = Entities.change_entity(updated_entity) - - socket = - socket - |> assign(:entity, updated_entity) - |> assign(:changeset, changeset) - |> put_flash(:info, gettext("Form statistics have been reset")) - - reply_with_broadcast(socket) - else - {:noreply, put_flash(socket, :error, gettext("Cannot edit - you are spectating"))} - end - end - - # Backup Settings Events - - def handle_event("toggle_backup_definitions", _params, socket) do - if socket.assigns[:lock_owner?] do - entity = socket.assigns.entity - current_value = Entities.mirror_definitions_enabled?(entity) - new_value = !current_value - - # When disabling definitions, also disable data sync - new_settings = - if new_value do - %{"mirror_definitions" => true} - else - %{"mirror_definitions" => false, "mirror_data" => false} - end - - case Entities.update_mirror_settings(entity, new_settings) do - {:ok, updated_entity} -> - socket = - socket - |> assign(:entity, updated_entity) - |> assign(:changeset, Entities.change_entity(updated_entity)) - - {:noreply, socket} - - {:error, _changeset} -> - {:noreply, put_flash(socket, :error, gettext("Failed to update backup settings"))} - end - else - {:noreply, put_flash(socket, :error, gettext("Cannot edit - you are spectating"))} - end - end - - def handle_event("toggle_backup_data", _params, socket) do - if socket.assigns[:lock_owner?] do - entity = socket.assigns.entity - current_value = Entities.mirror_data_enabled?(entity) - new_value = !current_value - - case Entities.update_mirror_settings(entity, %{"mirror_data" => new_value}) do - {:ok, updated_entity} -> - socket = - socket - |> assign(:entity, updated_entity) - |> assign(:changeset, Entities.change_entity(updated_entity)) - - {:noreply, socket} - - {:error, _changeset} -> - {:noreply, put_flash(socket, :error, gettext("Failed to update backup settings"))} - end - else - {:noreply, put_flash(socket, :error, gettext("Cannot edit - you are spectating"))} - end - end - - def handle_event("export_entity_now", _params, socket) do - if socket.assigns[:lock_owner?] do - entity = socket.assigns.entity - - message = - case Exporter.export_entity(entity) do - {:ok, _path, :with_data} -> - gettext("Exported %{name} (definition + records)", name: entity.display_name) - - {:ok, _path, :definition_only} -> - gettext("Exported %{name} (definition only)", name: entity.display_name) - - {:error, _reason} -> - nil - end - - socket = - if message do - put_flash(socket, :info, message) - else - put_flash(socket, :error, gettext("Export failed")) - end - - {:noreply, socket} - else - {:noreply, put_flash(socket, :error, gettext("Cannot export - you are spectating"))} - end - end - - ## Live updates - - @impl true - def handle_info({:entity_form_change, form_key, payload, source}, socket) do - cond do - socket.assigns.form_key == nil -> - {:noreply, socket} - - form_key != socket.assigns.form_key -> - {:noreply, socket} - - source == socket.assigns.live_source -> - {:noreply, socket} - - true -> - try do - socket = apply_remote_entity_form_change(socket, payload) - {:noreply, socket} - rescue - e -> - Logger.error("Failed to apply remote entity form change: #{inspect(e)}") - {:noreply, socket} - end - end - end - - def handle_info({:entity_created, _}, socket), do: {:noreply, socket} - - def handle_info({:entity_updated, entity_uuid}, socket) do - if socket.assigns.entity.uuid == entity_uuid do - # Ignore our own saves — the save handler already refreshes state - if socket.assigns[:lock_owner?] do - {:noreply, socket} - else - entity = Entities.get_entity!(entity_uuid) - locale = socket.assigns[:current_locale] || "en" - - # If entity was archived or unpublished, redirect to entities list - if entity.status != "published" do - {:noreply, - socket - |> put_flash( - :warning, - gettext("Entity '%{name}' was %{status} in another session.", - name: entity.display_name, - status: entity.status - ) - ) - |> redirect(to: Routes.path("/admin/entities", locale: locale))} - else - socket = - socket - |> refresh_entity_state(entity) - |> put_flash(:info, gettext("Entity updated in another session.")) - - {:noreply, socket} - end - end - else - {:noreply, socket} - end - end - - def handle_info({:entity_deleted, entity_uuid}, socket) do - if socket.assigns.entity.uuid == entity_uuid do - locale = socket.assigns[:current_locale] || "en" - - socket = - socket - |> put_flash(:error, gettext("This entity was deleted in another session.")) - |> push_navigate(to: Routes.path("/admin/entities", locale: locale)) - - {:noreply, socket} - else - {:noreply, socket} - end - end - - def handle_info(%Phoenix.Socket.Broadcast{event: "presence_diff"}, socket) do - # Someone joined or left - check if our role changed - if socket.assigns.entity && socket.assigns.entity.uuid do - entity_uuid = socket.assigns.entity.uuid - was_owner = socket.assigns[:lock_owner?] - - # Re-evaluate our role - socket = assign_editing_role(socket, entity_uuid) - - # If we were promoted from spectator to owner, reload fresh data - if !was_owner && socket.assigns[:lock_owner?] do - entity = Entities.get_entity!(entity_uuid) - - socket - |> assign(:entity, entity) - |> assign(:changeset, Entities.change_entity(entity)) - |> assign(:fields, entity.fields_definition || []) - |> assign(:has_unsaved_changes, false) - |> then(&{:noreply, &1}) - else - # Just a presence update (someone joined/left as spectator) - {:noreply, socket} - end - else - {:noreply, socket} - end - end - - # Helper Functions - - defp reply_with_broadcast(socket) do - {:noreply, broadcast_entity_form_state(socket)} - end - - defp broadcast_entity_form_state(socket, extra \\ %{}) do - socket = - if connected?(socket) && socket.assigns[:form_key] && socket.assigns.entity.uuid && - socket.assigns[:lock_owner?] do - entity_uuid = socket.assigns.entity.uuid - topic = PresenceHelpers.editing_topic(:entity, entity_uuid) - - payload = - %{ - changeset_params: extract_entity_changeset_params(socket.assigns.changeset), - fields: socket.assigns.fields - } - |> Map.merge(extra) - - # Update Presence metadata with form state (for spectators to sync) - Presence.update(self(), topic, socket.id, fn meta -> - Map.put(meta, :form_state, payload) - end) - - # Also broadcast for real-time sync to spectators - Events.broadcast_entity_form_change(socket.assigns.form_key, payload, - source: socket.assigns.live_source - ) - - socket - else - socket - end - - # Mark that we have unsaved changes - assign(socket, :has_unsaved_changes, true) - end - - defp apply_remote_entity_form_change(socket, payload) do - changeset_params = - Map.get(payload, :changeset_params) || - Map.get(payload, "changeset_params") || - extract_entity_changeset_params(socket.assigns.changeset) - - fields = Map.get(payload, :fields) || Map.get(payload, "fields") || socket.assigns.fields - - entity_params = - changeset_params - |> Map.put("fields_definition", fields) - - changeset = - socket.assigns.entity - |> Entities.change_entity(entity_params) - |> Map.put(:action, :validate) - - socket - |> assign(:fields, fields) - |> assign(:changeset, changeset) - |> assign(:delete_confirm_index, nil) - |> assign(:has_unsaved_changes, true) - - # Note: UI-only state (show_icon_picker, icon_search, selected_category, - # show_field_form, editing_field_index, field_form, field_error, delete_confirm_index) - # is not synced from remote changes to keep modal and form state local to each user - end - - defp extract_entity_changeset_params(changeset) do - changeset - |> Ecto.Changeset.apply_changes() - |> Map.from_struct() - |> Map.drop([ - :__meta__, - :creator, - :entity_data, - :fields_definition, - :inserted_at, - :updated_at - ]) - |> Enum.into(%{}, fn {key, value} -> {to_string(key), value} end) - end - - defp refresh_entity_state(socket, entity) do - fields = entity.fields_definition || [] - - params = - socket.assigns.changeset - |> extract_entity_changeset_params() - |> Map.put("fields_definition", fields) - - changeset = - entity - |> Entities.change_entity(params) - |> Map.put(:action, :validate) - - socket - |> assign(:entity, entity) - |> assign(:fields, fields) - |> assign(:changeset, changeset) - |> maybe_update_available_icons() - end - - defp maybe_update_available_icons(socket) do - icons = - cond do - socket.assigns.icon_search && String.trim(socket.assigns.icon_search) != "" -> - HeroIcons.search_icons(socket.assigns.icon_search) - - socket.assigns.selected_category == "All" -> - HeroIcons.list_all_icons() - - true -> - HeroIcons.list_icons_by_category()[socket.assigns.selected_category] || [] - end - - assign(socket, :available_icons, icons) - end - - defp sanitize_field_options(params) do - params - |> Map.get("options", []) - |> Enum.reject(&(&1 in [nil, ""] || String.trim(to_string(&1)) == "")) - end - - defp validate_field_requirements(params, sanitized_options) do - field_type = params["type"] - - cond do - field_type in ["select", "radio", "checkbox"] and sanitized_options == [] -> - {:error, gettext("Field type '%{type}' requires at least one option", type: field_type)} - - field_type == "relation" and params["target_entity"] in [nil, ""] -> - {:error, gettext("Relation field requires a target entity")} - - true -> - :ok - end - end - - defp save_validated_field(socket, validated_field) do - fields = - case socket.assigns.editing_field_index do - nil -> socket.assigns.fields ++ [validated_field] - index -> List.replace_at(socket.assigns.fields, index, validated_field) - end - - socket - |> assign(:fields, fields) - |> assign(:show_field_form, false) - |> assign(:editing_field_index, nil) - |> assign(:field_form, new_field_form()) - |> assign(:field_error, nil) - end - - defp save_entity(socket, entity_params) do - if socket.assigns.entity.uuid do - # Reload entity from database to ensure Ecto detects all changes - # (socket.assigns.entity may have in-memory modifications that mask changes) - fresh_entity = Entities.get_entity!(socket.assigns.entity.uuid) - Entities.update_entity(fresh_entity, entity_params) - else - Entities.create_entity(entity_params) - end - end - - defp move_field(fields, from_index, to_index) do - field = Enum.at(fields, from_index) - - fields - |> List.delete_at(from_index) - |> List.insert_at(to_index, field) - end - - defp validate_unique_field_key(field_params, existing_fields, editing_index) do - new_key = field_params["key"] - - duplicate? = - existing_fields - |> Enum.with_index() - |> Enum.any?(fn {field, index} -> - field["key"] == new_key && index != editing_index - end) - - if duplicate? do - {:error, - gettext("Field key '%{key}' already exists. Please use a unique key.", key: new_key)} - else - :ok - end - end - - defp update_changeset_field(socket, new_params) do - # Get all current data from the changeset (both changes and original data) - current_data = Ecto.Changeset.apply_changes(socket.assigns.changeset) - - # Convert struct to map - existing_data = - current_data - |> Map.from_struct() - |> Map.drop([:__meta__, :creator, :entity_data, :id, :uuid, :date_created, :date_updated]) - |> Enum.into(%{}, fn {k, v} -> {to_string(k), v} end) - - # Merge existing data with new params (new params override existing) - entity_params = Map.merge(existing_data, new_params) - - # Add fields_definition - entity_params = Map.put(entity_params, "fields_definition", socket.assigns.fields) - - # Add created_by for new entities - entity_params = - if socket.assigns.entity.uuid do - entity_params - else - entity_params - |> Map.put("created_by_uuid", socket.assigns.current_user.uuid) - end - - socket.assigns.entity - |> Entities.change_entity(entity_params) - |> Map.put(:action, :validate) - end - - # Template Helper Functions - - def field_type_label("text"), do: gettext("Text") - def field_type_label("textarea"), do: gettext("Text Area") - def field_type_label("email"), do: gettext("Email") - def field_type_label("url"), do: gettext("URL") - def field_type_label("rich_text"), do: gettext("Rich Text Editor") - def field_type_label("number"), do: gettext("Number") - def field_type_label("boolean"), do: gettext("Boolean") - def field_type_label("date"), do: gettext("Date") - def field_type_label("select"), do: gettext("Select Dropdown") - def field_type_label("radio"), do: gettext("Radio Buttons") - def field_type_label("checkbox"), do: gettext("Checkboxes") - - def field_type_label(type_name) do - case FieldTypes.get_type(type_name) do - nil -> type_name - type_info -> type_info.label - end - end - - def field_category_label(:basic), do: gettext("Basic") - def field_category_label(:numeric), do: gettext("Numeric") - def field_category_label(:boolean), do: gettext("Boolean") - def field_category_label(:datetime), do: gettext("Date & Time") - def field_category_label(:choice), do: gettext("Choice") - def field_category_label(other), do: to_string(other) - - def field_type_icon(type_name) do - case FieldTypes.get_type(type_name) do - nil -> "hero-question-mark-circle" - type_info -> type_info.icon - end - end - - def requires_options?(type_name) do - FieldTypes.requires_options?(type_name) - end - - def icon_category_label("All"), do: gettext("All") - def icon_category_label("General"), do: gettext("General") - def icon_category_label("Content"), do: gettext("Content") - def icon_category_label("Actions"), do: gettext("Actions") - def icon_category_label("Navigation"), do: gettext("Navigation") - def icon_category_label("Communication"), do: gettext("Communication") - def icon_category_label("Users"), do: gettext("Users") - def icon_category_label("Business"), do: gettext("Business") - def icon_category_label("Interface"), do: gettext("Interface") - def icon_category_label("Tech"), do: gettext("Tech") - def icon_category_label("Status"), do: gettext("Status") - def icon_category_label(category), do: category - - def format_stats_datetime(iso_string) when is_binary(iso_string) do - case DateTime.from_iso8601(iso_string) do - {:ok, datetime, _offset} -> - PhoenixKit.Utils.Date.format_datetime_with_user_format(datetime) - - _ -> - iso_string - end - end - - def format_stats_datetime(_), do: "-" - - defp ensure_live_source(socket) do - socket.assigns[:live_source] || - (socket.id || - "entities-form-" <> Base.url_encode64(:crypto.strong_rand_bytes(6), padding: false)) - end - - defp generate_slug_from_name(name) when is_binary(name), - do: Slug.slugify(name, separator: "_") - - defp generate_slug_from_name(_), do: "" - - defp merge_translation_params(socket, entity_params) do - settings = socket.assigns.entity.settings || %{} - existing_translations = settings["translations"] || %{} - - # Extract translation params from form (e.g., %{"es-ES" => %{"display_name" => "Marcas"}}) - new_translations = entity_params["translations"] || %{} - - # Merge new translations into existing, stripping empty values - updated_translations = - Enum.reduce(new_translations, existing_translations, fn {lang_code, fields}, acc -> - cleaned = - fields - |> Enum.reject(fn {_k, v} -> is_nil(v) or v == "" end) - |> Map.new() - - if map_size(cleaned) == 0 do - Map.delete(acc, lang_code) - else - Map.put(acc, lang_code, cleaned) - end - end) - - if map_size(updated_translations) == 0 do - Map.delete(settings, "translations") - else - Map.put(settings, "translations", updated_translations) - end - end - - defp new_field_form do - %{ - "type" => "text", - "key" => "", - "label" => "", - "required" => false, - "default" => "", - "options" => [] - } - end - - defp normalize_field_form(nil), do: new_field_form() - - defp normalize_field_form(field) when is_map(field) do - Enum.reduce(field, %{}, fn {key, value}, acc -> - cond do - is_binary(key) -> Map.put(acc, key, value) - is_atom(key) -> Map.put(acc, Atom.to_string(key), value) - true -> acc - end - end) - end - - defp maybe_auto_update_field_key(updated_form, previous_form, manual?, opts) do - if manual? || Keyword.get(opts, :editing?, false) do - updated_form - else - auto_update_field_key(updated_form, previous_form) - end - end - - defp auto_update_field_key(updated_form, previous_form) do - label = fetch_form_value(updated_form, "label") || "" - current_key = fetch_form_value(updated_form, "key") || "" - previous_label = fetch_form_value(previous_form, "label") || "" - auto_generated_key = generate_slug_from_name(previous_label) - - if label != "" && (current_key == "" || current_key == auto_generated_key) do - Map.put(updated_form, "key", generate_slug_from_name(label)) - else - updated_form - end - end - - defp fetch_form_value(form, key) do - Map.get(form, key) || - case key do - "label" -> Map.get(form, :label) - "key" -> Map.get(form, :key) - "type" -> Map.get(form, :type) - _ -> nil - end - end - - # File upload settings processing - defp process_file_upload_settings(%{"type" => "file"} = params) do - params - |> process_max_entries() - |> process_max_file_size() - |> process_accept_list() - end - - defp process_file_upload_settings(params), do: params - - defp process_max_entries(params) do - max_entries = - case params["max_entries"] do - value when is_integer(value) -> value - value when is_binary(value) -> parse_int(value, 5) - _ -> 5 - end - - # Clamp to valid range (1-20) - max_entries = max(1, min(20, max_entries)) - Map.put(params, "max_entries", max_entries) - end - - defp process_max_file_size(params) do - max_file_size = - case params do - %{"max_file_size_mb" => mb_value} -> mb_to_bytes(mb_value, 15) - %{"max_file_size" => bytes} when is_integer(bytes) -> bytes - _ -> 15_728_640 - end - - # Clamp to valid range (1-100 MB) - max_file_size = max(1_048_576, min(104_857_600, max_file_size)) - - params - |> Map.put("max_file_size", max_file_size) - |> Map.delete("max_file_size_mb") - end - - defp process_accept_list(params) do - accept = - case params["accept"] do - list when is_list(list) -> list - string when is_binary(string) -> parse_accept_list(string) - _ -> [] - end - - Map.put(params, "accept", accept) - end - - defp parse_int(value, default) when is_binary(value) do - case Integer.parse(value) do - {int, _} -> int - _ -> default - end - end - - defp parse_int(_, default), do: default - - defp mb_to_bytes(mb_string, default_mb) when is_binary(mb_string) do - case Float.parse(mb_string) do - {mb, _} -> round(mb * 1_048_576) - _ -> default_mb * 1_048_576 - end - end - - defp mb_to_bytes(mb_value, _default_mb) when is_number(mb_value) do - round(mb_value * 1_048_576) - end - - defp mb_to_bytes(_, default_mb), do: default_mb * 1_048_576 - - defp parse_accept_list(accept_string) when is_binary(accept_string) do - accept_string - |> String.split(",") - |> Enum.map(&String.trim/1) - |> Enum.reject(&(&1 == "")) - |> Enum.map(fn ext -> - if String.starts_with?(ext, "."), do: ext, else: "." <> ext - end) - end - - defp parse_accept_list(_), do: [] - - # Helper functions for the view - def bytes_to_mb(bytes) when is_integer(bytes) do - Float.round(bytes / 1_048_576, 1) - end - - def bytes_to_mb(_), do: 15.0 - - def format_accept_list(accept) when is_list(accept) do - Enum.join(accept, ", ") - end - - def format_accept_list(_), do: "" - - defp manual_key_target?(["field", "key"]), do: true - defp manual_key_target?(_), do: false - - defp populate_presence_info(socket, type, id) do - # Get all presences sorted by joined_at (FIFO order) - presences = PresenceHelpers.get_sorted_presences(type, id) - - # Extract owner (first in list) and spectators (rest of list) - {lock_owner_user, lock_info, spectators} = - case presences do - [] -> - {nil, nil, []} - - [{owner_socket_id, owner_meta} | spectator_list] -> - # Build owner info - IMPORTANT: use socket_id from KEY not phx_ref - lock_info = %{ - socket_id: owner_socket_id, - user_uuid: owner_meta.user_uuid - } - - # Map spectators to expected format with correct socket IDs - spectators = - Enum.map(spectator_list, fn {spectator_socket_id, meta} -> - %{ - socket_id: spectator_socket_id, - user: meta.user, - user_uuid: meta.user_uuid - } - end) - - {owner_meta.user, lock_info, spectators} - end - - socket - |> assign(:lock_owner_user, lock_owner_user) - |> assign(:lock_info, lock_info) - |> assign(:spectators, spectators) - end -end diff --git a/lib/modules/entities/web/entity_form.html.heex b/lib/modules/entities/web/entity_form.html.heex deleted file mode 100644 index 172ccdeb8..000000000 --- a/lib/modules/entities/web/entity_form.html.heex +++ /dev/null @@ -1,1557 +0,0 @@ - -
- <%!-- Header Section --%> - <.admin_page_header back={PhoenixKit.Utils.Routes.path("/admin/entities")}> -

- {if @entity.uuid, do: gettext("Edit Entity"), else: gettext("Create New Entity")} -

-

- {gettext("Define your custom content type with dynamic fields")} -

- - - <%!-- Readonly Banner --%> - <%= if @readonly? do %> -
- <.icon name="hero-eye" class="w-5 h-5" /> - - {gettext( - "This entity is currently being edited by another user. You are in view-only mode." - )} - -
- <% end %> - - <.form - :let={f} - for={@changeset} - phx-change="validate" - phx-debounce="500" - phx-submit="save" - class="space-y-8" - > -
- -
- - <%!-- Entity Metadata Section --%> -
-
-

- <.icon name="hero-information-circle" class="w-6 h-6" /> {gettext( - "Entity Information" - )} -

- - <% lang_translations = - if @multilang_enabled && @current_lang != @primary_language do - translations = (@entity.settings || %{})["translations"] || %{} - translations[@current_lang] || %{} - else - %{} - end %> - - <%!-- Language tabs --%> - <%= if @show_multilang_tabs do %> -
- <.icon name="hero-information-circle" class="w-4 h-4" /> - - {gettext( - "Use the language tabs below to translate this entity's name, plural name, slug, and description. The primary language (marked with a star) is required. Other languages are optional — any empty fields will fall back to the primary language value." - )} - -
- <% end %> - - <.multilang_tabs - multilang_enabled={@multilang_enabled} - language_tabs={@language_tabs} - current_lang={@current_lang} - show_header={false} - show_info={false} - class="mb-4" - /> - - <.multilang_fields_wrapper - multilang_enabled={@multilang_enabled} - current_lang={@current_lang} - skeleton_class="space-y-6" - fields_class="space-y-6" - > - <:skeleton> -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- - -
- <%!-- Entity Name (Singular) --%> - <.translatable_field - field_name="display_name" - form_prefix="entities" - changeset={@changeset} - schema_field={:display_name} - multilang_enabled={@multilang_enabled} - current_lang={@current_lang} - primary_language={@primary_language} - lang_data={lang_translations} - secondary_name={"entities[translations][#{@current_lang}][display_name]"} - lang_data_key="display_name" - label={gettext("Entity Name (Singular)")} - placeholder={gettext("Brand")} - required - disabled={@readonly?} - class="w-full" - hint={gettext("Singular form (e.g., \"Brand\")")} - /> - - <%!-- Entity Name (Plural) --%> - <.translatable_field - field_name="display_name_plural" - form_prefix="entities" - changeset={@changeset} - schema_field={:display_name_plural} - multilang_enabled={@multilang_enabled} - current_lang={@current_lang} - primary_language={@primary_language} - lang_data={lang_translations} - secondary_name={"entities[translations][#{@current_lang}][display_name_plural]"} - lang_data_key="display_name_plural" - label={gettext("Entity Name (Plural)")} - placeholder={gettext("Brands")} - required - disabled={@readonly?} - class="w-full" - hint={gettext("Plural form (e.g., \"Brands\")")} - /> -
- - <%!-- Slug (translatable) --%> - <.translatable_field - field_name="name" - form_prefix="entities" - changeset={@changeset} - schema_field={:name} - multilang_enabled={@multilang_enabled} - current_lang={@current_lang} - primary_language={@primary_language} - lang_data={lang_translations} - secondary_name={"entities[translations][#{@current_lang}][name]"} - lang_data_key="name" - label={gettext("Slug")} - placeholder={gettext("brand")} - required - disabled={@readonly?} - class="w-full" - hint={gettext("snake_case identifier used in the system")} - > - <:label_extra> - <%= if !@multilang_enabled || @current_lang == @primary_language do %> - - <% end %> - - - - <%!-- Description (translatable) --%> - <.translatable_field - field_name="description" - form_prefix="entities" - changeset={@changeset} - schema_field={:description} - multilang_enabled={@multilang_enabled} - current_lang={@current_lang} - primary_language={@primary_language} - lang_data={lang_translations} - secondary_name={"entities[translations][#{@current_lang}][description]"} - lang_data_key="description" - label={gettext("Description (Optional)")} - placeholder={gettext("Describe what this entity represents...")} - type="textarea" - rows={3} - disabled={@readonly?} - class="w-full" - /> - -
-
- - <%!-- Entity System Settings (non-translatable) --%> -
-
-

- <.icon name="hero-cog-6-tooth" class="w-6 h-6" /> {gettext("System Settings")} -

- -
- <%!-- Icon --%> -
- <.label for="entity_icon">{gettext("Icon (Optional)")} -
-
- <.input - field={f[:icon]} - type="text" - placeholder={gettext("hero-document-text")} - phx-debounce="300" - disabled={@readonly?} - /> -
- - <%= if f[:icon].value && f[:icon].value != "" do %> - - <%= if String.starts_with?(f[:icon].value, "hero-") do %> -
- <.icon name={f[:icon].value} class="w-6 h-6" /> -
- <% end %> - <% end %> -
- <.label class="label"> - - {gettext("Heroicon name or click Browse")} - - -
- - <%!-- Status --%> -
- <.label for="entity_status">{gettext("Status")} * - - <.label class="label"> - - {gettext("Only published can be used")} - - -
- - <%!-- Sort Mode --%> -
- <.label for="entity_sort_mode">{gettext("Record Ordering")} - - <%!-- TODO: uncomment when table drag-and-drop is ready --%> - <%!-- <.label class="label"> - - {gettext("Manual mode enables drag-and-drop reordering of records")} - - --%> -
-
-
-
- - <%!-- Fields Section --%> -
-
-
-

- <.icon name="hero-list-bullet" class="w-6 h-6" /> - {ngettext( - "%{count} Field Definition", - "%{count} Field Definitions", - length(@fields), - count: length(@fields) - )} -

- -
- - <%= if Enum.empty?(@fields) do %> - <%!-- Empty Fields State --%> -
-
📝
-

- {gettext("No Fields Yet")} -

-

- {gettext("Add fields to define what data this entity can store")} -

- -
- <% else %> - <%!-- Fields List --%> -
- <%= for {field, index} <- Enum.with_index(@fields) do %> -
-
-
- <%!-- Move buttons (stacked vertically) at the beginning --%> -
- - -
- -
- <%!-- Field Icon & Info --%> -
- <.icon - name={field_type_icon(field["type"])} - class="w-5 h-5 text-primary" - /> -
-
{field["label"]}
-
- {field["key"]} · {field_type_label(field["type"])} - {if field["required"], do: " · #{gettext("Required")}"} -
-
-
- - <%!-- Field Actions --%> -
- <%= if @delete_confirm_index == index do %> - <%!-- Delete confirmation buttons --%> - - - <% else %> - <%!-- Edit button --%> - - - <%!-- Delete button --%> - - <% end %> -
-
-
- - <%!-- Field Options Preview --%> - <%= if requires_options?(field["type"]) && field["options"] do %> -
- {gettext("Options")}: - {Enum.join(field["options"], ", ")} -
- <% end %> -
-
- <% end %> -
- <% end %> -
-
- - <%!-- Public Form Configuration Section --%> -
-
-
-
-

- <.icon name="hero-globe-alt" class="w-6 h-6" /> - {gettext("Public Form Configuration")} -

-

- {gettext("Enable this entity to be used as an embeddable form on public pages")} -

-
-
- - <%!-- Enable Public Form Toggle --%> -
- -
- - <%= if get_in(@entity.settings, ["public_form_enabled"]) do %> -
- - <%!-- Form Configuration --%> -
- <%!-- Form Title --%> -
- <.label>{gettext("Form Title")} - -
- - <%!-- Form Description --%> -
- <.label>{gettext("Form Description (Optional)")} - -
- - <%!-- Submit Button Text --%> -
- <.label>{gettext("Submit Button Text")} - -
- - <%!-- Success Message --%> -
- <.label>{gettext("Success Message")} - -
- - <%!-- Field Selection --%> - <%= if not Enum.empty?(@fields) do %> -
- <.label>{gettext("Form Fields")} -

- {gettext( - "Select which fields to include in the public form. Fields not selected will only be visible in the admin data viewer." - )} -

- -
- <%= for field <- @fields do %> - - <% end %> -
-
- <% else %> -
- <.icon name="hero-exclamation-triangle" class="w-5 h-5" /> - - {gettext( - "Add fields to this entity first before configuring the public form." - )} - -
- <% end %> - - <%!-- Usage Example --%> - <%= if not Enum.empty?(get_in(@entity.settings, ["public_form_fields"]) || []) do %> -
- <.icon name="hero-information-circle" class="w-5 h-5" /> -
-

{gettext("Embed this form:")}

- - <EntityForm entity_slug="{@entity.name}" /> - -
-
- <% end %> - -
- - <%!-- Security Section --%> -
-

- <.icon name="hero-shield-check" class="w-5 h-5" /> - {gettext("Security")} -

- -
- <%!-- Collect Metadata Toggle --%> -
- -
- - <%!-- Debug Mode Toggle --%> -
- -
- - <%= if get_in(@entity.settings, ["public_form_debug_mode"]) do %> -
- <.icon name="hero-exclamation-triangle" class="w-5 h-5" /> - - {gettext( - "Debug mode is enabled. Detailed security errors will be shown to users. Disable this in production." - )} - -
- <% end %> - - <%!-- Honeypot Protection --%> -
-
- -
- - <%= if get_in(@entity.settings, ["public_form_honeypot"]) do %> -
- <.label class="text-sm">{gettext("When triggered:")} - -
- <% end %> -
- - <%!-- Time-based Validation --%> -
-
- -
- - <%= if get_in(@entity.settings, ["public_form_time_check"]) do %> -
- <.label class="text-sm">{gettext("When triggered:")} - -
- <% end %> -
- - <%!-- Rate Limiting --%> -
-
- -
- - <%= if get_in(@entity.settings, ["public_form_rate_limit"]) do %> -
- <.label class="text-sm">{gettext("When triggered:")} - -
- <% end %> -
-
-
- - <%!-- Form Statistics --%> - <% stats = get_in(@entity.settings, ["public_form_stats"]) || %{} %> -
- -
-

- <.icon name="hero-chart-bar" class="w-5 h-5" /> - {gettext("Form Statistics")} -

- -
-
-
- <.icon name="hero-document-text" class="w-8 h-8" /> -
-
{gettext("Total Submissions")}
-
- {stats["total_submissions"] || 0} -
-
- -
-
- <.icon name="hero-check-circle" class="w-8 h-8" /> -
-
{gettext("Successful")}
-
- {stats["successful_submissions"] || 0} -
-
- -
-
- <.icon name="hero-x-circle" class="w-8 h-8" /> -
-
{gettext("Rejected")}
-
- {stats["rejected_submissions"] || 0} -
-
-
- - <%!-- Security trigger breakdown --%> - <%= if stats["honeypot_triggers"] || stats["too_fast_triggers"] || stats["rate_limited_triggers"] do %> -
-

- {gettext("Security Triggers")} -

-
- <%= if stats["honeypot_triggers"] do %> -
- <.icon name="hero-bug-ant" class="w-3 h-3" /> - {gettext("Honeypot")}: {stats["honeypot_triggers"]} -
- <% end %> - <%= if stats["too_fast_triggers"] do %> -
- <.icon name="hero-bolt" class="w-3 h-3" /> - {gettext("Too Fast")}: {stats["too_fast_triggers"]} -
- <% end %> - <%= if stats["rate_limited_triggers"] do %> -
- <.icon name="hero-clock" class="w-3 h-3" /> - {gettext("Rate Limited")}: {stats["rate_limited_triggers"]} -
- <% end %> -
-
- <% end %> - - <%!-- Last submission time --%> - <%= if stats["last_submission_at"] do %> -
- {gettext("Last submission")}: {format_stats_datetime( - stats["last_submission_at"] - )} -
- <% end %> - - <%!-- Reset Stats Button (only show if there are stats) --%> - <%= if stats["total_submissions"] do %> -
- -
- <% end %> -
-
- <% end %> -
-
- - <%!-- Backup Settings Section --%> -
-
-
-
-

- <.icon name="hero-arrow-down-tray" class="w-6 h-6" /> - {gettext("Backup Settings")} -

-

- {gettext("Configure automatic backup to file for this entity")} -

-
- <%= if @entity.uuid do %> - - <% end %> -
- - <%= if @entity.uuid do %> -
- <%!-- Definition Sync Toggle --%> -
- -
- - <%!-- Records Sync Toggle --%> -
- -
-
- - <%!-- File Info --%> -
-
- <.icon name="hero-folder" class="w-4 h-4 text-base-content/70" /> - {gettext("Export path")}: - - {@mirror_path}/{@entity.name}.json - -
-
- <% else %> - <%!-- New entity - show message --%> -
- <.icon name="hero-information-circle" class="w-5 h-5" /> - - {gettext("Save the entity first to configure backup settings.")} - -
- <% end %> -
-
- - <%!-- Form Actions --%> -
-
- <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/entities")} - class="btn btn-outline" - > - {gettext("Cancel")} - - - -
- - -
- - - <%!-- Field Form Modal --%> - <%= if @show_field_form do %> - - <% end %> - - <%!-- Icon Picker Modal --%> - <%= if @show_icon_picker do %> -
-
- <%!-- Modal Header --%> -
-

- <.icon name="hero-squares-2x2" class="w-6 h-6" /> {gettext("Select an Icon")} -

- -
- - <%!-- Search Bar --%> -
- <.form for={%{}} phx-change="search_icons" phx-submit="search_icons"> -
- - -
- -
- - <%!-- Category Tabs --%> -
-
- <%= for category <- @icon_categories do %> - - <% end %> -
-
- - <%!-- Icon Grid --%> -
- <%= if Enum.empty?(@available_icons) do %> -
-
🔍
-

- {gettext("No icons found matching your search")} -

-
- <% else %> -
- <%= for {icon_name, display_name} <- @available_icons do %> - - <% end %> -
- <% end %> -
- - <%!-- Modal Footer --%> -
-
- - {ngettext( - "%{count} icon available", - "%{count} icons available", - length(@available_icons), - count: length(@available_icons) - )} - - -
-
-
-
- <% end %> -
-
diff --git a/lib/modules/entities/web/hooks.ex b/lib/modules/entities/web/hooks.ex deleted file mode 100644 index 96890fb37..000000000 --- a/lib/modules/entities/web/hooks.ex +++ /dev/null @@ -1,71 +0,0 @@ -defmodule PhoenixKit.Modules.Entities.Web.Hooks do - @moduledoc """ - LiveView hooks for entity module pages. - - Provides common setup and subscriptions for all entity-related LiveViews. - """ - - import Phoenix.LiveView - alias PhoenixKit.Admin.Presence - alias PhoenixKit.Modules.Entities.Events - alias PhoenixKit.Users.Auth.Scope - alias PhoenixKit.Utils.Date, as: UtilsDate - - @doc """ - Subscribes to entity events and tracks user presence when the LiveView is connected. - - Add this to your entity LiveView with: - - on_mount PhoenixKit.Modules.Entities.Web.Hooks - - This automatically: - - Subscribes to entity creation, update, and deletion events - - Tracks authenticated user presence for dashboard statistics - """ - def on_mount(:default, _params, session, socket) do - if connected?(socket) do - Events.subscribe_to_entities() - track_page_visit(socket, session) - end - - {:cont, socket} - end - - defp track_page_visit(socket, session) do - scope = socket.assigns[:phoenix_kit_current_scope] - - if scope && Scope.authenticated?(scope) do - user = %{ - uuid: Scope.user_uuid(scope), - email: Scope.user_email(scope) - } - - session_id = session["live_socket_id"] || generate_session_id() - - Presence.track_user(user, %{ - connected_at: UtilsDate.utc_now(), - session_id: session_id, - current_page: get_current_page(socket), - ip_address: extract_ip(socket), - user_agent: get_connect_info(socket, :user_agent) - }) - end - end - - defp get_current_page(socket) do - # Try to get from socket assigns, fallback to generic entities page - socket.assigns[:current_path] || "/admin/entities" - end - - defp extract_ip(socket) do - case get_connect_info(socket, :peer_data) do - %{address: {a, b, c, d}} -> "#{a}.#{b}.#{c}.#{d}" - %{address: address} -> to_string(address) - _ -> "unknown" - end - end - - defp generate_session_id do - :crypto.strong_rand_bytes(16) |> Base.encode64() - end -end diff --git a/lib/modules/languages/README.md b/lib/modules/languages/README.md index 5cacbd82f..808e1fc2c 100644 --- a/lib/modules/languages/README.md +++ b/lib/modules/languages/README.md @@ -178,7 +178,7 @@ The Entities module uses Languages for **multi-language content storage**. When ### How It Works -1. `PhoenixKit.Modules.Entities.Multilang.enabled?/0` checks if Languages has 2+ enabled languages +1. `PhoenixKit.Utils.Multilang.enabled?/0` checks if Languages has 2+ enabled languages 2. `Multilang.primary_language/0` reads `Languages.get_default_language()` 3. `Multilang.enabled_languages/0` reads `Languages.get_enabled_language_codes()` 4. Entity data JSONB is structured by language code (e.g., `"en-US"`, `"es-ES"`) @@ -192,7 +192,7 @@ PhoenixKit.Modules.Languages.add_language("es-ES") PhoenixKit.Modules.Languages.add_language("fr-FR") # 2. Multilang is now active — use the convenience API -alias PhoenixKit.Modules.Entities.EntityData +alias PhoenixKitEntities.EntityData record = EntityData.get(uuid) EntityData.set_translation(record, "es-ES", %{"name" => "Producto"}) diff --git a/lib/modules/legal/legal.ex b/lib/modules/legal/legal.ex index 20121c2a1..7a2525857 100644 --- a/lib/modules/legal/legal.ex +++ b/lib/modules/legal/legal.ex @@ -6,7 +6,7 @@ defmodule PhoenixKit.Modules.Legal do - Compliance framework selection (GDPR, CCPA, etc.) - Company information management - Legal page generation (Privacy Policy, Terms, Cookie Policy) - - Integration with Publishing module for page storage + - Integration with Publishing module for page storage (optional, via phoenix_kit_publishing) ## Phase 2: Cookie Consent Widget (prepared infrastructure) - Cookie consent banner @@ -14,7 +14,7 @@ defmodule PhoenixKit.Modules.Legal do - Google Consent Mode v2 integration ## Dependencies - - Publishing module must be enabled before Legal module + - Publishing module (phoenix_kit_publishing) must be installed and enabled ## Usage @@ -38,6 +38,7 @@ defmodule PhoenixKit.Modules.Legal do alias PhoenixKit.Modules.Legal.PageType alias PhoenixKit.Modules.Legal.TemplateGenerator alias PhoenixKit.Settings + alias PhoenixKit.Utils.Routes @enabled_key "legal_enabled" @module_name "legal" @@ -551,12 +552,25 @@ defmodule PhoenixKit.Modules.Legal do - google_consent_mode: boolean - hide_for_authenticated: boolean - frameworks: list of framework IDs - - cookie_policy_url: string - - privacy_policy_url: string + - cookie_policy_url: string (backward compat, derived from published pages) + - privacy_policy_url: string (backward compat, derived from published pages) + - legal_links: list of %{title: string, url: string} for all published legal pages """ @spec get_consent_widget_config() :: map() def get_consent_widget_config do - prefix = PhoenixKit.Config.get_url_prefix() + legal_links = get_published_legal_links() + + cookie_policy_url = + case Enum.find(legal_links, &String.ends_with?(&1.url, "/cookie-policy")) do + %{url: url} -> url + nil -> Routes.path("/legal/cookie-policy") + end + + privacy_policy_url = + case Enum.find(legal_links, &String.ends_with?(&1.url, "/privacy-policy")) do + %{url: url} -> url + nil -> Routes.path("/legal/privacy-policy") + end %{ enabled: consent_widget_enabled?(), @@ -567,11 +581,26 @@ defmodule PhoenixKit.Modules.Legal do policy_version: get_auto_policy_version(), google_consent_mode: google_consent_mode_enabled?(), frameworks: get_selected_frameworks(), - cookie_policy_url: "#{prefix}/legal/cookie-policy", - privacy_policy_url: "#{prefix}/legal/privacy-policy" + cookie_policy_url: cookie_policy_url, + privacy_policy_url: privacy_policy_url, + legal_links: legal_links, + legal_index_url: Routes.path("/legal") } end + @doc """ + Returns a list of all published legal pages as link maps. + + Each map has `:title` and `:url` keys. Used by the cookie consent widget + to render dynamic links to all published legal pages. + """ + @spec get_published_legal_links() :: list(%{title: String.t(), url: String.t()}) + def get_published_legal_links do + list_generated_pages() + |> Enum.filter(&(&1.status == "published")) + |> Enum.map(&%{title: &1.title, url: Routes.path("/legal/#{&1.slug}")}) + end + @doc """ Check if there are unpublished legal pages that are required. diff --git a/lib/modules/legal/web/settings.ex b/lib/modules/legal/web/settings.ex index dfb05227e..3c5f07cb2 100644 --- a/lib/modules/legal/web/settings.ex +++ b/lib/modules/legal/web/settings.ex @@ -5,7 +5,7 @@ defmodule PhoenixKitWeb.Live.Modules.Legal.Settings do Route: {prefix}/admin/settings/legal Sections: - 1. Module enable/disable (with Publishing dependency check) + 1. Module enable/disable (with optional Publishing dependency check) 2. Compliance framework selection 3. Company information form 4. DPO contact form @@ -16,7 +16,6 @@ defmodule PhoenixKitWeb.Live.Modules.Legal.Settings do use Gettext, backend: PhoenixKitWeb.Gettext alias PhoenixKit.Modules.Legal - alias PhoenixKit.Modules.Publishing alias PhoenixKit.Settings alias PhoenixKit.Utils.Routes @@ -284,21 +283,27 @@ defmodule PhoenixKitWeb.Live.Modules.Legal.Settings do defp update_google_consent_mode(_), do: Legal.disable_google_consent_mode() defp publishing_module_languages do - if Publishing.enabled?(), do: Publishing.enabled_language_codes(), else: ["en"] - rescue - e -> - require Logger - Logger.warning("Failed to get enabled languages: #{inspect(e)}") + mod = PhoenixKit.Modules.Publishing + + if Code.ensure_loaded?(mod) and function_exported?(mod, :enabled?, 0) and mod.enabled?() do + mod.enabled_language_codes() + else ["en"] + end + rescue + _ -> ["en"] end defp default_language do - if Publishing.enabled?(), do: Publishing.get_primary_language(), else: "en" - rescue - e -> - require Logger - Logger.warning("Failed to get primary language: #{inspect(e)}") + mod = PhoenixKit.Modules.Publishing + + if Code.ensure_loaded?(mod) and function_exported?(mod, :enabled?, 0) and mod.enabled?() do + mod.get_primary_language() + else "en" + end + rescue + _ -> "en" end # Helper to get edit URL for a legal page diff --git a/lib/modules/pages/page_builder/renderer.ex b/lib/modules/pages/page_builder/renderer.ex index 33337ba0a..c5cb5e44f 100644 --- a/lib/modules/pages/page_builder/renderer.ex +++ b/lib/modules/pages/page_builder/renderer.ex @@ -45,8 +45,10 @@ defmodule PhoenixKit.Modules.Pages.PageBuilder.Renderer do defp resolve_component(:image), do: {:ok, PhoenixKit.Modules.Shared.Components.Image} defp resolve_component(:video), do: {:ok, PhoenixKit.Modules.Shared.Components.Video} - defp resolve_component(:entityform), - do: {:ok, PhoenixKit.Modules.Shared.Components.EntityForm} + defp resolve_component(:entityform) do + mod = PhoenixKitEntities.Components.EntityForm + if Code.ensure_loaded?(mod), do: {:ok, mod}, else: {:error, :not_found} + end defp resolve_component(_), do: {:error, :not_found} diff --git a/lib/modules/pages/renderer.ex b/lib/modules/pages/renderer.ex index ebc8443f3..4afd22d01 100644 --- a/lib/modules/pages/renderer.ex +++ b/lib/modules/pages/renderer.ex @@ -10,7 +10,6 @@ defmodule PhoenixKit.Modules.Pages.Renderer do alias Phoenix.HTML.Safe alias PhoenixKit.Modules.Pages.PageBuilder - alias PhoenixKit.Modules.Shared.Components.EntityForm alias PhoenixKit.Modules.Shared.Components.Image alias PhoenixKit.Modules.Shared.Components.Video alias PhoenixKit.Settings @@ -343,7 +342,7 @@ defmodule PhoenixKit.Modules.Pages.Renderer do children: [] } - EntityForm.render(assigns) + PhoenixKitEntities.Components.EntityForm.render(assigns) |> Safe.to_iodata() |> IO.iodata_to_binary() rescue diff --git a/lib/modules/publishing/README.md b/lib/modules/publishing/README.md deleted file mode 100644 index ad970e5cb..000000000 --- a/lib/modules/publishing/README.md +++ /dev/null @@ -1,1507 +0,0 @@ -# Publishing Module - -The PhoenixKit Publishing module provides a database-backed content management system with multi-language support and dual URL modes (slug-based or timestamp-based). Posts are stored in PostgreSQL via four normalized tables (`publishing_groups`, `publishing_posts`, `publishing_versions`, `publishing_contents`) with UUIDv7 primary keys. - -## Quick Links - -- **Admin Interface**: `/{prefix}/admin/publishing` -- **Public Content**: `/{prefix}/{language}/{group-slug}` (listing) or `/{prefix}/{group-slug}` (single-language) -- **Settings**: Configure via `publishing_public_enabled` and `publishing_posts_per_page` in Settings -- **Enable Module**: Activate via Admin → Modules or run `PhoenixKit.Modules.Publishing.enable_system/0` -- **Cache Settings**: Toggle `publishing_memory_cache_enabled` and `publishing_render_cache_enabled[_]` -- **What it ships**: Listing cache, render cache, collaborative editor, public fallback routing, and optional per-post version history - -## Public Content Display - -The publishing module includes public-facing routes for displaying published posts to site visitors. - -### Public URLs - -**Multi-language mode:** -``` -/{prefix}/{language}/{group-slug} # Group post listing -/{prefix}/{language}/{group-slug}/{post-slug} # Slug mode post -/{prefix}/{language}/{group-slug}/{post-slug}/v/{version} # Versioned slug-mode post -/{prefix}/{language}/{group-slug}/{date} # Timestamp mode (date-only shortcut) -/{prefix}/{language}/{group-slug}/{date}/{time} # Timestamp mode post -``` - -**Single-language mode** (when only one language is enabled): -``` -/{prefix}/{group-slug} # Group post listing -/{prefix}/{group-slug}/{post-slug} # Slug mode post -/{prefix}/{group-slug}/{post-slug}/v/{version} # Versioned slug-mode post -/{prefix}/{group-slug}/{date} # Timestamp mode (date-only shortcut) -/{prefix}/{group-slug}/{date}/{time} # Timestamp mode post -``` - -**Examples** (assuming `{prefix}` is `/phoenix_kit`): -- `/phoenix_kit/en/docs` - Lists all published posts in the Docs group (English) -- `/phoenix_kit/en/docs/getting-started` - Shows specific post (slug mode) -- `/phoenix_kit/en/news/2025-11-02/14:30` - Shows specific post (timestamp mode) -- `/phoenix_kit/en/news/2025-11-02` - Date-only timestamp URL (auto-resolves to the first published time) -- `/phoenix_kit/docs` - Single-language mode listing -- `/phoenix_kit/news/2025-11-02` - Date-only timestamp URL (renders if single post exists, otherwise redirects to the first time slot on that date) - -### Features - -- **Status-Based Access Control** - Only `status: published` posts are visible -- **Markdown Rendering** - GitHub-style markdown CSS with syntax highlighting -- **Language Support** - Multi-language posts with language switcher -- **Content-Based Language Detection** - Custom language content records work without predefinition -- **Flexible Fallbacks** - Missing language versions redirect to available alternatives -- **Pagination** - Configurable posts per page (default: 20) -- **SEO Ready** - Clean URLs, breadcrumbs, responsive design -- **Performance** - Content-hash-based caching with versioned keys (`v1:publishing_post:...`) - -### Language Detection - -The publishing module uses a multi-step detection process to determine if a URL segment is a language code or a group slug: - -**Detection Flow (`detect_language_or_group`):** -1. **Enabled language** - If the segment matches an enabled language code (e.g., `en`, `fr-CA`), treat as language -2. **Base code mapping** - If it's a 2-letter code that maps to an enabled dialect (e.g., `en` → `en-US`), treat as language -3. **Known language pattern** - If it matches a predefined language code (even if disabled), treat as language -4. **Content-based check** - If content exists for this language in the requested group, treat as language -5. **Default** - Otherwise, treat as a group slug and use the default language - -**Supported Language Types:** -- **Predefined Languages** - Languages configured in the Languages module (e.g., `en`, `fr`, `es`) -- **Content-Based Languages** - Any content record with a language code is treated as a valid language - -This allows custom language content records (e.g., Afrikaans `af`) to work correctly even if not predefined in the Languages module. In the **admin interface**, the language switcher shows these with a strikethrough to indicate they're not officially enabled. In the **public display**, only enabled languages appear in the language switcher, but custom language URLs remain accessible via direct link. - -**Single-Language Mode:** -When only one language is enabled, URLs don't require the language segment: -- `/phoenix_kit/docs/getting-started` works the same as `/phoenix_kit/en/docs/getting-started` - -### Fallback Behavior - -Fallbacks are triggered when posts are missing (`:post_not_found`, `:unpublished`) **and** when a group -slug is invalid (`:group_not_found`). Server errors or other reasons still render the standard 404 page. - -**For slug-mode posts (`/{prefix}/en/docs/getting-started`):** -1. Try other languages for the same post (default language first) -2. If no published language versions exist, redirect to group listing - -**For timestamp-mode posts (`/{prefix}/en/news/2025-12-24/15:30`):** -1. Try other languages for the same date/time -2. Try other times on the same date -3. If no posts on that date, redirect to group listing - -**Fallback Priority:** -The system tries languages in this order: -1. Default language (from Settings) -2. Other available languages (alphabetically sorted) - -**User Experience:** -- Redirects include a flash message: "The page you requested was not found. Showing closest match." -- Bookmarked URLs continue to work even if specific translations are removed -- Users are never shown a 404 if any published version of the content exists -- Invalid group slugs fall back to the default group listing (if one exists) before showing a 404 - -### Configuration - -Enable/disable public content display and set pagination programmatically: - -```elixir -# Enable public content routes (default: true) -PhoenixKit.Settings.update_setting("publishing_public_enabled", "true") - -# Set posts per page in listings (default: 20) -PhoenixKit.Settings.update_setting("publishing_posts_per_page", "20") -``` - -`publishing_public_enabled` gates the entire `PhoenixKit.Modules.Publishing.Web.Controller` – set it to `"false"` to return a 404 for every public content route. `publishing_posts_per_page` drives listing pagination. - -**Note:** These settings are currently only configurable via code. There is no admin UI for these options yet; expose them in your app if customers need runtime control. - -### Templates - -Public templates are located in: - -- `lib/modules/publishing/web/templates/show.html.heex` - Single post view -- `lib/modules/publishing/web/templates/index.html.heex` - Group listing - -### Admin Integration - -When editing a post in the admin interface: - -- **View Public** button appears for published posts -- Button links directly to the public URL -- Automatically updates when status changes to "published" - -### Caching - -PhoenixKit ships two cache layers: - -1. **Listing cache** – `PhoenixKit.Modules.Publishing.ListingCache` stores parsed listing data - in `:persistent_term` for sub-microsecond reads. Memory caching can be toggled via the - `publishing_memory_cache_enabled` setting or from the Publishing Settings UI, which also - offers regenerate/clear actions per group. -2. **Render cache** – `PhoenixKit.Modules.Publishing.Renderer` stores rendered HTML for published posts in the - `:publishing_posts` cache (6-hour TTL) with content-hash keys, a global - `publishing_render_cache_enabled` toggle, and per-group overrides (`publishing_render_cache_enabled_`) - plus UI buttons to clear stats or individual group caches. - -Example render cache key: `v1:publishing_post:docs:getting-started:en:a1b2c3d4` - -Manual cache operations remain available when scripting: - -```elixir -alias PhoenixKit.Modules.Publishing.ListingCache - -ListingCache.regenerate("my-blog") -ListingCache.invalidate("my-blog") -ListingCache.read("my-blog") -ListingCache.exists?("my-blog") - -# Context helpers that wrap ListingCache -PhoenixKit.Modules.Publishing.regenerate_cache("my-blog") -PhoenixKit.Modules.Publishing.invalidate_cache("my-blog") - -alias PhoenixKit.Modules.Publishing.Renderer - -Renderer.clear_group_cache("my-blog") -Renderer.clear_all_cache() -``` - -## Architecture Overview - -**Core Modules:** - -- **PhoenixKit.Modules.Publishing** – Main context module with mode-aware routing (all writes are DB-only) -- **PhoenixKit.Modules.Publishing.DBStorage** – Database CRUD layer for groups, posts, versions, and contents -- **PhoenixKit.Modules.Publishing.DBStorage.Mapper** – Converts DB records to the post map format consumed by web layer -- **PhoenixKit.Modules.Publishing.Metadata** – Metadata parsing and serialization - -**Schemas (V59 migration):** - -- **PhoenixKit.Modules.Publishing.PublishingGroup** – Publishing group schema (name, slug, mode, data JSONB) -- **PhoenixKit.Modules.Publishing.PublishingPost** – Post schema (group FK, slug, status, mode, data JSONB) -- **PhoenixKit.Modules.Publishing.PublishingVersion** – Version schema (post FK, version_number, status, data JSONB) -- **PhoenixKit.Modules.Publishing.PublishingContent** – Content/translation schema (version FK, language, title, content, url_slug, data JSONB) - -**Admin Interfaces:** - -- **PhoenixKit.Modules.Publishing.Web.Index** – Publishing groups overview with skeleton loading on tab switch -- **PhoenixKit.Modules.Publishing.Web.Listing** – Post listing with inline status controls, skeleton loading, and trash management -- **PhoenixKit.Modules.Publishing.Web.Settings** – Admin interface for group configuration -- **PhoenixKit.Modules.Publishing.Web.Editor** – Markdown editor with autosave, featured images, skeleton loading on language switch, and clear translation button -- **PhoenixKit.Modules.Publishing.Web.Preview** – Live preview for posts - -**Public Display:** - -- **PhoenixKit.Modules.Publishing.Web.Controller** – Public-facing routes for group listings and posts -- **PhoenixKit.Modules.Publishing.Web.HTML** – HTML helpers and view functions for public content - -**Rendering & Caching:** - -- **PhoenixKit.Modules.Publishing.ListingCache** – Memory listing cache -- **PhoenixKit.Modules.Publishing.Renderer** – Markdown/PHK rendering with content-hash caching - -**Collaborative Editing:** - -- **PhoenixKit.Modules.Publishing.Presence** – Phoenix.Presence for real-time user tracking -- **PhoenixKit.Modules.Publishing.PresenceHelpers** – Owner/spectator logic helpers -- **PhoenixKit.Modules.Publishing.PubSub** – Real-time change broadcasting - -**Workers:** - -- **MigratePrimaryLanguageWorker** – Ensures primary language metadata consistency - -## Core Features - -- **Dual URL Modes** – Timestamp-based (date/time URLs) or slug-based (semantic URLs) -- **Mode Immutability** – URL mode locked at group creation, cannot be changed -- **Slug Mutability** – Post slugs can be changed after creation (DB update, no file movement) -- **Multi-Language Support** – Separate content records per language, all stored in `publishing_contents` table -- **Database Storage** – All reads and writes go to PostgreSQL -- **Markdown Content** – Full Markdown support with syntax highlighting -- **JSONB Metadata** – Flexible metadata via `data` JSONB columns on all four schemas -- **Backward Compatibility** – Legacy groups without mode field default to "timestamp" - -## Admin UI Features - -### Inline Status Control - -Posts on the listing page have a status dropdown (Draft/Published/Archived) next to the -primary language badge. Changing status keeps you in the current tab — the post updates -in place with the new status color. Status tab counts update automatically. - -### Skeleton Loading - -All tab switches and language switches show animated skeleton placeholders while loading: -- **Index page**: Group card skeletons when switching Active/Trash tabs -- **Listing page**: Post card skeletons when switching status tabs -- **Editor**: Content area skeleton when switching between language translations - -Skeletons use `bg-base-200` with `animate-pulse` (not DaisyUI's `skeleton` class, which -depends on `--color-base-300` that resolves to white in some PhoenixKit themes). - -### Trash Management - -- **Trashing**: Posts are soft-deleted (status set to "trashed"). Trashed posts are excluded - from all public-facing queries (URL slugs, timestamp lookups, listing cache). -- **Empty posts**: Posts with no content across any version are automatically hard-deleted - by the stale fixer (prevents the restore → auto-trash loop). -- **Trash tab**: Shows trashed posts with full metadata (titles, languages) but all elements - are non-interactive (dimmed languages, no clickable titles, no public URL). -- **Restore**: Button next to primary language badge. Restores post as "draft" status. - -### Clear Translation - -Non-primary language translations can be cleared via a button in the editor sidebar. -This hard-deletes the content row from the database (not a soft-delete/archive). -The language disappears from the post and can be re-added later. The button appears -whenever a content row exists in the DB, regardless of whether it has body text. - -## URL Modes - -Each publishing group has an immutable URL mode that determines how post URLs are structured. The mode is set at creation and cannot be changed afterward. Both modes store data identically in the database. - -### 1. Timestamp Mode (Default, Legacy) - -Posts addressed by publication date and time: - -``` -/{prefix}/{language}/{group}/{YYYY-MM-DD}/{HH:MM} -``` - -**Characteristics:** -- URL auto-generated from `published_at` timestamp -- No slug field in editor UI -- Ideal for chronological content (news, announcements, changelogs) -- URL path cannot be manually controlled by user - -**Example URL:** `/phoenix_kit/en/news/2025-01-15/09:30` - -### 2. Slug Mode (Semantic URLs) - -Posts addressed by semantic slug: - -``` -/{prefix}/{language}/{group}/{post-slug} -``` - -**Characteristics:** -- User-provided or auto-generated slug from title -- Slug field visible in editor UI -- Slug validation: lowercase letters, numbers, hyphens only -- Ideal for documentation, guides, evergreen content -- Slug can be changed (DB update, old URLs can redirect via `previous_url_slugs`) - -**Example URL:** `/phoenix_kit/en/docs/getting-started` - -## Content Format - -Post content is stored in the `publishing_contents` table as Markdown text with metadata tracked in the schema fields and `data` JSONB column. - -**Title Extraction:** - -The post title is **extracted from the first Markdown heading** (`# Title`) in the content body. This approach: -- Keeps the title visible in the content for authors -- Avoids duplication between metadata and content -- Makes the rendered output match the source - -**Metadata Fields:** - -- `slug` – Post slug (used for URL in slug mode) -- `status` – Publication status: `draft`, `published`, or `archived` -- `published_at` – Publication timestamp (ISO8601 format) -- `featured_image_uuid` – Optional reference to a featured image asset -- `description` – Optional post description/excerpt for SEO -- `version`, `version_created_at`, `version_created_from` – Managed automatically for versioned posts -- `allow_version_access` – Enables public viewing of historical versions when set to `true` - -**Audit Fields (optional):** - -- `created_at` – Creation timestamp (for audit purposes) -- `created_by_uuid` – User ID who created the post -- `created_by_email` – Email of user who created the post -- `updated_by_uuid` – User ID who last updated the post -- `updated_by_email` – Email of user who last updated the post - -**PHK Component Format** - -In addition to Markdown, post content can contain PHK components for structured page layouts: - -```html - - -# Introduction - -Regular **Markdown** content can be mixed with components. - -Hero image - - -``` - -Supported components: `Image`, `Hero`, `CTA`, `Headline`, `Subheadline`, `Video`, `EntityForm`. The renderer processes these via the PageBuilder system. - -## Context Layer API - -The main context module (`publishing.ex`) provides the public API. All writes go to the database via `DBStorage`: - -## Command-Line / IEx Usage - -PhoenixKit exposes the entire publishing system through the `PhoenixKit.Modules.Publishing` -module, so you can manage publishing groups from IEx or any script without touching the UI. This is extremely -useful when seeding sample content, migrating posts, or when an AI assistant has CLI access. - -### Bootstrapping a session - -```bash -$ iex -S mix -iex> alias PhoenixKit.Modules.Publishing -iex> alias PhoenixKit.Users.Auth.Scope -iex> Publishing.enable_system() -``` - -- `Publishing` is available anywhere via the alias above. -- `Scope` is optional but lets you stamp `created_by_*` / `updated_by_*` metadata. -- Module settings live in `PhoenixKit.Settings`. - -### Managing publishing groups - -```elixir -iex> {:ok, docs} = Publishing.add_group("Documentation", mode: "slug") -iex> Publishing.list_groups() -[%{"name" => "Documentation", "slug" => "documentation", "mode" => "slug"}] -iex> {:ok, group} = Publishing.get_group("documentation") -iex> {:ok, _} = Publishing.update_group("documentation", %{"name" => "Docs"}) -iex> Publishing.trash_group("documentation") -{:ok, "trash/documentation-2025-01-15-09-30-00"} -``` - -- `mode` must be `"slug"` or `"timestamp"` and is immutable after creation. -- Groups are stored in the `publishing_groups` DB table (and synced to Settings JSON for backward compatibility). - -### Creating scope-aware posts - -```elixir -iex> user = MyApp.Repo.get!(MyApp.Users.User, 123) -iex> scope = Scope.for_user(user) -iex> {:ok, post} = Publishing.create_post("documentation", %{title: "Intro", scope: scope}) -iex> {:ok, post} = Publishing.create_post("docs", %{title: "Intro", slug: "getting-started"}) -iex> {:ok, post} = Publishing.create_post("news", %{scope: Scope.for_user(nil)}) -``` - -- Slug mode expects a title (auto-slug) or explicit `:slug`. -- Timestamp mode ignores slug and uses current UTC time for the URL. -- `scope` is optional; pass `Scope.for_user(nil)` for system automation. -- Replace `MyApp.*` with your host application's modules/Repo. - -### Reading and updating posts - -```elixir -iex> {:ok, post} = Publishing.read_post("docs", "getting-started") -iex> {:ok, post_es} = Publishing.read_post("docs", "getting-started", "es") -iex> {:ok, updated} = Publishing.update_post("docs", post, %{"content" => "# v2"}, scope: scope) -``` - -- Slug-mode identifiers can include versions, e.g. `"getting-started"` with version number. -- Timestamp-mode identifiers are `"YYYY-MM-DD/HH:MM"` paths. -- `update_post/4` updates the DB record directly; slug changes are tracked via `previous_url_slugs`. - -### Versioning and translations - -```elixir -iex> {:ok, draft_v2} = Publishing.create_version_from("docs", "getting-started", 1, %{"content" => "..."}) -iex> :ok = Publishing.publish_version("docs", "getting-started", 2) -iex> {:ok, spanish} = Publishing.add_language_to_post("docs", "getting-started", "es") -iex> :ok = Publishing.delete_language("docs", "getting-started", "fr") -iex> :ok = Publishing.delete_version("docs", "getting-started", 1) -``` - -- `create_version_from/5` creates a new version by copying from source (or blank if `nil`); publish with `publish_version/3`. -- Languages are stored as separate `publishing_contents` rows sharing the same version. - -### Cache helpers - -```elixir -iex> posts = Publishing.list_posts("docs") -iex> :ok = Publishing.regenerate_cache("docs") -iex> {:ok, cached} = Publishing.find_cached_post("docs", "getting-started") -iex> {:ok, _} = Publishing.trash_post("docs", "getting-started") -``` - -- Listing cache uses `:persistent_term` for sub-microsecond reads. -- `trash_post/2` performs a DB soft-delete (sets `status` to `"trashed"`). Trashed posts can be restored from the admin trash tab. -- Empty posts (no content in any version) are automatically hard-deleted by the stale fixer on the next listing page load. - -### Group Management - -```elixir -# Create group with URL mode -{:ok, group} = Publishing.add_group("Documentation", mode: "slug") -{:ok, group} = Publishing.add_group("Company News", mode: "timestamp") - -# With custom slug -{:ok, group} = Publishing.add_group("My API Docs", mode: "slug", slug: "api-docs") - -# List all groups (includes mode field) -groups = Publishing.list_groups() -# => [%{"name" => "Docs", "slug" => "docs", "mode" => "slug"}, ...] - -# Get group URL mode -mode = Publishing.get_group_mode("docs") # => "slug" - -# Update group name/slug -{:ok, group} = Publishing.update_group("docs", %{"name" => "New Name", "slug" => "new-docs"}) - -# Remove group from settings list -{:ok, _} = Publishing.remove_group("docs") - -# Trash group (soft-delete in DB, removes from settings) -{:ok, _} = Publishing.trash_group("docs") - -# Get group name from slug -name = Publishing.group_name("docs") # => "Documentation" - -# Slug utilities -slug = Publishing.slugify("My First Post!") # => "my-first-post" -Publishing.valid_slug?("my-slug") # => true -Publishing.valid_slug?("en") # => false (reserved language code) - -# Slug validation with error reason -{:ok, "hello-world"} = Publishing.validate_slug("hello-world") -{:error, :invalid_format} = Publishing.validate_slug("Hello World") -{:error, :reserved_language_code} = Publishing.validate_slug("en") - -# Check if slug exists and generate unique slugs -Publishing.slug_exists?("docs", "getting-started") # => true/false -{:ok, slug} = Publishing.generate_unique_slug("docs", "Getting Started") -# => {:ok, "getting-started"} or {:ok, "getting-started-1"} if exists - -# Language utilities -Publishing.enabled_language_codes() # => ["en", "es", "fr"] -Publishing.get_primary_language() # => "en" -Publishing.language_enabled?("en", ["en-US", "es"]) # => true -Publishing.get_display_code("en", ["en-US", "es"]) # => "en-US" -Publishing.order_languages_for_display(["fr", "en"], ["en", "es"]) -# => ["en", "fr"] (enabled first, then others) - -# Language info -info = Publishing.get_language_info("en") -# => %{code: "en", name: "English", flag: "🇺🇸"} -``` - -### Post Operations - -The context layer routes all writes to the database: - -```elixir -# Create post (DB insert, routes by group mode for URL generation) -{:ok, post} = Publishing.create_post("docs", %{title: "Hello World"}) -# Slug mode: auto-generates slug "hello-world" -# Timestamp mode: uses current date/time for URL - -# Create post with explicit slug and audit trail (slug mode only) -{:ok, post} = Publishing.create_post("docs", %{ - title: "Getting Started", - slug: "get-started", - scope: current_user_scope # Optional: records created_by_uuid/email -}) - -# List posts (routes by group mode) -posts = Publishing.list_posts("docs") -posts = Publishing.list_posts("docs", "es") # With language preference - -# Read post (routes by group mode) -{:ok, post} = Publishing.read_post("docs", "getting-started") -{:ok, post} = Publishing.read_post("docs", "getting-started", "es") - -# Update post (DB update) -{:ok, updated} = Publishing.update_post("docs", post, %{ - "title" => "Updated Title", - "slug" => "new-slug", # Slug mode: updates DB, tracks old slug for redirects - "content" => "Updated content..." -}, scope: current_user_scope) # Optional 4th arg: records updated_by_uuid/email - -# Add translation -{:ok, spanish_post} = Publishing.add_language_to_post("docs", "getting-started", "es") -``` - -### Delete Operations - -Posts use soft-delete (setting `status` to `"trashed"`). Trashed posts are excluded from -public access and can be restored from the admin trash tab. Empty posts (no content in any -version) are automatically hard-deleted by the stale fixer. - -Translations are hard-deleted (the content row is removed from the DB), so the language -disappears from the post entirely. A new translation can be added later. - -```elixir -# Trash post (soft-delete — sets status to "trashed") -{:ok, _} = Publishing.trash_post("docs", "getting-started") - -# For timestamp mode, use the post UUID -{:ok, _} = Publishing.trash_post("news", post_uuid) - -# Archive a translation (soft-delete — sets status to "archived", refuses if last language) -:ok = Publishing.delete_language("docs", post_uuid, "es") -:ok = Publishing.delete_language("docs", post_uuid, "es", 2) # specific version -{:error, :last_language} = Publishing.delete_language("docs", post_uuid, "en") - -# Hard-delete a translation (permanently removes the content row) -:ok = Publishing.clear_translation("docs", post_uuid, "es") - -# Archive a version (refuses if live or last active version) -:ok = Publishing.delete_version("docs", post_uuid, 1) -{:error, :cannot_delete_live} = Publishing.delete_version("docs", post_uuid, 2) -{:error, :last_version} = Publishing.delete_version("docs", post_uuid, 1) -``` - -### Versioning Operations - -```elixir -# List all versions of a post -versions = Publishing.list_versions("docs", "getting-started") -# => [1, 2, 3] - -# Get specific version info -{:ok, 3} = Publishing.get_latest_version("docs", "getting-started") -{:ok, 2} = Publishing.get_published_version("docs", "getting-started") - -# Get version metadata -{:ok, metadata} = Publishing.get_version_metadata("docs", "getting-started", 1, "en") - -# Create new version from existing post -# Create a new version from an existing version (branching) -{:ok, new_post} = Publishing.create_version_from("docs", "getting-started", 1, %{ - "content" => "Updated content..." -}, scope: current_user_scope) - -# Create a blank new version -{:ok, new_post} = Publishing.create_version_from("docs", "getting-started", nil, %{}, - scope: current_user_scope) - -# For timestamp mode, use the date/time path as identifier -{:ok, new_post} = Publishing.create_version_from("news", "2025-01-15/14:30", 1, %{}, - scope: current_user_scope) - -# Publish a version (archives all other published versions) -:ok = Publishing.publish_version("docs", "getting-started", 2) - -# Get the currently published version -{:ok, version} = Publishing.get_published_version("docs", "getting-started") - -# Helpers for version logic -Publishing.content_changed?(post, params) # => true/false -Publishing.status_change_only?(post, params) # => true/false -``` - -### Variant Versioning System - -Both slug-mode and timestamp-mode posts support **variant versioning** - versions are independent -attempts or drafts rather than sequential history. Only ONE version can be published at a time. - -**Key Concepts:** - -- **Radio-style publishing**: When you publish a version, all other published versions are - automatically archived. Only the newly published version is visible to the public. -- **Versions are editable**: Unlike historical versioning, all versions remain editable regardless - of status. You can modify drafts, archived versions, or even the published version. -- **Branching**: Create new versions by copying from an existing version or starting blank. -- **Translation inheritance**: When publishing, all translations inherit the primary language's status. - -**Creating New Versions:** - -1. Open any post in the editor -2. Click the **"New Version"** button next to the version switcher -3. Choose to copy from an existing version or start blank -4. The new version is created as a draft - -**Publishing a Version:** - -1. Open the version you want to publish -2. Change status to "Published" and save -3. All other published versions are automatically archived -4. The public URL now shows this version's content - -**Version Statuses:** - -- `published` - The live version visible to the public (only ONE per post) -- `draft` - Work in progress, not visible publicly -- `archived` - Previously published or intentionally hidden versions - -**Editor UI:** - -The editor provides a complete version management interface: - -- **Version Switcher** - Dropdown showing all versions with status indicators (green=published, yellow=draft, gray=archived) -- **New Version Button** - Opens a modal to create a new version -- **New Version Modal** - Choose to start blank or copy from any existing version -- **Status Dropdown** - Change status directly in the metadata panel - -**Translation Status Inheritance:** - -Translations always follow the primary language's status: - -- When the primary language status changes, all translations are updated to match -- Users can temporarily change a translation's status (e.g., set to "draft" while reviewing) -- However, the next primary status change will reset all translations to match -- Translations can only be changed to "draft" or "archived" if the primary is already published - -Example workflow: -1. Primary (English) is published with v2 → all translations become "published" -2. French translation has an issue, translator sets it to "draft" temporarily -3. When English v3 is published, French becomes "published" again (along with all translations) - -**Public URLs:** - -Public URLs always show the published version's content: -- `/{prefix}/{language}/{group}/{post}` - Shows the published version - -**Version Browsing (Opt-in):** - -By default, only the published version is accessible. To enable public access to older -published versions, set `allow_version_access: true` in the post's metadata (stored in the `data` JSONB column). - -When enabled, versioned URLs become accessible: -- `/{prefix}/{language}/{group}/{post}/v/{version}` - Direct version access - -The version dropdown appears on the public post page, showing all published versions. -This is useful for documentation sites where users may need to reference older versions. - -### Cache Operations - -```elixir -# Regenerate listing cache (called automatically on post changes) -:ok = Publishing.regenerate_cache("docs") - -# Invalidate (delete) cache -:ok = Publishing.invalidate_cache("docs") - -# Check if cache exists -Publishing.cache_exists?("docs") # => true/false - -# Fast post lookup from cache (O(1) via :persistent_term) -{:ok, post_data} = Publishing.find_cached_post("docs", "getting-started") -{:ok, post_data} = Publishing.find_cached_post_by_path("news", "2025-01-15", "14:30") -``` - -## Storage Architecture - -### DB Storage (Primary — All Writes) - -All create/update/delete operations go through `DBStorage`: - -```elixir -alias PhoenixKit.Modules.Publishing.DBStorage - -# Posts -{:ok, post} = DBStorage.create_post(%{group_uuid: group_uuid, slug: "hello", status: "draft", mode: "slug"}) -post = DBStorage.get_post("docs", "hello") # excludes trashed posts -{:ok, updated} = DBStorage.update_post(post, %{status: "published"}) -{:ok, _} = DBStorage.trash_post(post) # soft-delete (status → "trashed") -{:ok, _} = DBStorage.delete_post(post) # hard-delete (cascade removes versions/contents) - -# Versions -{:ok, version} = DBStorage.create_version(post_uuid, %{version_number: 1, status: "draft"}) -versions = DBStorage.list_versions(post_uuid) -{:ok, version} = DBStorage.get_version(post_uuid, 1) - -# Contents (translations) -{:ok, content} = DBStorage.create_content(version_uuid, %{language: "en", title: "Hello", content: "# Hello"}) -{:ok, content} = DBStorage.get_content(version_uuid, "en") -contents = DBStorage.list_contents(version_uuid) -``` - -The `Mapper` module converts DB records to the post map format that the web layer and templates consume: - -```elixir -alias PhoenixKit.Modules.Publishing.DBStorage.Mapper - -post_map = Mapper.to_post_map(group, post, version, content, available_languages) -listing_map = Mapper.to_listing_map(group, post, version, primary_content) -``` - -## LiveView Interfaces - -### Settings (`settings.ex`) - -Group configuration interface at `{prefix}/admin/settings/publishing`: - -- Create new groups with mode selector -- View existing groups with mode badges -- Delete groups -- Configure public display settings - -**Group Creation (New Group Form):** -- Mode selector: Radio buttons (Timestamp / Slug) -- Warning text: "Cannot be changed after group creation" -- Mode is locked permanently after creation - -### Editor (`editor.ex`) - -Markdown editor at `{prefix}/admin/publishing/{group}/edit`: - -- Title input (all modes) -- **Slug input** (slug mode only, with validation) -- Status selector (draft/published/archived) -- Published at timestamp picker -- Featured image selector (integrates with Media module) -- Markdown editor with preview -- Language switcher for translations - -**Autosave:** - -The editor automatically saves changes after 2 seconds of inactivity: -- Debounced saves prevent excessive writes -- Status indicator shows: "Saving...", "Saved", or error state -- Dirty detection tracks unsaved changes -- Navigation warnings when leaving with unsaved changes - -**Featured Images:** - -Posts can have an optional featured image: -- Click "Select Featured Image" to open the media picker -- Preview displays below the image selector -- Click "Clear" to remove the featured image -- Stored as `featured_image_uuid` in metadata - -**Migration Gate:** - -The editor requires the V59 database migration to be applied. The editor is available immediately on fresh installs. - -**Mode-Specific Behavior:** - -**Timestamp Mode:** -- No slug field visible -- URL auto-generated from `published_at` - -**Slug Mode:** -- Slug field visible with validation -- Auto-generates slug from title (debounced) -- User can override auto-generated slug -- Validation: lowercase, numbers, hyphens only -- **Reserved slugs**: Any language code from the Languages module cannot be used as a slug to prevent routing ambiguity -- Shows validation error for invalid slugs - -### Preview (`preview.ex`) - -Live preview at `{prefix}/admin/publishing/{group}/preview`: - -- Renders Markdown content with Phoenix.Component -- Shows metadata preview (title, status, published date) -- Language switcher for viewing translations - -### Collaborative Editing - -The editor uses Phoenix.Presence to coordinate multiple users editing the same post. - -**Owner/Spectator Model:** - -1. First user to open a post becomes the **owner** (full edit access) -2. Subsequent users become **spectators** (read-only mode) -3. When the owner leaves, the next spectator auto-promotes to owner -4. All users see who else is viewing the post in real-time - -**How It Works:** - -- Users join a Presence topic (e.g., `publishing_edit:group-slug:post-slug`) -- Users sorted by `joined_at` timestamp (FIFO ordering) -- First user in sorted list = owner (`readonly?: false`) -- All other users = spectators (`readonly?: true`) -- Phoenix.Presence auto-cleans disconnected users - -**UI Indicators:** - -- Spectator mode shows a banner: "Another user is currently editing this post" -- Users see avatars/names of other connected editors -- Read-only mode disables form inputs and save button - -**Files:** - -- `presence.ex` – Phoenix.Presence configuration -- `presence_helpers.ex` – Helper functions for owner/spectator logic -- `editor.ex` – Presence integration in the editor LiveView - -## Multi-Language Support - -Every post version can have multiple language translations stored as separate `publishing_contents` rows: - -``` -publishing_contents table: - version_uuid | language | title | content | url_slug - abc-123 | en | Getting Started | # Getting… | getting-started - abc-123 | es | Primeros Pasos | # Primeros | primeros-pasos - abc-123 | fr | Prise en Main | # Prise… | prise-en-main -``` - -**Workflow:** - -1. Create primary post (e.g., English) -2. Click language switcher → Select "Add Spanish" -3. System creates a new content record with empty content and title -4. Fill in translated content and save -5. All translations share same post/version, each with its own `url_slug` - -**Post Map Fields:** - -The `Mapper` module converts DB records into this map format consumed by templates and the web layer: - -```elixir -%{ - group: "docs", # Publishing group slug - slug: "getting-started", # Slug mode only - uuid: "01234567-...", # UUIDv7 from DB - date: ~D[2025-01-15], # Timestamp mode only - time: ~T[09:30:00], # Timestamp mode only - path: "docs/getting-started/v1/en", # Virtual path for compatibility - metadata: %{ - title: "Getting Started", - status: "published", # "published", "draft", or "archived" - slug: "getting-started", - published_at: "2025-01-15T09:30:00Z", - created_at: "2025-01-15T09:30:00Z", - version: 1, - version_created_at: "2025-01-15T09:30:00Z", - version_created_from: nil # Source version when branching - }, - content: "# Markdown content...", - language: "en", - available_languages: ["en", "es", "fr"], - language_statuses: %{"en" => "published", "es" => "draft", "fr" => "published"}, - mode: :slug, # :slug or :timestamp - version: 1, # Current version number - available_versions: [1, 2, 3], # All versions for this post - version_statuses: %{1 => "archived", 2 => "archived", 3 => "published"} -} -``` - -**Note on `uuid`:** All posts have a non-nil `uuid` field. The helper `Publishing.db_post?(post)` checks this. - -## AI Translation - -The Publishing module integrates with the AI module to provide automated translation of posts to multiple languages using an Oban background job. - -### Prerequisites - -1. **AI Module Enabled**: The AI module must be enabled (`PhoenixKit.Modules.AI.enable_system()`) -2. **AI Endpoint Configured**: At least one AI endpoint must be configured with a capable model -3. **Languages Enabled**: The Languages module should have multiple languages enabled - -### Editor UI - -When prerequisites are met, a collapsible **AI Translation** panel appears in the post editor (for primary language posts only): - -1. Open any post in the primary language -2. Expand the "AI Translation" section (marked with Beta badge) -3. Select an AI endpoint from the dropdown -4. Click one of the translation buttons: - - **Translate All Languages** - Translates to ALL enabled languages - - **Translate Missing Only** - Only translates languages that don't have a content record yet - -The translation runs as a background job. Progress can be monitored in the Oban dashboard or via logs. - -### Quick Start (Programmatic) - -```elixir -# Translate a post to all enabled languages -{:ok, job} = Publishing.translate_post_to_all_languages("docs", "getting-started", - endpoint_id: 1 -) - -# Translate to specific languages only -{:ok, job} = Publishing.translate_post_to_all_languages("docs", "getting-started", - endpoint_id: 1, - target_languages: ["es", "fr", "de"] -) - -# Translate a specific version -{:ok, job} = Publishing.translate_post_to_all_languages("docs", "getting-started", - endpoint_id: 1, - version: 2 -) -``` - -### Configuration - -Set a default AI endpoint for translations (optional): - -```elixir -PhoenixKit.Settings.update_setting("publishing_translation_endpoint_id", "1") -``` - -With a default endpoint configured, you can omit the `endpoint_id` option: - -```elixir -{:ok, job} = Publishing.translate_post_to_all_languages("docs", "getting-started") -``` - -### How It Works - -1. **Job Enqueued**: An Oban job is created in the `:default` queue -2. **Source Read**: The primary language content is read from the specified post -3. **AI Translation**: For each target language, the content is sent to the AI with a translation prompt -4. **Records Created**: Translation content records are created or updated in the database -5. **Cache Updated**: The listing cache is regenerated to include new translations - -### Translation Features - -**Format Preservation:** -- The AI preserves the EXACT formatting of the original content -- If the original has `# headings`, translations keep them; if not, they don't add them -- All Markdown formatting is preserved (bold, italic, lists, code blocks, links) -- Line breaks and spacing are maintained -- Code blocks and inline code are NOT translated - -**URL Slug Translation:** -- The AI generates a localized URL slug for each translation -- Example: `getting-started` → `primeros-pasos` (Spanish) -- Slugs are automatically sanitized (lowercase, hyphens, no special characters) -- See [Per-Language URL Slugs](#per-language-url-slugs) for more details - -**Title Extraction:** -- The AI extracts and translates the title separately -- Title is stored in metadata for listings and SEO -- Original document structure is preserved - -### Translation Prompt - -The worker uses a built-in translation prompt that instructs the AI to: -- Preserve exact formatting (headings, spacing, structure) -- Keep Markdown syntax intact -- Not translate code blocks or inline code -- Translate naturally and idiomatically -- Generate SEO-friendly URL slugs in the target language - -### Options - -| Option | Type | Description | -|--------|------|-------------| -| `endpoint_id` | integer | AI endpoint ID (required if not set in settings) | -| `source_language` | string | Source language code (defaults to primary language) | -| `target_languages` | list | Target language codes (defaults to all enabled except source) | -| `version` | integer | Version number to translate (defaults to latest) | -| `user_id` | integer | User ID for audit trail | - -### Job Monitoring - -Translation jobs can be monitored via: -- **Oban Dashboard**: View job status, retries, and errors -- **Jobs Module**: Enable at `/{prefix}/admin/modules` → Jobs -- **Logs**: Jobs log progress and errors with `[TranslatePostWorker]` prefix - -Example log output: -``` -[TranslatePostWorker] Starting translation of docs/getting-started from en to 5 languages -[TranslatePostWorker] Translating to es (Spanish)... -[TranslatePostWorker] AI call for es completed in 2341ms -[TranslatePostWorker] Got translated slug for es: primeros-pasos -[TranslatePostWorker] Creating new es translation -[TranslatePostWorker] Successfully translated to es -... -[TranslatePostWorker] Completed: 5 succeeded, 0 failed -``` - -### Error Handling - -- **Partial Failures**: If some languages fail, the job reports which languages succeeded and which failed -- **Retries**: Jobs retry up to 3 times with exponential backoff -- **Timeout**: Jobs have a 10-minute timeout for large posts or many languages -- **Language Fallback Protection**: The worker verifies each translation is saved to the correct language record (prevents overwriting primary) - -### Programmatic Usage - -```elixir -alias PhoenixKit.Modules.Publishing.Workers.TranslatePostWorker - -# Create a job without inserting -job = TranslatePostWorker.create_job("docs", "getting-started", endpoint_id: 1) - -# Insert the job -{:ok, oban_job} = Oban.insert(job) - -# Or use the convenience function -{:ok, oban_job} = TranslatePostWorker.enqueue("docs", "getting-started", endpoint_id: 1) - -# Translate only missing languages -missing_langs = ["de", "ja", "zh"] # Languages without content records -{:ok, job} = TranslatePostWorker.enqueue("docs", "getting-started", - endpoint_id: 1, - target_languages: missing_langs -) -``` - -## Migration Path - -### Fresh Installs - -New installs start with database storage immediately. The editor is available right away after the V59 migration is applied. - -### Existing Groups (Pre-Dual-Mode) - -All existing groups automatically default to `"timestamp"` mode via `normalize_groups/1`: - -```elixir -# Before (legacy group without mode field) -%{"name" => "News", "slug" => "news"} - -# After (normalized with default mode) -%{"name" => "News", "slug" => "news", "mode" => "timestamp"} -``` - -No migration script needed – backward compatibility is automatic. - -### Creating New Groups - -Admin chooses mode at creation time: - -1. Navigate to `{prefix}/admin/publishing/settings` -2. Enter group name: "Documentation" -3. Select mode: **Slug** or **Timestamp** -4. Click "Add Group" -5. Mode is now permanently locked for this group - -## Test Coverage - -**Status:** 122+ unit tests across 6 test files (all pure-function, no database required). - -**Test files:** - -| File | Tests | Coverage | -|------|-------|----------| -| `test/modules/publishing/schema_test.exs` | 28 | All 4 schema changesets, JSONB accessors, defaults | -| `test/modules/publishing/metadata_test.exs` | 26 | parse/serialize round-trip, title extraction, legacy XML | -| `test/modules/publishing/mapper_test.exs` | 26 | `to_post_map`, `to_listing_map`, field mapping, edge cases | -| `test/modules/publishing/pubsub_test.exs` | 13 | Topic generation, form key generation | -| `test/modules/publishing/publishing_api_test.exs` | 20 | Module loading, slugify, valid_slug?, db_post?, extract helpers | -| `test/modules/publishing/storage_utils_test.exs` | 9 | content_changed?, status_change_only?, should_create_new_version? | - -**Running Tests:** - -```bash -# Run all publishing tests -mix test test/modules/publishing/ - -# Run a specific test file -mix test test/modules/publishing/schema_test.exs -``` - -**Testing Philosophy:** - -PhoenixKit is a library module. Unit tests cover pure functions, changesets, and data transformations. Integration tests requiring a database (CRUD operations, LiveView flows) are run in parent Phoenix applications. - -## Configuration - -Publishing module uses PhoenixKit Settings for configuration: - -```elixir -# Enable/disable publishing system -Publishing.enable_system() -Publishing.disable_system() -Publishing.enabled?() # => true/false - -# Publishing groups are stored in the database (publishing_groups table) - -# Cache toggles -PhoenixKit.Settings.update_setting("publishing_memory_cache_enabled", "true") - -# Render cache (global + per group) -PhoenixKit.Settings.update_setting("publishing_render_cache_enabled", "true") -PhoenixKit.Settings.update_setting("publishing_render_cache_enabled_docs", "false") - -# Custom settings backend (optional) -config :phoenix_kit, publishing_settings_module: MyApp.CustomSettings -``` - -### Database Tables - -All content is stored in PostgreSQL via the V59 migration tables: - -| Table | Purpose | -|-------|---------| -| `phoenix_kit_publishing_groups` | Publishing groups (name, slug, mode, data JSONB) | -| `phoenix_kit_publishing_posts` | Posts (group FK, slug, status, mode, published_at) | -| `phoenix_kit_publishing_versions` | Versions (post FK, version_number, status) | -| `phoenix_kit_publishing_contents` | Content/translations (version FK, language, title, content, url_slug) | - -All reads and writes use the database. The editor is available once the V59 migration is applied. - -## Best Practices - -### Choosing URL Mode - -**Use Timestamp Mode when:** -- Content is time-sensitive (news, announcements, changelogs) -- Chronological order is primary navigation pattern -- URLs should reflect publication date -- Posts are rarely renamed or restructured - -**Use Slug Mode when:** -- Content is evergreen (documentation, guides, tutorials) -- Semantic URLs improve SEO and user experience -- Posts may be reorganized or renamed over time -- URL structure matters for branding - -### Slug Design Guidelines - -**Good slugs:** -- `getting-started` – Clear, readable -- `api-authentication` – Descriptive -- `migrate-from-v1-to-v2` – Self-explanatory - -**Bad slugs:** -- `Getting Started` – Contains uppercase and spaces (invalid) -- `post-1` – Not descriptive -- `api_auth` – Uses underscores instead of hyphens (invalid) -- `article` – Too generic - -### Multi-Language Strategy - -1. **Always create English first** – Establish primary content structure -2. **Use consistent slugs** – All translations share the same slug/path -3. **Translate titles** – Each language content record has its own `# Title` heading -4. **Don't mix languages** – One language per content record -5. **Test translations** – Use language switcher in editor/preview - -## Troubleshooting - -### Problem: Slug validation fails with valid-looking slug - -**Symptoms:** -``` -Invalid slug format -``` - -**Root Cause:** - -Slug contains uppercase letters, underscores, or special characters. - -**Solution:** - -Use only lowercase letters, numbers, and hyphens. Avoid language codes: - -```elixir -# ✅ Valid slugs -"hello-world" -"api-v2-guide" -"2025-roadmap" - -# ❌ Invalid slugs -"Hello-World" # Uppercase -"api_guide" # Underscore -"guide!" # Special char -"my slug" # Space -"en" # Reserved language code -"fr" # Reserved language code -``` - ---- - -### Problem: Slug is a reserved language code - -**Symptoms:** -``` -Slug cannot be a reserved language code -``` - -**Root Cause:** - -The slug matches a language code defined in the Languages module (e.g., `en`, `es`, `fr-CA`). - -**Solution:** - -Choose a different slug. Language codes are reserved to prevent URL routing ambiguity between `/{prefix}/en/docs` (language + group) and a post with slug `en`. - ---- - -### Problem: Slug already exists - -**Symptoms:** -``` -A post with this slug already exists -``` - -**Root Cause:** - -Another post in the same group already uses this slug. - -**Solution:** - -Choose a unique slug or append a number (e.g., `my-post-2`). The auto-slug generator handles this automatically when creating new posts. - ---- - -### Problem: Editor is in read-only mode - -**Symptoms:** - -Form inputs are disabled and a banner says "Another user is currently editing this post". - -**Root Cause:** - -Another user opened the editor first and is the current "owner". The collaborative editing system only allows one person to edit at a time. - -**Solution:** - -Wait for the other user to leave, or coordinate with them. When they close the editor, you'll automatically become the owner and gain edit access. - ---- - -### Problem: Post not found after changing slug - -**Symptoms:** -``` -Post not found -``` - -**Root Cause:** - -Old links still reference the previous slug. - -**Solution:** - -Slug changes update the database record. Old URLs need redirects. Update any hardcoded links: - -```elixir -# Before slug change -Publishing.read_post("docs", "old-slug") - -# After slug change (from "old-slug" to "new-slug") -Publishing.read_post("docs", "new-slug") # ✅ Works -Publishing.read_post("docs", "old-slug") # ❌ Not found -``` - -Consider implementing redirects in your application for user-facing URLs. - ---- - -### Problem: Cannot change group mode - -**Symptoms:** - -Mode field is read-only in settings UI. - -**Root Cause:** - -Mode immutability is by design – URL mode is locked at group creation. - -**Solution:** - -To change modes, you must: - -1. Create a new group with the desired mode -2. Manually migrate posts (via IEx or scripts) to the new group -3. Update internal references -4. Delete old group - -**No automatic migration is provided** – this is an infrequent operation best done manually. - ---- - -### Problem: Cannot delete the last language - -**Symptoms:** -``` -{:error, :last_language} -``` - -**Root Cause:** - -Every post version must have at least one active language. You cannot archive the only remaining translation. - -**Solution:** - -Either add another translation first, or trash the entire post: - -```elixir -# Add another language first -{:ok, _} = Publishing.add_language_to_post("docs", "post", "es") -# Then delete the unwanted one -:ok = Publishing.delete_language("docs", "post", "en") - -# Or trash the entire post -{:ok, _} = Publishing.trash_post("docs", "post") -``` - ---- - -### Problem: Cannot delete the live version - -**Symptoms:** -``` -{:error, :cannot_delete_live} -``` - -**Root Cause:** - -The version you're trying to delete is currently the live (public-facing) version. - -**Solution:** - -Publish a different version first: - -```elixir -# Publish another version (this archives the current published version) -:ok = Publishing.publish_version("docs", "post", 2) -# Now delete the old version -:ok = Publishing.delete_version("docs", "post", 1) -``` - ---- - -### Problem: Cannot delete the last version - -**Symptoms:** -``` -{:error, :last_version} -``` - -**Root Cause:** - -Every post must have at least one active version. You cannot archive the only remaining version. - -**Solution:** - -Either create a new version first, or trash the entire post: - -```elixir -# Trash the entire post instead -{:ok, _} = Publishing.trash_post("docs", "post") -``` - -## Per-Language URL Slugs - -Each language translation can have its own SEO-friendly URL slug, enabling localized URLs for better search engine optimization and user experience. - -**Example:** -``` -# Each language has its own URL slug -/en/docs/getting-started → post slug: "getting-started", content url_slug: "getting-started" -/es/docs/primeros-pasos → post slug: "getting-started", content url_slug: "primeros-pasos" -/fr/docs/prise-en-main → post slug: "getting-started", content url_slug: "prise-en-main" -``` - -**Key Concepts:** - -1. **Post Slug = Internal Identifier** - The post's `slug` field in the database ties all translations together. This is the canonical identifier. - -2. **url_slug = Public URL** - Each translation's `publishing_contents` row has its own `url_slug` for the public-facing URL. - -3. **Backward Compatible** - If no `url_slug` is set, the post slug is used (existing behavior). - -### Setting Up Per-Language Slugs - -**In the Editor:** - -1. Open a translation (non-primary language) in the editor -2. Find the "URL Slug" field in the metadata panel (only visible for translations) -3. Enter a localized slug (e.g., `primeros-pasos` for Spanish) -4. Save - the URL immediately updates - -**In Database:** - -The `url_slug` is stored on the `publishing_contents` record. - -**Auto-Generation:** - -When creating or editing a translation, the URL slug is automatically generated from the content title (first `# Heading`). You can override this by manually typing in the URL Slug field. - -### URL Slug Validation - -URL slugs are validated before saving: - -| Rule | Example | Error | -|------|---------|-------| -| Lowercase, numbers, hyphens only | `Hello-World` | Invalid format | -| Cannot be a language code | `en`, `es`, `fr-CA` | Reserved language code | -| Cannot be a reserved route | `admin`, `api`, `assets` | Reserved route word | -| Must be unique per language | Duplicate in same group+language | Already in use | - -**Reserved Route Words:** `admin`, `api`, `assets`, `phoenix_kit`, `auth`, `login`, `logout`, `register`, `settings` - -### 301 Redirects for Changed Slugs - -When you change a URL slug, the old slug is automatically stored in `previous_url_slugs` (in the content record's `data` JSONB) for 301 redirects. - -**Redirect Behavior:** -- Old URLs automatically 301 redirect to the new URL -- Multiple previous slugs are supported -- Works even on cold starts (no cache) via database query - -**Example:** -``` -# User changed Spanish slug from "empezando" to "primeros-pasos" -GET /es/docs/empezando -→ 301 Redirect to /es/docs/primeros-pasos -``` - -### Language Switcher Integration - -The language switcher automatically shows localized URLs for each language: - -```html - -English -Español -Français -``` - -### Cache Structure - -The listing cache stores per-language slug mappings for O(1) lookups: - -```json -{ - "slug": "getting-started", - "language_slugs": { - "en": "getting-started", - "es": "primeros-pasos", - "fr": "prise-en-main" - }, - "language_previous_slugs": { - "es": ["empezando", "comenzar"], - "fr": ["demarrage"] - } -} -``` - -### Programmatic API - -```elixir -# Find post by URL slug (any language) -{:ok, post} = ListingCache.find_by_url_slug("docs", "es", "primeros-pasos") -# => Returns post with slug: "getting-started" - -# Find post by previous URL slug (for redirects) -{:ok, post} = ListingCache.find_by_previous_url_slug("docs", "es", "empezando") -# => Returns post so you can build redirect URL - -# Validate URL slug before saving -{:ok, "primeros-pasos"} = SlugHelpers.validate_url_slug("docs", "primeros-pasos", "es", "getting-started") -{:error, :slug_already_exists} = SlugHelpers.validate_url_slug("docs", "existing-slug", "es", nil) -``` - -### Cold Start Fallback - -On cold starts (no cache), the system queries the database to resolve URL slugs: - -1. Queries `publishing_contents` for matching `url_slug` or entries in `data->previous_url_slugs` -2. Returns redirect for previous slugs, resolution for current slugs - -This ensures localized URLs work immediately after deployment without waiting for cache warm-up. - -### SEO Benefits - -- **Localized URLs**: Search engines prefer URLs in the user's language -- **Better Click-Through**: Users are more likely to click localized URLs in search results -- **Proper Hreflang**: The `` tags use language-specific URLs -- **Canonical URLs**: Each translation has its own canonical URL with its localized slug - -## Future Refactoring Notes - -- **Rename translation functions**: `clear_translation` (hard delete) and `delete_language` (archive/soft delete) have counterintuitive names — "delete" sounds harder than "clear" but does less. Consider renaming to `hard_delete_translation` / `archive_translation` in a future cleanup pass. - -## Getting Help - -1. Review DB storage layer: `lib/modules/publishing/db_storage.ex` -2. Review context module: `lib/modules/publishing/publishing.ex` -3. Inspect post map in IEx: `{:ok, post} = Publishing.read_post("docs", "slug")` → `IO.inspect(post)` -4. Enable debug logging: `Logger.configure(level: :debug)` -5. Search GitHub issues: diff --git a/lib/modules/publishing/components/cta.ex b/lib/modules/publishing/components/cta.ex deleted file mode 100644 index aef206542..000000000 --- a/lib/modules/publishing/components/cta.ex +++ /dev/null @@ -1,9 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.Components.CTA do - @moduledoc """ - Delegates to `PhoenixKit.Modules.Shared.Components.CTA`. - - Kept for backward compatibility with external consumers. - """ - - defdelegate render(assigns), to: PhoenixKit.Modules.Shared.Components.CTA -end diff --git a/lib/modules/publishing/components/entity_form.ex b/lib/modules/publishing/components/entity_form.ex deleted file mode 100644 index c22f7d9b1..000000000 --- a/lib/modules/publishing/components/entity_form.ex +++ /dev/null @@ -1,9 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.Components.EntityForm do - @moduledoc """ - Delegates to `PhoenixKit.Modules.Shared.Components.EntityForm`. - - Kept for backward compatibility with external consumers. - """ - - defdelegate render(assigns), to: PhoenixKit.Modules.Shared.Components.EntityForm -end diff --git a/lib/modules/publishing/components/headline.ex b/lib/modules/publishing/components/headline.ex deleted file mode 100644 index c83a232e6..000000000 --- a/lib/modules/publishing/components/headline.ex +++ /dev/null @@ -1,9 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.Components.Headline do - @moduledoc """ - Delegates to `PhoenixKit.Modules.Shared.Components.Headline`. - - Kept for backward compatibility with external consumers. - """ - - defdelegate render(assigns), to: PhoenixKit.Modules.Shared.Components.Headline -end diff --git a/lib/modules/publishing/components/hero.ex b/lib/modules/publishing/components/hero.ex deleted file mode 100644 index 99b496060..000000000 --- a/lib/modules/publishing/components/hero.ex +++ /dev/null @@ -1,9 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.Components.Hero do - @moduledoc """ - Delegates to `PhoenixKit.Modules.Shared.Components.Hero`. - - Kept for backward compatibility with external consumers. - """ - - defdelegate render(assigns), to: PhoenixKit.Modules.Shared.Components.Hero -end diff --git a/lib/modules/publishing/components/image.ex b/lib/modules/publishing/components/image.ex deleted file mode 100644 index bee43193a..000000000 --- a/lib/modules/publishing/components/image.ex +++ /dev/null @@ -1,9 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.Components.Image do - @moduledoc """ - Delegates to `PhoenixKit.Modules.Shared.Components.Image`. - - Kept for backward compatibility with external consumers. - """ - - defdelegate render(assigns), to: PhoenixKit.Modules.Shared.Components.Image -end diff --git a/lib/modules/publishing/components/page.ex b/lib/modules/publishing/components/page.ex deleted file mode 100644 index 964db972e..000000000 --- a/lib/modules/publishing/components/page.ex +++ /dev/null @@ -1,9 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.Components.Page do - @moduledoc """ - Delegates to `PhoenixKit.Modules.Shared.Components.Page`. - - Kept for backward compatibility with external consumers. - """ - - defdelegate render(assigns), to: PhoenixKit.Modules.Shared.Components.Page -end diff --git a/lib/modules/publishing/components/subheadline.ex b/lib/modules/publishing/components/subheadline.ex deleted file mode 100644 index af9613fd6..000000000 --- a/lib/modules/publishing/components/subheadline.ex +++ /dev/null @@ -1,9 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.Components.Subheadline do - @moduledoc """ - Delegates to `PhoenixKit.Modules.Shared.Components.Subheadline`. - - Kept for backward compatibility with external consumers. - """ - - defdelegate render(assigns), to: PhoenixKit.Modules.Shared.Components.Subheadline -end diff --git a/lib/modules/publishing/components/video.ex b/lib/modules/publishing/components/video.ex deleted file mode 100644 index c225b3a12..000000000 --- a/lib/modules/publishing/components/video.ex +++ /dev/null @@ -1,9 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.Components.Video do - @moduledoc """ - Delegates to `PhoenixKit.Modules.Shared.Components.Video`. - - Kept for backward compatibility with external consumers. - """ - - defdelegate render(assigns), to: PhoenixKit.Modules.Shared.Components.Video -end diff --git a/lib/modules/publishing/constants.ex b/lib/modules/publishing/constants.ex deleted file mode 100644 index 73260deb7..000000000 --- a/lib/modules/publishing/constants.ex +++ /dev/null @@ -1,111 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.Constants do - @moduledoc """ - Centralized constants for the Publishing module. - - Provides canonical lists for statuses, modes, and types used across - schemas, business logic, and templates. Import or alias this module - instead of hardcoding these values inline. - - For guard clauses and pattern matches, use the module attributes: - - @timestamp_modes Publishing.Constants.timestamp_modes() - @slug_modes Publishing.Constants.slug_modes() - - def my_func(mode) when mode in @timestamp_modes do ... - """ - - # --------------------------------------------------------------------------- - # Modes (post and group) - # --------------------------------------------------------------------------- - - @timestamp_modes [:timestamp, "timestamp"] - @slug_modes [:slug, "slug"] - @valid_modes ["timestamp", "slug"] - - @doc "Atom and string variants for timestamp mode — use in guards/pattern matches." - def timestamp_modes, do: @timestamp_modes - - @doc "Atom and string variants for slug mode — use in guards/pattern matches." - def slug_modes, do: @slug_modes - - @doc "Valid mode strings for schema validation." - def valid_modes, do: @valid_modes - - @doc "Returns true if mode is a timestamp mode (atom or string)." - def timestamp_mode?(mode), do: mode in @timestamp_modes - - @doc "Returns true if mode is a slug mode (atom or string)." - def slug_mode?(mode), do: mode in @slug_modes - - # --------------------------------------------------------------------------- - # Statuses - # --------------------------------------------------------------------------- - - @post_statuses ["draft", "published", "archived", "trashed"] - @content_statuses ["draft", "published", "archived"] - @group_statuses ["active", "trashed"] - - @doc "Valid post statuses: draft, published, archived, trashed." - def post_statuses, do: @post_statuses - - @doc "Valid version and content statuses: draft, published, archived." - def content_statuses, do: @content_statuses - - @doc "Valid group statuses: active, trashed." - def group_statuses, do: @group_statuses - - # --------------------------------------------------------------------------- - # Group types - # --------------------------------------------------------------------------- - - @preset_types ["blog", "faq", "legal"] - @valid_types ["blog", "faq", "legal", "custom"] - - @doc "Preset group types (shown as radio buttons in UI)." - def preset_types, do: @preset_types - - @doc "All valid group types including custom." - def valid_types, do: @valid_types - - # --------------------------------------------------------------------------- - # Defaults - # --------------------------------------------------------------------------- - - @default_mode "timestamp" - @default_type "blog" - @default_title "Untitled" - - @doc "Default group mode." - def default_mode, do: @default_mode - - @doc "Default group type." - def default_type, do: @default_type - - @doc "Default title for posts without a title." - def default_title, do: @default_title - - # --------------------------------------------------------------------------- - # Schema limits - # --------------------------------------------------------------------------- - - @max_slug_length 500 - @max_title_length 500 - @max_language_code_length 10 - @max_group_name_length 255 - @max_group_slug_length 255 - - @doc "Max length for post/content slugs." - def max_slug_length, do: @max_slug_length - - @doc "Max length for content titles." - def max_title_length, do: @max_title_length - - @doc "Max length for language codes." - def max_language_code_length, do: @max_language_code_length - - @doc "Max length for group names." - def max_group_name_length, do: @max_group_name_length - - @doc "Max length for group slugs." - def max_group_slug_length, do: @max_group_slug_length -end diff --git a/lib/modules/publishing/db_storage.ex b/lib/modules/publishing/db_storage.ex deleted file mode 100644 index 67c0e1fc9..000000000 --- a/lib/modules/publishing/db_storage.ex +++ /dev/null @@ -1,849 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.DBStorage do - @moduledoc """ - Database storage layer for the Publishing module. - - Provides CRUD operations for publishing groups, posts, versions, and contents - via PostgreSQL with Ecto. - """ - - import Ecto.Query - - alias PhoenixKit.Modules.Publishing.DBStorage.Mapper - alias PhoenixKit.Modules.Publishing.PublishingContent - alias PhoenixKit.Modules.Publishing.PublishingGroup - alias PhoenixKit.Modules.Publishing.PublishingPost - alias PhoenixKit.Modules.Publishing.PublishingVersion - - require Logger - - defp repo, do: PhoenixKit.RepoHelper.repo() - - # =========================================================================== - # Groups - # =========================================================================== - - @doc "Creates a publishing group." - def create_group(attrs) do - %PublishingGroup{} - |> PublishingGroup.changeset(attrs) - |> repo().insert() - end - - @doc "Updates a publishing group." - def update_group(%PublishingGroup{} = group, attrs) do - group - |> PublishingGroup.changeset(attrs) - |> repo().update() - end - - @doc "Gets a group by slug." - def get_group_by_slug(slug) do - repo().get_by(PublishingGroup, slug: slug) - end - - @doc "Gets a group by UUID." - def get_group(uuid) do - repo().get(PublishingGroup, uuid) - end - - @doc "Lists groups ordered by position. Filters by status (default: active only)." - def list_groups(status \\ "active") do - query = from(g in PublishingGroup, order_by: [asc: g.position, asc: g.name]) - - if status do - where(query, [g], g.status == ^status) - else - query - end - |> repo().all() - end - - @doc "Trashes a group by setting status to 'trashed'." - def trash_group(%PublishingGroup{} = group) do - update_group(group, %{status: "trashed"}) - end - - @doc "Restores a trashed group by setting status to 'active'." - def restore_group(%PublishingGroup{} = group) do - update_group(group, %{status: "active"}) - end - - @doc "Upserts a group by slug." - def upsert_group(attrs) do - slug = Map.get(attrs, :slug) || Map.get(attrs, "slug") - - case get_group_by_slug(slug) do - nil -> create_group(attrs) - group -> update_group(group, attrs) - end - end - - @doc "Deletes a group and all its posts (cascade)." - def delete_group(%PublishingGroup{} = group) do - repo().delete(group) - end - - # =========================================================================== - # Posts - # =========================================================================== - - @doc "Creates a post within a group." - def create_post(attrs) do - %PublishingPost{} - |> PublishingPost.changeset(attrs) - |> repo().insert() - end - - @doc "Updates a post." - def update_post(%PublishingPost{} = post, attrs) do - post - |> PublishingPost.changeset(attrs) - |> repo().update() - end - - @doc "Gets a post by group slug and post slug. Excludes trashed posts." - def get_post(group_slug, post_slug) do - from(p in PublishingPost, - join: g in assoc(p, :group), - where: g.slug == ^group_slug and p.slug == ^post_slug and p.status != "trashed", - preload: [group: g] - ) - |> repo().one() - end - - @doc """ - Gets a timestamp-mode post by date and time. - - Truncates seconds from the input time since URLs use HH:MM format only, - and new posts are stored with seconds zeroed. For older posts with non-zero - seconds, falls back to hour:minute matching. - """ - def get_post_by_datetime(group_slug, %Date{} = date, %Time{} = time) do - # Normalize to zero seconds (URLs only carry HH:MM) - normalized_time = %Time{hour: time.hour, minute: time.minute, second: 0, microsecond: {0, 0}} - - # Try exact match first (fast, uses index, works for all properly-stored posts) - result = - from(p in PublishingPost, - join: g in assoc(p, :group), - where: - g.slug == ^group_slug and p.post_date == ^date and p.post_time == ^normalized_time and - p.status != "trashed", - preload: [group: g] - ) - |> repo().one() - - if result do - result - else - # Fallback for older posts stored with non-zero seconds - hour = time.hour - minute = time.minute - - from(p in PublishingPost, - join: g in assoc(p, :group), - where: - g.slug == ^group_slug and p.post_date == ^date and p.status != "trashed" and - fragment( - "EXTRACT(HOUR FROM ?)::integer = ? AND EXTRACT(MINUTE FROM ?)::integer = ?", - p.post_time, - ^hour, - p.post_time, - ^minute - ), - order_by: [asc: p.post_time], - limit: 1, - preload: [group: g] - ) - |> repo().one() - end - end - - @doc "Gets a post by UUID with preloads." - def get_post_by_uuid(uuid, preloads \\ []) do - PublishingPost - |> repo().get(uuid) - |> maybe_preload(preloads) - end - - @doc "Lists posts in a group, optionally filtered by status. Excludes trashed by default." - def list_posts(group_slug, status \\ nil) do - query = - from(p in PublishingPost, - join: g in assoc(p, :group), - where: g.slug == ^group_slug, - preload: [group: g] - ) - - query = - if status do - where(query, [p], p.status == ^status) - else - where(query, [p], p.status != "trashed") - end - - query - |> order_by_mode() - |> repo().all() - end - - @doc "Counts non-trashed posts in a group." - def count_posts(group_slug) do - from(p in PublishingPost, - join: g in assoc(p, :group), - where: g.slug == ^group_slug and p.status != "trashed", - select: count(p.uuid) - ) - |> repo().one() || 0 - end - - @doc """ - Lists posts in timestamp mode (ordered by date/time desc). - - Options: - * `:date` - Filter to a specific date (Date struct or ISO 8601 string) - """ - def list_posts_timestamp_mode(group_slug, status \\ nil, opts \\ []) do - query = - from(p in PublishingPost, - join: g in assoc(p, :group), - where: g.slug == ^group_slug, - order_by: [desc: p.post_date, desc: p.post_time], - preload: [group: g] - ) - - query = - if status do - where(query, [p], p.status == ^status) - else - query - end - - query = - case Keyword.get(opts, :date) do - nil -> - query - - %Date{} = date -> - where(query, [p], p.post_date == ^date) - - date_string when is_binary(date_string) -> - where(query, [p], p.post_date == ^Date.from_iso8601!(date_string)) - end - - repo().all(query) - end - - @doc "Lists posts in slug mode (ordered by slug asc)." - def list_posts_slug_mode(group_slug, status \\ nil) do - query = - from(p in PublishingPost, - join: g in assoc(p, :group), - where: g.slug == ^group_slug, - order_by: [asc: p.slug], - preload: [group: g] - ) - - if status do - where(query, [p], p.status == ^status) - else - query - end - |> repo().all() - end - - @doc "Finds a post by date and time (timestamp mode, matches hour:minute only)." - def find_post_by_date_time(group_slug, date, time) do - # Delegate to get_post_by_datetime which handles normalization and fallback - get_post_by_datetime(group_slug, date, time) - end - - @doc "Trashes a post by setting status to 'trashed'." - # Uses Ecto.Changeset.change/2 instead of the full changeset to avoid - # slug validation errors on posts with nil/blank slugs. - def trash_post(%PublishingPost{} = post) do - post - |> Ecto.Changeset.change(status: "trashed") - |> repo().update() - end - - @doc "Hard-deletes a post and all its versions/contents (cascade)." - def delete_post(%PublishingPost{} = post) do - repo().delete(post) - end - - @doc """ - Counts posts by primary language status for a group. - - Returns `%{current: n, needs_migration: n, needs_backfill: n}` where: - - `current` — primary_language matches the global setting - - `needs_migration` — primary_language is set but differs from global - - `needs_backfill` — primary_language is nil - """ - def count_primary_language_status(group_slug, global_primary) do - posts = list_posts(group_slug) - count_primary_language_status_from_posts(posts, global_primary) - end - - @doc """ - Counts primary language status from an already-loaded list of posts. - Avoids re-querying when posts are already available. - - Posts can be DB structs or maps (with `:primary_language` key). - """ - def count_primary_language_status_from_posts(posts, global_primary) do - Enum.reduce(posts, %{current: 0, needs_migration: 0, needs_backfill: 0}, fn post, acc -> - primary_lang = Map.get(post, :primary_language) - - cond do - is_nil(primary_lang) -> - %{acc | needs_backfill: acc.needs_backfill + 1} - - primary_lang == global_primary -> - %{acc | current: acc.current + 1} - - true -> - %{acc | needs_migration: acc.needs_migration + 1} - end - end) - end - - @doc """ - Updates all posts in a group to use the given primary language. - - Returns `{:ok, count}` with the number of updated posts. - """ - def update_primary_language(group_slug, primary_language) do - group = get_group_by_slug(group_slug) - - if group do - {count, _} = - from(p in PublishingPost, - where: - p.group_uuid == ^group.uuid and - (is_nil(p.primary_language) or p.primary_language != ^primary_language) - ) - |> repo().update_all( - set: [primary_language: primary_language, updated_at: DateTime.utc_now()] - ) - - {:ok, count} - else - {:ok, 0} - end - end - - @doc "Counts posts needing primary language update in a group." - def count_posts_needing_language_update(group_slug, primary_language) do - group = get_group_by_slug(group_slug) - - if group do - from(p in PublishingPost, - where: - p.group_uuid == ^group.uuid and - (is_nil(p.primary_language) or p.primary_language != ^primary_language), - select: count(p.uuid) - ) - |> repo().one() || 0 - else - 0 - end - end - - # =========================================================================== - # Versions - # =========================================================================== - - @doc "Creates a new version for a post." - def create_version(attrs) do - %PublishingVersion{} - |> PublishingVersion.changeset(attrs) - |> repo().insert() - end - - @doc "Updates a version." - def update_version(%PublishingVersion{} = version, attrs) do - version - |> PublishingVersion.changeset(attrs) - |> repo().update() - end - - @doc "Gets the latest version for a post." - def get_latest_version(post_uuid) do - from(v in PublishingVersion, - where: v.post_uuid == ^post_uuid, - order_by: [desc: v.version_number], - limit: 1 - ) - |> repo().one() - end - - @doc "Gets a specific version by post and version number." - def get_version(post_uuid, version_number) do - repo().get_by(PublishingVersion, - post_uuid: post_uuid, - version_number: version_number - ) - end - - @doc "Lists all versions for a post, ordered by version number." - def list_versions(post_uuid) do - from(v in PublishingVersion, - where: v.post_uuid == ^post_uuid, - order_by: [asc: v.version_number] - ) - |> repo().all() - end - - @doc """ - Gets the next version number for a post. - - Uses SELECT ... FOR UPDATE to lock the row and prevent concurrent reads - from getting the same number. - """ - def next_version_number(post_uuid) do - # Lock existing version rows to prevent concurrent inserts, - # then compute max in Elixir. FOR UPDATE cannot be combined - # with aggregate functions in PostgreSQL. - versions = - from(v in PublishingVersion, - where: v.post_uuid == ^post_uuid, - select: v.version_number, - lock: "FOR UPDATE" - ) - |> repo().all() - - Enum.max(versions, fn -> 0 end) + 1 - end - - @doc """ - Creates a new version by cloning content from a source version. - - Creates a new version row and copies all content rows from the source. - Wrapped in a transaction for atomicity. - - Returns `{:ok, %PublishingVersion{}}` or `{:error, reason}`. - """ - def create_version_from(post_uuid, source_version_number, opts \\ %{}) do - repo().transaction(fn -> - source_version = get_version(post_uuid, source_version_number) - unless source_version, do: repo().rollback(:source_not_found) - - new_version = do_create_cloned_version(post_uuid, source_version, opts) - copy_contents_to_version(source_version.uuid, new_version.uuid) - new_version - end) - end - - defp do_create_cloned_version(post_uuid, source_version, opts) do - new_number = next_version_number(post_uuid) - - case create_version(%{ - post_uuid: post_uuid, - version_number: new_number, - status: "draft", - created_by_uuid: opts[:created_by_uuid], - data: %{"created_from" => source_version.version_number} - }) do - {:ok, new_version} -> new_version - {:error, reason} -> repo().rollback(reason) - end - end - - defp copy_contents_to_version(source_version_uuid, target_version_uuid) do - now = DateTime.utc_now() |> DateTime.truncate(:second) - - rows = - list_contents(source_version_uuid) - |> Enum.map(fn content -> - %{ - uuid: UUIDv7.generate(), - version_uuid: target_version_uuid, - language: content.language, - title: content.title || "", - content: content.content || "", - status: "draft", - url_slug: content.url_slug, - data: content.data || %{}, - inserted_at: now, - updated_at: now - } - end) - - if rows != [] do - case repo().insert_all(PublishingContent, rows, on_conflict: :nothing) do - {count, _} when count >= 0 -> :ok - _ -> repo().rollback(:content_copy_failed) - end - end - end - - # =========================================================================== - # Contents - # =========================================================================== - - @doc "Creates content for a version/language." - def create_content(attrs) do - %PublishingContent{} - |> PublishingContent.changeset(attrs) - |> repo().insert() - end - - @doc "Updates content." - def update_content(%PublishingContent{} = content, attrs) do - content - |> PublishingContent.changeset(attrs) - |> repo().update() - end - - @doc "Bulk-updates the status of all content rows for a version." - def update_content_status(version_uuid, new_status) do - from(c in PublishingContent, where: c.version_uuid == ^version_uuid) - |> repo().update_all(set: [status: new_status, updated_at: DateTime.utc_now()]) - end - - @doc "Bulk-updates the status of all content rows for a version, excluding a specific language." - def update_content_status_except(version_uuid, exclude_language, new_status) do - from(c in PublishingContent, - where: c.version_uuid == ^version_uuid and c.language != ^exclude_language - ) - |> repo().update_all(set: [status: new_status, updated_at: DateTime.utc_now()]) - end - - @doc "Gets content for a specific version and language." - def get_content(version_uuid, language) do - repo().get_by(PublishingContent, - version_uuid: version_uuid, - language: language - ) - end - - @doc "Lists all content rows for a version." - def list_contents(version_uuid) do - from(c in PublishingContent, - where: c.version_uuid == ^version_uuid, - order_by: [asc: c.language] - ) - |> repo().all() - end - - @doc "Lists available languages for a version." - def list_languages(version_uuid) do - from(c in PublishingContent, - where: c.version_uuid == ^version_uuid, - select: c.language, - order_by: [asc: c.language] - ) - |> repo().all() - end - - @doc "Finds content by URL slug across all versions in a group. Excludes trashed posts." - def find_by_url_slug(group_slug, language, url_slug) do - # Try matching by content url_slug first - result = - from(c in PublishingContent, - join: v in assoc(c, :version), - join: p in assoc(v, :post), - join: g in assoc(p, :group), - where: - g.slug == ^group_slug and c.language == ^language and c.url_slug == ^url_slug and - p.status != "trashed", - preload: [version: {v, post: {p, group: g}}] - ) - |> repo().one() - - # Fallback: if no custom url_slug match, try matching by post.slug - # (content rows with NULL/empty url_slug use the post slug as their public URL) - result || - from(c in PublishingContent, - join: v in assoc(c, :version), - join: p in assoc(v, :post), - join: g in assoc(p, :group), - where: - g.slug == ^group_slug and c.language == ^language and p.slug == ^url_slug and - p.status != "trashed" and - (is_nil(c.url_slug) or c.url_slug == ""), - preload: [version: {v, post: {p, group: g}}] - ) - |> repo().one() - end - - @doc "Finds content by a previous URL slug (stored in data.previous_url_slugs JSONB array). Excludes trashed posts." - def find_by_previous_url_slug(group_slug, language, url_slug) do - from(c in PublishingContent, - join: v in assoc(c, :version), - join: p in assoc(v, :post), - join: g in assoc(p, :group), - where: - g.slug == ^group_slug and - c.language == ^language and - p.status != "trashed" and - fragment("? @> ?", c.data, ^%{"previous_url_slugs" => [url_slug]}), - preload: [version: {v, post: {p, group: g}}] - ) - |> repo().one() - end - - @doc "Clears a specific url_slug from all content rows of a post. Returns cleared language codes." - def clear_url_slug_from_post(group_slug, post_slug, url_slug_to_clear) do - case get_post(group_slug, post_slug) do - nil -> - [] - - db_post -> - # Find affected languages, then bulk-clear url_slugs - contents = - from(c in PublishingContent, - join: v in assoc(c, :version), - where: v.post_uuid == ^db_post.uuid and c.url_slug == ^url_slug_to_clear, - select: {c, c.language} - ) - |> repo().all() - - # Bulk clear in one query - from(c in PublishingContent, - join: v in assoc(c, :version), - where: v.post_uuid == ^db_post.uuid and c.url_slug == ^url_slug_to_clear - ) - |> repo().update_all(set: [url_slug: nil, updated_at: DateTime.utc_now()]) - - Enum.map(contents, fn {_content, lang} -> lang end) |> Enum.uniq() - end - end - - @doc "Upserts content by version_id + language using ON CONFLICT." - def upsert_content(attrs) do - changeset = PublishingContent.changeset(%PublishingContent{}, attrs) - - repo().insert(changeset, - on_conflict: {:replace, [:title, :content, :status, :url_slug, :data, :updated_at]}, - conflict_target: [:version_uuid, :language], - returning: true - ) - end - - # =========================================================================== - # Compound Operations - # =========================================================================== - - @doc """ - Reads a full post with its latest version and content for a specific language. - - Returns a post map or nil if not found. - """ - def read_post(group_slug, post_slug, language \\ nil, version_number \\ nil) do - with post when not is_nil(post) <- get_post(group_slug, post_slug), - version when not is_nil(version) <- resolve_version(post, version_number), - contents <- list_contents(version.uuid), - content when not is_nil(content) <- resolve_content(contents, language, post) do - all_versions = list_versions(post.uuid) - - {:ok, Mapper.to_post_map(post, version, content, contents, all_versions)} - else - nil -> {:error, :not_found} - end - end - - @doc """ - Reads a timestamp-mode post by date and time instead of slug. - """ - def read_post_by_datetime(group_slug, date, time, language \\ nil, version_number \\ nil) do - with post when not is_nil(post) <- get_post_by_datetime(group_slug, date, time), - version when not is_nil(version) <- resolve_version(post, version_number), - contents <- list_contents(version.uuid), - content when not is_nil(content) <- resolve_content(contents, language, post) do - all_versions = list_versions(post.uuid) - - {:ok, Mapper.to_post_map(post, version, content, contents, all_versions)} - else - nil -> {:error, :not_found} - end - end - - @doc """ - Lists all posts in a group with their latest version metadata. - - Returns a list of post maps suitable for listing pages. - """ - def list_posts_with_metadata(group_slug, status \\ nil) do - posts = if status, do: list_posts(group_slug, status), else: list_posts(group_slug) - post_uuids = Enum.map(posts, & &1.uuid) - - # Batch-load ALL versions for all posts in one query - all_versions_by_post = batch_load_versions(post_uuids) - - # Find latest version per post and collect all version UUIDs we need contents for - latest_by_post = - Map.new(all_versions_by_post, fn {post_uuid, versions} -> - {post_uuid, List.last(versions)} - end) - - # Also find published versions that differ from latest (for status overlay) - published_by_post = - Map.new(all_versions_by_post, fn {post_uuid, versions} -> - {post_uuid, Enum.find(versions, fn v -> v.status == "published" end)} - end) - - # Collect all version UUIDs we need contents for (latest + published if different) - version_uuids_needed = - Enum.flat_map(posts, fn post -> - latest = latest_by_post[post.uuid] - published = published_by_post[post.uuid] - - [latest, published] - |> Enum.reject(&is_nil/1) - |> Enum.uniq_by(& &1.uuid) - |> Enum.map(& &1.uuid) - end) - - # Batch-load ALL contents for all needed versions in one query - all_contents_by_version = batch_load_contents(version_uuids_needed) - - Enum.map(posts, fn post -> - all_versions = Map.get(all_versions_by_post, post.uuid, []) - version = latest_by_post[post.uuid] - - if version do - contents = Map.get(all_contents_by_version, version.uuid, []) - published_version = published_by_post[post.uuid] - - published_statuses = - build_published_statuses(published_version, version, all_contents_by_version) - - primary_content = resolve_content(contents, nil, post) - - if primary_content do - Mapper.to_post_map(post, version, primary_content, contents, all_versions, - published_language_statuses: published_statuses - ) - else - Mapper.to_listing_map(post, version, contents, all_versions, - published_language_statuses: published_statuses - ) - end - else - Mapper.to_listing_map(post, nil, [], []) - end - end) - end - - @doc """ - Lists all posts in a group in listing format (excerpt only, no full content). - - Always uses `Mapper.to_listing_map/4` which strips content bodies and includes - only excerpts. Designed for caching in `:persistent_term` where data is copied - to the reading process heap — keeping entries small matters. - """ - def list_posts_for_listing(group_slug) do - posts = list_posts(group_slug) - post_uuids = Enum.map(posts, & &1.uuid) - - all_versions_by_post = batch_load_versions(post_uuids) - - latest_by_post = - Map.new(all_versions_by_post, fn {post_uuid, versions} -> - {post_uuid, List.last(versions)} - end) - - published_by_post = - Map.new(all_versions_by_post, fn {post_uuid, versions} -> - {post_uuid, Enum.find(versions, fn v -> v.status == "published" end)} - end) - - version_uuids_needed = - Enum.flat_map(posts, fn post -> - latest = latest_by_post[post.uuid] - published = published_by_post[post.uuid] - - [latest, published] - |> Enum.reject(&is_nil/1) - |> Enum.uniq_by(& &1.uuid) - |> Enum.map(& &1.uuid) - end) - - all_contents_by_version = batch_load_contents(version_uuids_needed) - - Enum.map(posts, fn post -> - all_versions = Map.get(all_versions_by_post, post.uuid, []) - version = latest_by_post[post.uuid] - - if version do - contents = Map.get(all_contents_by_version, version.uuid, []) - published_version = published_by_post[post.uuid] - - published_statuses = - build_published_statuses(published_version, version, all_contents_by_version) - - Mapper.to_listing_map(post, version, contents, all_versions, - published_language_statuses: published_statuses - ) - else - Mapper.to_listing_map(post, nil, [], []) - end - end) - end - - # =========================================================================== - # Private Helpers - # =========================================================================== - - defp resolve_version(post, nil), do: get_latest_version(post.uuid) - defp resolve_version(post, version_number), do: get_version(post.uuid, version_number) - - defp build_published_statuses(published_version, latest_version, all_contents_by_version) do - if published_version && published_version.uuid != latest_version.uuid do - Map.get(all_contents_by_version, published_version.uuid, []) - |> Map.new(fn c -> {c.language, c.status} end) - else - %{} - end - end - - defp resolve_content(contents, nil, post) do - # No language specified — use primary language, then any available - Enum.find(contents, fn c -> c.language == post.primary_language end) || - List.first(contents) - end - - defp resolve_content(contents, language, post) do - # Try exact language match first, fall back to primary language, then any available. - # This handles cases where the DB has partial content (e.g., only 3 of 39 languages - # were imported) but the editor requests the primary language. - Enum.find(contents, fn c -> c.language == language end) || - Enum.find(contents, fn c -> c.language == post.primary_language end) || - List.first(contents) - end - - defp order_by_mode(query) do - # Default ordering: published_at desc, then inserted_at desc - order_by(query, [p], desc: p.published_at, desc: p.inserted_at) - end - - @doc false - def batch_load_versions([]), do: %{} - - def batch_load_versions(post_uuids) do - from(v in PublishingVersion, - where: v.post_uuid in ^post_uuids, - order_by: [asc: v.version_number] - ) - |> repo().all() - |> Enum.group_by(& &1.post_uuid) - end - - @doc false - def batch_load_contents([]), do: %{} - - def batch_load_contents(version_uuids) do - from(c in PublishingContent, - where: c.version_uuid in ^version_uuids, - order_by: [asc: c.language] - ) - |> repo().all() - |> Enum.group_by(& &1.version_uuid) - end - - defp maybe_preload(nil, _preloads), do: nil - defp maybe_preload(record, []), do: record - defp maybe_preload(record, preloads), do: repo().preload(record, preloads) -end diff --git a/lib/modules/publishing/db_storage/mapper.ex b/lib/modules/publishing/db_storage/mapper.ex deleted file mode 100644 index 42f840bd9..000000000 --- a/lib/modules/publishing/db_storage/mapper.ex +++ /dev/null @@ -1,254 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.DBStorage.Mapper do - @moduledoc """ - Mapper: converts DB records to the map format expected by - Publishing's web layer (LiveViews, templates, controllers). - - ## Map Shape - - The web layer expects maps with these keys: - - `:group` - group slug - - `:slug` - post slug identifier - - `:url_slug` - per-language URL slug - - `:date` - Date struct (timestamp mode) - - `:time` - Time struct (timestamp mode) - - `:mode` - :timestamp or :slug atom - - `:language` - current language code - - `:available_languages` - list of language codes - - `:language_statuses` - %{language => status} - - `:version` - current version number - - `:available_versions` - list of version numbers - - `:version_statuses` - %{version_number => status} - - `:version_dates` - %{version_number => date_string} - - `:content` - markdown/PHK body - - `:metadata` - map with :title, :description, :status, :slug, etc. - - `:primary_language` - primary language code - """ - - alias PhoenixKit.Modules.Publishing.PublishingContent - alias PhoenixKit.Modules.Publishing.PublishingPost - alias PhoenixKit.Modules.Publishing.PublishingVersion - - @doc """ - Converts a full post read (post + version + content + all contents + all versions) - into the map format expected by the web layer. - """ - def to_post_map( - %PublishingPost{} = post, - %PublishingVersion{} = version, - %PublishingContent{} = content, - all_contents, - all_versions, - opts \\ [] - ) do - available_languages = Enum.map(all_contents, & &1.language) |> Enum.sort() - - language_statuses = - Map.new(all_contents, fn c -> {c.language, c.status} end) - |> merge_published_statuses(Keyword.get(opts, :published_language_statuses, %{})) - - available_versions = Enum.map(all_versions, & &1.version_number) |> Enum.sort() - - version_statuses = - Map.new(all_versions, fn v -> {v.version_number, v.status} end) - - version_dates = - Map.new(all_versions, fn v -> - {v.version_number, format_datetime(v.inserted_at)} - end) - - group_slug = get_group_slug(post) - - %{ - uuid: post.uuid, - group: group_slug, - slug: post.slug, - url_slug: presence(content.url_slug) || post.slug, - date: post.post_date, - time: post.post_time, - mode: safe_mode_atom(post.mode), - language: content.language, - available_languages: available_languages, - language_statuses: language_statuses, - language_slugs: build_language_slugs(all_contents, post.slug), - language_previous_slugs: build_language_previous_slugs(all_contents), - version: version.version_number, - available_versions: available_versions, - version_statuses: version_statuses, - version_dates: version_dates, - content: content.content, - content_updated_at: content.updated_at, - metadata: build_metadata(post, version, content), - primary_language: post.primary_language - } - end - - @doc """ - Converts a post to a listing-format map (no content body, just metadata). - Used for listing pages where full content isn't needed. - """ - def to_listing_map(%PublishingPost{} = post, version, all_contents, all_versions, opts \\ []) do - available_languages = Enum.map(all_contents, & &1.language) |> Enum.sort() - - language_statuses = - Map.new(all_contents, fn c -> {c.language, c.status} end) - |> merge_published_statuses(Keyword.get(opts, :published_language_statuses, %{})) - - available_versions = Enum.map(all_versions, & &1.version_number) |> Enum.sort() - - version_statuses = - Map.new(all_versions, fn v -> {v.version_number, v.status} end) - - version_dates = - Map.new(all_versions, fn v -> - {v.version_number, format_datetime(v.inserted_at)} - end) - - primary_content = - Enum.find(all_contents, fn c -> c.language == post.primary_language end) || - List.first(all_contents) - - group_slug = get_group_slug(post) - current_version = if version, do: version.version_number, else: 1 - - %{ - uuid: post.uuid, - group: group_slug, - slug: post.slug, - url_slug: presence(primary_content && primary_content.url_slug) || post.slug, - date: post.post_date, - time: post.post_time, - mode: safe_mode_atom(post.mode), - language: post.primary_language, - available_languages: available_languages, - language_statuses: language_statuses, - language_slugs: build_language_slugs(all_contents, post.slug), - language_previous_slugs: build_language_previous_slugs(all_contents), - version: current_version, - available_versions: available_versions, - version_statuses: version_statuses, - version_dates: version_dates, - content: primary_content && extract_excerpt(primary_content), - metadata: build_listing_metadata(post, primary_content), - primary_language: post.primary_language, - # Per-language data for listing pages (so language switching shows correct titles) - language_titles: Map.new(all_contents, fn c -> {c.language, c.title} end), - language_excerpts: Map.new(all_contents, fn c -> {c.language, extract_excerpt(c)} end) - } - end - - # =========================================================================== - # Private Helpers - # =========================================================================== - - defp get_group_slug(%PublishingPost{group: %{slug: slug}}), do: slug - defp get_group_slug(%PublishingPost{} = _post), do: nil - - defp build_metadata(post, version, content) do - %{ - title: content.title, - description: PublishingContent.get_description(content), - status: content.status, - slug: post.slug, - version: version.version_number, - allow_version_access: PublishingPost.allow_version_access?(post), - url_slug: content.url_slug, - previous_url_slugs: PublishingContent.get_previous_url_slugs(content), - published_at: format_datetime(post.published_at), - featured_image_uuid: PublishingContent.get_featured_image_uuid(content), - primary_language: post.primary_language - } - end - - defp build_listing_metadata(post, nil) do - %{ - title: nil, - description: nil, - status: post.status, - slug: post.slug, - published_at: format_datetime(post.published_at), - featured_image_uuid: nil, - primary_language: post.primary_language - } - end - - defp build_listing_metadata(post, content) do - %{ - title: content.title, - description: PublishingContent.get_description(content), - status: content.status, - slug: post.slug, - published_at: format_datetime(post.published_at), - featured_image_uuid: PublishingContent.get_featured_image_uuid(content), - primary_language: post.primary_language - } - end - - defp build_language_slugs(all_contents, default_slug) do - Map.new(all_contents, fn c -> - {c.language, presence(c.url_slug) || default_slug} - end) - end - - defp build_language_previous_slugs(all_contents) do - Map.new(all_contents, fn c -> - {c.language, PublishingContent.get_previous_url_slugs(c)} - end) - end - - defp extract_excerpt(%PublishingContent{} = content) do - # Use custom excerpt from data, or description, or first paragraph - case PublishingContent.get_excerpt(content) do - excerpt when is_binary(excerpt) and excerpt != "" -> - excerpt - - _ -> - case PublishingContent.get_description(content) do - desc when is_binary(desc) and desc != "" -> - desc - - _ -> - extract_first_paragraph(content.content) - end - end - end - - defp extract_first_paragraph(nil), do: nil - - defp extract_first_paragraph(content) when is_binary(content) do - content - |> String.split(~r/\n\n+/) - |> Enum.reject(&String.starts_with?(&1, "#")) - |> List.first() - |> case do - nil -> "" - text -> text |> String.trim() |> String.slice(0, 300) - end - end - - defp format_datetime(nil), do: nil - defp format_datetime(%DateTime{} = dt), do: DateTime.to_iso8601(dt) - defp format_datetime(other), do: to_string(other) - - # Merges published version's language statuses into the latest version's statuses. - # For each language, if the published version has it as "published", override the - # latest version's status. This ensures the listing page shows "published" when - # a language is live on an older version even if the latest draft doesn't have it published. - defp merge_published_statuses(latest_statuses, published_statuses) - when map_size(published_statuses) == 0, - do: latest_statuses - - defp merge_published_statuses(latest_statuses, published_statuses) do - Map.merge(latest_statuses, published_statuses, fn _lang, latest, published -> - if published == "published", do: "published", else: latest - end) - end - - defp safe_mode_atom("timestamp"), do: :timestamp - defp safe_mode_atom("slug"), do: :slug - defp safe_mode_atom(_), do: :timestamp - - # Returns nil for nil and empty string, otherwise the value - defp presence(nil), do: nil - defp presence(""), do: nil - defp presence(value), do: value -end diff --git a/lib/modules/publishing/groups.ex b/lib/modules/publishing/groups.ex deleted file mode 100644 index 5c998d389..000000000 --- a/lib/modules/publishing/groups.ex +++ /dev/null @@ -1,505 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.Groups do - @moduledoc """ - Group management functions for the Publishing module. - - Handles creating, listing, updating, and removing publishing groups, - as well as slug generation, type/mode normalization, and item naming. - """ - - require Logger - - alias PhoenixKit.Modules.Publishing - alias PhoenixKit.Modules.Publishing.DBStorage - alias PhoenixKit.Modules.Publishing.ListingCache - alias PhoenixKit.Modules.Publishing.PubSub, as: PublishingPubSub - alias PhoenixKit.Modules.Publishing.Shared - alias PhoenixKit.Modules.Publishing.StaleFixer - - alias PhoenixKit.Modules.Publishing.Constants - - @default_group_mode Constants.default_mode() - @default_group_type Constants.default_type() - @preset_types Constants.preset_types() - @valid_types Constants.valid_types() - @type_regex ~r/^[a-z][a-z0-9-]{0,31}$/ - - @type_item_names %{ - "blog" => {"post", "posts"}, - "faq" => {"question", "questions"}, - "legal" => {"document", "documents"} - } - @default_item_singular "item" - @default_item_plural "items" - - @type group :: map() - - @doc """ - Returns all publishing groups from the database. - """ - @spec list_groups() :: [group()] - def list_groups do - DBStorage.list_groups() - |> Enum.map(fn group -> group |> StaleFixer.fix_stale_group() |> db_group_to_map() end) - end - - @doc "Lists groups filtered by status (e.g. 'active', 'trashed')." - @spec list_groups(String.t()) :: [group()] - def list_groups(status) do - DBStorage.list_groups(status) - |> Enum.map(fn group -> group |> StaleFixer.fix_stale_group() |> db_group_to_map() end) - end - - @doc """ - Gets a publishing group by slug. - - ## Examples - - iex> Groups.get_group("news") - {:ok, %{"name" => "News", "slug" => "news", ...}} - - iex> Groups.get_group("nonexistent") - {:error, :not_found} - """ - @spec get_group(String.t()) :: {:ok, group()} | {:error, :not_found} - def get_group(slug) when is_binary(slug) do - case DBStorage.get_group_by_slug(slug) do - nil -> {:error, :not_found} - db_group -> {:ok, db_group |> StaleFixer.fix_stale_group() |> db_group_to_map()} - end - end - - @doc """ - Adds a new publishing group. - - ## Parameters - - * `name` - Display name for the group - * `opts` - Keyword list or map with options: - * `:mode` - Post mode: "timestamp" or "slug" (default: "timestamp") - * `:slug` - Optional custom slug, auto-generated from name if nil - * `:type` - Content type: "blog", "faq", "legal", or custom (default: "blog") - * `:item_singular` - Singular name for items (default: based on type, e.g., "post") - * `:item_plural` - Plural name for items (default: based on type, e.g., "posts") - - ## Examples - - iex> Groups.add_group("News") - {:ok, %{"name" => "News", "slug" => "news", "mode" => "timestamp", "type" => "blog", ...}} - - iex> Groups.add_group("FAQ", type: "faq", mode: "slug") - {:ok, %{"name" => "FAQ", "slug" => "faq", "mode" => "slug", "type" => "faq", "item_singular" => "question", ...}} - - iex> Groups.add_group("Recipes", type: "custom", item_singular: "recipe", item_plural: "recipes") - {:ok, %{"name" => "Recipes", ..., "item_singular" => "recipe", "item_plural" => "recipes"}} - """ - @spec add_group(String.t(), keyword() | map()) :: {:ok, group()} | {:error, atom()} - def add_group(name, opts \\ []) - - def add_group(name, opts) when is_binary(name) and (is_list(opts) or is_map(opts)) do - trimmed = String.trim(name) - mode = opts |> fetch_option(:mode) |> normalize_mode_with_default() - normalized_type = opts |> fetch_option(:type) |> normalize_type() - - cond do - trimmed == "" -> - {:error, :invalid_name} - - is_nil(mode) -> - {:error, :invalid_mode} - - is_nil(normalized_type) -> - {:error, :invalid_type} - - true -> - groups = list_groups() - preferred_slug = fetch_option(opts, :slug) - - with {:ok, requested_slug} <- derive_requested_slug(preferred_slug, trimmed), - :ok <- check_slug_availability(requested_slug, groups, preferred_slug) do - slug = ensure_unique_slug(requested_slug, groups) - - {default_singular, default_plural} = default_item_names(normalized_type) - - item_singular = - opts - |> fetch_option(:item_singular) - |> normalize_item_name(default_singular) - - item_plural = - opts - |> fetch_option(:item_plural) - |> normalize_item_name(default_plural) - - db_attrs = %{ - name: trimmed, - slug: slug, - mode: mode, - data: %{ - "type" => normalized_type, - "item_singular" => item_singular, - "item_plural" => item_plural - } - } - - case DBStorage.create_group(db_attrs) do - {:ok, db_group} -> - group = db_group_to_map(db_group) - PublishingPubSub.broadcast_group_created(group) - {:ok, group} - - {:error, _changeset} -> - {:error, :already_exists} - end - end - end - end - - @doc """ - Removes a publishing group by slug. - """ - @spec remove_group(String.t()) :: {:ok, any()} | {:error, any()} - def remove_group(slug) when is_binary(slug) do - remove_group(slug, force: false) - end - - @doc """ - Removes a publishing group by slug. - - By default, refuses to delete groups that contain posts. - Pass `force: true` to cascade-delete the group and all its posts. - """ - def remove_group(slug, opts) when is_binary(slug) do - force = Keyword.get(opts, :force, false) - - case DBStorage.get_group_by_slug(slug) do - nil -> - {:error, :not_found} - - db_group -> - post_count = DBStorage.count_posts(db_group.slug) - - if post_count > 0 and not force do - {:error, {:has_posts, post_count}} - else - case DBStorage.delete_group(db_group) do - {:ok, _} -> - ListingCache.invalidate(slug) - PublishingPubSub.broadcast_group_deleted(slug) - {:ok, slug} - - error -> - error - end - end - end - end - - @doc """ - Updates a publishing group's display name and slug. - """ - @spec update_group(String.t(), map() | keyword()) :: {:ok, group()} | {:error, atom()} - def update_group(slug, params) when is_binary(slug) do - case DBStorage.get_group_by_slug(slug) do - nil -> - {:error, :not_found} - - db_group -> - with {:ok, name} <- extract_and_validate_name(db_group, params), - {:ok, sanitized_slug} <- extract_and_validate_slug(db_group, params, name) do - case DBStorage.update_group(db_group, %{name: name, slug: sanitized_slug}) do - {:ok, updated} -> - group = db_group_to_map(updated) - PublishingPubSub.broadcast_group_updated(group) - {:ok, group} - - {:error, _} = error -> - error - end - end - end - end - - @doc """ - Moves a publishing group to trash (soft-delete). - - Sets the group status to "trashed". The group and its posts remain in the - database and can be restored. Trashed groups are hidden from list_groups/0. - """ - @spec trash_group(String.t()) :: {:ok, String.t()} | {:error, any()} - def trash_group(slug) when is_binary(slug) do - case DBStorage.get_group_by_slug(slug) do - nil -> - {:error, :not_found} - - db_group -> - case DBStorage.trash_group(db_group) do - {:ok, _} -> - ListingCache.invalidate(slug) - PublishingPubSub.broadcast_group_deleted(slug) - {:ok, slug} - - {:error, reason} -> - {:error, reason} - end - end - end - - @doc """ - Restores a trashed publishing group. - """ - @spec restore_group(String.t()) :: {:ok, String.t()} | {:error, any()} - def restore_group(slug) when is_binary(slug) do - case DBStorage.get_group_by_slug(slug) do - nil -> - {:error, :not_found} - - db_group -> - # Check if an active group already uses this slug (created while this was trashed) - active_conflict = - DBStorage.list_groups("active") - |> Enum.any?(fn g -> g.slug == slug and g.uuid != db_group.uuid end) - - if active_conflict do - {:error, :slug_taken} - else - case DBStorage.restore_group(db_group) do - {:ok, _} -> - ListingCache.regenerate(slug) - - PublishingPubSub.broadcast_group_created(%{ - "slug" => slug, - "name" => db_group.name - }) - - {:ok, slug} - - {:error, reason} -> - {:error, reason} - end - end - end - end - - @doc """ - Lists trashed publishing groups. - """ - @spec list_trashed_groups() :: [map()] - def list_trashed_groups do - DBStorage.list_groups("trashed") - |> Enum.map(&db_group_to_map/1) - end - - @doc """ - Looks up a publishing group name from its slug. - """ - @spec group_name(String.t()) :: String.t() | nil - def group_name(slug) do - case DBStorage.get_group_by_slug(slug) do - nil -> nil - db_group -> db_group.name - end - end - - @doc """ - Returns the configured post mode for a publishing group slug. - """ - @spec get_group_mode(String.t()) :: String.t() - def get_group_mode(group_slug) do - case DBStorage.get_group_by_slug(group_slug) do - nil -> @default_group_mode - db_group -> db_group.mode || @default_group_mode - end - end - - @doc """ - Returns the preset content types with their default item names. - """ - @spec preset_types() :: [map()] - def preset_types do - [ - %{type: "blog", label: "Blog", item_singular: "post", item_plural: "posts"}, - %{type: "faq", label: "FAQ", item_singular: "question", item_plural: "questions"}, - %{type: "legal", label: "Legal", item_singular: "document", item_plural: "documents"} - ] - end - - @doc """ - Returns the list of valid group type values. - """ - @spec valid_types() :: [String.t()] - def valid_types, do: @valid_types - - # ============================================================================ - # Private Helpers - # ============================================================================ - - defp extract_and_validate_name(db_group, params) do - name = - params - |> fetch_option(:name) - |> case do - nil -> db_group.name - value -> String.trim(to_string(value || "")) - end - - if name == "", do: {:error, :invalid_name}, else: {:ok, name} - end - - defp extract_and_validate_slug(db_group, params, name) do - desired_slug = - params - |> fetch_option(:slug) - |> case do - nil -> db_group.slug - value -> String.trim(to_string(value || "")) - end - - cond do - desired_slug == "" -> - auto_slug = Publishing.slugify(name) - - if Publishing.valid_slug?(auto_slug), - do: {:ok, auto_slug}, - else: {:error, :invalid_slug} - - Publishing.valid_slug?(desired_slug) -> - {:ok, desired_slug} - - true -> - {:error, :invalid_slug} - end - end - - defp db_group_to_map(%{name: name, slug: slug, mode: mode, status: status, data: data}) do - %{ - "name" => name, - "slug" => slug, - "mode" => mode || @default_group_mode, - "status" => status || "active", - "type" => Map.get(data, "type", @default_group_type), - "item_singular" => Map.get(data, "item_singular", @default_item_singular), - "item_plural" => Map.get(data, "item_plural", @default_item_plural) - } - end - - defp derive_requested_slug(nil, fallback_name) do - slugified = Publishing.slugify(fallback_name) - if slugified == "", do: {:error, :invalid_slug}, else: {:ok, slugified} - end - - defp derive_requested_slug(slug, fallback_name) when is_binary(slug) do - trimmed = slug |> String.trim() - - cond do - trimmed == "" -> - slugified = Publishing.slugify(fallback_name) - if slugified == "", do: {:error, :invalid_slug}, else: {:ok, slugified} - - Publishing.valid_slug?(trimmed) -> - {:ok, trimmed} - - true -> - {:error, :invalid_slug} - end - end - - defp derive_requested_slug(_other, fallback_name) do - slugified = Publishing.slugify(fallback_name) - if slugified == "", do: {:error, :invalid_slug}, else: {:ok, slugified} - end - - # Check if explicit slug already exists (only when preferred_slug is provided) - defp check_slug_availability(slug, groups, preferred_slug) when not is_nil(preferred_slug) do - if Enum.any?(groups, &(&1["slug"] == slug)) do - {:error, :already_exists} - else - :ok - end - end - - defp check_slug_availability(_slug, _groups, nil), do: :ok - - defp ensure_unique_slug(slug, groups), do: ensure_unique_slug(slug, groups, 2) - - defp ensure_unique_slug(slug, groups, counter) do - if Enum.any?(groups, &(&1["slug"] == slug)) do - ensure_unique_slug("#{slug}-#{counter}", groups, counter + 1) - else - slug - end - end - - defp normalize_mode(mode) when is_binary(mode) do - mode - |> String.downcase() - |> case do - "slug" -> "slug" - "timestamp" -> "timestamp" - _ -> nil - end - end - - defp normalize_mode(mode) when is_atom(mode), do: normalize_mode(Atom.to_string(mode)) - defp normalize_mode(_), do: nil - - # Normalize mode with default fallback - defp normalize_mode_with_default(nil), do: @default_group_mode - defp normalize_mode_with_default(mode), do: normalize_mode(mode) || @default_group_mode - - # Normalize and validate type - # Preset types are passed through, custom types are validated and normalized - defp normalize_type(nil), do: @default_group_type - - defp normalize_type(type) when is_binary(type) do - trimmed = String.trim(type) - downcased = String.downcase(trimmed) - - cond do - # Preset type - pass through as-is - downcased in @preset_types -> - downcased - - # Empty after trim - use default - trimmed == "" -> - @default_group_type - - # Custom type - validate format - true -> - # Normalize: downcase, replace spaces/underscores with hyphens - normalized = - downcased - |> String.replace(~r/[\s_]+/, "-") - |> String.replace(~r/[^a-z0-9-]/, "") - |> String.slice(0, 32) - - # Validate against type regex - if Regex.match?(@type_regex, normalized) do - normalized - else - nil - end - end - end - - defp normalize_type(type) when is_atom(type), do: normalize_type(Atom.to_string(type)) - defp normalize_type(_), do: nil - - # Get default item names for a type - defp default_item_names(type) do - Map.get(@type_item_names, type, {@default_item_singular, @default_item_plural}) - end - - # Normalize item name, using default if nil/empty - defp normalize_item_name(nil, default), do: default - defp normalize_item_name("", default), do: default - - defp normalize_item_name(name, default) when is_binary(name) do - trimmed = String.trim(name) - if trimmed == "", do: default, else: trimmed - end - - defp normalize_item_name(_, default), do: default - - @doc false - defdelegate fetch_option(opts, key), to: Shared -end diff --git a/lib/modules/publishing/language_helpers.ex b/lib/modules/publishing/language_helpers.ex deleted file mode 100644 index 043831604..000000000 --- a/lib/modules/publishing/language_helpers.ex +++ /dev/null @@ -1,280 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.LanguageHelpers do - @moduledoc """ - Pure language utility functions for the Publishing module. - - Provides language detection, display ordering, language info lookup, - and primary language management. - """ - - alias PhoenixKit.Modules.Languages - alias PhoenixKit.Modules.Languages.DialectMapper - alias PhoenixKit.Settings - - @doc """ - Returns all enabled language codes for multi-language support. - Falls back to content language if Languages module is disabled. - """ - @spec enabled_language_codes() :: [String.t()] - def enabled_language_codes do - if Languages.enabled?() do - Languages.get_enabled_language_codes() - else - [Settings.get_content_language()] - end - end - - @doc """ - Returns the primary/canonical language for versioning. - Uses Settings.get_content_language(). - """ - @spec get_primary_language() :: String.t() - def get_primary_language do - Settings.get_content_language() - end - - @doc """ - Gets language details (name, flag) for a given language code. - - Searches in order: - 1. Predefined languages (BeamLabCountries) - for full locale details - 2. User-configured languages - for custom/less common languages - """ - @spec get_language_info(String.t()) :: - %{code: String.t(), name: String.t(), flag: String.t()} | nil - def get_language_info(language_code) do - find_in_predefined_languages(language_code) || - find_in_configured_languages(language_code) - end - - @doc """ - Checks if a language code is enabled, considering base code matching. - - Handles cases where: - - The code is `en` and enabled languages has `"en-US"` -> matches - - The code is `en-US` and enabled languages has `"en"` -> matches - """ - @spec language_enabled?(String.t(), [String.t()]) :: boolean() - def language_enabled?(language_code, enabled_languages) do - if language_code in enabled_languages do - true - else - base_code = DialectMapper.extract_base(language_code) - - Enum.any?(enabled_languages, fn enabled_lang -> - enabled_lang == language_code or - DialectMapper.extract_base(enabled_lang) == base_code - end) - end - end - - @doc """ - Determines the display code for a language based on whether multiple dialects - of the same base language are enabled. - - If only one dialect of a base language is enabled (e.g., just "en-US"), - returns the base code ("en") for cleaner display. - - If multiple dialects are enabled (e.g., "en-US" and "en-GB"), - returns the full dialect code ("en-US") to distinguish them. - """ - @spec get_display_code(String.t(), [String.t()]) :: String.t() - def get_display_code(language_code, enabled_languages) do - base_code = DialectMapper.extract_base(language_code) - - dialects_count = - Enum.count(enabled_languages, fn lang -> - DialectMapper.extract_base(lang) == base_code - end) - - if dialects_count > 1 do - language_code - else - base_code - end - end - - @doc """ - Orders languages for display in the language switcher. - - Order: primary language first, then languages with translations (sorted), - then languages without translations (sorted). - """ - @spec order_languages_for_display([String.t()], [String.t()], String.t() | nil) :: [String.t()] - def order_languages_for_display(available_languages, enabled_languages, primary_language \\ nil) do - primary_lang = primary_language || get_primary_language() - - langs_with_content = - available_languages - |> Enum.reject(&(&1 == primary_lang)) - |> Enum.sort() - - langs_without_content = - enabled_languages - |> Enum.reject(&(&1 in available_languages or &1 == primary_lang)) - |> Enum.sort() - - [primary_lang] ++ langs_with_content ++ langs_without_content - end - - @doc """ - Checks if a language code is reserved (cannot be used as a slug). - """ - @spec reserved_language_code?(String.t()) :: boolean() - def reserved_language_code?(slug) do - language_codes = - try do - Languages.get_language_codes() - rescue - _ -> [] - end - - slug in language_codes - end - - # =========================================================================== - # Private Helpers - # =========================================================================== - - defp find_in_predefined_languages(language_code) do - case Languages.get_available_language_by_code(language_code) do - nil -> - base_code = DialectMapper.extract_base(language_code) - is_base_code = language_code == base_code and not String.contains?(language_code, "-") - default_dialect = DialectMapper.base_to_dialect(base_code) - - case Languages.get_available_language_by_code(default_dialect) do - nil -> - all_languages = Languages.get_available_languages() - - Enum.find(all_languages, fn lang -> - DialectMapper.extract_base(lang.code) == base_code - end) - - default_match -> - if is_base_code do - %{default_match | name: extract_base_language_name(default_match.name)} - else - default_match - end - end - - exact_match -> - exact_match - end - end - - defp extract_base_language_name(name) when is_binary(name) do - case String.split(name, " (", parts: 2) do - [base_name, _region] -> base_name - [base_name] -> base_name - end - end - - defp extract_base_language_name(name), do: name - - defp find_in_configured_languages(language_code) do - configured_languages = Languages.get_languages() - - exact_match = - Enum.find(configured_languages, fn lang -> lang.code == language_code end) - - result = - if exact_match do - exact_match - else - base_code = DialectMapper.extract_base(language_code) - default_dialect = DialectMapper.base_to_dialect(base_code) - - default_match = - Enum.find(configured_languages, fn lang -> lang.code == default_dialect end) - - if default_match do - default_match - else - Enum.find(configured_languages, fn lang -> - DialectMapper.extract_base(lang.code) == base_code - end) - end - end - - if result do - %{ - code: result.code, - name: result.name || result.code, - flag: result.flag || "" - } - else - nil - end - end - - # =========================================================================== - # Language Map Key Resolution - # =========================================================================== - - @doc """ - Resolves a display language code to a key in a language map. - - Language maps (e.g., `language_titles`, `language_slugs`) use full dialect - codes as keys (e.g., `"en-US"`), but the display/canonical language may be - a base code (e.g., `"en"`) when only one dialect is enabled. - - Tries exact match first, then falls back to base code matching. - """ - @spec resolve_language_key(String.t(), [String.t()]) :: String.t() - def resolve_language_key(language, available_keys) do - if language in available_keys do - language - else - base = DialectMapper.extract_base(language) - Enum.find(available_keys, language, fn key -> DialectMapper.extract_base(key) == base end) - end - end - - # =========================================================================== - # Post Language Building - # =========================================================================== - - @doc """ - Builds language data for a post's language switcher. - Returns a list of language maps with status, enabled flag, known flag, and metadata. - """ - def build_post_languages(post, enabled_languages, primary_language \\ nil) do - primary_lang = - primary_language || post[:primary_language] || get_primary_language() - - all_languages = - order_languages_for_display( - post.available_languages || [], - enabled_languages, - primary_lang - ) - - all_languages - |> Enum.map(&build_language_entry(&1, post, enabled_languages, primary_lang)) - |> Enum.filter(fn lang -> lang.exists || lang.enabled end) - end - - @doc """ - Builds a single language entry map for a post. - """ - def build_language_entry(lang_code, post, enabled_languages, primary_lang) do - lang_info = get_language_info(lang_code) - available = post.available_languages || [] - content_exists = lang_code in available - post_status = post[:metadata] && post.metadata.status - - %{ - code: lang_code, - display_code: get_display_code(lang_code, enabled_languages), - name: if(lang_info, do: lang_info.name, else: lang_code), - flag: if(lang_info, do: lang_info.flag, else: ""), - status: if(content_exists, do: post_status, else: nil), - exists: content_exists, - enabled: language_enabled?(lang_code, enabled_languages), - known: lang_info != nil, - is_primary: lang_code == primary_lang, - uuid: post[:uuid] - } - end -end diff --git a/lib/modules/publishing/listing_cache.ex b/lib/modules/publishing/listing_cache.ex deleted file mode 100644 index 770dd1ebc..000000000 --- a/lib/modules/publishing/listing_cache.ex +++ /dev/null @@ -1,728 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.ListingCache do - @moduledoc """ - Caches publishing group listing metadata in :persistent_term for sub-millisecond reads. - - Instead of querying the database on every request, the listing page reads from - an in-memory cache populated from the database. - - ## How It Works - - 1. When a post is created/updated/published, `regenerate/1` is called - 2. This queries the database and stores post metadata in :persistent_term - 3. `render_group_listing` reads from the in-memory cache - 4. Cache includes: title, slug, date, status, languages, versions (no content) - - ## Performance - - - Cache miss: ~20ms (DB query + store in :persistent_term) - - Cache hit: ~0.1μs (direct memory access, no variance) - - ## Cache Invalidation - - Cache is regenerated when: - - Post is created - - Post is updated (metadata or content) - - Post status changes (draft/published/archived) - - Translation is added - - Version is created - - ## In-Memory Caching with :persistent_term - - For sub-millisecond performance, parsed cache data is stored in `:persistent_term`. - - - First read after restart: queries DB, stores in :persistent_term (~20ms) - - Subsequent reads: direct memory access (~0.1μs, no variance) - - On regenerate: updates :persistent_term from DB - - On invalidate: clears :persistent_term entry (next read triggers regeneration) - """ - - alias PhoenixKit.Modules.Publishing.Constants - alias PhoenixKit.Modules.Publishing.DBStorage - - @timestamp_modes Constants.timestamp_modes() - alias PhoenixKit.Modules.Publishing.LanguageHelpers - alias PhoenixKit.Modules.Publishing.PubSub, as: PublishingPubSub - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Date, as: UtilsDate - - require Logger - - @persistent_term_prefix :phoenix_kit_group_listing_cache - @persistent_term_loaded_at_prefix :phoenix_kit_group_listing_cache_loaded_at - @persistent_term_cache_generated_at_prefix :phoenix_kit_group_listing_cache_generated_at - - # ETS table for regeneration locks (provides atomic test-and-set via insert_new) - @lock_table :phoenix_kit_listing_cache_locks - - # Settings key for memory cache toggle - @memory_cache_key "publishing_memory_cache_enabled" - - @doc """ - Reads the cached listing for a publishing group. - - Returns `{:ok, posts}` if cache exists and is valid. - Returns `{:error, :cache_miss}` if cache doesn't exist or caching is disabled. - - Respects the `publishing_memory_cache_enabled` setting. - """ - @spec read(String.t()) :: {:ok, [map()]} | {:error, :cache_miss} - def read(group_slug) do - if memory_cache_enabled?() do - term_key = persistent_term_key(group_slug) - - case safe_persistent_term_get(term_key) do - {:ok, _} = hit -> - hit - - :not_found -> - # Cache miss — regenerate from database - regenerate(group_slug) - - case safe_persistent_term_get(term_key) do - {:ok, _} = hit -> hit - :not_found -> {:error, :cache_miss} - end - end - else - {:error, :cache_miss} - end - end - - # Safely get from :persistent_term (returns :not_found instead of raising) - defp safe_persistent_term_get(key) do - {:ok, :persistent_term.get(key)} - rescue - ArgumentError -> :not_found - end - - @doc """ - Regenerates the listing cache for a group. - - Queries the database for all posts and stores the metadata in :persistent_term. - - This should be called after any post operation that changes the listing: - - create_post - - update_post - - add_language_to_post - - create_new_version - - Returns `:ok` on success or `{:error, reason}` on failure. - """ - @spec regenerate(String.t()) :: :ok | {:error, any()} - def regenerate(group_slug) do - if memory_cache_enabled?() do - do_regenerate(group_slug) - else - :ok - end - rescue - error -> - Logger.error( - "[ListingCache] Failed to regenerate cache for #{group_slug}: #{inspect(error)}" - ) - - {:error, {:regenerate_failed, error}} - end - - # Maximum number of posts to cache in :persistent_term per group. - # Groups exceeding this will still work but only cache the most recent posts. - @max_cached_posts 5000 - - defp do_regenerate(group_slug) do - start_time = System.monotonic_time(:millisecond) - - # Posts from to_listing_map are already atom-key maps with excerpts - all_posts = DBStorage.list_posts_for_listing(group_slug) - - posts = - if length(all_posts) > @max_cached_posts do - Logger.warning( - "[ListingCache] Group #{group_slug} has #{length(all_posts)} posts, caching most recent #{@max_cached_posts}" - ) - - Enum.take(all_posts, @max_cached_posts) - else - all_posts - end - - generated_at = UtilsDate.utc_now() |> DateTime.to_iso8601() - - safe_persistent_term_put(persistent_term_key(group_slug), posts) - safe_persistent_term_put(loaded_at_key(group_slug), generated_at) - safe_persistent_term_put(cache_generated_at_key(group_slug), generated_at) - - elapsed = System.monotonic_time(:millisecond) - start_time - - Logger.debug( - "[ListingCache] Regenerated cache from DB for #{group_slug} (#{length(posts)} posts) in #{elapsed}ms" - ) - - PublishingPubSub.broadcast_cache_changed(group_slug) - :ok - rescue - error -> - Logger.error( - "[ListingCache] Failed to regenerate cache for #{group_slug}: #{inspect(error)}" - ) - - {:error, {:regenerate_failed, error}} - end - - # Lock timeout in milliseconds (30 seconds) - # If a lock is older than this, it's considered stale (process likely died) - @lock_timeout_ms 30_000 - - @doc """ - Regenerates the cache if no other process is already regenerating it. - - This prevents the "thundering herd" problem where multiple concurrent requests - all trigger cache regeneration simultaneously after a server restart. - - Uses ETS with `insert_new/2` for atomic lock acquisition - only one process - can acquire the lock at a time. The lock includes a timestamp and will be - considered stale after #{@lock_timeout_ms}ms to prevent permanent lockout - if a process dies mid-regeneration. - - Returns: - - `:ok` if regeneration was performed successfully - - `:already_in_progress` if another process is currently regenerating - - `{:error, reason}` if regeneration failed - - ## Usage - - On cache miss in read paths, use this instead of `regenerate/1`: - - case ListingCache.regenerate_if_not_in_progress(group_slug) do - :ok -> # Cache is ready, read from it - :already_in_progress -> # Another process is regenerating, try again later - {:error, _} -> # Regeneration failed, query DB directly - end - """ - @spec regenerate_if_not_in_progress(String.t()) :: :ok | :already_in_progress | {:error, any()} - def regenerate_if_not_in_progress(group_slug) do - ensure_lock_table_exists() - now = System.monotonic_time(:millisecond) - - # Try to atomically acquire the lock using ETS insert_new - # Returns true if inserted (lock acquired), false if key already exists - case :ets.insert_new(@lock_table, {group_slug, now}) do - true -> - # We acquired the lock - perform regeneration - do_regenerate_with_lock(group_slug) - - false -> - # Lock exists - check if it's stale - handle_existing_lock(group_slug, now) - end - end - - # Handle case where lock already exists - check staleness - defp handle_existing_lock(group_slug, now) do - case :ets.lookup(@lock_table, group_slug) do - [{^group_slug, lock_timestamp}] -> - lock_age = now - lock_timestamp - - if lock_age < @lock_timeout_ms do - # Lock is valid and recent - another process is regenerating - Logger.debug( - "[ListingCache] Regeneration already in progress for #{group_slug} (#{lock_age}ms ago), skipping" - ) - - :already_in_progress - else - # Lock is stale - previous process likely died - # Try to take over by deleting and re-acquiring atomically - take_over_stale_lock(group_slug, lock_timestamp, lock_age, now) - end - - [] -> - # Lock was released between insert_new and lookup - try again - regenerate_if_not_in_progress(group_slug) - end - end - - # Attempt to take over a stale lock using compare-and-delete - defp take_over_stale_lock(group_slug, old_timestamp, lock_age, now) do - # Use match_delete for atomic compare-and-delete - # Only deletes if the timestamp matches (no one else took over) - case :ets.select_delete(@lock_table, [{{group_slug, old_timestamp}, [], [true]}]) do - 1 -> - # Successfully deleted stale lock - now try to acquire - Logger.warning( - "[ListingCache] Found stale lock for #{group_slug} (#{lock_age}ms old), taking over regeneration" - ) - - case :ets.insert_new(@lock_table, {group_slug, now}) do - true -> - do_regenerate_with_lock(group_slug) - - false -> - # Another process beat us to it - :already_in_progress - end - - 0 -> - # Lock was already taken over by another process or timestamp changed - :already_in_progress - end - end - - # Perform regeneration while holding the lock - defp do_regenerate_with_lock(group_slug) do - result = regenerate(group_slug) - - case result do - :ok -> :ok - {:error, _} = error -> error - end - after - # Always release the lock when done (success or failure) - :ets.delete(@lock_table, group_slug) - end - - # Ensure the ETS table for locks exists (lazy initialization) - defp ensure_lock_table_exists do - case :ets.whereis(@lock_table) do - :undefined -> - # Table doesn't exist - create it - # Use :public so any process can read/write - # Use :named_table so we can reference by atom - # Use :set for key-value storage - try do - :ets.new(@lock_table, [:set, :public, :named_table]) - rescue - ArgumentError -> - # Table was created by another process between whereis and new - :ok - end - - _tid -> - :ok - end - end - - # Safely put to :persistent_term (logs warning on failure instead of crashing) - defp safe_persistent_term_put(key, value) do - :persistent_term.put(key, value) - rescue - error -> - Logger.warning("[ListingCache] Failed to write to :persistent_term: #{inspect(error)}") - :error - end - - @doc """ - Loads the cache from the database into :persistent_term. - - Returns `:ok` if successful or `{:error, reason}` on failure. - """ - @spec load_into_memory(String.t()) :: :ok | {:error, any()} - def load_into_memory(group_slug) do - load_into_memory_from_db(group_slug) - end - - defp load_into_memory_from_db(group_slug) do - posts = DBStorage.list_posts_for_listing(group_slug) - generated_at = UtilsDate.utc_now() |> DateTime.to_iso8601() - - safe_persistent_term_put(persistent_term_key(group_slug), posts) - safe_persistent_term_put(loaded_at_key(group_slug), generated_at) - safe_persistent_term_put(cache_generated_at_key(group_slug), generated_at) - - Logger.debug( - "[ListingCache] Loaded #{group_slug} from DB into :persistent_term (#{length(posts)} posts)" - ) - - PublishingPubSub.broadcast_cache_changed(group_slug) - :ok - rescue - error -> - Logger.error("[ListingCache] Failed to load #{group_slug} from DB: #{inspect(error)}") - - {:error, {:load_failed, error}} - end - - @doc """ - Invalidates (clears) the cache for a group. - - Clears the :persistent_term entries. The next read will trigger - a regeneration from the database. - """ - @spec invalidate(String.t()) :: :ok - def invalidate(group_slug) do - # Clear :persistent_term entries - term_key = persistent_term_key(group_slug) - - try do - :persistent_term.erase(term_key) - rescue - ArgumentError -> :ok - end - - try do - :persistent_term.erase(loaded_at_key(group_slug)) - rescue - ArgumentError -> :ok - end - - try do - :persistent_term.erase(cache_generated_at_key(group_slug)) - rescue - ArgumentError -> :ok - end - - Logger.debug("[ListingCache] Invalidated cache for #{group_slug}") - :ok - end - - @doc """ - Checks if a cache exists for a group in :persistent_term. - """ - @spec exists?(String.t()) :: boolean() - def exists?(group_slug) do - case safe_persistent_term_get(persistent_term_key(group_slug)) do - {:ok, _} -> true - :not_found -> false - end - end - - @doc """ - Finds a post by slug in the cache. - - This is useful for single post views where we need metadata (language_statuses, - version_statuses, allow_version_access) without a separate DB query. - - Returns `{:ok, cached_post}` if found, `{:error, :not_found}` otherwise. - """ - @spec find_post(String.t(), String.t()) :: {:ok, map()} | {:error, :not_found | :cache_miss} - def find_post(group_slug, post_slug) do - case read(group_slug) do - {:ok, posts} -> - case Enum.find(posts, fn p -> p.slug == post_slug end) do - nil -> {:error, :not_found} - post -> {:ok, post} - end - - {:error, _} = error -> - error - end - end - - @doc """ - Finds a post by path pattern in the cache (for timestamp mode). - - Matches posts where the path contains the date/time pattern. - Returns `{:ok, cached_post}` if found, `{:error, :not_found}` otherwise. - """ - @spec find_post_by_path(String.t(), String.t(), String.t()) :: - {:ok, map()} | {:error, :not_found | :cache_miss} - def find_post_by_path(group_slug, date, time) do - case read(group_slug) do - {:ok, posts} -> - # Match posts using discrete date and time fields (more robust than path string matching) - # Parse the input date string to compare with the cached Date struct - target_date = parse_date_for_lookup(date) - # Normalize time format (handles both "HH:MM" and "HH:MM:SS") - target_time = normalize_time_for_lookup(time) - - case Enum.find(posts, fn p -> - dates_match?(p.date, target_date) && times_match?(p.time, target_time) - end) do - nil -> {:error, :not_found} - post -> {:ok, post} - end - - {:error, _} = error -> - error - end - end - - # Parse date string for lookup comparison - defp parse_date_for_lookup(date_str) when is_binary(date_str) do - case Date.from_iso8601(date_str) do - {:ok, date} -> date - _ -> date_str - end - end - - defp parse_date_for_lookup(date), do: date - - # Normalize time to "HH:MM" format for comparison - defp normalize_time_for_lookup(time_str) when is_binary(time_str) do - # Take just HH:MM portion - String.slice(time_str, 0, 5) - end - - defp normalize_time_for_lookup(time), do: time - - # Compare dates - handles both Date structs and strings - defp dates_match?(nil, _), do: false - defp dates_match?(_, nil), do: false - - defp dates_match?(%Date{} = cached, %Date{} = target) do - Date.compare(cached, target) == :eq - end - - defp dates_match?(%Date{} = cached, target_str) when is_binary(target_str) do - Date.to_iso8601(cached) == target_str - end - - defp dates_match?(_, _), do: false - - # Compare times - handles Time structs and "HH:MM" strings - defp times_match?(nil, _), do: false - defp times_match?(_, nil), do: false - - defp times_match?(%Time{} = cached, target_str) when is_binary(target_str) do - # Format cached time as HH:MM and compare - cached_str = cached |> Time.to_string() |> String.slice(0, 5) - cached_str == target_str - end - - defp times_match?(cached_str, target_str) - when is_binary(cached_str) and is_binary(target_str) do - String.slice(cached_str, 0, 5) == String.slice(target_str, 0, 5) - end - - defp times_match?(_, _), do: false - - @doc """ - Finds a post by URL slug for a specific language. - - This enables O(1) lookup from URL slug to internal identifier, supporting - per-language URL slugs for SEO-friendly localized URLs. - - ## Parameters - - `group_slug` - The publishing group - - `language` - The language code to search in - - `url_slug` - The URL slug to find - - ## Returns - - `{:ok, cached_post}` - Found post (includes internal `slug` for DB lookup) - - `{:error, :not_found}` - No post with this URL slug for this language - - `{:error, :cache_miss}` - Cache not available - """ - @spec find_by_url_slug(String.t(), String.t(), String.t()) :: - {:ok, map()} | {:error, :not_found | :cache_miss} - def find_by_url_slug(group_slug, language, url_slug) do - case read(group_slug) do - {:ok, posts} -> find_post_by_url_slug(posts, language, url_slug) - {:error, _} -> {:error, :cache_miss} - end - end - - defp find_post_by_url_slug(posts, language, url_slug) do - case Enum.find(posts, &(Map.get(&1.language_slugs || %{}, language) == url_slug)) do - nil -> {:error, :not_found} - post -> {:ok, post} - end - end - - @doc """ - Finds a post by a previous URL slug for 301 redirects. - - When a URL slug changes, the old slug is stored in `previous_url_slugs`. - This function finds posts that previously used the given URL slug. - - ## Returns - - `{:ok, cached_post}` - Found post that previously used this slug - - `{:error, :not_found}` - No post with this previous slug - - `{:error, :cache_miss}` - Cache not available - """ - @spec find_by_previous_url_slug(String.t(), String.t(), String.t()) :: - {:ok, map()} | {:error, :not_found | :cache_miss} - def find_by_previous_url_slug(group_slug, language, url_slug) do - case read(group_slug) do - {:ok, posts} -> find_post_by_previous_slug(posts, language, url_slug) - {:error, _} -> {:error, :cache_miss} - end - end - - defp find_post_by_previous_slug(posts, language, url_slug) do - case Enum.find(posts, &post_has_previous_slug?(&1, language, url_slug)) do - nil -> {:error, :not_found} - post -> {:ok, post} - end - end - - defp post_has_previous_slug?(post, language, url_slug) do - lang_previous_slugs = Map.get(post, :language_previous_slugs) || %{} - previous_for_lang = Map.get(lang_previous_slugs, language) || [] - - url_slug in previous_for_lang - end - - @doc """ - Finds a cached post by mode — uses date/time lookup for timestamp mode, slug for others. - """ - def find_post_by_mode(group_slug, post) do - mode = Map.get(post, :mode) - - if mode in @timestamp_modes do - date = post[:date] - time = post[:time] - - if date && time do - date_str = if is_struct(date, Date), do: Date.to_iso8601(date), else: to_string(date) - time_str = format_time_for_cache(time) - find_post_by_path(group_slug, date_str, time_str) - else - {:error, :not_found} - end - else - find_post(group_slug, post.slug) - end - end - - defp format_time_for_cache(%Time{} = time) do - time |> Time.to_string() |> String.slice(0, 5) - end - - defp format_time_for_cache(time) when is_binary(time), do: String.slice(time, 0, 5) - defp format_time_for_cache(_), do: "" - - @doc """ - Returns the :persistent_term key for a publishing group's cache. - """ - @spec persistent_term_key(String.t()) :: tuple() - def persistent_term_key(group_slug) do - {@persistent_term_prefix, group_slug} - end - - @doc """ - Returns the :persistent_term key for tracking when the memory cache was loaded. - """ - @spec loaded_at_key(String.t()) :: tuple() - def loaded_at_key(group_slug) do - {@persistent_term_loaded_at_prefix, group_slug} - end - - @doc """ - Returns when the memory cache was loaded (ISO 8601 string), or nil if not loaded. - """ - @spec memory_loaded_at(String.t()) :: String.t() | nil - def memory_loaded_at(group_slug) do - case safe_persistent_term_get(loaded_at_key(group_slug)) do - {:ok, loaded_at} -> loaded_at - :not_found -> nil - end - end - - @doc """ - Returns the :persistent_term key for tracking when the cache was last generated. - """ - @spec cache_generated_at_key(String.t()) :: tuple() - def cache_generated_at_key(group_slug) do - {@persistent_term_cache_generated_at_prefix, group_slug} - end - - @doc """ - Returns the timestamp of when the cache was last generated from the database. - """ - @spec cache_generated_at(String.t()) :: String.t() | nil - def cache_generated_at(group_slug) do - case safe_persistent_term_get(cache_generated_at_key(group_slug)) do - {:ok, generated_at} -> generated_at - :not_found -> nil - end - end - - @doc """ - Returns whether memory caching (:persistent_term) is enabled. - Uses cached settings to avoid database queries on every call. - """ - @spec memory_cache_enabled?() :: boolean() - def memory_cache_enabled? do - Settings.get_setting_cached(@memory_cache_key, "true") == "true" - end - - @doc """ - Returns a list of posts that need primary_language migration. - - This checks all posts in a group and returns those that either: - 1. Have no `primary_language` stored (need backfill) - 2. Have `primary_language` different from global setting (need migration decision) - """ - @spec posts_needing_primary_language_migration(String.t()) :: [map()] - def posts_needing_primary_language_migration(group_slug) do - case read(group_slug) do - {:ok, posts} -> - global_primary = LanguageHelpers.get_primary_language() - - Enum.filter(posts, fn post -> - # Use atom key since normalized posts use atoms - stored_primary = post[:primary_language] - stored_primary == nil or stored_primary != global_primary - end) - - {:error, _} -> - # If cache doesn't exist, query DB directly - scan_posts_needing_migration(group_slug) - end - end - - defp scan_posts_needing_migration(group_slug) do - global_primary = LanguageHelpers.get_primary_language() - - DBStorage.list_posts_for_listing(group_slug) - |> Enum.filter(fn post -> - stored_primary = post[:primary_language] - stored_primary == nil or stored_primary != global_primary - end) - end - - @doc """ - Counts posts by primary_language status in a group. - - Returns `%{current: n, needs_migration: n, needs_backfill: n}` where: - - `current` - posts with primary_language matching global setting - - `needs_migration` - posts with different primary_language (were created under old setting) - - `needs_backfill` - posts with no primary_language stored - """ - @spec count_primary_language_status(String.t()) :: map() - def count_primary_language_status(group_slug) do - case read(group_slug) do - {:ok, posts} -> - global_primary = LanguageHelpers.get_primary_language() - - Enum.reduce(posts, %{current: 0, needs_migration: 0, needs_backfill: 0}, fn post, acc -> - # Use atom key since normalized posts use atoms - stored_primary = post[:primary_language] - - cond do - stored_primary == nil -> - %{acc | needs_backfill: acc.needs_backfill + 1} - - stored_primary == global_primary -> - %{acc | current: acc.current + 1} - - true -> - %{acc | needs_migration: acc.needs_migration + 1} - end - end) - - {:error, _} -> - # If cache doesn't exist, query DB directly - scan_primary_language_status(group_slug) - end - end - - defp scan_primary_language_status(group_slug) do - global_primary = LanguageHelpers.get_primary_language() - - DBStorage.list_posts_for_listing(group_slug) - |> Enum.reduce(%{current: 0, needs_migration: 0, needs_backfill: 0}, fn post, acc -> - stored_primary = post[:primary_language] - - cond do - stored_primary == nil -> - %{acc | needs_backfill: acc.needs_backfill + 1} - - stored_primary == global_primary -> - %{acc | current: acc.current + 1} - - true -> - %{acc | needs_migration: acc.needs_migration + 1} - end - end) - end -end diff --git a/lib/modules/publishing/metadata.ex b/lib/modules/publishing/metadata.ex deleted file mode 100644 index 1f6ea630f..000000000 --- a/lib/modules/publishing/metadata.ex +++ /dev/null @@ -1,162 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.Metadata do - @moduledoc """ - Content metadata helpers for the Publishing module. - - Provides title extraction from markdown/component content. - """ - - @default_title PhoenixKit.Modules.Publishing.Constants.default_title() - - @doc """ - Extracts title from markdown content. - Looks for the first H1 heading (# Title) within the first few lines. - Falls back to the first line if no H1 found. - """ - @spec extract_title_from_content(String.t()) :: String.t() - def extract_title_from_content(content) when is_binary(content) do - content - |> String.trim() - |> do_extract_title() - end - - def extract_title_from_content(_), do: @default_title - - defp do_extract_title(""), do: @default_title - - defp do_extract_title(content) do - content - |> extract_title_from_lines() - |> case do - @default_title -> - extract_title_from_components(content) || @default_title - - title -> - title - end - end - - defp extract_title_from_lines(""), do: @default_title - - defp extract_title_from_lines(content) do - lines = - content - |> extract_candidate_lines() - |> Enum.take(15) - - # Look for first H1 heading (# Title) - h1_line = - Enum.find(lines, fn line -> - String.starts_with?(line, "# ") and String.length(line) > 2 - end) - - cond do - h1_line != nil -> - h1_line - |> String.trim_leading("# ") - |> String.trim() - - not Enum.empty?(lines) -> - List.first(lines) - |> String.slice(0, 100) - - true -> - @default_title - end - end - - defp extract_candidate_lines(content) do - {lines, _depth} = - content - |> String.split("\n") - |> Enum.reduce({[], 0}, fn raw_line, {acc, depth} -> - line = String.trim(raw_line) - - cond do - line == "" and depth == 0 -> - {acc, depth} - - component_self_closing?(line) -> - {acc, depth} - - component_open?(line) -> - {acc, depth + 1} - - depth > 0 and multiline_self_close?(raw_line) -> - {acc, max(depth - 1, 0)} - - component_close?(line) and depth > 0 -> - {acc, max(depth - 1, 0)} - - depth > 0 -> - {acc, depth} - - true -> - {[line | acc], depth} - end - end) - - lines - |> Enum.reverse() - |> Enum.reject(&(&1 == "")) - end - - defp component_open?(line) do - String.starts_with?(line, "<") and - not String.starts_with?(line, "}, line) - end - - defp component_self_closing?(line) do - component_open?(line) and String.ends_with?(line, "/>") - end - - defp multiline_self_close?(line) do - line - |> String.trim() - |> case do - "/>" -> true - ">" -> false - other -> String.ends_with?(other, "/>") - end - end - - defp extract_title_from_components(content) do - component_title(content, "Headline") || - component_attribute(content, "Hero", "title") || - component_title(content, "Title") - end - - defp component_title(content, tag) do - regex = ~r/<#{tag}\b[^>]*>(.*?)<\/#{tag}>/is - - case Regex.run(regex, content, capture: :all_but_first) do - [inner | _] -> sanitize_component_text(inner) - _ -> nil - end - end - - defp component_attribute(content, tag, attr) do - regex = ~r/<#{tag}\b[^>]*#{attr}="([^"]+)"[^>]*>/i - - case Regex.run(regex, content, capture: :all_but_first) do - [value | _] -> sanitize_component_text(value) - _ -> nil - end - end - - defp sanitize_component_text(text) do - text - |> String.trim() - |> String.replace(~r/<[^>]+>/, "") - |> String.replace(~r/\s+/, " ") - |> String.trim() - |> case do - "" -> nil - cleaned -> String.slice(cleaned, 0, 100) - end - end -end diff --git a/lib/modules/publishing/page_builder.ex b/lib/modules/publishing/page_builder.ex deleted file mode 100644 index eb0409c93..000000000 --- a/lib/modules/publishing/page_builder.ex +++ /dev/null @@ -1,99 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.PageBuilder do - @moduledoc """ - Rendering pipeline for PHK (PhoenixKit) page content. - - Processes component-based page definitions through: - 1. Parse XML to AST - 2. Inject dynamic data ({{variable}} placeholders) - 3. Resolve components (map to actual component modules) - 4. Apply theme/variants - 5. Render to HTML - """ - - alias PhoenixKit.Modules.Publishing.PageBuilder.Parser - alias PhoenixKit.Modules.Publishing.PageBuilder.Renderer - - @type assigns :: map() - @type ast :: map() - @type render_result :: {:ok, Phoenix.LiveView.Rendered.t()} | {:error, term()} - - @doc """ - Renders PHK content directly from a string. - """ - @spec render_content(String.t(), assigns()) :: render_result() - def render_content(content, assigns \\ %{}) do - with {:ok, ast} <- parse_to_ast(content), - {:ok, ast_with_data} <- inject_dynamic_data(ast, assigns), - {:ok, resolved} <- resolve_components(ast_with_data), - {:ok, themed} <- apply_theme(resolved, assigns), - {:ok, html} <- render_to_html(themed, assigns) do - {:ok, html} - else - {:error, reason} -> {:error, reason} - end - end - - # Step 1: Parse XML to AST - defp parse_to_ast(content) do - Parser.parse(content) - end - - # Step 3: Inject dynamic data (replace {{variable}} placeholders) - defp inject_dynamic_data(ast, assigns) do - {:ok, inject_assigns(ast, assigns)} - end - - # Step 4: Resolve components (map XML tags to actual component modules) - defp resolve_components(ast) do - {:ok, ast} - end - - # Step 5: Apply theme/variant settings - defp apply_theme(ast, _assigns) do - {:ok, ast} - end - - # Step 6: Render to HTML - defp render_to_html(ast, assigns) do - Renderer.render(ast, assigns) - end - - # Recursively inject assigns into AST nodes - defp inject_assigns(ast, assigns) when is_map(ast) do - ast - |> Map.update(:content, nil, &inject_assigns(&1, assigns)) - |> Map.update(:attributes, %{}, &inject_assigns(&1, assigns)) - |> Map.update(:children, [], &inject_assigns(&1, assigns)) - end - - defp inject_assigns(ast, assigns) when is_list(ast) do - Enum.map(ast, &inject_assigns(&1, assigns)) - end - - defp inject_assigns(content, assigns) when is_binary(content) do - interpolate_string(content, assigns) - end - - defp inject_assigns(value, _assigns), do: value - - # Interpolate {{variable}} placeholders - defp interpolate_string(string, assigns) do - Regex.replace(~r/\{\{([^}]+)\}\}/, string, fn _, path -> - get_nested_value(assigns, String.trim(path)) |> to_string() - end) - end - - # Get nested value from assigns (e.g., "user.name" -> assigns.user.name) - defp get_nested_value(map, path) do - path - |> String.split(".") - |> Enum.reduce(map, fn key, acc -> - case acc do - %{} -> Map.get(acc, key) || Map.get(acc, String.to_existing_atom(key)) - _ -> nil - end - end) - rescue - _ -> "" - end -end diff --git a/lib/modules/publishing/page_builder/parser.ex b/lib/modules/publishing/page_builder/parser.ex deleted file mode 100644 index 436390332..000000000 --- a/lib/modules/publishing/page_builder/parser.ex +++ /dev/null @@ -1,152 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.PageBuilder.Parser do - @moduledoc """ - Parses PHK (PhoenixKit) XML-style markup into an AST. - - Example input: - ```xml - - - Welcome to PhoenixKit - Build faster with {{framework}} - Get Started - - - ``` - - Output AST: - ```elixir - %{ - type: :page, - attributes: %{slug: "home"}, - children: [ - %{ - type: :hero, - attributes: %{variant: "split-image"}, - children: [ - %{type: :headline, content: "Welcome to PhoenixKit"}, - %{type: :subheadline, content: "Build faster with {{framework}}"}, - %{type: :cta, attributes: %{primary: "true", action: "/signup"}, content: "Get Started"} - ] - } - ] - } - ``` - """ - - @doc """ - Parses PHK XML content into an AST. - """ - @spec parse(String.t()) :: {:ok, map()} | {:error, term()} - def parse(content) when is_binary(content) do - content = String.trim(content) - - case Saxy.parse_string( - content, - PhoenixKit.Modules.Publishing.PageBuilder.SaxHandler, - [] - ) do - {:ok, ast} -> {:ok, ast} - {:error, reason} -> {:error, {:parse_error, reason}} - end - rescue - e -> {:error, {:parse_exception, e}} - end - - def parse(_), do: {:error, :invalid_content} -end - -defmodule PhoenixKit.Modules.Publishing.PageBuilder.SaxHandler do - @moduledoc false - @behaviour Saxy.Handler - - def handle_event(:start_document, _prolog, _state) do - {:ok, %{stack: [], result: nil}} - end - - def handle_event(:end_document, _data, state) do - {:ok, state.result} - end - - def handle_event(:start_element, {name, attributes}, state) do - node = %{ - type: normalize_tag_name(name), - attributes: parse_attributes(attributes), - children: [], - content: nil - } - - new_state = %{state | stack: [node | state.stack]} - {:ok, new_state} - end - - def handle_event(:end_element, _name, %{stack: [current | rest]} = state) do - # Simplify node if it only has content and no children - simplified = - cond do - current.children == [] and is_binary(current.content) -> - %{ - type: current.type, - attributes: current.attributes, - content: String.trim(current.content) - } - - current.content == nil and current.children != [] -> - %{ - type: current.type, - attributes: current.attributes, - children: Enum.reverse(current.children) - } - - true -> - %{ - type: current.type, - attributes: current.attributes, - children: Enum.reverse(current.children), - content: current.content && String.trim(current.content) - } - end - - case rest do - [] -> - {:ok, %{state | stack: [], result: simplified}} - - [parent | ancestors] -> - updated_parent = %{parent | children: [simplified | parent.children]} - {:ok, %{state | stack: [updated_parent | ancestors]}} - end - end - - def handle_event(:characters, chars, %{stack: [current | rest]} = state) do - trimmed = String.trim(chars) - - updated_current = - if trimmed != "" do - case current.content do - nil -> %{current | content: chars} - existing -> %{current | content: existing <> chars} - end - else - current - end - - {:ok, %{state | stack: [updated_current | rest]}} - end - - def handle_event(:characters, _chars, state) do - {:ok, state} - end - - # Normalize tag names to atoms (Page -> :page, Hero -> :hero) - defp normalize_tag_name(name) do - name - |> String.downcase() - |> String.to_atom() - end - - # Convert attribute list to map with string keys - defp parse_attributes(attrs) do - Enum.into(attrs, %{}, fn {key, value} -> - {String.downcase(key), value} - end) - end -end diff --git a/lib/modules/publishing/page_builder/renderer.ex b/lib/modules/publishing/page_builder/renderer.ex deleted file mode 100644 index 9f0e5a42d..000000000 --- a/lib/modules/publishing/page_builder/renderer.ex +++ /dev/null @@ -1,100 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.PageBuilder.Renderer do - @moduledoc """ - Renders AST nodes to HTML by delegating to component modules. - """ - - @doc """ - Renders an AST node to HTML. - """ - def render(ast, assigns) when is_map(ast) do - case resolve_component(ast.type) do - {:ok, component_module} -> - render_component(component_module, ast, assigns) - - {:error, :not_found} -> - # Fallback for unknown components - render_unknown(ast, assigns) - end - end - - def render(ast, _assigns) when is_list(ast) do - {:ok, - Phoenix.HTML.raw( - Enum.map_join(ast, fn node -> - case render(node, %{}) do - {:ok, html} -> Phoenix.HTML.safe_to_string(html) - {:error, _} -> "" - end - end) - )} - end - - def render(content, _assigns) when is_binary(content) do - {:ok, Phoenix.HTML.raw(content)} - end - - # Resolve component type to module - defp resolve_component(:page), do: {:ok, PhoenixKit.Modules.Shared.Components.Page} - defp resolve_component(:hero), do: {:ok, PhoenixKit.Modules.Shared.Components.Hero} - defp resolve_component(:headline), do: {:ok, PhoenixKit.Modules.Shared.Components.Headline} - - defp resolve_component(:subheadline), - do: {:ok, PhoenixKit.Modules.Shared.Components.Subheadline} - - defp resolve_component(:cta), do: {:ok, PhoenixKit.Modules.Shared.Components.CTA} - defp resolve_component(:image), do: {:ok, PhoenixKit.Modules.Shared.Components.Image} - defp resolve_component(:video), do: {:ok, PhoenixKit.Modules.Shared.Components.Video} - - defp resolve_component(:entityform), - do: {:ok, PhoenixKit.Modules.Shared.Components.EntityForm} - - defp resolve_component(_), do: {:error, :not_found} - - # Render using the component module - defp render_component(component_module, ast, assigns) do - component_assigns = build_component_assigns(ast, assigns) - - try do - html = component_module.render(component_assigns) - {:ok, html} - rescue - e -> - {:error, {:render_error, e}} - end - end - - # Build assigns map for component - defp build_component_assigns(ast, parent_assigns) do - base_assigns = %{ - __changed__: nil, - variant: Map.get(ast.attributes, "variant", "default"), - attributes: ast.attributes, - content: ast[:content], - children: ast[:children] || [] - } - - Map.merge(parent_assigns, base_assigns) - end - - # Fallback renderer for unknown components - defp render_unknown(ast, assigns) do - content = - cond do - ast[:content] -> - ast.content - - ast[:children] -> - Enum.map_join(ast.children, fn child -> - case render(child, assigns) do - {:ok, html} -> Phoenix.HTML.safe_to_string(html) - _ -> "" - end - end) - - true -> - "" - end - - {:ok, Phoenix.HTML.raw("
#{content}
")} - end -end diff --git a/lib/modules/publishing/posts.ex b/lib/modules/publishing/posts.ex deleted file mode 100644 index 09f8d059c..000000000 --- a/lib/modules/publishing/posts.ex +++ /dev/null @@ -1,818 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.Posts do - @moduledoc """ - Post CRUD operations for the Publishing module. - - Handles creating, reading, updating, and trashing posts, - as well as slug/version/language extraction and timestamp management. - """ - - require Logger - - alias PhoenixKit.Modules.Languages.DialectMapper - alias PhoenixKit.Modules.Publishing - alias PhoenixKit.Modules.Publishing.Constants - - @timestamp_modes Constants.timestamp_modes() - alias PhoenixKit.Modules.Publishing.DBStorage - alias PhoenixKit.Modules.Publishing.LanguageHelpers - alias PhoenixKit.Modules.Publishing.ListingCache - alias PhoenixKit.Modules.Publishing.PubSub, as: PublishingPubSub - alias PhoenixKit.Modules.Publishing.Shared - alias PhoenixKit.Modules.Publishing.SlugHelpers - alias PhoenixKit.Modules.Publishing.StaleFixer - alias PhoenixKit.Utils.Date, as: UtilsDate - - # Suppress dialyzer false positives for pattern matches - @dialyzer {:nowarn_function, create_post: 2} - - @max_timestamp_attempts 60 - - @doc """ - Returns true when the given post is a DB-backed post (has a UUID). - """ - @spec db_post?(map()) :: boolean() - def db_post?(post), do: not is_nil(post[:uuid]) - - @doc "Counts posts on a specific date for a group." - def count_posts_on_date(group_slug, date) do - group_slug - |> list_times_on_date(date) - |> length() - end - - @doc "Lists time values for posts on a specific date." - def list_times_on_date(group_slug, date) do - date = if is_binary(date), do: Date.from_iso8601!(date), else: date - - group_slug - |> DBStorage.list_posts_timestamp_mode("published", date: date) - |> Enum.map(&(Time.to_string(&1.post_time) |> String.slice(0, 5))) - |> Enum.uniq() - |> Enum.sort() - rescue - e -> - Logger.warning( - "[Publishing] list_times_on_date failed for #{group_slug}/#{date}: #{inspect(e)}" - ) - - [] - end - - @doc """ - Finds a post by URL slug from the database. - """ - @spec find_by_url_slug(String.t(), String.t(), String.t()) :: - {:ok, map()} | {:error, :not_found | :cache_miss} - def find_by_url_slug(group_slug, language, url_slug) do - case DBStorage.find_by_url_slug(group_slug, language, url_slug) do - nil -> {:error, :not_found} - content -> {:ok, db_content_to_post_map(content)} - end - end - - @doc """ - Finds a post by a previous URL slug (for 301 redirects). - """ - @spec find_by_previous_url_slug(String.t(), String.t(), String.t()) :: - {:ok, map()} | {:error, :not_found | :cache_miss} - def find_by_previous_url_slug(group_slug, language, url_slug) do - case DBStorage.find_by_previous_url_slug(group_slug, language, url_slug) do - nil -> {:error, :not_found} - content -> {:ok, db_content_to_post_map(content)} - end - end - - @doc """ - Lists posts for a given publishing group slug. - - Queries the database directly via DBStorage. - The optional second argument is accepted for API compatibility but unused. - """ - @spec list_posts(String.t(), String.t() | nil) :: [map()] - def list_posts(group_slug, _preferred_language \\ nil) do - DBStorage.list_posts_with_metadata(group_slug) - end - - @doc "Lists posts filtered by status (e.g. 'trashed', 'published')." - @spec list_posts_by_status(String.t(), String.t()) :: [map()] - def list_posts_by_status(group_slug, status) do - DBStorage.list_posts_with_metadata(group_slug, status) - end - - @doc "Lists raw DB post records for a group, optionally filtered by status." - @spec list_raw_posts(String.t(), String.t() | nil) :: [struct()] - def list_raw_posts(group_slug, status \\ nil) do - if status, - do: DBStorage.list_posts(group_slug, status), - else: DBStorage.list_posts(group_slug) - end - - @doc "Counts primary language migration status from a list of posts." - @spec count_primary_language_status(list(), String.t()) :: map() | nil - def count_primary_language_status([], _primary), do: nil - - def count_primary_language_status(posts, primary_language) do - DBStorage.count_primary_language_status_from_posts(posts, primary_language) - end - - @doc """ - Creates a new post for the given publishing group using the current timestamp. - """ - @spec create_post(String.t(), map() | keyword()) :: {:ok, map()} | {:error, any()} - def create_post(group_slug, opts \\ %{}) do - create_post_in_db(group_slug, opts) - end - - @doc """ - Reads a post by its database UUID. - - Resolves the UUID to a group slug and post slug, then delegates to `read_post/4`. - Invalid version/language params gracefully fall back to latest/primary. - """ - def read_post_by_uuid(post_uuid, language \\ nil, version \\ nil) do - case DBStorage.get_post_by_uuid(post_uuid, [:group]) do - nil -> - {:error, :not_found} - - db_post -> - db_post = StaleFixer.fix_stale_post(db_post) - group_slug = db_post.group.slug - resolved_language = resolve_language_to_dialect(language) - version_number = if version, do: normalize_version_number(version), else: nil - - if db_post.post_date && db_post.post_time do - DBStorage.read_post_by_datetime( - group_slug, - db_post.post_date, - db_post.post_time, - resolved_language, - version_number - ) - else - DBStorage.read_post(group_slug, db_post.slug, resolved_language, version_number) - end - end - rescue - e in [Ecto.QueryError, DBConnection.ConnectionError] -> - Logger.warning("[Publishing] read_post_by_uuid failed for #{post_uuid}: #{inspect(e)}") - {:error, :not_found} - end - - @doc """ - Reads an existing post. - - For slug-mode groups, accepts an optional version parameter. - If version is nil, reads the latest version. - - Reads from the database. - """ - @spec read_post(String.t(), String.t(), String.t() | nil, integer() | nil) :: - {:ok, map()} | {:error, any()} - def read_post(group_slug, identifier, language \\ nil, version \\ nil) do - read_post_from_db(group_slug, identifier, language, version) - end - - @doc """ - Updates a post in the database. - """ - @spec update_post(String.t(), map(), map(), map() | keyword()) :: - {:ok, map()} | {:error, any()} - def update_post(group_slug, post, params, opts \\ %{}) do - # Normalize opts to map (callers may pass keyword list or map) - opts_map = if Keyword.keyword?(opts), do: Map.new(opts), else: opts - - audit_meta = - opts_map - |> Shared.fetch_option(:scope) - |> Shared.audit_metadata(:update) - |> Map.put(:is_primary_language, Map.get(opts_map, :is_primary_language, true)) - - result = update_post_in_db(group_slug, post, params, audit_meta) - - with {:ok, updated_post} <- result do - ListingCache.regenerate(group_slug) - - unless Map.get(opts_map, :skip_broadcast, false) do - PublishingPubSub.broadcast_post_updated(group_slug, updated_post) - end - end - - result - end - - @doc """ - Changes a post's status by UUID. - - Reads the post, resolves primary language, updates status via `update_post`, - invalidates render cache, and broadcasts the change. - - Returns `{:ok, updated_post}` or `{:error, reason}`. - """ - @spec change_post_status(String.t(), String.t(), String.t(), keyword()) :: - {:ok, map()} | {:error, term()} - def change_post_status(group_slug, post_uuid, new_status, opts \\ []) do - case read_post_by_uuid(post_uuid) do - {:ok, post} -> - primary_language = post[:primary_language] || LanguageHelpers.get_primary_language() - is_primary_language = post.language == primary_language - - case update_post(group_slug, post, %{"status" => new_status}, - scope: opts[:scope], - is_primary_language: is_primary_language, - skip_broadcast: true - ) do - {:ok, updated_post} -> - identifier = updated_post[:uuid] || updated_post.slug - Publishing.Renderer.invalidate_cache(group_slug, identifier, updated_post.language) - PublishingPubSub.broadcast_post_status_changed(group_slug, updated_post) - {:ok, updated_post} - - {:error, _} = err -> - err - end - - {:error, _} = err -> - err - end - end - - @doc """ - Restores a trashed post by UUID, setting its status back to "draft". - - Reconciles version/content statuses and regenerates the group cache. - Returns {:ok, post_uuid} on success or {:error, reason} on failure. - """ - @spec restore_post(String.t(), String.t()) :: {:ok, String.t()} | {:error, term()} - def restore_post(group_slug, post_uuid) do - case DBStorage.get_post_by_uuid(post_uuid) do - nil -> - {:error, :not_found} - - db_post -> - case DBStorage.update_post(db_post, %{status: "draft"}) do - {:ok, _} -> - StaleFixer.reconcile_post_status(db_post) - ListingCache.regenerate(group_slug) - broadcast_id = db_post.slug || db_post.uuid - PublishingPubSub.broadcast_post_updated(group_slug, %{slug: broadcast_id}) - {:ok, post_uuid} - - {:error, reason} -> - {:error, reason} - end - end - end - - @doc """ - Soft-deletes a post by UUID. - - Returns {:ok, post_uuid} on success or {:error, reason} on failure. - """ - @spec trash_post(String.t(), String.t()) :: {:ok, String.t()} | {:error, term()} - def trash_post(group_slug, post_uuid) do - case DBStorage.get_post_by_uuid(post_uuid, [:group]) do - nil -> - {:error, :not_found} - - db_post -> - case DBStorage.trash_post(db_post) do - {:ok, _} -> - broadcast_id = db_post.slug || db_post.uuid - ListingCache.regenerate(group_slug) - PublishingPubSub.broadcast_post_deleted(group_slug, broadcast_id) - {:ok, post_uuid} - - {:error, reason} -> - {:error, reason} - end - end - end - - # Extract slug, version, and language from a path identifier - # Handles paths like: - # - "post-slug" → {"post-slug", nil, nil} - # - "post-slug/en" → {"post-slug", nil, "en"} - # - "post-slug/v1/en" → {"post-slug", 1, "en"} - # - "group/post-slug/v2/am" → {"post-slug", 2, "am"} - def extract_slug_version_and_language(_group_slug, nil), do: {"", nil, nil} - - def extract_slug_version_and_language(group_slug, identifier) do - parts = - identifier - |> to_string() - |> String.trim() - |> String.trim_leading("/") - |> String.split("/", trim: true) - |> drop_group_prefix(group_slug) - - case parts do - [] -> - {"", nil, nil} - - [slug] -> - {slug, nil, nil} - - [slug | rest] -> - # Extract version if present (v1, v2, v3, etc.) - {version, rest_after_version} = Shared.extract_version_from_parts(rest) - - # Extract language from remaining parts - language = - rest_after_version - |> List.first() - |> case do - nil -> nil - <<>> -> nil - lang_code -> lang_code - end - - {slug, version, language} - end - end - - @doc false - def read_back_post(group_slug, identifier, db_post, language, version_number) do - Shared.read_back_post(group_slug, identifier, db_post, language, version_number) - end - - # =========================================================================== - # Private helpers - # =========================================================================== - - # Converts a DBStorage content record (with preloaded version/post/group) to a post map - defp db_content_to_post_map(content) do - version = content.version - post = version.post - - %{ - slug: post.slug, - url_slug: content.url_slug, - language: content.language, - metadata: %{ - title: content.title, - status: content.status, - description: (content.data || %{})["description"] - } - } - end - - defp create_post_in_db(group_slug, opts) do - case DBStorage.get_group_by_slug(group_slug) do - nil -> - {:error, :group_not_found} - - group -> - do_create_post_in_db(group_slug, group, opts) - end - end - - defp do_create_post_in_db(group_slug, group, opts) do - scope = Shared.fetch_option(opts, :scope) - mode = Publishing.get_group_mode(group_slug) - primary_language = LanguageHelpers.get_primary_language() - now = UtilsDate.utc_now() - - # Resolve user UUID for audit - created_by_uuid = Shared.resolve_scope_user_uuids(scope) - - # Generate slug for slug-mode groups - slug_result = - case mode do - "slug" -> - title = Shared.fetch_option(opts, :title) - preferred_slug = Shared.fetch_option(opts, :slug) - SlugHelpers.generate_unique_slug(group_slug, title || "", preferred_slug) - - _ -> - {:ok, nil} - end - - with {:ok, post_slug} <- slug_result do - # Build post attributes - post_attrs = %{ - group_uuid: group.uuid, - slug: post_slug, - status: "draft", - mode: mode, - primary_language: primary_language, - published_at: nil, - created_by_uuid: created_by_uuid, - updated_by_uuid: created_by_uuid - } - - # Add initial date/time for timestamp mode (truncate seconds since URLs use HH:MM only) - # The actual available timestamp is resolved inside the transaction to avoid races. - post_attrs = - if mode == "timestamp" do - date = DateTime.to_date(now) - time = %Time{hour: now.hour, minute: now.minute, second: 0, microsecond: {0, 0}} - - Map.merge(post_attrs, %{ - post_date: date, - post_time: time - }) - else - post_attrs - end - - repo = PhoenixKit.RepoHelper.repo() - - tx_result = - repo.transaction(fn -> - # Find available timestamp INSIDE the transaction to prevent race conditions - final_attrs = - if mode == "timestamp" do - {date, time} = - find_available_timestamp(group_slug, post_attrs.post_date, post_attrs.post_time) - - %{post_attrs | post_date: date, post_time: time} - else - post_attrs - end - - with {:ok, db_post} <- DBStorage.create_post(final_attrs), - {:ok, db_version} <- - DBStorage.create_version(%{ - post_uuid: db_post.uuid, - version_number: 1, - status: "draft", - created_by_uuid: created_by_uuid - }), - {:ok, _content} <- - DBStorage.create_content(%{ - version_uuid: db_version.uuid, - language: primary_language, - title: Shared.fetch_option(opts, :title) || "", - content: Shared.fetch_option(opts, :content) || "", - status: "draft", - url_slug: post_slug - }) do - db_post - else - {:error, reason} -> repo.rollback(reason) - end - end) - - with {:ok, db_post} <- tx_result do - # Read back via mapper to get a proper post map with UUID - read_result = - if mode == "timestamp" do - DBStorage.read_post_by_datetime( - group_slug, - db_post.post_date, - db_post.post_time, - primary_language, - 1 - ) - else - DBStorage.read_post(group_slug, db_post.slug, primary_language, 1) - end - - case read_result do - {:ok, post} -> - ListingCache.regenerate(group_slug) - PublishingPubSub.broadcast_post_created(group_slug, post) - {:ok, post} - - {:error, _} = err -> - err - end - end - end - end - - defp read_post_from_db(group_slug, identifier, language, version) do - # If identifier is a UUID, resolve via UUID lookup (handles both modes) - if Shared.uuid_format?(identifier) do - read_post_by_uuid(identifier, language, version) - else - case Publishing.get_group_mode(group_slug) do - "timestamp" -> - read_post_from_db_timestamp(group_slug, identifier, language, version) - - _ -> - read_post_from_db_slug(group_slug, identifier, language, version) - end - end - end - - defp read_post_from_db_timestamp(group_slug, identifier, language, version) do - case Shared.parse_timestamp_path(identifier) do - {:ok, date, time, inferred_version, inferred_language} -> - final_language = resolve_language_to_dialect(language || inferred_language) - final_version = version || inferred_version - version_number = normalize_version_number(final_version) - - DBStorage.read_post_by_datetime( - group_slug, - date, - time, - final_language, - version_number - ) - - _ -> - # Fallback: try as slug-based lookup - read_post_from_db_slug(group_slug, identifier, language, version) - end - end - - defp read_post_from_db_slug(group_slug, identifier, language, version) do - {post_slug, inferred_version, inferred_language} = - extract_slug_version_and_language(group_slug, identifier) - - final_language = resolve_language_to_dialect(language || inferred_language) - final_version = version || inferred_version - version_number = normalize_version_number(final_version) - - DBStorage.read_post(group_slug, post_slug, final_language, version_number) - end - - defp normalize_version_number(nil), do: nil - - defp normalize_version_number(v) when is_integer(v) and v > 0, do: v - defp normalize_version_number(v) when is_integer(v), do: nil - - defp normalize_version_number(v) do - case Integer.parse("#{v}") do - {n, _} when n > 0 -> n - _ -> nil - end - end - - # Resolves base language codes (de, en) to stored BCP-47 dialect codes (de-DE, en-US). - # Content rows store full dialect codes, but URL paths use base codes. - defp resolve_language_to_dialect(nil), do: nil - - defp resolve_language_to_dialect(language) do - base = DialectMapper.extract_base(language) - - if base == language do - DialectMapper.base_to_dialect(language) - else - language - end - end - - # Finds the next available minute for a timestamp-mode post. - # If the given date/time is already taken, bumps forward by one minute at a time. - # Limited to 60 attempts to prevent unbounded recursion. - defp find_available_timestamp(group_slug, date, time, attempts \\ 0) - - defp find_available_timestamp(_group_slug, date, time, @max_timestamp_attempts) do - {date, time} - end - - defp find_available_timestamp(group_slug, date, time, attempts) do - case DBStorage.get_post_by_datetime(group_slug, date, time) do - nil -> - {date, time} - - _existing -> - # Bump by one minute - total_seconds = time.hour * 3600 + time.minute * 60 + 60 - - if total_seconds >= 86_400 do - # Rolled past midnight — advance to next day at 00:00 - next_date = Date.add(date, 1) - find_available_timestamp(group_slug, next_date, ~T[00:00:00], attempts + 1) - else - next_hour = div(total_seconds, 3600) - next_minute = div(rem(total_seconds, 3600), 60) - next_time = %Time{hour: next_hour, minute: next_minute, second: 0, microsecond: {0, 0}} - find_available_timestamp(group_slug, date, next_time, attempts + 1) - end - end - end - - # Updates a post in the database. - # Writes directly to the database and returns the updated post map. - defp update_post_in_db(group_slug, post, params, audit_meta) do - db_post = find_db_post_for_update(group_slug, post) - - if db_post do - if post[:mode] in @timestamp_modes || db_post.mode == "timestamp" do - # Timestamp-mode posts don't have slugs — skip slug validation - do_update_post_in_db(db_post, post, params, group_slug, nil, audit_meta) - else - # Handle slug changes - desired_slug = Map.get(params, "slug", post.slug) - - case maybe_update_db_slug(db_post, desired_slug, group_slug) do - {:ok, final_slug} -> - do_update_post_in_db(db_post, post, params, group_slug, final_slug, audit_meta) - - {:error, _reason} = error -> - error - end - end - else - {:error, :not_found} - end - rescue - e -> - Logger.warning("[Publishing] update_post_in_db failed: #{inspect(e)}") - {:error, :db_update_failed} - end - - # Find the DB post record for update, using UUID, date/time, or slug as available - defp find_db_post_for_update(group_slug, post) do - cond do - # If we have a UUID, use it directly (most reliable) - post[:uuid] -> - DBStorage.get_post_by_uuid(post[:uuid], [:group]) - - # Timestamp-mode: use date/time - post[:mode] in @timestamp_modes && post[:date] && post[:time] -> - DBStorage.get_post_by_datetime(group_slug, post[:date], post[:time]) - - # Slug-mode: use slug - post[:slug] -> - DBStorage.get_post(group_slug, post[:slug]) - - true -> - nil - end - end - - defp maybe_update_db_slug(db_post, desired_slug, _group_slug) - when desired_slug == db_post.slug do - {:ok, db_post.slug} - end - - defp maybe_update_db_slug(db_post, desired_slug, group_slug) do - with {:ok, valid_slug} <- SlugHelpers.validate_slug(desired_slug), - false <- SlugHelpers.slug_exists?(group_slug, valid_slug), - {:ok, _} <- DBStorage.update_post(db_post, %{slug: valid_slug}) do - {:ok, valid_slug} - else - true -> - {:error, :slug_already_exists} - - {:error, %Ecto.Changeset{} = changeset} -> - Logger.warning("[Publishing] slug update changeset error: #{inspect(changeset.errors)}") - - if Keyword.has_key?(changeset.errors, :slug), - do: {:error, :slug_already_exists}, - else: {:error, :db_update_failed} - - {:error, reason} -> - Logger.warning("[Publishing] slug update failed: #{inspect(reason)}") - {:error, reason} - end - end - - defp do_update_post_in_db(db_post, post, params, group_slug, final_slug, audit_meta) do - version_number = post[:version] || 1 - version = DBStorage.get_version(db_post.uuid, version_number) - - if version do - language = post[:language] || db_post.primary_language - post_metadata = post[:metadata] || %{} - new_status = Map.get(params, "status", post_metadata[:status] || "draft") - content = Map.get(params, "content", post[:content] || "") - new_title = resolve_post_title(params, post, content) - - with :ok <- validate_title_for_publish(db_post, language, new_status, new_title), - old_db_status = db_post.status, - :ok <- update_post_level_fields(db_post, new_status, params, audit_meta), - :ok <- - upsert_post_content(version, language, new_title, content, new_status, params, post) do - maybe_propagate_status(version, language, db_post, new_status, old_db_status) - read_updated_post(db_post, group_slug, final_slug, language, version_number) - end - else - {:error, :not_found} - end - end - - @default_title Constants.default_title() - - defp validate_title_for_publish(db_post, language, "published", title) - when title in ["", @default_title] do - if language == db_post.primary_language, - do: {:error, :title_required}, - else: :ok - end - - defp validate_title_for_publish(_db_post, _language, _status, _title), do: :ok - - defp read_updated_post(db_post, group_slug, final_slug, language, version_number) do - if db_post.mode == "timestamp" do - DBStorage.read_post_by_datetime( - group_slug, - db_post.post_date, - db_post.post_time, - language, - version_number - ) - else - DBStorage.read_post(group_slug, final_slug, language, version_number) - end - end - - defp resolve_post_title(params, post, _content) do - post_metadata = post[:metadata] || %{} - - Map.get(params, "title") || - post_metadata[:title] || - Constants.default_title() - end - - defp update_post_level_fields(db_post, new_status, params, audit_meta) do - update_attrs = - %{ - status: new_status, - published_at: parse_published_at(params, db_post) - } - |> maybe_put(:updated_by_uuid, audit_meta[:updated_by_uuid]) - |> maybe_put(:updated_by_email, audit_meta[:updated_by_email]) - - case DBStorage.update_post(db_post, update_attrs) do - {:ok, _} -> :ok - {:error, reason} -> {:error, reason} - end - end - - defp maybe_put(map, _key, nil), do: map - defp maybe_put(map, key, value), do: Map.put(map, key, value) - - defp upsert_post_content(version, language, new_title, content, new_status, params, post) do - existing_content = DBStorage.get_content(version.uuid, language) - existing_url_slug = if existing_content, do: existing_content.url_slug - existing_data = if existing_content, do: existing_content.data || %{}, else: %{} - - resolved_url_slug = - case Map.fetch(params, "url_slug") do - {:ok, val} -> val - :error -> existing_url_slug - end - - case DBStorage.upsert_content(%{ - version_uuid: version.uuid, - language: language, - title: new_title, - content: content, - status: new_status, - url_slug: resolved_url_slug, - data: build_content_data(params, post, existing_data) - }) do - {:ok, _} -> :ok - {:error, reason} -> {:error, reason} - end - end - - defp maybe_propagate_status(version, language, db_post, new_status, old_db_status) do - is_primary = language == db_post.primary_language - - if is_primary and new_status != old_db_status do - propagate_db_status_to_translations(version.uuid, language, new_status) - end - end - - defp propagate_db_status_to_translations(version_uuid, primary_language, new_status) do - DBStorage.update_content_status_except(version_uuid, primary_language, new_status) - end - - defp parse_published_at(params, db_post) do - case Map.get(params, "published_at") do - nil -> - db_post.published_at - - "" -> - db_post.published_at - - dt_string when is_binary(dt_string) -> - case DateTime.from_iso8601(dt_string) do - {:ok, dt, _} -> dt - _ -> db_post.published_at - end - - dt -> - dt - end - end - - defp build_content_data(params, post, existing_data) do - # Start from existing data to preserve previous_url_slugs, excerpt, seo_title, etc. - data = existing_data - - data = - case Map.get(params, "featured_image_uuid") do - nil -> data - id -> Map.put(data, "featured_image_uuid", id) - end - - post_metadata = post[:metadata] || %{} - - case Map.get(params, "description", post_metadata[:description]) do - nil -> data - desc -> Map.put(data, "description", desc) - end - end - - # Only drop group prefix if there are more elements after it - # This prevents dropping the post slug when it matches the group slug - defp drop_group_prefix([group_slug | rest], group_slug) when rest != [], do: rest - defp drop_group_prefix(list, _), do: list -end diff --git a/lib/modules/publishing/presence.ex b/lib/modules/publishing/presence.ex deleted file mode 100644 index 5c9d01f6f..000000000 --- a/lib/modules/publishing/presence.ex +++ /dev/null @@ -1,34 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.Presence do - @moduledoc """ - Presence tracking for collaborative post editing. - - Uses Phoenix.Presence to track who is currently editing a post. - The first person to join a topic becomes the "owner" (can edit), and everyone else - becomes "spectators" (read-only mode). - - ## How It Works - - 1. When a user opens the editor, they join a Presence topic (e.g., "publishing_edit:docs:post-slug") - 2. Presence tracks all connected users with metadata (user info, joined_at timestamp) - 3. Users are sorted by joined_at to determine order (FIFO) - 4. First user in the sorted list = owner (readonly?: false) - 5. All other users = spectators (readonly?: true) - 6. When owner leaves, Presence removes them automatically - 7. All connected users receive presence_diff event - 8. Each user re-evaluates: "Am I first now?" - 9. New first user auto-promotes to owner - - ## Automatic Cleanup - - Phoenix.Presence automatically detects when LiveView processes die and removes - them immediately via process monitoring. No manual cleanup needed. - - ## Topics - - - Post editing: "publishing_edit:" - """ - - use Phoenix.Presence, - otp_app: :phoenix_kit, - pubsub_server: :phoenix_kit_internal_pubsub -end diff --git a/lib/modules/publishing/presence_helpers.ex b/lib/modules/publishing/presence_helpers.ex deleted file mode 100644 index 416543d43..000000000 --- a/lib/modules/publishing/presence_helpers.ex +++ /dev/null @@ -1,190 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.PresenceHelpers do - @moduledoc """ - Helper functions for collaborative post editing with Phoenix.Presence. - - Provides utilities for tracking editing sessions, determining owner/spectator roles, - and syncing state between users. - """ - - alias PhoenixKit.Modules.Publishing.Presence - - @doc """ - Tracks the current LiveView process in a Presence topic. - - ## Parameters - - - `form_key`: The unique key for the post being edited - - `socket`: The LiveView socket - - `user`: The current user struct - - ## Examples - - track_editing_session("blog:my-post:en", socket, user) - # => {:ok, ref} - """ - def track_editing_session(form_key, socket, user) do - topic = editing_topic(form_key) - - Presence.track(self(), topic, socket.id, %{ - user_uuid: user.uuid, - user_email: user.email, - user: user, - joined_at: System.system_time(:millisecond), - phx_ref: socket.id, - pid: self(), - transport_pid: socket.transport_pid - }) - end - - @doc """ - Untracks the current LiveView process from a Presence topic. - - Call this when switching languages or versions to release the lock - on the previous form before tracking the new one. - - ## Parameters - - - `form_key`: The unique key for the post that was being edited - - `socket`: The LiveView socket - - ## Examples - - untrack_editing_session("blog:my-post:en", socket) - # => :ok - """ - def untrack_editing_session(form_key, socket) do - topic = editing_topic(form_key) - Presence.untrack(self(), topic, socket.id) - end - - @doc """ - Unsubscribes from presence events and editor form events for a form. - - Call this when switching languages or versions to clean up subscriptions. - """ - def unsubscribe_from_editing(form_key) do - topic = editing_topic(form_key) - Phoenix.PubSub.unsubscribe(:phoenix_kit_internal_pubsub, topic) - end - - @doc """ - Determines if the current socket is the owner (first in the presence list). - - Returns `{:owner, presences}` if this socket is the owner (or same user in different tab), or - `{:spectator, owner_meta, presences}` if a different user is the owner. - - ## Examples - - case get_editing_role("blog:my-post", socket.id, current_user.uuid) do - {:owner, all_presences} -> - # I can edit! - - {:spectator, owner_metadata, all_presences} -> - # I'm read-only, sync with owner's state - end - """ - def get_editing_role(form_key, socket_id, current_user_uuid) do - presences = get_sorted_presences(form_key) - - case presences do - [] -> - # No one here (shouldn't happen since caller is here) - # But treat as owner to avoid blocking - {:owner, []} - - [{^socket_id, _meta} | _rest] -> - # I'm first! I'm the owner - {:owner, presences} - - [{_other_socket_id, owner_meta} | _rest] -> - # Check if same user (different tab) or different user - if owner_meta.user_uuid == current_user_uuid do - # Same user, different tab - treat as owner so both tabs can edit - {:owner, presences} - else - # Different user - spectator mode (FIFO locking) - {:spectator, owner_meta, presences} - end - end - end - - @doc """ - Gets all presences for a form, sorted by join time (FIFO). - - Returns a list of tuples: `[{socket_id, metadata}, ...]` - """ - def get_sorted_presences(form_key) do - topic = editing_topic(form_key) - raw_presences = Presence.list(topic) - - raw_presences - |> Enum.flat_map(fn {socket_id, %{metas: metas}} -> - # Filter out metas with dead PIDs - valid_metas = - Enum.filter(metas, fn meta -> - case Map.get(meta, :pid) do - pid when is_pid(pid) -> Process.alive?(pid) - # Keep metas without PID - _ -> true - end - end) - - # Take the first valid meta (most recent) - case valid_metas do - [meta | _] -> [{socket_id, meta}] - [] -> [] - end - end) - |> Enum.sort_by(fn {_socket_id, meta} -> meta.joined_at end) - end - - @doc """ - Gets the lock owner's metadata, or nil if no one is editing. - """ - def get_lock_owner(form_key) do - case get_sorted_presences(form_key) do - [{_socket_id, meta} | _] -> meta - [] -> nil - end - end - - @doc """ - Gets all spectators (everyone except the first person). - - Returns a list of metadata for spectators only. - """ - def get_spectators(form_key) do - case get_sorted_presences(form_key) do - [] -> [] - [_owner | spectators] -> Enum.map(spectators, fn {_id, meta} -> meta end) - end - end - - @doc """ - Counts total number of people editing (owner + spectators). - """ - def count_editors(form_key) do - get_sorted_presences(form_key) |> length() - end - - @doc """ - Subscribes the current process to presence events for a form. - - After subscribing, the process will receive: - - `%Phoenix.Socket.Broadcast{event: "presence_diff", ...}` when users join/leave - """ - def subscribe_to_editing(form_key) do - topic = editing_topic(form_key) - Phoenix.PubSub.subscribe(:phoenix_kit_internal_pubsub, topic) - end - - @doc """ - Generates the Presence topic name for a form. - - ## Examples - - editing_topic("docs:my-post:en") - # => "publishing_edit:docs:my-post:en" - """ - def editing_topic(form_key), do: "publishing_edit:#{form_key}" -end diff --git a/lib/modules/publishing/publishing.ex b/lib/modules/publishing/publishing.ex deleted file mode 100644 index a38665da8..000000000 --- a/lib/modules/publishing/publishing.ex +++ /dev/null @@ -1,414 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing do - @moduledoc """ - Publishing module for managing content groups and their posts. - - Database-backed CMS for creating timestamped or slug-based posts - with multi-language support and versioning. - - This module acts as a facade, delegating to focused submodules: - - - `Publishing.Groups` — Group CRUD - - `Publishing.Posts` — Post CRUD, reading, and listing - - `Publishing.Versions` — Version create, publish, delete - - `Publishing.TranslationManager` — Language/translation management - - `Publishing.StaleFixer` — Stale value detection and repair - """ - - use PhoenixKit.Module - - require Logger - - alias PhoenixKit.Dashboard.Tab - alias PhoenixKit.Modules.Languages - alias PhoenixKit.Modules.Publishing.DBStorage - alias PhoenixKit.Modules.Publishing.LanguageHelpers - alias PhoenixKit.Modules.Publishing.SlugHelpers - # ============================================================================ - # Language Utility Delegates - # ============================================================================ - - defdelegate get_language_info(language_code), to: LanguageHelpers - defdelegate enabled_language_codes(), to: LanguageHelpers - defdelegate get_primary_language(), to: LanguageHelpers - defdelegate language_enabled?(language_code, enabled_languages), to: LanguageHelpers - defdelegate get_display_code(language_code, enabled_languages), to: LanguageHelpers - - defdelegate order_languages_for_display(available_languages, enabled_languages), - to: LanguageHelpers - - defdelegate order_languages_for_display(available_languages, enabled_languages, primary), - to: LanguageHelpers - - # ============================================================================ - # Slug Utility Delegates - # ============================================================================ - - defdelegate validate_slug(slug), to: SlugHelpers - defdelegate slug_exists?(group_slug, post_slug), to: SlugHelpers - defdelegate generate_unique_slug(group_slug, title), to: SlugHelpers - defdelegate generate_unique_slug(group_slug, title, preferred_slug), to: SlugHelpers - defdelegate generate_unique_slug(group_slug, title, preferred_slug, opts), to: SlugHelpers - defdelegate validate_url_slug(group_slug, url_slug, language, exclude), to: SlugHelpers - defdelegate clear_url_slug_from_post(group_slug, post_slug, url_slug), to: DBStorage - - # ============================================================================ - # Cache Delegates - # ============================================================================ - - alias PhoenixKit.Modules.Publishing.ListingCache - - defdelegate regenerate_cache(group_slug), to: ListingCache, as: :regenerate - defdelegate invalidate_cache(group_slug), to: ListingCache, as: :invalidate - defdelegate cache_exists?(group_slug), to: ListingCache, as: :exists? - defdelegate find_cached_post(group_slug, post_slug), to: ListingCache, as: :find_post - - defdelegate find_cached_post_by_path(group_slug, date, time), - to: ListingCache, - as: :find_post_by_path - - # ============================================================================ - # Group Delegates - # ============================================================================ - - alias PhoenixKit.Modules.Publishing.Groups - - defdelegate list_groups(), to: Groups - defdelegate list_groups(status), to: Groups - defdelegate get_group(slug), to: Groups - defdelegate add_group(name, opts \\ []), to: Groups - defdelegate remove_group(slug), to: Groups - defdelegate remove_group(slug, opts), to: Groups - defdelegate update_group(slug, params), to: Groups - defdelegate trash_group(slug), to: Groups - defdelegate group_name(slug), to: Groups - defdelegate get_group_mode(group_slug), to: Groups - defdelegate preset_types(), to: Groups - defdelegate valid_types(), to: Groups - defdelegate restore_group(slug), to: Groups - defdelegate list_trashed_groups(), to: Groups - - # ============================================================================ - # Post Delegates - # ============================================================================ - - alias PhoenixKit.Modules.Publishing.Posts - - defdelegate list_posts(group_slug, preferred_language \\ nil), to: Posts - defdelegate list_posts_by_status(group_slug, status), to: Posts - defdelegate list_raw_posts(group_slug, status \\ nil), to: Posts - defdelegate count_primary_language_status(posts, primary_language), to: Posts - defdelegate create_post(group_slug, opts \\ %{}), to: Posts - defdelegate read_post(group_slug, identifier, language \\ nil, version \\ nil), to: Posts - defdelegate read_post_by_uuid(post_uuid, language \\ nil, version \\ nil), to: Posts - defdelegate update_post(group_slug, post, params, opts \\ %{}), to: Posts - defdelegate change_post_status(group_slug, post_uuid, new_status, opts \\ []), to: Posts - defdelegate trash_post(group_slug, post_uuid), to: Posts - defdelegate restore_post(group_slug, post_uuid), to: Posts - defdelegate count_posts_on_date(group_slug, date), to: Posts - defdelegate list_times_on_date(group_slug, date), to: Posts - defdelegate read_post_by_datetime(group_slug, date, time), to: DBStorage - defdelegate find_by_url_slug(group_slug, language, url_slug), to: Posts - defdelegate find_by_previous_url_slug(group_slug, language, url_slug), to: Posts - defdelegate extract_slug_version_and_language(group_slug, identifier), to: Posts - - @doc "Always returns false — auto-versioning is disabled." - def should_create_new_version?(_post, _params, _editing_language), do: false - - @doc "Returns true when the given post is a DB-backed post (has a UUID)." - @spec db_post?(map()) :: boolean() - defdelegate db_post?(post), to: Posts - - # ============================================================================ - # Version Delegates - # ============================================================================ - - alias PhoenixKit.Modules.Publishing.Versions - - defdelegate list_versions(group_slug, post_slug), to: Versions - defdelegate get_published_version(group_slug, post_slug), to: Versions - defdelegate get_version_status(group_slug, post_slug, version_number, language), to: Versions - defdelegate get_version_metadata(group_slug, post_slug, version_number, language), to: Versions - - defdelegate create_new_version(group_slug, source_post, params \\ %{}, opts \\ %{}), - to: Versions - - defdelegate publish_version(group_slug, post_uuid, version, opts \\ []), to: Versions - - defdelegate create_version_from( - group_slug, - post_uuid, - source_version, - params \\ %{}, - opts \\ %{} - ), - to: Versions - - defdelegate delete_version(group_slug, post_uuid, version), to: Versions - @doc false - defdelegate broadcast_version_created(group_slug, broadcast_id, new_version), to: Versions - - # ============================================================================ - # Translation Delegates - # ============================================================================ - - alias PhoenixKit.Modules.Publishing.TranslationManager - - defdelegate get_post_primary_language(group_slug, post_slug, version \\ nil), - to: TranslationManager - - defdelegate check_primary_language_status(group_slug, post_slug), to: TranslationManager - - defdelegate update_post_primary_language(group_slug, post_uuid, new_primary_language), - to: TranslationManager - - defdelegate update_posts_primary_language(group_slug), to: TranslationManager - defdelegate count_posts_needing_language_update(group_slug), to: TranslationManager - - defdelegate add_language_to_post(group_slug, post_uuid, language_code, version \\ nil), - to: TranslationManager - - @doc false - defdelegate add_language_to_db(group_slug, post_uuid, language_code, version_number), - to: TranslationManager - - defdelegate delete_language(group_slug, post_uuid, language_code, version \\ nil), - to: TranslationManager - - defdelegate clear_translation(group_slug, post_uuid, language_code), to: TranslationManager - - defdelegate set_translation_status(group_slug, post_identifier, version, language, status), - to: TranslationManager - - defdelegate translate_post_to_all_languages(group_slug, post_uuid, opts \\ []), - to: TranslationManager - - # ============================================================================ - # Stale Value Correction Delegates - # ============================================================================ - - alias PhoenixKit.Modules.Publishing.StaleFixer - - defdelegate fix_stale_group(group), to: StaleFixer - defdelegate fix_stale_post(post), to: StaleFixer - defdelegate fix_stale_version(version), to: StaleFixer - defdelegate fix_stale_content(content), to: StaleFixer - defdelegate fix_all_stale_values(), to: StaleFixer - defdelegate reconcile_post_status(post), to: StaleFixer - - # ============================================================================ - # Module Behaviour Callbacks - # ============================================================================ - - @publishing_enabled_key "publishing_enabled" - - @impl PhoenixKit.Module - @spec enabled?() :: boolean() - def enabled? do - settings_call(:get_boolean_setting, [@publishing_enabled_key, false]) - end - - @impl PhoenixKit.Module - @spec enable_system() :: {:ok, any()} | {:error, any()} - def enable_system do - settings_call(:update_boolean_setting, [@publishing_enabled_key, true]) - end - - @impl PhoenixKit.Module - @spec disable_system() :: {:ok, any()} | {:error, any()} - def disable_system do - settings_call(:update_boolean_setting, [@publishing_enabled_key, false]) - end - - @impl PhoenixKit.Module - def module_key, do: "publishing" - - @impl PhoenixKit.Module - def module_name, do: "Publishing" - - @impl PhoenixKit.Module - def get_config do - %{ - enabled: enabled?(), - groups_count: length(list_groups()) - } - end - - @impl PhoenixKit.Module - def permission_metadata do - %{ - key: "publishing", - label: "Publishing", - icon: "hero-document-duplicate", - description: "Database-backed CMS pages and multi-language content" - } - end - - @impl PhoenixKit.Module - def admin_tabs do - [ - Tab.new!( - id: :admin_publishing, - label: "Publishing", - icon: "hero-document-text", - path: "publishing", - priority: 600, - level: :admin, - permission: "publishing", - match: :prefix, - group: :admin_modules, - subtab_display: :when_active, - highlight_with_subtabs: false, - dynamic_children: &__MODULE__.publishing_children/1 - ) - ] - end - - @doc "Dynamic children function for Publishing sidebar tabs." - def publishing_children(_scope) do - groups = load_publishing_groups_for_tabs() - - groups - |> Enum.with_index() - |> Enum.map(fn {group, idx} -> - slug = group["slug"] || "" - name = group["name"] || slug - hash = :erlang.phash2(slug) |> Integer.to_string(16) |> String.downcase() - sanitized = slug |> String.replace(~r/[^a-zA-Z0-9_]/, "_") |> String.slice(0, 50) - - %Tab{ - id: :"admin_publishing_#{sanitized}_#{hash}", - label: name, - icon: "hero-document-text", - path: "publishing/#{slug}", - priority: 601 + idx, - level: :admin, - permission: "publishing", - match: :prefix, - parent: :admin_publishing - } - end) - rescue - e -> - Logger.warning("[Publishing] dashboard_tabs failed: #{inspect(e)}") - [] - end - - defp load_publishing_groups_for_tabs do - alias PhoenixKit.Settings - - publishing_enabled = Settings.get_boolean_setting("publishing_enabled", false) - - if publishing_enabled do - alias PhoenixKit.Modules.Publishing.DBStorage - - DBStorage.list_groups() - |> Enum.map(fn g -> %{"name" => g.name, "slug" => g.slug} end) - else - [] - end - rescue - e -> - Logger.warning("[Publishing] load_publishing_groups_for_tabs failed: #{inspect(e)}") - [] - end - - @impl PhoenixKit.Module - def settings_tabs do - [ - Tab.new!( - id: :admin_settings_publishing, - label: "Publishing", - icon: "hero-document-text", - path: "publishing", - priority: 921, - level: :admin, - parent: :admin_settings, - permission: "publishing" - ) - ] - end - - @impl PhoenixKit.Module - def children, do: [PhoenixKit.Modules.Publishing.Presence] - - @impl PhoenixKit.Module - def route_module, do: PhoenixKitWeb.Routes.PublishingRoutes - - # ============================================================================ - # Shared Helpers (used across submodules) - # ============================================================================ - - alias PhoenixKit.Modules.Publishing.Shared - - @slug_regex ~r/^[a-z0-9]+(?:-[a-z0-9]+)*$/ - - @doc false - def slugify(name) when is_binary(name) do - name - |> String.downcase() - |> String.replace(~r/[^a-z0-9]+/u, "-") - |> String.trim("-") - end - - @doc """ - Returns true when the slug matches the allowed lowercase letters, numbers, and hyphen pattern, - and is not a reserved language code. - - Group slugs cannot be language codes (like 'en', 'es', 'fr') to prevent routing ambiguity. - """ - @spec valid_slug?(String.t()) :: boolean() - def valid_slug?(slug) when is_binary(slug) do - slug != "" and Regex.match?(@slug_regex, slug) and not reserved_language_code?(slug) - end - - def valid_slug?(_), do: false - - defp reserved_language_code?(slug) do - language_codes = - try do - Languages.get_language_codes() - rescue - e -> - Logger.debug( - "[Publishing] reserved_language_code? check failed, assuming no reserved codes: #{inspect(e)}" - ) - - [] - end - - slug in language_codes - end - - @doc false - defdelegate fetch_option(opts, key), to: Shared - - @doc false - defdelegate audit_metadata(scope, action), to: Shared - - # ============================================================================ - # Settings Helpers (private) - # ============================================================================ - - defp settings_module do - case PhoenixKit.Config.get(:publishing_settings_module) do - :not_found -> PhoenixKit.Settings - {:ok, module} -> module - end - end - - defp settings_call(fun, args) do - module = settings_module() - - case fun do - :get_json_setting_cached -> - if function_exported?(module, :get_json_setting_cached, length(args)) do - apply(module, :get_json_setting_cached, args) - else - apply(module, :get_json_setting, args) - end - - _ -> - apply(module, fun, args) - end - end -end diff --git a/lib/modules/publishing/pubsub.ex b/lib/modules/publishing/pubsub.ex deleted file mode 100644 index 0a3b626d6..000000000 --- a/lib/modules/publishing/pubsub.ex +++ /dev/null @@ -1,550 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.PubSub do - @moduledoc """ - PubSub integration for real-time publishing updates. - - Provides broadcasting and subscription for post changes, - enabling live updates across all connected admin clients. - - ## Features - - - Post lifecycle events (create, update, delete, status change) - - Collaborative editing with real-time form state sync - - Owner/spectator model for concurrent editing - """ - - alias PhoenixKit.PubSub.Manager - - @topic_prefix "publishing" - @topic_editor_forms "publishing:editor_forms" - @topic_groups "publishing:groups" - - # ============================================================================ - # Post Identifier Resolution - # ============================================================================ - - @doc """ - Returns the broadcast identifier for a post. - - Uses slug when available, falls back to uuid. This identifier is used - for PubSub topic construction and must be consistent between broadcasters - (e.g. translation worker) and subscribers (e.g. editor). - """ - def broadcast_id(post) do - post[:slug] || post[:uuid] - end - - # ============================================================================ - # Group-Level Updates (group creation/deletion) - # ============================================================================ - - @doc """ - Returns the topic for global group updates (create, delete). - """ - def groups_topic, do: @topic_groups - - @doc """ - Subscribes the current process to group updates (creation/deletion). - """ - def subscribe_to_groups do - Manager.subscribe(groups_topic()) - end - - @doc """ - Unsubscribes the current process from group updates. - """ - def unsubscribe_from_groups do - Manager.unsubscribe(groups_topic()) - end - - @doc """ - Broadcasts a group created event. - """ - def broadcast_group_created(group) do - Manager.broadcast(groups_topic(), {:group_created, group}) - end - - @doc """ - Broadcasts a group deleted event. - """ - def broadcast_group_deleted(group_slug) do - Manager.broadcast(groups_topic(), {:group_deleted, group_slug}) - end - - @doc """ - Broadcasts a group updated event. - """ - def broadcast_group_updated(group) do - Manager.broadcast(groups_topic(), {:group_updated, group}) - end - - # ============================================================================ - # Post List Updates (simple refresh) - # ============================================================================ - - @doc """ - Returns the topic for a specific group's posts. - """ - def posts_topic(group_slug) do - "#{@topic_prefix}:#{group_slug}:posts" - end - - @doc """ - Subscribes the current process to post updates for a group. - """ - def subscribe_to_posts(group_slug) do - Manager.subscribe(posts_topic(group_slug)) - end - - @doc """ - Unsubscribes the current process from post updates for a group. - """ - def unsubscribe_from_posts(group_slug) do - Manager.unsubscribe(posts_topic(group_slug)) - end - - @doc """ - Broadcasts a post created event. - """ - def broadcast_post_created(group_slug, post) do - Manager.broadcast(posts_topic(group_slug), {:post_created, post}) - end - - @doc """ - Broadcasts a post updated event. - """ - def broadcast_post_updated(group_slug, post) do - Manager.broadcast(posts_topic(group_slug), {:post_updated, post}) - end - - @doc """ - Broadcasts a post deleted event. - """ - def broadcast_post_deleted(group_slug, post_identifier) do - Manager.broadcast(posts_topic(group_slug), {:post_deleted, post_identifier}) - end - - @doc """ - Broadcasts a post status changed event. - """ - def broadcast_post_status_changed(group_slug, post) do - Manager.broadcast(posts_topic(group_slug), {:post_status_changed, post}) - end - - @doc """ - Broadcasts that a new version was created for a post. - """ - def broadcast_version_created(group_slug, post) do - Manager.broadcast(posts_topic(group_slug), {:version_created, post}) - end - - @doc """ - Broadcasts that the live version changed for a post. - """ - def broadcast_version_live_changed(group_slug, post_identifier, version) do - Manager.broadcast(posts_topic(group_slug), {:version_live_changed, post_identifier, version}) - end - - @doc """ - Broadcasts that a version was deleted from a post. - """ - def broadcast_version_deleted(group_slug, post_identifier, version) do - Manager.broadcast(posts_topic(group_slug), {:version_deleted, post_identifier, version}) - end - - # ============================================================================ - # Post-Level Updates (version and translation changes) - # ============================================================================ - - @doc """ - Returns the topic for a specific post's version updates. - This allows editors to receive notifications when versions are created/deleted. - """ - def post_versions_topic(group_slug, post_slug) do - "#{@topic_prefix}:#{group_slug}:post:#{post_slug}:versions" - end - - @doc """ - Subscribes to version updates for a specific post. - """ - def subscribe_to_post_versions(group_slug, post_slug) do - Manager.subscribe(post_versions_topic(group_slug, post_slug)) - end - - @doc """ - Unsubscribes from version updates for a specific post. - """ - def unsubscribe_from_post_versions(group_slug, post_slug) do - Manager.unsubscribe(post_versions_topic(group_slug, post_slug)) - end - - @doc """ - Broadcasts that a new version was created for a post (to post-level topic). - """ - def broadcast_post_version_created(group_slug, post_slug, version_info) do - Manager.broadcast( - post_versions_topic(group_slug, post_slug), - {:post_version_created, group_slug, post_slug, version_info} - ) - end - - @doc """ - Broadcasts that a version was deleted from a post (to post-level topic). - """ - def broadcast_post_version_deleted(group_slug, post_slug, version) do - Manager.broadcast( - post_versions_topic(group_slug, post_slug), - {:post_version_deleted, group_slug, post_slug, version} - ) - end - - @doc """ - Broadcasts that the live/published version changed (to post-level topic). - Includes source_id so receivers can ignore their own broadcasts. - """ - def broadcast_post_version_published(group_slug, post_slug, version, source_id \\ nil) do - Manager.broadcast( - post_versions_topic(group_slug, post_slug), - {:post_version_published, group_slug, post_slug, version, source_id} - ) - end - - @doc """ - Returns the topic for a specific post's translation updates. - This allows all editors of different language versions to receive updates - when new translations are added. - """ - def post_translations_topic(group_slug, post_slug) do - "#{@topic_prefix}:#{group_slug}:post:#{post_slug}:translations" - end - - @doc """ - Subscribes to translation updates for a specific post. - """ - def subscribe_to_post_translations(group_slug, post_slug) do - Manager.subscribe(post_translations_topic(group_slug, post_slug)) - end - - @doc """ - Unsubscribes from translation updates for a specific post. - """ - def unsubscribe_from_post_translations(group_slug, post_slug) do - Manager.unsubscribe(post_translations_topic(group_slug, post_slug)) - end - - @doc """ - Broadcasts that a new translation was created for a post. - """ - def broadcast_translation_created(group_slug, post_slug, language) do - Manager.broadcast( - post_translations_topic(group_slug, post_slug), - {:translation_created, group_slug, post_slug, language} - ) - end - - @doc """ - Broadcasts that a translation was deleted from a post. - """ - def broadcast_translation_deleted(group_slug, post_slug, language) do - Manager.broadcast( - post_translations_topic(group_slug, post_slug), - {:translation_deleted, group_slug, post_slug, language} - ) - end - - # ============================================================================ - # Editor Save Sync (last-save-wins model) - # ============================================================================ - - @doc """ - Broadcasts that a post was saved, so other editors can reload. - - The `source` is the socket.id of the saver, so they don't reload their own save. - """ - def broadcast_editor_saved(form_key, source) do - Manager.broadcast( - editor_form_topic(form_key), - {:editor_saved, form_key, source} - ) - end - - # ============================================================================ - # Collaborative Editor (real-time form sync) - # ============================================================================ - - @doc """ - Returns the topic for a specific editor form. - - The form_key uniquely identifies a post being edited: - - For existing posts: "group_slug:post_path" or "group_slug:slug" - - For new posts: "group_slug:new:language" - """ - def editor_form_topic(form_key) do - "#{@topic_editor_forms}:#{form_key}" - end - - @doc """ - Returns the presence topic for tracking editors of a post. - """ - def editor_presence_topic(form_key) do - "publishing:presence:editor:#{form_key}" - end - - @doc """ - Subscribes to collaborative events for a specific editor form. - """ - def subscribe_to_editor_form(form_key) do - Manager.subscribe(editor_form_topic(form_key)) - end - - @doc """ - Unsubscribes from collaborative events for a specific editor form. - """ - def unsubscribe_from_editor_form(form_key) do - Manager.unsubscribe(editor_form_topic(form_key)) - end - - @doc """ - Broadcasts a form state change to all subscribers. - - Options: - - `:source` - The source identifier to prevent self-echoing - """ - def broadcast_editor_form_change(form_key, payload, opts \\ []) do - Manager.broadcast( - editor_form_topic(form_key), - {:editor_form_change, form_key, payload, Keyword.get(opts, :source)} - ) - end - - @doc """ - Broadcasts a sync request for new joiners to get current state. - """ - def broadcast_editor_sync_request(form_key, requester_socket_id) do - Manager.broadcast( - editor_form_topic(form_key), - {:editor_sync_request, form_key, requester_socket_id} - ) - end - - @doc """ - Broadcasts a sync response with current form state. - """ - def broadcast_editor_sync_response(form_key, requester_socket_id, state) do - Manager.broadcast( - editor_form_topic(form_key), - {:editor_sync_response, form_key, requester_socket_id, state} - ) - end - - # ============================================================================ - # Cache Updates (for live admin UI updates) - # ============================================================================ - - @doc """ - Returns the topic for cache updates for a specific group. - """ - def cache_topic(group_slug) do - "#{@topic_prefix}:#{group_slug}:cache" - end - - @doc """ - Subscribes the current process to cache updates for a group. - """ - def subscribe_to_cache(group_slug) do - Manager.subscribe(cache_topic(group_slug)) - end - - @doc """ - Unsubscribes the current process from cache updates for a group. - """ - def unsubscribe_from_cache(group_slug) do - Manager.unsubscribe(cache_topic(group_slug)) - end - - @doc """ - Broadcasts that the cache state has changed (cache regenerated, memory loaded, etc). - """ - def broadcast_cache_changed(group_slug) do - Manager.broadcast(cache_topic(group_slug), {:cache_changed, group_slug}) - end - - # ============================================================================ - # AI Translation Progress - # ============================================================================ - - @doc """ - Broadcasts that AI translation has started. - Sent to both posts_topic (for group listing) and post_translations_topic (for editor). - """ - def broadcast_translation_started(group_slug, post_slug, target_languages) do - payload = {:translation_started, group_slug, post_slug, target_languages} - - # Broadcast to group listing - Manager.broadcast( - posts_topic(group_slug), - {:translation_started, post_slug, length(target_languages)} - ) - - # Broadcast to editor (more detailed info) - Manager.broadcast(post_translations_topic(group_slug, post_slug), payload) - end - - @doc """ - Broadcasts AI translation progress (after each language completes). - Sent to both posts_topic (for group listing) and post_translations_topic (for editor). - """ - def broadcast_translation_progress(group_slug, post_slug, completed, total, last_language) do - # Broadcast to group listing - Manager.broadcast( - posts_topic(group_slug), - {:translation_progress, post_slug, completed, total} - ) - - # Broadcast to editor (more detailed info) - Manager.broadcast( - post_translations_topic(group_slug, post_slug), - {:translation_progress, group_slug, post_slug, completed, total, last_language} - ) - end - - @doc """ - Broadcasts that AI translation has completed (success or partial failure). - Sent to both posts_topic (for group listing) and post_translations_topic (for editor). - """ - def broadcast_translation_completed(group_slug, post_slug, results) do - # Broadcast to group listing - Manager.broadcast( - posts_topic(group_slug), - {:translation_completed, post_slug, results} - ) - - # Broadcast to editor - Manager.broadcast( - post_translations_topic(group_slug, post_slug), - {:translation_completed, group_slug, post_slug, results} - ) - end - - # ============================================================================ - # Editor Presence for Group Listing - # ============================================================================ - - @doc """ - Returns the global topic for editor activity across a group. - Used by group listing to show who's editing what. - """ - def group_editors_topic(group_slug) do - "#{@topic_prefix}:#{group_slug}:editors" - end - - @doc """ - Subscribes to editor activity for a group (used by group listing). - """ - def subscribe_to_group_editors(group_slug) do - Manager.subscribe(group_editors_topic(group_slug)) - end - - @doc """ - Unsubscribes from editor activity for a group. - """ - def unsubscribe_from_group_editors(group_slug) do - Manager.unsubscribe(group_editors_topic(group_slug)) - end - - @doc """ - Broadcasts that a user started editing a post. - """ - def broadcast_editor_joined(group_slug, post_slug, user_info) do - Manager.broadcast( - group_editors_topic(group_slug), - {:editor_joined, post_slug, user_info} - ) - end - - @doc """ - Broadcasts that a user stopped editing a post. - """ - def broadcast_editor_left(group_slug, post_slug, user_info) do - Manager.broadcast( - group_editors_topic(group_slug), - {:editor_left, post_slug, user_info} - ) - end - - # ============================================================================ - # Form Key Helpers - # ============================================================================ - - @doc """ - Generates a form key for a post being edited. - - The form key includes the language to allow concurrent editing of different - translations of the same post. - - ## Examples - - generate_form_key("blog", %{path: "blog/my-post/v1/en"}) - # => "blog:blog/my-post/v1/en" - - generate_form_key("blog", %{slug: "my-post", language: "en"}) - # => "blog:my-post:en" - - generate_form_key("blog", %{slug: "my-post", language: "en"}, :new) - # => "blog:new:en" - """ - def generate_form_key(group_slug, post, mode \\ :edit) - - # UUID-based form key (preferred for DB posts) - def generate_form_key(group_slug, %{uuid: uuid, language: lang}, :edit) - when is_binary(uuid) and is_binary(lang) do - "#{group_slug}:#{uuid}:#{lang}" - end - - # Path already includes language (e.g., "blog/my-post/v1/en") - def generate_form_key(group_slug, %{path: path}, :edit) when is_binary(path) do - "#{group_slug}:#{path}" - end - - # Slug mode - include language for per-language locking - def generate_form_key(group_slug, %{slug: slug, language: lang}, :edit) - when is_binary(slug) and is_binary(lang) do - "#{group_slug}:#{slug}:#{lang}" - end - - # Fallback for slug without language (shouldn't happen in practice) - def generate_form_key(group_slug, %{slug: slug}, :edit) when is_binary(slug) do - "#{group_slug}:#{slug}" - end - - def generate_form_key(group_slug, %{language: lang}, :new) do - "#{group_slug}:new:#{lang}" - end - - def generate_form_key(group_slug, _post, :new) do - "#{group_slug}:new" - end - - def generate_form_key(group_slug, _, _) do - "#{group_slug}:unknown" - end - - # ============================================================================ - # Primary Language Migration Progress - # ============================================================================ - - @doc """ - Broadcasts that primary language migration has completed. - """ - def broadcast_primary_language_migration_completed( - group_slug, - success_count, - error_count, - primary_language - ) do - Manager.broadcast( - posts_topic(group_slug), - {:primary_language_migration_completed, group_slug, success_count, error_count, - primary_language} - ) - end -end diff --git a/lib/modules/publishing/renderer.ex b/lib/modules/publishing/renderer.ex deleted file mode 100644 index 2dc53480a..000000000 --- a/lib/modules/publishing/renderer.ex +++ /dev/null @@ -1,596 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.Renderer do - @moduledoc """ - Renders publishing post markdown to HTML with caching support. - - Uses PhoenixKit.Cache for performance optimization of markdown rendering. - Cache keys include content hashes for automatic invalidation. - """ - - require Logger - - alias Phoenix.HTML.Safe - alias PhoenixKit.Modules.Publishing.PageBuilder - alias PhoenixKit.Modules.Shared.Components.EntityForm - alias PhoenixKit.Modules.Shared.Components.Image - alias PhoenixKit.Modules.Shared.Components.Video - alias PhoenixKit.Settings - - @cache_name :publishing_posts - @cache_version "v2" - - @global_cache_key "publishing_render_cache_enabled" - @per_group_cache_prefix "publishing_render_cache_enabled_" - - @component_regex ~r/<(Image|Hero|CTA|Headline|Subheadline|Video|EntityForm)\s+([^>]*?)\/>/s - @component_block_regex ~r/<(Hero|CTA|Headline|Subheadline|Video|EntityForm)\s*([^>]*)>(.*?)<\/\1>/s - - # Tailwind/daisyUI classes for post-processing Earmark HTML output. - # Code blocks (pre, code) are handled separately in style_code_blocks/1. - @pre_classes "bg-base-300 p-4 rounded-lg overflow-x-auto my-4" - @inline_code_classes "bg-base-200 px-1.5 py-0.5 rounded text-sm font-mono" - - @tag_classes [ - {"h1", "text-4xl font-bold mt-6 mb-4 pb-2 border-b border-base-content/10"}, - {"h2", "text-3xl font-semibold mt-6 mb-3"}, - {"h3", "text-2xl font-semibold mt-5 mb-2"}, - {"h4", "text-xl font-semibold mt-4 mb-2"}, - {"h5", "text-lg font-semibold mt-4 mb-2"}, - {"h6", "text-base font-semibold mt-4 mb-2"}, - {"p", "my-4 leading-relaxed"}, - {"a", "link link-primary"}, - {"blockquote", "border-l-4 border-primary pl-4 my-4 text-base-content/70 italic"}, - {"table", "table w-full my-4"}, - {"thead", "bg-base-200"}, - {"th", "font-semibold text-left p-2"}, - {"td", "border-t border-base-content/10 p-2"}, - {"img", "max-w-full h-auto rounded-lg my-4"}, - {"ul", "list-disc pl-8 my-4"}, - {"ol", "list-decimal pl-8 my-4"}, - {"li", "my-1"}, - {"hr", "my-8 border-0 border-t-2 border-base-content/10"} - ] - - # Build {regex_source, tag, classes} tuples at compile time. - # Regex structs can't be stored in module attributes, so we store the source - # strings and compile them once at runtime via a persistent cache. - @tag_patterns Enum.map(@tag_classes, fn {tag, classes} -> - {"<#{Regex.escape(tag)}(?=[\\s>\\/])([^>]*)>", tag, classes} - end) - - @doc """ - Renders a post's markdown content to HTML. - - Caches the result for published posts using content-hash-based keys. - Lazy-loads cache (only caches after first render). - - Respects `publishing_render_cache_enabled` (global) and - `publishing_render_cache_enabled_{group_slug}` (per-group) settings. - - ## Examples - - {:ok, html} = Renderer.render_post(post) - - """ - def render_post(post) do - if post.metadata.status == "published" and render_cache_enabled?(post.group) do - cache_key = build_cache_key(post) - - case get_cached(cache_key) do - {:ok, html} -> - {:ok, html} - - :miss -> - render_and_cache(post, cache_key) - end - else - # Don't cache drafts, archived posts, or when cache is disabled - {:ok, render_markdown(post.content)} - end - end - - @doc """ - Returns whether render caching is enabled for a group. - - Checks both the global setting and per-group setting. - Both must be enabled (or default to enabled) for caching to work. - """ - @spec render_cache_enabled?(String.t()) :: boolean() - def render_cache_enabled?(group_slug) do - global_enabled = global_render_cache_enabled?() - per_group_enabled = group_render_cache_enabled?(group_slug) - - global_enabled and per_group_enabled - end - - @doc """ - Returns whether the global render cache is enabled. - """ - @spec global_render_cache_enabled?() :: boolean() - def global_render_cache_enabled? do - Settings.get_setting_cached(@global_cache_key, "true") == "true" - end - - @doc """ - Returns whether render cache is enabled for a specific group. - Does not check the global setting. - """ - @spec group_render_cache_enabled?(String.t()) :: boolean() - def group_render_cache_enabled?(group_slug) do - key = @per_group_cache_prefix <> group_slug - Settings.get_setting_cached(key, "true") == "true" - end - - @doc """ - Returns the settings key for per-group render cache. - Used by other modules that need to write to the setting. - """ - @spec per_group_cache_key(String.t()) :: String.t() - def per_group_cache_key(group_slug), do: @per_group_cache_prefix <> group_slug - - @doc """ - Renders markdown or PHK content directly without caching. - - Automatically detects PHK XML format and routes to PageBuilder. - Falls back to Earmark markdown rendering for non-XML content. - - ## Examples - - html = Renderer.render_markdown(content) - - """ - def render_markdown(content) when is_binary(content) do - {time, result} = - :timer.tc(fn -> - cond do - pure_phk_content?(content) -> - render_phk_content(content) - - has_embedded_components?(content) -> - render_mixed_content(content) - - true -> - render_earmark_markdown(content) - end - end) - - Logger.debug("Content render time: #{time}μs", content_size: byte_size(content)) - result - end - - def render_markdown(_), do: "" - - # Detect if content is pure PHK XML format (starts with or ) - defp pure_phk_content?(content) do - trimmed = String.trim(content) - String.starts_with?(trimmed, " - # Convert Phoenix.LiveView.Rendered to string - html - |> Safe.to_iodata() - |> IO.iodata_to_binary() - - {:error, reason} -> - Logger.warning("PHK render error: #{inspect(reason)}") - "

Error rendering page content

" - end - end - - # Render markdown using Earmark, then inject Tailwind/daisyUI classes on each tag. - defp render_earmark_markdown(content) do - content = normalize_markdown(content) - - case Earmark.as_html(content, %Earmark.Options{ - code_class_prefix: "language-", - smartypants: true, - gfm: true, - escape: false - }) do - {:ok, html, _warnings} -> add_tailwind_classes(html) - {:error, _html, _errors} -> ~s(

Error rendering markdown

) - end - end - - defp normalize_markdown(content) when is_binary(content) do - content - # Remove leading indentation before Markdown headings (e.g., " ## Title") - |> then(&Regex.replace(~r/^[ \t]+(?=#)/m, &1, "")) - # Preserve intentional blank lines: convert runs of 2+ blank lines into - # visible spacing so the rendered output matches what the author typed. - # A single blank line remains a normal paragraph break (standard Markdown). - |> preserve_blank_lines() - end - - # Converts sequences of 2+ consecutive blank lines into paragraph breaks - # with
spacers. Each extra blank line beyond the first becomes one
. - defp preserve_blank_lines(content) do - Regex.replace(~r/\n{3,}/, content, fn match -> - # Number of extra blank lines beyond the standard paragraph break - # \n\n = 1 blank line (normal paragraph break), \n\n\n = 2 blank lines, etc. - extra_lines = String.length(match) - 2 - br_tags = String.duplicate(" \n\n", extra_lines) - "\n\n#{br_tags}" - end) - end - - # ============================================================================ - # Tailwind Class Injection - # ============================================================================ - - # Adds Tailwind/daisyUI classes to rendered HTML tags so markdown content - # is styled without requiring a prose plugin or inline - - -
-
-

Secure Login Link

-
- -

Hi #{user.email},

- -

Click the button below to securely log in to your account:

- -

- Log In Securely -

- -
- ⚠️ Important: This link will expire in 15 minutes and can only be used once. -
- -

If you didn't request this login link, you can safely ignore this email.

- -

For your security, never share this link with anyone.

- - -
- - - - """ - end - # Text version of the magic link email - defp magic_link_text_body(%User{} = user, magic_link_url) do + defp magic_link_text_body(_user, magic_link_url) do """ - Secure Login Link - - Hi #{user.email}, - - Click the link below to securely log in to your account: - - #{magic_link_url} - - ⚠️ Important: This link will expire in 15 minutes and can only be used once. - - If you didn't request this login link, you can safely ignore this email. - - For your security, never share this link with anyone. + Your login link: #{magic_link_url} + This link expires in 15 minutes. """ end - # Handle delivery result for email tracking updates - defp handle_delivery_result(email, result, opts) do - # Only process if email tracking is enabled - if Emails.enabled?() do - case extract_log_uuid_from_email(email) do - nil -> - # No log UUID found, skip tracking - :ok - - log_uuid -> - case Emails.get_log(log_uuid) do - nil -> :ok - log -> update_log_after_delivery(log, result, opts) - end - end - end - rescue - # Don't fail email delivery if tracking update fails - error -> - Logger.error("Failed to update email tracking after delivery: #{inspect(error)}") - :ok - end - - # Extract log UUID from email headers - defp extract_log_uuid_from_email(email) do - case get_in(email.headers, ["X-PhoenixKit-Log-Id"]) do - nil -> nil - log_uuid when is_binary(log_uuid) -> log_uuid - end - end - - # Update email log based on delivery result - defp update_log_after_delivery(log, {:ok, response}, _opts) do - Interceptor.update_after_send(log, response) - end - - defp update_log_after_delivery(log, {:error, error}, _opts) do - Interceptor.update_after_failure(log, error) - end - - defp update_log_after_delivery(_log, _result, _opts) do - # Unknown result format, skip update - :ok - end - # Detect current email provider from configuration defp detect_provider do mailer = get_mailer() @@ -421,220 +307,18 @@ defmodule PhoenixKit.Mailer do defp detect_builtin_provider do config = PhoenixKit.Config.get(PhoenixKit.Mailer, []) adapter = Keyword.get(config, :adapter) - EmailUtils.adapter_to_provider_name(adapter, "phoenix_kit_builtin") + Provider.current().adapter_to_provider_name(adapter, "phoenix_kit_builtin") end # Detect provider for parent application mailer defp detect_parent_app_provider(mailer) when is_atom(mailer) do config = PhoenixKit.Config.get_parent_app_config(mailer, []) adapter = Keyword.get(config, :adapter) - EmailUtils.adapter_to_provider_name(adapter, "parent_app_mailer") + Provider.current().adapter_to_provider_name(adapter, "parent_app_mailer") end defp detect_parent_app_provider(_mailer), do: "unknown" - @doc """ - Send a test tracking email to verify email delivery and tracking functionality. - - Uses the 'test_email' template from the database if available, - falls back to hardcoded template if not found. - - This function sends a test email with test links - to verify that the email tracking system is working correctly. - - ## Parameters - - - `recipient_email` - The email address to send the test email to - - `user_uuid` - Optional user UUID to associate with the test email (default: nil) - - ## Returns - - - `{:ok, %Swoosh.Email{}}` - Email sent successfully - - `{:error, reason}` - Email failed to send - - ## Examples - - iex> PhoenixKit.Mailer.send_test_tracking_email("admin@example.com") - {:ok, %Swoosh.Email{}} - - iex> PhoenixKit.Mailer.send_test_tracking_email("admin@example.com", "019...") - {:ok, %Swoosh.Email{}} - - """ - def send_test_tracking_email(recipient_email, user_uuid \\ nil) - when is_binary(recipient_email) do - timestamp = UtilsDate.utc_now() |> DateTime.to_string() - test_link_url = Routes.url("/admin/emails") - - # Variables for template substitution - template_variables = %{ - "recipient_email" => recipient_email, - "timestamp" => timestamp, - "test_link_url" => test_link_url - } - - # Try to get template from database, fallback to hardcoded - {subject, html_body, text_body} = - case Templates.get_active_template_by_name("test_email") do - nil -> - # Fallback to hardcoded templates - { - "Test Tracking Email - #{timestamp}", - test_email_html_body(recipient_email, timestamp), - test_email_text_body(recipient_email, timestamp) - } - - template -> - # Use database template with variable substitution - rendered = Templates.render_template(template, template_variables) - {rendered.subject, rendered.html_body, rendered.text_body} - end - - email = - new() - |> to(recipient_email) - |> from({get_from_name(), get_from_email()}) - |> subject(subject) - |> html_body(html_body) - |> text_body(text_body) - - # Track template usage if using database template - case Templates.get_active_template_by_name("test_email") do - # No template to track - nil -> :ok - template -> Templates.track_usage(template) - end - - deliver_email(email, - user_uuid: user_uuid, - template_name: "test_email", - campaign_id: "test", - category: "system", - source_module: "admin", - provider: detect_provider() - ) - end - - # HTML version of the test tracking email - defp test_email_html_body(recipient_email, timestamp) do - test_link_url = Routes.url("/admin/emails") - - """ - - - - - - Test Tracking Email - - - -
-
-

📧 Test Tracking Email

-

Email Tracking System Verification

-
- -
-
- ✅ Success! This test email was sent successfully through the PhoenixKit email tracking system. -
- -

Hello,

- -

This is a test email to verify that your email tracking system is working correctly. If you received this email, it means:

- -
    -
  • ✅ Email delivery is working
  • -
  • ✅ AWS SES configuration is correct (if using SES)
  • -
  • ✅ Email tracking is enabled and logging
  • -
  • ✅ Configuration set is properly configured
  • -
- -
- 📊 Tracking Information: -
- Recipient: #{recipient_email}
- Sent at: #{timestamp}
- Campaign: test
- Template: test_email -
-
- - - -

Click any of the buttons above to test link tracking. Then check your emails in the admin panel to see the tracking data.

- -
- - -
- - - """ - end - - # Text version of the test tracking email - defp test_email_text_body(recipient_email, timestamp) do - test_link_url = Routes.url("/admin/emails") - - """ - TEST TRACKING EMAIL - EMAIL SYSTEM VERIFICATION - - Success! This test email was sent successfully through the PhoenixKit email tracking system. - - Hello, - - This is a test email to verify that your email tracking system is working correctly. If you received this email, it means: - - ✅ Email delivery is working - ✅ AWS SES configuration is correct (if using SES) - ✅ Email tracking is enabled and logging - ✅ Configuration set is properly configured - - TRACKING INFORMATION: - --------------------- - Recipient: #{recipient_email} - Sent at: #{timestamp} - Campaign: test - Template: test_email - - TEST LINKS: - ----------- - Test these tracking features by visiting: - - Test Link 1: #{test_link_url}?test=link1 - Test Link 2: #{test_link_url}?test=link2 - Test Link 3: #{test_link_url}?test=link3 - - Click any of the links above to test link tracking. Then check your emails in the admin panel to see the tracking data. - - --- - This is an automated test email from PhoenixKit Email Tracking System. - Check your admin panel at: #{test_link_url} - """ - end - # Get the from email address from configuration or use a default # Priority: Settings Database > Config file > Default defp get_from_email do diff --git a/lib/phoenix_kit/migrations/postgres.ex b/lib/phoenix_kit/migrations/postgres.ex index 215e5fd6e..5e98269d6 100644 --- a/lib/phoenix_kit/migrations/postgres.ex +++ b/lib/phoenix_kit/migrations/postgres.ex @@ -529,7 +529,11 @@ 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 - ### V84 - Rename mailing tables to newsletters ⚡ LATEST + ### V85 - Add system_prompt to AI prompts ⚡ LATEST + - Adds `system_prompt` (text) column to `phoenix_kit_ai_prompts` + - Allows storing system-level instructions separately from user prompt content + + ### V84 - Rename mailing tables to newsletters - 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 @@ -650,7 +654,7 @@ defmodule PhoenixKit.Migrations.Postgres do use Ecto.Migration @initial_version 1 - @current_version 84 + @current_version 85 @default_prefix "public" @doc false diff --git a/lib/phoenix_kit/migrations/postgres/v15.ex b/lib/phoenix_kit/migrations/postgres/v15.ex index c112b7ed0..5ff6c8df2 100644 --- a/lib/phoenix_kit/migrations/postgres/v15.ex +++ b/lib/phoenix_kit/migrations/postgres/v15.ex @@ -24,8 +24,6 @@ defmodule PhoenixKit.Migrations.Postgres.V15 do """ use Ecto.Migration - alias Mix.Tasks.PhoenixKit.SeedTemplates - @doc """ Run the V15 migration to add email templates system. """ @@ -130,10 +128,13 @@ defmodule PhoenixKit.Migrations.Postgres.V15 do # Private function to seed system email templates defp seed_system_templates do - case Code.ensure_loaded(SeedTemplates) do + seed_mod = Mix.Tasks.PhoenixKit.SeedTemplates + + case Code.ensure_loaded(seed_mod) do {:module, _} -> try do - SeedTemplates.run(["--quiet"]) + # credo:disable-for-next-line Credo.Check.Refactor.Apply + apply(seed_mod, :run, [["--quiet"]]) rescue error -> # Log the error but don't fail the migration diff --git a/lib/phoenix_kit/migrations/postgres/v31.ex b/lib/phoenix_kit/migrations/postgres/v31.ex index 1ceaff5fa..6c9c63642 100644 --- a/lib/phoenix_kit/migrations/postgres/v31.ex +++ b/lib/phoenix_kit/migrations/postgres/v31.ex @@ -44,8 +44,6 @@ defmodule PhoenixKit.Migrations.Postgres.V31 do """ use Ecto.Migration - alias PhoenixKit.Modules.Emails.Templates - @doc """ Run the V31 migration to add the billing system. """ @@ -768,16 +766,22 @@ defmodule PhoenixKit.Migrations.Postgres.V31 do # Seed billing email templates (billing_invoice and billing_receipt) if they don't exist defp seed_billing_invoice_template do - case Code.ensure_loaded(Templates) do + templates_mod = PhoenixKit.Modules.Emails.Templates + + case Code.ensure_loaded(templates_mod) do {:module, _} -> try do # Check if billing templates already exist - invoice_exists = Templates.get_template_by_name("billing_invoice") != nil - receipt_exists = Templates.get_template_by_name("billing_receipt") != nil + # Module loaded dynamically — apply/3 required to avoid compile-time warnings + # credo:disable-for-next-line Credo.Check.Refactor.Apply + invoice_exists = apply(templates_mod, :get_template_by_name, ["billing_invoice"]) != nil + # credo:disable-for-next-line Credo.Check.Refactor.Apply + receipt_exists = apply(templates_mod, :get_template_by_name, ["billing_receipt"]) != nil # If any template is missing, run seed to create all missing system templates unless invoice_exists and receipt_exists do - Templates.seed_system_templates() + # credo:disable-for-next-line Credo.Check.Refactor.Apply + apply(templates_mod, :seed_system_templates, []) end :ok diff --git a/lib/phoenix_kit/migrations/postgres/v85.ex b/lib/phoenix_kit/migrations/postgres/v85.ex new file mode 100644 index 000000000..84eab6721 --- /dev/null +++ b/lib/phoenix_kit/migrations/postgres/v85.ex @@ -0,0 +1,60 @@ +defmodule PhoenixKit.Migrations.Postgres.V85 do + @moduledoc """ + V85: Add system_prompt field to AI prompts table. + + Adds an optional system_prompt column to `phoenix_kit_ai_prompts` for + storing system-level instructions separately from the user prompt content. + """ + + 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 + + execute(""" + DO $$ + BEGIN + IF EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = '#{schema}' AND table_name = 'phoenix_kit_ai_prompts' + ) AND NOT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = '#{schema}' + AND table_name = 'phoenix_kit_ai_prompts' + AND column_name = 'system_prompt' + ) THEN + ALTER TABLE #{p}phoenix_kit_ai_prompts ADD COLUMN system_prompt TEXT; + END IF; + END $$; + """) + + execute("COMMENT ON TABLE #{p}phoenix_kit IS '85'") + end + + 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 EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = '#{schema}' + AND table_name = 'phoenix_kit_ai_prompts' + AND column_name = 'system_prompt' + ) THEN + ALTER TABLE #{p}phoenix_kit_ai_prompts DROP COLUMN system_prompt; + END IF; + END $$; + """) + + execute("COMMENT ON TABLE #{p}phoenix_kit IS '84'") + end + + defp prefix_str("public"), do: "public." + defp prefix_str(prefix), do: "#{prefix}." +end diff --git a/lib/phoenix_kit/module.ex b/lib/phoenix_kit/module.ex index f8d18ddbb..00eecc2e4 100644 --- a/lib/phoenix_kit/module.ex +++ b/lib/phoenix_kit/module.ex @@ -109,6 +109,21 @@ defmodule PhoenixKit.Module do @callback migration_module() :: module() | nil @callback required_modules() :: [String.t()] + @doc """ + Returns the OTP app name for Tailwind CSS source scanning. + + The installer uses this to generate the correct `@source` directive in the + parent app's `app.css`. It automatically resolves the right path based on + whether the dep is installed from Hex (`deps/`) or as a path dep. + + ## Example + + def css_sources, do: [:phoenix_kit_publishing] + + Headless modules (no templates) can skip this callback — the default is `[]`. + """ + @callback css_sources() :: [String.t()] + @optional_callbacks [ get_config: 0, permission_metadata: 0, @@ -119,7 +134,8 @@ defmodule PhoenixKit.Module do route_module: 0, version: 0, migration_module: 0, - required_modules: 0 + required_modules: 0, + css_sources: 0 ] defmacro __using__(_opts) do @@ -162,6 +178,9 @@ defmodule PhoenixKit.Module do @impl PhoenixKit.Module def required_modules, do: [] + @impl PhoenixKit.Module + def css_sources, do: [] + defoverridable get_config: 0, permission_metadata: 0, admin_tabs: 0, @@ -171,7 +190,8 @@ defmodule PhoenixKit.Module do route_module: 0, version: 0, migration_module: 0, - required_modules: 0 + required_modules: 0, + css_sources: 0 end end end diff --git a/lib/phoenix_kit/module_registry.ex b/lib/phoenix_kit/module_registry.ex index 46c0d7a6c..3593dc2e1 100644 --- a/lib/phoenix_kit/module_registry.ex +++ b/lib/phoenix_kit/module_registry.ex @@ -401,18 +401,14 @@ defmodule PhoenixKit.ModuleRegistry do # remove it from this list and add it to :modules config instead. defp internal_modules do [ - PhoenixKit.Modules.AI, PhoenixKit.Modules.Billing, PhoenixKit.Modules.Comments, PhoenixKit.Modules.Connections, PhoenixKit.Modules.DB, - PhoenixKit.Modules.Emails, - PhoenixKit.Modules.Entities, PhoenixKit.Modules.Languages, PhoenixKit.Modules.Legal, PhoenixKit.Modules.Maintenance, PhoenixKit.Modules.Pages, - PhoenixKit.Modules.Publishing, PhoenixKit.Modules.Referrals, PhoenixKit.Modules.SEO, PhoenixKit.Modules.Shop, @@ -456,6 +452,46 @@ defmodule PhoenixKit.ModuleRegistry do "Blog posts, tags, groups, likes, media attachments, and scheduled publishing.", icon: "📝", hex_url: "https://hex.pm/packages/phoenix_kit_posts" + }, + %{ + module: PhoenixKit.Modules.Emails, + key: "emails", + hex_package: "phoenix_kit_emails", + name: "Emails", + description: + "Email tracking, templates, SQS integration, blocklist, and delivery analytics.", + icon: "📨", + hex_url: "https://hex.pm/packages/phoenix_kit_emails" + }, + %{ + module: PhoenixKit.Modules.Publishing, + key: "publishing", + hex_package: "phoenix_kit_publishing", + name: "Publishing", + description: + "Content publishing with groups, multilingual support, versioning, and collaborative editing.", + icon: "📰", + hex_url: "https://hex.pm/packages/phoenix_kit_publishing" + }, + %{ + module: PhoenixKitEntities, + key: "entities", + hex_package: "phoenix_kit_entities", + name: "Entities", + description: + "Custom data entities with fields, forms, multilingual support, and data navigation.", + icon: "🧩", + hex_url: "https://hex.pm/packages/phoenix_kit_entities" + }, + %{ + module: PhoenixKitAI, + key: "ai", + hex_package: "phoenix_kit_ai", + name: "AI", + description: + "AI endpoint management, prompt templates, completions via OpenRouter, and usage tracking.", + icon: "🤖", + hex_url: "https://hex.pm/packages/phoenix_kit_ai" } ] end diff --git a/lib/phoenix_kit/supervisor.ex b/lib/phoenix_kit/supervisor.ex index 07d7c8091..96aa7a10b 100644 --- a/lib/phoenix_kit/supervisor.ex +++ b/lib/phoenix_kit/supervisor.ex @@ -48,11 +48,6 @@ defmodule PhoenixKit.Supervisor do name: :settings, sync_init: true, warmer: &PhoenixKit.Settings.warm_cache_data/0}, id: :settings_cache ), - # Cache rendered blog posts (HTML) to avoid re-rendering markdown on every request - Supervisor.child_spec( - {PhoenixKit.Cache, name: :publishing_posts, ttl: :timer.hours(6)}, - id: :publishing_posts_cache - ), # Dashboard tab registry for user dashboard navigation. # Starts after settings_cache so module enabled? checks hit cache rather than DB. PhoenixKit.Dashboard.Registry, diff --git a/lib/phoenix_kit/users/auth/user_notifier.ex b/lib/phoenix_kit/users/auth/user_notifier.ex index 23974578f..d73b70268 100644 --- a/lib/phoenix_kit/users/auth/user_notifier.ex +++ b/lib/phoenix_kit/users/auth/user_notifier.ex @@ -26,8 +26,8 @@ defmodule PhoenixKit.Users.Auth.UserNotifier do """ import Swoosh.Email + alias PhoenixKit.Email.Provider alias PhoenixKit.Mailer - alias PhoenixKit.Modules.Emails.Templates # Delivers the email using the appropriate mailer. # Uses the configured parent application mailer if available, @@ -103,15 +103,11 @@ defmodule PhoenixKit.Users.Auth.UserNotifier do "confirmation_url" => url } - # Try to get template from database, fallback to hardcoded - {subject, html_body, text_body} = - case Templates.get_active_template_by_name("register") do + # Try to get template from database, fallback to text-only + {subject, html_body, text_body, db_template} = + case Provider.current().get_active_template_by_name("register") do nil -> - # Fallback to hardcoded templates fallback_text = """ - - ============================== - Hi #{user.email}, You can confirm your account by visiting the URL below: @@ -119,28 +115,16 @@ defmodule PhoenixKit.Users.Auth.UserNotifier do #{url} If you didn't create an account with us, please ignore this. - - ============================== """ - { - "Confirm your account", - confirmation_html_body(user.email, url), - fallback_text - } + {"Confirm your account", nil, fallback_text, nil} template -> - # Use database template with variable substitution - rendered = Templates.render_template(template, template_variables) - {rendered.subject, rendered.html_body, rendered.text_body} + rendered = Provider.current().render_template(template, template_variables) + {rendered.subject, rendered.html_body, rendered.text_body, template} end - # Track template usage if using database template - case Templates.get_active_template_by_name("register") do - # No template to track - nil -> :ok - template -> Templates.track_usage(template) - end + if db_template, do: Provider.current().track_usage(db_template) deliver(user.email, subject, text_body, html_body) end @@ -158,15 +142,11 @@ defmodule PhoenixKit.Users.Auth.UserNotifier do "reset_url" => url } - # Try to get template from database, fallback to hardcoded - {subject, html_body, text_body} = - case Templates.get_active_template_by_name("reset_password") do + # Try to get template from database, fallback to text-only + {subject, html_body, text_body, db_template} = + case Provider.current().get_active_template_by_name("reset_password") do nil -> - # Fallback to hardcoded templates fallback_text = """ - - ============================== - Hi #{user.email}, You can reset your password by visiting the URL below: @@ -174,28 +154,16 @@ defmodule PhoenixKit.Users.Auth.UserNotifier do #{url} If you didn't request this change, please ignore this. - - ============================== """ - { - "Reset your password", - reset_password_html_body(user.email, url), - fallback_text - } + {"Reset your password", nil, fallback_text, nil} template -> - # Use database template with variable substitution - rendered = Templates.render_template(template, template_variables) - {rendered.subject, rendered.html_body, rendered.text_body} + rendered = Provider.current().render_template(template, template_variables) + {rendered.subject, rendered.html_body, rendered.text_body, template} end - # Track template usage if using database template - case Templates.get_active_template_by_name("reset_password") do - # No template to track - nil -> :ok - template -> Templates.track_usage(template) - end + if db_template, do: Provider.current().track_usage(db_template) deliver(user.email, subject, text_body, html_body) end @@ -213,15 +181,11 @@ defmodule PhoenixKit.Users.Auth.UserNotifier do "update_url" => url } - # Try to get template from database, fallback to hardcoded - {subject, html_body, text_body} = - case Templates.get_active_template_by_name("update_email") do + # Try to get template from database, fallback to text-only + {subject, html_body, text_body, db_template} = + case Provider.current().get_active_template_by_name("update_email") do nil -> - # Fallback to hardcoded templates fallback_text = """ - - ============================== - Hi #{user.email}, You can change your email by visiting the URL below: @@ -229,177 +193,20 @@ defmodule PhoenixKit.Users.Auth.UserNotifier do #{url} If you didn't request this change, please ignore this. - - ============================== """ - { - "Confirm your email change", - update_email_html_body(user.email, url), - fallback_text - } + {"Confirm your email change", nil, fallback_text, nil} template -> - # Use database template with variable substitution - rendered = Templates.render_template(template, template_variables) - {rendered.subject, rendered.html_body, rendered.text_body} + rendered = Provider.current().render_template(template, template_variables) + {rendered.subject, rendered.html_body, rendered.text_body, template} end - # Track template usage if using database template - case Templates.get_active_template_by_name("update_email") do - # No template to track - nil -> :ok - template -> Templates.track_usage(template) - end + if db_template, do: Provider.current().track_usage(db_template) deliver(user.email, subject, text_body, html_body) end - # HTML template for account confirmation email - defp confirmation_html_body(email, url) do - """ - - - - - - Confirm Your Account - - - -
-
-

Welcome! Please confirm your account

-
- -

Hi #{email},

- -

Thank you for creating an account! To complete your registration, please confirm your email address by clicking the button below:

- -

- Confirm My Account -

- -
- ℹ️ Note: This confirmation link is secure and will verify your email address. -
- -

If you didn't create an account with us, you can safely ignore this email.

- - -
- - - """ - end - - # HTML template for password reset email - defp reset_password_html_body(email, url) do - """ - - - - - - Reset Your Password - - - -
-
-

Password Reset Request

-
- -

Hi #{email},

- -

We received a request to reset your password. Click the button below to create a new password:

- -

- Reset My Password -

- -
- ⚠️ Security Notice: This password reset link will expire soon for your security. -
- -

If you didn't request this password reset, you can safely ignore this email. Your password will remain unchanged.

- - -
- - - """ - end - - # HTML template for email update confirmation - defp update_email_html_body(email, url) do - """ - - - - - - Confirm Email Change - - - -
-
-

Confirm Your Email Change

-
- -

Hi #{email},

- -

We received a request to change your email address. To complete this change, please confirm your new email address by clicking the button below:

- -

- Confirm Email Change -

- -
- ✓ Verification Required: This step ensures your new email address is valid and accessible. -
- -

If you didn't request this email change, you can safely ignore this message. Your current email address will remain unchanged.

- - -
- - - """ - end - @doc """ Deliver magic link registration instructions. @@ -420,15 +227,11 @@ defmodule PhoenixKit.Users.Auth.UserNotifier do "registration_url" => url } - # Try to get template from database, fallback to hardcoded - {subject, html_body, text_body} = - case Templates.get_active_template_by_name("magic_link_registration") do + # Try to get template from database, fallback to text-only + {subject, html_body, text_body, db_template} = + case Provider.current().get_active_template_by_name("magic_link_registration") do nil -> - # Fallback to hardcoded templates fallback_text = """ - - ============================== - Hi #{email}, Welcome! To complete your registration, please click the link below: @@ -438,78 +241,17 @@ defmodule PhoenixKit.Users.Auth.UserNotifier do This link will expire in 30 minutes for your security. If you didn't request this registration, please ignore this email. - - ============================== """ - { - "Complete Your Registration", - magic_link_registration_html_body(email, url), - fallback_text - } + {"Complete Your Registration", nil, fallback_text, nil} template -> - # Use database template with variable substitution - rendered = Templates.render_template(template, template_variables) - {rendered.subject, rendered.html_body, rendered.text_body} + rendered = Provider.current().render_template(template, template_variables) + {rendered.subject, rendered.html_body, rendered.text_body, template} end - # Track template usage if using database template - case Templates.get_active_template_by_name("magic_link_registration") do - # No template to track - nil -> :ok - template -> Templates.track_usage(template) - end + if db_template, do: Provider.current().track_usage(db_template) deliver(email, subject, text_body, html_body) end - - # HTML template for magic link registration email - defp magic_link_registration_html_body(email, url) do - """ - - - - - - Complete Your Registration - - - -
-
-

Welcome! Complete Your Registration

-
- -

Hi #{email},

- -

Thank you for starting your registration! Click the button below to complete your account setup:

- -

- Complete Registration -

- -
- ℹ️ Security Note: This registration link will expire in 30 minutes and can only be used once. -
- -

If you didn't request this registration, you can safely ignore this email.

- - -
- - - """ - end end diff --git a/lib/modules/entities/html_sanitizer.ex b/lib/phoenix_kit/utils/html_sanitizer.ex similarity index 88% rename from lib/modules/entities/html_sanitizer.ex rename to lib/phoenix_kit/utils/html_sanitizer.ex index fb7b4f24f..adf96ced8 100644 --- a/lib/modules/entities/html_sanitizer.ex +++ b/lib/phoenix_kit/utils/html_sanitizer.ex @@ -1,4 +1,4 @@ -defmodule PhoenixKit.Modules.Entities.HtmlSanitizer do +defmodule PhoenixKit.Utils.HtmlSanitizer do @moduledoc """ HTML sanitization for rich text content in entities. @@ -25,10 +25,10 @@ defmodule PhoenixKit.Modules.Entities.HtmlSanitizer do ## Usage - iex> PhoenixKit.Modules.Entities.HtmlSanitizer.sanitize("

Hello

") + iex> PhoenixKit.Utils.HtmlSanitizer.sanitize("

Hello

") "

Hello

" - iex> PhoenixKit.Modules.Entities.HtmlSanitizer.sanitize("Click") + iex> PhoenixKit.Utils.HtmlSanitizer.sanitize("Click") "Click" """ @@ -58,7 +58,7 @@ defmodule PhoenixKit.Modules.Entities.HtmlSanitizer do ## Examples - iex> PhoenixKit.Modules.Entities.HtmlSanitizer.sanitize("

Hello

") + iex> PhoenixKit.Utils.HtmlSanitizer.sanitize("

Hello

") "

Hello

" """ def sanitize(nil), do: nil @@ -88,7 +88,7 @@ defmodule PhoenixKit.Modules.Entities.HtmlSanitizer do iex> fields = [%{"type" => "rich_text", "key" => "content"}] iex> data = %{"content" => "

Hello

"} - iex> PhoenixKit.Modules.Entities.HtmlSanitizer.sanitize_rich_text_fields(fields, data) + iex> PhoenixKit.Utils.HtmlSanitizer.sanitize_rich_text_fields(fields, data) %{"content" => "

Hello

"} """ def sanitize_rich_text_fields(fields_definition, data) diff --git a/lib/modules/entities/multilang.ex b/lib/phoenix_kit/utils/multilang.ex similarity index 99% rename from lib/modules/entities/multilang.ex rename to lib/phoenix_kit/utils/multilang.ex index 704f63fc9..e60ed2f39 100644 --- a/lib/modules/entities/multilang.ex +++ b/lib/phoenix_kit/utils/multilang.ex @@ -1,4 +1,4 @@ -defmodule PhoenixKit.Modules.Entities.Multilang do +defmodule PhoenixKit.Utils.Multilang do @moduledoc """ Multi-language data transformation helpers for entity data JSONB. diff --git a/lib/phoenix_kit_web/components/core/cookie_consent.ex b/lib/phoenix_kit_web/components/core/cookie_consent.ex index ed04a93b4..2ae48bbf0 100644 --- a/lib/phoenix_kit_web/components/core/cookie_consent.ex +++ b/lib/phoenix_kit_web/components/core/cookie_consent.ex @@ -75,6 +75,13 @@ defmodule PhoenixKitWeb.Components.Core.CookieConsent do attr :policy_version, :string, default: "1.0", doc: "Policy version for consent tracking" attr :cookie_policy_url, :string, default: "/legal/cookie-policy" attr :privacy_policy_url, :string, default: "/legal/privacy-policy" + + attr :legal_links, :list, + default: [], + doc: "Dynamic list of %{title, url} for published legal pages" + + attr :legal_index_url, :string, default: "/legal", doc: "URL to legal pages index" + attr :google_consent_mode, :boolean, default: false, doc: "Enable Google Consent Mode v2" attr :class, :string, default: "" @@ -173,7 +180,7 @@ defmodule PhoenixKitWeb.Components.Core.CookieConsent do } .pk-glass { - background: oklch(var(--b1) / 0.95); + background: oklch(var(--b1) / 0.98); backdrop-filter: blur(20px) saturate(180%); -webkit-backdrop-filter: blur(20px) saturate(180%); border: 1px solid var(--pk-border); @@ -190,25 +197,6 @@ defmodule PhoenixKitWeb.Components.Core.CookieConsent do transform: translateY(-2px); box-shadow: 0 4px 12px oklch(var(--bc) / 0.1); } - - .pk-toggle-track { - background: var(--pk-border); - transition: background-color 0.2s ease; - } - - .pk-toggle-track.active { - background: var(--pk-primary); - } - - .pk-toggle-thumb { - background: var(--pk-bg); - box-shadow: 0 1px 3px oklch(var(--bc) / 0.2); - transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1); - } - - input:checked + .pk-toggle-track .pk-toggle-thumb { - transform: translateX(20px); - } <%!-- Floating Icon (only for opt-in frameworks) --%> @@ -254,17 +242,16 @@ defmodule PhoenixKitWeb.Components.Core.CookieConsent do

{gettext("We value your privacy")}

-

+

{gettext( "We use cookies to enhance your browsing experience and analyze our traffic." )} {" "} - {gettext("Cookie Policy")} + {gettext("Legal")}

@@ -308,7 +295,7 @@ defmodule PhoenixKitWeb.Components.Core.CookieConsent do > <%!-- Backdrop --%>
@@ -356,7 +343,7 @@ defmodule PhoenixKitWeb.Components.Core.CookieConsent do <%= for category <- @categories do %>
@@ -374,34 +361,21 @@ defmodule PhoenixKitWeb.Components.Core.CookieConsent do <% end %>
-

+

{category.description}

<%!-- Custom Toggle --%> - + <% end %> @@ -411,21 +385,12 @@ defmodule PhoenixKitWeb.Components.Core.CookieConsent do
<%!-- Policy Links --%> -
- - {gettext("Privacy Policy")} - - + diff --git a/lib/phoenix_kit_web/components/core/markdown.ex b/lib/phoenix_kit_web/components/core/markdown.ex index e3d4bc529..5bb8813f5 100644 --- a/lib/phoenix_kit_web/components/core/markdown.ex +++ b/lib/phoenix_kit_web/components/core/markdown.ex @@ -33,7 +33,7 @@ defmodule PhoenixKitWeb.Components.Core.Markdown do import Phoenix.HTML, only: [raw: 1] import PhoenixKitWeb.Components.Core.MarkdownContent, only: [markdown_content: 1] - alias PhoenixKit.Modules.Entities.HtmlSanitizer + alias PhoenixKit.Utils.HtmlSanitizer @doc """ Renders markdown content with styling and optional XSS protection. diff --git a/lib/phoenix_kit_web/components/core/module_card.ex b/lib/phoenix_kit_web/components/core/module_card.ex index bc4508548..2810ea4db 100644 --- a/lib/phoenix_kit_web/components/core/module_card.ex +++ b/lib/phoenix_kit_web/components/core/module_card.ex @@ -9,6 +9,8 @@ defmodule PhoenixKitWeb.Components.Core.ModuleCard do use Phoenix.Component + import PhoenixKitWeb.Components.Core.Icon, only: [icon: 1] + @doc """ Renders a module card with header, toggle, status, actions, and optional stats. @@ -77,7 +79,13 @@ defmodule PhoenixKitWeb.Components.Core.ModuleCard do
<%!-- Header: Icon, Title, Description, Toggle --%>
-
{@icon}
+
+ <%= if String.starts_with?(@icon, "hero-") do %> + <.icon name={@icon} class="w-8 h-8" /> + <% else %> + {@icon} + <% end %> +

{@title}

diff --git a/lib/phoenix_kit_web/components/layout_wrapper.ex b/lib/phoenix_kit_web/components/layout_wrapper.ex index 5f77299a7..e557c0c41 100644 --- a/lib/phoenix_kit_web/components/layout_wrapper.ex +++ b/lib/phoenix_kit_web/components/layout_wrapper.ex @@ -700,6 +700,8 @@ defmodule PhoenixKitWeb.Components.LayoutWrapper do policy_version={config.policy_version} cookie_policy_url={config.cookie_policy_url} privacy_policy_url={config.privacy_policy_url} + legal_links={config.legal_links} + legal_index_url={config.legal_index_url} google_consent_mode={config.google_consent_mode} /> <% end %> diff --git a/lib/phoenix_kit_web/components/multilang_form.ex b/lib/phoenix_kit_web/components/multilang_form.ex index 0522aefa9..1d13928e3 100644 --- a/lib/phoenix_kit_web/components/multilang_form.ex +++ b/lib/phoenix_kit_web/components/multilang_form.ex @@ -92,7 +92,8 @@ defmodule PhoenixKitWeb.Components.MultilangForm do import PhoenixKitWeb.Components.LanguageSwitcher, only: [language_switcher: 1] alias Phoenix.LiveView.JS - alias PhoenixKit.Modules.Entities.Multilang + # PhoenixKit.Utils.Multilang is in an external package — referenced by full name + # with Code.ensure_loaded?/rescue guards throughout this module. # ═══════════════════════════════════════════════════════════════════ # Mount & Event Helpers @@ -248,7 +249,7 @@ defmodule PhoenixKitWeb.Components.MultilangForm do # No value submitted — preserve existing from JSONB data existing_data = safe_get_changeset_data(assigns.changeset) - case Multilang.get_raw_language_data(existing_data, current_lang) do + case PhoenixKit.Utils.Multilang.get_raw_language_data(existing_data, current_lang) do %{^data_key => existing} -> Map.put(form_data, data_key, existing) _ -> form_data end @@ -265,7 +266,7 @@ defmodule PhoenixKitWeb.Components.MultilangForm do `validated_data` for the given `lang_code`. Handles three cases: - - Multilang enabled: uses `Multilang.put_language_data/3` + - Multilang enabled: uses `PhoenixKit.Utils.Multilang.put_language_data/3` - Multilang disabled but data has multilang structure: preserves translations - Flat data, no multilang: passes through as-is @@ -284,7 +285,7 @@ defmodule PhoenixKitWeb.Components.MultilangForm do """ def get_lang_data(changeset, current_lang, multilang_enabled) do if multilang_enabled && changeset do - Multilang.get_raw_language_data( + PhoenixKit.Utils.Multilang.get_raw_language_data( Ecto.Changeset.get_field(changeset, :data), current_lang ) @@ -317,25 +318,25 @@ defmodule PhoenixKitWeb.Components.MultilangForm do @doc "Returns true when the Languages module is enabled with 2+ languages." def multilang_enabled? do - Code.ensure_loaded?(Multilang) and Multilang.enabled?() + Code.ensure_loaded?(PhoenixKit.Utils.Multilang) and PhoenixKit.Utils.Multilang.enabled?() rescue _ -> false end defp safe_primary_language do - Multilang.primary_language() + PhoenixKit.Utils.Multilang.primary_language() rescue _ -> "en-US" end defp safe_enabled_languages do - Multilang.enabled_languages() + PhoenixKit.Utils.Multilang.enabled_languages() rescue _ -> [] end defp safe_build_language_tabs do - Multilang.build_language_tabs() + PhoenixKit.Utils.Multilang.build_language_tabs() rescue _ -> [] end @@ -376,10 +377,10 @@ defmodule PhoenixKitWeb.Components.MultilangForm do cond do assigns[:multilang_enabled] == true -> - Multilang.put_language_data(existing_data, lang_code, validated_data) + PhoenixKit.Utils.Multilang.put_language_data(existing_data, lang_code, validated_data) - Multilang.multilang_data?(existing_data) -> - Multilang.put_language_data(existing_data, lang_code, validated_data) + PhoenixKit.Utils.Multilang.multilang_data?(existing_data) -> + PhoenixKit.Utils.Multilang.put_language_data(existing_data, lang_code, validated_data) true -> validated_data @@ -435,7 +436,7 @@ defmodule PhoenixKitWeb.Components.MultilangForm do ## Attributes * `multilang_enabled` — boolean, whether multilang is active - * `language_tabs` — list of tab maps from `Multilang.build_language_tabs/0` + * `language_tabs` — list of tab maps from `PhoenixKit.Utils.Multilang.build_language_tabs/0` * `current_lang` — the currently selected language code * `compact` — force compact mode (short codes). Default: nil (auto) * `show_header` — show the "Content Language" header. Default: true diff --git a/lib/phoenix_kit_web/controllers/entity_form_controller.ex b/lib/phoenix_kit_web/controllers/entity_form_controller.ex deleted file mode 100644 index 9153db626..000000000 --- a/lib/phoenix_kit_web/controllers/entity_form_controller.ex +++ /dev/null @@ -1,505 +0,0 @@ -defmodule PhoenixKitWeb.EntityFormController do - @moduledoc """ - Controller for handling public entity form submissions. - """ - use PhoenixKitWeb, :controller - use Gettext, backend: PhoenixKitWeb.Gettext - - alias PhoenixKit.Modules.Entities - alias PhoenixKit.Modules.Entities.EntityData - alias PhoenixKit.Users.RateLimiter - alias PhoenixKit.Utils.Date, as: UtilsDate - - require Logger - - @browser_patterns [ - {"Edg/", "Edge"}, - {"OPR/", "Opera"}, - {"Opera", "Opera"}, - {"Chrome/", "Chrome"}, - {"Safari/", "Safari"}, - {"Firefox/", "Firefox"}, - {"MSIE", "Internet Explorer"}, - {"Trident/", "Internet Explorer"} - ] - - @os_patterns [ - {"Windows NT 10", "Windows 10"}, - {"Windows NT 6.3", "Windows 8.1"}, - {"Windows NT 6.2", "Windows 8"}, - {"Windows NT 6.1", "Windows 7"}, - {"Windows", "Windows"}, - {"Mac OS X", "macOS"}, - {"Macintosh", "macOS"}, - {"Linux", "Linux"}, - {"Android", "Android"}, - {"iPhone", "iOS"}, - {"iPad", "iOS"} - ] - - @device_patterns [ - {"Mobile", "mobile"}, - {"Android", "mobile"}, - {"iPhone", "mobile"}, - {"iPad", "tablet"}, - {"Tablet", "tablet"} - ] - - # Minimum time in seconds for form submission (time-based validation) - @min_submission_time 3 - - # Rate limit: max submissions per IP - @rate_limit_max 5 - @rate_limit_window_ms 60_000 - - @doc """ - Handles public form submission for entities. - """ - def submit(conn, %{"entity_slug" => entity_slug} = params) do - entity = Entities.get_entity_by_name(entity_slug) - - cond do - is_nil(entity) -> - conn - |> put_flash(:error, gettext("Entity not found")) - |> redirect_back(conn) - - !public_form_enabled?(entity) -> - conn - |> put_flash(:error, gettext("Public form is not enabled for this entity")) - |> redirect_back(conn) - - true -> - # Run security checks and collect any flags - security_result = run_security_checks(conn, entity, params) - handle_security_result(conn, entity, params, security_result) - end - end - - defp run_security_checks(conn, entity, params) do - settings = entity.settings || %{} - - # Collect all security check results - checks = [ - check_honeypot(settings, params), - check_submission_time(settings, params), - check_rate_limit(conn, settings, entity) - ] - - # Find any triggered checks that require action - triggered = - checks - |> Enum.filter(fn - {:triggered, _type, _action} -> true - _ -> false - end) - - case triggered do - [] -> :ok - flags -> {:flagged, flags} - end - end - - defp handle_security_result(conn, entity, params, :ok) do - handle_submission(conn, entity, params, []) - end - - defp handle_security_result(conn, entity, params, {:flagged, flags}) do - # Check if any flags require rejection - reject_flags = - Enum.filter(flags, fn {:triggered, _type, action} -> - action in ["reject_silent", "reject_error"] - end) - - save_flags = - Enum.filter(flags, fn {:triggered, _type, action} -> - action in ["save_suspicious", "save_log"] - end) - - cond do - # If any flag requires rejection - not Enum.empty?(reject_flags) -> - handle_rejection(conn, entity, reject_flags) - - # If flags only require saving with markers - not Enum.empty?(save_flags) -> - handle_submission(conn, entity, params, save_flags) - - # Fallback - should not happen - true -> - handle_submission(conn, entity, params, []) - end - end - - defp handle_rejection(conn, entity, reject_flags) do - settings = entity.settings || %{} - debug_mode = Map.get(settings, "public_form_debug_mode", false) - - # Track rejected submission - increment_form_stats(entity, :rejected, reject_flags) - - # Check if any require error message (reject_error takes precedence) - has_error = - Enum.any?(reject_flags, fn {:triggered, _type, action} -> - action == "reject_error" - end) - - if has_error do - # Get the first error type for the message - {:triggered, error_type, _} = List.first(reject_flags) - error_message = get_error_message(error_type, debug_mode) - - conn - |> put_flash(:error, error_message) - |> redirect_back(conn) - else - # Silent rejection - show fake success - conn - |> put_flash(:info, get_success_message(entity)) - |> redirect_back(conn) - end - end - - # Debug mode error messages (detailed) - defp get_error_message(:honeypot, true), - do: gettext("[Debug] Submission rejected: Honeypot field was filled.") - - defp get_error_message(:too_fast, true), - do: - gettext( - "[Debug] Submission rejected: Form was submitted too quickly (less than 3 seconds)." - ) - - defp get_error_message(:rate_limited, true), - do: gettext("[Debug] Submission rejected: Rate limit exceeded (5 submissions per minute).") - - defp get_error_message(type, true), - do: gettext("[Debug] Submission rejected: Security check failed (%{type}).", type: type) - - # Normal error messages (generic) - defp get_error_message(:honeypot, false), do: gettext("There was an error submitting the form.") - - defp get_error_message(:too_fast, false), - do: gettext("Please take your time filling out the form.") - - defp get_error_message(:rate_limited, false), - do: gettext("Too many submissions. Please try again later.") - - defp get_error_message(_, false), do: gettext("There was an error submitting the form.") - - defp check_honeypot(settings, params) do - if Map.get(settings, "public_form_honeypot", false) do - honeypot_value = Map.get(params, "_hp_website", "") - - if honeypot_value == "" do - :ok - else - action = Map.get(settings, "public_form_honeypot_action", "reject_silent") - {:triggered, :honeypot, action} - end - else - :ok - end - end - - defp check_submission_time(settings, params) do - if Map.get(settings, "public_form_time_check", false) do - case get_time_to_submit(params) do - nil -> - # No timestamp provided, allow (could be form cached before feature enabled) - :ok - - seconds when seconds >= @min_submission_time -> - :ok - - _too_fast -> - action = Map.get(settings, "public_form_time_check_action", "reject_error") - {:triggered, :too_fast, action} - end - else - :ok - end - end - - defp get_time_to_submit(params) do - case Map.get(params, "_form_loaded_at") do - nil -> - nil - - loaded_at_str -> - case DateTime.from_iso8601(loaded_at_str) do - {:ok, loaded_at, _offset} -> - DateTime.diff(UtilsDate.utc_now(), loaded_at, :second) - - _ -> - nil - end - end - end - - defp check_rate_limit(conn, settings, entity) do - if Map.get(settings, "public_form_rate_limit", false) do - ip = get_client_ip(conn) - key = "entity_form:#{entity.id}:#{ip}" - - # Use the same Backend module used by RateLimiter - case RateLimiter.Backend.hit(key, @rate_limit_window_ms, @rate_limit_max) do - {:allow, _count} -> - :ok - - {:deny, _retry_after} -> - action = Map.get(settings, "public_form_rate_limit_action", "reject_error") - {:triggered, :rate_limited, action} - end - else - :ok - end - rescue - # If Hammer is not available or not started, allow the request - _ -> :ok - end - - defp get_success_message(entity) do - settings = entity.settings || %{} - Map.get(settings, "public_form_success_message", gettext("Form submitted successfully!")) - end - - defp apply_security_flags(metadata, [], _logger) do - {metadata, "published"} - end - - defp apply_security_flags(metadata, security_flags, logger) do - # Build list of security warnings - warnings = - Enum.map(security_flags, fn {:triggered, type, action} -> - %{"type" => Atom.to_string(type), "action" => action} - end) - - # Check if any flag marks as suspicious - is_suspicious = - Enum.any?(security_flags, fn {:triggered, _type, action} -> - action == "save_suspicious" - end) - - # Check if any flag requires logging - should_log = - Enum.any?(security_flags, fn {:triggered, _type, action} -> - action == "save_log" - end) - - # Log warnings if needed - if should_log do - flag_types = Enum.map(security_flags, fn {:triggered, type, _} -> type end) - logger.warning("Form submission with security flags: #{inspect(flag_types)}") - end - - # Add warnings to metadata - metadata = Map.put(metadata, "security_warnings", warnings) - - # Set status based on flags - status = if is_suspicious, do: "draft", else: "published" - - {metadata, status} - end - - defp handle_submission(conn, entity, params, security_flags) do - # Extract form data from params - form_data = get_in(params, ["phoenix_kit_entity_data", "data"]) || %{} - - # Filter to only include allowed public form fields - settings = entity.settings || %{} - allowed_fields = Map.get(settings, "public_form_fields", []) - - filtered_data = - form_data - |> Enum.filter(fn {key, _value} -> key in allowed_fields end) - |> Enum.into(%{}) - - # Build entity data params - # For public submissions, use the current user if logged in, otherwise nil (system) - current_user = conn.assigns[:current_user] - created_by_uuid = if current_user, do: current_user.uuid, else: nil - title = generate_submission_title(entity, filtered_data) - - # Capture submission metadata if enabled (default is true) - collect_metadata = Map.get(settings, "public_form_collect_metadata") != false - - metadata = - if collect_metadata, - do: build_submission_metadata(conn, params), - else: %{"source" => "public_form"} - - # Add security flags to metadata if any were triggered - {metadata, status} = apply_security_flags(metadata, security_flags, Logger) - - entity_data_params = %{ - "entity_uuid" => entity.id, - "title" => title, - "slug" => generate_slug(title), - "status" => status, - "data" => filtered_data, - "metadata" => metadata, - "created_by_uuid" => created_by_uuid - } - - case EntityData.create(entity_data_params) do - {:ok, _data_record} -> - # Track successful submission - increment_form_stats(entity, :submitted, security_flags) - - success_message = - Map.get( - settings, - "public_form_success_message", - gettext("Form submitted successfully!") - ) - - conn - |> put_flash(:info, success_message) - |> redirect_back(conn) - - {:error, _changeset} -> - conn - |> put_flash(:error, gettext("There was an error submitting the form. Please try again.")) - |> redirect_back(conn) - end - end - - defp public_form_enabled?(entity) do - settings = entity.settings || %{} - enabled = Map.get(settings, "public_form_enabled", false) - fields = Map.get(settings, "public_form_fields", []) - # Form is only truly enabled if it's enabled AND has at least one field selected - enabled && not Enum.empty?(fields) - end - - defp generate_submission_title(entity, data) do - # Try to use a meaningful field value as title, or use entity display name - title_candidates = ["name", "title", "subject", "email"] - - Enum.find_value(title_candidates, fn field -> - value = Map.get(data, field) - if value && value != "", do: value - end) || entity.display_name - end - - defp generate_slug(title) do - title - |> String.downcase() - |> String.replace(~r/[^a-z0-9\s-]/, "") - |> String.replace(~r/\s+/, "-") - |> String.trim("-") - |> Kernel.<>("-#{:rand.uniform(9999)}") - end - - defp build_submission_metadata(conn, params) do - user_agent = get_req_header(conn, "user-agent") |> List.first() || "" - referer = get_req_header(conn, "referer") |> List.first() - submitted_at = UtilsDate.utc_now() - - # Get form timing data - form_loaded_at = Map.get(params, "_form_loaded_at") - time_to_submit = get_time_to_submit(params) - - %{ - "source" => "public_form", - "ip_address" => get_client_ip(conn), - "user_agent" => user_agent, - "browser" => parse_browser(user_agent), - "os" => parse_os(user_agent), - "device" => parse_device(user_agent), - "referer" => referer, - "form_loaded_at" => form_loaded_at, - "submitted_at" => DateTime.to_iso8601(submitted_at), - "time_to_submit_seconds" => time_to_submit - } - end - - defp get_client_ip(conn) do - # Check for forwarded IP (behind proxy/load balancer) - forwarded_for = get_req_header(conn, "x-forwarded-for") |> List.first() - - if forwarded_for do - forwarded_for - |> String.split(",") - |> List.first() - |> String.trim() - else - conn.remote_ip - |> :inet.ntoa() - |> to_string() - end - end - - defp parse_browser(user_agent) do - Enum.find_value(@browser_patterns, "Unknown", fn {pattern, name} -> - if String.contains?(user_agent, pattern), do: name - end) - end - - defp parse_os(user_agent) do - Enum.find_value(@os_patterns, "Unknown", fn {pattern, name} -> - if String.contains?(user_agent, pattern), do: name - end) - end - - defp parse_device(user_agent) do - Enum.find_value(@device_patterns, "desktop", fn {pattern, type} -> - if String.contains?(user_agent, pattern), do: type - end) - end - - defp redirect_back(conn, _fallback_conn) do - referer = get_req_header(conn, "referer") |> List.first() - - if referer do - redirect(conn, external: referer) - else - redirect(conn, to: "/") - end - end - - # Form statistics tracking - # Stats are stored in entity.settings under "public_form_stats" - defp increment_form_stats(entity, event_type, security_flags) do - # Run async to not block the response - Task.start(fn -> - try do - current_settings = entity.settings || %{} - current_stats = Map.get(current_settings, "public_form_stats", %{}) - - # Initialize stats structure if needed - updated_stats = - current_stats - |> Map.update("total_submissions", 1, &(&1 + 1)) - |> update_event_count(event_type) - |> update_security_stats(security_flags) - |> Map.put("last_submission_at", UtilsDate.utc_now() |> DateTime.to_iso8601()) - - updated_settings = Map.put(current_settings, "public_form_stats", updated_stats) - - # Update entity settings directly via Repo - Entities.update_entity(entity, %{"settings" => updated_settings}) - rescue - _ -> :ok - end - end) - end - - defp update_event_count(stats, :submitted) do - Map.update(stats, "successful_submissions", 1, &(&1 + 1)) - end - - defp update_event_count(stats, :rejected) do - Map.update(stats, "rejected_submissions", 1, &(&1 + 1)) - end - - defp update_security_stats(stats, []), do: stats - - defp update_security_stats(stats, security_flags) do - Enum.reduce(security_flags, stats, fn {:triggered, type, _action}, acc -> - key = "#{type}_triggers" - Map.update(acc, key, 1, &(&1 + 1)) - end) - end -end diff --git a/lib/phoenix_kit_web/helpers/admin_edit_helper.ex b/lib/phoenix_kit_web/helpers/admin_edit_helper.ex new file mode 100644 index 000000000..05beb3589 --- /dev/null +++ b/lib/phoenix_kit_web/helpers/admin_edit_helper.ex @@ -0,0 +1,36 @@ +defmodule PhoenixKitWeb.AdminEditHelper do + @moduledoc """ + Universal admin edit URL helper. + Assigns admin_edit_url and admin_edit_label to conn/socket if user is admin. + Works with both Plug.Conn (controllers) and Phoenix.LiveView.Socket (LiveViews). + """ + alias PhoenixKit.Users.Auth.Scope + + @doc """ + Assigns `:admin_edit_url` and `:admin_edit_label` if the current user is an admin. + Returns conn/socket unchanged for non-admins or unauthenticated users. + """ + def assign_admin_edit(conn_or_socket, path, label \\ "Edit") + + def assign_admin_edit(%Plug.Conn{} = conn, path, label) do + if admin?(conn.assigns[:phoenix_kit_current_scope]) do + conn + |> Plug.Conn.assign(:admin_edit_url, path) + |> Plug.Conn.assign(:admin_edit_label, label) + else + conn + end + end + + def assign_admin_edit(%Phoenix.LiveView.Socket{} = socket, path, label) do + if admin?(socket.assigns[:phoenix_kit_current_scope]) do + socket + |> Phoenix.Component.assign(:admin_edit_url, path) + |> Phoenix.Component.assign(:admin_edit_label, label) + else + socket + end + end + + defp admin?(scope), do: scope != nil and Scope.admin?(scope) +end diff --git a/lib/phoenix_kit_web/integration.ex b/lib/phoenix_kit_web/integration.ex index e57a3e0f3..24834937c 100644 --- a/lib/phoenix_kit_web/integration.ex +++ b/lib/phoenix_kit_web/integration.ex @@ -99,10 +99,7 @@ defmodule PhoenixKitWeb.Integration do alias PhoenixKit.Utils.Routes alias PhoenixKitWeb - alias PhoenixKitWeb.Routes.BlogRoutes alias PhoenixKitWeb.Routes.CustomerServiceRoutes - alias PhoenixKitWeb.Routes.EmailsRoutes - alias PhoenixKitWeb.Routes.PublishingRoutes alias PhoenixKitWeb.Routes.ReferralsRoutes alias PhoenixKitWeb.Routes.ShopRoutes @@ -194,6 +191,10 @@ defmodule PhoenixKitWeb.Integration do plug PhoenixKitWeb.Users.Auth, :phoenix_kit_require_admin end + pipeline :phoenix_kit_optional_scope do + plug PhoenixKitWeb.Users.Auth, :fetch_phoenix_kit_current_scope + end + # Define API pipeline for JSON endpoints pipeline :phoenix_kit_api do plug :accepts, ["json"] @@ -379,20 +380,15 @@ defmodule PhoenixKitWeb.Integration do # so plugin LiveViews don't need to wrap with LayoutWrapper themselves plugin_admin_routes = compile_plugin_admin_routes(__CALLER__.module) - # Get external route module AST outside quote to avoid require/alias inside quote - emails_admin = safe_route_call(EmailsRoutes, :admin_routes, []) - - {tickets_admin, publishing_admin, referrals_admin} = + {tickets_admin, referrals_admin} = if suffix == :_locale do { safe_route_call(CustomerServiceRoutes, :admin_locale_routes, []), - safe_route_call(PublishingRoutes, :admin_locale_routes, []), safe_route_call(ReferralsRoutes, :admin_locale_routes, []) } else { safe_route_call(CustomerServiceRoutes, :admin_routes, []), - safe_route_call(PublishingRoutes, :admin_routes, []), safe_route_call(ReferralsRoutes, :admin_routes, []) } end @@ -570,40 +566,6 @@ defmodule PhoenixKitWeb.Integration do live "/admin/settings/comments", PhoenixKit.Modules.Comments.Web.Settings, :settings, as: :comments_settings - # Entities module routes - live "/admin/entities", PhoenixKit.Modules.Entities.Web.Entities, :index, as: :entities - - live "/admin/entities/new", PhoenixKit.Modules.Entities.Web.EntityForm, :new, - as: :entities_new - - live "/admin/entities/:id/edit", PhoenixKit.Modules.Entities.Web.EntityForm, :edit, - as: :entities_edit - - live "/admin/entities/:entity_slug/data", - PhoenixKit.Modules.Entities.Web.DataNavigator, - :entity, - as: :entities_data_entity - - live "/admin/entities/:entity_slug/data/new", - PhoenixKit.Modules.Entities.Web.DataForm, - :new, - as: :entities_data_new - - live "/admin/entities/:entity_slug/data/:uuid", - PhoenixKit.Modules.Entities.Web.DataForm, - :show, - as: :entities_data_show - - live "/admin/entities/:entity_slug/data/:uuid/edit", - PhoenixKit.Modules.Entities.Web.DataForm, - :edit, - as: :entities_data_edit - - live "/admin/settings/entities", - PhoenixKit.Modules.Entities.Web.EntitiesSettings, - :index, - as: :entities_settings - # Shop admin routes live "/admin/shop", PhoenixKit.Modules.Shop.Web.Dashboard, :index, as: :shop_dashboard @@ -662,32 +624,8 @@ defmodule PhoenixKitWeb.Integration do live "/admin/shop/test", PhoenixKit.Modules.Shop.Web.TestShop, :index, as: :shop_test - # AI module routes - live "/admin/ai", PhoenixKit.Modules.AI.Web.Endpoints, :index, as: :ai_index - - live "/admin/ai/endpoints", PhoenixKit.Modules.AI.Web.Endpoints, :endpoints, - as: :ai_endpoints - - live "/admin/ai/usage", PhoenixKit.Modules.AI.Web.Endpoints, :usage, as: :ai_usage - - live "/admin/ai/endpoints/new", PhoenixKit.Modules.AI.Web.EndpointForm, :new, - as: :ai_endpoint_new - - live "/admin/ai/endpoints/:id/edit", PhoenixKit.Modules.AI.Web.EndpointForm, :edit, - as: :ai_endpoint_edit - - live "/admin/ai/prompts", PhoenixKit.Modules.AI.Web.Prompts, :index, as: :ai_prompts - - live "/admin/ai/prompts/new", PhoenixKit.Modules.AI.Web.PromptForm, :new, - as: :ai_prompt_new - - live "/admin/ai/prompts/:id/edit", PhoenixKit.Modules.AI.Web.PromptForm, :edit, - as: :ai_prompt_edit - # Routes from external route modules - unquote(emails_admin) unquote(tickets_admin) - unquote(publishing_admin) unquote(referrals_admin) # Custom admin routes from :admin_dashboard_tabs config @@ -1124,10 +1062,37 @@ defmodule PhoenixKitWeb.Integration do def compile_external_admin_routes(suffix) do fun = if suffix == :_locale, do: :admin_locale_routes, else: :admin_routes - PhoenixKit.Config.get(:route_modules, []) + all_route_modules() |> Enum.flat_map(&collect_admin_routes(&1, fun)) end + # Collect route modules from config + auto-discovered external PhoenixKit modules + defp all_route_modules do + config_modules = PhoenixKit.Config.get(:route_modules, []) + + discovered_route_modules = + PhoenixKit.ModuleDiscovery.discover_external_modules() + |> Enum.flat_map(fn mod -> + case Code.ensure_compiled(mod) do + {:module, _} -> + if function_exported?(mod, :route_module, 0) do + case mod.route_module() do + nil -> [] + route_mod -> [route_mod] + end + else + [] + end + + _ -> + [] + end + end) + + (config_modules ++ discovered_route_modules) + |> Enum.uniq() + end + defp collect_admin_routes(mod, fun) do case Code.ensure_compiled(mod) do {:module, _} -> resolve_admin_routes(mod, fun) @@ -1147,7 +1112,7 @@ defmodule PhoenixKitWeb.Integration do # Each module should implement public_routes/1 (receives url_prefix). @doc false def compile_external_public_routes(url_prefix) do - PhoenixKit.Config.get(:route_modules, []) + all_route_modules() |> Enum.flat_map(&collect_public_routes(&1, url_prefix)) end @@ -1276,10 +1241,7 @@ defmodule PhoenixKitWeb.Integration do # Call route generators BEFORE quote block (aliases work in this context) # Uses safe_route_call/3 so modules can be safely extracted to separate packages - emails_routes = safe_route_call(EmailsRoutes, :generate, [url_prefix]) - publishing_routes = safe_route_call(PublishingRoutes, :generate, [url_prefix]) customer_service_routes = safe_route_call(CustomerServiceRoutes, :generate, [url_prefix]) - blog_routes = safe_route_call(BlogRoutes, :generate, [url_prefix]) # External route modules with public/non-admin routes external_public_routes = compile_external_public_routes(url_prefix) @@ -1299,8 +1261,6 @@ defmodule PhoenixKitWeb.Integration do unquote_splicing(module_public_routes) # Generate module routes from separate files (improves compilation time) - unquote(emails_routes) - unquote(publishing_routes) unquote(customer_service_routes) # Generate localized routes @@ -1309,9 +1269,6 @@ defmodule PhoenixKitWeb.Integration do # Generate non-localized routes unquote(generate_non_localized_routes(url_prefix)) - # Generate blog routes (after other routes to prevent conflicts) - unquote(blog_routes) - # External route modules with public routes unquote_splicing(external_public_routes) diff --git a/lib/phoenix_kit_web/live/components/media_selector_modal.html.heex b/lib/phoenix_kit_web/live/components/media_selector_modal.html.heex index 4c2773960..de6fad262 100644 --- a/lib/phoenix_kit_web/live/components/media_selector_modal.html.heex +++ b/lib/phoenix_kit_web/live/components/media_selector_modal.html.heex @@ -2,7 +2,7 @@

- <%= if PhoenixKit.Modules.Emails.enabled?() do %> + <%= if Code.ensure_loaded?(PhoenixKit.Modules.Emails) and apply(PhoenixKit.Modules.Emails, :enabled?, []) do %> <.link navigate={PhoenixKit.Utils.Routes.path("/admin/emails")} class="card bg-purple-500 text-white hover:shadow-lg transition-all" diff --git a/lib/phoenix_kit_web/live/modules.ex b/lib/phoenix_kit_web/live/modules.ex index 42890fd64..6449fdadc 100644 --- a/lib/phoenix_kit_web/live/modules.ex +++ b/lib/phoenix_kit_web/live/modules.ex @@ -446,31 +446,57 @@ defmodule PhoenixKitWeb.Live.Modules do |> Enum.filter(fn mod -> Code.ensure_loaded?(mod) and function_exported?(mod, :module_key, 0) end) - |> Enum.map(fn mod -> - key = mod.module_key() - config = module_configs[key] || %{} - perm = if function_exported?(mod, :permission_metadata, 0), do: mod.permission_metadata() - - %{ - module: mod, - key: key, - name: mod.module_name(), - icon: (perm && perm[:icon]) || "hero-puzzle-piece", - description: (perm && perm[:description]) || "External module", - enabled: config[:enabled] || false, - version: if(function_exported?(mod, :version, 0), do: mod.version(), else: "0.0.0"), - required_modules: - if(function_exported?(mod, :required_modules, 0), do: mod.required_modules(), else: []), - admin_links: extract_admin_links(mod) - } - end) + |> Enum.map(&build_external_module_data(&1, module_configs)) |> Enum.sort_by(& &1.name) end + defp build_external_module_data(mod, module_configs) do + key = mod.module_key() + config = module_configs[key] || %{} + perm = if function_exported?(mod, :permission_metadata, 0), do: mod.permission_metadata() + + %{ + module: mod, + key: key, + name: mod.module_name(), + icon: (perm && perm[:icon]) || "hero-puzzle-piece", + description: (perm && perm[:description]) || "External module", + enabled: config[:enabled] || false, + config: safe_get_config(mod), + version: if(function_exported?(mod, :version, 0), do: mod.version(), else: "0.0.0"), + required_modules: + if(function_exported?(mod, :required_modules, 0), do: mod.required_modules(), else: []), + admin_links: extract_admin_links(mod), + settings_path: extract_settings_path(mod) + } + end + + defp safe_get_config(mod) do + if function_exported?(mod, :get_config, 0), do: mod.get_config(), else: %{} + rescue + _ -> %{} + end + + defp extract_settings_path(mod) do + if Code.ensure_loaded?(mod) and function_exported?(mod, :settings_tabs, 0) do + case mod.settings_tabs() do + [first | _] -> "/admin/settings/" <> first.path + _ -> nil + end + else + nil + end + rescue + _ -> nil + end + defp extract_admin_links(mod) do if Code.ensure_loaded?(mod) and function_exported?(mod, :admin_tabs, 0) do mod.admin_tabs() - |> Enum.filter(fn tab -> tab.live_view != nil and tab.visible != false end) + |> Enum.filter(fn tab -> + tab.live_view != nil and tab.visible != false and tab.parent != nil + end) + |> Enum.uniq_by(fn tab -> tab.path end) |> Enum.take(3) |> Enum.map(fn tab -> %{label: tab.label, path: "/admin/" <> tab.path, icon: tab.icon} end) else diff --git a/lib/phoenix_kit_web/live/modules.html.heex b/lib/phoenix_kit_web/live/modules.html.heex index 63c75f305..a293f16fa 100644 --- a/lib/phoenix_kit_web/live/modules.html.heex +++ b/lib/phoenix_kit_web/live/modules.html.heex @@ -135,82 +135,6 @@ <% end %> - <%!-- Email Module --%> - <%= if "emails" in @accessible_modules do %> - <% cfg = @module_configs["emails"] || %{} %> - - <:status_badges> - - {if cfg[:enabled], do: "Enabled", else: "Disabled"} - - <%= if cfg[:enabled] do %> - - {if cfg[:save_body], do: "Body Saved", else: "Headers Only"} - - <% end %> - - - <:action_buttons> -
- <%= if cfg[:enabled] do %> - <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/settings/emails")} - class="btn btn-primary btn-sm" - > - <.icon name="hero-cog-6-tooth" class="w-4 h-4 mr-1" /> Configure - -
- <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/emails")} - class="btn btn-outline btn-sm flex-1" - > - <.icon name="hero-envelope" class="w-4 h-4 mr-1" /> Emails - - <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/emails/templates")} - class="btn btn-outline btn-sm flex-1" - > - <.icon name="hero-document-text" class="w-4 h-4 mr-1" /> Templates - -
- <% else %> - <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/settings/emails")} - class="btn btn-outline btn-sm" - > - <.icon name="hero-cog-6-tooth" class="w-4 h-4 mr-1" /> Configure - - <% end %> -
- - - <:stats> -
-
- SES Events: - - {if cfg[:ses_events], do: "On", else: "Off"} - -
-
- Retention: - {cfg[:retention_days]} days -
-
- -
- <% end %> - <%!-- Languages Module --%> <%= if "languages" in @accessible_modules do %> <% cfg = @module_configs["languages"] || %{} %> @@ -1279,73 +1203,111 @@ <% end %> <%!-- External Modules (auto-discovered) --%> <%= for ext <- @external_modules, ext.key in @accessible_modules do %> -
-
-
-
- <%= if String.starts_with?(ext.icon, "hero-") do %> - <.icon name={ext.icon} class="w-8 h-8" /> - <% else %> - {ext.icon} - <% end %> -
-
-

{ext.name}

-

{ext.description}

-
-
- -
-
- -
- -
-
- - {if ext.enabled, do: "Enabled", else: "Disabled"} + + <:status_badges> + + {if ext.enabled, do: "Enabled", else: "Disabled"} + + v{ext.version} + External + <%= for req_mod <- ext.required_modules do %> + <%= unless mcfg(@module_configs, req_mod, :enabled, false) do %> + + Requires {String.capitalize(req_mod)} - v{ext.version} - External - <%= for req_mod <- ext.required_modules do %> - <%= unless mcfg(@module_configs, req_mod, :enabled, false) do %> - - Requires {String.capitalize(req_mod)} - - <% end %> - <% end %> -
- - {if ext.enabled, do: "Module is active", else: "Enable to activate"} - -
+ <% end %> + <% end %> + <%!-- Module-specific badges from config --%> + <%= if ext.enabled do %> + <%= if Map.has_key?(ext.config, :save_body) do %> + + {if ext.config[:save_body], do: "Body Saved", else: "Headers Only"} + + <% end %> + <% end %> + - <%= if ext.enabled and ext.admin_links != [] do %> -
- <%= for link <- ext.admin_links do %> + <:action_buttons> +
+ <%= if ext.enabled do %> + <%= if ext.settings_path do %> <.link - navigate={PhoenixKit.Utils.Routes.path(link.path)} - class="btn btn-outline btn-sm flex-1" + navigate={PhoenixKit.Utils.Routes.path(ext.settings_path)} + class="btn btn-primary btn-sm" > - <%= if link.icon do %> - <.icon name={link.icon} class="w-4 h-4 mr-1" /> + <.icon name="hero-cog-6-tooth" class="w-4 h-4 mr-1" /> Configure + + <% end %> + <%= if ext.admin_links != [] do %> +
+ <%= for link <- ext.admin_links do %> + <.link + navigate={PhoenixKit.Utils.Routes.path(link.path)} + class="btn btn-outline btn-sm flex-1" + > + <%= if link.icon do %> + <.icon name={link.icon} class="w-4 h-4 mr-1" /> + <% end %> + {link.label} + <% end %> - {link.label} +
+ <% end %> + <% else %> + <%= if ext.settings_path do %> + <.link + navigate={PhoenixKit.Utils.Routes.path(ext.settings_path)} + class="btn btn-outline btn-sm" + > + <.icon name="hero-cog-6-tooth" class="w-4 h-4 mr-1" /> Configure <% end %> -
- <% end %> -
-
+ <% end %> +
+ + + <:stats> + <%!-- Render stats from config if available --%> +
+ <%= if Map.has_key?(ext.config, :ses_events) do %> +
+ SES Events: + + {if ext.config[:ses_events], do: "On", else: "Off"} + +
+ <% end %> + <%= if Map.has_key?(ext.config, :retention_days) do %> +
+ Retention: + {ext.config[:retention_days]} days +
+ <% end %> + <%= if Map.has_key?(ext.config, :sampling_rate) do %> +
+ Sampling: + {ext.config[:sampling_rate]}% +
+ <% end %> + <%= if Map.has_key?(ext.config, :compress_after_days) do %> +
+ Compress: + {ext.config[:compress_after_days]} days +
+ <% end %> +
+ + <% end %>
diff --git a/lib/phoenix_kit_web/live/modules/languages.ex b/lib/phoenix_kit_web/live/modules/languages.ex index bf0c8e42c..ca8256071 100644 --- a/lib/phoenix_kit_web/live/modules/languages.ex +++ b/lib/phoenix_kit_web/live/modules/languages.ex @@ -8,8 +8,6 @@ defmodule PhoenixKitWeb.Live.Modules.Languages do alias PhoenixKit.Config alias PhoenixKit.Modules.Languages - alias PhoenixKit.Modules.Publishing - alias PhoenixKit.Modules.Publishing.ListingCache alias PhoenixKit.Settings alias PhoenixKit.Utils.Routes @@ -409,13 +407,20 @@ defmodule PhoenixKitWeb.Live.Modules.Languages do |> Enum.sort_by(fn {country, _} -> country end) end - # Regenerate listing caches for all publishing groups when language settings change + # Regenerate listing caches for all publishing groups when language settings change. + # Publishing may be an external package — only call if available. defp regenerate_all_group_caches do - if Publishing.enabled?() do - Publishing.list_groups() - |> Enum.each(fn group -> - ListingCache.regenerate(group["slug"]) - end) + publishing = PhoenixKit.Modules.Publishing + cache = PhoenixKit.Modules.Publishing.ListingCache + + if Code.ensure_loaded?(publishing) and function_exported?(publishing, :enabled?, 0) and + publishing.enabled?() do + if Code.ensure_loaded?(cache) and function_exported?(cache, :regenerate, 1) do + publishing.list_groups() + |> Enum.each(fn group -> cache.regenerate(group["slug"]) end) + end end + rescue + _ -> :ok end end diff --git a/lib/phoenix_kit_web/routes/blog.ex b/lib/phoenix_kit_web/routes/blog.ex deleted file mode 100644 index 282461c98..000000000 --- a/lib/phoenix_kit_web/routes/blog.ex +++ /dev/null @@ -1,54 +0,0 @@ -defmodule PhoenixKitWeb.Routes.BlogRoutes do - @moduledoc """ - Blog/Publishing catch-all routes. - - Provides route definitions for serving published blog content. - """ - - @doc """ - Returns quoted code for blog catch-all routes. - """ - def generate(url_prefix) do - quote do - # Multi-language blog routes with language prefix - blog_scope_multi = - case unquote(url_prefix) do - "/" -> "/:language" - prefix -> "#{prefix}/:language" - end - - scope blog_scope_multi do - pipe_through [:browser, :phoenix_kit_auto_setup, :phoenix_kit_locale_validation] - - get "/:group", PhoenixKit.Modules.Publishing.Web.Controller, :show, - constraints: %{ - "group" => ~r/^(?!admin$|assets$|images$|fonts$|js$|css$|favicon)/, - "language" => ~r/^[a-z]{2,3}(-[A-Za-z]{2,4})?$/ - } - - get "/:group/*path", PhoenixKit.Modules.Publishing.Web.Controller, :show, - constraints: %{ - "group" => ~r/^(?!admin$|assets$|images$|fonts$|js$|css$|favicon)/, - "language" => ~r/^[a-z]{2,3}(-[A-Za-z]{2,4})?$/ - } - end - - # Non-localized blog routes (for when url_prefix is "/") - blog_scope_non_localized = - case unquote(url_prefix) do - "/" -> "/" - prefix -> prefix - end - - scope blog_scope_non_localized do - pipe_through [:browser, :phoenix_kit_auto_setup, :phoenix_kit_locale_validation] - - get "/:group", PhoenixKit.Modules.Publishing.Web.Controller, :show, - constraints: %{"group" => ~r/^(?!admin$|assets$|images$|fonts$|js$|css$|favicon)/} - - get "/:group/*path", PhoenixKit.Modules.Publishing.Web.Controller, :show, - constraints: %{"group" => ~r/^(?!admin$|assets$|images$|fonts$|js$|css$|favicon)/} - end - end - end -end diff --git a/lib/phoenix_kit_web/routes/emails.ex b/lib/phoenix_kit_web/routes/emails.ex deleted file mode 100644 index 99f4621ad..000000000 --- a/lib/phoenix_kit_web/routes/emails.ex +++ /dev/null @@ -1,77 +0,0 @@ -defmodule PhoenixKitWeb.Routes.EmailsRoutes do - @moduledoc """ - Email module routes. - - Provides route definitions for email webhooks, exports, and admin interfaces. - Separated to improve compilation time. - """ - - @doc """ - Returns quoted code for email non-LiveView routes (webhooks, exports). - """ - def generate(url_prefix) do - quote do - # Email webhook endpoint (public - no authentication required) - scope unquote(url_prefix) do - pipe_through [:browser] - - post "/webhooks/email", PhoenixKit.Modules.Emails.Web.WebhookController, :handle - end - - # Email export routes (require admin or owner role) - scope unquote(url_prefix) do - pipe_through [:browser, :phoenix_kit_auto_setup, :phoenix_kit_admin_only] - - get "/admin/emails/export", PhoenixKit.Modules.Emails.Web.ExportController, :export_logs - - get "/admin/emails/metrics/export", - PhoenixKit.Modules.Emails.Web.ExportController, - :export_metrics - - get "/admin/emails/blocklist/export", - PhoenixKit.Modules.Emails.Web.ExportController, - :export_blocklist - - get "/admin/emails/:id/export", - PhoenixKit.Modules.Emails.Web.ExportController, - :export_email_details - end - end - end - - @doc """ - Returns quoted admin LiveView route declarations for inclusion in the shared admin live_session. - """ - def admin_routes do - quote do - live "/admin/settings/emails", PhoenixKit.Modules.Emails.Web.Settings, :index, - as: :emails_settings - - live "/admin/emails/dashboard", PhoenixKit.Modules.Emails.Web.Metrics, :index, - as: :emails_metrics - - live "/admin/emails", PhoenixKit.Modules.Emails.Web.Emails, :index, as: :emails_index - - live "/admin/emails/email/:id", PhoenixKit.Modules.Emails.Web.Details, :show, - as: :emails_details - - live "/admin/emails/queue", PhoenixKit.Modules.Emails.Web.Queue, :index, as: :emails_queue - - live "/admin/emails/blocklist", PhoenixKit.Modules.Emails.Web.Blocklist, :index, - as: :emails_blocklist - - live "/admin/emails/templates", PhoenixKit.Modules.Emails.Web.Templates, :index, - as: :emails_templates - - live "/admin/emails/templates/new", - PhoenixKit.Modules.Emails.Web.TemplateEditor, - :new, - as: :emails_template_new - - live "/admin/emails/templates/:id/edit", - PhoenixKit.Modules.Emails.Web.TemplateEditor, - :edit, - as: :emails_template_edit - end - end -end diff --git a/lib/phoenix_kit_web/routes/publishing.ex b/lib/phoenix_kit_web/routes/publishing.ex deleted file mode 100644 index 901761963..000000000 --- a/lib/phoenix_kit_web/routes/publishing.ex +++ /dev/null @@ -1,120 +0,0 @@ -defmodule PhoenixKitWeb.Routes.PublishingRoutes do - @moduledoc """ - Publishing module routes. - - Provides route definitions for content management (publishing groups and posts). - """ - - @doc """ - Returns quoted code for publishing non-LiveView routes. - Currently a no-op — reserved for future non-LiveView routes. - """ - def generate(_url_prefix) do - quote do - end - end - - @doc """ - Returns quoted admin LiveView route declarations for the shared admin live_session (localized). - """ - def admin_locale_routes do - quote do - live "/admin/publishing", PhoenixKit.Modules.Publishing.Web.Index, :index, - as: :publishing_index_localized - - # Literal path routes MUST come before :group param routes - live "/admin/publishing/new-group", PhoenixKit.Modules.Publishing.Web.New, :new, - as: :publishing_new_group_localized - - live "/admin/publishing/edit-group/:group", - PhoenixKit.Modules.Publishing.Web.Edit, - :edit, - as: :publishing_edit_group_localized - - live "/admin/publishing/:group", PhoenixKit.Modules.Publishing.Web.Listing, :group, - as: :publishing_group_localized - - live "/admin/publishing/:group/edit", PhoenixKit.Modules.Publishing.Web.Editor, :edit, - as: :publishing_editor_localized - - live "/admin/publishing/:group/new", PhoenixKit.Modules.Publishing.Web.Editor, :new, - as: :publishing_new_post_localized - - live "/admin/publishing/:group/preview", - PhoenixKit.Modules.Publishing.Web.Preview, - :preview, - as: :publishing_preview_localized - - # UUID-based routes (param routes — must come AFTER literal routes above) - live "/admin/publishing/:group/:post_uuid", - PhoenixKit.Modules.Publishing.Web.PostShow, - :show, - as: :publishing_post_show_localized - - live "/admin/publishing/:group/:post_uuid/edit", - PhoenixKit.Modules.Publishing.Web.Editor, - :edit_post, - as: :publishing_post_editor_localized - - live "/admin/publishing/:group/:post_uuid/preview", - PhoenixKit.Modules.Publishing.Web.Preview, - :preview_post, - as: :publishing_post_preview_localized - - live "/admin/settings/publishing", PhoenixKit.Modules.Publishing.Web.Settings, :index, - as: :publishing_settings_localized - end - end - - @doc """ - Returns quoted admin LiveView route declarations for the shared admin live_session (non-localized). - """ - def admin_routes do - quote do - live "/admin/publishing", PhoenixKit.Modules.Publishing.Web.Index, :index, - as: :publishing_index - - # Literal path routes MUST come before :group param routes - live "/admin/publishing/new-group", PhoenixKit.Modules.Publishing.Web.New, :new, - as: :publishing_new_group - - live "/admin/publishing/edit-group/:group", - PhoenixKit.Modules.Publishing.Web.Edit, - :edit, - as: :publishing_edit_group - - live "/admin/publishing/:group", PhoenixKit.Modules.Publishing.Web.Listing, :group, - as: :publishing_group - - live "/admin/publishing/:group/edit", PhoenixKit.Modules.Publishing.Web.Editor, :edit, - as: :publishing_editor - - live "/admin/publishing/:group/new", PhoenixKit.Modules.Publishing.Web.Editor, :new, - as: :publishing_new_post - - live "/admin/publishing/:group/preview", - PhoenixKit.Modules.Publishing.Web.Preview, - :preview, - as: :publishing_preview - - # UUID-based routes (param routes — must come AFTER literal routes above) - live "/admin/publishing/:group/:post_uuid", - PhoenixKit.Modules.Publishing.Web.PostShow, - :show, - as: :publishing_post_show - - live "/admin/publishing/:group/:post_uuid/edit", - PhoenixKit.Modules.Publishing.Web.Editor, - :edit_post, - as: :publishing_post_editor - - live "/admin/publishing/:group/:post_uuid/preview", - PhoenixKit.Modules.Publishing.Web.Preview, - :preview_post, - as: :publishing_post_preview - - live "/admin/settings/publishing", PhoenixKit.Modules.Publishing.Web.Settings, :index, - as: :publishing_settings - end - end -end diff --git a/lib/phoenix_kit_web/users/auth.ex b/lib/phoenix_kit_web/users/auth.ex index 4cb53e894..3989902e4 100644 --- a/lib/phoenix_kit_web/users/auth.ex +++ b/lib/phoenix_kit_web/users/auth.ex @@ -947,6 +947,8 @@ defmodule PhoenixKitWeb.Users.Auth do defp external_plugin_view?(view) do case Module.split(view) do ["PhoenixKitWeb" | _] -> false + # Extracted packages keep PhoenixKit.Modules.*.Web namespace — treat as external + ["PhoenixKit", "Modules", _, "Web" | _] -> true ["PhoenixKit" | _] -> false _ -> true end diff --git a/mix.exs b/mix.exs index 7519cca77..7704f260a 100644 --- a/mix.exs +++ b/mix.exs @@ -110,7 +110,7 @@ defmodule PhoenixKit.MixProject do {:hackney, "~> 1.9"}, # Content editor - {:leaf, "~> 0.2"}, + {:leaf, "~> 0.2.5"}, # Utilities {:jason, "~> 1.4"}, @@ -179,7 +179,6 @@ defmodule PhoenixKit.MixProject do "guides/auth-header-integration.md", "guides/draggable-list-component.md", "guides/README.md", - "guides/entities-guide.md", "guides/custom-admin-pages.md", "lib/phoenix_kit/dashboard/ADMIN_README.md" ], diff --git a/mix.lock b/mix.lock index 6a0a6c2c0..63a96f9c6 100644 --- a/mix.lock +++ b/mix.lock @@ -39,7 +39,7 @@ "igniter": {:hex, :igniter, "0.7.6", "687d622c735e020f13cf480c83d0fce1cc899f4fbed547f5254b960ea82d3525", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "424f41a41273fce0f7424008405ee073b5bd06359ca9396e841f83a669c01619"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"}, - "leaf": {:hex, :leaf, "0.2.4", "aa32f081cbb23e6480c283fb5cac45e2c024982fe03182604b08f4063205632c", [:mix], [{:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "90c37da932aaa4ea4eedcbf27a6ebad143a36cef2e23b7f80817a6c5eb5056ab"}, + "leaf": {:hex, :leaf, "0.2.5", "e5fa6053a8a1f50681943310a92ae12c8b6f23ca6aba1d1d0fe14b25abe31957", [:mix], [{:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "1b50cbcab097ae735c201e93f1d404ec2203356800b3f9fbe37d2bd6ca4ca796"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"}, @@ -58,6 +58,7 @@ "phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"}, "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, + "phoenix_kit": {:hex, :phoenix_kit, "1.7.81", "933b40843bd8d10ed51a39038c243009c9d79c84d2c26b1c6a16ac78daeed0d1", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:beamlab_countries, "~> 1.0", [hex: :beamlab_countries, repo: "hexpm", optional: false]}, {:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ex_aws, "~> 2.4", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:ex_aws_ec2, "~> 2.0", [hex: :ex_aws_ec2, repo: "hexpm", optional: false]}, {:ex_aws_s3, "~> 2.4", [hex: :ex_aws_s3, repo: "hexpm", optional: false]}, {:ex_aws_sns, "~> 2.3", [hex: :ex_aws_sns, repo: "hexpm", optional: false]}, {:ex_aws_sqs, "~> 3.4", [hex: :ex_aws_sqs, repo: "hexpm", optional: false]}, {:ex_aws_sts, "~> 2.3", [hex: :ex_aws_sts, repo: "hexpm", optional: false]}, {:finch, "~> 0.18", [hex: :finch, repo: "hexpm", optional: false]}, {:gen_smtp, "~> 1.2", [hex: :gen_smtp, repo: "hexpm", optional: false]}, {:gettext, "~> 1.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: false]}, {:hammer, "~> 7.1", [hex: :hammer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.7", [hex: :igniter, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:leaf, "~> 0.2", [hex: :leaf, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.2", [hex: :nimble_csv, repo: "hexpm", optional: false]}, {:oauth2, "~> 2.0", [hex: :oauth2, repo: "hexpm", optional: false]}, {:oban, "~> 2.20", [hex: :oban, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.8.1", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_ecto, "~> 4.6", [hex: :phoenix_ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.22", [hex: :postgrex, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:saxy, "~> 1.5", [hex: :saxy, repo: "hexpm", optional: false]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}, {:swoosh, "~> 1.20", [hex: :swoosh, repo: "hexpm", optional: false]}, {:ueberauth, "~> 0.10", [hex: :ueberauth, repo: "hexpm", optional: false]}, {:ueberauth_apple, "~> 0.6", [hex: :ueberauth_apple, repo: "hexpm", optional: false]}, {:ueberauth_facebook, "~> 0.10", [hex: :ueberauth_facebook, repo: "hexpm", optional: false]}, {:ueberauth_github, "~> 0.8", [hex: :ueberauth_github, repo: "hexpm", optional: false]}, {:ueberauth_google, "~> 0.12", [hex: :ueberauth_google, repo: "hexpm", optional: false]}, {:uuidv7, "~> 1.0", [hex: :uuidv7, repo: "hexpm", optional: false]}, {:websockex, "~> 0.5.1", [hex: :websockex, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.9", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "89f072911443900094fcc959bdf6a2ea1b8da163e62ca8ed29d3d6f642b6b80f"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"}, "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.27", "9afcab28b0c82afdc51044e661bcd5b8de53d242593d34c964a37710b40a42af", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "415735d0b2c612c9104108b35654e977626a0cb346711e1e4f1ed16e3c827ede"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, diff --git a/priv/static/assets/phoenix_kit.js b/priv/static/assets/phoenix_kit.js index 5b528a2d8..0342da824 100644 --- a/priv/static/assets/phoenix_kit.js +++ b/priv/static/assets/phoenix_kit.js @@ -1766,7 +1766,7 @@ if (typeof window.Chart === "undefined") { // ============================================================================ (function() { - var LEAF_CDN = "https://cdn.jsdelivr.net/gh/alexdont/leaf@v0.2.4/priv/static/assets/leaf.js"; + var LEAF_CDN = "https://cdn.jsdelivr.net/gh/alexdont/leaf@v0.2.5/priv/static/assets/leaf.js"; var leafLoading = false; var leafCallbacks = []; diff --git a/test/modules/entities/field_type_test.exs b/test/modules/entities/field_type_test.exs deleted file mode 100644 index 081e23515..000000000 --- a/test/modules/entities/field_type_test.exs +++ /dev/null @@ -1,73 +0,0 @@ -defmodule PhoenixKit.Modules.Entities.FieldTypeTest do - use ExUnit.Case, async: true - - alias PhoenixKit.Modules.Entities.FieldType - - # --- from_map/1 --- - - describe "from_map/1" do - test "converts atom-key map to struct" do - map = %{ - name: "text", - label: "Text", - description: "Single-line text input", - category: :basic, - icon: "hero-pencil", - requires_options: false, - default_props: %{"max_length" => 255} - } - - result = FieldType.from_map(map) - - assert %FieldType{} = result - assert result.name == "text" - assert result.label == "Text" - assert result.description == "Single-line text input" - assert result.category == :basic - assert result.icon == "hero-pencil" - assert result.requires_options == false - assert result.default_props == %{"max_length" => 255} - end - - test "converts string-key map to struct" do - map = %{ - "name" => "select", - "label" => "Select", - "description" => "Dropdown", - "category" => :choice, - "icon" => "hero-chevron-down", - "requires_options" => true, - "default_props" => %{} - } - - result = FieldType.from_map(map) - - assert result.name == "select" - assert result.label == "Select" - assert result.requires_options == true - assert result.category == :choice - end - - test "defaults requires_options to false" do - map = %{name: "custom", label: "Custom", category: :basic} - result = FieldType.from_map(map) - - assert result.requires_options == false - end - - test "defaults default_props to empty map" do - map = %{name: "custom", label: "Custom", category: :basic} - result = FieldType.from_map(map) - - assert result.default_props == %{} - end - - test "handles nil description and icon" do - map = %{name: "custom", label: "Custom", category: :basic} - result = FieldType.from_map(map) - - assert result.description == nil - assert result.icon == nil - end - end -end diff --git a/test/modules/entities/field_types_test.exs b/test/modules/entities/field_types_test.exs deleted file mode 100644 index 38fcef55e..000000000 --- a/test/modules/entities/field_types_test.exs +++ /dev/null @@ -1,400 +0,0 @@ -defmodule PhoenixKit.Modules.Entities.FieldTypesTest do - use ExUnit.Case, async: true - - alias PhoenixKit.Modules.Entities.FieldType - alias PhoenixKit.Modules.Entities.FieldTypes - - # --- all/0 --- - - describe "all/0" do - test "returns a map of FieldType structs" do - result = FieldTypes.all() - - assert is_map(result) - assert map_size(result) > 0 - - Enum.each(result, fn {key, value} -> - assert is_binary(key) - assert %FieldType{} = value - end) - end - - test "includes all expected field types" do - result = FieldTypes.all() - - expected = - ~w(text textarea email url rich_text number boolean date select radio checkbox file) - - for type <- expected do - assert Map.has_key?(result, type), "Missing field type: #{type}" - end - end - end - - # --- list_types/0 --- - - describe "list_types/0" do - test "returns list of strings" do - result = FieldTypes.list_types() - assert is_list(result) - assert Enum.all?(result, &is_binary/1) - end - - test "includes core types" do - types = FieldTypes.list_types() - assert "text" in types - assert "select" in types - assert "number" in types - assert "boolean" in types - end - end - - # --- get_type/1 --- - - describe "get_type/1" do - test "returns FieldType struct for valid type" do - result = FieldTypes.get_type("text") - - assert %FieldType{} = result - assert result.name == "text" - assert result.label == "Text" - assert result.category == :basic - end - - test "returns nil for invalid type" do - assert FieldTypes.get_type("nonexistent") == nil - end - - test "returns correct info for choice type" do - result = FieldTypes.get_type("select") - - assert result.name == "select" - assert result.requires_options == true - assert result.category == :choice - end - - test "returns correct info for number type" do - result = FieldTypes.get_type("number") - - assert result.category == :numeric - assert result.requires_options == false - end - end - - # --- valid_type?/1 --- - - describe "valid_type?/1" do - test "returns true for all known types" do - for type <- FieldTypes.list_types() do - assert FieldTypes.valid_type?(type), "Expected #{type} to be valid" - end - end - - test "returns false for unknown types" do - refute FieldTypes.valid_type?("invalid") - refute FieldTypes.valid_type?("xml") - refute FieldTypes.valid_type?("") - end - end - - # --- by_category/1 --- - - describe "by_category/1" do - test "returns basic field types" do - result = FieldTypes.by_category(:basic) - - assert is_list(result) - assert result != [] - - names = Enum.map(result, & &1.name) - assert "text" in names - assert "textarea" in names - end - - test "returns choice field types" do - result = FieldTypes.by_category(:choice) - names = Enum.map(result, & &1.name) - - assert "select" in names - assert "radio" in names - assert "checkbox" in names - end - - test "returns empty list for nonexistent category" do - assert FieldTypes.by_category(:nonexistent) == [] - end - - test "all returned types have matching category" do - for {category, _label} <- FieldTypes.category_list() do - types = FieldTypes.by_category(category) - - Enum.each(types, fn type -> - assert type.category == category - end) - end - end - end - - # --- categories/0 --- - - describe "categories/0" do - test "returns map grouped by category atom" do - result = FieldTypes.categories() - - assert is_map(result) - assert Map.has_key?(result, :basic) - assert Map.has_key?(result, :choice) - end - - test "every type appears in exactly one category" do - result = FieldTypes.categories() - - all_names = - result - |> Map.values() - |> List.flatten() - |> Enum.map(& &1.name) - - assert length(all_names) == length(Enum.uniq(all_names)) - assert length(all_names) == map_size(FieldTypes.all()) - end - end - - # --- category_list/0 --- - - describe "category_list/0" do - test "returns list of {atom, string} tuples" do - result = FieldTypes.category_list() - - assert is_list(result) - - Enum.each(result, fn {key, label} -> - assert is_atom(key) - assert is_binary(label) - end) - end - - test "includes expected categories" do - keys = FieldTypes.category_list() |> Enum.map(&elem(&1, 0)) - - assert :basic in keys - assert :numeric in keys - assert :choice in keys - end - end - - # --- requires_options?/1 --- - - describe "requires_options?/1" do - test "returns true for select, radio, checkbox" do - assert FieldTypes.requires_options?("select") - assert FieldTypes.requires_options?("radio") - assert FieldTypes.requires_options?("checkbox") - end - - test "returns false for text, number, boolean" do - refute FieldTypes.requires_options?("text") - refute FieldTypes.requires_options?("number") - refute FieldTypes.requires_options?("boolean") - end - - test "returns false for unknown type" do - refute FieldTypes.requires_options?("unknown") - end - end - - # --- default_props/1 --- - - describe "default_props/1" do - test "returns props for text type" do - props = FieldTypes.default_props("text") - assert is_map(props) - assert Map.has_key?(props, "max_length") - end - - test "returns props for textarea type" do - props = FieldTypes.default_props("textarea") - assert Map.has_key?(props, "rows") - assert Map.has_key?(props, "max_length") - end - - test "returns empty map for unknown type" do - assert FieldTypes.default_props("unknown") == %{} - end - end - - # --- for_picker/0 --- - - describe "for_picker/0" do - test "returns list of maps with expected keys" do - result = FieldTypes.for_picker() - - assert is_list(result) - assert result != [] - - Enum.each(result, fn item -> - assert Map.has_key?(item, :value) - assert Map.has_key?(item, :label) - assert Map.has_key?(item, :category) - assert Map.has_key?(item, :icon) - end) - end - end - - # --- validate_field/1 --- - - describe "validate_field/1" do - test "validates a valid text field" do - field = %{"type" => "text", "key" => "title", "label" => "Title"} - assert {:ok, ^field} = FieldTypes.validate_field(field) - end - - test "validates a valid select field with options" do - field = %{ - "type" => "select", - "key" => "category", - "label" => "Category", - "options" => ["A", "B"] - } - - assert {:ok, ^field} = FieldTypes.validate_field(field) - end - - test "rejects missing required keys" do - assert {:error, msg} = FieldTypes.validate_field(%{"type" => "text"}) - assert msg =~ "Missing required keys" - end - - test "rejects invalid field type" do - field = %{"type" => "invalid", "key" => "test", "label" => "Test"} - assert {:error, msg} = FieldTypes.validate_field(field) - assert msg =~ "Invalid field type" - end - - test "rejects select field without options" do - field = %{"type" => "select", "key" => "cat", "label" => "Category"} - assert {:error, msg} = FieldTypes.validate_field(field) - assert msg =~ "requires options" - end - - test "rejects select field with empty options" do - field = %{"type" => "select", "key" => "cat", "label" => "Category", "options" => []} - assert {:error, msg} = FieldTypes.validate_field(field) - assert msg =~ "requires options" - end - end - - # --- new_field/4 --- - - describe "new_field/4" do - test "creates a text field with defaults" do - result = FieldTypes.new_field("text", "name", "Name") - - assert result["type"] == "text" - assert result["key"] == "name" - assert result["label"] == "Name" - assert result["required"] == false - assert Map.has_key?(result, "max_length") - end - - test "creates a field with required flag" do - result = FieldTypes.new_field("text", "name", "Name", required: true) - assert result["required"] == true - end - - test "creates a select field with options" do - result = FieldTypes.new_field("select", "status", "Status", options: ["A", "B"]) - - assert result["type"] == "select" - assert result["options"] == ["A", "B"] - end - - test "merges type-specific default props" do - result = FieldTypes.new_field("textarea", "bio", "Biography") - assert Map.has_key?(result, "rows") - end - - test "accepts custom default value for types without default_props default" do - result = FieldTypes.new_field("text", "name", "Name", default: "untitled") - assert result["default"] == "untitled" - end - end - - # --- Builder helpers --- - - describe "builder helpers" do - test "text_field/3" do - result = FieldTypes.text_field("name", "Name", required: true) - assert result["type"] == "text" - assert result["key"] == "name" - assert result["required"] == true - end - - test "textarea_field/3" do - result = FieldTypes.textarea_field("bio", "Bio") - assert result["type"] == "textarea" - assert Map.has_key?(result, "rows") - end - - test "email_field/3" do - result = FieldTypes.email_field("email", "Email") - assert result["type"] == "email" - end - - test "number_field/3" do - result = FieldTypes.number_field("age", "Age") - assert result["type"] == "number" - end - - test "boolean_field/3" do - result = FieldTypes.boolean_field("active", "Active") - assert result["type"] == "boolean" - # Boolean type has default_props %{"default" => false} which merges last - assert result["default"] == false - end - - test "rich_text_field/3" do - result = FieldTypes.rich_text_field("content", "Content") - assert result["type"] == "rich_text" - end - - test "select_field/4" do - result = FieldTypes.select_field("cat", "Category", ["A", "B"]) - assert result["type"] == "select" - assert result["options"] == ["A", "B"] - end - - test "radio_field/4" do - result = FieldTypes.radio_field("priority", "Priority", ["Low", "High"]) - assert result["type"] == "radio" - assert result["options"] == ["Low", "High"] - end - - test "checkbox_field/4" do - result = FieldTypes.checkbox_field("tags", "Tags", ["A", "B", "C"]) - assert result["type"] == "checkbox" - assert result["options"] == ["A", "B", "C"] - end - - test "file_field/3" do - result = FieldTypes.file_field("docs", "Docs") - assert result["type"] == "file" - assert Map.has_key?(result, "max_entries") - assert Map.has_key?(result, "max_file_size") - assert Map.has_key?(result, "accept") - end - - test "file_field/3 with custom constraints" do - result = - FieldTypes.file_field("docs", "Docs", - max_entries: 10, - max_file_size: 52_428_800, - accept: [".pdf"] - ) - - assert result["max_entries"] == 10 - assert result["max_file_size"] == 52_428_800 - assert result["accept"] == [".pdf"] - end - end -end diff --git a/test/modules/entities/html_sanitizer_test.exs b/test/modules/entities/html_sanitizer_test.exs deleted file mode 100644 index 5b3942199..000000000 --- a/test/modules/entities/html_sanitizer_test.exs +++ /dev/null @@ -1,190 +0,0 @@ -defmodule PhoenixKit.Modules.Entities.HtmlSanitizerTest do - use ExUnit.Case, async: true - - alias PhoenixKit.Modules.Entities.HtmlSanitizer - - # --- sanitize/1 --- - - describe "sanitize/1" do - test "preserves safe HTML" do - assert HtmlSanitizer.sanitize("

Hello world

") == - "

Hello world

" - end - - test "removes script tags and content" do - assert HtmlSanitizer.sanitize("

Hello

") == - "

Hello

" - end - - test "removes script tags with attributes" do - input = ~s[

Hi

] - result = HtmlSanitizer.sanitize(input) - refute result =~ "script" - assert result =~ "

Hi

" - end - - test "removes style tags and content" do - input = "

Visible

" - result = HtmlSanitizer.sanitize(input) - refute result =~ "style" - assert result =~ "

Visible

" - end - - test "removes onclick event handlers" do - result = HtmlSanitizer.sanitize(~s[

Hello

]) - assert result == "

Hello

" - end - - test "removes onerror event handlers" do - result = HtmlSanitizer.sanitize(~s[]) - refute result =~ "onerror" - end - - test "removes onload event handlers" do - input = ~s[

Content

] - result = HtmlSanitizer.sanitize(input) - refute result =~ "onload" - end - - test "removes javascript: URLs from href" do - result = HtmlSanitizer.sanitize(~s[Click]) - refute result =~ "javascript" - assert result =~ "Click" - end - - test "removes javascript: URLs from src" do - input = ~s[] - result = HtmlSanitizer.sanitize(input) - refute result =~ "javascript" - end - - test "removes data: URLs" do - input = ~s[Click] - result = HtmlSanitizer.sanitize(input) - refute result =~ "data:" - end - - test "removes iframe tags" do - input = ~s(

Safe

) - result = HtmlSanitizer.sanitize(input) - refute result =~ "iframe" - assert result =~ "

Safe

" - end - - test "removes object tags" do - input = ~s(

Safe

) - result = HtmlSanitizer.sanitize(input) - refute result =~ "object" - end - - test "removes embed tags" do - input = ~s(

Safe

) - result = HtmlSanitizer.sanitize(input) - refute result =~ "embed" - end - - test "removes form tags" do - input = ~s(
) - result = HtmlSanitizer.sanitize(input) - refute result =~ "form" - refute result =~ "input" - end - - test "preserves safe links" do - input = ~s(Link) - assert HtmlSanitizer.sanitize(input) == input - end - - test "preserves tables" do - input = "
Cell
" - assert HtmlSanitizer.sanitize(input) == input - end - - test "preserves lists" do - input = "
  • Item 1
  • Item 2
" - assert HtmlSanitizer.sanitize(input) == input - end - - test "returns nil for nil input" do - assert HtmlSanitizer.sanitize(nil) == nil - end - - test "returns empty string for empty input" do - assert HtmlSanitizer.sanitize("") == "" - end - - test "passes through non-string values" do - assert HtmlSanitizer.sanitize(42) == 42 - end - - test "trims whitespace from result" do - assert HtmlSanitizer.sanitize("

Hello

") == "

Hello

" - end - end - - # --- sanitize_rich_text_fields/2 --- - - describe "sanitize_rich_text_fields/2" do - test "sanitizes only rich_text fields" do - fields = [ - %{"type" => "rich_text", "key" => "content"}, - %{"type" => "text", "key" => "title"} - ] - - data = %{ - "content" => "

Hello

", - "title" => "Title" - } - - result = HtmlSanitizer.sanitize_rich_text_fields(fields, data) - - assert result["content"] == "

Hello

" - # text field is NOT sanitized - assert result["title"] == "Title" - end - - test "handles multiple rich_text fields" do - fields = [ - %{"type" => "rich_text", "key" => "body"}, - %{"type" => "rich_text", "key" => "summary"} - ] - - data = %{ - "body" => "

Body

", - "summary" => "

Summary

" - } - - result = HtmlSanitizer.sanitize_rich_text_fields(fields, data) - - assert result["body"] == "

Body

" - assert result["summary"] == "

Summary

" - end - - test "skips nil values in rich_text fields" do - fields = [%{"type" => "rich_text", "key" => "content"}] - data = %{"content" => nil} - - result = HtmlSanitizer.sanitize_rich_text_fields(fields, data) - assert result["content"] == nil - end - - test "handles no rich_text fields" do - fields = [%{"type" => "text", "key" => "name"}] - data = %{"name" => "test"} - - result = HtmlSanitizer.sanitize_rich_text_fields(fields, data) - assert result == data - end - - test "handles empty fields list" do - data = %{"content" => ""} - result = HtmlSanitizer.sanitize_rich_text_fields([], data) - assert result == data - end - - test "returns data unchanged for invalid fields argument" do - data = %{"test" => "value"} - assert HtmlSanitizer.sanitize_rich_text_fields("invalid", data) == data - end - end -end diff --git a/test/modules/entities/multilang_test.exs b/test/modules/entities/multilang_test.exs deleted file mode 100644 index bee83fc12..000000000 --- a/test/modules/entities/multilang_test.exs +++ /dev/null @@ -1,459 +0,0 @@ -defmodule PhoenixKit.Modules.Entities.MultilangTest do - use ExUnit.Case, async: true - - alias PhoenixKit.Modules.Entities.Multilang - - # --- Test Data --- - - defp multilang_data do - %{ - "_primary_language" => "en-US", - "en-US" => %{"name" => "Acme", "category" => "Tech", "desc" => "A company"}, - "es-ES" => %{"name" => "Acme España"}, - "fr-FR" => %{"desc" => "Une entreprise"} - } - end - - defp flat_data do - %{"name" => "Acme", "category" => "Tech"} - end - - # --- multilang_data?/1 --- - - describe "multilang_data?/1" do - test "returns true for data with _primary_language key" do - assert Multilang.multilang_data?(multilang_data()) - end - - test "returns false for flat data" do - refute Multilang.multilang_data?(flat_data()) - end - - test "returns false for nil" do - refute Multilang.multilang_data?(nil) - end - - test "returns false for empty map" do - refute Multilang.multilang_data?(%{}) - end - - test "returns false for non-map values" do - refute Multilang.multilang_data?("string") - refute Multilang.multilang_data?(42) - refute Multilang.multilang_data?([]) - end - end - - # --- get_language_data/2 --- - - describe "get_language_data/2" do - test "returns primary data for primary language" do - result = Multilang.get_language_data(multilang_data(), "en-US") - - assert result == %{ - "name" => "Acme", - "category" => "Tech", - "desc" => "A company" - } - end - - test "returns merged data for secondary language" do - result = Multilang.get_language_data(multilang_data(), "es-ES") - - assert result == %{ - "name" => "Acme España", - "category" => "Tech", - "desc" => "A company" - } - end - - test "secondary language overrides only differ from primary" do - result = Multilang.get_language_data(multilang_data(), "fr-FR") - - assert result == %{ - "name" => "Acme", - "category" => "Tech", - "desc" => "Une entreprise" - } - end - - test "returns primary data for language with no overrides" do - result = Multilang.get_language_data(multilang_data(), "de-DE") - - assert result == %{ - "name" => "Acme", - "category" => "Tech", - "desc" => "A company" - } - end - - test "returns flat data as-is for non-multilang data" do - result = Multilang.get_language_data(flat_data(), "en-US") - assert result == flat_data() - end - - test "returns empty map for nil data" do - assert Multilang.get_language_data(nil, "en-US") == %{} - end - end - - # --- get_primary_data/1 --- - - describe "get_primary_data/1" do - test "extracts primary language data from multilang" do - result = Multilang.get_primary_data(multilang_data()) - - assert result == %{ - "name" => "Acme", - "category" => "Tech", - "desc" => "A company" - } - end - - test "returns flat data as-is" do - assert Multilang.get_primary_data(flat_data()) == flat_data() - end - - test "returns empty map for nil" do - assert Multilang.get_primary_data(nil) == %{} - end - end - - # --- get_raw_language_data/2 --- - - describe "get_raw_language_data/2" do - test "returns raw primary data (all fields)" do - result = Multilang.get_raw_language_data(multilang_data(), "en-US") - - assert result == %{ - "name" => "Acme", - "category" => "Tech", - "desc" => "A company" - } - end - - test "returns raw overrides only for secondary language" do - result = Multilang.get_raw_language_data(multilang_data(), "es-ES") - assert result == %{"name" => "Acme España"} - end - - test "returns empty map for language with no overrides" do - result = Multilang.get_raw_language_data(multilang_data(), "de-DE") - assert result == %{} - end - - test "returns flat data as-is for non-multilang" do - result = Multilang.get_raw_language_data(flat_data(), "en-US") - assert result == flat_data() - end - - test "returns empty map for nil" do - assert Multilang.get_raw_language_data(nil, "en-US") == %{} - end - end - - # --- put_language_data/3 --- - - describe "put_language_data/3" do - test "stores all fields for primary language" do - new_fields = %{"name" => "Acme Corp", "category" => "Business", "desc" => "Updated"} - result = Multilang.put_language_data(multilang_data(), "en-US", new_fields) - - assert result["_primary_language"] == "en-US" - assert result["en-US"] == new_fields - # Other languages preserved - assert result["es-ES"] == %{"name" => "Acme España"} - end - - test "stores only overrides for secondary language" do - new_fields = %{"name" => "Acme Frankreich", "category" => "Tech", "desc" => "A company"} - result = Multilang.put_language_data(multilang_data(), "de-DE", new_fields) - - # Only "name" differs from primary, so only "name" is stored - assert result["de-DE"] == %{"name" => "Acme Frankreich"} - end - - test "removes secondary language key when all fields match primary" do - # Submit exact same data as primary - primary_data = %{"name" => "Acme", "category" => "Tech", "desc" => "A company"} - result = Multilang.put_language_data(multilang_data(), "es-ES", primary_data) - - refute Map.has_key?(result, "es-ES") - end - - test "removes secondary language key when all fields are empty" do - result = - Multilang.put_language_data(multilang_data(), "es-ES", %{"name" => "", "category" => ""}) - - refute Map.has_key?(result, "es-ES") - end - - test "converts flat data to multilang structure on first put" do - result = Multilang.put_language_data(flat_data(), "en-US", %{"name" => "Updated"}) - - assert Multilang.multilang_data?(result) - assert result["en-US"] == %{"name" => "Updated"} - end - - test "handles nil existing data" do - result = Multilang.put_language_data(nil, "en-US", %{"name" => "New"}) - - assert Multilang.multilang_data?(result) - end - - test "uses embedded primary for existing multilang data" do - data = multilang_data() - new_es = %{"name" => "Nuevo Nombre", "category" => "Tech", "desc" => "A company"} - result = Multilang.put_language_data(data, "es-ES", new_es) - - # Only the override (name) should be stored - assert result["es-ES"] == %{"name" => "Nuevo Nombre"} - # Primary unchanged - assert result["_primary_language"] == "en-US" - end - end - - # --- migrate_to_multilang/2 --- - - describe "migrate_to_multilang/2" do - test "wraps flat data into multilang structure" do - result = Multilang.migrate_to_multilang(flat_data(), "en-US") - - assert result["_primary_language"] == "en-US" - assert result["en-US"] == flat_data() - end - - test "handles nil data" do - result = Multilang.migrate_to_multilang(nil, "en-US") - - assert result["_primary_language"] == "en-US" - assert result["en-US"] == %{} - end - - test "uses provided language code" do - result = Multilang.migrate_to_multilang(flat_data(), "es-ES") - - assert result["_primary_language"] == "es-ES" - assert result["es-ES"] == flat_data() - end - end - - # --- flatten_to_primary/1 --- - - describe "flatten_to_primary/1" do - test "extracts primary language data" do - result = Multilang.flatten_to_primary(multilang_data()) - - assert result == %{ - "name" => "Acme", - "category" => "Tech", - "desc" => "A company" - } - end - - test "returns flat data as-is (no _primary_language key)" do - assert Multilang.flatten_to_primary(flat_data()) == flat_data() - end - - test "returns empty map for nil" do - assert Multilang.flatten_to_primary(nil) == %{} - end - - test "returns empty map for non-map input" do - assert Multilang.flatten_to_primary("string") == %{} - end - - test "handles missing primary language data gracefully" do - data = %{"_primary_language" => "ja-JP"} - assert Multilang.flatten_to_primary(data) == %{} - end - end - - # --- rekey_primary/2 --- - - describe "rekey_primary/2" do - test "promotes new primary with all fields from old primary" do - result = Multilang.rekey_primary(multilang_data(), "es-ES") - - assert result["_primary_language"] == "es-ES" - - # New primary gets merged: old primary base + its own overrides - assert result["es-ES"] == %{ - "name" => "Acme España", - "category" => "Tech", - "desc" => "A company" - } - end - - test "strips old primary to overrides" do - result = Multilang.rekey_primary(multilang_data(), "es-ES") - - # Old primary (en-US) is now secondary — only fields differing from new primary are kept. - # New primary has: name="Acme España", category="Tech", desc="A company" - # Old primary had: name="Acme", category="Tech", desc="A company" - # Only "name" differs → stored as override - assert result["en-US"] == %{"name" => "Acme"} - end - - test "recomputes other secondaries against new primary" do - result = Multilang.rekey_primary(multilang_data(), "es-ES") - - # fr-FR had override: desc="Une entreprise" - # New primary has: name="Acme España", category="Tech", desc="A company" - # fr-FR full data: name="Acme", category="Tech", desc="Une entreprise" - # Overrides vs new primary: name differs ("Acme" vs "Acme España"), desc differs - assert result["fr-FR"] == %{"name" => "Acme", "desc" => "Une entreprise"} - end - - test "returns data unchanged when already using that primary" do - result = Multilang.rekey_primary(multilang_data(), "en-US") - assert result == multilang_data() - end - - test "returns non-multilang data unchanged" do - result = Multilang.rekey_primary(flat_data(), "es-ES") - assert result == flat_data() - end - - test "returns nil unchanged" do - assert Multilang.rekey_primary(nil, "es-ES") == nil - end - - test "re-keys to language with no existing overrides" do - result = Multilang.rekey_primary(multilang_data(), "de-DE") - - assert result["_primary_language"] == "de-DE" - - # de-DE gets all fields from old primary (no overrides existed) - assert result["de-DE"] == %{ - "name" => "Acme", - "category" => "Tech", - "desc" => "A company" - } - - # Old primary (en-US) now matches de-DE exactly → key removed entirely - refute Map.has_key?(result, "en-US") - end - - test "removes secondary when all fields match new primary" do - # Create data where es-ES has overrides that match what de-DE would promote to - data = %{ - "_primary_language" => "en-US", - "en-US" => %{"name" => "Acme", "color" => "red"}, - "es-ES" => %{"name" => "Acme"} - } - - # Rekey to es-ES: promoted = merge(en-US, es-ES) = %{name: "Acme", color: "red"} - # en-US vs promoted: name same, color same → removed entirely - result = Multilang.rekey_primary(data, "es-ES") - - assert result["_primary_language"] == "es-ES" - assert result["es-ES"] == %{"name" => "Acme", "color" => "red"} - refute Map.has_key?(result, "en-US") - end - - test "is idempotent" do - once = Multilang.rekey_primary(multilang_data(), "es-ES") - twice = Multilang.rekey_primary(once, "es-ES") - assert once == twice - end - - test "round-trip preserves all translatable data" do - rekeyed = Multilang.rekey_primary(multilang_data(), "es-ES") - back = Multilang.rekey_primary(rekeyed, "en-US") - - # Primary data should be fully restored - assert back["_primary_language"] == "en-US" - - assert back["en-US"] == %{ - "name" => "Acme", - "category" => "Tech", - "desc" => "A company" - } - - # es-ES becomes overrides-only (name differs from restored primary) - assert back["es-ES"] == %{"name" => "Acme España"} - - # fr-FR still has its override - assert back["fr-FR"] == %{"desc" => "Une entreprise"} - end - end - - # --- maybe_rekey_data/1 --- - # Note: In test env without Languages module DB, primary_language() falls - # back to "en-US". So data with embedded "en-US" is a no-op, while data - # with any other embedded primary will be re-keyed to "en-US". - - describe "maybe_rekey_data/1" do - test "re-keys when embedded primary differs from global" do - # Embedded is "es-ES", global fallback is "en-US" → should re-key - data = %{ - "_primary_language" => "es-ES", - "es-ES" => %{"name" => "Acme España", "category" => "Tech"}, - "en-US" => %{"name" => "Acme"} - } - - result = Multilang.maybe_rekey_data(data) - - assert result["_primary_language"] == "en-US" - # New primary promoted: merge(es-ES base, en-US overrides) = name="Acme", category="Tech" - assert result["en-US"] == %{"name" => "Acme", "category" => "Tech"} - # Old primary (es-ES) stripped to overrides: only name differs - assert result["es-ES"] == %{"name" => "Acme España"} - end - - test "returns data unchanged when already using global primary" do - # Embedded is "en-US" which matches the fallback global - result = Multilang.maybe_rekey_data(multilang_data()) - - assert result == multilang_data() - end - - test "returns non-multilang data unchanged" do - result = Multilang.maybe_rekey_data(flat_data()) - assert result == flat_data() - end - - test "returns nil unchanged" do - assert Multilang.maybe_rekey_data(nil) == nil - end - end - - # --- Integration: migrate then put --- - - describe "migrate + put workflow" do - test "flat data -> multilang -> add secondary" do - data = flat_data() - multilang = Multilang.migrate_to_multilang(data, "en-US") - - assert Multilang.multilang_data?(multilang) - - result = - Multilang.put_language_data(multilang, "es-ES", %{ - "name" => "Acme España", - "category" => "Tech" - }) - - # Only name differs, category matches primary - assert result["es-ES"] == %{"name" => "Acme España"} - assert result["en-US"] == flat_data() - end - - test "get_language_data returns correct merged result after put" do - data = multilang_data() - - updated = - Multilang.put_language_data(data, "de-DE", %{ - "name" => "Acme DE", - "category" => "Tech", - "desc" => "A company" - }) - - result = Multilang.get_language_data(updated, "de-DE") - - assert result["name"] == "Acme DE" - assert result["category"] == "Tech" - assert result["desc"] == "A company" - end - end -end diff --git a/test/modules/entities/title_translation_test.exs b/test/modules/entities/title_translation_test.exs deleted file mode 100644 index 1e479b2d4..000000000 --- a/test/modules/entities/title_translation_test.exs +++ /dev/null @@ -1,165 +0,0 @@ -defmodule PhoenixKit.Modules.Entities.TitleTranslationTest do - use ExUnit.Case, async: true - - alias PhoenixKit.Modules.Entities.EntityData - - # Pure-function tests for get_title_translation/2 and get_all_title_translations/1. - # set_title_translation/3 requires DB access and is covered in parent app integration tests. - - # --- Test Fixtures --- - - defp record_with_title_in_data do - %EntityData{ - title: "Acme", - data: %{ - "_primary_language" => "en-US", - "en-US" => %{"name" => "Acme Corp", "_title" => "Acme"}, - "es-ES" => %{"name" => "Acme España", "_title" => "Acme ES"} - }, - metadata: %{} - } - end - - defp record_with_old_metadata_translations do - %EntityData{ - title: "Acme", - data: %{ - "_primary_language" => "en-US", - "en-US" => %{"name" => "Acme Corp"} - }, - metadata: %{ - "translations" => %{ - "es-ES" => %{"title" => "Acme Metadata ES"} - } - } - } - end - - defp record_with_no_translations do - %EntityData{ - title: "Acme", - data: %{ - "_primary_language" => "en-US", - "en-US" => %{"name" => "Acme Corp"} - }, - metadata: %{} - } - end - - defp record_with_flat_data do - %EntityData{ - title: "Acme", - data: %{"name" => "Acme Corp"}, - metadata: %{} - } - end - - defp record_with_nil_data do - %EntityData{ - title: "Acme", - data: nil, - metadata: nil - } - end - - # --- get_title_translation/2 --- - - describe "get_title_translation/2" do - test "returns _title from JSONB data for primary language" do - assert EntityData.get_title_translation(record_with_title_in_data(), "en-US") == "Acme" - end - - test "returns _title from JSONB data for secondary language" do - assert EntityData.get_title_translation(record_with_title_in_data(), "es-ES") == "Acme ES" - end - - test "falls back to metadata translations for unmigrated records" do - assert EntityData.get_title_translation(record_with_old_metadata_translations(), "es-ES") == - "Acme Metadata ES" - end - - test "falls back to title column when no translations exist" do - assert EntityData.get_title_translation(record_with_no_translations(), "es-ES") == "Acme" - end - - test "falls back to title column for unknown language" do - assert EntityData.get_title_translation(record_with_title_in_data(), "de-DE") == "Acme" - end - - test "handles flat (non-multilang) data" do - assert EntityData.get_title_translation(record_with_flat_data(), "en-US") == "Acme" - end - - test "handles nil data" do - assert EntityData.get_title_translation(record_with_nil_data(), "en-US") == "Acme" - end - - test "prefers JSONB _title over metadata translations" do - # Record has _title in data AND old metadata translations - record = %EntityData{ - title: "Fallback", - data: %{ - "_primary_language" => "en-US", - "en-US" => %{"_title" => "From Data"}, - "es-ES" => %{"_title" => "Desde Datos"} - }, - metadata: %{ - "translations" => %{ - "es-ES" => %{"title" => "Desde Metadata"} - } - } - } - - assert EntityData.get_title_translation(record, "es-ES") == "Desde Datos" - end - - test "skips empty _title and falls back" do - record = %EntityData{ - title: "Fallback Title", - data: %{ - "_primary_language" => "en-US", - "en-US" => %{"_title" => ""}, - "es-ES" => %{"_title" => ""} - }, - metadata: %{} - } - - assert EntityData.get_title_translation(record, "en-US") == "Fallback Title" - assert EntityData.get_title_translation(record, "es-ES") == "Fallback Title" - end - - test "secondary language without override inherits primary _title" do - record = %EntityData{ - title: "Acme", - data: %{ - "_primary_language" => "en-US", - "en-US" => %{"name" => "Acme Corp", "_title" => "Acme Products"} - }, - metadata: %{} - } - - # fr-FR has no override, get_language_data merges primary → _title inherited - assert EntityData.get_title_translation(record, "fr-FR") == "Acme Products" - end - end - - # --- get_all_title_translations/1 --- - - describe "get_all_title_translations/1" do - test "returns map with all enabled languages" do - result = EntityData.get_all_title_translations(record_with_title_in_data()) - - # In test env, enabled_languages falls back to ["en-US"] - assert is_map(result) - assert Map.has_key?(result, "en-US") - assert result["en-US"] == "Acme" - end - - test "handles record with no translations" do - result = EntityData.get_all_title_translations(record_with_no_translations()) - - assert is_map(result) - assert result["en-US"] == "Acme" - end - end -end diff --git a/test/modules/publishing/editor_forms_test.exs b/test/modules/publishing/editor_forms_test.exs deleted file mode 100644 index 443c66dc8..000000000 --- a/test/modules/publishing/editor_forms_test.exs +++ /dev/null @@ -1,250 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.Web.Editor.FormsTest do - use ExUnit.Case, async: true - - alias PhoenixKit.Modules.Publishing.Web.Editor.Forms - - # ============================================================================ - # Helper to build a minimal socket with assigns for testing - # ============================================================================ - - defp build_socket(overrides) do - defaults = %{ - form: %{"title" => "", "slug" => "", "url_slug" => "", "status" => "draft"}, - post: %{slug: "", uuid: nil, metadata: %{status: "draft"}}, - group_slug: "blog", - group_mode: "slug", - is_primary_language: true, - slug_manually_set: false, - last_auto_slug: "", - url_slug_manually_set: false, - last_auto_url_slug: "" - } - - merged = Map.merge(defaults, overrides) - # Override form fully if provided - merged = - if overrides[:form], - do: %{merged | form: Map.merge(defaults.form, overrides.form)}, - else: merged - - # Build a proper Phoenix.LiveView.Socket with assigns - %Phoenix.LiveView.Socket{} - |> Phoenix.Component.assign(merged) - end - - # ============================================================================ - # maybe_update_slug_from_title/3 - # ============================================================================ - - describe "maybe_update_slug_from_title/3" do - test "generates slug from title for primary language" do - socket = build_socket(%{group_mode: "slug", is_primary_language: true}) - {_socket, form, events} = Forms.maybe_update_slug_from_title(socket, "Hello World") - - assert form["slug"] == "hello-world" - assert [{"update-slug", %{slug: "hello-world"}}] = events - end - - test "returns no update for empty title" do - socket = build_socket(%{group_mode: "slug"}) - {_socket, form, events} = Forms.maybe_update_slug_from_title(socket, "") - - assert form["slug"] == "" - assert events == [] - end - - test "returns no update for nil title" do - socket = build_socket(%{group_mode: "slug"}) - {_socket, _form, events} = Forms.maybe_update_slug_from_title(socket, nil) - - assert events == [] - end - - test "returns no update for timestamp mode" do - socket = build_socket(%{group_mode: "timestamp"}) - {_socket, _form, events} = Forms.maybe_update_slug_from_title(socket, "Hello World") - - assert events == [] - end - - test "respects slug_manually_set flag" do - socket = build_socket(%{slug_manually_set: true, form: %{"slug" => "custom-slug"}}) - {_socket, form, events} = Forms.maybe_update_slug_from_title(socket, "Different Title") - - assert form["slug"] == "custom-slug" - assert events == [] - end - - test "overrides slug when force option is set" do - socket = build_socket(%{slug_manually_set: true, form: %{"slug" => "custom-slug"}}) - - {_socket, form, events} = - Forms.maybe_update_slug_from_title(socket, "New Title", force: true) - - assert form["slug"] == "new-title" - assert [{"update-slug", _}] = events - end - - test "generates url_slug for translation language" do - socket = build_socket(%{is_primary_language: false, form: %{"url_slug" => ""}}) - {_socket, form, events} = Forms.maybe_update_slug_from_title(socket, "Translated Title") - - assert form["url_slug"] == "translated-title" - assert [{"update-url-slug", %{url_slug: "translated-title"}}] = events - end - - test "respects url_slug_manually_set for translations" do - socket = - build_socket(%{ - is_primary_language: false, - url_slug_manually_set: true, - form: %{"url_slug" => "custom-url"} - }) - - {_socket, form, events} = Forms.maybe_update_slug_from_title(socket, "Other Title") - - assert form["url_slug"] == "custom-url" - assert events == [] - end - - test "no update when slug already matches" do - socket = build_socket(%{form: %{"slug" => "hello-world"}}) - {_socket, _form, events} = Forms.maybe_update_slug_from_title(socket, "Hello World") - - assert events == [] - end - end - - # ============================================================================ - # assign_form_with_tracking/3 - # ============================================================================ - - describe "assign_form_with_tracking/3" do - test "assigns slug tracking state" do - socket = build_socket(%{}) - form = %{"title" => "Test", "slug" => "test", "status" => "draft"} - - result = Forms.assign_form_with_tracking(socket, form) - - assert result.assigns.form == form - assert result.assigns.slug_manually_set == false - assert result.assigns.last_auto_slug == "test" - end - - test "does not assign title_manually_set (removed)" do - socket = build_socket(%{}) - form = %{"title" => "Test", "slug" => "test", "status" => "draft"} - - result = Forms.assign_form_with_tracking(socket, form) - - refute Map.has_key?(result.assigns, :title_manually_set) - refute Map.has_key?(result.assigns, :last_auto_title) - end - end - - # ============================================================================ - # Form Building - # ============================================================================ - - describe "post_form/1" do - test "builds form with title from metadata" do - post = %{ - metadata: %{ - title: "My Post", - status: "draft", - published_at: nil, - featured_image_uuid: nil, - url_slug: nil - }, - slug: "my-post", - mode: :slug, - content: "# My Post\nContent here", - url_slug: nil - } - - form = Forms.post_form(post) - - assert form["title"] == "My Post" - assert form["slug"] == "my-post" - assert form["status"] == "draft" - end - - test "returns empty title for Untitled posts" do - post = %{ - metadata: %{ - title: "Untitled", - status: "draft", - published_at: nil, - featured_image_uuid: nil, - url_slug: nil - }, - slug: nil, - mode: "timestamp", - content: "", - url_slug: nil - } - - form = Forms.post_form(post) - assert form["title"] == "" - end - end - - # ============================================================================ - # dirty?/3 - # ============================================================================ - - describe "dirty?/3" do - test "detects title change as dirty" do - post = %{ - metadata: %{ - title: "Original", - status: "draft", - published_at: nil, - featured_image_uuid: nil, - url_slug: nil - }, - slug: "original", - mode: "slug", - content: "content", - url_slug: nil - } - - form = Forms.post_form(post) - modified_form = Map.put(form, "title", "Changed Title") - - assert Forms.dirty?(post, modified_form, "content") - end - - test "detects content change as dirty" do - post = %{ - metadata: %{ - title: "Title", - status: "draft", - published_at: nil, - featured_image_uuid: nil, - url_slug: nil - }, - slug: "title", - mode: "slug", - content: "original content", - url_slug: nil - } - - form = Forms.post_form(post) - assert Forms.dirty?(post, form, "new content") - end - end - - # ============================================================================ - # push_slug_events/2 - # ============================================================================ - - describe "push_slug_events/2" do - test "pushes no events for empty list" do - socket = build_socket(%{}) - result = Forms.push_slug_events(socket, []) - # No crash = success; events are pushed via Phoenix.LiveView.push_event - assert result - end - end -end diff --git a/test/modules/publishing/facade_test.exs b/test/modules/publishing/facade_test.exs deleted file mode 100644 index 805a5cb8b..000000000 --- a/test/modules/publishing/facade_test.exs +++ /dev/null @@ -1,173 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.FacadeTest do - @moduledoc """ - Tests that all public functions are properly delegated through the facade. - Verifies every function in Publishing.* submodules is accessible via Publishing. - """ - use ExUnit.Case, async: true - - alias PhoenixKit.Modules.Publishing - - # ============================================================================ - # Group Delegations - # ============================================================================ - - describe "group delegations" do - test "all group functions are exported from facade" do - assert function_exported?(Publishing, :list_groups, 0) - assert function_exported?(Publishing, :get_group, 1) - assert function_exported?(Publishing, :add_group, 1) - assert function_exported?(Publishing, :add_group, 2) - assert function_exported?(Publishing, :remove_group, 1) - assert function_exported?(Publishing, :remove_group, 2) - assert function_exported?(Publishing, :update_group, 2) - assert function_exported?(Publishing, :trash_group, 1) - assert function_exported?(Publishing, :group_name, 1) - assert function_exported?(Publishing, :get_group_mode, 1) - assert function_exported?(Publishing, :preset_types, 0) - assert function_exported?(Publishing, :valid_types, 0) - end - end - - # ============================================================================ - # Version Delegations - # ============================================================================ - - describe "version delegations" do - test "all version functions are exported from facade" do - assert function_exported?(Publishing, :list_versions, 2) - assert function_exported?(Publishing, :get_published_version, 2) - assert function_exported?(Publishing, :get_version_status, 4) - assert function_exported?(Publishing, :get_version_metadata, 4) - assert function_exported?(Publishing, :create_new_version, 2) - assert function_exported?(Publishing, :create_new_version, 3) - assert function_exported?(Publishing, :create_new_version, 4) - assert function_exported?(Publishing, :publish_version, 3) - assert function_exported?(Publishing, :publish_version, 4) - assert function_exported?(Publishing, :create_version_from, 3) - assert function_exported?(Publishing, :create_version_from, 4) - assert function_exported?(Publishing, :create_version_from, 5) - assert function_exported?(Publishing, :delete_version, 3) - assert function_exported?(Publishing, :broadcast_version_created, 3) - end - end - - # ============================================================================ - # Translation Delegations - # ============================================================================ - - describe "translation delegations" do - test "all translation functions are exported from facade" do - assert function_exported?(Publishing, :get_post_primary_language, 2) - assert function_exported?(Publishing, :get_post_primary_language, 3) - assert function_exported?(Publishing, :check_primary_language_status, 2) - assert function_exported?(Publishing, :update_post_primary_language, 3) - assert function_exported?(Publishing, :update_posts_primary_language, 1) - assert function_exported?(Publishing, :count_posts_needing_language_update, 1) - assert function_exported?(Publishing, :add_language_to_post, 3) - assert function_exported?(Publishing, :add_language_to_post, 4) - assert function_exported?(Publishing, :add_language_to_db, 4) - assert function_exported?(Publishing, :delete_language, 3) - assert function_exported?(Publishing, :delete_language, 4) - assert function_exported?(Publishing, :set_translation_status, 5) - assert function_exported?(Publishing, :translate_post_to_all_languages, 2) - assert function_exported?(Publishing, :translate_post_to_all_languages, 3) - end - end - - # ============================================================================ - # Stale Fixer Delegations - # ============================================================================ - - describe "stale fixer delegations" do - test "all stale fixer functions are exported from facade" do - assert function_exported?(Publishing, :fix_stale_group, 1) - assert function_exported?(Publishing, :fix_stale_post, 1) - assert function_exported?(Publishing, :fix_stale_version, 1) - assert function_exported?(Publishing, :fix_stale_content, 1) - assert function_exported?(Publishing, :fix_all_stale_values, 0) - assert function_exported?(Publishing, :reconcile_post_status, 1) - end - end - - # ============================================================================ - # Cache Delegations - # ============================================================================ - - describe "cache delegations" do - test "all cache functions are exported from facade" do - assert function_exported?(Publishing, :regenerate_cache, 1) - assert function_exported?(Publishing, :invalidate_cache, 1) - assert function_exported?(Publishing, :cache_exists?, 1) - assert function_exported?(Publishing, :find_cached_post, 2) - assert function_exported?(Publishing, :find_cached_post_by_path, 3) - end - end - - # ============================================================================ - # Language Helper Delegations - # ============================================================================ - - describe "language helper delegations" do - test "all language helper functions are exported from facade" do - assert function_exported?(Publishing, :get_language_info, 1) - assert function_exported?(Publishing, :enabled_language_codes, 0) - assert function_exported?(Publishing, :get_primary_language, 0) - assert function_exported?(Publishing, :language_enabled?, 2) - assert function_exported?(Publishing, :get_display_code, 2) - assert function_exported?(Publishing, :order_languages_for_display, 2) - assert function_exported?(Publishing, :order_languages_for_display, 3) - end - end - - # ============================================================================ - # Slug Helper Delegations - # ============================================================================ - - describe "slug helper delegations" do - test "all slug helper functions are exported from facade" do - assert function_exported?(Publishing, :validate_slug, 1) - assert function_exported?(Publishing, :slug_exists?, 2) - assert function_exported?(Publishing, :generate_unique_slug, 2) - assert function_exported?(Publishing, :generate_unique_slug, 3) - assert function_exported?(Publishing, :generate_unique_slug, 4) - assert function_exported?(Publishing, :validate_url_slug, 4) - end - end - - # ============================================================================ - # Shared Helpers on Facade - # ============================================================================ - - describe "shared helpers on facade" do - test "slugify is accessible" do - assert Publishing.slugify("Hello World") == "hello-world" - end - - test "valid_slug? is accessible" do - assert Publishing.valid_slug?("hello-world") - refute Publishing.valid_slug?("") - end - - test "fetch_option is accessible" do - assert Publishing.fetch_option(%{key: "val"}, :key) == "val" - end - - test "audit_metadata is accessible" do - assert Publishing.audit_metadata(nil, :create) == %{} - end - - test "db_post? is accessible" do - assert Publishing.db_post?(%{uuid: "test"}) - refute Publishing.db_post?(%{}) - end - - test "should_create_new_version? always returns false" do - refute Publishing.should_create_new_version?(%{}, %{}, "en") - end - - test "module behaviour functions" do - assert Publishing.module_key() == "publishing" - assert Publishing.module_name() == "Publishing" - end - end -end diff --git a/test/modules/publishing/groups_test.exs b/test/modules/publishing/groups_test.exs deleted file mode 100644 index 9ad6c9846..000000000 --- a/test/modules/publishing/groups_test.exs +++ /dev/null @@ -1,94 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.GroupsTest do - use ExUnit.Case, async: true - - alias PhoenixKit.Modules.Publishing.Groups - - # ============================================================================ - # preset_types/0 - # ============================================================================ - - describe "preset_types/0" do - test "returns list of preset type maps" do - types = Groups.preset_types() - assert is_list(types) - assert length(types) == 3 - - labels = Enum.map(types, & &1.type) - assert "blog" in labels - assert "faq" in labels - assert "legal" in labels - end - - test "each preset has type, label, item_singular, item_plural" do - for preset <- Groups.preset_types() do - assert is_binary(preset.type) - assert is_binary(preset.label) - assert is_binary(preset.item_singular) - assert is_binary(preset.item_plural) - end - end - - test "blog preset has post/posts item names" do - blog = Enum.find(Groups.preset_types(), &(&1.type == "blog")) - assert blog.item_singular == "post" - assert blog.item_plural == "posts" - end - - test "faq preset has question/questions item names" do - faq = Enum.find(Groups.preset_types(), &(&1.type == "faq")) - assert faq.item_singular == "question" - assert faq.item_plural == "questions" - end - - test "legal preset has document/documents item names" do - legal = Enum.find(Groups.preset_types(), &(&1.type == "legal")) - assert legal.item_singular == "document" - assert legal.item_plural == "documents" - end - end - - # ============================================================================ - # valid_types/0 - # ============================================================================ - - describe "valid_types/0" do - test "returns list of valid type strings" do - types = Groups.valid_types() - assert is_list(types) - assert "blog" in types - assert "faq" in types - assert "legal" in types - assert "custom" in types - end - - test "includes custom type" do - assert "custom" in Groups.valid_types() - end - end - - # ============================================================================ - # fetch_option/2 - # ============================================================================ - - describe "fetch_option/2" do - test "fetches atom key from map" do - assert Groups.fetch_option(%{mode: "slug"}, :mode) == "slug" - end - - test "fetches string key from map as fallback" do - assert Groups.fetch_option(%{"mode" => "slug"}, :mode) == "slug" - end - - test "fetches from keyword list" do - assert Groups.fetch_option([mode: "slug"], :mode) == "slug" - end - - test "returns nil for missing key" do - assert Groups.fetch_option(%{}, :mode) == nil - end - - test "returns nil for non-container" do - assert Groups.fetch_option(nil, :mode) == nil - end - end -end diff --git a/test/modules/publishing/integration/groups_test.exs b/test/modules/publishing/integration/groups_test.exs deleted file mode 100644 index cca9994e9..000000000 --- a/test/modules/publishing/integration/groups_test.exs +++ /dev/null @@ -1,342 +0,0 @@ -defmodule PhoenixKit.Integration.Publishing.GroupsTest do - use PhoenixKit.DataCase, async: true - - alias PhoenixKit.Modules.Publishing.Groups - alias PhoenixKit.Modules.Publishing.Posts - - defp unique_name, do: "Test Group #{System.unique_integer([:positive])}" - - # ============================================================================ - # add_group/2 - # ============================================================================ - - describe "add_group/2" do - test "creates group with defaults (timestamp mode, blog type)" do - {:ok, group} = Groups.add_group(unique_name()) - - assert group["slug"] - assert group["mode"] == "timestamp" - assert group["status"] == "active" - assert group["type"] == "blog" - assert group["item_singular"] == "post" - assert group["item_plural"] == "posts" - end - - test "creates slug-mode group" do - {:ok, group} = Groups.add_group(unique_name(), mode: "slug") - assert group["mode"] == "slug" - end - - test "creates faq type group" do - {:ok, group} = Groups.add_group(unique_name(), type: "faq") - assert group["item_singular"] == "question" - assert group["item_plural"] == "questions" - end - - test "creates legal type group" do - {:ok, group} = Groups.add_group(unique_name(), type: "legal") - assert group["item_singular"] == "document" - assert group["item_plural"] == "documents" - end - - test "creates group with custom slug" do - slug = "custom-slug-#{System.unique_integer([:positive])}" - {:ok, group} = Groups.add_group(unique_name(), slug: slug) - assert group["slug"] == slug - end - - test "creates group with custom item names" do - {:ok, group} = - Groups.add_group(unique_name(), item_singular: "recipe", item_plural: "recipes") - - assert group["item_singular"] == "recipe" - assert group["item_plural"] == "recipes" - end - - test "creates group with all options combined" do - {:ok, group} = - Groups.add_group(unique_name(), - mode: "slug", - type: "faq", - item_singular: "entry", - item_plural: "entries" - ) - - assert group["mode"] == "slug" - assert group["item_singular"] == "entry" - assert group["item_plural"] == "entries" - end - - test "auto-generates unique slug for duplicate names" do - name = unique_name() - {:ok, first} = Groups.add_group(name) - {:ok, second} = Groups.add_group(name) - assert first["slug"] != second["slug"] - end - - test "rejects empty name" do - assert {:error, :invalid_name} = Groups.add_group("") - end - - test "rejects whitespace-only name" do - assert {:error, :invalid_name} = Groups.add_group(" ") - end - - test "invalid mode falls back to default" do - {:ok, group} = Groups.add_group(unique_name(), mode: "invalid") - assert group["mode"] == "timestamp" - end - - test "normalizes mode case" do - {:ok, group} = Groups.add_group(unique_name(), mode: "SLUG") - assert group["mode"] == "slug" - end - - test "auto-generates slug from name" do - {:ok, group} = Groups.add_group("My Great Blog") - assert group["slug"] =~ "my-great-blog" - end - - test "map opts work same as keyword opts" do - {:ok, group} = Groups.add_group(unique_name(), %{mode: "slug", type: "faq"}) - assert group["mode"] == "slug" - end - end - - # ============================================================================ - # get_group/1 - # ============================================================================ - - describe "get_group/1" do - test "returns group by slug" do - {:ok, created} = Groups.add_group(unique_name()) - assert {:ok, found} = Groups.get_group(created["slug"]) - assert found["slug"] == created["slug"] - assert found["name"] == created["name"] - end - - test "returns all data fields" do - {:ok, created} = Groups.add_group(unique_name(), type: "faq", mode: "slug") - {:ok, found} = Groups.get_group(created["slug"]) - - assert found["mode"] == "slug" - assert found["status"] == "active" - assert found["item_singular"] == "question" - end - - test "returns trashed group (get_group finds any status)" do - {:ok, group} = Groups.add_group(unique_name()) - {:ok, _} = Groups.trash_group(group["slug"]) - {:ok, found} = Groups.get_group(group["slug"]) - assert found["status"] == "trashed" - end - - test "returns error for nonexistent slug" do - assert {:error, :not_found} = Groups.get_group("nonexistent-slug") - end - end - - # ============================================================================ - # list_groups/0 and list_groups/1 - # ============================================================================ - - describe "list_groups/0 and list_groups/1" do - test "lists active groups" do - {:ok, group} = Groups.add_group(unique_name()) - groups = Groups.list_groups() - slugs = Enum.map(groups, & &1["slug"]) - assert group["slug"] in slugs - end - - test "excludes trashed groups from default listing" do - {:ok, group} = Groups.add_group(unique_name()) - {:ok, _} = Groups.trash_group(group["slug"]) - groups = Groups.list_groups() - slugs = Enum.map(groups, & &1["slug"]) - refute group["slug"] in slugs - end - - test "lists trashed groups when filtered" do - {:ok, group} = Groups.add_group(unique_name()) - {:ok, _} = Groups.trash_group(group["slug"]) - trashed = Groups.list_groups("trashed") - slugs = Enum.map(trashed, & &1["slug"]) - assert group["slug"] in slugs - end - - test "returns maps with expected keys" do - {:ok, _} = Groups.add_group(unique_name()) - [group | _] = Groups.list_groups() - - assert Map.has_key?(group, "slug") - assert Map.has_key?(group, "name") - assert Map.has_key?(group, "mode") - assert Map.has_key?(group, "status") - end - end - - # ============================================================================ - # update_group/2 - # ============================================================================ - - describe "update_group/2" do - test "updates group name" do - {:ok, group} = Groups.add_group(unique_name()) - new_name = unique_name() - {:ok, updated} = Groups.update_group(group["slug"], %{name: new_name}) - assert updated["name"] == new_name - end - - test "updates group slug" do - {:ok, group} = Groups.add_group(unique_name()) - new_slug = "updated-slug-#{System.unique_integer([:positive])}" - {:ok, updated} = Groups.update_group(group["slug"], %{slug: new_slug}) - assert updated["slug"] == new_slug - assert {:error, :not_found} = Groups.get_group(group["slug"]) - end - - test "preserves unchanged fields on partial update" do - {:ok, group} = Groups.add_group(unique_name(), mode: "slug") - {:ok, updated} = Groups.update_group(group["slug"], %{name: "New Name"}) - assert updated["mode"] == "slug" - assert updated["slug"] == group["slug"] - end - - test "rejects empty name update" do - {:ok, group} = Groups.add_group(unique_name()) - assert {:error, :invalid_name} = Groups.update_group(group["slug"], %{name: ""}) - end - - test "returns error for nonexistent group" do - assert {:error, :not_found} = Groups.update_group("nonexistent", %{name: "New"}) - end - end - - # ============================================================================ - # trash_group/1 and restore_group/1 - # ============================================================================ - - describe "trash and restore lifecycle" do - test "trash_group/1 soft-deletes group" do - {:ok, group} = Groups.add_group(unique_name()) - assert {:ok, _slug} = Groups.trash_group(group["slug"]) - {:ok, found} = Groups.get_group(group["slug"]) - assert found["status"] == "trashed" - end - - test "restore_group/1 restores trashed group" do - {:ok, group} = Groups.add_group(unique_name()) - {:ok, _} = Groups.trash_group(group["slug"]) - assert {:ok, _slug} = Groups.restore_group(group["slug"]) - {:ok, found} = Groups.get_group(group["slug"]) - assert found["status"] == "active" - end - - test "trashed group not in default listing, restored group is" do - {:ok, group} = Groups.add_group(unique_name()) - slug = group["slug"] - - {:ok, _} = Groups.trash_group(slug) - refute slug in Enum.map(Groups.list_groups(), & &1["slug"]) - - {:ok, _} = Groups.restore_group(slug) - assert slug in Enum.map(Groups.list_groups(), & &1["slug"]) - end - end - - # ============================================================================ - # remove_group/2 - # ============================================================================ - - describe "remove_group/2" do - test "hard-deletes empty group" do - {:ok, group} = Groups.add_group(unique_name()) - assert {:ok, _} = Groups.remove_group(group["slug"]) - assert {:error, :not_found} = Groups.get_group(group["slug"]) - end - - test "refuses to delete group with posts unless forced" do - {:ok, group} = Groups.add_group(unique_name()) - {:ok, _post} = Posts.create_post(group["slug"], %{}) - assert {:error, {:has_posts, count}} = Groups.remove_group(group["slug"]) - assert count >= 1 - end - - test "force-deletes group with posts" do - {:ok, group} = Groups.add_group(unique_name()) - {:ok, _post} = Posts.create_post(group["slug"], %{}) - assert {:ok, _} = Groups.remove_group(group["slug"], force: true) - assert {:error, :not_found} = Groups.get_group(group["slug"]) - end - - test "force-delete cascades to all posts and versions" do - {:ok, group} = Groups.add_group(unique_name()) - {:ok, post} = Posts.create_post(group["slug"], %{title: "Will Be Deleted"}) - {:ok, _} = Groups.remove_group(group["slug"], force: true) - - # Post should be gone - assert {:error, _} = Posts.read_post(group["slug"], post[:uuid], nil, nil) - end - - test "can remove trashed group" do - {:ok, group} = Groups.add_group(unique_name()) - {:ok, _} = Groups.trash_group(group["slug"]) - assert {:ok, _} = Groups.remove_group(group["slug"]) - assert {:error, :not_found} = Groups.get_group(group["slug"]) - end - end - - # ============================================================================ - # group_name/1 and get_group_mode/1 - # ============================================================================ - - describe "group_name/1" do - test "returns group name by slug" do - {:ok, group} = Groups.add_group(unique_name()) - assert Groups.group_name(group["slug"]) == group["name"] - end - - test "returns nil for nonexistent slug" do - assert Groups.group_name("nonexistent") == nil - end - end - - describe "get_group_mode/1" do - test "returns timestamp for timestamp-mode group" do - {:ok, group} = Groups.add_group(unique_name(), mode: "timestamp") - assert Groups.get_group_mode(group["slug"]) == "timestamp" - end - - test "returns slug for slug-mode group" do - {:ok, group} = Groups.add_group(unique_name(), mode: "slug") - assert Groups.get_group_mode(group["slug"]) == "slug" - end - end - - # ============================================================================ - # preset_types/0 and valid_types/0 - # ============================================================================ - - describe "preset_types/0" do - test "returns list of type definitions with required fields" do - types = Groups.preset_types() - assert length(types) >= 3 - - for type <- types do - assert Map.has_key?(type, :type) - assert Map.has_key?(type, :label) - assert Map.has_key?(type, :item_singular) - assert Map.has_key?(type, :item_plural) - end - end - end - - describe "valid_types/0" do - test "includes all preset types" do - valid = Groups.valid_types() - assert "blog" in valid - assert "faq" in valid - assert "legal" in valid - end - end -end diff --git a/test/modules/publishing/integration/posts_test.exs b/test/modules/publishing/integration/posts_test.exs deleted file mode 100644 index 6608825f4..000000000 --- a/test/modules/publishing/integration/posts_test.exs +++ /dev/null @@ -1,398 +0,0 @@ -defmodule PhoenixKit.Integration.Publishing.PostsTest do - use PhoenixKit.DataCase, async: true - - alias PhoenixKit.Modules.Publishing.Groups - alias PhoenixKit.Modules.Publishing.Posts - alias PhoenixKit.Modules.Publishing.Versions - - defp unique_name, do: "Posts Group #{System.unique_integer([:positive])}" - - defp create_group(mode) do - {:ok, group} = Groups.add_group(unique_name(), mode: mode) - group - end - - # ============================================================================ - # create_post/2 — timestamp mode - # ============================================================================ - - describe "create_post/2 in timestamp mode" do - test "creates post with auto-generated timestamp" do - group = create_group("timestamp") - {:ok, post} = Posts.create_post(group["slug"], %{}) - - assert post[:uuid] - assert post[:date] - assert post[:time] - assert post[:version] == 1 - assert post[:primary_language] == "en" - assert post[:mode] in ["timestamp", :timestamp] - end - - test "creates post with title" do - group = create_group("timestamp") - {:ok, post} = Posts.create_post(group["slug"], %{title: "My First Post"}) - assert post[:metadata][:title] == "My First Post" - end - - test "creates post with content" do - group = create_group("timestamp") - {:ok, post} = Posts.create_post(group["slug"], %{content: "

Hello world

"}) - assert post[:content] == "

Hello world

" - end - - test "auto-increments time on collision" do - group = create_group("timestamp") - {:ok, post1} = Posts.create_post(group["slug"], %{}) - {:ok, post2} = Posts.create_post(group["slug"], %{}) - assert post1[:uuid] != post2[:uuid] - assert post1[:date] == post2[:date] - end - - test "status defaults to draft" do - group = create_group("timestamp") - {:ok, post} = Posts.create_post(group["slug"], %{}) - assert post[:metadata][:status] == "draft" - end - - test "creates version 1 and primary language content automatically" do - group = create_group("timestamp") - {:ok, post} = Posts.create_post(group["slug"], %{title: "Auto V1"}) - - assert post[:version] == 1 - assert post[:language] == "en" - end - end - - # ============================================================================ - # create_post/2 — slug mode - # ============================================================================ - - describe "create_post/2 in slug mode" do - test "creates post with auto-generated slug from title" do - group = create_group("slug") - {:ok, post} = Posts.create_post(group["slug"], %{title: "My Slug Post"}) - assert post[:slug] - assert post[:version] == 1 - assert post[:mode] in ["slug", :slug] - end - - test "creates post with custom slug" do - group = create_group("slug") - {:ok, post} = Posts.create_post(group["slug"], %{slug: "custom-slug"}) - assert post[:slug] == "custom-slug" - end - - test "creates post without title" do - group = create_group("slug") - {:ok, post} = Posts.create_post(group["slug"], %{}) - assert post[:uuid] - assert post[:slug] - end - - test "posts in different groups can share slugs" do - group1 = create_group("slug") - group2 = create_group("slug") - {:ok, p1} = Posts.create_post(group1["slug"], %{slug: "shared-slug"}) - {:ok, p2} = Posts.create_post(group2["slug"], %{slug: "shared-slug"}) - assert p1[:uuid] != p2[:uuid] - end - end - - # ============================================================================ - # read_post/4 - # ============================================================================ - - describe "read_post/4" do - test "reads post by uuid" do - group = create_group("timestamp") - {:ok, created} = Posts.create_post(group["slug"], %{title: "Read Me"}) - {:ok, post} = Posts.read_post(group["slug"], created[:uuid], nil, nil) - assert post[:uuid] == created[:uuid] - assert post[:metadata][:title] == "Read Me" - end - - test "reads post by slug in slug mode" do - group = create_group("slug") - {:ok, created} = Posts.create_post(group["slug"], %{slug: "readable-post"}) - {:ok, post} = Posts.read_post(group["slug"], "readable-post", nil, nil) - assert post[:uuid] == created[:uuid] - end - - test "returns full post map structure" do - group = create_group("slug") - {:ok, created} = Posts.create_post(group["slug"], %{title: "Full Structure"}) - {:ok, post} = Posts.read_post(group["slug"], created[:uuid], nil, nil) - - assert post[:uuid] - assert post[:version] - assert post[:language] - assert post[:primary_language] - assert post[:metadata] - assert post[:available_versions] - assert is_list(post[:available_versions]) - end - - test "reads specific version" do - group = create_group("slug") - {:ok, created} = Posts.create_post(group["slug"], %{title: "V1"}) - {:ok, _v2} = Versions.create_new_version(group["slug"], created, %{}, %{}) - - {:ok, v1} = Posts.read_post(group["slug"], created[:uuid], nil, 1) - assert v1[:version] == 1 - - {:ok, v2} = Posts.read_post(group["slug"], created[:uuid], nil, 2) - assert v2[:version] == 2 - end - - test "added language appears in available_languages" do - group = create_group("slug") - {:ok, created} = Posts.create_post(group["slug"], %{title: "English"}) - - alias PhoenixKit.Modules.Publishing.TranslationManager - {:ok, _} = TranslationManager.add_language_to_post(group["slug"], created[:uuid], "de", nil) - - {:ok, post} = Posts.read_post(group["slug"], created[:uuid], nil, nil) - assert "de" in post[:available_languages] - end - - test "returns error for nonexistent post" do - group = create_group("timestamp") - assert {:error, _} = Posts.read_post(group["slug"], "nonexistent", nil, nil) - end - - test "defaults to latest version when nil" do - group = create_group("slug") - {:ok, created} = Posts.create_post(group["slug"], %{title: "V1"}) - {:ok, _v2} = Versions.create_new_version(group["slug"], created, %{}, %{}) - - {:ok, post} = Posts.read_post(group["slug"], created[:uuid], nil, nil) - assert post[:version] == 2 - end - end - - # ============================================================================ - # update_post/4 - # ============================================================================ - - describe "update_post/4" do - test "updates post title" do - group = create_group("slug") - {:ok, post} = Posts.create_post(group["slug"], %{title: "Original"}) - - {:ok, updated} = - Posts.update_post(group["slug"], post, %{"title" => "Updated Title"}, %{}) - - assert updated[:metadata][:title] == "Updated Title" - end - - test "updates post content" do - group = create_group("slug") - {:ok, post} = Posts.create_post(group["slug"], %{title: "Has Content"}) - - {:ok, updated} = - Posts.update_post(group["slug"], post, %{"content" => "

New body

"}, %{}) - - assert updated[:content] == "

New body

" - end - - test "returns updated post map" do - group = create_group("slug") - {:ok, post} = Posts.create_post(group["slug"], %{title: "Check Return"}) - - {:ok, updated} = - Posts.update_post(group["slug"], post, %{"title" => "New"}, %{}) - - assert updated[:uuid] == post[:uuid] - assert updated[:version] - assert updated[:metadata] - end - end - - # ============================================================================ - # list_posts/2 - # ============================================================================ - - describe "list_posts/2" do - test "lists posts in group" do - group = create_group("timestamp") - {:ok, _} = Posts.create_post(group["slug"], %{title: "Post 1"}) - {:ok, _} = Posts.create_post(group["slug"], %{title: "Post 2"}) - posts = Posts.list_posts(group["slug"], nil) - assert length(posts) >= 2 - end - - test "returns empty list for empty group" do - group = create_group("timestamp") - assert Posts.list_posts(group["slug"], nil) == [] - end - - test "does not list trashed posts" do - group = create_group("slug") - {:ok, post} = Posts.create_post(group["slug"], %{title: "Trashable"}) - {:ok, _} = Posts.trash_post(group["slug"], post[:uuid]) - - posts = Posts.list_posts(group["slug"], nil) - uuids = Enum.map(posts, & &1[:uuid]) - refute post[:uuid] in uuids - end - - test "does not list posts from other groups" do - group1 = create_group("slug") - group2 = create_group("slug") - {:ok, p1} = Posts.create_post(group1["slug"], %{title: "Group 1"}) - {:ok, _} = Posts.create_post(group2["slug"], %{title: "Group 2"}) - - posts = Posts.list_posts(group1["slug"], nil) - uuids = Enum.map(posts, & &1[:uuid]) - assert p1[:uuid] in uuids - assert length(posts) == 1 - end - end - - # ============================================================================ - # list_posts_by_status/2 - # ============================================================================ - - describe "list_posts_by_status/2" do - test "lists only published posts" do - group = create_group("slug") - {:ok, draft} = Posts.create_post(group["slug"], %{title: "Draft"}) - {:ok, pub} = Posts.create_post(group["slug"], %{title: "Published"}) - Posts.change_post_status(group["slug"], pub[:uuid], "published") - - published = Posts.list_posts_by_status(group["slug"], "published") - uuids = Enum.map(published, &(&1[:uuid] || &1.uuid)) - - assert pub[:uuid] in uuids - refute draft[:uuid] in uuids - end - - test "lists only draft posts" do - group = create_group("slug") - {:ok, _} = Posts.create_post(group["slug"], %{title: "Draft"}) - - drafts = Posts.list_posts_by_status(group["slug"], "draft") - assert drafts != [] - end - end - - # ============================================================================ - # trash_post/2 and restore_post/2 - # ============================================================================ - - describe "trash_post/2 and restore_post/2" do - test "trashes a post" do - group = create_group("slug") - {:ok, post} = Posts.create_post(group["slug"], %{title: "Trash Me"}) - assert {:ok, _uuid} = Posts.trash_post(group["slug"], post[:uuid]) - end - - test "trashed post is excluded from list_posts" do - group = create_group("slug") - {:ok, post} = Posts.create_post(group["slug"], %{title: "Check Status"}) - {:ok, _} = Posts.trash_post(group["slug"], post[:uuid]) - - posts = Posts.list_posts(group["slug"], nil) - uuids = Enum.map(posts, & &1[:uuid]) - refute post[:uuid] in uuids - end - - test "restores a trashed post to draft" do - group = create_group("slug") - {:ok, post} = Posts.create_post(group["slug"], %{title: "Restore Me"}) - {:ok, _} = Posts.trash_post(group["slug"], post[:uuid]) - assert {:ok, _uuid} = Posts.restore_post(group["slug"], post[:uuid]) - - {:ok, restored} = Posts.read_post(group["slug"], post[:uuid], nil, nil) - status = restored[:status] || restored[:metadata][:status] - assert status == "draft" - end - - test "trash nonexistent post returns error" do - group = create_group("slug") - assert {:error, _} = Posts.trash_post(group["slug"], UUIDv7.generate()) - end - end - - # ============================================================================ - # change_post_status/4 - # ============================================================================ - - describe "change_post_status/4" do - test "publishes a post with title" do - group = create_group("slug") - {:ok, post} = Posts.create_post(group["slug"], %{title: "Publishable"}) - assert {:ok, _} = Posts.change_post_status(group["slug"], post[:uuid], "published") - end - - test "archives a published post" do - group = create_group("slug") - {:ok, post} = Posts.create_post(group["slug"], %{title: "Archive Me"}) - {:ok, _} = Posts.change_post_status(group["slug"], post[:uuid], "published") - assert {:ok, _} = Posts.change_post_status(group["slug"], post[:uuid], "archived") - end - - test "publishing post without title still succeeds via change_post_status" do - # change_post_status delegates to update_post which allows empty title publish - # The title_required validation is on Versions.publish_version, not here - group = create_group("slug") - {:ok, post} = Posts.create_post(group["slug"], %{}) - result = Posts.change_post_status(group["slug"], post[:uuid], "published") - # This may succeed or fail depending on validation path - assert match?({:ok, _}, result) or match?({:error, _}, result) - end - - test "nonexistent post returns error" do - group = create_group("slug") - result = Posts.change_post_status(group["slug"], UUIDv7.generate(), "published") - assert match?({:error, _}, result) - end - end - - # ============================================================================ - # Full publish workflow end-to-end - # ============================================================================ - - describe "full publish workflow" do - test "create → edit → publish → read published" do - group = create_group("slug") - - # Create - {:ok, post} = Posts.create_post(group["slug"], %{title: "Draft Post"}) - assert post[:metadata][:status] == "draft" - - # Edit content (title preserved since we pass it explicitly) - {:ok, edited} = - Posts.update_post( - group["slug"], - post, - %{"title" => "Draft Post", "content" => "

Final content

"}, - %{} - ) - - assert edited[:content] == "

Final content

" - - # Publish - {:ok, _} = Posts.change_post_status(group["slug"], post[:uuid], "published") - - # Read published - {:ok, published} = Posts.read_post(group["slug"], post[:uuid], nil, nil) - assert published[:uuid] == post[:uuid] - end - - test "create → trash → restore → publish" do - group = create_group("slug") - {:ok, post} = Posts.create_post(group["slug"], %{title: "Lifecycle"}) - - # Trash - {:ok, _} = Posts.trash_post(group["slug"], post[:uuid]) - - # Restore - {:ok, _} = Posts.restore_post(group["slug"], post[:uuid]) - - # Publish - assert {:ok, _} = Posts.change_post_status(group["slug"], post[:uuid], "published") - end - end -end diff --git a/test/modules/publishing/integration/slug_update_test.exs b/test/modules/publishing/integration/slug_update_test.exs deleted file mode 100644 index e701f4891..000000000 --- a/test/modules/publishing/integration/slug_update_test.exs +++ /dev/null @@ -1,65 +0,0 @@ -defmodule PhoenixKit.Integration.Publishing.SlugUpdateTest do - use PhoenixKit.DataCase, async: true - - alias PhoenixKit.Modules.Publishing - alias PhoenixKit.Modules.Publishing.Groups - alias PhoenixKit.Modules.Publishing.Posts - - describe "slug update on existing post" do - setup do - {:ok, group} = Groups.add_group("Slug Test", mode: "slug", slug: "slug-test") - %{group: group} - end - - test "can create post with explicit slug and update it", %{group: group} do - # Create post with title and slug - {:ok, post} = - Posts.create_post(group["slug"], %{title: "Original Title", slug: "original-title"}) - - assert post.slug == "original-title" - - # Update to a different slug - result = - Publishing.update_post(group["slug"], post, %{ - "slug" => "new-slug", - "title" => "Original Title", - "content" => "Some content", - "status" => "draft" - }) - - assert {:ok, updated} = result - assert updated.slug == "new-slug" - end - - test "can create post with auto-generated slug from title", %{group: group} do - {:ok, post} = - Posts.create_post(group["slug"], %{title: "My Great Post"}) - - assert post.slug == "my-great-post" - end - - test "can create post with empty title (gets untitled slug)", %{group: group} do - {:ok, post} = - Posts.create_post(group["slug"], %{title: ""}) - - assert post.slug == "untitled" - end - - test "can update slug from untitled to real slug", %{group: group} do - # This simulates the bug: post created with "untitled" slug, user types title, slug changes - {:ok, post} = Posts.create_post(group["slug"], %{title: ""}) - assert post.slug == "untitled" - - result = - Publishing.update_post(group["slug"], post, %{ - "slug" => "hello", - "title" => "Hello", - "content" => "content", - "status" => "draft" - }) - - assert {:ok, updated} = result - assert updated.slug == "hello" - end - end -end diff --git a/test/modules/publishing/integration/translate_retry_test.exs b/test/modules/publishing/integration/translate_retry_test.exs deleted file mode 100644 index 107f98325..000000000 --- a/test/modules/publishing/integration/translate_retry_test.exs +++ /dev/null @@ -1,121 +0,0 @@ -defmodule PhoenixKit.Integration.Publishing.TranslateRetryTest do - use PhoenixKit.DataCase, async: true - - alias PhoenixKit.Modules.Publishing.DBStorage - alias PhoenixKit.Modules.Publishing.Groups - alias PhoenixKit.Modules.Publishing.Posts - alias PhoenixKit.Modules.Publishing.Workers.TranslatePostWorker - - defp unique_name, do: "retry Group #{System.unique_integer([:positive])}" - - defp create_group_and_post do - {:ok, group} = Groups.add_group(unique_name(), mode: "slug") - {:ok, post} = Posts.create_post(group["slug"], %{title: "Retry Test"}) - {group, post} - end - - # ============================================================================ - # Retry skip logic — languages translated by a previous attempt are skipped - # ============================================================================ - - describe "skip_already_translated/5" do - test "skips languages with content updated after job insertion" do - {group, post} = create_group_and_post() - group_slug = group["slug"] - - # Record a time BEFORE we add translations - job_inserted_at = DateTime.add(DateTime.utc_now(), -60, :second) - - # Get the version UUID for direct content creation - version = DBStorage.get_latest_version(post[:uuid]) - - # Create "de" and "fr" content rows directly (simulating previous attempt's work) - {:ok, _} = - DBStorage.upsert_content(%{ - version_uuid: version.uuid, - language: "de", - title: "Hallo", - content: "Hallo Welt", - status: "published" - }) - - {:ok, _} = - DBStorage.upsert_content(%{ - version_uuid: version.uuid, - language: "fr", - title: "Bonjour", - content: "Bonjour le monde", - status: "published" - }) - - # "es" has no content row - target_languages = ["de", "fr", "es"] - - remaining = - TranslatePostWorker.skip_already_translated( - target_languages, - group_slug, - post[:uuid], - nil, - job_inserted_at - ) - - # de and fr should be skipped (content updated after job_inserted_at) - # es should remain (no content exists) - assert "es" in remaining - refute "de" in remaining - refute "fr" in remaining - end - - test "does not skip languages with content from before job insertion" do - {group, post} = create_group_and_post() - group_slug = group["slug"] - - # Create "de" content BEFORE the job was "inserted" - version = DBStorage.get_latest_version(post[:uuid]) - - {:ok, _} = - DBStorage.upsert_content(%{ - version_uuid: version.uuid, - language: "de", - title: "Alt", - content: "Alt inhalt", - status: "draft" - }) - - # Job inserted AFTER the content — translation is stale, not from this job - job_inserted_at = DateTime.add(DateTime.utc_now(), 5, :second) - - remaining = - TranslatePostWorker.skip_already_translated( - ["de"], - group_slug, - post[:uuid], - nil, - job_inserted_at - ) - - # de should NOT be skipped because its content predates the job - assert "de" in remaining - end - - test "processes all languages when none were previously translated" do - {group, post} = create_group_and_post() - group_slug = group["slug"] - - job_inserted_at = DateTime.add(DateTime.utc_now(), -60, :second) - target_languages = ["de", "fr", "es"] - - remaining = - TranslatePostWorker.skip_already_translated( - target_languages, - group_slug, - post[:uuid], - nil, - job_inserted_at - ) - - assert remaining == target_languages - end - end -end diff --git a/test/modules/publishing/integration/translation_reload_test.exs b/test/modules/publishing/integration/translation_reload_test.exs deleted file mode 100644 index eaca72348..000000000 --- a/test/modules/publishing/integration/translation_reload_test.exs +++ /dev/null @@ -1,70 +0,0 @@ -defmodule PhoenixKit.Integration.Publishing.TranslationReloadTest do - use PhoenixKit.DataCase, async: true - - alias PhoenixKit.Modules.Publishing - alias PhoenixKit.Modules.Publishing.Groups - alias PhoenixKit.Modules.Publishing.Posts - - describe "read_post_by_uuid with language parameter" do - setup do - {:ok, group} = Groups.add_group("Translation Test", mode: "slug", slug: "translation-test") - {:ok, post} = Posts.create_post(group["slug"], %{title: "English Title", slug: "test-post"}) - - # Save the primary language content - {:ok, saved_post} = - Publishing.update_post(group["slug"], post, %{ - "title" => "English Title", - "content" => "English content", - "status" => "draft" - }) - - # Add a translation language - {:ok, _} = - Publishing.add_language_to_post(group["slug"], saved_post[:uuid], "uk", 1) - - # Save translated content - {:ok, translated_post} = - Publishing.read_post_by_uuid(saved_post[:uuid], "uk", 1) - - {:ok, _} = - Publishing.update_post(group["slug"], translated_post, %{ - "title" => "Ukrainian Title", - "content" => "Ukrainian content", - "status" => "draft" - }) - - %{group: group, post_uuid: saved_post[:uuid]} - end - - test "reading without language returns primary language content", %{post_uuid: uuid} do - {:ok, post} = Publishing.read_post_by_uuid(uuid) - assert post.metadata.title == "English Title" - assert post.content == "English content" - end - - test "reading with specific language returns that language's content", %{post_uuid: uuid} do - {:ok, post} = Publishing.read_post_by_uuid(uuid, "uk") - assert post.metadata.title == "Ukrainian Title" - assert post.content == "Ukrainian content" - end - - test "reload_translated_content should read correct language", %{post_uuid: uuid} do - # This test verifies the pattern used in reload_translated_content: - # re_read_post(socket, current_language) should return the translated content, - # NOT the primary language content. - {:ok, primary} = Publishing.read_post_by_uuid(uuid, nil) - {:ok, translated} = Publishing.read_post_by_uuid(uuid, "uk") - - # Primary should return English - assert primary.language == "en-US" - assert primary.metadata.title == "English Title" - - # Translated should return Ukrainian - assert translated.language == "uk" - assert translated.metadata.title == "Ukrainian Title" - - # They should be different - refute primary.content == translated.content - end - end -end diff --git a/test/modules/publishing/integration/translations_test.exs b/test/modules/publishing/integration/translations_test.exs deleted file mode 100644 index cf31f765a..000000000 --- a/test/modules/publishing/integration/translations_test.exs +++ /dev/null @@ -1,277 +0,0 @@ -defmodule PhoenixKit.Integration.Publishing.TranslationsTest do - use PhoenixKit.DataCase, async: true - - alias PhoenixKit.Modules.Publishing.Groups - alias PhoenixKit.Modules.Publishing.Posts - alias PhoenixKit.Modules.Publishing.TranslationManager - alias PhoenixKit.Modules.Publishing.Versions - - defp unique_name, do: "i18n Group #{System.unique_integer([:positive])}" - - defp create_group_and_post(opts \\ []) do - title = Keyword.get(opts, :title, "Translatable") - {:ok, group} = Groups.add_group(unique_name(), mode: "slug") - {:ok, post} = Posts.create_post(group["slug"], %{title: title}) - {group, post} - end - - # ============================================================================ - # add_language_to_post/4 - # ============================================================================ - - describe "add_language_to_post/4" do - test "adds a new language to post" do - {group, post} = create_group_and_post() - - assert {:ok, updated} = - TranslationManager.add_language_to_post(group["slug"], post[:uuid], "de", nil) - - assert is_map(updated) - end - - test "added language appears in available_languages" do - {group, post} = create_group_and_post() - {:ok, _} = TranslationManager.add_language_to_post(group["slug"], post[:uuid], "fr", nil) - - {:ok, post_map} = Posts.read_post(group["slug"], post[:uuid], nil, nil) - assert "fr" in post_map[:available_languages] - end - - test "adding primary language is idempotent" do - {group, post} = create_group_and_post() - result = TranslationManager.add_language_to_post(group["slug"], post[:uuid], "en", nil) - assert match?({:ok, _}, result) - end - - test "adds multiple languages" do - {group, post} = create_group_and_post() - {:ok, _} = TranslationManager.add_language_to_post(group["slug"], post[:uuid], "de", nil) - {:ok, _} = TranslationManager.add_language_to_post(group["slug"], post[:uuid], "fr", nil) - {:ok, _} = TranslationManager.add_language_to_post(group["slug"], post[:uuid], "es", nil) - - {:ok, post_map} = Posts.read_post(group["slug"], post[:uuid], nil, nil) - langs = post_map[:available_languages] - - assert "de" in langs - assert "fr" in langs - assert "es" in langs - end - - test "new language content starts as draft" do - {group, post} = create_group_and_post() - {:ok, _} = TranslationManager.add_language_to_post(group["slug"], post[:uuid], "de", nil) - - {:ok, post_map} = Posts.read_post(group["slug"], post[:uuid], nil, nil) - assert post_map[:language_statuses]["de"] == "draft" - end - end - - # ============================================================================ - # delete_language/4 - # ============================================================================ - - describe "delete_language/4" do - test "removes a non-primary language" do - {group, post} = create_group_and_post() - {:ok, _} = TranslationManager.add_language_to_post(group["slug"], post[:uuid], "fr", nil) - - result = TranslationManager.delete_language(group["slug"], post[:uuid], "fr", nil) - assert result == :ok or match?({:ok, _}, result) - end - - test "cannot delete last active language" do - {group, post} = create_group_and_post() - - result = TranslationManager.delete_language(group["slug"], post[:uuid], "en", nil) - assert result == {:error, :last_language} or match?({:error, _}, result) - end - - test "can delete primary if other languages exist" do - {group, post} = create_group_and_post() - {:ok, _} = TranslationManager.add_language_to_post(group["slug"], post[:uuid], "de", nil) - - # With 2 languages, deleting one should work - result = TranslationManager.delete_language(group["slug"], post[:uuid], "en", nil) - assert result == :ok or match?({:ok, _}, result) - end - end - - # ============================================================================ - # get_post_primary_language/3 - # ============================================================================ - - describe "get_post_primary_language/3" do - test "returns primary language" do - {group, post} = create_group_and_post() - - lang = - TranslationManager.get_post_primary_language( - group["slug"], - post[:slug] || post[:uuid], - nil - ) - - assert lang == "en" - end - end - - # ============================================================================ - # set_translation_status/5 - # ============================================================================ - - describe "set_translation_status/5" do - test "sets primary language to draft" do - {group, post} = create_group_and_post() - - assert :ok = - TranslationManager.set_translation_status( - group["slug"], - post[:uuid], - 1, - "en", - "draft" - ) - end - - test "publishes primary language" do - {group, post} = create_group_and_post(title: "Publishable") - - assert :ok = - TranslationManager.set_translation_status( - group["slug"], - post[:uuid], - 1, - "en", - "published" - ) - end - - test "cannot publish non-primary when primary is draft" do - {group, post} = create_group_and_post(title: "Primary Draft") - {:ok, _} = TranslationManager.add_language_to_post(group["slug"], post[:uuid], "es", nil) - - result = - TranslationManager.set_translation_status( - group["slug"], - post[:uuid], - 1, - "es", - "published" - ) - - assert result == {:error, :primary_not_published} - end - - test "can publish non-primary when primary is published" do - {group, post} = create_group_and_post(title: "Primary Published") - {:ok, _} = TranslationManager.add_language_to_post(group["slug"], post[:uuid], "de", nil) - - # Publish primary first - :ok = - TranslationManager.set_translation_status( - group["slug"], - post[:uuid], - 1, - "en", - "published" - ) - - # Now publish secondary - assert :ok = - TranslationManager.set_translation_status( - group["slug"], - post[:uuid], - 1, - "de", - "published" - ) - end - - test "can set non-primary to draft regardless of primary status" do - {group, post} = create_group_and_post() - {:ok, _} = TranslationManager.add_language_to_post(group["slug"], post[:uuid], "fr", nil) - - assert :ok = - TranslationManager.set_translation_status( - group["slug"], - post[:uuid], - 1, - "fr", - "draft" - ) - end - - test "rejects invalid status" do - {group, post} = create_group_and_post() - - result = - TranslationManager.set_translation_status( - group["slug"], - post[:uuid], - 1, - "en", - "invalid" - ) - - assert result == {:error, :invalid_status} - end - - test "returns error for nonexistent post" do - {group, _post} = create_group_and_post() - - result = - TranslationManager.set_translation_status( - group["slug"], - UUIDv7.generate(), - 1, - "en", - "draft" - ) - - assert match?({:error, _}, result) - end - end - - # ============================================================================ - # Full translation workflow - # ============================================================================ - - describe "full multilingual workflow" do - test "create → add languages → publish via version → verify all published" do - {group, post} = create_group_and_post(title: "Multilingual Post") - - # Add languages - {:ok, _} = TranslationManager.add_language_to_post(group["slug"], post[:uuid], "de", nil) - {:ok, _} = TranslationManager.add_language_to_post(group["slug"], post[:uuid], "fr", nil) - - # Publish version (publishes all content in the version) - :ok = Versions.publish_version(group["slug"], post[:uuid], 1) - - # Verify all languages are published via language_statuses - {:ok, post_map} = Posts.read_post(group["slug"], post[:uuid], nil, nil) - statuses = post_map[:language_statuses] - - assert statuses["en"] == "published" - assert statuses["de"] == "published" - assert statuses["fr"] == "published" - end - - test "version cloning preserves all languages" do - {group, post} = create_group_and_post(title: "V1 Multilang") - {:ok, _} = TranslationManager.add_language_to_post(group["slug"], post[:uuid], "de", nil) - {:ok, _} = TranslationManager.add_language_to_post(group["slug"], post[:uuid], "fr", nil) - - # Clone to v2 - {:ok, v2} = Versions.create_new_version(group["slug"], post, %{}, %{}) - assert v2[:version] == 2 - - # V2 should have all 3 languages - {:ok, v2_post} = Posts.read_post(group["slug"], post[:uuid], nil, 2) - v2_langs = v2_post[:available_languages] - - assert "en" in v2_langs - assert "de" in v2_langs - assert "fr" in v2_langs - end - end -end diff --git a/test/modules/publishing/integration/versions_test.exs b/test/modules/publishing/integration/versions_test.exs deleted file mode 100644 index e42cd11ea..000000000 --- a/test/modules/publishing/integration/versions_test.exs +++ /dev/null @@ -1,305 +0,0 @@ -defmodule PhoenixKit.Integration.Publishing.VersionsTest do - use PhoenixKit.DataCase, async: true - - alias PhoenixKit.Modules.Publishing.Groups - alias PhoenixKit.Modules.Publishing.Posts - alias PhoenixKit.Modules.Publishing.Versions - - defp unique_name, do: "Versions Group #{System.unique_integer([:positive])}" - - defp create_group_and_post(opts \\ []) do - mode = Keyword.get(opts, :mode, "slug") - title = Keyword.get(opts, :title, "Versioned Post") - - {:ok, group} = Groups.add_group(unique_name(), mode: mode) - {:ok, post} = Posts.create_post(group["slug"], %{title: title}) - - {group, post} - end - - # ============================================================================ - # list_versions/2 - # ============================================================================ - - describe "list_versions/2" do - test "new post has version 1" do - {group, post} = create_group_and_post() - versions = Versions.list_versions(group["slug"], post[:slug] || post[:uuid]) - assert versions == [1] - end - - test "multiple versions are listed in order" do - {group, post} = create_group_and_post() - {:ok, v2} = Versions.create_new_version(group["slug"], post, %{}, %{}) - {:ok, _v3} = Versions.create_new_version(group["slug"], v2, %{}, %{}) - - versions = Versions.list_versions(group["slug"], post[:slug] || post[:uuid]) - assert versions == [1, 2, 3] - end - end - - # ============================================================================ - # create_new_version/4 - # ============================================================================ - - describe "create_new_version/4" do - test "creates version 2 by cloning latest" do - {group, post} = create_group_and_post() - {:ok, new_post} = Versions.create_new_version(group["slug"], post, %{}, %{}) - - assert new_post[:version] == 2 - assert 1 in new_post[:available_versions] - assert 2 in new_post[:available_versions] - end - - test "clones content from source version" do - {group, post} = create_group_and_post(title: "Clone Me") - {:ok, v2_post} = Versions.create_new_version(group["slug"], post, %{}, %{}) - assert v2_post[:metadata][:title] == "Clone Me" - end - - test "creates successive versions" do - {group, post} = create_group_and_post() - {:ok, v2} = Versions.create_new_version(group["slug"], post, %{}, %{}) - {:ok, v3} = Versions.create_new_version(group["slug"], v2, %{}, %{}) - - assert v3[:version] == 3 - versions = Versions.list_versions(group["slug"], post[:slug] || post[:uuid]) - assert versions == [1, 2, 3] - end - - test "new version starts as draft" do - {group, post} = create_group_and_post(title: "Publish V1") - :ok = Versions.publish_version(group["slug"], post[:uuid], 1) - - {:ok, v2} = Versions.create_new_version(group["slug"], post, %{}, %{}) - assert v2[:metadata][:status] == "draft" - end - - test "clones all languages from source version" do - {group, post} = create_group_and_post(title: "Multilang") - - alias PhoenixKit.Modules.Publishing.TranslationManager - {:ok, _} = TranslationManager.add_language_to_post(group["slug"], post[:uuid], "de", nil) - - {:ok, v2} = Versions.create_new_version(group["slug"], post, %{}, %{}) - - # V2 should have both en and de - {:ok, v2_post} = Posts.read_post(group["slug"], post[:uuid], nil, 2) - v2_langs = v2_post[:available_languages] - assert "en" in v2_langs - assert "de" in v2_langs - end - - test "returns post map with available_versions updated" do - {group, post} = create_group_and_post() - {:ok, v2} = Versions.create_new_version(group["slug"], post, %{}, %{}) - - assert is_list(v2[:available_versions]) - assert length(v2[:available_versions]) == 2 - end - end - - # ============================================================================ - # publish_version/4 - # ============================================================================ - - describe "publish_version/4" do - test "publishes a version with title" do - {group, post} = create_group_and_post(title: "Publish Me") - assert :ok = Versions.publish_version(group["slug"], post[:uuid], 1) - end - - test "published version status is published" do - {group, post} = create_group_and_post(title: "Check Published") - :ok = Versions.publish_version(group["slug"], post[:uuid], 1) - - status = - Versions.get_version_status(group["slug"], post[:slug] || post[:uuid], 1, "en") - - assert status == "published" - end - - test "archives previously published version" do - {group, post} = create_group_and_post(title: "V1 Title") - :ok = Versions.publish_version(group["slug"], post[:uuid], 1) - - {:ok, v2} = Versions.create_new_version(group["slug"], post, %{}, %{}) - Posts.update_post(group["slug"], v2, %{"title" => "V2 Title"}, %{}) - :ok = Versions.publish_version(group["slug"], post[:uuid], 2) - - v1_status = - Versions.get_version_status(group["slug"], post[:slug] || post[:uuid], 1, "en") - - assert v1_status == "archived" - end - - test "only one version published at a time" do - {group, post} = create_group_and_post(title: "Multi V") - :ok = Versions.publish_version(group["slug"], post[:uuid], 1) - - {:ok, v2} = Versions.create_new_version(group["slug"], post, %{}, %{}) - Posts.update_post(group["slug"], v2, %{"title" => "V2"}, %{}) - :ok = Versions.publish_version(group["slug"], post[:uuid], 2) - - v1 = Versions.get_version_status(group["slug"], post[:slug] || post[:uuid], 1, "en") - v2_status = Versions.get_version_status(group["slug"], post[:slug] || post[:uuid], 2, "en") - - assert v1 == "archived" - assert v2_status == "published" - end - - test "rejects publishing version without title" do - {:ok, group} = Groups.add_group(unique_name(), mode: "slug") - {:ok, post} = Posts.create_post(group["slug"], %{}) - - assert {:error, :title_required} = - Versions.publish_version(group["slug"], post[:uuid], 1) - end - - test "rejects publishing trashed post" do - {group, post} = create_group_and_post(title: "Trashed") - {:ok, _} = Posts.trash_post(group["slug"], post[:uuid]) - - assert {:error, :post_trashed} = - Versions.publish_version(group["slug"], post[:uuid], 1) - end - - test "rejects publishing nonexistent version" do - {group, post} = create_group_and_post(title: "Missing V") - - assert {:error, :version_not_found} = - Versions.publish_version(group["slug"], post[:uuid], 99) - end - end - - # ============================================================================ - # get_published_version/2 - # ============================================================================ - - describe "get_published_version/2" do - test "returns published version number" do - {group, post} = create_group_and_post(title: "Published V") - :ok = Versions.publish_version(group["slug"], post[:uuid], 1) - - assert {:ok, 1} = - Versions.get_published_version(group["slug"], post[:slug] || post[:uuid]) - end - - test "returns error when no version is published" do - {group, post} = create_group_and_post(title: "No Pub") - - result = Versions.get_published_version(group["slug"], post[:slug] || post[:uuid]) - assert match?({:error, _}, result) - end - end - - # ============================================================================ - # get_version_status/4 - # ============================================================================ - - describe "get_version_status/4" do - test "returns draft for new version" do - {group, post} = create_group_and_post() - - status = - Versions.get_version_status(group["slug"], post[:slug] || post[:uuid], 1, "en") - - assert status == "draft" - end - - test "returns published after publishing" do - {group, post} = create_group_and_post(title: "Pub Status") - :ok = Versions.publish_version(group["slug"], post[:uuid], 1) - - status = - Versions.get_version_status(group["slug"], post[:slug] || post[:uuid], 1, "en") - - assert status == "published" - end - end - - # ============================================================================ - # delete_version/3 - # ============================================================================ - - describe "delete_version/3" do - test "archives a draft version" do - {group, post} = create_group_and_post() - {:ok, _v2} = Versions.create_new_version(group["slug"], post, %{}, %{}) - assert :ok = Versions.delete_version(group["slug"], post[:uuid], 1) - end - - test "cannot delete published version" do - {group, post} = create_group_and_post(title: "Published") - :ok = Versions.publish_version(group["slug"], post[:uuid], 1) - - assert {:error, :cannot_delete_live} = - Versions.delete_version(group["slug"], post[:uuid], 1) - end - - test "cannot delete last remaining version" do - {group, post} = create_group_and_post() - - assert {:error, :last_version} = - Versions.delete_version(group["slug"], post[:uuid], 1) - end - - test "cannot delete nonexistent version" do - {group, post} = create_group_and_post() - - result = Versions.delete_version(group["slug"], post[:uuid], 99) - assert match?({:error, _}, result) - end - - test "deleted version is archived, not hard-deleted" do - {group, post} = create_group_and_post() - {:ok, _v2} = Versions.create_new_version(group["slug"], post, %{}, %{}) - :ok = Versions.delete_version(group["slug"], post[:uuid], 1) - - # Version still exists in the list - versions = Versions.list_versions(group["slug"], post[:slug] || post[:uuid]) - assert 1 in versions - end - end - - # ============================================================================ - # Full version workflow - # ============================================================================ - - describe "full version workflow" do - test "create → publish v1 → create v2 → edit v2 → publish v2 → v1 archived" do - {group, post} = create_group_and_post(title: "V1 Content") - - # Publish v1 first - :ok = Versions.publish_version(group["slug"], post[:uuid], 1) - - # Create v2 - {:ok, v2} = Versions.create_new_version(group["slug"], post, %{}, %{}) - assert v2[:version] == 2 - assert v2[:metadata][:title] == "V1 Content" - - # Edit v2 - {:ok, _} = Posts.update_post(group["slug"], v2, %{"title" => "V2 Content"}, %{}) - - # Publish v2 - :ok = Versions.publish_version(group["slug"], post[:uuid], 2) - - # V1 should now be archived (was published, got superseded) - v1_status = - Versions.get_version_status(group["slug"], post[:slug] || post[:uuid], 1, "en") - - assert v1_status == "archived" - - # V2 should be published - v2_status = - Versions.get_version_status(group["slug"], post[:slug] || post[:uuid], 2, "en") - - assert v2_status == "published" - - # Reading without version gives v2 (latest) - {:ok, latest} = Posts.read_post(group["slug"], post[:uuid], nil, nil) - assert latest[:version] == 2 - end - end -end diff --git a/test/modules/publishing/mapper_test.exs b/test/modules/publishing/mapper_test.exs deleted file mode 100644 index fe9ec291e..000000000 --- a/test/modules/publishing/mapper_test.exs +++ /dev/null @@ -1,488 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.DBStorage.MapperTest do - use ExUnit.Case, async: true - - alias PhoenixKit.Modules.Publishing.DBStorage.Mapper - alias PhoenixKit.Modules.Publishing.PublishingContent - alias PhoenixKit.Modules.Publishing.PublishingGroup - alias PhoenixKit.Modules.Publishing.PublishingPost - alias PhoenixKit.Modules.Publishing.PublishingVersion - - # ============================================================================ - # Test Data Builders - # ============================================================================ - - defp build_group(attrs \\ %{}) do - %PublishingGroup{ - uuid: UUIDv7.generate(), - name: "Blog", - slug: "blog", - mode: "slug", - position: 0, - data: %{} - } - |> Map.merge(attrs) - end - - defp build_post(group, attrs \\ %{}) do - %PublishingPost{ - uuid: UUIDv7.generate(), - group_uuid: group.uuid, - group: group, - slug: "hello-world", - status: "published", - mode: "slug", - primary_language: "en", - published_at: ~U[2025-06-15 14:30:00Z], - post_date: nil, - post_time: nil, - data: %{} - } - |> Map.merge(attrs) - end - - defp build_version(post, attrs \\ %{}) do - %PublishingVersion{ - uuid: UUIDv7.generate(), - post_uuid: post.uuid, - version_number: 1, - status: "published", - data: %{}, - inserted_at: ~U[2025-06-15 14:30:00Z] - } - |> Map.merge(attrs) - end - - defp build_content(version, attrs \\ %{}) do - %PublishingContent{ - uuid: UUIDv7.generate(), - version_uuid: version.uuid, - language: "en", - title: "Hello World", - content: "# Hello World\n\nThis is the content.", - status: "published", - url_slug: nil, - data: %{} - } - |> Map.merge(attrs) - end - - # ============================================================================ - # to_post_map/5 - # ============================================================================ - - describe "to_post_map/5" do - test "converts DB records to post map format" do - group = build_group() - post = build_post(group) - version = build_version(post) - content = build_content(version) - - result = Mapper.to_post_map(post, version, content, [content], [version]) - - assert result.uuid == post.uuid - assert result.group == "blog" - assert result.slug == "hello-world" - assert result.mode == :slug - assert result.language == "en" - assert result.version == 1 - assert result.content == content.content - assert result.primary_language == "en" - end - - test "builds available_languages from all contents" do - group = build_group() - post = build_post(group) - version = build_version(post) - en_content = build_content(version, %{language: "en", status: "published"}) - es_content = build_content(version, %{language: "es", status: "draft"}) - - result = - Mapper.to_post_map(post, version, en_content, [en_content, es_content], [version]) - - assert result.available_languages == ["en", "es"] - assert result.language_statuses == %{"en" => "published", "es" => "draft"} - end - - test "builds version_statuses from all versions" do - group = build_group() - post = build_post(group) - v1 = build_version(post, %{version_number: 1, status: "archived"}) - v2 = build_version(post, %{version_number: 2, status: "published"}) - content = build_content(v2) - - result = Mapper.to_post_map(post, v2, content, [content], [v1, v2]) - - assert result.available_versions == [1, 2] - assert result.version_statuses == %{1 => "archived", 2 => "published"} - end - - test "includes date/time for timestamp-mode post" do - group = build_group() - - post = - build_post(group, %{ - mode: "timestamp", - post_date: ~D[2025-06-15], - post_time: ~T[14:30:00] - }) - - version = build_version(post) - content = build_content(version) - - result = Mapper.to_post_map(post, version, content, [content], [version]) - - assert result.mode == :timestamp - assert result.date == ~D[2025-06-15] - assert result.time == ~T[14:30:00] - end - - test "url_slug falls back to post slug when nil" do - group = build_group() - post = build_post(group) - version = build_version(post) - content = build_content(version, %{url_slug: nil}) - - result = Mapper.to_post_map(post, version, content, [content], [version]) - - assert result.url_slug == "hello-world" - end - - test "url_slug uses content url_slug when set" do - group = build_group() - post = build_post(group) - version = build_version(post) - content = build_content(version, %{url_slug: "custom-url"}) - - result = Mapper.to_post_map(post, version, content, [content], [version]) - - assert result.url_slug == "custom-url" - end - - test "metadata includes expected fields" do - group = build_group() - post = build_post(group) - version = build_version(post) - - content = - build_content(version, %{ - data: %{ - "description" => "A test post", - "featured_image_uuid" => "img-123", - "previous_url_slugs" => ["old-url"] - } - }) - - result = Mapper.to_post_map(post, version, content, [content], [version]) - - assert result.metadata.title == "Hello World" - assert result.metadata.description == "A test post" - assert result.metadata.status == "published" - assert result.metadata.slug == "hello-world" - assert result.metadata.version == 1 - assert result.metadata.featured_image_uuid == "img-123" - assert result.metadata.previous_url_slugs == ["old-url"] - assert result.metadata.published_at == "2025-06-15T14:30:00Z" - assert result.metadata.primary_language == "en" - end - - test "builds language_slugs map" do - group = build_group() - post = build_post(group) - version = build_version(post) - en = build_content(version, %{language: "en", url_slug: "hello"}) - es = build_content(version, %{language: "es", url_slug: "hola"}) - - result = Mapper.to_post_map(post, version, en, [en, es], [version]) - - assert result.language_slugs == %{"en" => "hello", "es" => "hola"} - end - - test "builds version_dates from all versions" do - group = build_group() - post = build_post(group) - v1 = build_version(post, %{version_number: 1, inserted_at: ~U[2025-06-10 10:00:00Z]}) - v2 = build_version(post, %{version_number: 2, inserted_at: ~U[2025-06-15 14:30:00Z]}) - content = build_content(v2) - - result = Mapper.to_post_map(post, v2, content, [content], [v1, v2]) - - assert result.version_dates == %{ - 1 => "2025-06-10T10:00:00Z", - 2 => "2025-06-15T14:30:00Z" - } - end - - test "builds language_previous_slugs from all contents" do - group = build_group() - post = build_post(group) - version = build_version(post) - - en = - build_content(version, %{ - language: "en", - data: %{"previous_url_slugs" => ["old-hello"]} - }) - - es = build_content(version, %{language: "es", data: %{}}) - - result = Mapper.to_post_map(post, version, en, [en, es], [version]) - - assert result.language_previous_slugs["en"] == ["old-hello"] - assert result.language_previous_slugs["es"] == [] - end - - test "merges published_language_statuses via opts" do - group = build_group() - post = build_post(group) - version = build_version(post) - en = build_content(version, %{language: "en", status: "draft"}) - es = build_content(version, %{language: "es", status: "draft"}) - - result = - Mapper.to_post_map(post, version, en, [en, es], [version], - published_language_statuses: %{"en" => "published"} - ) - - assert result.language_statuses["en"] == "published" - assert result.language_statuses["es"] == "draft" - end - - test "group slug is nil when group is not preloaded" do - group = build_group() - - post = - build_post(group, %{ - group: %Ecto.Association.NotLoaded{ - __field__: :group, - __cardinality__: :one, - __owner__: PublishingPost - } - }) - - version = build_version(post) - content = build_content(version) - - result = Mapper.to_post_map(post, version, content, [content], [version]) - - assert result.group == nil - end - - test "url_slug falls back to post slug when empty string" do - group = build_group() - post = build_post(group) - version = build_version(post) - content = build_content(version, %{url_slug: ""}) - - result = Mapper.to_post_map(post, version, content, [content], [version]) - - assert result.url_slug == "hello-world" - end - end - - # ============================================================================ - # to_listing_map/4 - # ============================================================================ - - describe "to_listing_map/4" do - test "converts post to listing format" do - group = build_group() - post = build_post(group) - version = build_version(post) - content = build_content(version) - - result = Mapper.to_listing_map(post, version, [content], [version]) - - assert result.uuid == post.uuid - assert result.group == "blog" - assert result.slug == "hello-world" - assert result.mode == :slug - end - - test "uses primary language content for listing" do - group = build_group() - post = build_post(group, %{primary_language: "en"}) - version = build_version(post) - en = build_content(version, %{language: "en", title: "English Title"}) - es = build_content(version, %{language: "es", title: "Titulo"}) - - result = Mapper.to_listing_map(post, version, [en, es], [version]) - - assert result.metadata.title == "English Title" - assert result.language == "en" - end - - test "extracts excerpt from content" do - group = build_group() - post = build_post(group) - version = build_version(post) - - content = - build_content(version, %{ - content: "First paragraph here.\n\n## Section\n\nMore content." - }) - - result = Mapper.to_listing_map(post, version, [content], [version]) - - assert result.content == "First paragraph here." - end - - test "uses custom excerpt from data when available" do - group = build_group() - post = build_post(group) - version = build_version(post) - - content = - build_content(version, %{ - content: "Full content here", - data: %{"excerpt" => "Custom excerpt text"} - }) - - result = Mapper.to_listing_map(post, version, [content], [version]) - - assert result.content == "Custom excerpt text" - end - - test "handles nil content gracefully" do - group = build_group() - post = build_post(group, %{primary_language: "en"}) - version = build_version(post) - - result = Mapper.to_listing_map(post, version, [], [version]) - - assert result.metadata.title == nil - assert result.content == nil - end - - test "falls back to first content when primary language not found" do - group = build_group() - post = build_post(group, %{primary_language: "en"}) - version = build_version(post) - es = build_content(version, %{language: "es", title: "Titulo Espanol"}) - - result = Mapper.to_listing_map(post, version, [es], [version]) - - assert result.metadata.title == "Titulo Espanol" - end - - test "uses description as excerpt fallback when no custom excerpt" do - group = build_group() - post = build_post(group) - version = build_version(post) - - content = - build_content(version, %{ - content: "Full content here", - data: %{"description" => "A meta description"} - }) - - result = Mapper.to_listing_map(post, version, [content], [version]) - - assert result.content == "A meta description" - end - - test "builds language_titles and language_excerpts" do - group = build_group() - post = build_post(group) - version = build_version(post) - - en = - build_content(version, %{ - language: "en", - title: "Hello", - data: %{"excerpt" => "EN excerpt"} - }) - - es = - build_content(version, %{ - language: "es", - title: "Hola", - data: %{"excerpt" => "ES excerpt"} - }) - - result = Mapper.to_listing_map(post, version, [en, es], [version]) - - assert result.language_titles == %{"en" => "Hello", "es" => "Hola"} - assert result.language_excerpts == %{"en" => "EN excerpt", "es" => "ES excerpt"} - end - - test "builds version_dates from all versions" do - group = build_group() - post = build_post(group) - v1 = build_version(post, %{version_number: 1, inserted_at: ~U[2025-06-10 10:00:00Z]}) - v2 = build_version(post, %{version_number: 2, inserted_at: ~U[2025-06-15 14:30:00Z]}) - content = build_content(v2) - - result = Mapper.to_listing_map(post, v2, [content], [v1, v2]) - - assert result.version_dates == %{ - 1 => "2025-06-10T10:00:00Z", - 2 => "2025-06-15T14:30:00Z" - } - end - - test "url_slug falls back to post slug when content url_slug is nil" do - group = build_group() - post = build_post(group) - version = build_version(post) - content = build_content(version, %{url_slug: nil}) - - result = Mapper.to_listing_map(post, version, [content], [version]) - - assert result.url_slug == "hello-world" - end - - test "merges published_language_statuses via opts" do - group = build_group() - post = build_post(group) - version = build_version(post) - en = build_content(version, %{language: "en", status: "draft"}) - - result = - Mapper.to_listing_map(post, version, [en], [version], - published_language_statuses: %{"en" => "published"} - ) - - assert result.language_statuses["en"] == "published" - end - - test "version defaults to 1 when version is nil" do - group = build_group() - post = build_post(group) - version = build_version(post) - content = build_content(version) - - result = Mapper.to_listing_map(post, nil, [content], [version]) - - assert result.version == 1 - end - - test "builds available_versions and version_statuses" do - group = build_group() - post = build_post(group) - v1 = build_version(post, %{version_number: 1, status: "archived"}) - v2 = build_version(post, %{version_number: 2, status: "published"}) - content = build_content(v2) - - result = Mapper.to_listing_map(post, v2, [content], [v1, v2]) - - assert result.available_versions == [1, 2] - assert result.version_statuses == %{1 => "archived", 2 => "published"} - end - - test "extracts first non-heading paragraph when no excerpt or description" do - group = build_group() - post = build_post(group) - version = build_version(post) - - content = - build_content(version, %{ - content: "## Heading\n\nActual paragraph here.\n\nMore content.", - data: %{} - }) - - result = Mapper.to_listing_map(post, version, [content], [version]) - - assert result.content == "Actual paragraph here." - end - end -end diff --git a/test/modules/publishing/metadata_test.exs b/test/modules/publishing/metadata_test.exs deleted file mode 100644 index b4e81d859..000000000 --- a/test/modules/publishing/metadata_test.exs +++ /dev/null @@ -1,61 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.MetadataTest do - use ExUnit.Case, async: true - - alias PhoenixKit.Modules.Publishing.Metadata - - # ============================================================================ - # extract_title_from_content/1 - # ============================================================================ - - describe "extract_title_from_content/1" do - test "extracts H1 heading" do - assert Metadata.extract_title_from_content("# Hello World") == "Hello World" - end - - test "extracts H1 from multiline content" do - content = """ - Some text - - # The Title - - More content - """ - - assert Metadata.extract_title_from_content(content) == "The Title" - end - - test "returns Untitled for empty string" do - assert Metadata.extract_title_from_content("") == "Untitled" - end - - test "returns Untitled for nil" do - assert Metadata.extract_title_from_content(nil) == "Untitled" - end - - test "falls back to first line when no H1" do - assert Metadata.extract_title_from_content("Just text\nMore text") == "Just text" - end - - test "ignores content inside components" do - content = """ - - # This should be ignored - - - # Real Title - """ - - assert Metadata.extract_title_from_content(content) == "Real Title" - end - - test "extracts title from Headline component" do - content = "My Headline" - assert Metadata.extract_title_from_content(content) == "My Headline" - end - - test "extracts title from Hero component title attribute" do - content = ~s() - assert Metadata.extract_title_from_content(content) == "Welcome Home" - end - end -end diff --git a/test/modules/publishing/posts_test.exs b/test/modules/publishing/posts_test.exs deleted file mode 100644 index 82650b78b..000000000 --- a/test/modules/publishing/posts_test.exs +++ /dev/null @@ -1,106 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.PostsTest do - use ExUnit.Case, async: true - - alias PhoenixKit.Modules.Publishing.Posts - - # ============================================================================ - # db_post?/1 - # ============================================================================ - - describe "db_post?/1" do - test "returns true when post has uuid" do - assert Posts.db_post?(%{uuid: "019cce93-ed2e-7e1b-9e62-af160709fd94"}) - end - - test "returns false when uuid is nil" do - refute Posts.db_post?(%{uuid: nil}) - end - - test "returns false when no uuid key" do - refute Posts.db_post?(%{slug: "test"}) - end - end - - # ============================================================================ - # extract_slug_version_and_language/2 - # ============================================================================ - - describe "extract_slug_version_and_language/2" do - test "extracts slug only" do - assert {"hello-world", nil, nil} = - Posts.extract_slug_version_and_language("blog", "hello-world") - end - - test "extracts slug and version" do - assert {"hello-world", 2, nil} = - Posts.extract_slug_version_and_language("blog", "hello-world/v2") - end - - test "extracts slug, version, and language" do - assert {"hello-world", 2, "en"} = - Posts.extract_slug_version_and_language("blog", "hello-world/v2/en") - end - - test "extracts slug and language without version" do - assert {"hello-world", nil, "en"} = - Posts.extract_slug_version_and_language("blog", "hello-world/en") - end - - test "handles nil identifier" do - assert {"", nil, nil} = Posts.extract_slug_version_and_language("blog", nil) - end - - test "drops group prefix when present" do - assert {"hello-world", 1, "en"} = - Posts.extract_slug_version_and_language("blog", "blog/hello-world/v1/en") - end - - test "handles leading slash" do - assert {"hello-world", nil, nil} = - Posts.extract_slug_version_and_language("blog", "/hello-world") - end - - test "does not drop group prefix when it's the only element" do - assert {"blog", nil, nil} = - Posts.extract_slug_version_and_language("blog", "blog") - end - - test "handles empty string identifier" do - assert {"", nil, nil} = - Posts.extract_slug_version_and_language("blog", "") - end - end - - # ============================================================================ - # Facade delegation consistency - # ============================================================================ - - describe "facade consistency" do - test "all public functions are accessible through Publishing facade" do - alias PhoenixKit.Modules.Publishing - - # These should all be delegated and callable (they may fail at DB level, - # but the delegation should not raise UndefinedFunctionError) - assert function_exported?(Publishing, :list_posts, 1) - assert function_exported?(Publishing, :list_posts, 2) - assert function_exported?(Publishing, :create_post, 1) - assert function_exported?(Publishing, :create_post, 2) - assert function_exported?(Publishing, :read_post, 2) - assert function_exported?(Publishing, :read_post, 3) - assert function_exported?(Publishing, :read_post, 4) - assert function_exported?(Publishing, :read_post_by_uuid, 1) - assert function_exported?(Publishing, :read_post_by_uuid, 2) - assert function_exported?(Publishing, :read_post_by_uuid, 3) - assert function_exported?(Publishing, :update_post, 3) - assert function_exported?(Publishing, :update_post, 4) - assert function_exported?(Publishing, :trash_post, 2) - assert function_exported?(Publishing, :count_posts_on_date, 2) - assert function_exported?(Publishing, :list_times_on_date, 2) - assert function_exported?(Publishing, :find_by_url_slug, 3) - assert function_exported?(Publishing, :find_by_previous_url_slug, 3) - assert function_exported?(Publishing, :db_post?, 1) - assert function_exported?(Publishing, :should_create_new_version?, 3) - assert function_exported?(Publishing, :extract_slug_version_and_language, 2) - end - end -end diff --git a/test/modules/publishing/publishing_api_test.exs b/test/modules/publishing/publishing_api_test.exs deleted file mode 100644 index 36f60babd..000000000 --- a/test/modules/publishing/publishing_api_test.exs +++ /dev/null @@ -1,203 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.PublishingAPITest do - use ExUnit.Case, async: true - - alias PhoenixKit.Modules.Publishing - - # ============================================================================ - # Module Loading - # ============================================================================ - - describe "module loading" do - test "Publishing module is defined" do - assert Code.ensure_loaded?(PhoenixKit.Modules.Publishing) - end - - test "DBStorage module is defined" do - assert Code.ensure_loaded?(PhoenixKit.Modules.Publishing.DBStorage) - end - - test "ListingCache module is defined" do - assert Code.ensure_loaded?(PhoenixKit.Modules.Publishing.ListingCache) - end - - test "LanguageHelpers module is defined" do - assert Code.ensure_loaded?(PhoenixKit.Modules.Publishing.LanguageHelpers) - end - - test "SlugHelpers module is defined" do - assert Code.ensure_loaded?(PhoenixKit.Modules.Publishing.SlugHelpers) - end - - test "Metadata module is defined" do - assert Code.ensure_loaded?(PhoenixKit.Modules.Publishing.Metadata) - end - - test "PubSub module is defined" do - assert Code.ensure_loaded?(PhoenixKit.Modules.Publishing.PubSub) - end - - test "All schema modules are defined" do - assert Code.ensure_loaded?(PhoenixKit.Modules.Publishing.PublishingGroup) - assert Code.ensure_loaded?(PhoenixKit.Modules.Publishing.PublishingPost) - assert Code.ensure_loaded?(PhoenixKit.Modules.Publishing.PublishingVersion) - assert Code.ensure_loaded?(PhoenixKit.Modules.Publishing.PublishingContent) - end - - test "Mapper module is defined" do - assert Code.ensure_loaded?(PhoenixKit.Modules.Publishing.DBStorage.Mapper) - end - - test "Worker modules are defined" do - assert Code.ensure_loaded?( - PhoenixKit.Modules.Publishing.Workers.MigratePrimaryLanguageWorker - ) - end - - test "Refactored submodules are defined" do - assert Code.ensure_loaded?(PhoenixKit.Modules.Publishing.Groups) - assert Code.ensure_loaded?(PhoenixKit.Modules.Publishing.Posts) - assert Code.ensure_loaded?(PhoenixKit.Modules.Publishing.Versions) - assert Code.ensure_loaded?(PhoenixKit.Modules.Publishing.TranslationManager) - assert Code.ensure_loaded?(PhoenixKit.Modules.Publishing.StaleFixer) - assert Code.ensure_loaded?(PhoenixKit.Modules.Publishing.Shared) - end - end - - # ============================================================================ - # slugify/1 - # ============================================================================ - - describe "slugify/1" do - test "converts to lowercase" do - assert Publishing.slugify("Hello World") == "hello-world" - end - - test "replaces spaces with hyphens" do - assert Publishing.slugify("my blog post") == "my-blog-post" - end - - test "removes special characters" do - assert Publishing.slugify("Hello! World?") == "hello-world" - end - - test "trims leading and trailing hyphens" do - assert Publishing.slugify(" Hello ") == "hello" - end - - test "handles multiple consecutive spaces" do - assert Publishing.slugify("hello world") == "hello-world" - end - - test "handles empty string" do - assert Publishing.slugify("") == "" - end - - test "handles unicode characters" do - result = Publishing.slugify("Héllo Wörld") - assert is_binary(result) - assert result =~ ~r/^[a-z0-9-]*$/ - end - end - - # ============================================================================ - # valid_slug?/1 - # ============================================================================ - - describe "valid_slug?/1" do - test "accepts lowercase alphanumeric with hyphens" do - assert Publishing.valid_slug?("hello-world") - assert Publishing.valid_slug?("my-post-123") - assert Publishing.valid_slug?("a") - end - - test "rejects empty string" do - refute Publishing.valid_slug?("") - end - - test "rejects non-string values" do - refute Publishing.valid_slug?(nil) - refute Publishing.valid_slug?(123) - end - - test "rejects uppercase" do - refute Publishing.valid_slug?("Hello") - end - - test "rejects special characters" do - refute Publishing.valid_slug?("hello world") - refute Publishing.valid_slug?("hello_world") - refute Publishing.valid_slug?("hello.world") - end - end - - # ============================================================================ - # db_post?/1 - # ============================================================================ - - describe "db_post?/1" do - test "returns true when post has uuid" do - assert Publishing.db_post?(%{uuid: "some-uuid"}) - end - - test "returns false when post has nil uuid" do - refute Publishing.db_post?(%{uuid: nil}) - end - - test "returns false when post has no uuid key" do - refute Publishing.db_post?(%{slug: "test"}) - end - end - - # ============================================================================ - # extract_slug_version_and_language/2 - # ============================================================================ - - describe "extract_slug_version_and_language/2" do - test "extracts slug only" do - assert Publishing.extract_slug_version_and_language("blog", "hello-world") == - {"hello-world", nil, nil} - end - - test "extracts slug and version" do - assert Publishing.extract_slug_version_and_language("blog", "hello-world/v2") == - {"hello-world", 2, nil} - end - - test "extracts slug, version, and language" do - assert Publishing.extract_slug_version_and_language("blog", "hello-world/v2/en") == - {"hello-world", 2, "en"} - end - - test "handles nil identifier" do - assert Publishing.extract_slug_version_and_language("blog", nil) == {"", nil, nil} - end - - test "drops group prefix when present" do - assert Publishing.extract_slug_version_and_language("blog", "blog/hello-world/v1/en") == - {"hello-world", 1, "en"} - end - - test "handles leading slash" do - assert Publishing.extract_slug_version_and_language("blog", "/hello-world") == - {"hello-world", nil, nil} - end - end - - # ============================================================================ - # preset_types/0 - # ============================================================================ - - describe "preset_types/0" do - test "returns a list of preset types" do - types = Publishing.preset_types() - assert is_list(types) - refute Enum.empty?(types) - - # Each type should have label and value - Enum.each(types, fn type -> - assert Map.has_key?(type, :label) or Map.has_key?(type, "label") or - Map.has_key?(type, :value) or Map.has_key?(type, "value") - end) - end - end -end diff --git a/test/modules/publishing/pubsub_broadcast_id_test.exs b/test/modules/publishing/pubsub_broadcast_id_test.exs deleted file mode 100644 index b526747d4..000000000 --- a/test/modules/publishing/pubsub_broadcast_id_test.exs +++ /dev/null @@ -1,71 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.PubSubBroadcastIdTest do - use ExUnit.Case, async: true - - alias PhoenixKit.Modules.Publishing.PubSub, as: PublishingPubSub - - # ============================================================================ - # broadcast_id/1 - # ============================================================================ - - describe "broadcast_id/1" do - test "returns slug when present" do - post = %{slug: "my-post", uuid: "019cfcf7-8234-7ea5-b8fb-f6d5ae74ea18"} - assert PublishingPubSub.broadcast_id(post) == "my-post" - end - - test "falls back to uuid when slug is nil" do - post = %{slug: nil, uuid: "019cfcf7-8234-7ea5-b8fb-f6d5ae74ea18"} - assert PublishingPubSub.broadcast_id(post) == "019cfcf7-8234-7ea5-b8fb-f6d5ae74ea18" - end - - test "falls back to uuid when slug key is missing" do - post = %{uuid: "019cfcf7-8234-7ea5-b8fb-f6d5ae74ea18"} - assert PublishingPubSub.broadcast_id(post) == "019cfcf7-8234-7ea5-b8fb-f6d5ae74ea18" - end - - test "returns nil when both slug and uuid are nil" do - post = %{slug: nil, uuid: nil} - assert PublishingPubSub.broadcast_id(post) == nil - end - - test "returns nil for nil post" do - assert PublishingPubSub.broadcast_id(nil) == nil - end - - test "prefers slug over uuid" do - post = %{slug: "hello-world", uuid: "019cfcf7-0000-0000-0000-000000000000"} - assert PublishingPubSub.broadcast_id(post) == "hello-world" - end - end - - # ============================================================================ - # Topic consistency - # ============================================================================ - - describe "topic consistency" do - test "subscription and broadcast use the same topic for slug-mode posts" do - post = %{slug: "my-post", uuid: "019cfcf7-8234-7ea5-b8fb-f6d5ae74ea18"} - broadcast_id = PublishingPubSub.broadcast_id(post) - - # The subscription topic should match what the worker would broadcast to - topic = PublishingPubSub.post_translations_topic("blog", broadcast_id) - assert topic == "publishing:blog:post:my-post:translations" - end - - test "subscription and broadcast use the same topic for timestamp-mode posts (no slug)" do - post = %{slug: nil, uuid: "019cfcf7-8234-7ea5-b8fb-f6d5ae74ea18"} - broadcast_id = PublishingPubSub.broadcast_id(post) - - topic = PublishingPubSub.post_translations_topic("news", broadcast_id) - assert topic == "publishing:news:post:019cfcf7-8234-7ea5-b8fb-f6d5ae74ea18:translations" - end - - test "version topic uses same broadcast_id pattern" do - post = %{slug: nil, uuid: "019cfcf7-8234-7ea5-b8fb-f6d5ae74ea18"} - broadcast_id = PublishingPubSub.broadcast_id(post) - - topic = PublishingPubSub.post_versions_topic("news", broadcast_id) - assert topic == "publishing:news:post:019cfcf7-8234-7ea5-b8fb-f6d5ae74ea18:versions" - end - end -end diff --git a/test/modules/publishing/pubsub_test.exs b/test/modules/publishing/pubsub_test.exs deleted file mode 100644 index 3e45264ca..000000000 --- a/test/modules/publishing/pubsub_test.exs +++ /dev/null @@ -1,80 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.PubSubTest do - use ExUnit.Case, async: false - - alias PhoenixKit.Modules.Publishing.PubSub, as: PublishingPubSub - - # ============================================================================ - # Topic Generation - # ============================================================================ - - describe "topic generation" do - test "groups_topic returns consistent topic" do - assert PublishingPubSub.groups_topic() == "publishing:groups" - end - - test "posts_topic includes blog slug" do - assert PublishingPubSub.posts_topic("blog") == "publishing:blog:posts" - assert PublishingPubSub.posts_topic("faq") == "publishing:faq:posts" - end - - test "post_versions_topic includes blog and post slugs" do - topic = PublishingPubSub.post_versions_topic("blog", "hello-world") - assert topic == "publishing:blog:post:hello-world:versions" - end - - test "post_translations_topic includes blog and post slugs" do - topic = PublishingPubSub.post_translations_topic("blog", "hello-world") - assert topic == "publishing:blog:post:hello-world:translations" - end - - test "editor_form_topic includes form key" do - topic = PublishingPubSub.editor_form_topic("blog:hello-world:en") - assert topic == "publishing:editor_forms:blog:hello-world:en" - end - - test "editor_presence_topic includes form key" do - topic = PublishingPubSub.editor_presence_topic("blog:hello-world:en") - assert topic == "publishing:presence:editor:blog:hello-world:en" - end - - test "cache_topic includes blog slug" do - assert PublishingPubSub.cache_topic("blog") == "publishing:blog:cache" - end - - test "group_editors_topic includes group slug" do - assert PublishingPubSub.group_editors_topic("blog") == "publishing:blog:editors" - end - end - - # ============================================================================ - # Form Key Generation - # ============================================================================ - - describe "generate_form_key/3" do - test "generates key from uuid and language" do - key = PublishingPubSub.generate_form_key("blog", %{uuid: "abc-123", language: "en"}, :edit) - assert key == "blog:abc-123:en" - end - - test "generates key from slug and language" do - key = - PublishingPubSub.generate_form_key( - "blog", - %{slug: "hello-world", language: "en"}, - :edit - ) - - assert key == "blog:hello-world:en" - end - - test "generates key for new post mode" do - key = PublishingPubSub.generate_form_key("blog", %{language: "en"}, :new) - assert key == "blog:new:en" - end - - test "generates fallback key for new mode without language" do - key = PublishingPubSub.generate_form_key("blog", %{}, :new) - assert key == "blog:new" - end - end -end diff --git a/test/modules/publishing/renderer_test.exs b/test/modules/publishing/renderer_test.exs deleted file mode 100644 index a1a7559e7..000000000 --- a/test/modules/publishing/renderer_test.exs +++ /dev/null @@ -1,203 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.RendererTest do - use ExUnit.Case, async: true - - alias PhoenixKit.Modules.Publishing.Renderer - - # ============================================================================ - # Tailwind Class Injection - # ============================================================================ - - describe "render_markdown/1 adds Tailwind classes to headings" do - test "h1 gets size, weight, border classes" do - html = Renderer.render_markdown("# Title") - assert html =~ ~s(

A quote") - assert html =~ ~s(
]*bg-base-200/ - end - end -end diff --git a/test/modules/publishing/schema_test.exs b/test/modules/publishing/schema_test.exs deleted file mode 100644 index 51777cde3..000000000 --- a/test/modules/publishing/schema_test.exs +++ /dev/null @@ -1,373 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.SchemaTest do - use ExUnit.Case, async: true - - alias PhoenixKit.Modules.Publishing.PublishingContent - alias PhoenixKit.Modules.Publishing.PublishingGroup - alias PhoenixKit.Modules.Publishing.PublishingPost - alias PhoenixKit.Modules.Publishing.PublishingVersion - - # ============================================================================ - # PublishingGroup - # ============================================================================ - - describe "PublishingGroup" do - test "module is defined and loadable" do - assert Code.ensure_loaded?(PublishingGroup) - end - - test "changeset validates required fields" do - changeset = PublishingGroup.changeset(%PublishingGroup{}, %{}) - refute changeset.valid? - - assert "can't be blank" in errors_on(changeset, :name) - # mode has default "timestamp", so it won't be blank - end - - test "changeset validates mode inclusion" do - changeset = - PublishingGroup.changeset(%PublishingGroup{}, %{ - name: "Test", - slug: "test", - mode: "invalid" - }) - - refute changeset.valid? - assert "is invalid" in errors_on(changeset, :mode) - end - - test "changeset accepts valid modes" do - for mode <- ["timestamp", "slug"] do - changeset = - PublishingGroup.changeset(%PublishingGroup{}, %{ - name: "Test", - slug: "test", - mode: mode - }) - - assert changeset.valid?, "Expected mode '#{mode}' to be valid" - end - end - - test "changeset auto-generates slug from name when slug provided" do - changeset = - PublishingGroup.changeset(%PublishingGroup{}, %{ - name: "My Blog Group", - slug: "my-blog-group", - mode: "slug" - }) - - assert changeset.valid? - assert Ecto.Changeset.get_change(changeset, :slug) == "my-blog-group" - end - - test "data JSONB accessors return defaults" do - group = %PublishingGroup{data: %{}} - - assert PublishingGroup.get_type(group) == "blog" - assert PublishingGroup.get_item_singular(group) == "Post" - assert PublishingGroup.get_item_plural(group) == "Posts" - assert PublishingGroup.get_description(group) == nil - assert PublishingGroup.get_icon(group) == nil - assert PublishingGroup.comments_enabled?(group) == false - assert PublishingGroup.likes_enabled?(group) == false - assert PublishingGroup.views_enabled?(group) == false - end - - test "data JSONB accessors return custom values" do - group = %PublishingGroup{ - data: %{ - "type" => "faq", - "item_singular" => "Question", - "item_plural" => "Questions", - "description" => "FAQ section", - "icon" => "hero-question-mark-circle", - "comments_enabled" => true, - "likes_enabled" => true, - "views_enabled" => true - } - } - - assert PublishingGroup.get_type(group) == "faq" - assert PublishingGroup.get_item_singular(group) == "Question" - assert PublishingGroup.get_item_plural(group) == "Questions" - assert PublishingGroup.get_description(group) == "FAQ section" - assert PublishingGroup.get_icon(group) == "hero-question-mark-circle" - assert PublishingGroup.comments_enabled?(group) == true - assert PublishingGroup.likes_enabled?(group) == true - assert PublishingGroup.views_enabled?(group) == true - end - end - - # ============================================================================ - # PublishingPost - # ============================================================================ - - describe "PublishingPost" do - test "module is defined and loadable" do - assert Code.ensure_loaded?(PublishingPost) - end - - test "changeset validates required fields" do - changeset = PublishingPost.changeset(%PublishingPost{}, %{}) - refute changeset.valid? - - assert "can't be blank" in errors_on(changeset, :group_uuid) - # status, mode, primary_language have schema defaults so they won't be blank - end - - test "changeset requires slug for slug-mode posts" do - changeset = - PublishingPost.changeset(%PublishingPost{}, %{ - group_uuid: UUIDv7.generate(), - mode: "slug", - primary_language: "en" - }) - - assert "can't be blank" in errors_on(changeset, :slug) - end - - test "changeset requires post_date and post_time for timestamp-mode posts" do - changeset = - PublishingPost.changeset(%PublishingPost{}, %{ - group_uuid: UUIDv7.generate(), - mode: "timestamp", - primary_language: "en" - }) - - assert "can't be blank" in errors_on(changeset, :post_date) - assert "can't be blank" in errors_on(changeset, :post_time) - assert errors_on(changeset, :slug) == [] - end - - test "changeset validates status inclusion" do - changeset = - PublishingPost.changeset(%PublishingPost{}, %{ - group_uuid: UUIDv7.generate(), - slug: "test", - status: "invalid", - mode: "slug", - primary_language: "en" - }) - - refute changeset.valid? - assert "is invalid" in errors_on(changeset, :status) - end - - test "changeset accepts valid statuses" do - for status <- ["draft", "published", "archived", "trashed"] do - attrs = %{ - group_uuid: UUIDv7.generate(), - slug: "test", - status: status, - mode: "slug", - primary_language: "en" - } - - changeset = PublishingPost.changeset(%PublishingPost{}, attrs) - - assert changeset.valid?, - "Expected status '#{status}' to be valid, got: #{inspect(changeset.errors)}" - end - end - - test "changeset rejects invalid status" do - changeset = - PublishingPost.changeset(%PublishingPost{}, %{ - group_uuid: UUIDv7.generate(), - slug: "test", - status: "invalid", - mode: "slug", - primary_language: "en" - }) - - refute changeset.valid? - end - - test "status helpers" do - published = %PublishingPost{status: "published"} - draft = %PublishingPost{status: "draft"} - archived = %PublishingPost{status: "archived"} - - assert PublishingPost.published?(published) - refute PublishingPost.published?(draft) - - assert PublishingPost.draft?(draft) - refute PublishingPost.draft?(published) - refute PublishingPost.draft?(archived) - end - - test "data JSONB accessors return defaults" do - post = %PublishingPost{data: %{}} - - assert PublishingPost.allow_version_access?(post) == false - assert PublishingPost.get_featured_image(post) == nil - assert PublishingPost.get_tags(post) == [] - assert PublishingPost.get_seo(post) == %{} - end - - test "data JSONB accessors return custom values" do - post = %PublishingPost{ - data: %{ - "allow_version_access" => true, - "featured_image" => "img-uuid-123", - "tags" => ["elixir", "phoenix"], - "seo" => %{"og_title" => "My Post"} - } - } - - assert PublishingPost.allow_version_access?(post) == true - assert PublishingPost.get_featured_image(post) == "img-uuid-123" - assert PublishingPost.get_tags(post) == ["elixir", "phoenix"] - assert PublishingPost.get_seo(post) == %{"og_title" => "My Post"} - end - end - - # ============================================================================ - # PublishingVersion - # ============================================================================ - - describe "PublishingVersion" do - test "module is defined and loadable" do - assert Code.ensure_loaded?(PublishingVersion) - end - - test "changeset validates required fields" do - changeset = PublishingVersion.changeset(%PublishingVersion{}, %{}) - refute changeset.valid? - - assert "can't be blank" in errors_on(changeset, :post_uuid) - assert "can't be blank" in errors_on(changeset, :version_number) - # status has default "draft" - end - - test "changeset validates status inclusion" do - changeset = - PublishingVersion.changeset(%PublishingVersion{}, %{ - post_uuid: UUIDv7.generate(), - version_number: 1, - status: "invalid" - }) - - refute changeset.valid? - assert "is invalid" in errors_on(changeset, :status) - end - - test "changeset validates version_number > 0" do - changeset = - PublishingVersion.changeset(%PublishingVersion{}, %{ - post_uuid: UUIDv7.generate(), - version_number: 0, - status: "draft" - }) - - refute changeset.valid? - assert "must be greater than 0" in errors_on(changeset, :version_number) - end - - test "data JSONB accessors" do - version = %PublishingVersion{data: %{"created_from" => 1, "notes" => "Bug fix"}} - - assert PublishingVersion.get_created_from(version) == 1 - assert PublishingVersion.get_notes(version) == "Bug fix" - end - - test "data JSONB accessors return nil for empty data" do - version = %PublishingVersion{data: %{}} - - assert PublishingVersion.get_created_from(version) == nil - assert PublishingVersion.get_notes(version) == nil - end - end - - # ============================================================================ - # PublishingContent - # ============================================================================ - - describe "PublishingContent" do - test "module is defined and loadable" do - assert Code.ensure_loaded?(PublishingContent) - end - - test "changeset validates required fields" do - changeset = PublishingContent.changeset(%PublishingContent{}, %{}) - refute changeset.valid? - - assert "can't be blank" in errors_on(changeset, :version_uuid) - assert "can't be blank" in errors_on(changeset, :language) - # title defaults to "" via default_if_nil, so it's not required - # status has default "draft" - end - - test "changeset validates status inclusion" do - changeset = - PublishingContent.changeset(%PublishingContent{}, %{ - version_uuid: UUIDv7.generate(), - language: "en", - title: "Test", - status: "invalid" - }) - - refute changeset.valid? - assert "is invalid" in errors_on(changeset, :status) - end - - test "changeset accepts valid content" do - changeset = - PublishingContent.changeset(%PublishingContent{}, %{ - version_uuid: UUIDv7.generate(), - language: "en", - title: "Test Post", - status: "draft", - content: "Hello world", - url_slug: "custom-url" - }) - - assert changeset.valid? - end - - test "data JSONB accessors return defaults" do - content = %PublishingContent{data: %{}} - - assert PublishingContent.get_description(content) == nil - assert PublishingContent.get_previous_url_slugs(content) == [] - assert PublishingContent.get_featured_image_uuid(content) == nil - assert PublishingContent.get_seo_title(content) == nil - assert PublishingContent.get_excerpt(content) == nil - assert PublishingContent.get_updated_by_uuid(content) == nil - end - - test "data JSONB accessors return custom values" do - content = %PublishingContent{ - data: %{ - "description" => "A test post", - "previous_url_slugs" => ["old-slug"], - "featured_image_uuid" => "img-456", - "seo_title" => "SEO Title", - "excerpt" => "Custom excerpt", - "updated_by_uuid" => "uuid-789" - } - } - - assert PublishingContent.get_description(content) == "A test post" - assert PublishingContent.get_previous_url_slugs(content) == ["old-slug"] - assert PublishingContent.get_featured_image_uuid(content) == "img-456" - assert PublishingContent.get_seo_title(content) == "SEO Title" - assert PublishingContent.get_excerpt(content) == "Custom excerpt" - assert PublishingContent.get_updated_by_uuid(content) == "uuid-789" - end - end - - # ============================================================================ - # Helpers - # ============================================================================ - - defp errors_on(changeset, field) do - changeset.errors - |> Keyword.get_values(field) - |> Enum.map(fn {msg, opts} -> - Regex.replace(~r/%{(\w+)}/, msg, fn _, key -> - opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() - end) - end) - end -end diff --git a/test/modules/publishing/shared_test.exs b/test/modules/publishing/shared_test.exs deleted file mode 100644 index e8cd84c3c..000000000 --- a/test/modules/publishing/shared_test.exs +++ /dev/null @@ -1,204 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.SharedTest do - use ExUnit.Case, async: true - - alias PhoenixKit.Modules.Publishing.Shared - - # ============================================================================ - # uuid_format?/1 - # ============================================================================ - - describe "uuid_format?/1" do - test "returns true for valid UUIDv7" do - assert Shared.uuid_format?("019cce93-ed2e-7e1b-9e62-af160709fd94") - end - - test "returns false for non-UUID string" do - refute Shared.uuid_format?("not-a-uuid") - refute Shared.uuid_format?("hello") - refute Shared.uuid_format?("") - end - - test "returns false for nil" do - refute Shared.uuid_format?(nil) - end - - test "returns false for non-string types" do - refute Shared.uuid_format?(123) - refute Shared.uuid_format?(:atom) - end - end - - # ============================================================================ - # fetch_option/2 - # ============================================================================ - - describe "fetch_option/2" do - test "fetches atom key from map" do - assert Shared.fetch_option(%{title: "Hello"}, :title) == "Hello" - end - - test "fetches string key from map as fallback" do - assert Shared.fetch_option(%{"title" => "Hello"}, :title) == "Hello" - end - - test "fetches from keyword list" do - assert Shared.fetch_option([title: "Hello"], :title) == "Hello" - end - - test "returns nil for missing key in map" do - assert Shared.fetch_option(%{other: "value"}, :title) == nil - end - - test "returns nil for missing key in keyword list" do - assert Shared.fetch_option([other: "value"], :title) == nil - end - - test "returns nil for non-map non-list" do - assert Shared.fetch_option("string", :title) == nil - assert Shared.fetch_option(nil, :title) == nil - assert Shared.fetch_option(123, :title) == nil - end - end - - # ============================================================================ - # parse_timestamp_path/1 - # ============================================================================ - - describe "parse_timestamp_path/1" do - test "parses date only" do - assert {:ok, ~D[2025-12-09], nil, nil, nil} = - Shared.parse_timestamp_path("2025-12-09") - end - - test "parses date and time" do - assert {:ok, ~D[2025-12-09], ~T[15:30:00], nil, nil} = - Shared.parse_timestamp_path("2025-12-09/15:30") - end - - test "parses date, time, and version" do - assert {:ok, ~D[2025-12-09], ~T[15:30:00], 2, nil} = - Shared.parse_timestamp_path("2025-12-09/15:30/v2") - end - - test "parses date, time, and language" do - assert {:ok, ~D[2025-12-09], ~T[15:30:00], nil, "en"} = - Shared.parse_timestamp_path("2025-12-09/15:30/en") - end - - test "parses date, time, version, and language" do - assert {:ok, ~D[2025-12-09], ~T[15:30:00], 3, "fr"} = - Shared.parse_timestamp_path("2025-12-09/15:30/v3/fr") - end - - test "strips leading slash" do - assert {:ok, ~D[2025-12-09], ~T[15:30:00], nil, nil} = - Shared.parse_timestamp_path("/2025-12-09/15:30") - end - - test "returns nil for non-date strings" do - assert Shared.parse_timestamp_path("not-a-date") == nil - assert Shared.parse_timestamp_path("hello/world") == nil - end - - test "returns nil for invalid date" do - assert Shared.parse_timestamp_path("2025-13-45") == nil - end - - test "returns nil for invalid time" do - assert Shared.parse_timestamp_path("2025-12-09/25:99") == nil - end - - test "returns nil for empty string" do - assert Shared.parse_timestamp_path("") == nil - end - end - - # ============================================================================ - # parse_time/1 - # ============================================================================ - - describe "parse_time/1" do - test "parses valid HH:MM time" do - assert {:ok, ~T[15:30:00]} = Shared.parse_time("15:30") - assert {:ok, ~T[00:00:00]} = Shared.parse_time("00:00") - assert {:ok, ~T[23:59:00]} = Shared.parse_time("23:59") - end - - test "returns error for invalid time" do - assert match?({:error, _}, Shared.parse_time("25:00")) - assert match?(:error, Shared.parse_time("abc")) - assert match?(:error, Shared.parse_time("")) - end - - test "returns error for non-string" do - assert match?(:error, Shared.parse_time(nil)) - assert match?(:error, Shared.parse_time(123)) - end - end - - # ============================================================================ - # extract_version_from_parts/1 - # ============================================================================ - - describe "extract_version_from_parts/1" do - test "extracts version from v-prefixed part" do - assert {1, ["en"]} = Shared.extract_version_from_parts(["v1", "en"]) - assert {42, []} = Shared.extract_version_from_parts(["v42"]) - end - - test "returns nil version for non-version parts" do - assert {nil, ["en"]} = Shared.extract_version_from_parts(["en"]) - assert {nil, ["slug"]} = Shared.extract_version_from_parts(["slug"]) - end - - test "handles empty list" do - assert {nil, []} = Shared.extract_version_from_parts([]) - end - end - - # ============================================================================ - # parse_version_segment/1 - # ============================================================================ - - describe "parse_version_segment/1" do - test "parses v-prefixed version numbers" do - assert {:ok, 1} = Shared.parse_version_segment("v1") - assert {:ok, 10} = Shared.parse_version_segment("v10") - assert {:ok, 999} = Shared.parse_version_segment("v999") - end - - test "returns error for non-version strings" do - assert :error = Shared.parse_version_segment("en") - assert :error = Shared.parse_version_segment("version1") - assert :error = Shared.parse_version_segment("v") - assert :error = Shared.parse_version_segment("") - end - - test "returns error for non-string" do - assert :error = Shared.parse_version_segment(nil) - assert :error = Shared.parse_version_segment(123) - end - end - - # ============================================================================ - # audit_metadata/2 - # ============================================================================ - - describe "audit_metadata/2" do - test "returns empty map for nil scope" do - assert Shared.audit_metadata(nil, :create) == %{} - assert Shared.audit_metadata(nil, :update) == %{} - end - end - - # ============================================================================ - # resolve_db_version/2 - # ============================================================================ - - describe "resolve_db_version/2" do - test "function exists and is callable" do - # Just verify the function is defined (actual DB calls tested in integration) - assert function_exported?(Shared, :resolve_db_version, 2) - end - end -end diff --git a/test/modules/publishing/translate_post_worker_test.exs b/test/modules/publishing/translate_post_worker_test.exs deleted file mode 100644 index 597616ed1..000000000 --- a/test/modules/publishing/translate_post_worker_test.exs +++ /dev/null @@ -1,43 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.TranslatePostWorkerTest do - use ExUnit.Case, async: true - - alias PhoenixKit.Modules.Publishing.Workers.TranslatePostWorker - - # ============================================================================ - # timeout/1 — Dynamic timeout scaling - # ============================================================================ - - describe "timeout/1" do - test "scales with number of target languages" do - job = build_job(%{"target_languages" => Enum.map(1..10, &"lang-#{&1}")}) - timeout_ms = TranslatePostWorker.timeout(job) - # 10 * 1.5 = 15 minutes - assert timeout_ms == :timer.minutes(15) - end - - test "uses minimum of 15 minutes for small language counts" do - job = build_job(%{"target_languages" => ["de", "fr"]}) - timeout_ms = TranslatePostWorker.timeout(job) - # 2 * 1.5 = 3, but min is 15 - assert timeout_ms == :timer.minutes(15) - end - - test "scales up for many languages" do - langs = Enum.map(1..39, &"lang-#{&1}") - job = build_job(%{"target_languages" => langs}) - timeout_ms = TranslatePostWorker.timeout(job) - # 39 * 1.5 = 58.5, ceil = 59 - assert timeout_ms == :timer.minutes(59) - end - - test "handles single language" do - job = build_job(%{"target_languages" => ["de"]}) - timeout_ms = TranslatePostWorker.timeout(job) - assert timeout_ms == :timer.minutes(15) - end - - defp build_job(args) do - %Oban.Job{args: args} - end - end -end diff --git a/test/modules/publishing/web/controller/listing_test.exs b/test/modules/publishing/web/controller/listing_test.exs deleted file mode 100644 index d48c40fef..000000000 --- a/test/modules/publishing/web/controller/listing_test.exs +++ /dev/null @@ -1,238 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.Web.Controller.ListingTest do - use ExUnit.Case, async: true - - alias PhoenixKit.Modules.Publishing.Web.Controller.Listing - - # ============================================================================ - # Test Data Helpers - # ============================================================================ - - defp build_post(attrs \\ %{}) do - %{ - slug: "test-post", - mode: "slug", - date: nil, - time: nil, - available_languages: ["en"], - language_statuses: %{"en" => "published"}, - language_titles: %{}, - language_excerpts: %{}, - metadata: %{ - title: "Test Post", - status: "published" - } - } - |> Map.merge(attrs) - end - - defp build_timestamp_post(date, time, attrs \\ %{}) do - build_post( - Map.merge( - %{ - mode: "timestamp", - date: date, - time: time - }, - attrs - ) - ) - end - - # ============================================================================ - # filter_published/1 - # ============================================================================ - - describe "filter_published/1" do - test "includes published posts" do - posts = [build_post()] - assert length(Listing.filter_published(posts)) == 1 - end - - test "excludes draft posts" do - posts = [build_post(%{metadata: %{title: "Draft", status: "draft"}})] - assert Listing.filter_published(posts) == [] - end - - test "excludes archived posts" do - posts = [build_post(%{metadata: %{title: "Archived", status: "archived"}})] - assert Listing.filter_published(posts) == [] - end - - test "excludes future timestamp posts" do - future = Date.add(Date.utc_today(), 30) - posts = [build_timestamp_post(future, ~T[12:00:00])] - assert Listing.filter_published(posts) == [] - end - - test "includes past timestamp posts" do - past = Date.add(Date.utc_today(), -30) - posts = [build_timestamp_post(past, ~T[12:00:00])] - assert length(Listing.filter_published(posts)) == 1 - end - - test "includes today's timestamp posts" do - posts = [build_timestamp_post(Date.utc_today(), ~T[12:00:00])] - assert length(Listing.filter_published(posts)) == 1 - end - - test "does not exclude future slug-mode posts" do - future = Date.add(Date.utc_today(), 30) - posts = [build_post(%{mode: "slug", date: future})] - assert length(Listing.filter_published(posts)) == 1 - end - - test "handles empty list" do - assert Listing.filter_published([]) == [] - end - end - - # ============================================================================ - # paginate/3 - # ============================================================================ - - describe "paginate/3" do - test "returns first page" do - posts = Enum.map(1..10, &build_post(%{slug: "post-#{&1}"})) - result = Listing.paginate(posts, 1, 3) - assert length(result) == 3 - assert hd(result).slug == "post-1" - end - - test "returns second page" do - posts = Enum.map(1..10, &build_post(%{slug: "post-#{&1}"})) - result = Listing.paginate(posts, 2, 3) - assert length(result) == 3 - assert hd(result).slug == "post-4" - end - - test "returns partial last page" do - posts = Enum.map(1..5, &build_post(%{slug: "post-#{&1}"})) - result = Listing.paginate(posts, 2, 3) - assert length(result) == 2 - end - - test "returns empty for page beyond range" do - posts = Enum.map(1..3, &build_post(%{slug: "post-#{&1}"})) - assert Listing.paginate(posts, 5, 3) == [] - end - - test "handles empty list" do - assert Listing.paginate([], 1, 10) == [] - end - end - - # ============================================================================ - # get_page_param/1 - # ============================================================================ - - describe "get_page_param/1" do - test "parses valid page string" do - assert Listing.get_page_param(%{"page" => "3"}) == 3 - end - - test "defaults to 1 when missing" do - assert Listing.get_page_param(%{}) == 1 - end - - test "defaults to 1 for zero" do - assert Listing.get_page_param(%{"page" => "0"}) == 1 - end - - test "defaults to 1 for negative" do - assert Listing.get_page_param(%{"page" => "-1"}) == 1 - end - - test "defaults to 1 for non-numeric" do - assert Listing.get_page_param(%{"page" => "abc"}) == 1 - end - - test "accepts integer page" do - assert Listing.get_page_param(%{"page" => 5}) == 5 - end - - test "defaults to 1 for zero integer" do - assert Listing.get_page_param(%{"page" => 0}) == 1 - end - end - - # ============================================================================ - # filter_by_exact_language/3 - # ============================================================================ - - describe "filter_by_exact_language/3" do - test "filters by exact language match" do - posts = [ - build_post(%{available_languages: ["en", "fr"]}), - build_post(%{slug: "only-fr", available_languages: ["fr"]}) - ] - - result = Listing.filter_by_exact_language(posts, "blog", "en") - assert length(result) == 1 - assert hd(result).slug == "test-post" - end - - test "returns empty when no posts match" do - posts = [build_post(%{available_languages: ["en"]})] - assert Listing.filter_by_exact_language(posts, "blog", "de") == [] - end - - test "handles empty posts list" do - assert Listing.filter_by_exact_language([], "blog", "en") == [] - end - end - - # ============================================================================ - # filter_by_exact_language_strict/2 - # ============================================================================ - - describe "filter_by_exact_language_strict/2" do - test "only matches exact language code" do - posts = [ - build_post(%{available_languages: ["en-US"]}), - build_post(%{slug: "en-post", available_languages: ["en"]}) - ] - - result = Listing.filter_by_exact_language_strict(posts, "en") - assert length(result) == 1 - assert hd(result).slug == "en-post" - end - - test "does not match base code for dialects" do - posts = [build_post(%{available_languages: ["en-US"]})] - assert Listing.filter_by_exact_language_strict(posts, "en") == [] - end - end - - # ============================================================================ - # find_matching_language/2 - # ============================================================================ - - describe "find_matching_language/2" do - test "direct match" do - assert Listing.find_matching_language("en", ["en", "fr"]) == "en" - end - - test "returns nil when no match" do - assert Listing.find_matching_language("de", ["en", "fr"]) == nil - end - - test "handles empty available languages" do - assert Listing.find_matching_language("en", []) == nil - end - end - - # ============================================================================ - # get_fallback_language/2 - # ============================================================================ - - describe "get_fallback_language/2" do - test "returns matching language from first post" do - posts = [build_post(%{available_languages: ["en", "fr"]})] - assert Listing.get_fallback_language("en", posts) == "en" - end - - test "returns requested language when no posts" do - assert Listing.get_fallback_language("de", []) == "de" - end - end -end diff --git a/test/modules/publishing/web/controller/routing_test.exs b/test/modules/publishing/web/controller/routing_test.exs deleted file mode 100644 index 23a315dd6..000000000 --- a/test/modules/publishing/web/controller/routing_test.exs +++ /dev/null @@ -1,154 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.Web.Controller.RoutingTest do - use ExUnit.Case, async: true - - alias PhoenixKit.Modules.Publishing.Web.Controller.Routing - - # ============================================================================ - # build_segments/1 - # ============================================================================ - - describe "build_segments/1" do - test "returns group only when no path" do - assert Routing.build_segments(%{"group" => "blog"}) == ["blog"] - end - - test "appends path list to group" do - params = %{"group" => "blog", "path" => ["2026-03-16", "14:30"]} - assert Routing.build_segments(params) == ["blog", "2026-03-16", "14:30"] - end - - test "wraps binary path as single segment" do - params = %{"group" => "docs", "path" => "getting-started"} - assert Routing.build_segments(params) == ["docs", "getting-started"] - end - - test "ignores non-list non-binary path" do - params = %{"group" => "blog", "path" => 42} - assert Routing.build_segments(params) == ["blog"] - end - - test "returns empty list when group missing" do - assert Routing.build_segments(%{"path" => ["foo"]}) == [] - assert Routing.build_segments(%{}) == [] - end - - test "returns empty list for non-map input" do - assert Routing.build_segments(nil) == [] - assert Routing.build_segments("string") == [] - end - end - - # ============================================================================ - # parse_path/1 - # ============================================================================ - - describe "parse_path/1" do - test "empty list returns error" do - assert Routing.parse_path([]) == {:error, :invalid_path} - end - - test "single segment returns listing" do - assert Routing.parse_path(["blog"]) == {:listing, "blog"} - end - - test "slug post" do - assert Routing.parse_path(["docs", "getting-started"]) == - {:slug_post, "docs", "getting-started"} - end - - test "timestamp post with date and time" do - assert Routing.parse_path(["blog", "2026-03-16", "14:30"]) == - {:timestamp_post, "blog", "2026-03-16", "14:30"} - end - - test "date-only post" do - assert Routing.parse_path(["blog", "2026-03-16"]) == - {:date_only_post, "blog", "2026-03-16"} - end - - test "versioned post" do - assert Routing.parse_path(["docs", "my-post", "v", "3"]) == - {:versioned_post, "docs", "my-post", 3} - end - - test "versioned post with invalid version" do - assert Routing.parse_path(["docs", "my-post", "v", "abc"]) == - {:error, :invalid_version} - end - - test "versioned post with zero version" do - assert Routing.parse_path(["docs", "my-post", "v", "0"]) == - {:error, :invalid_version} - end - - test "versioned post with negative version" do - assert Routing.parse_path(["docs", "my-post", "v", "-1"]) == - {:error, :invalid_version} - end - - test "two segments where first is date and second is not time" do - assert Routing.parse_path(["blog", "2026-03-16", "not-a-time"]) == - {:error, :invalid_path} - end - - test "too many segments" do - assert Routing.parse_path(["a", "b", "c", "d", "e"]) == {:error, :invalid_path} - end - end - - # ============================================================================ - # date?/1 - # ============================================================================ - - describe "date?/1" do - test "valid dates" do - assert Routing.date?("2026-01-01") - assert Routing.date?("2026-12-31") - assert Routing.date?("2000-06-15") - end - - test "invalid dates" do - refute Routing.date?("2026-13-01") - refute Routing.date?("2026-00-01") - refute Routing.date?("2026-01-32") - refute Routing.date?("2026-01-00") - refute Routing.date?("not-a-date") - refute Routing.date?("20260101") - refute Routing.date?("2026-1-1") - end - - test "non-string input" do - refute Routing.date?(nil) - refute Routing.date?(42) - refute Routing.date?(~D[2026-01-01]) - end - end - - # ============================================================================ - # time?/1 - # ============================================================================ - - describe "time?/1" do - test "valid times" do - assert Routing.time?("00:00") - assert Routing.time?("23:59") - assert Routing.time?("12:30") - assert Routing.time?("09:05") - end - - test "invalid times" do - refute Routing.time?("24:00") - refute Routing.time?("12:60") - refute Routing.time?("1:30") - refute Routing.time?("12:5") - refute Routing.time?("12:30:00") - refute Routing.time?("not-a-time") - end - - test "non-string input" do - refute Routing.time?(nil) - refute Routing.time?(42) - refute Routing.time?(~T[12:30:00]) - end - end -end diff --git a/test/phoenix_kit/module_discovery_test.exs b/test/phoenix_kit/module_discovery_test.exs index b7d9a9c11..b61234750 100644 --- a/test/phoenix_kit/module_discovery_test.exs +++ b/test/phoenix_kit/module_discovery_test.exs @@ -18,7 +18,6 @@ defmodule PhoenixKit.ModuleDiscoveryTest do test "does not include internal PhoenixKit modules" do modules = ModuleDiscovery.discover_external_modules() - refute PhoenixKit.Modules.AI in modules refute PhoenixKit.Modules.CustomerService in modules refute PhoenixKit.Modules.Billing in modules refute PhoenixKit.Jobs in modules diff --git a/test/phoenix_kit/module_registry_test.exs b/test/phoenix_kit/module_registry_test.exs index 6672c7f13..78a913331 100644 --- a/test/phoenix_kit/module_registry_test.exs +++ b/test/phoenix_kit/module_registry_test.exs @@ -3,7 +3,7 @@ defmodule PhoenixKit.ModuleRegistryTest do alias PhoenixKit.ModuleRegistry - # The registry is started in test_helper.exs with all 20 internal modules loaded. + # The registry is started in test_helper.exs with all 15 internal modules loaded. describe "all_modules/0" do test "returns a non-empty list" do @@ -12,9 +12,9 @@ defmodule PhoenixKit.ModuleRegistryTest do assert modules != [] end - test "contains all 20 internal modules" do + test "contains all 15 internal modules" do modules = ModuleRegistry.all_modules() - assert length(modules) >= 20 + assert length(modules) >= 15 end test "all entries are atoms" do @@ -25,10 +25,8 @@ defmodule PhoenixKit.ModuleRegistryTest do test "contains known internal modules" do modules = ModuleRegistry.all_modules() - assert PhoenixKit.Modules.AI in modules assert PhoenixKit.Modules.CustomerService in modules assert PhoenixKit.Modules.Billing in modules - assert PhoenixKit.Modules.Entities in modules assert PhoenixKit.Jobs in modules end @@ -96,7 +94,6 @@ defmodule PhoenixKit.ModuleRegistryTest do describe "get_by_key/1" do test "finds module by key string" do - assert ModuleRegistry.get_by_key("ai") == PhoenixKit.Modules.AI assert ModuleRegistry.get_by_key("customer_service") == PhoenixKit.Modules.CustomerService assert ModuleRegistry.get_by_key("billing") == PhoenixKit.Modules.Billing end @@ -125,7 +122,6 @@ defmodule PhoenixKit.ModuleRegistryTest do assert :admin_customer_service in tab_ids assert :admin_billing in tab_ids - assert :admin_entities in tab_ids end end @@ -144,7 +140,7 @@ defmodule PhoenixKit.ModuleRegistryTest do test "returns a list of permission metadata maps" do metadata = ModuleRegistry.all_permission_metadata() assert is_list(metadata) - assert length(metadata) >= 19 + assert length(metadata) >= 14 for meta <- metadata do assert is_map(meta) @@ -159,23 +155,20 @@ defmodule PhoenixKit.ModuleRegistryTest do keys = Enum.map(ModuleRegistry.all_permission_metadata(), & &1.key) assert "customer_service" in keys assert "billing" in keys - assert "ai" in keys - assert "entities" in keys assert "shop" in keys end end describe "all_feature_keys/0" do - test "returns sorted list of 19 feature keys" do + test "returns sorted list of 14 feature keys" do keys = ModuleRegistry.all_feature_keys() assert is_list(keys) - assert length(keys) == 19 + assert length(keys) == 14 assert keys == Enum.sort(keys) end test "contains expected keys" do keys = ModuleRegistry.all_feature_keys() - assert "ai" in keys assert "billing" in keys assert "shop" in keys assert "customer_service" in keys @@ -196,7 +189,7 @@ defmodule PhoenixKit.ModuleRegistryTest do test "returns a map of key => {module, :enabled?}" do checks = ModuleRegistry.feature_enabled_checks() assert is_map(checks) - assert map_size(checks) >= 19 + assert map_size(checks) >= 14 for {key, {mod, fun}} <- checks do assert is_binary(key) @@ -208,7 +201,6 @@ defmodule PhoenixKit.ModuleRegistryTest do test "maps known keys to correct modules" do checks = ModuleRegistry.feature_enabled_checks() assert checks["customer_service"] == {PhoenixKit.Modules.CustomerService, :enabled?} - assert checks["ai"] == {PhoenixKit.Modules.AI, :enabled?} assert checks["billing"] == {PhoenixKit.Modules.Billing, :enabled?} end end @@ -218,7 +210,6 @@ defmodule PhoenixKit.ModuleRegistryTest do labels = ModuleRegistry.permission_labels() assert is_map(labels) assert labels["customer_service"] == "Customer Service" - assert labels["ai"] == "AI" assert labels["shop"] == "E-Commerce" end end diff --git a/test/phoenix_kit/module_test.exs b/test/phoenix_kit/module_test.exs index a2e4e6004..02061e91e 100644 --- a/test/phoenix_kit/module_test.exs +++ b/test/phoenix_kit/module_test.exs @@ -4,18 +4,14 @@ defmodule PhoenixKit.ModuleTest do alias PhoenixKit.ModuleRegistry @all_internal_modules [ - PhoenixKit.Modules.AI, PhoenixKit.Modules.Billing, PhoenixKit.Modules.Comments, PhoenixKit.Modules.Connections, PhoenixKit.Modules.DB, - PhoenixKit.Modules.Emails, - PhoenixKit.Modules.Entities, PhoenixKit.Modules.Languages, PhoenixKit.Modules.Legal, PhoenixKit.Modules.Maintenance, PhoenixKit.Modules.Pages, - PhoenixKit.Modules.Publishing, PhoenixKit.Modules.Referrals, PhoenixKit.Modules.SEO, PhoenixKit.Modules.Shop, @@ -32,7 +28,7 @@ defmodule PhoenixKit.ModuleTest do :ok end - describe "all 20 modules implement PhoenixKit.Module behaviour" do + describe "all 15 modules implement PhoenixKit.Module behaviour" do test "all modules are loadable" do for mod <- @all_internal_modules do assert Code.ensure_loaded?(mod), "#{inspect(mod)} should be loadable" diff --git a/test/phoenix_kit/users/permissions_test.exs b/test/phoenix_kit/users/permissions_test.exs index 50c013c01..6e2b2d493 100644 --- a/test/phoenix_kit/users/permissions_test.exs +++ b/test/phoenix_kit/users/permissions_test.exs @@ -50,10 +50,6 @@ defmodule PhoenixKit.Users.PermissionsTest do assert is_list(keys) assert "billing" in keys assert "shop" in keys - assert "emails" in keys - assert "entities" in keys - assert "ai" in keys - assert length(keys) == 19 end test "does not include core keys" do @@ -70,8 +66,8 @@ defmodule PhoenixKit.Users.PermissionsTest do assert MapSet.new(all) == MapSet.new(expected) end - test "has 24 built-in keys" do - assert length(Permissions.all_module_keys()) == 24 + test "has 19 built-in keys" do + assert length(Permissions.all_module_keys()) == 19 end end @@ -105,7 +101,6 @@ defmodule PhoenixKit.Users.PermissionsTest do assert Permissions.module_label("dashboard") == "Dashboard" assert Permissions.module_label("users") == "Users" assert Permissions.module_label("shop") == "E-Commerce" - assert Permissions.module_label("ai") == "AI" assert Permissions.module_label("db") == "DB" end @@ -124,7 +119,6 @@ defmodule PhoenixKit.Users.PermissionsTest do test "returns correct icons for built-in keys" do assert Permissions.module_icon("dashboard") == "hero-home" assert Permissions.module_icon("users") == "hero-users" - assert Permissions.module_icon("ai") == "hero-sparkles" end test "returns default icon for unknown keys" do @@ -284,7 +278,7 @@ defmodule PhoenixKit.Users.PermissionsTest do assert Permissions.valid_module_key?("billing") end - test "returns true for all 24 built-in keys" do + test "returns true for all 19 built-in keys" do for key <- Permissions.core_section_keys() ++ Permissions.feature_module_keys() do assert Permissions.valid_module_key?(key), "Expected #{key} to be valid" end