diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs index a2f0683b1..3a78ebc51 100644 --- a/.dialyzer_ignore.exs +++ b/.dialyzer_ignore.exs @@ -32,6 +32,9 @@ # Ecto.Multi opaque type false positives (code works correctly) ~r/lib\/phoenix_kit\/users\/auth\.ex:.*call_without_opaque/, + # Connections module (extracted to phoenix_kit_user_connections) — conditional calls via Code.ensure_loaded? + {"lib/phoenix_kit_web/live/users/user_details.ex", :unknown_function}, + # Legal module (extracted to phoenix_kit_legal) — conditional component calls {"lib/phoenix_kit_web/components/layout_wrapper.ex", :unknown_function}, {"lib/phoenix_kit_web/components/layouts/root.html.heex", :unknown_function}, diff --git a/lib/modules/connections/README.md b/lib/modules/connections/README.md deleted file mode 100644 index 50df6cae4..000000000 --- a/lib/modules/connections/README.md +++ /dev/null @@ -1,306 +0,0 @@ -# Connections Module - Social Relationships System - -## Overview - -The Connections module provides a complete social relationships system for PhoenixKit applications with two types of relationships: - -1. **Follows** - One-way relationships (User A follows User B, no consent needed) -2. **Connections** - Two-way mutual relationships (both users must accept) - -Plus **blocking** functionality to prevent unwanted interactions. - -## Architecture - -Each relationship type uses a **dual-table pattern**: - -- **Main table**: Stores only the **current state** (one row per user pair) -- **History table**: Logs all **activity over time** for auditing and activity feeds - -This design keeps the main tables lean and fast while preserving a complete audit trail. - -## Terminology - -- **Follow**: One-way, no consent required (like Twitter/Instagram) -- **Connection**: Two-way, requires acceptance from both parties (like LinkedIn) -- **Block**: Prevents all interaction (following, connecting, profile viewing) - ---- - -## Database Schema - -### Main Tables (Current State) - -#### Table: `phoenix_kit_user_follows` - -Stores only ACTIVE follows. Row is deleted when user unfollows. - -| Column | Type | Description | -|--------|------|-------------| -| id | UUIDv7 | Primary key | -| follower_uuid | UUID | User who is following (FK to users.uuid) | -| followed_uuid | UUID | User being followed (FK to users.uuid) | -| inserted_at | naive_datetime | When follow was created | - -**Indexes:** Unique on `(follower_uuid, followed_uuid)`, index on `followed_uuid`, index on `follower_uuid` - -#### Table: `phoenix_kit_user_connections` - -Stores only CURRENT connection state per user pair. Status is "pending" or "accepted" only. -Rejected connections are deleted (not stored as "rejected"). - -| Column | Type | Description | -|--------|------|-------------| -| id | UUIDv7 | Primary key | -| requester_uuid | UUID | User who initiated request (FK to users.uuid) | -| recipient_uuid | UUID | User who received request (FK to users.uuid) | -| status | string | "pending" or "accepted" | -| requested_at | naive_datetime | When request was sent | -| responded_at | naive_datetime | When recipient responded | -| inserted_at | naive_datetime | Created timestamp | -| updated_at | naive_datetime | Updated timestamp | - -**Indexes:** Index on `(recipient_uuid, status)`, index on `(requester_uuid, status)` - -#### Table: `phoenix_kit_user_blocks` - -Stores only ACTIVE blocks. Row is deleted when user unblocks. - -| Column | Type | Description | -|--------|------|-------------| -| id | UUIDv7 | Primary key | -| blocker_uuid | UUID | User who blocked (FK to users.uuid) | -| blocked_uuid | UUID | User who is blocked (FK to users.uuid) | -| reason | string | Optional reason (nullable) | -| inserted_at | naive_datetime | When block was created | - -**Indexes:** Unique on `(blocker_uuid, blocked_uuid)`, index on `blocked_uuid` - ---- - -### History Tables (Activity Log) - -#### Table: `phoenix_kit_user_follows_history` - -Logs all follow/unfollow events. - -| Column | Type | Description | -|--------|------|-------------| -| id | UUIDv7 | Primary key | -| follower_uuid | UUID | User who performed action (FK to users.uuid) | -| followed_uuid | UUID | Target user (FK to users.uuid) | -| action | string | "follow" or "unfollow" | -| inserted_at | naive_datetime | When action occurred | - -#### Table: `phoenix_kit_user_connections_history` - -Logs all connection events. - -| Column | Type | Description | -|--------|------|-------------| -| id | UUIDv7 | Primary key | -| user_a_uuid | UUID | First user (normalized: lower UUID) (FK to users.uuid) | -| user_b_uuid | UUID | Second user (normalized: higher UUID) (FK to users.uuid) | -| actor_uuid | UUID | User who performed this action (FK to users.uuid) | -| action | string | "requested", "accepted", "rejected", "removed" | -| inserted_at | naive_datetime | When action occurred | - -#### Table: `phoenix_kit_user_blocks_history` - -Logs all block/unblock events. - -| Column | Type | Description | -|--------|------|-------------| -| id | UUIDv7 | Primary key | -| blocker_uuid | UUID | User who performed action (FK to users.uuid) | -| blocked_uuid | UUID | Target user (FK to users.uuid) | -| action | string | "block" or "unblock" | -| reason | string | Reason (for block action) | -| inserted_at | naive_datetime | When action occurred | - ---- - -## Public API: `PhoenixKit.Modules.Connections` - -**This is a PUBLIC API** - all functions are available to parent applications for use in their own views, components, and logic. - -### Module Management - -```elixir -PhoenixKit.Modules.Connections.enabled?() -PhoenixKit.Modules.Connections.enable_system() -PhoenixKit.Modules.Connections.disable_system() -PhoenixKit.Modules.Connections.get_config() -PhoenixKit.Modules.Connections.get_stats() -``` - -### Follows - -```elixir -# Create/remove follows (automatically logs to history) -follow(follower, followed) # Create a follow relationship -unfollow(follower, followed) # Remove a follow relationship - -# Query follows -following?(follower, followed) # Check if user A follows user B -list_followers(user) # Get all followers of a user -list_following(user) # Get all users a user follows -followers_count(user) # Count followers -following_count(user) # Count following -``` - -### Connections - -```elixir -# Connection requests (automatically logs to history) -request_connection(requester, recipient) # Send connection request -accept_connection(connection_id) # Accept a pending request -reject_connection(connection_id) # Reject a pending request -remove_connection(user_a, user_b) # Remove existing connection - -# Query connections -connected?(user_a, user_b) # Check if two users are connected -list_connections(user) # Get all connections for a user -list_pending_requests(user) # Get pending incoming requests -list_sent_requests(user) # Get pending outgoing requests -connections_count(user) # Count connections -pending_requests_count(user) # Count pending incoming requests -``` - -### Blocks - -```elixir -# Create/remove blocks (automatically logs to history) -block(blocker, blocked) # Block a user -unblock(blocker, blocked) # Remove a block - -# Query blocks -blocked?(blocker, blocked) # Check if user A blocked user B -blocked_by?(user, other) # Check if user is blocked by other -list_blocked(user) # Get all users blocked by a user -can_interact?(user_a, user_b) # Check if two users can interact -``` - -### Relationship Status (Convenience) - -```elixir -# Get full relationship status between two users in one call -get_relationship(user_a, user_b) -# Returns: -%{ - following: true/false, # A follows B - followed_by: true/false, # B follows A - connected: true/false, # mutual connection exists - connection_pending: :sent/:received/nil, - blocked: true/false, # A blocked B - blocked_by: true/false # B blocked A -} -``` - ---- - -## Usage Examples - -### In a User Profile Page - -```elixir -# Get relationship for rendering follow/connect buttons -alias PhoenixKit.Modules.Connections - -relationship = Connections.get_relationship(current_user, profile_user) - -# Display counts -followers = Connections.followers_count(profile_user) -following = Connections.following_count(profile_user) -connections = Connections.connections_count(profile_user) -``` - -### In a LiveView - -```elixir -def handle_event("follow", %{"user_id" => user_id}, socket) do - target_user = get_user(user_id) - - case Connections.follow(socket.assigns.current_user, target_user) do - {:ok, _follow} -> {:noreply, put_flash(socket, :info, "Now following!")} - {:error, reason} -> {:noreply, put_flash(socket, :error, reason)} - end -end - -def handle_event("request_connection", %{"user_id" => user_id}, socket) do - target_user = get_user(user_id) - - case Connections.request_connection(socket.assigns.current_user, target_user) do - {:ok, _connection} -> {:noreply, put_flash(socket, :info, "Connection request sent!")} - {:error, reason} -> {:noreply, put_flash(socket, :error, reason)} - end -end -``` - ---- - -## Business Rules - -### Following - -- Cannot follow yourself -- Cannot follow if blocked (either direction) -- Instant, no approval needed - -### Connections - -- Cannot connect with yourself -- Cannot connect if blocked -- Requires acceptance from recipient -- If A requests B while B has pending request to A → auto-accept both - -### Blocking - -- Blocking removes any existing follow/connection between the users -- Blocked user cannot follow, connect, or view profile -- Blocking is one-way (A blocks B doesn't mean B blocks A) - ---- - -## File Structure - -``` -lib/modules/connections/ - README.md # This documentation - connections.ex # Main context API - follow.ex # Follow schema - follow_history.ex # Follow history schema - connection.ex # Connection schema - connection_history.ex # Connection history schema - block.ex # Block schema - block_history.ex # Block history schema - -lib/phoenix_kit_web/live/modules/connections/ - connections.ex # Admin: overview/moderation - connections.html.heex - user_connections.ex # User: manage own connections - user_connections.html.heex - -lib/phoenix_kit/migrations/postgres/ - v36.ex # Migration for all connection tables -``` - ---- - -## Admin Interface - -Available at `{prefix}/admin/connections`: - -- Overview statistics (total follows, connections, pending, blocks) -- Module enable/disable toggle -- Relationship type explanations -- Public API documentation - -## User Interface - -Available at `{prefix}/profile/connections`: - -- **Followers** tab - Users who follow you -- **Following** tab - Users you follow (with unfollow button) -- **Connections** tab - Mutual connections (with remove button) -- **Requests** tab - Pending incoming/outgoing requests (with accept/reject buttons) -- **Blocked** tab - Users you've blocked (with unblock button) diff --git a/lib/modules/connections/block.ex b/lib/modules/connections/block.ex deleted file mode 100644 index 2e173930c..000000000 --- a/lib/modules/connections/block.ex +++ /dev/null @@ -1,121 +0,0 @@ -defmodule PhoenixKit.Modules.Connections.Block do - @moduledoc """ - Schema for user blocking relationships. - - Represents a one-way block where one user blocks another. - Blocking prevents all interaction between users. - - ## Fields - - - `blocker_uuid` - UUID of the user who initiated the block - - `blocked_uuid` - UUID of the user who is blocked - - `reason` - Optional reason for the block (visible to admins) - - `inserted_at` - When the block was created - - ## Examples - - # User A blocks User B - %Block{ - uuid: "018e3c4a-9f6b-7890-abcd-ef1234567890", - blocker_uuid: "019abc12-3456-7890-abcd-ef1234567890", - blocked_uuid: "019abc12-9876-5432-abcd-ef1234567890", - reason: "Spam", - inserted_at: ~N[2025-01-15 10:30:00] - } - - ## Business Rules - - - Cannot block yourself - - Blocking removes any existing follows between the users - - Blocking removes any existing connections between the users - - Blocked users cannot follow, connect, or view the blocker's profile - - Blocking is one-way (A blocks B doesn't mean B blocks A) - """ - use Ecto.Schema - import Ecto.Changeset - - alias PhoenixKit.Utils.Date, as: UtilsDate - - @primary_key {:uuid, UUIDv7, autogenerate: true} - - @type t :: %__MODULE__{ - uuid: UUIDv7.t() | nil, - blocker_uuid: UUIDv7.t(), - blocked_uuid: UUIDv7.t(), - reason: String.t() | nil, - blocker: PhoenixKit.Users.Auth.User.t() | Ecto.Association.NotLoaded.t(), - blocked: PhoenixKit.Users.Auth.User.t() | Ecto.Association.NotLoaded.t(), - inserted_at: DateTime.t() | nil - } - - schema "phoenix_kit_user_blocks" do - belongs_to :blocker, PhoenixKit.Users.Auth.User, - foreign_key: :blocker_uuid, - references: :uuid, - type: UUIDv7 - - belongs_to :blocked, PhoenixKit.Users.Auth.User, - foreign_key: :blocked_uuid, - references: :uuid, - type: UUIDv7 - - field :reason, :string - field :inserted_at, :utc_datetime - end - - @doc """ - Changeset for creating a block. - - ## Required Fields - - - `blocker_uuid` - UUID of the user who is blocking - - `blocked_uuid` - UUID of the user being blocked - - ## Optional Fields - - - `reason` - Why the user was blocked - - ## Validation Rules - - - Both user UUIDs are required - - Cannot block yourself - - Unique constraint on (blocker_uuid, blocked_uuid) pair - """ - def changeset(block, attrs) do - block - |> cast(attrs, [:blocker_uuid, :blocked_uuid, :reason]) - |> validate_required([:blocker_uuid, :blocked_uuid]) - |> validate_length(:reason, max: 500) - |> validate_not_self_block() - |> put_inserted_at() - |> foreign_key_constraint(:blocker_uuid) - |> foreign_key_constraint(:blocked_uuid) - |> unique_constraint([:blocker_uuid, :blocked_uuid], - name: :phoenix_kit_user_blocks_unique_idx, - message: "user is already blocked" - ) - end - - defp validate_not_self_block(changeset) do - blocker_uuid = get_field(changeset, :blocker_uuid) - blocked_uuid = get_field(changeset, :blocked_uuid) - - if blocker_uuid && blocked_uuid && blocker_uuid == blocked_uuid do - add_error(changeset, :blocked_uuid, "cannot block yourself") - else - changeset - end - end - - defp put_inserted_at(changeset) do - if get_field(changeset, :inserted_at) do - changeset - else - put_change( - changeset, - :inserted_at, - UtilsDate.utc_now() - ) - end - end -end diff --git a/lib/modules/connections/block_history.ex b/lib/modules/connections/block_history.ex deleted file mode 100644 index bb11993a6..000000000 --- a/lib/modules/connections/block_history.ex +++ /dev/null @@ -1,61 +0,0 @@ -defmodule PhoenixKit.Modules.Connections.BlockHistory do - @moduledoc """ - Schema for block activity history. - - Records all block/unblock events for auditing and activity feeds. - The main `Block` table stores only current state (active blocks), - while this table preserves the complete history of actions. - - ## Actions - - - `"block"` - User blocked another user - - `"unblock"` - User unblocked another user - """ - - use Ecto.Schema - import Ecto.Changeset - - alias PhoenixKit.Utils.Date, as: UtilsDate - - @primary_key {:uuid, UUIDv7, autogenerate: true} - @foreign_key_type UUIDv7 - - schema "phoenix_kit_user_blocks_history" do - belongs_to :blocker, PhoenixKit.Users.Auth.User, - foreign_key: :blocker_uuid, - references: :uuid, - type: UUIDv7 - - belongs_to :blocked, PhoenixKit.Users.Auth.User, - foreign_key: :blocked_uuid, - references: :uuid, - type: UUIDv7 - - field :action, :string - field :reason, :string - field :inserted_at, :utc_datetime - end - - @actions ~w(block unblock) - - @doc """ - Creates a changeset for a block history record. - """ - def changeset(history, attrs) do - history - |> cast(attrs, [:blocker_uuid, :blocked_uuid, :action, :reason]) - |> validate_required([:blocker_uuid, :blocked_uuid, :action]) - |> validate_inclusion(:action, @actions) - |> put_timestamp() - |> foreign_key_constraint(:blocker_uuid) - |> foreign_key_constraint(:blocked_uuid) - end - - defp put_timestamp(changeset) do - put_change( - changeset, - :inserted_at, - UtilsDate.utc_now() - ) - end -end diff --git a/lib/modules/connections/connection.ex b/lib/modules/connections/connection.ex deleted file mode 100644 index 25006aec8..000000000 --- a/lib/modules/connections/connection.ex +++ /dev/null @@ -1,194 +0,0 @@ -defmodule PhoenixKit.Modules.Connections.Connection do - @moduledoc """ - Schema for two-way mutual connection relationships. - - Represents a bidirectional relationship that requires acceptance from both parties. - Similar to LinkedIn connections or Facebook friend requests. - - ## Status Flow - - - `pending` - Request sent, awaiting response - - `accepted` - Both parties have agreed to connect - - `rejected` - Recipient declined the request - - ## Fields - - - `requester_uuid` - UUID of the user who initiated the connection request - - `recipient_uuid` - UUID of the user who received the request - - `status` - Current status of the connection - - `requested_at` - When the request was sent - - `responded_at` - When the recipient responded (nil if pending) - - ## Examples - - # Pending connection request - %Connection{ - uuid: "018e3c4a-9f6b-7890-abcd-ef1234567890", - requester_uuid: "019abc12-3456-7890-abcd-ef1234567890", - recipient_uuid: "019abc12-9876-5432-abcd-ef1234567890", - status: "pending", - requested_at: ~N[2025-01-15 10:30:00], - responded_at: nil - } - - # Accepted connection - %Connection{ - uuid: "018e3c4a-9f6b-7890-abcd-ef1234567890", - requester_uuid: "019abc12-3456-7890-abcd-ef1234567890", - recipient_uuid: "019abc12-9876-5432-abcd-ef1234567890", - status: "accepted", - requested_at: ~N[2025-01-15 10:30:00], - responded_at: ~N[2025-01-15 11:00:00] - } - - ## Business Rules - - - Cannot connect with yourself - - Cannot connect if blocked (either direction) - - If A requests B while B has pending request to A -> auto-accept both - - Only one active connection per user pair - """ - use Ecto.Schema - import Ecto.Changeset - - alias PhoenixKit.Utils.Date, as: UtilsDate - - @primary_key {:uuid, UUIDv7, autogenerate: true} - - @statuses ["pending", "accepted", "rejected"] - - @type status :: String.t() - - @type t :: %__MODULE__{ - uuid: UUIDv7.t() | nil, - requester_uuid: UUIDv7.t(), - recipient_uuid: UUIDv7.t(), - status: status(), - requested_at: DateTime.t(), - responded_at: DateTime.t() | nil, - requester: PhoenixKit.Users.Auth.User.t() | Ecto.Association.NotLoaded.t(), - recipient: PhoenixKit.Users.Auth.User.t() | Ecto.Association.NotLoaded.t(), - inserted_at: DateTime.t() | nil, - updated_at: DateTime.t() | nil - } - - schema "phoenix_kit_user_connections" do - belongs_to :requester, PhoenixKit.Users.Auth.User, - foreign_key: :requester_uuid, - references: :uuid, - type: UUIDv7 - - belongs_to :recipient, PhoenixKit.Users.Auth.User, - foreign_key: :recipient_uuid, - references: :uuid, - type: UUIDv7 - - field :status, :string, default: "pending" - field :requested_at, :utc_datetime - field :responded_at, :utc_datetime - - timestamps(type: :utc_datetime) - end - - @doc """ - Returns the list of valid statuses. - """ - def statuses, do: @statuses - - @doc """ - Changeset for creating a new connection request. - - ## Required Fields - - - `requester_uuid` - UUID of the user sending the request - - `recipient_uuid` - UUID of the user receiving the request - - ## Validation Rules - - - Both user UUIDs are required - - Cannot request connection with yourself - - Status must be valid - """ - def changeset(connection, attrs) do - connection - |> cast(attrs, [ - :requester_uuid, - :recipient_uuid, - :status, - :requested_at, - :responded_at - ]) - |> validate_required([:requester_uuid, :recipient_uuid]) - |> validate_inclusion(:status, @statuses) - |> validate_not_self_connection() - |> put_requested_at() - |> foreign_key_constraint(:requester_uuid) - |> foreign_key_constraint(:recipient_uuid) - end - - @doc """ - Changeset for updating connection status (accept/reject). - """ - def status_changeset(connection, attrs) do - connection - |> cast(attrs, [:status, :responded_at]) - |> validate_required([:status]) - |> validate_inclusion(:status, @statuses) - |> put_responded_at() - end - - defp validate_not_self_connection(changeset) do - requester_uuid = get_field(changeset, :requester_uuid) - recipient_uuid = get_field(changeset, :recipient_uuid) - - if requester_uuid && recipient_uuid && requester_uuid == recipient_uuid do - add_error(changeset, :recipient_uuid, "cannot connect with yourself") - else - changeset - end - end - - defp put_requested_at(changeset) do - if get_field(changeset, :requested_at) do - changeset - else - put_change( - changeset, - :requested_at, - UtilsDate.utc_now() - ) - end - end - - defp put_responded_at(changeset) do - status = get_change(changeset, :status) - - if status in ["accepted", "rejected"] && !get_field(changeset, :responded_at) do - put_change( - changeset, - :responded_at, - UtilsDate.utc_now() - ) - else - changeset - end - end - - @doc """ - Returns whether this connection is pending. - """ - def pending?(%__MODULE__{status: "pending"}), do: true - def pending?(_), do: false - - @doc """ - Returns whether this connection is accepted. - """ - def accepted?(%__MODULE__{status: "accepted"}), do: true - def accepted?(_), do: false - - @doc """ - Returns whether this connection is rejected. - """ - def rejected?(%__MODULE__{status: "rejected"}), do: true - def rejected?(_), do: false -end diff --git a/lib/modules/connections/connection_history.ex b/lib/modules/connections/connection_history.ex deleted file mode 100644 index c5e72bdc2..000000000 --- a/lib/modules/connections/connection_history.ex +++ /dev/null @@ -1,91 +0,0 @@ -defmodule PhoenixKit.Modules.Connections.ConnectionHistory do - @moduledoc """ - Schema for connection activity history. - - Records all connection-related events for auditing and activity feeds. - The main `Connection` table stores only current state per user pair, - while this table preserves the complete history of actions. - - ## Actions - - - `"requested"` - User requested a connection - - `"accepted"` - User accepted a connection request - - `"rejected"` - User rejected a connection request - - `"removed"` - User removed an existing connection - - ## Fields - - - `user_a_uuid` / `user_b_uuid` - The two users involved (stored with lower UUID first for consistency) - - `actor_uuid` - The user who performed this action - """ - - use Ecto.Schema - import Ecto.Changeset - - alias PhoenixKit.Utils.Date, as: UtilsDate - - @primary_key {:uuid, UUIDv7, autogenerate: true} - @foreign_key_type UUIDv7 - - schema "phoenix_kit_user_connections_history" do - belongs_to :user_a, PhoenixKit.Users.Auth.User, - foreign_key: :user_a_uuid, - references: :uuid, - type: UUIDv7 - - belongs_to :user_b, PhoenixKit.Users.Auth.User, - foreign_key: :user_b_uuid, - references: :uuid, - type: UUIDv7 - - belongs_to :actor, PhoenixKit.Users.Auth.User, - foreign_key: :actor_uuid, - references: :uuid, - type: UUIDv7 - - field :action, :string - field :inserted_at, :utc_datetime - end - - @actions ~w(requested accepted rejected removed) - - @doc """ - Creates a changeset for a connection history record. - - The user_a_uuid and user_b_uuid are automatically normalized so that - the lower UUID is always stored as user_a_uuid for consistent querying. - """ - def changeset(history, attrs) do - attrs = normalize_user_uuids(attrs) - - history - |> cast(attrs, [ - :user_a_uuid, - :user_b_uuid, - :actor_uuid, - :action - ]) - |> validate_required([:user_a_uuid, :user_b_uuid, :actor_uuid, :action]) - |> validate_inclusion(:action, @actions) - |> put_timestamp() - |> foreign_key_constraint(:user_a_uuid) - |> foreign_key_constraint(:user_b_uuid) - |> foreign_key_constraint(:actor_uuid) - end - - # Normalize user UUIDs so user_a_uuid < user_b_uuid for consistent storage - defp normalize_user_uuids(%{user_a_uuid: a_uuid, user_b_uuid: b_uuid} = attrs) - when is_binary(a_uuid) and is_binary(b_uuid) and a_uuid > b_uuid do - %{attrs | user_a_uuid: b_uuid, user_b_uuid: a_uuid} - end - - defp normalize_user_uuids(attrs), do: attrs - - defp put_timestamp(changeset) do - put_change( - changeset, - :inserted_at, - UtilsDate.utc_now() - ) - end -end diff --git a/lib/modules/connections/connections.ex b/lib/modules/connections/connections.ex deleted file mode 100644 index 379709695..000000000 --- a/lib/modules/connections/connections.ex +++ /dev/null @@ -1,1150 +0,0 @@ -defmodule PhoenixKit.Modules.Connections do - @moduledoc """ - Connections module for PhoenixKit - Social Relationships System. - - Provides a complete social relationships system with two types of relationships: - - 1. **Follows** - One-way relationships (User A follows User B, no consent needed) - 2. **Connections** - Two-way mutual relationships (both users must accept) - - Plus **blocking** functionality to prevent unwanted interactions. - - ## Public API - - This is a **PUBLIC API** - all functions are available to parent applications - for use in their own views, components, and logic. - - ## Usage Examples - - ### In a User Profile Page - - alias PhoenixKit.Modules.Connections - - # Get relationship for rendering follow/connect buttons - relationship = Connections.get_relationship(current_user, profile_user) - - # Display counts - followers = Connections.followers_count(profile_user) - following = Connections.following_count(profile_user) - connections = Connections.connections_count(profile_user) - - ### In a LiveView - - def handle_event("follow", %{"user_uuid" => user_uuid}, socket) do - target_user = get_user(user_uuid) - - case Connections.follow(socket.assigns.current_user, target_user) do - {:ok, _follow} -> {:noreply, put_flash(socket, :info, "Now following!")} - {:error, reason} -> {:noreply, put_flash(socket, :error, reason)} - end - end - - ## Business Rules - - ### Following - - Cannot follow yourself - - Cannot follow if blocked (either direction) - - Instant, no approval needed - - ### Connections - - Cannot connect with yourself - - Cannot connect if blocked - - Requires acceptance from recipient - - If A requests B while B has pending request to A → auto-accept both - - ### Blocking - - Blocking removes any existing follow/connection between the users - - Blocked user cannot follow, connect, or view profile - - Blocking is one-way (A blocks B doesn't mean B blocks A) - """ - - use PhoenixKit.Module - - import Ecto.Query, warn: false - - alias PhoenixKit.Modules.Connections.Block - alias PhoenixKit.Modules.Connections.BlockHistory - alias PhoenixKit.Modules.Connections.Connection - alias PhoenixKit.Modules.Connections.ConnectionHistory - alias PhoenixKit.Modules.Connections.Follow - alias PhoenixKit.Modules.Connections.FollowHistory - alias PhoenixKit.Settings - - # ===== MODULE STATUS ===== - - @impl PhoenixKit.Module - @doc """ - Checks if the Connections module is enabled. - - ## Examples - - iex> PhoenixKit.Modules.Connections.enabled?() - true - """ - def enabled? do - Settings.get_boolean_setting("connections_enabled", false) - end - - @impl PhoenixKit.Module - @doc """ - Enables the Connections module. - """ - def enable_system do - Settings.update_boolean_setting("connections_enabled", true) - end - - @impl PhoenixKit.Module - @doc """ - Disables the Connections module. - """ - def disable_system do - Settings.update_boolean_setting("connections_enabled", false) - end - - @impl PhoenixKit.Module - @doc """ - Returns the Connections module configuration. - - Used by the Modules admin page to display module status and statistics. - - ## Returns - - A map containing: - - `:enabled` - Whether the module is enabled - - `:follows_count` - Total number of follows - - `:connections_count` - Total number of accepted connections - - `:pending_count` - Total number of pending connection requests - - `:blocks_count` - Total number of blocks - - ## Examples - - iex> Connections.get_config() - %{ - enabled: true, - follows_count: 100, - connections_count: 50, - pending_count: 5, - blocks_count: 3 - } - """ - def get_config do - %{ - enabled: enabled?(), - follows_count: get_total_follows_count(), - connections_count: get_total_connections_count(), - pending_count: get_total_pending_count(), - blocks_count: get_total_blocks_count() - } - end - - # ============================================================================ - # Module Behaviour Callbacks - # ============================================================================ - - @impl PhoenixKit.Module - def module_key, do: "connections" - - @impl PhoenixKit.Module - def module_name, do: "Connections" - - @impl PhoenixKit.Module - def permission_metadata do - %{ - key: "connections", - label: "Connections", - icon: "hero-link", - description: "External service connections and integrations" - } - end - - @doc """ - Returns statistics for the admin overview page. - - ## Returns - - A map containing: - - `:follows` - Total follows across all users - - `:connections` - Total accepted connections - - `:pending` - Total pending connection requests - - `:blocks` - Total blocks - - ## Examples - - iex> Connections.get_stats() - %{follows: 100, connections: 50, pending: 5, blocks: 3} - """ - def get_stats do - %{ - follows: get_total_follows_count(), - connections: get_total_connections_count(), - pending: get_total_pending_count(), - blocks: get_total_blocks_count() - } - end - - # ===== FOLLOWS ===== - - @doc """ - Creates a follow relationship. - - User A follows User B. No consent is required from User B. - - ## Parameters - - - `follower` - The user who is following (struct with uuid/id, or integer/UUID string) - - `followed` - The user being followed (struct with uuid/id, or integer/UUID string) - - ## Returns - - - `{:ok, %Follow{}}` - Follow created successfully - - `{:error, :blocked}` - Cannot follow due to block - - `{:error, :self_follow}` - Cannot follow yourself - - `{:error, %Ecto.Changeset{}}` - Validation error - - ## Examples - - iex> Connections.follow(current_user, target_user) - {:ok, %Follow{}} - - iex> Connections.follow(user, user) - {:error, :self_follow} - """ - def follow(follower, followed) do - follower_uuid = get_user_uuid(follower) - followed_uuid = get_user_uuid(followed) - - cond do - follower_uuid == followed_uuid -> - {:error, :self_follow} - - blocked?(followed_uuid, follower_uuid) || blocked?(follower_uuid, followed_uuid) -> - {:error, :blocked} - - following?(follower_uuid, followed_uuid) -> - {:error, :already_following} - - true -> - repo().transaction(fn -> - case %Follow{} - |> Follow.changeset(%{ - follower_uuid: follower_uuid, - followed_uuid: followed_uuid - }) - |> repo().insert() do - {:ok, follow} -> - log_follow_history( - follower_uuid, - followed_uuid, - "follow" - ) - - follow - - {:error, changeset} -> - repo().rollback(changeset) - end - end) - end - end - - @doc """ - Removes a follow relationship. - - ## Parameters - - - `follower` - The user who is following - - `followed` - The user being followed - - ## Returns - - - `{:ok, %Follow{}}` - Follow removed successfully - - `{:error, :not_following}` - No follow relationship exists - """ - def unfollow(follower, followed) do - follower_uuid = get_user_uuid(follower) - followed_uuid = get_user_uuid(followed) - - case get_follow(follower_uuid, followed_uuid) do - nil -> - {:error, :not_following} - - follow -> - repo().transaction(fn -> - case repo().delete(follow) do - {:ok, deleted} -> - log_follow_history( - follow.follower_uuid, - follow.followed_uuid, - "unfollow" - ) - - deleted - - {:error, changeset} -> - repo().rollback(changeset) - end - end) - end - end - - @doc """ - Checks if user A is following user B. - - ## Examples - - iex> Connections.following?(user_a, user_b) - true - """ - def following?(follower, followed) do - follower_uuid = get_user_uuid(follower) - followed_uuid = get_user_uuid(followed) - - Follow - |> where([f], f.follower_uuid == ^follower_uuid and f.followed_uuid == ^followed_uuid) - |> repo().exists?() - end - - @doc """ - Returns all followers of a user. - - ## Options - - - `:preload` - Preload the follower user (default: true) - - `:limit` - Maximum number of results - - `:offset` - Number of results to skip - - ## Examples - - iex> Connections.list_followers(user) - [%Follow{follower: %User{}}] - """ - def list_followers(user, opts \\ []) do - user_uuid = get_user_uuid(user) - preload = Keyword.get(opts, :preload, true) - - query = - Follow - |> where([f], f.followed_uuid == ^user_uuid) - |> order_by([f], desc: f.inserted_at) - |> maybe_limit(opts[:limit]) - |> maybe_offset(opts[:offset]) - - query = if preload, do: preload(query, [:follower]), else: query - - repo().all(query) - end - - @doc """ - Returns all users that a user is following. - - ## Options - - - `:preload` - Preload the followed user (default: true) - - `:limit` - Maximum number of results - - `:offset` - Number of results to skip - - ## Examples - - iex> Connections.list_following(user) - [%Follow{followed: %User{}}] - """ - def list_following(user, opts \\ []) do - user_uuid = get_user_uuid(user) - preload = Keyword.get(opts, :preload, true) - - query = - Follow - |> where([f], f.follower_uuid == ^user_uuid) - |> order_by([f], desc: f.inserted_at) - |> maybe_limit(opts[:limit]) - |> maybe_offset(opts[:offset]) - - query = if preload, do: preload(query, [:followed]), else: query - - repo().all(query) - end - - @doc """ - Returns the count of followers for a user. - - ## Examples - - iex> Connections.followers_count(user) - 42 - """ - def followers_count(user) do - user_uuid = get_user_uuid(user) - - Follow - |> where([f], f.followed_uuid == ^user_uuid) - |> repo().aggregate(:count) - end - - @doc """ - Returns the count of users that a user is following. - - ## Examples - - iex> Connections.following_count(user) - 100 - """ - def following_count(user) do - user_uuid = get_user_uuid(user) - - Follow - |> where([f], f.follower_uuid == ^user_uuid) - |> repo().aggregate(:count) - end - - # ===== CONNECTIONS ===== - - @doc """ - Sends a connection request from requester to recipient. - - If recipient already has a pending request to requester, both requests - are automatically accepted. - - ## Parameters - - - `requester` - The user sending the request - - `recipient` - The user receiving the request - - ## Returns - - - `{:ok, %Connection{status: "pending"}}` - Request sent - - `{:ok, %Connection{status: "accepted"}}` - Auto-accepted (mutual request) - - `{:error, :blocked}` - Cannot connect due to block - - `{:error, :self_connection}` - Cannot connect with yourself - - `{:error, :already_connected}` - Already connected - - `{:error, :pending_request}` - Already has pending request - """ - def request_connection(requester, recipient) do - requester_uuid = get_user_uuid(requester) - recipient_uuid = get_user_uuid(recipient) - - cond do - requester_uuid == recipient_uuid -> - {:error, :self_connection} - - blocked?(requester_uuid, recipient_uuid) || blocked?(recipient_uuid, requester_uuid) -> - {:error, :blocked} - - connected?(requester_uuid, recipient_uuid) -> - {:error, :already_connected} - - true -> - # Check if there's a pending request from recipient to requester - case get_pending_request_between(recipient_uuid, requester_uuid) do - %Connection{} = existing -> - # Auto-accept the existing request (mutual request) - # The accept_connection will log the "accepted" history entry - accept_connection_with_actor(existing, requester_uuid) - - nil -> - # Check if there's already a pending request from requester to recipient - case get_pending_request_between(requester_uuid, recipient_uuid) do - %Connection{} -> - {:error, :pending_request} - - nil -> - # Create new pending request - create_pending_connection( - requester_uuid, - recipient_uuid - ) - end - end - end - end - - @doc """ - Accepts a pending connection request. - - ## Parameters - - - `connection_or_uuid` - Connection struct or connection UUID - - ## Returns - - - `{:ok, %Connection{status: "accepted"}}` - Request accepted - - `{:error, :not_found}` - Connection not found - - `{:error, :not_pending}` - Connection is not pending - """ - def accept_connection(%Connection{status: "pending"} = connection) do - # When called directly, the actor is the recipient (who accepts) - accept_connection_with_actor(connection, connection.recipient_uuid) - end - - def accept_connection(%Connection{}), do: {:error, :not_pending} - - def accept_connection(connection_uuid) when is_binary(connection_uuid) do - case repo().get(Connection, connection_uuid) do - nil -> {:error, :not_found} - connection -> accept_connection(connection) - end - end - - # Internal function that tracks the actor for history - defp accept_connection_with_actor(%Connection{status: "pending"} = connection, actor_uuid) do - repo().transaction(fn -> - case connection - |> Connection.status_changeset(%{status: "accepted"}) - |> repo().update() do - {:ok, updated} -> - log_connection_history( - connection.requester_uuid, - connection.recipient_uuid, - actor_uuid, - "accepted" - ) - - updated - - {:error, changeset} -> - repo().rollback(changeset) - end - end) - end - - defp accept_connection_with_actor(%Connection{}, _actor_uuid), do: {:error, :not_pending} - - @doc """ - Rejects a pending connection request. - - ## Parameters - - - `connection_or_uuid` - Connection struct or connection UUID - - ## Returns - - - `{:ok, %Connection{status: "rejected"}}` - Request rejected - - `{:error, :not_found}` - Connection not found - - `{:error, :not_pending}` - Connection is not pending - """ - def reject_connection(%Connection{status: "pending"} = connection) do - repo().transaction(fn -> - # Log history before deleting (rejected connections are removed from main table) - log_connection_history( - connection.requester_uuid, - connection.recipient_uuid, - connection.recipient_uuid, - "rejected" - ) - - # Delete instead of updating to rejected status - case repo().delete(connection) do - {:ok, deleted} -> deleted - {:error, changeset} -> repo().rollback(changeset) - end - end) - end - - def reject_connection(%Connection{}), do: {:error, :not_pending} - - def reject_connection(connection_uuid) when is_binary(connection_uuid) do - case repo().get(Connection, connection_uuid) do - nil -> {:error, :not_found} - connection -> reject_connection(connection) - end - end - - @doc """ - Removes an existing connection between two users. - - Either user can remove the connection. - - ## Parameters - - - `user_a` - First user - - `user_b` - Second user - - ## Returns - - - `{:ok, %Connection{}}` - Connection removed - - `{:error, :not_connected}` - No connection exists - """ - def remove_connection(user_a, user_b) do - user_a_uuid = get_user_uuid(user_a) - user_b_uuid = get_user_uuid(user_b) - - case get_accepted_connection(user_a_uuid, user_b_uuid) do - nil -> - {:error, :not_connected} - - connection -> - repo().transaction(fn -> - # Log history - user_a is the actor (the one removing) - log_connection_history( - connection.requester_uuid, - connection.recipient_uuid, - user_a_uuid, - "removed" - ) - - case repo().delete(connection) do - {:ok, deleted} -> deleted - {:error, changeset} -> repo().rollback(changeset) - end - end) - end - end - - @doc """ - Checks if two users are connected (mutual connection exists). - - ## Examples - - iex> Connections.connected?(user_a, user_b) - true - """ - def connected?(user_a, user_b) do - user_a_uuid = get_user_uuid(user_a) - user_b_uuid = get_user_uuid(user_b) - - Connection - |> where([c], c.status == "accepted") - |> where( - [c], - (c.requester_uuid == ^user_a_uuid and c.recipient_uuid == ^user_b_uuid) or - (c.requester_uuid == ^user_b_uuid and c.recipient_uuid == ^user_a_uuid) - ) - |> repo().exists?() - end - - @doc """ - Returns all connections for a user. - - ## Options - - - `:preload` - Preload the other user (default: true) - - `:limit` - Maximum number of results - - `:offset` - Number of results to skip - - ## Examples - - iex> Connections.list_connections(user) - [%Connection{requester: %User{}, recipient: %User{}}] - """ - def list_connections(user, opts \\ []) do - user_uuid = get_user_uuid(user) - preload = Keyword.get(opts, :preload, true) - - query = - Connection - |> where([c], c.status == "accepted") - |> where([c], c.requester_uuid == ^user_uuid or c.recipient_uuid == ^user_uuid) - |> order_by([c], desc: c.responded_at) - |> maybe_limit(opts[:limit]) - |> maybe_offset(opts[:offset]) - - query = if preload, do: preload(query, [:requester, :recipient]), else: query - - repo().all(query) - end - - @doc """ - Returns pending incoming connection requests for a user. - - ## Options - - - `:preload` - Preload the requester user (default: true) - - `:limit` - Maximum number of results - - `:offset` - Number of results to skip - """ - def list_pending_requests(user, opts \\ []) do - user_uuid = get_user_uuid(user) - preload = Keyword.get(opts, :preload, true) - - query = - Connection - |> where([c], c.recipient_uuid == ^user_uuid and c.status == "pending") - |> order_by([c], desc: c.requested_at) - |> maybe_limit(opts[:limit]) - |> maybe_offset(opts[:offset]) - - query = if preload, do: preload(query, [:requester]), else: query - - repo().all(query) - end - - @doc """ - Returns pending outgoing connection requests sent by a user. - - ## Options - - - `:preload` - Preload the recipient user (default: true) - - `:limit` - Maximum number of results - - `:offset` - Number of results to skip - """ - def list_sent_requests(user, opts \\ []) do - user_uuid = get_user_uuid(user) - preload = Keyword.get(opts, :preload, true) - - query = - Connection - |> where([c], c.requester_uuid == ^user_uuid and c.status == "pending") - |> order_by([c], desc: c.requested_at) - |> maybe_limit(opts[:limit]) - |> maybe_offset(opts[:offset]) - - query = if preload, do: preload(query, [:recipient]), else: query - - repo().all(query) - end - - @doc """ - Returns the count of connections for a user. - - ## Examples - - iex> Connections.connections_count(user) - 50 - """ - def connections_count(user) do - user_uuid = get_user_uuid(user) - - Connection - |> where([c], c.status == "accepted") - |> where([c], c.requester_uuid == ^user_uuid or c.recipient_uuid == ^user_uuid) - |> repo().aggregate(:count) - end - - @doc """ - Returns the count of pending incoming connection requests for a user. - - ## Examples - - iex> Connections.pending_requests_count(user) - 5 - """ - def pending_requests_count(user) do - user_uuid = get_user_uuid(user) - - Connection - |> where([c], c.recipient_uuid == ^user_uuid and c.status == "pending") - |> repo().aggregate(:count) - end - - # ===== BLOCKS ===== - - @doc """ - Blocks a user. - - Blocking removes any existing follows and connections between the users. - - ## Parameters - - - `blocker` - The user who is blocking - - `blocked` - The user being blocked - - `reason` - Optional reason for the block - - ## Returns - - - `{:ok, %Block{}}` - Block created successfully - - `{:error, :self_block}` - Cannot block yourself - - `{:error, :already_blocked}` - User is already blocked - """ - def block(blocker, blocked, reason \\ nil) do - blocker_uuid = get_user_uuid(blocker) - blocked_uuid = get_user_uuid(blocked) - - cond do - blocker_uuid == blocked_uuid -> - {:error, :self_block} - - blocked?(blocker_uuid, blocked_uuid) -> - {:error, :already_blocked} - - true -> - repo().transaction(fn -> - # Remove any existing follows (both directions) - log history for each - remove_follows_between_with_history(blocker_uuid, blocked_uuid) - - # Remove any existing connections - log history - remove_connections_between_with_history(blocker_uuid, blocked_uuid) - - # Create the block - attrs = %{ - blocker_uuid: blocker_uuid, - blocked_uuid: blocked_uuid, - reason: reason - } - - case %Block{} |> Block.changeset(attrs) |> repo().insert() do - {:ok, block} -> - log_block_history( - blocker_uuid, - blocked_uuid, - "block", - reason - ) - - block - - {:error, changeset} -> - repo().rollback(changeset) - end - end) - end - end - - @doc """ - Removes a block. - - ## Parameters - - - `blocker` - The user who blocked - - `blocked` - The user who was blocked - - ## Returns - - - `{:ok, %Block{}}` - Block removed - - `{:error, :not_blocked}` - No block exists - """ - def unblock(blocker, blocked) do - blocker_uuid = get_user_uuid(blocker) - blocked_uuid = get_user_uuid(blocked) - - case get_block(blocker_uuid, blocked_uuid) do - nil -> - {:error, :not_blocked} - - block -> - repo().transaction(fn -> - case repo().delete(block) do - {:ok, deleted} -> - log_block_history( - block.blocker_uuid, - block.blocked_uuid, - "unblock", - nil - ) - - deleted - - {:error, changeset} -> - repo().rollback(changeset) - end - end) - end - end - - @doc """ - Checks if user A has blocked user B. - - ## Examples - - iex> Connections.blocked?(user_a, user_b) - true - """ - def blocked?(blocker, blocked) do - blocker_uuid = get_user_uuid(blocker) - blocked_uuid = get_user_uuid(blocked) - - Block - |> where([b], b.blocker_uuid == ^blocker_uuid and b.blocked_uuid == ^blocked_uuid) - |> repo().exists?() - end - - @doc """ - Checks if user is blocked by other user. - - ## Examples - - iex> Connections.blocked_by?(user, other) - true - """ - def blocked_by?(user, other) do - blocked?(other, user) - end - - @doc """ - Returns all users blocked by a user. - - ## Options - - - `:preload` - Preload the blocked user (default: true) - - `:limit` - Maximum number of results - - `:offset` - Number of results to skip - """ - def list_blocked(user, opts \\ []) do - user_uuid = get_user_uuid(user) - preload = Keyword.get(opts, :preload, true) - - query = - Block - |> where([b], b.blocker_uuid == ^user_uuid) - |> order_by([b], desc: b.inserted_at) - |> maybe_limit(opts[:limit]) - |> maybe_offset(opts[:offset]) - - query = if preload, do: preload(query, [:blocked]), else: query - - repo().all(query) - end - - @doc """ - Checks if two users can interact (neither has blocked the other). - - ## Examples - - iex> Connections.can_interact?(user_a, user_b) - true - """ - def can_interact?(user_a, user_b) do - user_a_uuid = get_user_uuid(user_a) - user_b_uuid = get_user_uuid(user_b) - - not (blocked?(user_a_uuid, user_b_uuid) or blocked?(user_b_uuid, user_a_uuid)) - end - - # ===== RELATIONSHIP STATUS ===== - - @doc """ - Gets the full relationship status between two users in one call. - - ## Parameters - - - `user_a` - First user - - `user_b` - Second user - - ## Returns - - A map containing: - - `:following` - Whether A follows B - - `:followed_by` - Whether B follows A - - `:connected` - Whether they have a mutual connection - - `:connection_pending` - `:sent`, `:received`, or `nil` - - `:blocked` - Whether A blocked B - - `:blocked_by` - Whether B blocked A - - ## Examples - - iex> Connections.get_relationship(user_a, user_b) - %{ - following: true, - followed_by: false, - connected: false, - connection_pending: :sent, - blocked: false, - blocked_by: false - } - """ - def get_relationship(user_a, user_b) do - user_a_uuid = get_user_uuid(user_a) - user_b_uuid = get_user_uuid(user_b) - - %{ - following: following?(user_a_uuid, user_b_uuid), - followed_by: following?(user_b_uuid, user_a_uuid), - connected: connected?(user_a_uuid, user_b_uuid), - connection_pending: get_connection_pending_status(user_a_uuid, user_b_uuid), - blocked: blocked?(user_a_uuid, user_b_uuid), - blocked_by: blocked?(user_b_uuid, user_a_uuid) - } - end - - # ===== PRIVATE HELPERS ===== - - defp repo do - PhoenixKit.Config.get_repo() - end - - # Resolves user UUID from struct or UUID string - defp get_user_uuid(%{uuid: uuid}) when is_binary(uuid), do: uuid - defp get_user_uuid(id) when is_binary(id), do: id - - defp get_follow(follower_uuid, followed_uuid) do - Follow - |> where([f], f.follower_uuid == ^follower_uuid and f.followed_uuid == ^followed_uuid) - |> repo().one() - end - - defp get_block(blocker_uuid, blocked_uuid) do - Block - |> where([b], b.blocker_uuid == ^blocker_uuid and b.blocked_uuid == ^blocked_uuid) - |> repo().one() - end - - defp get_pending_request_between(requester_uuid, recipient_uuid) do - Connection - |> where([c], c.requester_uuid == ^requester_uuid and c.recipient_uuid == ^recipient_uuid) - |> where([c], c.status == "pending") - |> repo().one() - end - - defp get_accepted_connection(user_a_uuid, user_b_uuid) do - Connection - |> where([c], c.status == "accepted") - |> where( - [c], - (c.requester_uuid == ^user_a_uuid and c.recipient_uuid == ^user_b_uuid) or - (c.requester_uuid == ^user_b_uuid and c.recipient_uuid == ^user_a_uuid) - ) - |> repo().one() - end - - defp get_connection_pending_status(user_a_uuid, user_b_uuid) do - # Check if user_a sent a pending request to user_b - sent = - Connection - |> where([c], c.requester_uuid == ^user_a_uuid and c.recipient_uuid == ^user_b_uuid) - |> where([c], c.status == "pending") - |> repo().exists?() - - if sent do - :sent - else - # Check if user_a received a pending request from user_b - received = - Connection - |> where([c], c.requester_uuid == ^user_b_uuid and c.recipient_uuid == ^user_a_uuid) - |> where([c], c.status == "pending") - |> repo().exists?() - - if received, do: :received, else: nil - end - end - - defp maybe_limit(query, nil), do: query - defp maybe_limit(query, limit), do: limit(query, ^limit) - - defp maybe_offset(query, nil), do: query - defp maybe_offset(query, offset), do: offset(query, ^offset) - - # Total counts for statistics - defp get_total_follows_count do - Follow - |> repo().aggregate(:count) - end - - defp get_total_connections_count do - Connection - |> where([c], c.status == "accepted") - |> repo().aggregate(:count) - end - - defp get_total_pending_count do - Connection - |> where([c], c.status == "pending") - |> repo().aggregate(:count) - end - - defp get_total_blocks_count do - Block - |> repo().aggregate(:count) - end - - # ===== HISTORY LOGGING ===== - - defp log_follow_history(follower_uuid, followed_uuid, action) do - %FollowHistory{} - |> FollowHistory.changeset(%{ - follower_uuid: follower_uuid, - followed_uuid: followed_uuid, - action: action - }) - |> repo().insert!() - end - - defp log_connection_history( - user_a_uuid, - user_b_uuid, - actor_uuid, - action - ) do - %ConnectionHistory{} - |> ConnectionHistory.changeset(%{ - user_a_uuid: user_a_uuid, - user_b_uuid: user_b_uuid, - actor_uuid: actor_uuid, - action: action - }) - |> repo().insert!() - end - - # Create a new pending connection request with history logging - defp create_pending_connection(requester_uuid, recipient_uuid) do - repo().transaction(fn -> - case %Connection{} - |> Connection.changeset(%{ - requester_uuid: requester_uuid, - recipient_uuid: recipient_uuid - }) - |> repo().insert() do - {:ok, connection} -> - log_connection_history( - requester_uuid, - recipient_uuid, - requester_uuid, - "requested" - ) - - connection - - {:error, changeset} -> - repo().rollback(changeset) - end - end) - end - - defp log_block_history(blocker_uuid, blocked_uuid, action, reason) do - %BlockHistory{} - |> BlockHistory.changeset(%{ - blocker_uuid: blocker_uuid, - blocked_uuid: blocked_uuid, - action: action, - reason: reason - }) - |> repo().insert!() - end - - # Remove follows between users with history logging - defp remove_follows_between_with_history(user_a_uuid, user_b_uuid) do - # Get follows in both directions - follows = - Follow - |> where( - [f], - (f.follower_uuid == ^user_a_uuid and f.followed_uuid == ^user_b_uuid) or - (f.follower_uuid == ^user_b_uuid and f.followed_uuid == ^user_a_uuid) - ) - |> repo().all() - - # Log history for each and delete - Enum.each(follows, fn follow -> - log_follow_history( - follow.follower_uuid, - follow.followed_uuid, - "unfollow" - ) - - repo().delete!(follow) - end) - end - - # Remove connections between users with history logging - defp remove_connections_between_with_history(actor_uuid, user_b_uuid) do - # Get connection between users - connections = - Connection - |> where( - [c], - (c.requester_uuid == ^actor_uuid and c.recipient_uuid == ^user_b_uuid) or - (c.requester_uuid == ^user_b_uuid and c.recipient_uuid == ^actor_uuid) - ) - |> repo().all() - - # Log history for each and delete - Enum.each(connections, fn connection -> - log_connection_history( - connection.requester_uuid, - connection.recipient_uuid, - actor_uuid, - "removed" - ) - - repo().delete!(connection) - end) - end -end diff --git a/lib/modules/connections/follow.ex b/lib/modules/connections/follow.ex deleted file mode 100644 index bf03e506d..000000000 --- a/lib/modules/connections/follow.ex +++ /dev/null @@ -1,110 +0,0 @@ -defmodule PhoenixKit.Modules.Connections.Follow do - @moduledoc """ - Schema for one-way follow relationships. - - Represents a unidirectional relationship where one user follows another. - No consent is required from the followed user. - - ## Fields - - - `follower_uuid` - UUID of the user who is doing the following - - `followed_uuid` - UUID of the user being followed - - `inserted_at` - When the follow was created - - ## Examples - - # User A follows User B - %Follow{ - uuid: "018e3c4a-9f6b-7890-abcd-ef1234567890", - follower_uuid: "019abc12-3456-7890-abcd-ef1234567890", - followed_uuid: "019abc12-9876-5432-abcd-ef1234567890", - inserted_at: ~N[2025-01-15 10:30:00] - } - - ## Business Rules - - - Cannot follow yourself - - Cannot follow if blocked (either direction) - - Duplicate follows are prevented by unique constraint - """ - use Ecto.Schema - import Ecto.Changeset - - alias PhoenixKit.Utils.Date, as: UtilsDate - - @primary_key {:uuid, UUIDv7, autogenerate: true} - - @type t :: %__MODULE__{ - uuid: UUIDv7.t() | nil, - follower_uuid: UUIDv7.t(), - followed_uuid: UUIDv7.t(), - follower: PhoenixKit.Users.Auth.User.t() | Ecto.Association.NotLoaded.t(), - followed: PhoenixKit.Users.Auth.User.t() | Ecto.Association.NotLoaded.t(), - inserted_at: DateTime.t() | nil - } - - schema "phoenix_kit_user_follows" do - belongs_to :follower, PhoenixKit.Users.Auth.User, - foreign_key: :follower_uuid, - references: :uuid, - type: UUIDv7 - - belongs_to :followed, PhoenixKit.Users.Auth.User, - foreign_key: :followed_uuid, - references: :uuid, - type: UUIDv7 - - field :inserted_at, :utc_datetime - end - - @doc """ - Changeset for creating a follow relationship. - - ## Required Fields - - - `follower_uuid` - UUID of the user who is following - - `followed_uuid` - UUID of the user being followed - - ## Validation Rules - - - Both user UUIDs are required - - Cannot follow yourself (follower_uuid != followed_uuid) - - Unique constraint on (follower_uuid, followed_uuid) pair - """ - def changeset(follow, attrs) do - follow - |> cast(attrs, [:follower_uuid, :followed_uuid]) - |> validate_required([:follower_uuid, :followed_uuid]) - |> validate_not_self_follow() - |> put_inserted_at() - |> foreign_key_constraint(:follower_uuid) - |> foreign_key_constraint(:followed_uuid) - |> unique_constraint([:follower_uuid, :followed_uuid], - name: :phoenix_kit_user_follows_unique_idx, - message: "already following this user" - ) - end - - defp validate_not_self_follow(changeset) do - follower_uuid = get_field(changeset, :follower_uuid) - followed_uuid = get_field(changeset, :followed_uuid) - - if follower_uuid && followed_uuid && follower_uuid == followed_uuid do - add_error(changeset, :followed_uuid, "cannot follow yourself") - else - changeset - end - end - - defp put_inserted_at(changeset) do - if get_field(changeset, :inserted_at) do - changeset - else - put_change( - changeset, - :inserted_at, - UtilsDate.utc_now() - ) - end - end -end diff --git a/lib/modules/connections/follow_history.ex b/lib/modules/connections/follow_history.ex deleted file mode 100644 index 7277a47e3..000000000 --- a/lib/modules/connections/follow_history.ex +++ /dev/null @@ -1,60 +0,0 @@ -defmodule PhoenixKit.Modules.Connections.FollowHistory do - @moduledoc """ - Schema for follow activity history. - - Records all follow/unfollow events for auditing and activity feeds. - The main `Follow` table stores only current state (active follows), - while this table preserves the complete history of actions. - - ## Actions - - - `"follow"` - User followed another user - - `"unfollow"` - User unfollowed another user - """ - - use Ecto.Schema - import Ecto.Changeset - - alias PhoenixKit.Utils.Date, as: UtilsDate - - @primary_key {:uuid, UUIDv7, autogenerate: true} - @foreign_key_type UUIDv7 - - schema "phoenix_kit_user_follows_history" do - belongs_to :follower, PhoenixKit.Users.Auth.User, - foreign_key: :follower_uuid, - references: :uuid, - type: UUIDv7 - - belongs_to :followed, PhoenixKit.Users.Auth.User, - foreign_key: :followed_uuid, - references: :uuid, - type: UUIDv7 - - field :action, :string - field :inserted_at, :utc_datetime - end - - @actions ~w(follow unfollow) - - @doc """ - Creates a changeset for a follow history record. - """ - def changeset(history, attrs) do - history - |> cast(attrs, [:follower_uuid, :followed_uuid, :action]) - |> validate_required([:follower_uuid, :followed_uuid, :action]) - |> validate_inclusion(:action, @actions) - |> put_timestamp() - |> foreign_key_constraint(:follower_uuid) - |> foreign_key_constraint(:followed_uuid) - end - - defp put_timestamp(changeset) do - put_change( - changeset, - :inserted_at, - UtilsDate.utc_now() - ) - end -end diff --git a/lib/phoenix_kit/module_registry.ex b/lib/phoenix_kit/module_registry.ex index 28bd1c99d..f342fd3cb 100644 --- a/lib/phoenix_kit/module_registry.ex +++ b/lib/phoenix_kit/module_registry.ex @@ -401,7 +401,6 @@ defmodule PhoenixKit.ModuleRegistry do # remove it from this list and add it to :modules config instead. defp internal_modules do [ - PhoenixKit.Modules.Connections, PhoenixKit.Modules.DB, PhoenixKit.Modules.Languages, PhoenixKit.Modules.Maintenance, diff --git a/lib/phoenix_kit/users/custom_fields.ex b/lib/phoenix_kit/users/custom_fields.ex index bd139243e..8ee91e0ce 100644 --- a/lib/phoenix_kit/users/custom_fields.ex +++ b/lib/phoenix_kit/users/custom_fields.ex @@ -401,6 +401,18 @@ defmodule PhoenixKit.Users.CustomFields do # Private Helpers + defp parse_position(nil), do: 0 + defp parse_position(pos) when is_integer(pos), do: pos + + defp parse_position(pos) when is_binary(pos) do + case Integer.parse(pos) do + {n, _} -> n + :error -> 0 + end + end + + defp parse_position(_), do: 0 + defp parse_definitions(json_string) do case Jason.decode(json_string) do {:ok, definitions} when is_list(definitions) -> definitions @@ -519,7 +531,7 @@ defmodule PhoenixKit.Users.CustomFields do if new_keys != [] do next_position = definitions - |> Enum.map(&(&1["position"] || 0)) + |> Enum.map(&parse_position(&1["position"])) |> Enum.max(fn -> 0 end) |> Kernel.+(1) diff --git a/lib/phoenix_kit_web/live/components/media_selector_modal.ex b/lib/phoenix_kit_web/live/components/media_selector_modal.ex index 9860c88b6..04ba925ab 100644 --- a/lib/phoenix_kit_web/live/components/media_selector_modal.ex +++ b/lib/phoenix_kit_web/live/components/media_selector_modal.ex @@ -62,6 +62,7 @@ defmodule PhoenixKitWeb.Live.Components.MediaSelectorModal do socket |> assign(assigns) |> assign(:has_buckets, has_buckets) + |> assign_new(:user_uuid, fn -> nil end) |> assign_new(:file_type_filter, fn -> :all end) |> assign_new(:search_query, fn -> "" end) |> assign_new(:current_page, fn -> 1 end) @@ -346,6 +347,13 @@ defmodule PhoenixKitWeb.Live.Components.MediaSelectorModal do query = from(f in File, order_by: [desc: f.inserted_at]) + query = + if socket.assigns[:user_uuid] do + where(query, [f], f.user_uuid == ^socket.assigns.user_uuid) + else + query + end + query = case filter do :image -> where(query, [f], f.file_type == "image") diff --git a/lib/phoenix_kit_web/live/components/user_media_selector_modal.ex b/lib/phoenix_kit_web/live/components/user_media_selector_modal.ex new file mode 100644 index 000000000..b1d2d011d --- /dev/null +++ b/lib/phoenix_kit_web/live/components/user_media_selector_modal.ex @@ -0,0 +1,94 @@ +defmodule PhoenixKitWeb.Live.Components.UserMediaSelectorModal do + @moduledoc """ + User-scoped media selector modal. + + A thin wrapper around `MediaSelectorModal` that automatically filters to only + show media files owned by the current user. Use this for user-facing pages + where users should only see and select their own uploads. + + ## Usage + + <.live_component + module={PhoenixKitWeb.Live.Components.UserMediaSelectorModal} + id="user-media-selector" + show={@show_media_selector} + mode={@media_selection_mode} + selected_uuids={@media_selected_uuids} + phoenix_kit_current_user={@phoenix_kit_current_user} + /> + + ## Optional assigns + + * `on_select` — `{module, id, action}` tuple. When provided, selection results + are sent via `send_update(module, %{id: id, action: action, file_uuid: uuid})` + instead of `send(self(), {:media_selected, uuids})`. This allows embedding + inside LiveComponents without requiring the parent LiveView to forward messages. + + All other assigns are passed through to `MediaSelectorModal`, with `user_uuid` + automatically injected from `phoenix_kit_current_user`. + """ + use PhoenixKitWeb, :live_component + + alias PhoenixKitWeb.Live.Components.MediaSelectorModal + + @impl true + def update(assigns, socket) do + user_uuid = + case assigns[:phoenix_kit_current_user] do + %{uuid: uuid} -> uuid + _ -> nil + end + + # Store on_select callback before delegating (MediaSelectorModal won't touch it) + socket = + if assigns[:on_select] do + assign(socket, :on_select, assigns[:on_select]) + else + assign_new(socket, :on_select, fn -> nil end) + end + + assigns = Map.put(assigns, :user_uuid, user_uuid) + + MediaSelectorModal.update(assigns, socket) + end + + @impl true + def render(assigns) do + MediaSelectorModal.render(assigns) + end + + @impl true + def handle_event("confirm_selection", _params, socket) do + selected_uuids = socket.assigns.selected_uuids |> MapSet.to_list() + + case socket.assigns[:on_select] do + {module, id, action} -> + file_uuid = List.first(selected_uuids) + + if file_uuid do + send_update(module, %{id: id, action: action, file_uuid: file_uuid}) + end + + _ -> + send(self(), {:media_selected, selected_uuids}) + end + + {:noreply, assign(socket, :show, false)} + end + + def handle_event("close_modal", _params, socket) do + case socket.assigns[:on_select] do + {module, id, _action} -> + send_update(module, %{id: id, action: :avatar_selector_closed}) + + _ -> + send(self(), {:media_selector_closed}) + end + + {:noreply, assign(socket, :show, false)} + end + + def handle_event(event, params, socket) do + MediaSelectorModal.handle_event(event, params, socket) + end +end diff --git a/lib/phoenix_kit_web/live/components/user_settings.ex b/lib/phoenix_kit_web/live/components/user_settings.ex index 47ebfe7e5..8b731a777 100644 --- a/lib/phoenix_kit_web/live/components/user_settings.ex +++ b/lib/phoenix_kit_web/live/components/user_settings.ex @@ -35,7 +35,6 @@ defmodule PhoenixKitWeb.Live.Components.UserSettings do require Logger - alias PhoenixKit.Modules.Storage alias PhoenixKit.Settings alias PhoenixKit.Users.Auth alias PhoenixKit.Users.CustomFields @@ -46,29 +45,34 @@ defmodule PhoenixKitWeb.Live.Components.UserSettings do @default_sections [:identity, :custom_fields, :email, :password, :oauth] @impl true - def update(%{action: :check_avatar_uploads_complete}, socket) do - entries = socket.assigns.uploads.avatar.entries - - Logger.info( - "check_avatar_uploads_complete: entries=#{length(entries)}, done?=#{inspect(Enum.map(entries, & &1.done?))}" - ) - - if entries != [] && Enum.all?(entries, & &1.done?) do - Logger.info("Avatar uploads done! Processing...") - process_avatar_uploads(socket) - else - Logger.info("Still uploading avatar, checking again...") + def update(%{action: :set_avatar, file_uuid: file_uuid}, socket) do + user = socket.assigns.user - send_update_after( - __MODULE__, - %{id: socket.assigns.id, action: :check_avatar_uploads_complete}, - 500 - ) + case Auth.update_user_fields(user, %{"avatar_file_uuid" => file_uuid}) do + {:ok, updated_user} -> + send(self(), {:phoenix_kit_user_updated, updated_user}) - {:ok, socket} + {:ok, + socket + |> assign(:user, updated_user) + |> assign(:show_avatar_selector, false) + |> assign(:last_uploaded_avatar_uuid, file_uuid) + |> assign(:avatar_success_message, gettext("Avatar updated successfully!")) + |> assign(:avatar_error_message, nil)} + + {:error, _changeset} -> + {:ok, + socket + |> assign(:show_avatar_selector, false) + |> assign(:avatar_error_message, gettext("Failed to update avatar")) + |> assign(:avatar_success_message, nil)} end end + def update(%{action: :avatar_selector_closed}, socket) do + {:ok, assign(socket, :show_avatar_selector, false)} + end + def update(assigns, socket) do user = assigns[:user] || socket.assigns[:user] sections = assigns[:sections] || socket.assigns[:sections] || @default_sections @@ -126,26 +130,13 @@ defmodule PhoenixKitWeb.Live.Components.UserSettings do CustomFields.list_user_accessible_field_definitions() end) |> assign_new(:last_uploaded_avatar_uuid, fn -> nil end) + |> assign_new(:show_avatar_selector, fn -> false end) |> assign_new(:show_email_form, fn -> false end) |> assign_new(:show_password_form, fn -> false end) - |> maybe_allow_upload() {:ok, socket} end - defp maybe_allow_upload(socket) do - if socket.assigns[:uploads] && socket.assigns.uploads[:avatar] do - socket - else - allow_upload(socket, :avatar, - accept: ["image/*"], - max_entries: 1, - max_file_size: 10_000_000, - auto_upload: true - ) - end - end - # Event handlers @impl true @@ -249,23 +240,11 @@ defmodule PhoenixKitWeb.Live.Components.UserSettings do end def handle_event("validate_profile", params, socket) do - %{"user" => user_params} = params - - socket = - if params["_target"] == ["avatar"] do - entries = socket.assigns.uploads.avatar.entries - - if entries != [] do - send_update_after( - __MODULE__, - %{id: socket.assigns.id, action: :check_avatar_uploads_complete}, - 500 - ) - end - - socket - else - socket + user_params = + case params do + %{"user" => user_params} -> user_params + %{"profile_form" => %{"user" => user_params}} -> user_params + _ -> %{} end socket = @@ -300,7 +279,13 @@ defmodule PhoenixKitWeb.Live.Components.UserSettings do end def handle_event("update_profile", params, socket) do - %{"user" => user_params} = params + user_params = + case params do + %{"user" => user_params} -> user_params + %{"profile_form" => %{"user" => user_params}} -> user_params + _ -> %{} + end + user = socket.assigns.user merged_params = merge_custom_fields_for_save(params, user_params, user) @@ -424,8 +409,8 @@ defmodule PhoenixKitWeb.Live.Components.UserSettings do end end - def handle_event("cancel_upload", %{"ref" => ref}, socket) do - {:noreply, cancel_upload(socket, :avatar, ref)} + def handle_event("open_avatar_selector", _params, socket) do + {:noreply, assign(socket, :show_avatar_selector, true)} end def handle_event("toggle_email_form", _params, socket) do @@ -564,97 +549,6 @@ defmodule PhoenixKitWeb.Live.Components.UserSettings do defp format_provider_name("github"), do: "GitHub" defp format_provider_name(provider), do: String.capitalize(provider) - defp process_avatar_uploads(socket) do - uploaded_avatars = - consume_uploaded_entries(socket, :avatar, fn %{path: path}, entry -> - ext = Path.extname(entry.client_name) |> String.replace_leading(".", "") - current_user = socket.assigns.user - user_uuid = current_user.uuid - - {:ok, stat} = Elixir.File.stat(path) - file_size = stat.size - file_hash = Auth.calculate_file_hash(path) - - case Storage.store_file_in_buckets( - path, - "image", - user_uuid, - file_hash, - ext, - entry.client_name - ) do - {:ok, file, :duplicate} -> - Logger.info("Avatar file is duplicate with ID: #{file.uuid}") - - {:ok, - %{ - file_uuid: file.uuid, - filename: entry.client_name, - size: file_size, - duplicate: true - }} - - {:ok, file} -> - Logger.info("Avatar file stored with ID: #{file.uuid}") - - {:ok, - %{ - file_uuid: file.uuid, - filename: entry.client_name, - size: file_size - }} - - {:error, reason} -> - Logger.error("Storage Error: #{inspect(reason)}") - {:error, reason} - end - end) - - Logger.info("Uploaded avatars: #{inspect(uploaded_avatars)}") - avatar_file_uuids = Enum.map(uploaded_avatars, &get_avatar_file_uuid/1) - Logger.info("Avatar file UUIDs: #{inspect(avatar_file_uuids)}") - avatar_file_uuid = List.first(avatar_file_uuids) - Logger.info("First avatar file UUID: #{inspect(avatar_file_uuid)}") - - socket = - if avatar_file_uuid && avatar_file_uuid != nil do - user = socket.assigns.user - - case Auth.update_user_fields(user, %{"avatar_file_uuid" => avatar_file_uuid}) do - {:ok, updated_user} -> - Logger.info("Avatar file UUID saved: #{avatar_file_uuid}") - send(self(), {:phoenix_kit_user_updated, updated_user}) - - socket - |> assign(:user, updated_user) - |> assign(:last_uploaded_avatar_uuid, avatar_file_uuid) - |> assign(:avatar_success_message, gettext("Avatar uploaded successfully!")) - |> assign(:avatar_error_message, nil) - - {:error, changeset} -> - Logger.error("Failed to save avatar file UUID: #{inspect(changeset)}") - - socket - |> assign(:last_uploaded_avatar_uuid, avatar_file_uuid) - |> assign( - :avatar_error_message, - gettext("Avatar uploaded but failed to save to profile") - ) - |> assign(:avatar_success_message, nil) - end - else - socket - |> assign(:avatar_error_message, gettext("Failed to upload avatar")) - |> assign(:avatar_success_message, nil) - end - - {:ok, socket} - end - - defp get_avatar_file_uuid(%{file_uuid: file_uuid}), do: file_uuid - defp get_avatar_file_uuid({:ok, %{file_uuid: file_uuid}}), do: file_uuid - defp get_avatar_file_uuid(_), do: nil - @impl Phoenix.LiveComponent def render(assigns) do ~H""" @@ -714,10 +608,23 @@ defmodule PhoenixKitWeb.Live.Components.UserSettings do <% end %> - <.file_upload - upload={@uploads.avatar} - variant="button" - label="Upload" + + + <.live_component + module={PhoenixKitWeb.Live.Components.UserMediaSelectorModal} + id={"#{@id}-avatar-media-selector"} + show={@show_avatar_selector} + mode={:single} + selected_uuids={[]} + phoenix_kit_current_user={@user} + on_select={{PhoenixKitWeb.Live.Components.UserSettings, @id, :set_avatar}} /> diff --git a/lib/phoenix_kit_web/live/modules/connections/connections.ex b/lib/phoenix_kit_web/live/modules/connections/connections.ex deleted file mode 100644 index 8f02375f2..000000000 --- a/lib/phoenix_kit_web/live/modules/connections/connections.ex +++ /dev/null @@ -1,81 +0,0 @@ -defmodule PhoenixKitWeb.Live.Modules.Connections.Connections do - @moduledoc """ - Admin LiveView for the Connections module. - - Provides an overview of all social relationships in the system, - statistics, and moderation capabilities. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Connections - alias PhoenixKit.Settings - alias PhoenixKit.Users.Roles - alias PhoenixKit.Utils.Routes - - @impl true - def mount(_params, _session, socket) do - current_user = socket.assigns[:phoenix_kit_current_user] - - if can_access?(current_user) do - project_title = Settings.get_project_title() - - socket = - socket - |> assign(:page_title, "Connections") - |> assign(:project_title, project_title) - |> assign(:current_user, current_user) - |> load_stats() - - {:ok, socket} - else - {:ok, - socket - |> put_flash(:error, "Access denied") - |> push_navigate(to: Routes.path("/admin"))} - end - end - - @impl true - def handle_params(_params, uri, socket) do - {:noreply, assign(socket, :url_path, URI.parse(uri).path)} - end - - @impl true - def handle_event("toggle_enabled", _params, socket) do - new_value = !socket.assigns.enabled - - result = - if new_value do - Connections.enable_system() - else - Connections.disable_system() - end - - case result do - {:ok, _} -> - {:noreply, - socket - |> put_flash( - :info, - if(new_value, do: "Connections enabled", else: "Connections disabled") - ) - |> assign(:enabled, new_value)} - - {:error, _} -> - {:noreply, put_flash(socket, :error, "Failed to update setting")} - end - end - - defp can_access?(nil), do: false - - defp can_access?(user) do - Roles.user_has_role_owner?(user) or Roles.user_has_role_admin?(user) - end - - defp load_stats(socket) do - socket - |> assign(:enabled, Connections.enabled?()) - |> assign(:stats, Connections.get_stats()) - end -end diff --git a/lib/phoenix_kit_web/live/modules/connections/connections.html.heex b/lib/phoenix_kit_web/live/modules/connections/connections.html.heex deleted file mode 100644 index eb58599c9..000000000 --- a/lib/phoenix_kit_web/live/modules/connections/connections.html.heex +++ /dev/null @@ -1,153 +0,0 @@ - -
- <.admin_page_header - back={PhoenixKit.Utils.Routes.path("/admin/modules")} - title="Connections" - subtitle="Social relationships overview and management" - /> - - <%!-- Main Content --%> -
- <%!-- Stats Overview --%> -
-
-

- <.icon name="hero-chart-bar" class="w-6 h-6" /> Statistics -

-
-
-
Follows
-
{@stats.follows}
-
One-way follows
-
-
-
Connections
-
{@stats.connections}
-
Mutual connections
-
-
-
Pending
-
{@stats.pending}
-
Awaiting response
-
-
-
Blocks
-
{@stats.blocks}
-
Active blocks
-
-
-
-
- - <%!-- Module Settings --%> -
-
-

- <.icon name="hero-cog-6-tooth" class="w-6 h-6" /> Module Configuration -

- - <%!-- Enable/Disable --%> -
- -
-
-
- - <%!-- Relationship Types Info --%> -
-
-

- <.icon name="hero-information-circle" class="w-6 h-6" /> Relationship Types -

- -
- <%!-- Follows --%> -
-
- <.icon name="hero-user-plus" class="w-5 h-5 text-primary" /> - Follows -
-

- One-way relationships where User A follows User B without requiring consent. - Similar to Twitter/Instagram follows. -

-
- - <%!-- Connections --%> -
-
- <.icon name="hero-users" class="w-5 h-5 text-success" /> - Connections -
-

- Two-way mutual relationships that require acceptance from both parties. - Similar to LinkedIn connections or Facebook friends. -

-
- - <%!-- Blocks --%> -
-
- <.icon name="hero-no-symbol" class="w-5 h-5 text-error" /> - Blocks -
-

- Prevents all interaction between users. Blocking removes any existing - follows and connections between the users. -

-
-
-
-
- - <%!-- API Usage --%> -
-
-

- <.icon name="hero-code-bracket" class="w-6 h-6" /> Public API -

- -

- The Connections module provides a public API that parent applications can use - to integrate social features into their own views and components. -

- -
-
alias PhoenixKit.Modules.Connections
-
-
 Follows
-
Connections.follow(current_user, target_user)
-
Connections.following?(current_user, target_user)
-
Connections.list_followers(user)
-
-
 Connections
-
Connections.request_connection(user_a, user_b)
-
Connections.connected?(user_a, user_b)
-
-
 Full relationship status
-
Connections.get_relationship(user_a, user_b)
-
-
-
-
-
-
diff --git a/lib/phoenix_kit_web/live/modules/connections/user_connections.ex b/lib/phoenix_kit_web/live/modules/connections/user_connections.ex deleted file mode 100644 index f7e137800..000000000 --- a/lib/phoenix_kit_web/live/modules/connections/user_connections.ex +++ /dev/null @@ -1,202 +0,0 @@ -defmodule PhoenixKitWeb.Live.Modules.Connections.UserConnections do - @moduledoc """ - User-facing LiveView for managing personal connections. - - Provides tabs for: - - Followers - Users who follow the current user - - Following - Users the current user follows - - Connections - Mutual connections - - Requests - Pending incoming/outgoing connection requests - - Blocked - Users blocked by the current user - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.Connections - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Routes - - @tabs ~w(followers following connections requests blocked) - @default_tab "connections" - - @impl true - def mount(_params, _session, socket) do - current_user = socket.assigns[:phoenix_kit_current_user] - - if current_user && Connections.enabled?() do - project_title = Settings.get_project_title() - - socket = - socket - |> assign(:page_title, "My Connections") - |> assign(:project_title, project_title) - |> assign(:current_user, current_user) - |> assign(:tab, @default_tab) - |> load_counts() - |> load_tab_data(@default_tab) - - {:ok, socket} - else - message = if current_user, do: "Connections module is disabled", else: "Please log in" - - {:ok, - socket - |> put_flash(:error, message) - |> push_navigate(to: Routes.path("/"))} - end - end - - @impl true - def handle_params(%{"tab" => tab}, uri, socket) when tab in @tabs do - socket = - socket - |> assign(:url_path, URI.parse(uri).path) - |> assign(:tab, tab) - |> load_tab_data(tab) - - {:noreply, socket} - end - - @impl true - def handle_params(_params, uri, socket) do - {:noreply, - socket - |> assign(:url_path, URI.parse(uri).path) - |> assign(:tab, @default_tab) - |> load_tab_data(@default_tab)} - end - - @impl true - def handle_event("unfollow", %{"uuid" => user_uuid}, socket) do - case Connections.unfollow(socket.assigns.current_user, user_uuid) do - {:ok, _} -> - {:noreply, - socket - |> put_flash(:info, "Unfollowed successfully") - |> load_counts() - |> load_tab_data(socket.assigns.tab)} - - {:error, _} -> - {:noreply, put_flash(socket, :error, "Failed to unfollow")} - end - end - - @impl true - def handle_event("remove_follower", %{"uuid" => _user_uuid}, socket) do - # Remove follower by having them unfollow us (via block/unblock or admin action) - # For now, we just show a message - actual follower removal would need admin rights - {:noreply, put_flash(socket, :info, "Follower removal requires blocking the user")} - end - - @impl true - def handle_event("accept_request", %{"id" => connection_uuid}, socket) do - case Connections.accept_connection(connection_uuid) do - {:ok, _} -> - {:noreply, - socket - |> put_flash(:info, "Connection accepted!") - |> load_counts() - |> load_tab_data(socket.assigns.tab)} - - {:error, _} -> - {:noreply, put_flash(socket, :error, "Failed to accept request")} - end - end - - @impl true - def handle_event("reject_request", %{"id" => connection_uuid}, socket) do - case Connections.reject_connection(connection_uuid) do - {:ok, _} -> - {:noreply, - socket - |> put_flash(:info, "Connection rejected") - |> load_counts() - |> load_tab_data(socket.assigns.tab)} - - {:error, _} -> - {:noreply, put_flash(socket, :error, "Failed to reject request")} - end - end - - @impl true - def handle_event("remove_connection", %{"uuid" => user_uuid}, socket) do - case Connections.remove_connection(socket.assigns.current_user, user_uuid) do - {:ok, _} -> - {:noreply, - socket - |> put_flash(:info, "Connection removed") - |> load_counts() - |> load_tab_data(socket.assigns.tab)} - - {:error, _} -> - {:noreply, put_flash(socket, :error, "Failed to remove connection")} - end - end - - @impl true - def handle_event("unblock", %{"uuid" => user_uuid}, socket) do - case Connections.unblock(socket.assigns.current_user, user_uuid) do - {:ok, _} -> - {:noreply, - socket - |> put_flash(:info, "User unblocked") - |> load_counts() - |> load_tab_data(socket.assigns.tab)} - - {:error, _} -> - {:noreply, put_flash(socket, :error, "Failed to unblock user")} - end - end - - defp load_counts(socket) do - user = socket.assigns.current_user - - socket - |> assign(:followers_count, Connections.followers_count(user)) - |> assign(:following_count, Connections.following_count(user)) - |> assign(:connections_count, Connections.connections_count(user)) - |> assign(:pending_count, Connections.pending_requests_count(user)) - |> assign(:blocked_count, length(Connections.list_blocked(user, preload: false))) - end - - defp load_tab_data(socket, "followers") do - followers = Connections.list_followers(socket.assigns.current_user) - assign(socket, :items, followers) - end - - defp load_tab_data(socket, "following") do - following = Connections.list_following(socket.assigns.current_user) - assign(socket, :items, following) - end - - defp load_tab_data(socket, "connections") do - connections = Connections.list_connections(socket.assigns.current_user) - assign(socket, :items, connections) - end - - defp load_tab_data(socket, "requests") do - incoming = Connections.list_pending_requests(socket.assigns.current_user) - outgoing = Connections.list_sent_requests(socket.assigns.current_user) - - socket - |> assign(:incoming_requests, incoming) - |> assign(:outgoing_requests, outgoing) - |> assign(:items, []) - end - - defp load_tab_data(socket, "blocked") do - blocked = Connections.list_blocked(socket.assigns.current_user) - assign(socket, :items, blocked) - end - - defp load_tab_data(socket, _), do: assign(socket, :items, []) - - # Helper to get the other user from a connection - def get_other_user(connection, current_user_uuid) do - if connection.requester_uuid == current_user_uuid do - connection.recipient - else - connection.requester - end - end -end diff --git a/lib/phoenix_kit_web/live/modules/connections/user_connections.html.heex b/lib/phoenix_kit_web/live/modules/connections/user_connections.html.heex deleted file mode 100644 index 27b81a487..000000000 --- a/lib/phoenix_kit_web/live/modules/connections/user_connections.html.heex +++ /dev/null @@ -1,345 +0,0 @@ - -
- <.admin_page_header - back={PhoenixKit.Utils.Routes.path("/admin/modules/connections")} - title="My Connections" - subtitle="Manage your followers, following, and connections" - /> - - <%!-- Tabs Navigation --%> -
-
- <.link - navigate={PhoenixKit.Utils.Routes.path("/profile/connections?tab=followers")} - role="tab" - class={"tab #{if @tab == "followers", do: "tab-active"}"} - > - Followers {@followers_count} - - <.link - navigate={PhoenixKit.Utils.Routes.path("/profile/connections?tab=following")} - role="tab" - class={"tab #{if @tab == "following", do: "tab-active"}"} - > - Following {@following_count} - - <.link - navigate={PhoenixKit.Utils.Routes.path("/profile/connections?tab=connections")} - role="tab" - class={"tab #{if @tab == "connections", do: "tab-active"}"} - > - Connections {@connections_count} - - <.link - navigate={PhoenixKit.Utils.Routes.path("/profile/connections?tab=requests")} - role="tab" - class={"tab #{if @tab == "requests", do: "tab-active"}"} - > - Requests - <%= if @pending_count > 0 do %> - {@pending_count} - <% end %> - - <.link - navigate={PhoenixKit.Utils.Routes.path("/profile/connections?tab=blocked")} - role="tab" - class={"tab #{if @tab == "blocked", do: "tab-active"}"} - > - Blocked {@blocked_count} - -
-
- - <%!-- Tab Content --%> -
- <%!-- Followers Tab --%> - <%= if @tab == "followers" do %> -
-
-

- <.icon name="hero-user-group" class="w-5 h-5" /> Your Followers -

- - <%= if Enum.empty?(@items) do %> -
- <.icon name="hero-user-group" class="w-12 h-12 mx-auto mb-2 opacity-50" /> -

No followers yet

-
- <% else %> -
- <%= for follow <- @items do %> -
-
-
-
- - {String.first(follow.follower.email || "?")} - -
-
-
-

{follow.follower.email}

-

- Followed you <.time_ago datetime={follow.inserted_at} /> -

-
-
-
- <% end %> -
- <% end %> -
-
- <% end %> - - <%!-- Following Tab --%> - <%= if @tab == "following" do %> -
-
-

- <.icon name="hero-user-plus" class="w-5 h-5" /> People You Follow -

- - <%= if Enum.empty?(@items) do %> -
- <.icon name="hero-user-plus" class="w-12 h-12 mx-auto mb-2 opacity-50" /> -

You're not following anyone yet

-
- <% else %> -
- <%= for follow <- @items do %> -
-
-
-
- - {String.first(follow.followed.email || "?")} - -
-
-
-

{follow.followed.email}

-

- Following since <.time_ago datetime={follow.inserted_at} /> -

-
-
- -
- <% end %> -
- <% end %> -
-
- <% end %> - - <%!-- Connections Tab --%> - <%= if @tab == "connections" do %> -
-
-

- <.icon name="hero-users" class="w-5 h-5" /> Your Connections -

- - <%= if Enum.empty?(@items) do %> -
- <.icon name="hero-users" class="w-12 h-12 mx-auto mb-2 opacity-50" /> -

No connections yet

-
- <% else %> -
- <%= for connection <- @items do %> - <% other_user = - PhoenixKitWeb.Live.Modules.Connections.UserConnections.get_other_user( - connection, - @current_user.uuid - ) %> -
-
-
-
- {String.first(other_user.email || "?")} -
-
-
-

{other_user.email}

-

- Connected <.time_ago datetime={connection.responded_at} /> -

-
-
- -
- <% end %> -
- <% end %> -
-
- <% end %> - - <%!-- Requests Tab --%> - <%= if @tab == "requests" do %> -
- <%!-- Incoming Requests --%> -
-
-

- <.icon name="hero-inbox" class="w-5 h-5" /> Incoming Requests -

- - <%= if Enum.empty?(@incoming_requests) do %> -
- <.icon name="hero-inbox" class="w-12 h-12 mx-auto mb-2 opacity-50" /> -

No pending requests

-
- <% else %> -
- <%= for request <- @incoming_requests do %> -
-
-
-
- - {String.first(request.requester.email || "?")} - -
-
-
-

{request.requester.email}

-

- Requested <.time_ago datetime={request.requested_at} /> -

-
-
-
- - -
-
- <% end %> -
- <% end %> -
-
- - <%!-- Outgoing Requests --%> -
-
-

- <.icon name="hero-paper-airplane" class="w-5 h-5" /> Sent Requests -

- - <%= if Enum.empty?(@outgoing_requests) do %> -
- <.icon name="hero-paper-airplane" class="w-12 h-12 mx-auto mb-2 opacity-50" /> -

No pending outgoing requests

-
- <% else %> -
- <%= for request <- @outgoing_requests do %> -
-
-
-
- - {String.first(request.recipient.email || "?")} - -
-
-
-

{request.recipient.email}

-

- Sent <.time_ago datetime={request.requested_at} /> -

-
-
- Pending -
- <% end %> -
- <% end %> -
-
-
- <% end %> - - <%!-- Blocked Tab --%> - <%= if @tab == "blocked" do %> -
-
-

- <.icon name="hero-no-symbol" class="w-5 h-5" /> Blocked Users -

- - <%= if Enum.empty?(@items) do %> -
- <.icon name="hero-no-symbol" class="w-12 h-12 mx-auto mb-2 opacity-50" /> -

No blocked users

-
- <% else %> -
- <%= for block <- @items do %> -
-
-
-
- {String.first(block.blocked.email || "?")} -
-
-
-

{block.blocked.email}

-

- Blocked <.time_ago datetime={block.inserted_at} /> -

- <%= if block.reason do %> -

Reason: {block.reason}

- <% end %> -
-
- -
- <% end %> -
- <% end %> -
-
- <% end %> -
-
-
diff --git a/lib/phoenix_kit_web/live/users/user_details.ex b/lib/phoenix_kit_web/live/users/user_details.ex index d1a9b1b7f..42d12416a 100644 --- a/lib/phoenix_kit_web/live/users/user_details.ex +++ b/lib/phoenix_kit_web/live/users/user_details.ex @@ -10,8 +10,9 @@ defmodule PhoenixKitWeb.Live.Users.UserDetails do use PhoenixKitWeb, :live_view use Gettext, backend: PhoenixKitWeb.Gettext + @compile {:no_warn_undefined, PhoenixKitUserConnections} + alias PhoenixKit.Admin.Events - alias PhoenixKit.Modules.Connections alias PhoenixKit.Settings alias PhoenixKit.Users.AdminNote alias PhoenixKit.Users.Auth @@ -37,20 +38,20 @@ defmodule PhoenixKitWeb.Live.Users.UserDetails do custom_field_definitions = CustomFields.list_field_definitions() - # Load connections stats if module is enabled - connections_enabled = Connections.enabled?() - - connections_stats = - if connections_enabled do - %{ - followers: Connections.followers_count(user), - following: Connections.following_count(user), - connections: Connections.connections_count(user), - pending: Connections.pending_requests_count(user), - blocked: length(Connections.list_blocked(user)) - } + # Load connections stats if module is available and enabled + {connections_enabled, connections_stats} = + if Code.ensure_loaded?(PhoenixKitUserConnections) and + PhoenixKitUserConnections.enabled?() do + {true, + %{ + followers: PhoenixKitUserConnections.followers_count(user), + following: PhoenixKitUserConnections.following_count(user), + connections: PhoenixKitUserConnections.connections_count(user), + pending: PhoenixKitUserConnections.pending_requests_count(user), + blocked: length(PhoenixKitUserConnections.list_blocked(user)) + }} else - nil + {false, nil} end # Load admin notes diff --git a/test/phoenix_kit/module_registry_test.exs b/test/phoenix_kit/module_registry_test.exs index 7281fd8a8..211cc90c5 100644 --- a/test/phoenix_kit/module_registry_test.exs +++ b/test/phoenix_kit/module_registry_test.exs @@ -18,10 +18,8 @@ defmodule PhoenixKit.ModuleRegistryTest do # Verify known modules are present rather than asserting a hardcoded count, # so this test doesn't break when modules are extracted or added. expected = [ - PhoenixKit.Modules.Connections, PhoenixKit.Modules.DB, PhoenixKit.Modules.Languages, - PhoenixKit.Modules.Legal, PhoenixKit.Modules.Maintenance, PhoenixKit.Modules.Pages, PhoenixKit.Modules.Referrals, @@ -157,7 +155,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) >= 10 + assert length(metadata) >= 9 for meta <- metadata do assert is_map(meta) @@ -178,7 +176,7 @@ defmodule PhoenixKit.ModuleRegistryTest do test "returns sorted list of feature keys" do keys = ModuleRegistry.all_feature_keys() assert is_list(keys) - assert length(keys) >= 10 + assert length(keys) >= 9 assert keys == Enum.sort(keys) end @@ -202,7 +200,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) >= 10 + assert map_size(checks) >= 9 for {key, {mod, fun}} <- checks do assert is_binary(key) diff --git a/test/phoenix_kit/module_test.exs b/test/phoenix_kit/module_test.exs index 8eeeda829..980a811ba 100644 --- a/test/phoenix_kit/module_test.exs +++ b/test/phoenix_kit/module_test.exs @@ -4,10 +4,8 @@ defmodule PhoenixKit.ModuleTest do alias PhoenixKit.ModuleRegistry @all_internal_modules [ - PhoenixKit.Modules.Connections, PhoenixKit.Modules.DB, PhoenixKit.Modules.Languages, - PhoenixKit.Modules.Legal, PhoenixKit.Modules.Maintenance, PhoenixKit.Modules.Pages, PhoenixKit.Modules.Referrals,