defmodule Clacks.Activity do require Logger use Ecto.Schema import Ecto.Changeset alias Clacks.{Repo, Object} import Ecto.Query @type t() :: %__MODULE__{} @primary_key {:id, FlakeId.Ecto.Type, autogenerate: true} schema "activities" do field :data, :map field :local, :boolean belongs_to :actor, Clacks.Actor, foreign_key: :actor_ap_id, references: :ap_id, type: :string has_one :object, Clacks.Object, on_delete: :nothing, foreign_key: :id timestamps() end def changeset(%__MODULE__{} = schema, attrs) do schema |> cast(attrs, [:data, :local, :actor_ap_id]) |> validate_required([:data, :local, :actor_ap_id]) end @spec changeset_for_creating(activity :: map(), local :: boolean()) :: Ecto.Changeset.t() def changeset_for_creating(activity, local \\ false) do changeset(%__MODULE__{}, %{ data: activity, local: local, actor_ap_id: activity["actor"] }) end @spec get(id :: String.t(), opts :: Keyword.t()) :: t() | nil def get(id, opts \\ []) do if Keyword.get(opts, :with_object, false) do __MODULE__ |> where([a], a.id == ^id) |> preload_object() |> Repo.one() else Repo.get(__MODULE__, id) end end @spec get_by_ap_id(ap_id :: String.t(), force_refetch :: boolean()) :: t() | nil def get_by_ap_id(ap_id, force_refetch \\ false) do if force_refetch do fetch(ap_id) else get_cached_by_ap_id(ap_id) || fetch(ap_id) end end @spec get_cached_by_ap_id(ap_id :: String.t()) :: t() | nil def get_cached_by_ap_id(ap_id) do Repo.one(from a in __MODULE__, where: fragment("?->>'id'", a.data) == ^ap_id) end @spec get_by_object_ap_id(object_id :: String.t(), type :: String.t()) :: t() | nil def get_by_object_ap_id(object_id, type) do __MODULE__ |> where( [a], fragment("?->>'type'", a.data) == ^type and fragment("COALESCE(?->'object'->>'id', ?->>'object')", a.data, a.data) == ^object_id ) |> preload_object() # todo: may not need to preload actor by default |> preload_actor() |> Repo.one() end @spec join_with_object(Ecto.Queryable.t()) :: Ecto.Query.t() def join_with_object(query) do join(query, :inner, [a], o in Object, as: :object, on: fragment("?->>'id' = COALESCE(?->'object'->>'id', ?->>'object')", o.data, a.data, a.data) ) end @spec preload_object(Ecto.Queryable.t()) :: Ecto.Query.t() def preload_object(query) do if Ecto.Query.has_named_binding?(query, :object) do query else join_with_object(query) end |> preload([_a, object: object], object: object) end @spec preload_actor(Ecto.Queryable.t()) :: Ecto.Query.t() def preload_actor(query) do preload(query, :actor) end @spec fetch(ap_id :: String.t()) :: t() | nil def fetch(ap_id) do case Clacks.ActivityPub.Fetcher.fetch_activity(ap_id) do nil -> nil data -> actor = data["actor"] || data["attributedTo"] existing = get_cached_by_ap_id(data["id"]) changeset = changeset(existing || %__MODULE__{}, %{ data: data, local: false, actor: actor }) case Repo.insert_or_update(changeset) do {:ok, activity} -> _ = Clacks.Actor.get_by_ap_id(actor) activity {:error, changeset} -> Logger.error("Couldn't store remote activity #{ap_id}: #{inspect(changeset)}") nil end end end @spec data_with_object(activity :: Activity.t()) :: map() def data_with_object(%__MODULE__{ data: %{"object" => object_id} = data, object: %Object{data: object_data} }) when is_binary(object_id) do Map.put(data, "object", object_data) end def data_with_object(%___MODULE__{data: data}), do: data end