diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/assets/css/app.css b/assets/css/app.css index 6bae5f6..d22a3fe 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -474,6 +474,46 @@ html, body { word-break: break-word; } +.mx-tweet-footer { + display: flex; + align-items: center; + margin-top: 0.875rem; +} + +.mx-like-btn { + display: inline-flex; + align-items: center; + gap: 0.45rem; + border: 1px solid var(--mx-border2); + border-radius: 999px; + background: color-mix(in oklch, var(--mx-surface2) 72%, transparent); + color: var(--mx-fg2); + cursor: pointer; + font-family: 'DM Mono', monospace; + font-size: 0.75rem; + line-height: 1; + padding: 0.45rem 0.75rem; + transition: transform 0.15s, border-color 0.15s, color 0.15s, background 0.15s; +} + +.mx-like-btn:hover:not(:disabled) { + color: var(--mx-red); + border-color: color-mix(in oklch, var(--mx-red) 35%, transparent); + background: color-mix(in oklch, var(--mx-red) 10%, transparent); + transform: translateY(-1px); +} + +.mx-like-btn:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.mx-like-btn-active { + color: var(--mx-red); + border-color: color-mix(in oklch, var(--mx-red) 35%, transparent); + background: color-mix(in oklch, var(--mx-red) 12%, transparent); +} + /* ── Edit ── */ .mx-edit-area { margin-top: 0.25rem; } diff --git a/assets/js/ash_rpc.ts b/assets/js/ash_rpc.ts index 78478a7..a7d44b4 100644 --- a/assets/js/ash_rpc.ts +++ b/assets/js/ash_rpc.ts @@ -435,6 +435,74 @@ export async function validateDestroyTweet( } +export type LikeTweetFields = UnifiedFieldSelection[]; + +export type InferLikeTweetResult< + Fields extends LikeTweetFields | undefined, +> = InferResult; + +export type LikeTweetResult = | { success: true; data: InferLikeTweetResult; } +| { success: false; errors: AshRpcError[]; } + +; + +/** + * Update an existing Tweet + * + * @ashActionType :update + */ +export async function likeTweet( + config: { + tenant?: string; + identity: UUID; + fields?: Fields; + headers?: Record; + fetchOptions?: RequestInit; + customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; +} +): Promise> { + const payload = { + action: "like_tweet", + ...(config.tenant !== undefined && { tenant: config.tenant }), + identity: config.identity, + ...(config.fields !== undefined && { fields: config.fields }) + }; + + return executeActionRpcRequest>( + payload, + config + ); +} + + +/** + * Validate: Update an existing Tweet + * + * @ashActionType :update + * @validation true + */ +export async function validateLikeTweet( + config: { + tenant?: string; + identity: UUID | string; + headers?: Record; + fetchOptions?: RequestInit; + customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; +} +): Promise { + const payload = { + action: "like_tweet", + ...(config.tenant !== undefined && { tenant: config.tenant }), + identity: config.identity + }; + + return executeValidationRpcRequest( + payload, + config + ); +} + + export type ReadTweetFields = UnifiedFieldSelection[]; @@ -536,11 +604,76 @@ export async function validateReadTweet( } +export type UnlikeTweetFields = UnifiedFieldSelection[]; + +export type InferUnlikeTweetResult< + Fields extends UnlikeTweetFields | undefined, +> = InferResult; + +export type UnlikeTweetResult = | { success: true; data: InferUnlikeTweetResult; } +| { success: false; errors: AshRpcError[]; } + +; + +/** + * Update an existing Tweet + * + * @ashActionType :update + */ +export async function unlikeTweet( + config: { + tenant?: string; + identity: UUID; + fields?: Fields; + headers?: Record; + fetchOptions?: RequestInit; + customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; +} +): Promise> { + const payload = { + action: "unlike_tweet", + ...(config.tenant !== undefined && { tenant: config.tenant }), + identity: config.identity, + ...(config.fields !== undefined && { fields: config.fields }) + }; + + return executeActionRpcRequest>( + payload, + config + ); +} + + +/** + * Validate: Update an existing Tweet + * + * @ashActionType :update + * @validation true + */ +export async function validateUnlikeTweet( + config: { + tenant?: string; + identity: UUID | string; + headers?: Record; + fetchOptions?: RequestInit; + customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; +} +): Promise { + const payload = { + action: "unlike_tweet", + ...(config.tenant !== undefined && { tenant: config.tenant }), + identity: config.identity + }; + + return executeValidationRpcRequest( + payload, + config + ); +} + + export type UpdateTweetInput = { content?: string; - likes?: number; - userId?: UUID; - state?: "posted" | "drafted"; }; export type UpdateTweetFields = UnifiedFieldSelection[]; diff --git a/assets/js/ash_types.ts b/assets/js/ash_types.ts index 6d326fb..b6a0be8 100644 --- a/assets/js/ash_types.ts +++ b/assets/js/ash_types.ts @@ -31,12 +31,13 @@ export type mediaAttributesOnlySchema = { // tweets Schema export type tweetsResourceSchema = { __type: "Resource"; - __primitiveFields: "id" | "content" | "likes" | "userId" | "state"; + __primitiveFields: "id" | "content" | "likes" | "userId" | "state" | "likedByMe"; id: UUID; content: string; likes: number; userId: UUID; state: "posted" | "drafted"; + likedByMe: boolean; media: { __type: "Relationship"; __array: true; __resource: mediaResourceSchema; }; }; @@ -126,6 +127,11 @@ export type tweetsFilterInput = { in?: Array<"posted" | "drafted">; }; + likedByMe?: { + eq?: boolean; + notEq?: boolean; + isNil?: boolean; + }; media?: mediaFilterInput; @@ -135,14 +141,14 @@ export type tweetsFilterInput = { export const mediaFilterFields = ["id", "s3Key", "userId", "tweetId", "user", "tweet"] as const; export type mediaFilterField = (typeof mediaFilterFields)[number]; -export const tweetsFilterFields = ["id", "content", "likes", "userId", "state", "user", "media"] as const; +export const tweetsFilterFields = ["id", "content", "likes", "userId", "state", "likedByMe", "user", "media"] as const; export type tweetsFilterField = (typeof tweetsFilterFields)[number]; export const mediaSortFields = ["id", "s3Key", "userId", "tweetId"] as const; export type mediaSortField = (typeof mediaSortFields)[number]; -export const tweetsSortFields = ["id", "content", "likes", "userId", "state"] as const; +export const tweetsSortFields = ["id", "content", "likes", "userId", "state", "likedByMe"] as const; export type tweetsSortField = (typeof tweetsSortFields)[number]; diff --git a/assets/js/index.tsx b/assets/js/index.tsx index 208a891..65af790 100644 --- a/assets/js/index.tsx +++ b/assets/js/index.tsx @@ -11,6 +11,8 @@ import { createTweet, readTweet, destroyTweet, + likeTweet, + unlikeTweet, updateTweet, buildCSRFHeaders, } from "./ash_rpc"; @@ -23,7 +25,15 @@ const queryClient = new QueryClient({ // ── Types ────────────────────────────────────────────────────────────────────── type MediaItem = { id: string; s3Key: string }; -type Tweet = { id: string; content: string; userId: string; state: string; media?: MediaItem[] }; +type Tweet = { + id: string; + content: string; + likes: number; + likedByMe?: boolean; + userId: string; + state: string; + media?: MediaItem[]; +}; // ── Auth context ─────────────────────────────────────────────────────────────── @@ -298,6 +308,7 @@ function TweetMedia({ media }: { media: MediaItem[] }) { function TweetCard({ tweet }: { tweet: Tweet }) { const { userId: currentUserId } = useContext(AuthCtx); const canModify = !!currentUserId && tweet.userId === currentUserId; + const canLike = !!currentUserId; const [editing, setEditing] = useState(false); const [editText, setEditText] = useState(tweet.content); @@ -335,6 +346,23 @@ function TweetCard({ tweet }: { tweet: Tweet }) { onError: (e: Error) => setError(e.message), }); + const likeMutation = useMutation({ + mutationFn: async () => { + const action = tweet.likedByMe ? unlikeTweet : likeTweet; + const res = await action({ + identity: tweet.id, + fields: ["id", "likes", "likedByMe"], + headers: buildCSRFHeaders(), + }); + if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed to update like"); + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["tweets"] }); + setError(null); + }, + onError: (e: Error) => setError(e.message), + }); + function saveEdit() { const trimmed = editText.trim(); if (!trimmed) return; @@ -431,6 +459,26 @@ function TweetCard({ tweet }: { tweet: Tweet }) { )} +
+ +
+ {error && !editing &&

{error}

} @@ -442,7 +490,7 @@ function Feed() { queryKey: ["tweets"], queryFn: async () => { const res = await readTweet({ - fields: ["id", "content", "userId", "state", { media: ["id", "s3Key"] }], + fields: ["id", "content", "likes", "likedByMe", "userId", "state", { media: ["id", "s3Key"] }], sort: "-id", headers: buildCSRFHeaders(), }); diff --git a/lib/mixer/accounts/user.ex b/lib/mixer/accounts/user.ex index 37466d4..d25676a 100644 --- a/lib/mixer/accounts/user.ex +++ b/lib/mixer/accounts/user.ex @@ -304,6 +304,8 @@ defmodule Mixer.Accounts.User do filter expr(valid) end + has_many :tweet_likes, Mixer.Posts.TweetLike + has_many :tweets, Mixer.Posts.Tweet end diff --git a/lib/mixer/posts.ex b/lib/mixer/posts.ex index f038dda..4554943 100644 --- a/lib/mixer/posts.ex +++ b/lib/mixer/posts.ex @@ -9,13 +9,16 @@ defmodule Mixer.Posts do resources do resource Mixer.Posts.Tweet + resource Mixer.Posts.TweetLike resource Mixer.Posts.Media end typescript_rpc do resource Mixer.Posts.Tweet do rpc_action :create_tweet, :create + rpc_action :like_tweet, :like rpc_action :read_tweet, :read + rpc_action :unlike_tweet, :unlike rpc_action :update_tweet, :update rpc_action :destroy_tweet, :destroy end diff --git a/lib/mixer/posts/tweet.ex b/lib/mixer/posts/tweet.ex index a877131..911819e 100644 --- a/lib/mixer/posts/tweet.ex +++ b/lib/mixer/posts/tweet.ex @@ -1,4 +1,7 @@ defmodule Mixer.Posts.Tweet do + import Ash.Expr + require Ash.Query + use Ash.Resource, otp_app: :mixer, domain: Mixer.Posts, @@ -25,7 +28,7 @@ defmodule Mixer.Posts.Tweet do end actions do - defaults [:read, :destroy, update: :*] + defaults [:read, :destroy] create :create do upsert? true @@ -50,6 +53,62 @@ defmodule Mixer.Posts.Tweet do 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} -> + increment_likes(tweet, context.actor) + + {:noop, _like} -> + {:ok, tweet} + + {: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} -> + decrement_likes(tweet, context.actor) + + {:noop, _like} -> + {:ok, tweet} + + {: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(likes - 1)) + end end attributes do @@ -83,6 +142,15 @@ defmodule Mixer.Posts.Tweet do has_many :media, Mixer.Posts.Media do public? true end + + has_many :tweet_likes, Mixer.Posts.TweetLike + end + + aggregates do + exists :liked_by_me, :tweet_likes do + public? true + filter expr(user_id == ^actor(:id)) + end end policies do @@ -94,8 +162,93 @@ defmodule Mixer.Posts.Tweet do authorize_if actor_present() end - policy action_type([:destroy, :update]) do + policy action(:update) do authorize_if relates_to_actor_via(:user) end + + policy action(:destroy) do + authorize_if relates_to_actor_via(:user) + end + + policy action(:like) do + authorize_if actor_present() + end + + policy action(:unlike) do + authorize_if actor_present() + 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 diff --git a/lib/mixer/posts/tweet_like.ex b/lib/mixer/posts/tweet_like.ex new file mode 100644 index 0000000..ddcabf7 --- /dev/null +++ b/lib/mixer/posts/tweet_like.ex @@ -0,0 +1,65 @@ +defmodule Mixer.Posts.TweetLike do + use Ash.Resource, + otp_app: :mixer, + domain: Mixer.Posts, + data_layer: AshPostgres.DataLayer, + authorizers: [Ash.Policy.Authorizer] + + postgres do + table "tweet_likes" + repo Mixer.Repo + end + + actions do + defaults [:read, :destroy] + + create :create do + accept [:tweet_id] + change relate_actor(:user) + end + end + + attributes do + uuid_primary_key :id + + attribute :tweet_id, :uuid do + allow_nil? false + end + + attribute :user_id, :uuid do + allow_nil? false + end + end + + relationships do + belongs_to :tweet, Mixer.Posts.Tweet do + attribute_type :uuid + attribute_writable? true + allow_nil? false + end + + belongs_to :user, Mixer.Accounts.User do + attribute_type :uuid + attribute_writable? true + allow_nil? false + end + end + + identities do + identity :unique_user_tweet, [:tweet_id, :user_id] + end + + policies do + policy action_type(:read) do + authorize_if always() + end + + policy action(:create) do + authorize_if actor_present() + end + + policy action_type(:destroy) do + authorize_if relates_to_actor_via(:user) + end + end +end diff --git a/priv/repo/migrations/20260331182608_add_tweet_likes.exs b/priv/repo/migrations/20260331182608_add_tweet_likes.exs new file mode 100644 index 0000000..e2b45e0 --- /dev/null +++ b/priv/repo/migrations/20260331182608_add_tweet_likes.exs @@ -0,0 +1,47 @@ +defmodule Mixer.Repo.Migrations.AddTweetLikes do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + create table(:tweet_likes, primary_key: false) do + add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true + + add :tweet_id, + references(:tweets, + column: :id, + name: "tweet_likes_tweet_id_fkey", + type: :uuid, + prefix: "public" + ), null: false + + add :user_id, + references(:users, + column: :id, + name: "tweet_likes_user_id_fkey", + type: :uuid, + prefix: "public" + ), null: false + end + + create unique_index(:tweet_likes, [:tweet_id, :user_id], + name: "tweet_likes_unique_user_tweet_index" + ) + end + + def down do + drop_if_exists unique_index(:tweet_likes, [:tweet_id, :user_id], + name: "tweet_likes_unique_user_tweet_index" + ) + + drop constraint(:tweet_likes, "tweet_likes_tweet_id_fkey") + + drop constraint(:tweet_likes, "tweet_likes_user_id_fkey") + + drop table(:tweet_likes) + end +end diff --git a/priv/resource_snapshots/repo/tweet_likes/20260331182609.json b/priv/resource_snapshots/repo/tweet_likes/20260331182609.json new file mode 100644 index 0000000..944f038 --- /dev/null +++ b/priv/resource_snapshots/repo/tweet_likes/20260331182609.json @@ -0,0 +1,113 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "tweet_likes_tweet_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "tweets" + }, + "scale": null, + "size": null, + "source": "tweet_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "tweet_likes_user_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "users" + }, + "scale": null, + "size": null, + "source": "user_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "create_table_options": null, + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "8F9732A70AB924AD5FE6FBB20274DFBDB8EDCA638722B29BFA6D8E47C22BF7B6", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "tweet_likes_unique_user_tweet_index", + "keys": [ + { + "type": "atom", + "value": "tweet_id" + }, + { + "type": "atom", + "value": "user_id" + } + ], + "name": "unique_user_tweet", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mixer.Repo", + "schema": null, + "table": "tweet_likes" +} \ No newline at end of file diff --git a/test/mixer/posts/tweet_like_test.exs b/test/mixer/posts/tweet_like_test.exs new file mode 100644 index 0000000..a42c7b8 --- /dev/null +++ b/test/mixer/posts/tweet_like_test.exs @@ -0,0 +1,117 @@ +defmodule Mixer.Posts.TweetLikeTest do + use Mixer.DataCase, async: true + + import Ash.Expr + + alias Mixer.Accounts.User + alias Mixer.Posts.Tweet + alias Mixer.Posts.TweetLike + + describe "tweet likes" do + test "a user can like a tweet once and liked_by_me reflects the actor" do + user = user_fixture("liker@example.com") + tweet = tweet_fixture(user, "first post") + + assert {:ok, liked_tweet} = + tweet + |> Ash.Changeset.for_update(:like, %{}, actor: user) + |> Ash.update() + + assert liked_tweet.likes == 1 + assert count_likes(tweet.id) == 1 + + tweet_for_actor = + Tweet + |> Ash.get!(tweet.id, actor: user, load: [:liked_by_me], authorize?: false) + + refute Ash.ForbiddenField.forbidden?(tweet_for_actor.liked_by_me) + assert tweet_for_actor.liked_by_me + + tweet_without_actor = + Tweet + |> Ash.get!(tweet.id, load: [:liked_by_me], authorize?: false) + + refute Ash.ForbiddenField.forbidden?(tweet_without_actor.liked_by_me) + refute tweet_without_actor.liked_by_me + end + + test "liking the same tweet twice does not create duplicate rows or inflate the counter" do + user = user_fixture("duplicate@example.com") + tweet = tweet_fixture(user, "duplicate like test") + + assert {:ok, _tweet} = + tweet + |> Ash.Changeset.for_update(:like, %{}, actor: user) + |> Ash.update() + + assert {:ok, liked_again} = + tweet + |> Ash.Changeset.for_update(:like, %{}, actor: user) + |> Ash.update() + + assert liked_again.likes == 1 + assert count_likes(tweet.id) == 1 + end + + test "unliking removes the relation and decrements the counter without going negative" do + user = user_fixture("unlike@example.com") + tweet = tweet_fixture(user, "unlike test") + + tweet + |> Ash.Changeset.for_update(:like, %{}, actor: user) + |> Ash.update!() + + assert {:ok, unliked_tweet} = + tweet + |> Ash.Changeset.for_update(:unlike, %{}, actor: user) + |> Ash.update() + + assert unliked_tweet.likes == 0 + assert count_likes(tweet.id) == 0 + + assert {:ok, still_unliked} = + tweet + |> Ash.Changeset.for_update(:unlike, %{}, actor: user) + |> Ash.update() + + assert still_unliked.likes == 0 + assert count_likes(tweet.id) == 0 + end + + test "guests cannot like tweets" do + owner = user_fixture("owner@example.com") + tweet = tweet_fixture(owner, "guest like test") + + assert {:error, error} = + tweet + |> Ash.Changeset.for_update(:like, %{}) + |> Ash.update() + + assert Exception.message(error) =~ "forbidden" + assert count_likes(tweet.id) == 0 + end + end + + defp user_fixture(email) do + User + |> Ash.Changeset.for_create(:register_with_password, %{ + email: email, + password: "password1234", + password_confirmation: "password1234" + }) + |> Ash.create!() + end + + defp tweet_fixture(user, content) do + Tweet + |> Ash.Changeset.for_create(:create, %{content: content}, actor: user) + |> Ash.create!() + end + + defp count_likes(tweet_id) do + TweetLike + |> Ash.Query.filter(expr(tweet_id == ^tweet_id)) + |> Ash.read!(authorize?: false) + |> length() + end +end