diff --git a/.credo.exs b/.credo.exs index 57aa8acc2..0363cb5c7 100644 --- a/.credo.exs +++ b/.credo.exs @@ -35,7 +35,26 @@ ## Design Checks # {Credo.Check.Design.AliasUsage, - [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, + [ + priority: :low, + if_nested_deeper_than: 2, + if_called_more_often_than: 0, + excluded_namespaces: [ + # External optional modules — can't be aliased because they may not be installed + "PhoenixKitEntities", + "PhoenixKitAI", + "PhoenixKitPosts", + # Internal modules used behind Code.ensure_loaded? guards + "Igniter" + ], + excluded_lastnames: [ + # Extracted utility modules used with full paths for clarity + "Multilang", + "HtmlSanitizer", + # Used behind Code.ensure_loaded? in module enable/disable + "Registry" + ] + ]}, {Credo.Check.Design.TagTODO, [priority: :low]}, {Credo.Check.Design.TagFIXME, []}, diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs index d9dd5dd43..2ca332265 100644 --- a/.dialyzer_ignore.exs +++ b/.dialyzer_ignore.exs @@ -12,8 +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/entities/export.ex", :unknown_function}, - {"lib/mix/tasks/phoenix_kit/entities/import.ex", :unknown_function}, {"lib/mix/tasks/phoenix_kit.cleanup_orphaned_files.ex", :unknown_function}, # Mix.Task behaviour callbacks (expected in Mix tasks) @@ -28,15 +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/entities/export.ex", :callback_info_missing, 1}, - {"lib/mix/tasks/phoenix_kit/entities/import.ex", :callback_info_missing, 1}, {"lib/mix/tasks/phoenix_kit.cleanup_orphaned_files.ex", :callback_info_missing, 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}, + # 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/, @@ -49,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/, @@ -92,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 @@ -100,6 +87,11 @@ # 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 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/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/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 ` - - - diff --git a/lib/modules/ai/web/prompt_form.ex b/lib/modules/ai/web/prompt_form.ex deleted file mode 100644 index 3d8cba154..000000000 --- a/lib/modules/ai/web/prompt_form.ex +++ /dev/null @@ -1,130 +0,0 @@ -defmodule PhoenixKit.Modules.AI.Web.PromptForm do - @moduledoc """ - LiveView for creating and editing AI prompts. - - A prompt is a reusable text template with variable substitution support. - Variables use the `{{VariableName}}` syntax. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.AI - alias PhoenixKit.Modules.AI.Prompt - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Routes - - # =========================================== - # LIFECYCLE - # =========================================== - - @impl true - def mount(params, _session, socket) do - if AI.enabled?() do - project_title = Settings.get_project_title() - - socket = - socket - |> assign(:project_title, project_title) - |> assign(:current_path, Routes.path("/admin/ai")) - |> assign(:extracted_variables, []) - |> load_prompt(params["id"]) - - {:ok, socket} - else - {:ok, - socket - |> put_flash(:error, "AI module is not enabled") - |> push_navigate(to: Routes.path("/admin/modules"))} - end - end - - defp load_prompt(socket, nil) do - changeset = AI.change_prompt(%Prompt{}) - - socket - |> assign(:page_title, "New AI Prompt") - |> assign(:prompt, nil) - |> assign(:form, to_form(changeset)) - end - - defp load_prompt(socket, id) do - case AI.get_prompt(id) do - nil -> - socket - |> put_flash(:error, "Prompt not found") - |> push_navigate(to: Routes.ai_path() <> "/prompts") - - prompt -> - changeset = AI.change_prompt(prompt) - - socket - |> assign(:page_title, "Edit AI Prompt") - |> assign(:prompt, prompt) - |> assign(:form, to_form(changeset)) - |> assign(:extracted_variables, prompt.variables || []) - end - end - - @impl true - def handle_params(_params, _url, socket) do - {:noreply, socket} - end - - # =========================================== - # EVENT HANDLERS - # =========================================== - - @impl true - def handle_event("validate", %{"prompt" => params}, socket) do - changeset = - (socket.assigns.prompt || %Prompt{}) - |> AI.change_prompt(params) - - # Extract variables from content for preview - content = params["content"] || "" - extracted_variables = Prompt.extract_variables(content) - - socket = - socket - |> assign(:form, to_form(changeset)) - |> assign(:extracted_variables, extracted_variables) - - {:noreply, socket} - end - - @impl true - def handle_event("save", %{"prompt" => params}, socket) do - save_prompt(socket, params) - end - - # =========================================== - # PRIVATE HELPERS - # =========================================== - - defp save_prompt(socket, params) do - result = - if socket.assigns.prompt do - AI.update_prompt(socket.assigns.prompt, params) - else - AI.create_prompt(params) - end - - case result do - {:ok, _prompt} -> - action = if socket.assigns.prompt, do: "updated", else: "created" - - {:noreply, - socket - |> put_flash(:info, "Prompt #{action} successfully") - |> push_navigate(to: Routes.ai_path() <> "/prompts")} - - {:error, changeset} -> - {:noreply, assign(socket, :form, to_form(changeset))} - end - rescue - e -> - require Logger - Logger.error("Prompt save failed: #{Exception.message(e)}") - {:noreply, put_flash(socket, :error, gettext("Something went wrong. Please try again."))} - end -end diff --git a/lib/modules/ai/web/prompt_form.html.heex b/lib/modules/ai/web/prompt_form.html.heex deleted file mode 100644 index 57eba2a94..000000000 --- a/lib/modules/ai/web/prompt_form.html.heex +++ /dev/null @@ -1,217 +0,0 @@ - -
- <.admin_page_header back={PhoenixKit.Utils.Routes.ai_path() <> "/prompts"}> -

{@page_title}

-

- Create reusable prompts with variable substitution -

- - - <%!-- Form Content (constrained width) --%> -
- <%!-- Form --%> -
-
- <.form for={@form} phx-change="validate" phx-submit="save" class="space-y-5"> - <%!-- Name --%> -
- - - <%= if @form.errors[:name] do %> -

{elem(@form.errors[:name], 0)}

- <% end %> -
- - <%!-- Slug --%> -
- - -
- - <%!-- Description --%> -
- - -
- - <%!-- System Prompt --%> -
- - -

- Sent as the system message before the user prompt. Supports {"{{variables}}"} too. -

- <%= if @form.errors[:system_prompt] do %> -

{elem(@form.errors[:system_prompt], 0)}

- <% end %> -
- - <%!-- Content --%> -
- - - <%= if @form.errors[:content] do %> -

{elem(@form.errors[:content], 0)}

- <% end %> -
- - <%!-- Extracted Variables Preview --%> - <%= if length(@extracted_variables) > 0 do %> -
- <.icon name="hero-variable" class="w-5 h-5" /> -
-
Detected Variables
-
- <%= for var <- @extracted_variables do %> - - {"{{#{var}}}"} - - <% end %> -
-
-
- <% end %> - - <%!-- Enabled Toggle --%> -
- -

- Disabled prompts cannot be used via the API -

-
- - <%!-- Actions --%> -
- <.link - navigate={PhoenixKit.Utils.Routes.ai_path() <> "/prompts"} - class="btn btn-ghost" - > - Cancel - - -
- -
-
- - <%!-- Help Section --%> -
-
-

- <.icon name="hero-question-mark-circle" class="w-5 h-5" /> Variable Syntax -

-
-
-

Use double curly braces for variables:

-
- Translate to {"{{Language}}"}:
- {"{{Text}}"} -
-
- -
-

Naming rules:

-
    -
  • - Use letters, numbers, and underscores only — - no spaces -
  • -
  • - {"{{UserLanguage}}"} - or {"{{user_language}}"} - — both work -
  • -
  • - {"{{User Language}}"} - — won't be detected as a variable -
  • -
-
- -
-

Missing variables:

-

- If a variable isn't provided when the prompt is used, it stays as-is in the output - (e.g., {"{{Language}}"} - remains unchanged). -

-
-
-
-
-
-
-
diff --git a/lib/modules/ai/web/prompts.ex b/lib/modules/ai/web/prompts.ex deleted file mode 100644 index 59626c07a..000000000 --- a/lib/modules/ai/web/prompts.ex +++ /dev/null @@ -1,242 +0,0 @@ -defmodule PhoenixKit.Modules.AI.Web.Prompts do - @moduledoc """ - LiveView for AI prompts management. - - This module provides an interface for managing reusable AI prompt templates - with variable substitution support. - - ## Features - - - **Prompt Management**: Add, edit, delete, enable/disable AI prompts - - **Variable Display**: Shows extracted variables from prompt content - - **Usage Tracking**: View usage count and last used time - - ## Route - - This LiveView is mounted at `{prefix}/admin/ai/prompts` and requires - appropriate admin permissions. - """ - - use PhoenixKitWeb, :live_view - use Gettext, backend: PhoenixKitWeb.Gettext - - alias PhoenixKit.Modules.AI - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Routes - - @sort_options [ - {:sort_order, "Order"}, - {:name, "Name"}, - {:usage_count, "Usage"}, - {:last_used_at, "Last Used"}, - {:inserted_at, "Created"} - ] - - @page_size 20 - - @impl true - def mount(_params, session, socket) do - current_path = get_current_path(socket, session) - project_title = Settings.get_project_title() - - # Subscribe to real-time updates - if connected?(socket) do - AI.subscribe_prompts() - end - - socket = - socket - |> assign(:current_path, current_path) - |> assign(:page_title, "AI Prompts") - |> assign(:project_title, project_title) - |> assign(:prompts, []) - |> assign(:sort_by, :sort_order) - |> assign(:sort_dir, :asc) - |> assign(:sort_options, @sort_options) - |> assign(:page, 1) - |> assign(:page_size, @page_size) - |> assign(:total_prompts, 0) - - {:ok, socket} - end - - @impl true - def handle_params(params, uri, socket) do - {sort_by, sort_dir, page} = parse_sort_params(params) - current_path = URI.parse(uri).path - - socket = - socket - |> assign(:sort_by, sort_by) - |> assign(:sort_dir, sort_dir) - |> assign(:page, page) - |> assign(:current_path, current_path) - |> reload_prompts() - - {:noreply, socket} - end - - @valid_sort_fields Enum.map(@sort_options, fn {field, _} -> Atom.to_string(field) end) - - defp parse_sort_params(params) do - { - parse_sort_field(params["sort"], @valid_sort_fields, :sort_order), - parse_sort_dir(params["dir"]), - parse_page(params["page"]) - } - end - - defp parse_sort_field(field, valid_fields, default) when is_binary(field) do - if field in valid_fields, do: String.to_existing_atom(field), else: default - end - - defp parse_sort_field(_, _valid_fields, default), do: default - - defp parse_sort_dir("asc"), do: :asc - defp parse_sort_dir("desc"), do: :desc - defp parse_sort_dir(_), do: :asc - - defp parse_page(nil), do: 1 - defp parse_page(""), do: 1 - - defp parse_page(p) when is_binary(p) do - case Integer.parse(p) do - {n, ""} when n > 0 -> n - _ -> 1 - end - end - - defp parse_page(_), do: 1 - - # =========================================== - # PROMPT ACTIONS - # =========================================== - - @impl true - def handle_event("toggle_prompt", %{"uuid" => uuid}, socket) do - prompt = AI.get_prompt!(uuid) - - case AI.update_prompt(prompt, %{enabled: !prompt.enabled}) do - {:ok, _updated} -> - {:noreply, - socket - |> reload_prompts() - |> put_flash(:info, "Prompt #{if prompt.enabled, do: "disabled", else: "enabled"}")} - - {:error, _changeset} -> - {:noreply, put_flash(socket, :error, "Failed to update prompt")} - end - end - - @impl true - def handle_event("delete_prompt", %{"uuid" => uuid}, socket) do - prompt = AI.get_prompt!(uuid) - - case AI.delete_prompt(prompt) do - {:ok, _} -> - {:noreply, - socket - |> reload_prompts() - |> put_flash(:info, "Prompt deleted")} - - {:error, _} -> - {:noreply, put_flash(socket, :error, "Failed to delete prompt")} - end - end - - @impl true - def handle_event("sort", %{"by" => field}, socket) do - # Validate field before converting to atom to prevent crashes from malicious input - field = - if field in @valid_sort_fields do - String.to_existing_atom(field) - else - :sort_order - end - - current_sort_by = socket.assigns.sort_by - current_sort_dir = socket.assigns.sort_dir - - # Toggle direction if same field, otherwise default to desc for usage/last_used, asc for others - sort_dir = - if field == current_sort_by do - if current_sort_dir == :asc, do: :desc, else: :asc - else - if field in [:usage_count, :last_used_at, :inserted_at], do: :desc, else: :asc - end - - # Reset to page 1 when sorting changes - path = Routes.ai_path() <> "/prompts?sort=#{field}&dir=#{sort_dir}" - {:noreply, push_patch(socket, to: path)} - end - - @impl true - def handle_event("goto_page", %{"page" => page_str}, socket) do - case Integer.parse(page_str) do - {page, ""} when page > 0 -> - sort_by = socket.assigns.sort_by - sort_dir = socket.assigns.sort_dir - - path = build_prompts_url(sort_by, sort_dir, page) - {:noreply, push_patch(socket, to: path)} - - _ -> - {:noreply, socket} - end - end - - # =========================================== - # PUBSUB HANDLERS - Real-time updates - # =========================================== - - @impl true - def handle_info({event, _prompt}, socket) - when event in [:prompt_created, :prompt_updated, :prompt_deleted] do - # Reload prompts list when any prompt changes - {:noreply, reload_prompts(socket)} - end - - # Catch-all for other PubSub messages - @impl true - def handle_info(_msg, socket), do: {:noreply, socket} - - # =========================================== - # PRIVATE HELPERS - # =========================================== - - defp reload_prompts(socket) do - sort_by = socket.assigns.sort_by - sort_dir = socket.assigns.sort_dir - page = socket.assigns.page - page_size = socket.assigns.page_size - - {prompts, total} = - AI.list_prompts( - sort_by: sort_by, - sort_dir: sort_dir, - page: page, - page_size: page_size - ) - - socket - |> assign(:prompts, prompts) - |> assign(:total_prompts, total) - end - - defp build_prompts_url(sort_by, sort_dir, page) do - base = Routes.ai_path() <> "/prompts?sort=#{sort_by}&dir=#{sort_dir}" - - if page > 1 do - base <> "&page=#{page}" - else - base - end - end - - defp get_current_path(socket, session) do - case socket.assigns do - %{__changed__: _, current_path: path} when is_binary(path) -> path - _ -> session["current_path"] || Routes.ai_path() <> "/prompts" - end - end -end diff --git a/lib/modules/ai/web/prompts.html.heex b/lib/modules/ai/web/prompts.html.heex deleted file mode 100644 index 56547e5ce..000000000 --- a/lib/modules/ai/web/prompts.html.heex +++ /dev/null @@ -1,222 +0,0 @@ - -
- <.admin_page_header - back={PhoenixKit.Utils.Routes.path("/admin")} - title="AI Prompts" - subtitle="Reusable prompt templates with variable substitution" - /> - - <%!-- Controls --%> -
-
- <%!-- Tabs --%> -
- <.link - navigate={PhoenixKit.Utils.Routes.ai_path() <> "/endpoints"} - class="tab" - > - <.icon name="hero-server-stack" class="w-4 h-4 mr-2" /> Endpoints - - <.link - navigate={PhoenixKit.Utils.Routes.ai_path() <> "/prompts"} - class="tab tab-active" - > - <.icon name="hero-document-text" class="w-4 h-4 mr-2" /> Prompts - - <.link - navigate={PhoenixKit.Utils.Routes.ai_path() <> "/playground"} - class="tab" - > - <.icon name="hero-beaker" class="w-4 h-4 mr-2" /> Playground - - <.link - navigate={PhoenixKit.Utils.Routes.ai_path() <> "/usage"} - class="tab" - > - <.icon name="hero-chart-bar" class="w-4 h-4 mr-2" /> Usage - -
- - <%!-- Quick Actions --%> - <.link - navigate={PhoenixKit.Utils.Routes.ai_path() <> "/prompts/new"} - class="btn btn-primary" - > - <.icon name="hero-plus" class="w-5 h-5 mr-2" /> New Prompt - -
-
- - <%!-- Content --%> - <%= if Enum.empty?(@prompts) do %> - <%!-- Empty State --%> -
-
- <.icon name="hero-document-text" class="w-16 h-16 text-base-content/30" /> -

No Prompts Yet

-

- Create reusable prompt templates with variable substitution. - Use {"{{VariableName}}"} - syntax for dynamic content. -

- <.link - navigate={PhoenixKit.Utils.Routes.ai_path() <> "/prompts/new"} - class="btn btn-primary mt-4" - > - <.icon name="hero-plus" class="w-5 h-5 mr-2" /> Create First Prompt - -
-
- <% else %> - <%!-- Sort Controls --%> -
- Sort by: - <%= for {field, label} <- @sort_options do %> - - <% end %> -
- - <%!-- Prompts Grid --%> -
- <%= for prompt <- @prompts do %> -
-
-
-
-
- <.link - navigate={PhoenixKit.Utils.Routes.ai_path() <> "/prompts/#{prompt.uuid}/edit"} - class="font-semibold text-lg hover:text-primary hover:underline" - > - {prompt.name} - - <.enabled_badge enabled={prompt.enabled} /> - {prompt.slug} -
- - <%!-- Variables --%> - <%= if prompt.variables && length(prompt.variables) > 0 do %> -
- Variables: - <%= for var <- prompt.variables do %> - - {"{{#{var}}}"} - - <% end %> -
- <% end %> - - <%!-- Content Preview --%> -
- {PhoenixKit.Modules.AI.Prompt.content_preview(prompt.content)} -
- - <%!-- Usage Stats --%> -
-
- <.icon name="hero-arrow-path" class="w-4 h-4" /> - {prompt.usage_count} uses -
-
- <.icon name="hero-clock" class="w-4 h-4" /> - - <%= if prompt.last_used_at do %> - <.time_ago datetime={prompt.last_used_at} /> - <% else %> - Never used - <% end %> - -
-
- <.icon name="hero-calendar" class="w-4 h-4" /> - - <.time_ago datetime={prompt.inserted_at} /> - -
-
- - <%= if prompt.description do %> -

{prompt.description}

- <% end %> -
- - <%!-- Actions --%> -
- <.link - navigate={PhoenixKit.Utils.Routes.ai_path() <> "/prompts/#{prompt.uuid}/edit"} - class="btn btn-xs btn-outline btn-info tooltip tooltip-bottom" - data-tip={gettext("Edit")} - > - <.icon name="hero-pencil" class="w-4 h-4 hidden sm:inline" /> - {gettext("Edit")} - - - -
-
-
-
- <% end %> -
- - <%!-- Pagination --%> - <% total_pages = ceil(@total_prompts / @page_size) %> - <%= if total_pages > 1 do %> -
-
- <%= for page_num <- 1..total_pages do %> - - <% end %> -
-
- <% end %> - <% end %> -
-
diff --git a/lib/modules/entities/DEEP_DIVE.md b/lib/modules/entities/DEEP_DIVE.md deleted file mode 100644 index 8e59f4f93..000000000 --- a/lib/modules/entities/DEEP_DIVE.md +++ /dev/null @@ -1,1943 +0,0 @@ -# PhoenixKit Entities System – Deep Dive - -**Dynamic Content Types for Elixir/Phoenix** - -> Looking for the summary? Start with `OVERVIEW.md` in this directory. This deep dive captures the architecture, rationale, and implementation details behind the feature. - -## Table of Contents - -1. [Overview](#overview) -2. [Architecture](#architecture) -3. [Database Schema](#database-schema) -4. [Field Types System](#field-types-system) -5. [Core Modules](#core-modules) -6. [Admin Interfaces](#admin-interfaces) -7. [Public Form Builder](#public-form-builder) -8. [HTML Sanitization](#html-sanitization) -9. [Real-Time Collaboration](#real-time-collaboration) -10. [Multi-Language Support](#multi-language-support) -11. [Usage Examples](#usage-examples) -12. [Implementation Details](#implementation-details) -13. [Settings Integration](#settings-integration) - ---- - -## Overview - -The PhoenixKit Entities System is a dynamic content type management system. It allows administrators to create custom content types (entities) with flexible field schemas without writing code or running database migrations. - -### Key Features - -- **Dynamic Schema Creation**: Create custom content types with flexible field definitions stored as JSONB -- **12 Field Types**: Comprehensive field type support including text, textarea, email, url, number, boolean, date, select, radio, checkbox, rich text, and file. *(Image and relation fields exist in the form builder as placeholders but are not registered in FieldTypes.)* -- **Admin Interfaces**: Complete CRUD interfaces for both entity definitions and entity data -- **Dynamic Form Generation**: Forms automatically generated from entity field definitions -- **System-Wide Toggle**: Enable/disable the entire entities system via Settings -- **Status Workflow**: Draft → Published → Archived status for both entities and data records -- **Field Validation**: Comprehensive validation including unique field key enforcement -- **Performance Trade-off**: Accepts 1.5-2x performance cost for schema flexibility using PostgreSQL JSONB - -### Use Cases - -- **Blog Posts**: Title, content, excerpt, category, featured image, publish date -- **Products**: Name, price, description, SKU, images, variants -- **Team Members**: Name, role, bio, photo, social links -- **Events**: Title, date, location, description, registration link -- **Any Structured Content**: Create custom content types for any business need - ---- - -## Architecture - -### Two-Table Design - -The system uses a two-table architecture that separates entity definitions (blueprints) from actual data records: - -``` -┌─────────────────────────────┐ -│ phoenix_kit_entities │ (Entity Definitions) -│ - Content type blueprints │ -│ - Field definitions (JSONB)│ -│ - Settings (JSONB) │ -└──────────────┬──────────────┘ - │ 1:N - │ -┌──────────────▼──────────────┐ -│ phoenix_kit_entity_data │ (Entity Data Records) -│ - Actual data records │ -│ - Field values (JSONB) │ -│ - Metadata (JSONB) │ -└─────────────────────────────┘ -``` - -### Why JSONB? - -**Advantages:** -- **Schema Flexibility**: Create new content types without migrations -- **Rapid Development**: No code changes needed for new field types -- **Dynamic Forms**: Forms generated at runtime from definitions -- **PostgreSQL Native**: Leverages PostgreSQL's powerful JSONB support - -**Trade-offs:** -- **Performance**: 1.5-2x slower than normalized tables (acceptable for admin interfaces) -- **No Foreign Keys**: Field-level relationships require application-level enforcement -- **Indexing Limitations**: Complex queries on JSONB fields can be slower - -**Benchmark Data** (referenced during design): -- Normalized schema: ~2000 inserts/sec -- JSONB schema: ~1200 inserts/sec -- Read performance: Similar with proper indexing - ---- - -## Database Schema - -### Migration: V17 - -**File**: `lib/phoenix_kit/migrations/postgres/v17.ex` - -### phoenix_kit_entities (Entity Definitions) - -Stores content type blueprints with field definitions. - -| Column | Type | Description | -|---------------------|-------------------|--------------------------------------------------| -| `uuid` | UUIDv7 | Primary key | -| `name` | string | Unique technical identifier (snake_case) | -| `display_name` | string | Human-readable name for UI | -| `description` | text | Description of what this entity represents | -| `icon` | string | Heroicon name for UI display | -| `status` | string | draft / published / archived | -| `fields_definition` | jsonb | Array of field definitions | -| `settings` | jsonb | Entity-specific settings | -| `created_by_uuid` | UUIDv7 | UUID of creator | -| `date_created` | utc_datetime | Creation timestamp | -| `date_updated` | utc_datetime | Last update timestamp | - -**Indexes:** -- Unique index on `name` -- Index on `created_by_uuid` -- Index on `status` - -**Example Entity Record:** - -```elixir -%PhoenixKit.Modules.Entities{ - uuid: "018f1234-5678-7890-abcd-ef1234567890", - name: "blog_post", - display_name: "Blog Post", - description: "Blog post content type with rich text support", - icon: "hero-document-text", - status: "published", - fields_definition: [ - %{ - "type" => "text", - "key" => "title", - "label" => "Title", - "required" => true - }, - %{ - "type" => "rich_text", - "key" => "content", - "label" => "Content", - "required" => true - }, - %{ - "type" => "select", - "key" => "category", - "label" => "Category", - "required" => false, - "options" => ["Tech", "Business", "Lifestyle"] - } - ], - created_by_uuid: "018f0000-0000-7000-8000-000000000001", - date_created: ~U[2025-01-15 10:30:00.000000Z], - date_updated: ~U[2025-01-15 10:30:00.000000Z] -} -``` - -### phoenix_kit_entity_data (Data Records) - -Stores actual content records based on entity blueprints. - -| Column | Type | Description | -|------------------|-------------------|--------------------------------------------------| -| `uuid` | UUIDv7 | Primary key | -| `entity_uuid` | UUIDv7 | Foreign key to phoenix_kit_entities | -| `title` | string | Record title (duplicated for indexing) | -| `slug` | string | URL-friendly identifier | -| `status` | string | draft / published / archived | -| `data` | jsonb | All field values as key-value pairs | -| `metadata` | jsonb | Additional metadata (tags, categories, etc.) | -| `created_by_uuid`| UUIDv7 | UUID of creator | -| `date_created` | utc_datetime | Creation timestamp | -| `date_updated` | utc_datetime | Last update timestamp | - -**Indexes:** -- Index on `entity_uuid` -- Index on `slug` -- Index on `status` -- Index on `created_by_uuid` -- Index on `title` - -**Foreign Key:** -- `entity_uuid` references `phoenix_kit_entities(uuid)` with `on_delete: :delete_all` - -**Example Data Record:** - -```elixir -%PhoenixKit.Modules.Entities.EntityData{ - uuid: "018f2345-6789-7890-abcd-ef2345678901", - entity_uuid: "018f1234-5678-7890-abcd-ef1234567890", - title: "Getting Started with PhoenixKit", - slug: "getting-started-with-phoenixkit", - status: "published", - data: %{ - "title" => "Getting Started with PhoenixKit", - "content" => "

Welcome to PhoenixKit...

", - "category" => "Tech" - }, - metadata: %{ - "tags" => ["tutorial", "beginner"], - "featured" => true - }, - created_by_uuid: "018f0000-0000-7000-8000-000000000001", - date_created: ~U[2025-01-15 11:00:00.000000Z], - date_updated: ~U[2025-01-15 11:00:00.000000Z] -} -``` - ---- - -## Field Types System - -**File**: `lib/modules/entities/field_types.ex` - -The system supports 11 fully functional field types organized into 5 categories, plus 3 placeholder types for future implementation: - -### Basic Fields - -| Type | Label | Description | Requires Options | -|--------------|--------------------| --------------------------------------|------------------| -| `text` | Text | Single-line text input | No | -| `textarea` | Text Area | Multi-line text input | No | -| `email` | Email | Email address input with validation | No | -| `url` | URL | URL input with validation | No | -| `rich_text` | Rich Text Editor | WYSIWYG editor for formatted content | No | - -### Numeric Fields - -| Type | Label | Description | Requires Options | -|--------------|--------------------| --------------------------------------|------------------| -| `number` | Number | Numeric input (integer or decimal) | No | - -### Boolean Fields - -| Type | Label | Description | Requires Options | -|--------------|--------------------| --------------------------------------|------------------| -| `boolean` | Boolean | True/false toggle or checkbox | No | - -### Date & Time Fields - -| Type | Label | Description | Requires Options | -|--------------|--------------------| --------------------------------------|------------------| -| `date` | Date | Date picker | No | - -### Choice Fields - -| Type | Label | Description | Requires Options | -|--------------|--------------------| --------------------------------------|------------------| -| `select` | Select Dropdown | Single choice from dropdown | **Yes** | -| `radio` | Radio Buttons | Single choice from radio buttons | **Yes** | -| `checkbox` | Checkboxes | Multiple choices from checkboxes | **Yes** | - -### Media Fields - -| Type | Label | Description | Requires Options | Status | -|--------------|--------------------| --------------------------------------|------------------|--------| -| `file` | File Upload | File upload with configurable constraints | No | **Registered** | -| `image` | Image Upload | Image file upload | No | Placeholder UI | - -> **Note**: `file` is fully registered in `FieldTypes` and can be created via `file_field/3`. `image` is defined in the form builder schema but renders a "Coming Soon" placeholder — no actual image upload functionality is implemented yet. - -### Relational Fields *(Coming Soon)* - -| Type | Label | Description | Requires Options | Status | -|--------------|--------------------| --------------------------------------|------------------|--------| -| `relation` | Relation | Relationship to other entity records | **Yes** | Placeholder UI | - -> **Note**: Relation fields are defined in the schema but render "Coming Soon" placeholders. The `entities_allow_relations` setting exists but is not yet enforced. - -### Field Definition Structure - -Each field in `fields_definition` is a map with the following structure: - -```elixir -%{ - "type" => "text", # Field type (required) - "key" => "field_name", # Unique identifier (required, snake_case) - "label" => "Field Name", # Display label (required) - "required" => true, # Whether field is required (optional, default: false) - "default" => "default value", # Default value (optional) - "options" => ["Option 1", "Option 2"] # Options for choice fields (required for select/radio/checkbox; relation will also require options once implemented) -} -``` - -### Field Validation - -The `FieldTypes.validate_field/1` function validates: - -1. **Required Keys**: `type`, `key`, `label` must be present -2. **Valid Type**: Type must be one of the 11 registered types (image/file/relation are not in the registry) -3. **Options Presence**: Choice fields (select/radio/checkbox) must have options array -4. **Options Content**: Options must be non-empty for fields that require them -5. **Unique Keys**: Field keys must be unique within an entity (enforced at LiveView level) - -> **Note**: The form builder renders placeholder UI for image/file/relation types, but `FieldTypes.valid_type?/1` will reject them since they're not in the registry. - -**Validation Examples:** - -```elixir -# Valid field -{:ok, validated_field} = FieldTypes.validate_field(%{ - "type" => "text", - "key" => "title", - "label" => "Title", - "required" => true -}) - -# Missing required key -{:error, "Field missing required keys: type"} = FieldTypes.validate_field(%{ - "key" => "title", - "label" => "Title" -}) - -# Invalid type -{:error, "Invalid field type 'invalid_type'"} = FieldTypes.validate_field(%{ - "type" => "invalid_type", - "key" => "title", - "label" => "Title" -}) - -# Select without options -{:error, "Field type 'select' requires options array"} = FieldTypes.validate_field(%{ - "type" => "select", - "key" => "category", - "label" => "Category" -}) - -# Duplicate field key (LiveView validation) -{:error, "Field key 'title' already exists. Please use a unique key."} = - validate_unique_field_key(field_params, existing_fields, editing_index) -``` - ---- - -## Core Modules - -### 1. PhoenixKit.Modules.Entities - -**File**: `lib/modules/entities/entities.ex` - -Main module for entity management with both Ecto schema and business logic. - -**Key Functions:** - -```elixir -# List all entities -PhoenixKit.Modules.Entities.list_entities() -# => [%PhoenixKit.Modules.Entities{}, ...] - -# List only published entities -PhoenixKit.Modules.Entities.list_active_entities() -# => [%PhoenixKit.Modules.Entities{status: "published"}, ...] - -# Get entity by UUID (raises if not found) -PhoenixKit.Modules.Entities.get_entity!("018f1234-5678-7890-abcd-ef1234567890") -# => %PhoenixKit.Modules.Entities{} - -# Get entity by UUID (returns nil if not found) -PhoenixKit.Modules.Entities.get_entity("018f1234-5678-7890-abcd-ef1234567890") -# => %PhoenixKit.Modules.Entities{} | nil - -# Get entity by unique name -PhoenixKit.Modules.Entities.get_entity_by_name("blog_post") -# => %PhoenixKit.Modules.Entities{} - -# Create entity -# Note: created_by_uuid is optional - it auto-fills with first admin user if not provided -PhoenixKit.Modules.Entities.create_entity(%{ - name: "blog_post", - display_name: "Blog Post", - description: "Blog post content type", - icon: "hero-document-text", - status: "draft", - # created_by_uuid: user_uuid, # Optional! Auto-filled if omitted - fields_definition: [...] -}) -# => {:ok, %PhoenixKit.Modules.Entities{}} - -# Update entity -PhoenixKit.Modules.Entities.update_entity(entity, %{status: "published"}) -# => {:ok, %PhoenixKit.Modules.Entities{}} - -# Delete entity (also deletes all associated data) -PhoenixKit.Modules.Entities.delete_entity(entity) -# => {:ok, %PhoenixKit.Modules.Entities{}} - -# Get changeset for forms -PhoenixKit.Modules.Entities.change_entity(entity, attrs) -# => %Ecto.Changeset{} - -# System stats -PhoenixKit.Modules.Entities.get_system_stats() -# => %{total_entities: 5, active_entities: 4, total_data_records: 150} - -# Check if enabled -PhoenixKit.Modules.Entities.enabled?() -# => true - -# Enable/disable system -PhoenixKit.Modules.Entities.enable_system() -PhoenixKit.Modules.Entities.disable_system() -``` - -**Validations:** - -- **Name**: 2-50 characters, snake_case, unique -- **Display Name**: 2-100 characters -- **Description**: Max 500 characters -- **Status**: Must be "draft", "published", or "archived" -- **Fields Definition**: Must be valid array of field definitions -- **Timestamps**: Auto-set on create/update - -### 2. PhoenixKit.Modules.Entities.EntityData - -**File**: `lib/modules/entities/entity_data.ex` - -Module for entity data records with dynamic validation. - -**Key Functions:** - -```elixir -# List all data for an entity -PhoenixKit.Modules.Entities.EntityData.list_by_entity(entity_uuid) -# => [%PhoenixKit.Modules.Entities.EntityData{}, ...] - -# List all data across all entities -PhoenixKit.Modules.Entities.EntityData.list_all() -# => [%PhoenixKit.Modules.Entities.EntityData{}, ...] - -# Get data record by UUID (raises if not found) -PhoenixKit.Modules.Entities.EntityData.get!(uuid) -# => %PhoenixKit.Modules.Entities.EntityData{} - -# Get data record by UUID (returns nil if not found) -PhoenixKit.Modules.Entities.EntityData.get(uuid) -# => %PhoenixKit.Modules.Entities.EntityData{} | nil - -# Create data record -# Note: created_by_uuid is optional - it auto-fills with first admin user if not provided -PhoenixKit.Modules.Entities.EntityData.create(%{ - entity_uuid: "018f1234-5678-7890-abcd-ef1234567890", - title: "My First Post", - slug: "my-first-post", - status: "draft", - data: %{"title" => "My First Post", "content" => "..."} - # created_by_uuid: user_uuid # Optional! Auto-filled if omitted -}) -# => {:ok, %PhoenixKit.Modules.Entities.EntityData{}} - -# Update data record -PhoenixKit.Modules.Entities.EntityData.update(data_record, %{status: "published"}) -# => {:ok, %PhoenixKit.Modules.Entities.EntityData{}} - -# Delete data record -PhoenixKit.Modules.Entities.EntityData.delete(data_record) -# => {:ok, %PhoenixKit.Modules.Entities.EntityData{}} - -# Get changeset -PhoenixKit.Modules.Entities.EntityData.change(data_record, attrs) -# => %Ecto.Changeset{} -``` - -**Dynamic Validation:** - -The `validate_data_against_entity/1` function validates data records against their entity's field definitions: - -1. **Required Fields**: Ensures all required fields have values -2. **Field Types**: Validates values match field type expectations -3. **Options**: For choice fields, validates values are in allowed options -4. **Data Completeness**: Ensures data map contains entries for defined fields - -### 3. PhoenixKit.Modules.Entities.FieldTypes - -**File**: `lib/modules/entities/field_types.ex` - -Field type definitions and validation. - -**Key Functions:** - -```elixir -# Get all field types -PhoenixKit.Modules.Entities.FieldTypes.all() -# => %{"text" => %{name: "text", label: "Text", ...}, ...} - -# Get field types by category -PhoenixKit.Modules.Entities.FieldTypes.by_category(:basic) -# => [%{name: "text", label: "Text", ...}, ...] - -# Get category list -PhoenixKit.Modules.Entities.FieldTypes.category_list() -# => [{:basic, "Basic Fields"}, {:numeric, "Numeric"}, ...] - -# Get specific type -PhoenixKit.Modules.Entities.FieldTypes.get_type("text") -# => %{name: "text", label: "Text", category: :basic, icon: "hero-document-text"} - -# Check if type requires options -PhoenixKit.Modules.Entities.FieldTypes.requires_options?("select") -# => true - -# Validate field definition -PhoenixKit.Modules.Entities.FieldTypes.validate_field(field_map) -# => {:ok, validated_field} | {:error, error_message} - -# Format for picker UI -PhoenixKit.Modules.Entities.FieldTypes.for_picker() -# => Structured data for UI dropdowns - -# Field Builder Helpers (for programmatic entity creation) -# These helpers make it easy to create field definitions with proper structure - -# Create a field with options -PhoenixKit.Modules.Entities.FieldTypes.new_field("text", "title", "Title", required: true) -# => %{"type" => "text", "key" => "title", "label" => "Title", "required" => true, ...} - -# Create choice fields with options -PhoenixKit.Modules.Entities.FieldTypes.select_field("category", "Category", ["Tech", "Business", "Other"]) -# => %{"type" => "select", "key" => "category", "label" => "Category", "options" => [...], ...} - -PhoenixKit.Modules.Entities.FieldTypes.radio_field("priority", "Priority", ["Low", "Medium", "High"]) -# => %{"type" => "radio", "key" => "priority", "label" => "Priority", "options" => [...], ...} - -PhoenixKit.Modules.Entities.FieldTypes.checkbox_field("tags", "Tags", ["Featured", "Popular", "New"]) -# => %{"type" => "checkbox", "key" => "tags", "label" => "Tags", "options" => [...], ...} - -# Convenience helpers for common field types -PhoenixKit.Modules.Entities.FieldTypes.text_field("name", "Full Name", required: true) -PhoenixKit.Modules.Entities.FieldTypes.textarea_field("bio", "Biography") -PhoenixKit.Modules.Entities.FieldTypes.email_field("email", "Email Address", required: true) -PhoenixKit.Modules.Entities.FieldTypes.number_field("age", "Age") -PhoenixKit.Modules.Entities.FieldTypes.boolean_field("active", "Is Active", default: true) -PhoenixKit.Modules.Entities.FieldTypes.rich_text_field("content", "Content") -``` - -### 4. PhoenixKit.Modules.Entities.FormBuilder - -**File**: `lib/modules/entities/form_builder.ex` - -Dynamic form generation from entity field definitions. - -**Key Functions:** - -```elixir -# Generate form fields from entity (returns Phoenix.Component HTML) -PhoenixKit.Modules.Entities.FormBuilder.build_fields(entity, changeset, opts \\ []) -# => Phoenix.LiveView.Rendered (HEEx template) - -# Generate single field (multi-clause function handles all field types) -PhoenixKit.Modules.Entities.FormBuilder.build_field(field_definition, changeset, opts \\ []) -# => Phoenix.LiveView.Rendered (HEEx template) - -# Validate entity data against field definitions -PhoenixKit.Modules.Entities.FormBuilder.validate_data(entity, data_params) -# => {:ok, validated_data} | {:error, errors} -``` - -**Options for build_fields/build_field:** - -- `:wrapper_class` - CSS class for field wrapper divs -- `:input_class` - CSS class for input elements -- `:label_class` - CSS class for label elements - -**Internal Field Rendering:** - -The `build_field/3` function uses pattern matching on field type to render appropriate inputs. -Media fields (`image`, `file`) and relation fields render "Coming Soon" placeholders. - ---- - -## Admin Interfaces - -### 1. Entities Manager - -**Route**: `/phoenix_kit/admin/entities` -**File**: `lib/modules/entities/web/entities.ex` -**Template**: `lib/modules/entities/web/entities.html.heex` - -**Features:** - -- List all entities with status badges (Draft/Published/Archived) -- Table and card view toggle (card view auto-selected on small screens) -- Create new entity button -- Edit entity button for each entity -- View data button to browse entity records -- Archive/restore entity actions -- Empty state with helpful onboarding message - -**LiveView Events:** - -```elixir -handle_event("toggle_view_mode", %{"mode" => mode}, socket) -handle_event("archive_entity", %{"uuid" => uuid}, socket) -handle_event("restore_entity", %{"uuid" => uuid}, socket) -``` - -### 2. Entity Form (Create/Edit) - -**Routes**: -- Create: `/phoenix_kit/admin/entities/new` -- Edit: `/phoenix_kit/admin/entities/:id/edit` - -**Files**: -- `lib/modules/entities/web/entity_form.ex` -- `lib/modules/entities/web/entity_form.html.heex` - -**Features:** - -- **Entity Metadata Section**: - - Entity Name (technical identifier, snake_case) - - Display Name (human-readable) - - Icon (Heroicon name) - - Status (draft/published/archived dropdown) - - Description (optional) - -- **Field Definitions Section**: - - Add Field button - - List of defined fields with: - - Field icon, label, key, type, required status - - Move Up/Down buttons for reordering - - Edit button - - Delete button with confirmation - - Empty state when no fields defined - -- **Field Form Modal**: - - Field Type dropdown (organized by category) - - Field Key input (snake_case, unique validation) - - Field Label input - - Required toggle - - Default value input - - Options management (for choice fields): - - Add Option button - - List of options with delete buttons - - Empty state for options - -- **Form Validation**: - - Real-time validation with `phx-change="validate"` - - Submit button disabled until valid and has fields - - Flash messages for errors - - Field key uniqueness enforcement - -**LiveView Events:** - -```elixir -handle_event("validate", %{"entities" => params}, socket) -handle_event("save", %{"entities" => params}, socket) -handle_event("add_field", _params, socket) -handle_event("edit_field", %{"index" => index}, socket) -handle_event("delete_field", %{"index" => index}, socket) -handle_event("move_field_up", %{"index" => index}, socket) -handle_event("move_field_down", %{"index" => index}, socket) -handle_event("save_field", %{"field" => params}, socket) -handle_event("cancel_field", _params, socket) -handle_event("update_field_form", %{"field" => params}, socket) -handle_event("add_option", _params, socket) -handle_event("remove_option", %{"index" => index}, socket) -handle_event("update_option", %{"index" => index, "value" => value}, socket) -``` - -### 3. Data Navigator - -**Route**: `/phoenix_kit/admin/entities/:entity_slug/data` - -**Files**: -- `lib/modules/entities/web/data_navigator.ex` -- `lib/modules/entities/web/data_navigator.html.heex` - -> **Note**: The route requires `:entity_slug`. The LiveView mounts with a nil entity if the slug doesn't resolve to a valid entity. - -**Features:** - -- Browse a single entity's records in table or card layouts -- Status filters (all/published/draft/archived) and keyword search scoped to the selected entity -- At-a-glance stats (total/published/draft/archived) for that entity -- Quick navigation links back to the entity definition plus "Add" shortcuts for new data -- Row/card actions include view, edit, archive/restore, and status toggle buttons -- Empty states that prompt the user to publish an entity or add the first record - -**LiveView Events:** - -```elixir -handle_event("toggle_view_mode", _params, socket) # Switch table/card view -handle_event("filter_by_status", %{"status" => status}, socket) -handle_event("search", %{"search" => %{"query" => query}}, socket) -handle_event("clear_filters", _params, socket) -handle_event("archive_data", %{"id" => id}, socket) -handle_event("restore_data", %{"id" => id}, socket) -handle_event("toggle_status", %{"id" => id}, socket) -``` - -### 4. Data Form (Create/Edit/View) - -**Routes**: -- Create: `/phoenix_kit/admin/entities/:entity_slug/data/new` -- View: `/phoenix_kit/admin/entities/:entity_slug/data/:id` -- Edit: `/phoenix_kit/admin/entities/:entity_slug/data/:id/edit` - -**Files**: -- `lib/modules/entities/web/data_form.ex` -- `lib/modules/entities/web/data_form.html.heex` -- `lib/modules/entities/web/data_view.ex` (for :show action) - -> **Note**: Routes use `:entity_slug` (not `:entity_id`). - -**Features:** - -- **Record Metadata Section**: - - Title (required, indexed) - - Slug (optional, URL-friendly) - - Status (draft/published/archived) - -- **Dynamic Fields Section**: - - Fields auto-generated from entity definition - - Field types render appropriate inputs - - Required field indicators - - Help text from field labels - -- **Three Modes**: - - **View**: Read-only display of record - - **Edit**: Editable form with save button - - **Create**: New record form - -**LiveView Events:** - -```elixir -handle_event("validate", %{"entity_data" => params}, socket) -handle_event("save", %{"entity_data" => params}, socket) -``` - ---- - -## Public Form Builder - -The Entities system includes a Public Form Builder that allows administrators to create embeddable forms for public-facing pages. This enables use cases like contact forms, lead capture, surveys, and user submissions. - -### Overview - -The Public Form Builder provides: - -- **Embeddable Forms**: Use `` in publishing pages -- **Field Selection**: Choose which entity fields appear on the public form -- **Security Options**: Honeypot, time-based validation, and rate limiting -- **Configurable Actions**: Choose what happens when security checks trigger -- **Statistics Tracking**: Monitor submissions, rejections, and security events -- **Debug Mode**: Detailed error messages for troubleshooting - -### Configuration - -Public form settings are stored in the entity's `settings` JSONB column: - -| Setting Key | Type | Default | Description | -|-------------|------|---------|-------------| -| `public_form_enabled` | boolean | false | Master toggle for public form | -| `public_form_fields` | array | [] | List of field keys to include | -| `public_form_title` | string | "" | Form title displayed to users | -| `public_form_description` | string | "" | Form description/instructions | -| `public_form_submit_text` | string | "Submit" | Submit button text | -| `public_form_success_message` | string | "Form submitted successfully!" | Success message | -| `public_form_collect_metadata` | boolean | true | Collect IP, browser, device info | -| `public_form_debug_mode` | boolean | false | Show detailed security errors | - -### Security Options - -#### Honeypot Protection - -Adds a hidden field that bots typically fill out: - -| Setting | Type | Default | Description | -|---------|------|---------|-------------| -| `public_form_honeypot` | boolean | false | Enable honeypot field | -| `public_form_honeypot_action` | string | "reject_silent" | Action when triggered | - -#### Time-Based Validation - -Rejects submissions that happen too quickly (less than 3 seconds): - -| Setting | Type | Default | Description | -|---------|------|---------|-------------| -| `public_form_time_check` | boolean | false | Enable time validation | -| `public_form_time_check_action` | string | "reject_error" | Action when triggered | - -#### Rate Limiting - -Limits submissions per IP address (5 per minute): - -| Setting | Type | Default | Description | -|---------|------|---------|-------------| -| `public_form_rate_limit` | boolean | false | Enable rate limiting | -| `public_form_rate_limit_action` | string | "reject_error" | Action when triggered | - -### Security Actions - -Each security option can be configured with one of four actions: - -| Action | Description | -|--------|-------------| -| `reject_silent` | Show fake success message, don't save data | -| `reject_error` | Show error message to user, don't save data | -| `save_suspicious` | Save data with "draft" status, add security warnings to metadata | -| `save_log` | Save data normally, log warning for monitoring | - -### Form Statistics - -Statistics are automatically tracked in `settings["public_form_stats"]`: - -```elixir -%{ - "total_submissions" => 150, - "successful_submissions" => 142, - "rejected_submissions" => 8, - "honeypot_triggers" => 5, - "too_fast_triggers" => 2, - "rate_limited_triggers" => 1, - "last_submission_at" => "2025-01-15T10:30:00Z" -} -``` - -### Submission Metadata - -When `public_form_collect_metadata` is enabled, each submission includes: - -```elixir -%{ - "source" => "public_form", - "ip_address" => "192.168.1.1", - "user_agent" => "Mozilla/5.0...", - "browser" => "Chrome", - "os" => "macOS", - "device" => "desktop", - "referer" => "https://example.com/contact", - "form_loaded_at" => "2025-01-15T10:29:30Z", - "submitted_at" => "2025-01-15T10:30:00Z", - "time_to_submit_seconds" => 30, - "security_warnings" => [] # Added if any security checks triggered with save actions -} -``` - -### Embedding Forms - -Use the `` component in publishing pages: - -```heex - -``` - -The component: -1. Loads the entity by slug -2. Checks if public form is enabled AND has fields selected -3. Renders the form with selected fields only -4. Includes CSRF token, honeypot (if enabled), and timing data -5. Posts to `/phoenix_kit/entities/{slug}/submit` - -### Controller Flow - -**File**: `lib/phoenix_kit_web/controllers/entity_form_controller.ex` - -1. **Validation**: Check entity exists and public form is enabled with fields -2. **Security Checks**: Run honeypot, time, and rate limit checks -3. **Handle Result**: - - If any check triggers "reject" action → reject submission - - If checks trigger "save" actions → save with flags - - If all checks pass → save normally -4. **Statistics**: Update form statistics asynchronously -5. **Redirect**: Return to referrer with flash message - -### Admin Interface - -The Entity Form page includes a "Public Form Configuration" section when editing an entity: - -1. **Enable/Disable Toggle**: Master switch for public form -2. **Form Details**: Title, description, submit text, success message -3. **Field Selection**: Checkboxes for each entity field -4. **Security Section**: - - Collect Metadata toggle - - Debug Mode toggle (with warning) - - Honeypot Protection with action dropdown - - Time-Based Validation with action dropdown - - Rate Limiting with action dropdown -5. **Statistics Display**: Shows submission counts, security triggers, last submission time - -### Security Warnings in Data View - -When viewing a submission that triggered security checks (with save actions), the Data View shows: - -- Alert banner with "Security Flags" heading -- Badges for each triggered check (Honeypot, Too Fast, Rate Limited) -- Action taken for each (Marked as suspicious, Logged warning) - ---- - -## HTML Sanitization - -Rich text fields are automatically sanitized to prevent XSS attacks. - -### HtmlSanitizer Module - -**File**: `lib/modules/entities/html_sanitizer.ex` - -The sanitizer removes dangerous content while preserving safe HTML: - -**Removed:** -- `

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 d90d65ee6..7f7d209f4 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 @@ -33,6 +33,20 @@ defmodule PhoenixKit.Modules.Legal do use PhoenixKit.Module + @compile {:no_warn_undefined, + [ + {PhoenixKit.Modules.Publishing, :enabled?, 0}, + {PhoenixKit.Modules.Publishing, :get_primary_language, 0}, + {PhoenixKit.Modules.Publishing, :get_group, 1}, + {PhoenixKit.Modules.Publishing, :add_group, 2}, + {PhoenixKit.Modules.Publishing, :list_posts, 1}, + {PhoenixKit.Modules.Publishing, :read_post, 2}, + {PhoenixKit.Modules.Publishing, :read_post, 4}, + {PhoenixKit.Modules.Publishing, :create_post, 2}, + {PhoenixKit.Modules.Publishing, :update_post, 4}, + {PhoenixKit.Modules.Publishing, :add_language_to_post, 4} + ]} + alias PhoenixKit.Dashboard.Tab alias PhoenixKit.Modules.Legal.LegalFramework alias PhoenixKit.Modules.Legal.PageType diff --git a/lib/modules/legal/web/settings.ex b/lib/modules/legal/web/settings.ex index dfb05227e..249852d33 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 @@ -15,8 +15,14 @@ defmodule PhoenixKitWeb.Live.Modules.Legal.Settings do use PhoenixKitWeb, :live_view use Gettext, backend: PhoenixKitWeb.Gettext + @compile {:no_warn_undefined, + [ + {PhoenixKit.Modules.Publishing, :enabled?, 0}, + {PhoenixKit.Modules.Publishing, :enabled_language_codes, 0}, + {PhoenixKit.Modules.Publishing, :get_primary_language, 0} + ]} + alias PhoenixKit.Modules.Legal - alias PhoenixKit.Modules.Publishing alias PhoenixKit.Settings alias PhoenixKit.Utils.Routes @@ -284,21 +290,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..ca3c3412c 100644 --- a/lib/modules/pages/renderer.ex +++ b/lib/modules/pages/renderer.ex @@ -6,11 +6,12 @@ defmodule PhoenixKit.Modules.Pages.Renderer do Cache keys include content hashes for automatic invalidation. """ + @compile {:no_warn_undefined, [{PhoenixKitEntities.Components.EntityForm, :render, 1}]} + require Logger 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 +344,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

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 604a8f14d..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 18 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 18 internal modules" do + test "contains all 15 internal modules" do modules = ModuleRegistry.all_modules() - assert length(modules) >= 18 + 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) >= 17 + 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 17 feature keys" do + test "returns sorted list of 14 feature keys" do keys = ModuleRegistry.all_feature_keys() assert is_list(keys) - assert length(keys) == 17 + 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) >= 17 + 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 4050ae859..02061e91e 100644 --- a/test/phoenix_kit/module_test.exs +++ b/test/phoenix_kit/module_test.exs @@ -4,17 +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.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, @@ -31,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 d73218559..6e2b2d493 100644 --- a/test/phoenix_kit/users/permissions_test.exs +++ b/test/phoenix_kit/users/permissions_test.exs @@ -50,9 +50,6 @@ defmodule PhoenixKit.Users.PermissionsTest do assert is_list(keys) assert "billing" in keys assert "shop" in keys - assert "entities" in keys - assert "ai" in keys - assert length(keys) == 17 end test "does not include core keys" do @@ -69,8 +66,8 @@ defmodule PhoenixKit.Users.PermissionsTest do assert MapSet.new(all) == MapSet.new(expected) end - test "has 22 built-in keys" do - assert length(Permissions.all_module_keys()) == 22 + test "has 19 built-in keys" do + assert length(Permissions.all_module_keys()) == 19 end end @@ -104,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 @@ -123,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 @@ -283,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