# `PhoenixKit.Module`
[🔗](https://github.com/BeamLabEU/phoenix_kit/blob/v2.2.0/lib/phoenix_kit/module.ex#L1)

Behaviour for PhoenixKit feature modules (internal and external).

Any module that implements this behaviour can register itself with PhoenixKit's
tab registry, permission system, supervisor tree, and route system.

## Usage

    defmodule PhoenixKitHelloWorld do
      use PhoenixKit.Module

      @impl true
      def module_key, do: "hello_world"

      @impl true
      def module_name, do: "Hello World"

      @impl true
      def enabled?, do: PhoenixKit.Settings.get_boolean_setting("hello_world_enabled", false)

      @impl true
      def enable_system do
        PhoenixKit.Settings.update_boolean_setting_with_module("hello_world_enabled", true, "hello_world")
      end

      @impl true
      def disable_system do
        PhoenixKit.Settings.update_boolean_setting_with_module("hello_world_enabled", false, "hello_world")
      end

      @impl true
      def permission_metadata do
        %{
          key: "hello_world",
          label: "Hello World",
          icon: "hero-hand-raised",
          description: "A demo module",
          # Optional fine-grained permissions under the base key.
          # Stored/checked as "hello_world.moderate"; only effective
          # while the module is enabled.
          sub_permissions: [
            %{
              key: "moderate",
              label: "Moderate content",
              description: "Approve or reject other users' entries"
            }
          ]
        }
      end

      @impl true
      def admin_tabs do
        [%PhoenixKit.Dashboard.Tab{
          id: :admin_hello_world,
          label: "Hello World",
          icon: "hero-hand-raised",
          path: "hello-world",
          priority: 640,
          level: :admin,
          permission: "hello_world",
          match: :prefix,
          group: :admin_modules
        }]
      end
    end

## Required Callbacks

- `module_key/0` - Unique string key (e.g., `"tickets"`, `"billing"`)
- `module_name/0` - Human-readable display name
- `enabled?/0` - Whether the module is currently enabled
- `enable_system/0` - Enable the module system-wide
- `disable_system/0` - Disable the module system-wide

## Optional Callbacks

All optional callbacks have sensible defaults provided by `use PhoenixKit.Module`:

- `get_config/0` - Module stats/config map (default: `%{enabled: enabled?()}`).
  The default calls `enabled?()` which may hit the database. External modules
  with expensive config should override this with a cached implementation.
- `permission_metadata/0` - Permission key, label, icon, description (default: `nil`)
- `admin_tabs/0` - Admin navigation tabs (default: `[]`)
- `settings_tabs/0` - Settings subtabs (default: `[]`)
- `user_dashboard_tabs/0` - User-facing dashboard tabs (default: `[]`)
- `children/0` - Supervisor child specs (default: `[]`)
- `route_module/0` - Module providing route macros (default: `nil`)
- `version/0` - Module version string (default: `"0.0.0"`)
- `migration_module/0` - Module implementing versioned migrations (default: `nil`).
  When set, `mix phoenix_kit.update` will automatically run this module's migrations
  alongside the core PhoenixKit migrations.
- `required_integrations/0` - Integration provider keys this module needs (default: `[]`).
  Used by the Integrations settings page to show relevant providers.
- `integration_providers/0` - Additional provider definitions this module contributes (default: `[]`).
- `email_settings_sections/0` - Sections this module contributes to the core Email Sending
  settings page (default: `[]`).

# `email_settings_section`

```elixir
@type email_settings_section() :: %{
  id: atom(),
  title: String.t(),
  permission: String.t() | nil,
  component: module()
}
```

A module-contributed section on the core Email Sending settings page
(`/admin/settings/email-sending`).

  * `:id` — unique atom, used as the `live_component` DOM id.
  * `:title` — section heading rendered above the component.
  * `:permission` — module permission key gating visibility, checked the
    same way admin tab permissions are (via
    `PhoenixKit.Users.Auth.Scope.has_module_access?/2`); `nil` renders the
    section unconditionally to any admin who can reach the page.
  * `:component` — a `Phoenix.LiveComponent` module. The core page mounts
    it as `<.live_component module={component} id={id} />` — the section
    owns its own `mount/update/handle_event`, so the core page needs no
    knowledge of what's inside.

# `permission_meta`

```elixir
@type permission_meta() :: %{
  :key =&gt; String.t(),
  :label =&gt; String.t(),
  :icon =&gt; String.t(),
  :description =&gt; String.t(),
  optional(:sub_permissions) =&gt; [sub_permission()],
  optional(:gettext_backend) =&gt; module(),
  optional(:gettext_domain) =&gt; String.t()
}
```

Permission metadata for the module.

`:sub_permissions` (optional) declares fine-grained permissions under the
module's base key. The base key gates access to the module's admin pages;
sub-permissions are additive grants the module checks itself (via
`PhoenixKit.Users.Auth.Scope.can?/2`) for specific in-module capabilities.
A sub-permission is only effective while its parent module is enabled.

`:gettext_backend` / `:gettext_domain` (optional) let the module's
permission label render translated in the admin permissions matrix, the
same way `PhoenixKit.Dashboard.Tab` translates sidebar labels: the label
string is used as the msgid against the given backend (domain defaults to
`"default"`). Sub-permission labels inherit the module's backend. Without
a backend the label falls back to the library's own `PhoenixKitWeb.Gettext`
lookup, which resolves for core-shipped modules and is a no-op msgid
fallback for everyone else.

# `sub_permission`

```elixir
@type sub_permission() :: %{
  key: String.t(),
  label: String.t(),
  description: String.t()
}
```

A fine-grained permission a module declares under its base key.

`:key` is the short action name (e.g. `"view_others"`); it is stored and
checked as the composed dotted key `"<module_key>.<key>"` (e.g.
`"calendar.view_others"`). Both parts must match `~r/^[a-z][a-z0-9_]*$/`,
so a composed key always contains exactly one dot.

# `admin_tabs`
*optional* 

```elixir
@callback admin_tabs() :: [PhoenixKit.Dashboard.Tab.t()]
```

# `before_user_delete`
*optional* 

```elixir
@callback before_user_delete(user_uuid :: String.t()) :: any()
```

Lifecycle hook: called for every discovered module BEFORE a user row is
deleted (while the user's related rows — memberships, assignments —
still exist). Modules use it for remediation the DB cascades can't do:
succession, audit trails, orphan warnings. Best-effort — a raising
hook is logged and never aborts the deletion.

# `children`
*optional* 

```elixir
@callback children() :: [Supervisor.child_spec() | module() | {module(), term()}]
```

# `css_sources`
*optional* 

```elixir
@callback css_sources() :: [atom() | String.t()]
```

Returns Tailwind CSS source roots for scanning.

Each entry is either:

  * an atom — the OTP app name. The compiler resolves it via the parent
    app's `mix.exs` deps (`deps/<app>` for Hex, `path:` value for path deps).
  * a string — a literal path. Absolute paths (starting with `/`) emit as
    `@source "<abs>";` verbatim; relative paths emit as `@source "../../<path>";`
    (relative to `assets/css/_phoenix_kit_sources.css`). Useful when a module
    wants to add a path-dep absolute fallback alongside the OTP-app entry,
    so both Hex and path-dep installs work without parent-app toggles.

## Examples

    def css_sources, do: [:phoenix_kit_publishing]

    # Path-dep friendly:
    @source_root Path.expand(Path.join(__DIR__, "../.."))
    def css_sources, do: [:phoenix_kit_publishing, @source_root]

Headless modules (no templates) can skip this callback — the default is `[]`.

# `disable_system`

```elixir
@callback disable_system() :: :ok | {:ok, term()} | {:error, term()}
```

# `email_settings_sections`
*optional* 

```elixir
@callback email_settings_sections() :: [email_settings_section()]
```

Returns module-contributed sections to render on the core Email Sending
settings page (`/admin/settings/email-sending`), below the core sections
(sender identity, transport, default integration, test send).

Lets a module (e.g. an emails or newsletters package) add its own
settings UI to the shared page without the core page needing to know it
exists — the same zero-config pattern as `settings_tabs/0`, but for
composing INTO one page instead of adding a new tab. Collected via
`PhoenixKit.ModuleRegistry.all_email_settings_sections/0`, which only
consults **enabled** modules — a disabled module must not inject a
live_component that assumes its own supervised state is running.

## Example

    @impl PhoenixKit.Module
    def email_settings_sections do
      [%{
        id: :emails_queue_pacing,
        title: "Queue & Pacing",
        permission: "emails",
        component: PhoenixKit.Modules.Emails.Web.SettingsSection
      }]
    end

Modules with nothing to add here skip this callback — the default is `[]`.

# `enable_system`

```elixir
@callback enable_system() :: :ok | {:ok, term()} | {:error, term()}
```

# `enabled?`

```elixir
@callback enabled?() :: boolean()
```

# `get_config`
*optional* 

```elixir
@callback get_config() :: map()
```

# `integration_providers`
*optional* 

```elixir
@callback integration_providers() :: [map()]
```

# `js_sources`
*optional* 

```elixir
@callback js_sources() :: [%{app: atom(), file: String.t(), global: String.t()}]
```

Returns JavaScript hook bundles this module needs registered in the host's
`LiveSocket`.

A LiveView JS hook must be present in the host's single `LiveSocket` at
construction time — a nested LiveView cannot register one at runtime. This
callback lets a module declare a prebuilt bundle (e.g. a standalone Hex
package's hooks) so the `:phoenix_kit_js_sources` compiler can wire it into
the host automatically, the same way `css_sources/0` wires Tailwind sources.

Each entry is a map:

  * `:app` — the OTP app shipping the bundle. Resolved at compile time via
    `:code.priv_dir/1`, so it works for Hex installs and path deps alike (no
    `deps/<app>` path arithmetic).
  * `:file` — path to the prebuilt bundle **inside that app's `priv/`**, e.g.
    `"static/assets/my_hooks.js"`. The file must ship in the app's `priv/`.
  * `:global` — the `window.<Name>` the bundle assigns its hooks to. The
    compiler folds it into `window.PhoenixKitHooks` (which the host already
    spreads into `LiveSocket`), so no per-module `app.js` edit is needed. Must
    be unique across all modules — two bundles sharing a global would clobber
    each other, so the compiler fails loudly on a collision.

## Hook names must be globally unique too

The compiler enforces unique `:global` names, but it cannot see *inside* a
prebuilt bundle. The final fold is `Object.assign(window.PhoenixKitHooks,
<bundle globals…>)`, which is last-write-wins on the **hook names** each
bundle exports. So two modules with distinct globals that happen to export a
hook of the same name (e.g. both define `Chart`) will silently clobber one
another — and a bundle hook whose name matches a core PhoenixKit hook (e.g.
`RowMenu`, `SortableGrid`) overrides the core one. Namespace your hook names
(e.g. prefix them with the module name) to keep them unique across every
module and the core set.

## Example

    @impl PhoenixKit.Module
    def js_sources do
      [%{app: :phoenix_live_gantt,
         file: "static/assets/phoenix_live_gantt.js",
         global: "PhoenixLiveGanttHooks"}]
    end

Modules with no JS hooks skip this callback — the default is `[]`.

# `migrate_legacy`
*optional* 

```elixir
@callback migrate_legacy() :: :ok | {:ok, map()} | {:error, term()}
```

Run any one-shot legacy data migrations this module owns.

Two transitions every module that touches Integrations may need:

1. **Local credentials → Integrations** — the module used to store API
   keys / OAuth tokens itself; move them into a `PhoenixKit.Integrations`
   row and point the module's records at that row by uuid.
2. **Name-string references → uuid references** — the module already
   used Integrations but referenced rows by `provider:name` strings;
   resolve those to uuids and persist the cleaner reference.

Implementations should:

- Be idempotent — safe to call on every host-app boot. Use cheap
  short-circuit guards (a "completed_at" setting, "no rows need
  migration" check, etc.) so repeat runs do nothing.
- Log activity (`PhoenixKit.Activity.log/1`) for every record actually
  migrated, with `mode: "auto"`. Operators can audit the migration
  via the activity feed.
- Never raise — wrap risky paths in `try/rescue` and return
  `{:error, reason}` for the orchestrator to log. A failed migration
  must not crash the host app.
- Redact PII in metadata: log uuids and resource refs, never the
  decrypted API key / OAuth tokens / etc.

Default implementation returns `:ok` (modules that don't have legacy
data don't need to override this).

## Orchestration

Host apps call `PhoenixKit.ModuleRegistry.run_all_legacy_migrations/0`
from `Application.start/2`; that walks every registered module and
invokes this callback. Per-module errors are caught + logged; the
boot doesn't fail.

# `migration_module`
*optional* 

```elixir
@callback migration_module() :: module() | nil
```

# `module_key`

```elixir
@callback module_key() :: String.t()
```

# `module_name`

```elixir
@callback module_name() :: String.t()
```

# `notification_channels`
*optional* 

```elixir
@callback notification_channels() :: [module()]
```

Declares extra notification delivery channels this module contributes.

Returns a list of modules implementing the `PhoenixKit.Notifications.Channel`
behaviour. They are merged with the core channels by
`PhoenixKit.Notifications.Channels.list/0` (core first, module extras after,
duplicate keys rejected), exactly like `notification_types/0` merges types.

A channel is a delivery destination for a user's notifications (Telegram,
email, …); the default is `[]`.

## Example

    def notification_channels, do: [MyApp.Notifications.Channels.Slack]

# `notification_types`
*optional* 

```elixir
@callback notification_types() :: [map()]
```

Returns a list of notification types this module contributes.

Each type is a map with:
  * `:key` — binary, stable identifier used in user prefs (e.g. `"posts"`).
    Must not contain a `.` (the sub-type separator).
  * `:label` — binary, user-facing display (e.g. `"Posts"`)
  * `:description` — binary, short explainer shown under the toggle
  * `:actions` — list of dotted action strings (`["post.liked", "post.commented"]`)
  * `:default` — boolean, the toggle's default state for users who haven't
    set a preference
  * `:sub_types` — OPTIONAL list of finer-grained toggles under this type, each
    the same shape (`:key` bare + `.`-free, `:label`, `:description`, `:actions`,
    `:default`). Sub keys are composed to `"<type>.<sub>"` at registration.

When a type declares sub-types the base becomes a **master switch** (resolution
time only): base off ⇒ every sub muted; base on ⇒ each sub follows its own
toggle. This is the OPPOSITE of the permission sub-key cascade — nothing is
stored-normalized, so a user's per-sub choices survive toggling the master.
When a type is fully split, leave its own `:actions` empty so every action is
owned by exactly one sub. Each action should be claimed by exactly one type or
sub-type; a duplicate claim is resolved first-declared-wins with a warning.
**Note:** a NEW sub with `default: false` actively mutes that action for existing
users under an already-on master — pick defaults deliberately.

Types merge with core PhoenixKit types (`account`, `posts`, `comments`,
`security`) and show up automatically on the Notification Settings page. The
filter in `PhoenixKit.Notifications.maybe_create_from_activity/1` resolves each
action to its most-specific type/sub-type via `PhoenixKit.Notifications.Types`
and skips the fan-out when the user has muted it. Labels/descriptions are
runtime data — `mix gettext.extract` won't see them.

Headless modules (no user-facing actions) can skip this callback — the
default is `[]`.

## Example

    def notification_types do
      [
        %{
          key: "reviews",
          label: "Reviews",
          description: "Reviews people leave you",
          actions: [],
          default: true,
          sub_types: [
            %{key: "new", label: "New reviews", actions: ["review.submitted"], default: true},
            %{key: "edited", label: "Edited reviews", actions: ["review.edited"], default: false}
          ]
        }
      ]
    end

# `permission_metadata`
*optional* 

```elixir
@callback permission_metadata() :: permission_meta() | nil
```

# `required_integrations`
*optional* 

```elixir
@callback required_integrations() :: [String.t()]
```

# `required_modules`
*optional* 

```elixir
@callback required_modules() :: [String.t()]
```

# `reserved_route_prefixes`
*optional* 

```elixir
@callback reserved_route_prefixes() :: [String.t()]
```

Returns top-level route path segments this module owns for its own
LiveViews/controllers (e.g. a host app declares `live "/legal", LegalLive`
and this module IS the "legal" feature).

Consulted by modules that dispatch requests based on a database-driven
path segment (e.g. Publishing's `/:language/:group/*path` catch-all, which
treats any first segment matching a stored group slug as one of its own
groups) so they don't swallow a route another module owns just because a
same-named record happens to exist in their own data. Collected via
`PhoenixKit.ModuleRegistry.all_reserved_route_prefixes/0`.

Segments are compared literally (no leading/trailing slash, e.g. `"legal"`
not `"/legal"`).

## Example

    @impl PhoenixKit.Module
    def reserved_route_prefixes, do: ["legal"]

Modules that don't own a reserved top-level segment skip this callback —
the default is `[]`.

# `resource_links`
*optional* 

```elixir
@callback resource_links() :: %{optional(String.t()) =&gt; module() | String.t() | map()}
```

Declares how this module's resource types deep-link to their pages.

Lets the activity feed, notifications, and the comments moderation admin turn
a `(resource_type, resource_uuid)` pair from *this* module into a clickable
link to the underlying record — with zero host configuration. Returns a map of
`resource_type => resolver`, where a resolver is either:

  * **a module** implementing `resolve_comment_resources/1` (the same contract
    core's `"user"`/`"file"`/`"post"` handlers use) — for rich resolution with
    a title and optional thumbnail. Return `%{uuid => %{title:, path:}}` with a
    **raw** phoenix_kit path (`Routes.path/1` is applied once at render).

  * **a path-template string** — the no-code shortcut, e.g.
    `"/admin/widgets/:uuid"`. Placeholders: `:uuid` and `:metadata.<key>`
    (pulled from the activity/comment metadata). Treated as an internal
    phoenix_kit route (SPA-navigated, prefix/locale applied). Use the map form
    `%{"path" => "...", "title" => ":metadata.name"}` to set a display title.

## Examples

    # No-code: a raw admin route with a uuid segment
    def resource_links, do: %{"widget" => "/admin/widgets/:uuid"}

    # Titled template
    def resource_links,
      do: %{"widget" => %{"path" => "/admin/widgets/:uuid", "title" => ":metadata.name"}}

    # Rich: point at a resolver module (title + thumbnail via a DB lookup)
    def resource_links, do: %{"widget" => MyApp.WidgetLinks}

Merges with core's built-in handlers and the host's `comment_resource_paths`
setting via `PhoenixKit.ResourceLinks`. Modules with no linkable resources can
skip this callback — the default is `%{}`.

# `route_module`
*optional* 

```elixir
@callback route_module() :: module() | nil
```

# `settings_tabs`
*optional* 

```elixir
@callback settings_tabs() :: [PhoenixKit.Dashboard.Tab.t()]
```

# `sitemap_sources`
*optional* 

```elixir
@callback sitemap_sources() :: [module()]
```

Returns sitemap source modules this module contributes.

Each entry is a module implementing the
`PhoenixKit.Modules.Sitemap.Sources.Source` behaviour. The sitemap
`Generator` merges these with its built-in sources (router discovery,
static, publishing, posts, shop) so an external module's content
(e.g. Entities records) appears in the generated sitemap with no
host-app configuration — the same zero-config pattern as `css_sources/0`
and route discovery.

Sources are collected via `PhoenixKit.ModuleRegistry.all_sitemap_sources/0`
and appended to the base source list, deduplicated by module.

## Example

    @impl PhoenixKit.Module
    def sitemap_sources, do: [PhoenixKitEntities.SitemapSource]

Headless modules (no public content) skip this callback — the default is `[]`.

# `user_dashboard_tabs`
*optional* 

```elixir
@callback user_dashboard_tabs() :: [PhoenixKit.Dashboard.Tab.t()]
```

# `version`
*optional* 

```elixir
@callback version() :: String.t()
```

---

*Consult [api-reference.md](api-reference.md) for complete listing*
