- unlike noop now reloads tweet from DB (same fix as like noop from prev loop) - decrement_likes uses GREATEST(likes - 1, 0) to prevent negative counts - add fix_plan.md to track remaining work Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
355 lines
8.6 KiB
Elixir
355 lines
8.6 KiB
Elixir
defmodule Mixer.Posts.Tweet do
|
|
import Ash.Expr
|
|
require Ash.Query
|
|
|
|
use Ash.Resource,
|
|
otp_app: :mixer,
|
|
domain: Mixer.Posts,
|
|
data_layer: AshPostgres.DataLayer,
|
|
authorizers: [Ash.Policy.Authorizer],
|
|
extensions: [AshStateMachine, AshTypescript.Resource]
|
|
|
|
postgres do
|
|
table "tweets"
|
|
repo Mixer.Repo
|
|
|
|
references do
|
|
reference :parent_tweet, on_delete: :delete
|
|
end
|
|
end
|
|
|
|
state_machine do
|
|
initial_states [:drafted, :posted]
|
|
default_initial_state :drafted
|
|
|
|
transitions do
|
|
transition :create, from: :*, to: :posted
|
|
end
|
|
end
|
|
|
|
typescript do
|
|
type_name "tweets"
|
|
end
|
|
|
|
actions do
|
|
defaults [:read]
|
|
|
|
read :following_feed do
|
|
filter expr(
|
|
user_id == ^actor(:id) or
|
|
exists(user.followers, follower_id == ^actor(:id))
|
|
)
|
|
end
|
|
|
|
create :create do
|
|
upsert? true
|
|
accept [:content, :parent_tweet_id]
|
|
argument :media_id, :uuid, allow_nil?: true
|
|
change relate_actor(:user)
|
|
change transition_state(:posted)
|
|
|
|
change fn changeset, context ->
|
|
case Ash.Changeset.get_argument(changeset, :media_id) do
|
|
nil ->
|
|
changeset
|
|
|
|
media_id ->
|
|
Ash.Changeset.after_action(changeset, fn _changeset, tweet ->
|
|
Mixer.Posts.Media
|
|
|> Ash.get!(media_id, authorize?: false)
|
|
|> Ash.Changeset.for_update(:link_to_tweet, %{tweet_id: tweet.id},
|
|
actor: context.actor
|
|
)
|
|
|> Ash.update!()
|
|
|
|
{:ok, tweet}
|
|
end)
|
|
end
|
|
end
|
|
|
|
# Track post / comment creation metrics.
|
|
# Root tweets emit a "post" event recorded against their own ID.
|
|
# Replies emit a "comment" event recorded against the parent tweet ID so
|
|
# that `get_summary/1` can count how many replies a tweet has received.
|
|
change fn changeset, context ->
|
|
parent_tweet_id = Ash.Changeset.get_attribute(changeset, :parent_tweet_id)
|
|
user_id = context.actor && context.actor.id
|
|
|
|
Ash.Changeset.after_action(changeset, fn _changeset, tweet ->
|
|
if parent_tweet_id do
|
|
Mixer.Metrics.track_comment(parent_tweet_id, user_id: user_id)
|
|
else
|
|
Mixer.Metrics.track_post(tweet.id, user_id: user_id)
|
|
end
|
|
|
|
{:ok, tweet}
|
|
end)
|
|
end
|
|
end
|
|
|
|
# Explicit destroy so we can attach a metrics hook. The policy and cascade
|
|
# behaviour are identical to the previous default :destroy action.
|
|
destroy :destroy do
|
|
require_atomic? false
|
|
|
|
change fn changeset, context ->
|
|
# Capture the record's identity *before* deletion — after the action
|
|
# completes the row no longer exists.
|
|
tweet_id = changeset.data.id
|
|
parent_tweet_id = changeset.data.parent_tweet_id
|
|
user_id = context.actor && context.actor.id
|
|
|
|
Ash.Changeset.after_action(changeset, fn _changeset, result ->
|
|
if parent_tweet_id do
|
|
Mixer.Metrics.track_delete_comment(parent_tweet_id, user_id: user_id)
|
|
else
|
|
Mixer.Metrics.track_delete_post(tweet_id, user_id: user_id)
|
|
end
|
|
|
|
{:ok, result}
|
|
end)
|
|
end
|
|
end
|
|
|
|
update :update do
|
|
accept [:content]
|
|
end
|
|
|
|
update :like do
|
|
accept []
|
|
require_atomic? false
|
|
|
|
change fn changeset, context ->
|
|
Ash.Changeset.after_action(changeset, fn _changeset, tweet ->
|
|
case ensure_like(tweet, context.actor) do
|
|
{:created, _like} ->
|
|
Mixer.Metrics.track_like(tweet.id, user_id: context.actor && context.actor.id)
|
|
increment_likes(tweet, context.actor)
|
|
|
|
{:noop, _like} ->
|
|
Ash.get(__MODULE__, tweet.id, authorize?: false)
|
|
|
|
{:error, error} ->
|
|
{:error, error}
|
|
end
|
|
end)
|
|
end
|
|
end
|
|
|
|
update :unlike do
|
|
accept []
|
|
require_atomic? false
|
|
|
|
change fn changeset, context ->
|
|
Ash.Changeset.after_action(changeset, fn _changeset, tweet ->
|
|
case remove_like(tweet, context.actor) do
|
|
{:deleted, _like} ->
|
|
Mixer.Metrics.track_unlike(tweet.id, user_id: context.actor && context.actor.id)
|
|
decrement_likes(tweet, context.actor)
|
|
|
|
{:noop, _like} ->
|
|
Ash.get(__MODULE__, tweet.id, authorize?: false)
|
|
|
|
{:error, error} ->
|
|
{:error, error}
|
|
end
|
|
end)
|
|
end
|
|
end
|
|
|
|
update :increment_likes do
|
|
accept []
|
|
require_atomic? false
|
|
change atomic_update(:likes, expr(likes + 1))
|
|
end
|
|
|
|
update :decrement_likes do
|
|
accept []
|
|
require_atomic? false
|
|
change atomic_update(:likes, expr(fragment("GREATEST(? - 1, 0)", likes)))
|
|
end
|
|
end
|
|
|
|
policies do
|
|
policy action_type(:read) do
|
|
authorize_if always()
|
|
end
|
|
|
|
policy action_type(:create) do
|
|
authorize_if actor_present()
|
|
end
|
|
|
|
policy action(:update) do
|
|
authorize_if relates_to_actor_via(:user)
|
|
end
|
|
|
|
policy action(:destroy) do
|
|
authorize_if relates_to_actor_via(:user)
|
|
authorize_if relates_to_actor_via([:parent_tweet, :user])
|
|
end
|
|
|
|
policy action(:like) do
|
|
authorize_if actor_present()
|
|
end
|
|
|
|
policy action(:unlike) do
|
|
authorize_if actor_present()
|
|
end
|
|
end
|
|
|
|
attributes do
|
|
uuid_primary_key :id
|
|
|
|
attribute :content, :string do
|
|
allow_nil? false
|
|
public? true
|
|
end
|
|
|
|
attribute :likes, :integer do
|
|
allow_nil? false
|
|
default 0
|
|
public? true
|
|
end
|
|
|
|
attribute :user_id, :uuid do
|
|
allow_nil? false
|
|
public? true
|
|
end
|
|
|
|
create_timestamp :inserted_at do
|
|
public? true
|
|
end
|
|
|
|
update_timestamp :updated_at
|
|
end
|
|
|
|
relationships do
|
|
belongs_to :user, Mixer.Accounts.User do
|
|
attribute_type :uuid
|
|
attribute_writable? true
|
|
allow_nil? false
|
|
public? true
|
|
end
|
|
|
|
belongs_to :parent_tweet, Mixer.Posts.Tweet do
|
|
attribute_type :uuid
|
|
attribute_writable? true
|
|
allow_nil? true
|
|
public? true
|
|
end
|
|
|
|
has_many :comments, Mixer.Posts.Tweet do
|
|
destination_attribute :parent_tweet_id
|
|
public? true
|
|
end
|
|
|
|
has_many :media, Mixer.Posts.Media do
|
|
public? true
|
|
end
|
|
|
|
has_many :tweet_likes, Mixer.Posts.TweetLike
|
|
end
|
|
|
|
calculations do
|
|
calculate :user_email, :string, expr(user.email) do
|
|
public? true
|
|
end
|
|
|
|
calculate :user_username, :string, expr(user.username) do
|
|
public? true
|
|
end
|
|
|
|
calculate :user_display_name, :string, expr(user.display_name) do
|
|
public? true
|
|
end
|
|
|
|
calculate :user_avatar_url, :string, expr(user.avatar_url) do
|
|
public? true
|
|
end
|
|
end
|
|
|
|
aggregates do
|
|
count :comment_count, :comments do
|
|
public? true
|
|
end
|
|
|
|
exists :liked_by_me, :tweet_likes do
|
|
public? true
|
|
filter expr(user_id == ^actor(:id))
|
|
end
|
|
end
|
|
|
|
defp ensure_like(_tweet, nil), do: {:error, Ash.Error.Forbidden.exception([])}
|
|
|
|
defp ensure_like(tweet, actor) do
|
|
case get_like(tweet.id, actor.id) do
|
|
{:ok, nil} ->
|
|
case create_like(tweet.id, actor) do
|
|
{:ok, like} ->
|
|
{:created, like}
|
|
|
|
{:error, error} ->
|
|
case get_like(tweet.id, actor.id) do
|
|
{:ok, nil} ->
|
|
{:error, error}
|
|
|
|
{:ok, like} ->
|
|
{:noop, like}
|
|
|
|
{:error, error} ->
|
|
{:error, error}
|
|
end
|
|
end
|
|
|
|
{:ok, like} ->
|
|
{:noop, like}
|
|
|
|
{:error, error} ->
|
|
{:error, error}
|
|
end
|
|
end
|
|
|
|
defp remove_like(_tweet, nil), do: {:error, Ash.Error.Forbidden.exception([])}
|
|
|
|
defp remove_like(tweet, actor) do
|
|
case get_like(tweet.id, actor.id) do
|
|
{:ok, nil} ->
|
|
{:noop, nil}
|
|
|
|
{:ok, like} ->
|
|
case Ash.destroy(like, actor: actor) do
|
|
:ok -> {:deleted, like}
|
|
{:ok, _destroyed_like} -> {:deleted, like}
|
|
{:error, error} -> {:error, error}
|
|
end
|
|
|
|
{:error, error} ->
|
|
{:error, error}
|
|
end
|
|
end
|
|
|
|
defp create_like(tweet_id, actor) do
|
|
Mixer.Posts.TweetLike
|
|
|> Ash.Changeset.for_create(:create, %{tweet_id: tweet_id}, actor: actor)
|
|
|> Ash.create()
|
|
end
|
|
|
|
defp get_like(tweet_id, user_id) do
|
|
Mixer.Posts.TweetLike
|
|
|> Ash.Query.filter(expr(tweet_id == ^tweet_id and user_id == ^user_id))
|
|
|> Ash.read_one(authorize?: false)
|
|
end
|
|
|
|
defp increment_likes(tweet, actor) do
|
|
tweet
|
|
|> Ash.Changeset.for_update(:increment_likes, %{}, actor: actor)
|
|
|> Ash.update(authorize?: false)
|
|
end
|
|
|
|
defp decrement_likes(tweet, actor) do
|
|
tweet
|
|
|> Ash.Changeset.for_update(:decrement_likes, %{}, actor: actor)
|
|
|> Ash.update(authorize?: false)
|
|
end
|
|
end
|