19.6 KiB · text History 6d24830
defmodule FeedPug.Groups do
  @moduledoc """
  The Groups context: user-owned hierarchical groups of feeds, cross-user
  follows, subgroup exclusions, and resolution of a user's effective newsfeed
  feed set.

  All write operations are scoped through `%FeedPug.Accounts.Scope{}`. The
  materialized `path` of a group is maintained exclusively here.
  """
  import Ecto.Query, warn: false

  alias Ecto.Multi
  alias FeedPug.Repo
  alias FeedPug.Accounts.Scope
  alias FeedPug.Feeds
  alias FeedPug.Groups.{Group, GroupFeed, GroupFollow, FollowExclusion}

  @default_group_names ~w(blogs comics news podcasts)
  @path_separator "."

  ## PubSub -------------------------------------------------------------------

  @doc "PubSub topic carrying newsfeed-invalidation events for a user."
  def newsfeed_topic(user_id), do: "newsfeed:#{user_id}"

  defp broadcast_newsfeed_change(user_id) do
    Phoenix.PubSub.broadcast(FeedPug.PubSub, newsfeed_topic(user_id), :newsfeed_changed)
  end

  ## Default groups -----------------------------------------------------------

  @doc """
  Appends inserts for the default root groups onto an `Ecto.Multi`, reading the
  freshly-inserted user from the `:user` change so it runs in the registration
  transaction.
  """
  def seed_default_groups_multi(multi) do
    Enum.reduce(@default_group_names, multi, fn name, multi ->
      Multi.insert(multi, {:default_group, name}, fn %{user: user} ->
        slug = slugify(name)

        Group.placement_changeset(%Group{}, %{
          name: name,
          slug: slug,
          path: slug,
          user_id: user.id,
          is_default: true
        })
      end)
    end)
  end

  def default_group_names, do: @default_group_names

  ## Reading groups -----------------------------------------------------------

  @doc "All of the scoped user's own groups, ordered by path."
  def list_groups(%Scope{user: user}) do
    from(g in Group, where: g.user_id == ^user.id, order_by: [asc: g.path])
    |> Repo.all()
  end

  @doc "Fetch one of the scoped user's own groups."
  def get_group!(%Scope{user: user}, id) do
    Repo.get_by!(Group, id: id, user_id: user.id)
  end

  @doc "Fetch any group by id (used to follow another user's group)."
  def get_group(id), do: Repo.get(Group, id)

  @doc "Fetch a scoped user's child group by parent and name, if it exists."
  def get_subgroup(%Scope{user: user}, parent_id, name) do
    Repo.get_by(Group, user_id: user.id, parent_id: parent_id, name: name)
  end

  @doc "Feeds placed directly in a group, with the canonical feed preloaded."
  def list_group_feeds(%Group{id: group_id}) do
    from(gf in GroupFeed,
      where: gf.group_id == ^group_id,
      preload: [:feed],
      order_by: [asc: gf.id]
    )
    |> Repo.all()
  end

  @doc "Descendant subgroups of a group (excluding the group itself), by path."
  def list_descendant_groups(%Group{} = group) do
    prefix = group.path <> @path_separator <> "%"

    from(g in Group,
      where: g.user_id == ^group.user_id and like(g.path, ^prefix),
      order_by: [asc: g.path]
    )
    |> Repo.all()
  end

  @doc "All feeds in a group's whole subtree (the group and its descendants)."
  def list_subtree_feeds(%Group{} = group) do
    prefix = group.path <> @path_separator <> "%"

    from(gf in GroupFeed,
      join: g in Group,
      on: g.id == gf.group_id,
      where: g.user_id == ^group.user_id and (g.path == ^group.path or like(g.path, ^prefix)),
      preload: [:feed],
      order_by: [asc: g.path, asc: gf.id]
    )
    |> Repo.all()
  end

  ## Creating / mutating groups ----------------------------------------------

  @doc """
  Creates a subgroup under `parent` (or a root group when `parent` is nil) for
  the scoped user. Computes the slug and materialized path.
  """
  def create_group(%Scope{user: user}, attrs, parent \\ nil) do
    name = attrs |> Map.get("name", Map.get(attrs, :name)) |> to_string() |> String.trim()
    parent = parent && ensure_owned!(user, parent)
    slug = unique_sibling_slug(user.id, parent_id(parent), slugify(name))
    path = build_path(parent, slug)

    %Group{}
    |> Group.placement_changeset(%{
      name: name,
      slug: slug,
      path: path,
      parent_id: parent_id(parent),
      user_id: user.id
    })
    |> Repo.insert()
    |> tap_ok(fn _ -> broadcast_newsfeed_change(user.id) end)
  end

  @doc """
  Renames a group, rewriting the materialized path of the whole subtree in one
  transaction so `path` and `parent_id` can never disagree.
  """
  def rename_group(%Scope{user: user} = scope, %Group{} = group, new_name) do
    group = ensure_owned!(user, group)
    new_name = new_name |> to_string() |> String.trim()
    parent = group.parent_id && get_group!(scope, group.parent_id)
    new_slug = unique_sibling_slug(user.id, group.parent_id, slugify(new_name), group.id)
    new_path = build_path(parent, new_slug)

    Multi.new()
    |> Multi.update(
      :group,
      Group.placement_changeset(group, %{name: new_name, slug: new_slug, path: new_path})
    )
    |> rewrite_descendants(group.path, new_path)
    |> Repo.transaction()
    |> case do
      {:ok, %{group: updated}} ->
        broadcast_newsfeed_change(user.id)
        {:ok, updated}

      {:error, _step, changeset, _} ->
        {:error, changeset}
    end
  end

  @doc "Deletes a group (and, by FK cascade, its subtree, feeds, and follows)."
  def delete_group(%Scope{user: user}, %Group{} = group) do
    group = ensure_owned!(user, group)

    if group.is_default do
      {:error, :default_group}
    else
      group
      |> Repo.delete()
      |> tap_ok(fn _ -> broadcast_newsfeed_change(user.id) end)
    end
  end

  ## Feeds in groups ----------------------------------------------------------

  @doc """
  Adds a feed (by URL) to one of the scoped user's groups, creating the
  canonical feed if needed.
  """
  def add_feed_to_group(%Scope{user: user}, %Group{} = group, url, custom_title \\ nil) do
    group = ensure_owned!(user, group)

    with {:ok, feed} <- Feeds.upsert_feed_by_url(url) do
      place_feed(user, group, feed, custom_title)
    end
  end

  @doc """
  Copies an existing canonical feed into one of the scoped user's own groups
  (e.g. pinning a feed seen in a followed group). No feed/item duplication.
  """
  def copy_feed_to_group(%Scope{user: user}, %Group{} = group, feed_id, custom_title \\ nil) do
    group = ensure_owned!(user, group)
    feed = Feeds.get_feed!(feed_id)
    place_feed(user, group, feed, custom_title)
  end

  defp place_feed(_user, group, feed, custom_title) do
    %GroupFeed{}
    |> GroupFeed.changeset(%{group_id: group.id, feed_id: feed.id, custom_title: custom_title})
    |> Repo.insert()
    |> tap_ok(fn _ -> notify_followers_of_group_change(group) end)
  end

  @doc "Removes a feed membership from one of the scoped user's groups."
  def remove_feed_from_group(%Scope{user: user}, %GroupFeed{} = group_feed) do
    group = ensure_owned!(user, get_group(group_feed.group_id))

    group_feed
    |> Repo.delete()
    |> tap_ok(fn _ -> notify_followers_of_group_change(group) end)
  end

  ## OPML export ---------------------------------------------------------------

  @doc """
  Builds a nested OPML node tree (folders + feeds) of the scoped user's own
  groups, suitable for `FeedPug.Opml.export/2`. Mirrors the group hierarchy:
  subgroups become nested folder outlines, feeds become rss outlines.
  """
  def export_tree(%Scope{} = scope) do
    groups = list_groups(scope)
    feeds_by_group = Map.new(groups, fn g -> {g.id, list_group_feeds(g)} end)
    children_by_parent = Enum.group_by(groups, & &1.parent_id)
    build_export_nodes(Map.get(children_by_parent, nil, []), children_by_parent, feeds_by_group)
  end

  defp build_export_nodes(groups, children_by_parent, feeds_by_group) do
    Enum.map(groups, fn group ->
      subgroups =
        build_export_nodes(
          Map.get(children_by_parent, group.id, []),
          children_by_parent,
          feeds_by_group
        )

      feeds =
        feeds_by_group
        |> Map.get(group.id, [])
        |> Enum.map(fn gf ->
          %{
            type: :feed,
            title: gf.custom_title || gf.feed.title || gf.feed.url,
            xml_url: gf.feed.url,
            html_url: gf.feed.site_url
          }
        end)

      %{type: :group, name: group.name, children: subgroups ++ feeds}
    end)
  end

  ## OPML import ---------------------------------------------------------------

  @doc """
  Imports a parsed OPML node tree (see `FeedPug.Opml.parse/1`) into the scoped
  user's account.

  `target` is either a `%Group{}` (folders nest beneath it, feeds attach to it)
  or `:root` (the OPML's top-level folders become new root groups; loose
  top-level feeds land in an "Imported" root group). Existing same-named groups
  are reused, so re-imports are idempotent. Returns `{groups_created, feeds_added}`.
  """
  def import_opml(scope, nodes, target)

  def import_opml(%Scope{} = scope, nodes, %Group{} = target) when is_list(nodes) do
    import_into(scope, nodes, target)
  end

  def import_opml(%Scope{} = scope, nodes, :root) when is_list(nodes) do
    Enum.reduce(nodes, {0, 0}, fn
      %{type: :group, name: name, children: children}, {groups, feeds} ->
        {root, created} = ensure_group(scope, nil, name)
        {g2, f2} = import_into(scope, children, root)
        {groups + created + g2, feeds + f2}

      %{type: :feed} = node, {groups, feeds} ->
        # Loose top-level feeds have no folder — collect them in an "Imported" root.
        {root, created} = ensure_group(scope, nil, "Imported")
        {_g, f2} = import_into(scope, [node], root)
        {groups + created, feeds + f2}
    end)
  end

  defp import_into(scope, nodes, %Group{} = parent) do
    Enum.reduce(nodes, {0, 0}, fn
      %{type: :feed, xml_url: url, title: title}, {groups, feeds} ->
        case add_feed_to_group(scope, parent, url, title) do
          {:ok, _} -> {groups, feeds + 1}
          # Duplicate within the group — skip, don't abort the import.
          {:error, _} -> {groups, feeds}
        end

      %{type: :group, name: name, children: children}, {groups, feeds} ->
        {sub, created} = ensure_group(scope, parent, name)
        {g2, f2} = import_into(scope, children, sub)
        {groups + created + g2, feeds + f2}
    end)
  end

  # Creates a group under `parent` (or a root when nil), reusing an existing
  # same-named one on collision. Returns `{group, 1 | 0}`.
  defp ensure_group(%Scope{} = scope, parent, name) do
    case create_group(scope, %{"name" => name}, parent) do
      {:ok, group} -> {group, 1}
      {:error, _changeset} -> {get_subgroup(scope, parent && parent.id, name), 0}
    end
  end

  ## Following ----------------------------------------------------------------

  @doc "Groups owned by other users that the scoped user can browse/follow."
  def list_followable_groups(%Scope{user: user}) do
    from(g in Group,
      where: g.user_id != ^user.id and is_nil(g.parent_id),
      order_by: [asc: g.name],
      preload: [:user]
    )
    |> Repo.all()
  end

  def list_follows(%Scope{user: user}) do
    from(f in GroupFollow,
      where: f.follower_user_id == ^user.id,
      preload: [group: :user, exclusions: :excluded_group]
    )
    |> Repo.all()
  end

  @doc "Follows another user's group. Rejects following your own group."
  def follow_group(%Scope{user: user}, %Group{} = group) do
    if group.user_id == user.id do
      {:error, :cannot_follow_own_group}
    else
      %GroupFollow{}
      |> GroupFollow.changeset(%{follower_user_id: user.id, group_id: group.id})
      |> Repo.insert()
      |> tap_ok(fn _ -> broadcast_newsfeed_change(user.id) end)
    end
  end

  def unfollow_group(%Scope{user: user}, %GroupFollow{} = follow) do
    true = follow.follower_user_id == user.id

    follow
    |> Repo.delete()
    |> tap_ok(fn _ -> broadcast_newsfeed_change(user.id) end)
  end

  @doc "Excludes a descendant subgroup from a follow."
  def add_exclusion(%Scope{user: user}, %GroupFollow{} = follow, %Group{} = excluded) do
    true = follow.follower_user_id == user.id

    if descendant?(follow.group_id, excluded) do
      %FollowExclusion{}
      |> FollowExclusion.changeset(%{group_follow_id: follow.id, excluded_group_id: excluded.id})
      |> Repo.insert()
      |> tap_ok(fn _ -> broadcast_newsfeed_change(user.id) end)
    else
      {:error, :not_a_descendant}
    end
  end

  def remove_exclusion(%Scope{user: user}, %FollowExclusion{} = exclusion) do
    exclusion = Repo.preload(exclusion, :group_follow)
    true = exclusion.group_follow.follower_user_id == user.id

    exclusion
    |> Repo.delete()
    |> tap_ok(fn _ -> broadcast_newsfeed_change(user.id) end)
  end

  ## Effective feed resolution ------------------------------------------------

  @doc """
  Resolves the set of feed ids feeding a user's newsfeed: feeds in their own
  groups, plus feeds in the descendants of every group they follow, minus
  excluded subtrees. Deduped.
  """
  def effective_feed_ids(%Scope{user: user}) do
    own_group_ids =
      from(g in Group, where: g.user_id == ^user.id, select: g.id) |> Repo.all()

    followed_group_ids = followed_group_ids(user)
    group_ids = Enum.uniq(own_group_ids ++ followed_group_ids)

    case group_ids do
      [] ->
        []

      ids ->
        from(gf in GroupFeed, where: gf.group_id in ^ids, select: gf.feed_id, distinct: true)
        |> Repo.all()
    end
  end

  @doc """
  The selectable "sources" feeding a user's newsfeed, for filtering: each of
  their own root groups and each group they follow, with the feed ids each
  contributes (own = subtree; followed = subtree minus exclusions).
  """
  def list_newsfeed_sources(%Scope{user: user}) do
    own =
      from(g in Group,
        where: g.user_id == ^user.id and is_nil(g.parent_id),
        order_by: [asc: g.name]
      )
      |> Repo.all()
      |> Enum.map(fn g ->
        %{key: "group:#{g.id}", label: g.name, kind: :own, feed_ids: subtree_feed_ids(g)}
      end)

    followed =
      from(f in GroupFollow,
        where: f.follower_user_id == ^user.id,
        preload: [group: :user, exclusions: :excluded_group]
      )
      |> Repo.all()
      |> Enum.map(fn f ->
        %{
          key: "follow:#{f.id}",
          label: f.group.name,
          kind: :follow,
          feed_ids: follow_feed_ids(f)
        }
      end)

    own ++ followed
  end

  defp subtree_feed_ids(%Group{} = group) do
    prefix = group.path <> @path_separator <> "%"

    ids =
      from(g in Group,
        where: g.user_id == ^group.user_id and (g.path == ^group.path or like(g.path, ^prefix)),
        select: g.id
      )
      |> Repo.all()

    feed_ids_in_groups(ids)
  end

  defp follow_feed_ids(%GroupFollow{} = follow) do
    case follow_condition(follow) do
      nil ->
        []

      condition ->
        ids = from(g in Group, where: ^condition, select: g.id) |> Repo.all()
        feed_ids_in_groups(ids)
    end
  end

  defp feed_ids_in_groups([]), do: []

  defp feed_ids_in_groups(group_ids) do
    from(gf in GroupFeed, where: gf.group_id in ^group_ids, select: gf.feed_id, distinct: true)
    |> Repo.all()
  end

  defp followed_group_ids(user) do
    follows =
      from(f in GroupFollow,
        where: f.follower_user_id == ^user.id,
        preload: [:group, exclusions: :excluded_group]
      )
      |> Repo.all()

    conditions =
      follows
      |> Enum.map(&follow_condition/1)
      |> Enum.reject(&is_nil/1)

    case conditions do
      [] ->
        []

      [first | rest] ->
        combined = Enum.reduce(rest, first, fn c, acc -> dynamic(^acc or ^c) end)
        from(g in Group, where: ^combined, select: g.id) |> Repo.all()
    end
  end

  # A follow contributes: root's subtree, minus each excluded subtree.
  defp follow_condition(%GroupFollow{group: %Group{} = root} = follow) do
    base = subtree_dynamic(root.user_id, root.path)

    case follow.exclusions do
      [] ->
        base

      exclusions ->
        excl =
          exclusions
          |> Enum.map(fn e ->
            subtree_dynamic(e.excluded_group.user_id, e.excluded_group.path)
          end)
          |> Enum.reduce(fn c, acc -> dynamic(^acc or ^c) end)

        dynamic(^base and not (^excl))
    end
  end

  defp follow_condition(_), do: nil

  # "group is at or below (user_id, path)" — a safe prefix match (slugs contain
  # no LIKE wildcards).
  defp subtree_dynamic(user_id, path) do
    prefix = path <> @path_separator <> "%"
    dynamic([g], g.user_id == ^user_id and (g.path == ^path or like(g.path, ^prefix)))
  end

  ## Path / slug maintenance --------------------------------------------------

  defp rewrite_descendants(multi, old_path, new_path) do
    old_prefix = old_path <> @path_separator
    new_prefix = new_path <> @path_separator
    like = old_prefix <> "%"
    # Replace the old path prefix with the new one for every descendant.
    cut = String.length(old_prefix)

    Multi.update_all(
      multi,
      :descendants,
      fn _ ->
        from(g in Group,
          where: like(g.path, ^like),
          update: [
            set: [
              path: fragment("? || substring(?, ?::integer)", ^new_prefix, g.path, ^(cut + 1))
            ]
          ]
        )
      end,
      []
    )
  end

  defp descendant?(ancestor_id, %Group{} = node) do
    case get_group(ancestor_id) do
      %Group{} = ancestor ->
        ancestor.user_id == node.user_id and
          (node.path == ancestor.path or
             String.starts_with?(node.path, ancestor.path <> @path_separator))

      _ ->
        false
    end
  end

  defp build_path(nil, slug), do: slug
  defp build_path(%Group{path: parent_path}, slug), do: parent_path <> @path_separator <> slug

  defp parent_id(nil), do: nil
  defp parent_id(%Group{id: id}), do: id

  defp unique_sibling_slug(user_id, parent_id, base, exclude_id \\ nil) do
    taken = sibling_slugs(user_id, parent_id, exclude_id)
    if base in taken, do: next_free_slug(base, taken, 2), else: base
  end

  defp next_free_slug(base, taken, n) do
    candidate = "#{base}-#{n}"
    if candidate in taken, do: next_free_slug(base, taken, n + 1), else: candidate
  end

  defp sibling_slugs(user_id, parent_id, exclude_id) do
    query =
      from(g in Group,
        where: g.user_id == ^user_id and g.slug != "",
        select: g.slug
      )

    query =
      if is_nil(parent_id) do
        from(g in query, where: is_nil(g.parent_id))
      else
        from(g in query, where: g.parent_id == ^parent_id)
      end

    query =
      if exclude_id, do: from(g in query, where: g.id != ^exclude_id), else: query

    Repo.all(query)
  end

  @doc "Slugifies a display name into `[a-z0-9-]` with single hyphen runs."
  def slugify(name) do
    slug =
      name
      |> to_string()
      |> String.downcase()
      |> String.replace(~r/[^a-z0-9]+/u, "-")
      |> String.trim("-")

    if slug == "", do: "group", else: slug
  end

  ## Follower notification ----------------------------------------------------

  # Notify the owner plus everyone whose follow subtree contains `group`.
  defp notify_followers_of_group_change(%Group{} = group) do
    broadcast_newsfeed_change(group.user_id)

    follower_ids =
      from(f in GroupFollow,
        join: g in Group,
        on: g.id == f.group_id,
        where:
          g.user_id == ^group.user_id and
            (g.path == ^group.path or
               fragment("? LIKE ? || '.%'", ^group.path, g.path)),
        select: f.follower_user_id,
        distinct: true
      )
      |> Repo.all()

    Enum.each(follower_ids, &broadcast_newsfeed_change/1)
    :ok
  end

  ## Misc ---------------------------------------------------------------------

  defp ensure_owned!(user, %Group{user_id: uid} = group) when uid == user.id, do: group
  defp ensure_owned!(_user, %Group{}), do: raise(FeedPug.Groups.NotOwnerError)

  defp tap_ok({:ok, _} = res, fun), do: tap(res, fn _ -> fun.(res) end)
  defp tap_ok(other, _fun), do: other
end