diff --git a/assets/js/ash_rpc.ts b/assets/js/ash_rpc.ts index 18e48b0..d638ba6 100644 --- a/assets/js/ash_rpc.ts +++ b/assets/js/ash_rpc.ts @@ -2,7 +2,7 @@ // Do not edit this file manually -import type { AshRpcError, ConditionalPaginatedResultMixed, InferResult, SortString, UUID, UnifiedFieldSelection, ValidationResult, mediaFilterInput, mediaResourceSchema, mediaSortField, tweetsFilterInput, tweetsResourceSchema, tweetsSortField, usersFilterInput, usersResourceSchema, usersSortField } from "./ash_types"; +import type { AshRpcError, ConditionalPaginatedResultMixed, InferResult, SortString, UUID, UnifiedFieldSelection, ValidationResult, followsFilterInput, followsResourceSchema, followsSortField, mediaFilterInput, mediaResourceSchema, mediaSortField, tweetsFilterInput, tweetsResourceSchema, tweetsSortField, usersFilterInput, usersResourceSchema, usersSortField } from "./ash_types"; export type * from "./ash_types"; // Helper Functions @@ -201,6 +201,245 @@ export async function executeValidationRpcRequest( +export type FollowUserInput = { + followingId: UUID; +}; + +export type FollowUserFields = UnifiedFieldSelection[]; + +export type InferFollowUserResult< + Fields extends FollowUserFields | undefined, +> = InferResult; + +export type FollowUserResult = | { success: true; data: InferFollowUserResult; } +| { success: false; errors: AshRpcError[]; } + +; + +/** + * Create a new Follow + * + * @ashActionType :create + */ +export async function followUser( + config: { + tenant?: string; + input: FollowUserInput; + fields?: Fields; + headers?: Record; + fetchOptions?: RequestInit; + customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; +} +): Promise> { + const payload = { + action: "follow_user", + ...(config.tenant !== undefined && { tenant: config.tenant }), + input: config.input, + ...(config.fields !== undefined && { fields: config.fields }) + }; + + return executeActionRpcRequest>( + payload, + config + ); +} + + +/** + * Validate: Create a new Follow + * + * @ashActionType :create + * @validation true + */ +export async function validateFollowUser( + config: { + tenant?: string; + input: FollowUserInput; + headers?: Record; + fetchOptions?: RequestInit; + customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; +} +): Promise { + const payload = { + action: "follow_user", + ...(config.tenant !== undefined && { tenant: config.tenant }), + input: config.input + }; + + return executeValidationRpcRequest( + payload, + config + ); +} + + +export type ReadFollowFields = UnifiedFieldSelection[]; + + +export type InferReadFollowResult< + Fields extends ReadFollowFields | undefined, + Page extends ReadFollowConfig["page"] = undefined +> = ConditionalPaginatedResultMixed>, { + results: Array>; + hasMore: boolean; + limit: number; + offset: number; + count?: number | null; + type: "offset"; +}, { + results: Array>; + hasMore: boolean; + limit: number; + after: string | null; + before: string | null; + previousPage: string; + nextPage: string; + count?: number | null; + type: "keyset"; +}>; + +export type ReadFollowConfig = { + tenant?: string; + fields: ReadFollowFields; + filter?: followsFilterInput; + sort?: SortString | SortString[]; + page?: ( + { + limit?: number; + offset?: number; + count?: boolean; + } | { + limit?: number; + after?: string; + before?: string; + } + ); + headers?: Record; + fetchOptions?: RequestInit; + customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; +}; + +export type ReadFollowResult = | { success: true; data: InferReadFollowResult; } +| { success: false; errors: AshRpcError[]; } + +; + +/** + * Read Follow records + * + * @ashActionType :read + */ +export async function readFollow( + config: Config & { fields: Fields } +): Promise> { + const payload = { + action: "read_follow", + ...(config.tenant !== undefined && { tenant: config.tenant }), + ...(config.fields !== undefined && { fields: config.fields }), + ...(config.filter && { filter: config.filter }), + ...(config.sort && { sort: Array.isArray(config.sort) ? config.sort.join(",") : config.sort }), + ...(config.page && { page: config.page }) + }; + + return executeActionRpcRequest>( + payload, + config + ); +} + + +/** + * Validate: Read Follow records + * + * @ashActionType :read + * @validation true + */ +export async function validateReadFollow( + config: { + tenant?: string; + headers?: Record; + fetchOptions?: RequestInit; + customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; +} +): Promise { + const payload = { + action: "read_follow", + ...(config.tenant !== undefined && { tenant: config.tenant }) + }; + + return executeValidationRpcRequest( + payload, + config + ); +} + + +export type UnfollowUserInput = { + followingId: UUID; +}; + +export type InferUnfollowUserResult = {}; + +export type UnfollowUserResult = | { success: true; data: InferUnfollowUserResult; } +| { success: false; errors: AshRpcError[]; } + +; + +/** + * Execute generic action on Follow + * + * @ashActionType :action + */ +export async function unfollowUser( + config: { + tenant?: string; + input: UnfollowUserInput; + headers?: Record; + fetchOptions?: RequestInit; + customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; +} +): Promise { + const payload = { + action: "unfollow_user", + ...(config.tenant !== undefined && { tenant: config.tenant }), + input: config.input + }; + + return executeActionRpcRequest( + payload, + config + ); +} + + +/** + * Validate: Execute generic action on Follow + * + * @ashActionType :action + * @validation true + */ +export async function validateUnfollowUser( + config: { + tenant?: string; + input: UnfollowUserInput; + headers?: Record; + fetchOptions?: RequestInit; + customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; +} +): Promise { + const payload = { + action: "unfollow_user", + ...(config.tenant !== undefined && { tenant: config.tenant }), + input: config.input + }; + + return executeValidationRpcRequest( + payload, + config + ); +} + + export type ReadUserFields = UnifiedFieldSelection[]; diff --git a/assets/js/ash_types.ts b/assets/js/ash_types.ts index c4cc722..b4b759f 100644 --- a/assets/js/ash_types.ts +++ b/assets/js/ash_types.ts @@ -6,12 +6,32 @@ export type UUID = string; export type UtcDateTimeUsec = string; +// follows Schema +export type followsResourceSchema = { + __type: "Resource"; + __primitiveFields: "id"; + id: UUID; +}; + + + +export type followsAttributesOnlySchema = { + __type: "Resource"; + __primitiveFields: "id"; + id: UUID; +}; + + // users Schema export type usersResourceSchema = { __type: "Resource"; - __primitiveFields: "id" | "email"; + __primitiveFields: "id" | "email" | "followerCount" | "followingCount" | "amIFollowing" | "myFollowId"; id: UUID; email: string; + followerCount: number; + followingCount: number; + amIFollowing: boolean; + myFollowId: UUID; }; @@ -78,6 +98,20 @@ export type tweetsAttributesOnlySchema = { }; +export type followsFilterInput = { + and?: Array; + or?: Array; + not?: Array; + + id?: { + eq?: UUID; + notEq?: UUID; + in?: Array; + }; + + + +}; export type usersFilterInput = { and?: Array; or?: Array; @@ -95,6 +129,40 @@ export type usersFilterInput = { in?: Array; }; + followerCount?: { + eq?: number; + notEq?: number; + greaterThan?: number; + greaterThanOrEqual?: number; + lessThan?: number; + lessThanOrEqual?: number; + in?: Array; + isNil?: boolean; + }; + + followingCount?: { + eq?: number; + notEq?: number; + greaterThan?: number; + greaterThanOrEqual?: number; + lessThan?: number; + lessThanOrEqual?: number; + in?: Array; + isNil?: boolean; + }; + + amIFollowing?: { + eq?: boolean; + notEq?: boolean; + isNil?: boolean; + }; + + myFollowId?: { + eq?: UUID; + notEq?: UUID; + in?: Array; + isNil?: boolean; + }; }; @@ -203,7 +271,10 @@ export type tweetsFilterInput = { }; -export const usersFilterFields = ["id", "email"] as const; +export const followsFilterFields = ["id"] as const; +export type followsFilterField = (typeof followsFilterFields)[number]; + +export const usersFilterFields = ["id", "email", "followerCount", "followingCount", "amIFollowing", "myFollowId"] as const; export type usersFilterField = (typeof usersFilterFields)[number]; export const mediaFilterFields = ["id", "s3Key", "userId", "tweetId", "user", "tweet"] as const; @@ -213,7 +284,10 @@ export const tweetsFilterFields = ["id", "content", "likes", "userId", "inserted export type tweetsFilterField = (typeof tweetsFilterFields)[number]; -export const usersSortFields = ["id", "email"] as const; +export const followsSortFields = ["id"] as const; +export type followsSortField = (typeof followsSortFields)[number]; + +export const usersSortFields = ["id", "email", "followerCount", "followingCount", "amIFollowing", "myFollowId"] as const; export type usersSortField = (typeof usersSortFields)[number]; export const mediaSortFields = ["id", "s3Key", "userId", "tweetId"] as const; diff --git a/assets/js/index.tsx b/assets/js/index.tsx index 312405b..7199808 100644 --- a/assets/js/index.tsx +++ b/assets/js/index.tsx @@ -16,6 +16,8 @@ import { unlikeTweet, updateTweet, readUser, + followUser, + unfollowUser, buildCSRFHeaders, } from "./ash_rpc"; import { uploadFile } from "./upload"; @@ -26,7 +28,14 @@ const queryClient = new QueryClient({ // ── Types ────────────────────────────────────────────────────────────────────── -type User = { id: string; email: string }; +type User = { + id: string; + email: string; + followerCount?: number; + followingCount?: number; + amIFollowing?: boolean; + myFollowId?: string | null; +}; type MediaItem = { id: string; s3Key: string }; type Tweet = { id: string; @@ -887,6 +896,56 @@ function RefreshButton() { ); } +function FollowButton({ targetUserId, amIFollowing }: { targetUserId: string; amIFollowing: boolean }) { + const { userId: currentUserId } = useContext(AuthCtx); + const qc = useQueryClient(); + + const followMutation = useMutation({ + mutationFn: async () => { + const res = await followUser({ + input: { followingId: targetUserId }, + headers: buildCSRFHeaders(), + }); + if (!res.success) throw new Error((res.errors?.[0] as any)?.message ?? "Follow failed"); + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["users"] }); + qc.invalidateQueries({ queryKey: ["user", targetUserId] }); + }, + }); + + const unfollowMutation = useMutation({ + mutationFn: async () => { + const res = await unfollowUser({ + input: { followingId: targetUserId }, + headers: buildCSRFHeaders(), + }); + if (!res.success) throw new Error((res.errors?.[0] as any)?.message ?? "Unfollow failed"); + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["users"] }); + qc.invalidateQueries({ queryKey: ["user", targetUserId] }); + }, + }); + + if (!currentUserId || currentUserId === targetUserId) return null; + + const isPending = followMutation.isPending || unfollowMutation.isPending; + + return ( + + ); +} + function UserCard({ user }: { user: User }) { const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null); @@ -913,7 +972,14 @@ function UserCard({ user }: { user: User }) {
{user.email} +
+ {(user.followerCount !== undefined || user.followingCount !== undefined) && ( +
+ {user.followerCount ?? 0} followers + {user.followingCount ?? 0} following +
+ )}
{ctxMenu && ( { const res = await readUser({ - fields: ["id", "email"], + fields: ["id", "email", "followerCount", "followingCount", "amIFollowing"], headers: buildCSRFHeaders(), }); if (!res.success) throw new Error("Failed to load users"); @@ -970,7 +1036,7 @@ function UserDetail({ userId }: { userId: string }) { queryKey: ["user", userId], queryFn: async () => { const res = await readUser({ - fields: ["id", "email"], + fields: ["id", "email", "followerCount", "followingCount", "amIFollowing"], filter: { id: { eq: userId } }, headers: buildCSRFHeaders(), }); @@ -998,7 +1064,16 @@ function UserDetail({ userId }: { userId: string }) {
M
- {user.email} +
+
+ {user.email} + +
+
+ {user.followerCount ?? 0} followers + {user.followingCount ?? 0} following +
+
diff --git a/lib/mixer/accounts.ex b/lib/mixer/accounts.ex index cf73236..a40288d 100644 --- a/lib/mixer/accounts.ex +++ b/lib/mixer/accounts.ex @@ -9,11 +9,18 @@ defmodule Mixer.Accounts do resource Mixer.Accounts.Token resource Mixer.Accounts.User resource Mixer.Accounts.ApiKey + + resource Mixer.Accounts.Follow end typescript_rpc do resource Mixer.Accounts.User do rpc_action :read_user, :read end + resource Mixer.Accounts.Follow do + rpc_action :read_follow, :read + rpc_action :follow_user, :follow + rpc_action :unfollow_user, :unfollow + end end end diff --git a/lib/mixer/accounts/follow.ex b/lib/mixer/accounts/follow.ex new file mode 100644 index 0000000..c599ef6 --- /dev/null +++ b/lib/mixer/accounts/follow.ex @@ -0,0 +1,102 @@ +defmodule Mixer.Accounts.Follow do + require Ash.Query + use Ash.Resource, + domain: Mixer.Accounts, + data_layer: AshPostgres.DataLayer, + authorizers: [Ash.Policy.Authorizer], + extensions: [AshTypescript.Resource] + + postgres do + table "follows" + repo Mixer.Repo + + references do + reference :follower, on_delete: :delete + reference :following, on_delete: :delete + end + end + + typescript do + type_name "follows" + end + + attributes do + uuid_primary_key :id + create_timestamp :created_at + end + + relationships do + belongs_to :follower, Mixer.Accounts.User do + primary_key? true + allow_nil? false + attribute_writable? true + end + + belongs_to :following, Mixer.Accounts.User do + primary_key? true + allow_nil? false + attribute_writable? true + end + end + + actions do + defaults [:read, :destroy] + + create :follow do + primary? true + upsert? true + upsert_identity :unique_follow + accept [:following_id] + change relate_actor(:follower) + validate fn changeset, _context -> + follower_id = Ash.Changeset.get_attribute(changeset, :follower_id) + following_id = Ash.Changeset.get_attribute(changeset, :following_id) + + if follower_id == following_id do + {:error, field: :following_id, message: "You cannot follow yourself"} + else + :ok + end + end + end + + action :unfollow do + argument :following_id, :uuid, allow_nil?: false + + run fn input, context -> + actor = context.actor + + Mixer.Accounts.Follow + |> Ash.Query.filter( + Ash.Expr.expr( + follower_id == ^actor.id and following_id == ^input.arguments.following_id + ) + ) + |> Ash.read_one(authorize?: false) + |> case do + {:ok, nil} -> :ok + {:ok, follow} -> Ash.destroy(follow, authorize?: false) + {:error, error} -> {:error, error} + end + end + end + end + + identities do + identity :unique_follow, [:follower_id, :following_id] + end + + policies do + policy action_type(:read) do + authorize_if always() + end + + policy action(:follow) do + authorize_if actor_present() + end + + policy action(:unfollow) do + authorize_if actor_present() + end + end +end diff --git a/lib/mixer/accounts/user.ex b/lib/mixer/accounts/user.ex index f31f306..92d0927 100644 --- a/lib/mixer/accounts/user.ex +++ b/lib/mixer/accounts/user.ex @@ -1,4 +1,6 @@ defmodule Mixer.Accounts.User do + import Ash.Expr + use Ash.Resource, otp_app: :mixer, domain: Mixer.Accounts, @@ -315,6 +317,34 @@ defmodule Mixer.Accounts.User do has_many :tweet_likes, Mixer.Posts.TweetLike has_many :tweets, Mixer.Posts.Tweet + + has_many :followers, Mixer.Accounts.Follow do + destination_attribute :following_id + end + + has_many :following, Mixer.Accounts.Follow do + destination_attribute :follower_id + end + end + + aggregates do + count :follower_count, :followers do + public? true + end + + count :following_count, :following do + public? true + end + + exists :am_i_following, :followers do + public? true + filter expr(follower_id == ^actor(:id)) + end + + first :my_follow_id, :followers, :id do + public? true + filter expr(follower_id == ^actor(:id)) + end end identities do diff --git a/priv/repo/migrations/20260403012654_follow_feature.exs b/priv/repo/migrations/20260403012654_follow_feature.exs new file mode 100644 index 0000000..d31946a --- /dev/null +++ b/priv/repo/migrations/20260403012654_follow_feature.exs @@ -0,0 +1,53 @@ +defmodule Mixer.Repo.Migrations.FollowFeature 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(:follows, primary_key: false) do + add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true + + add :created_at, :utc_datetime_usec, + null: false, + default: fragment("(now() AT TIME ZONE 'utc')") + + add :follower_id, + references(:users, + column: :id, + name: "follows_follower_id_fkey", + type: :uuid, + prefix: "public", + on_delete: :delete_all + ), primary_key: true, null: false + + add :following_id, + references(:users, + column: :id, + name: "follows_following_id_fkey", + type: :uuid, + prefix: "public", + on_delete: :delete_all + ), primary_key: true, null: false + end + + create unique_index(:follows, [:follower_id, :following_id], + name: "follows_unique_follow_index" + ) + end + + def down do + drop_if_exists unique_index(:follows, [:follower_id, :following_id], + name: "follows_unique_follow_index" + ) + + drop constraint(:follows, "follows_follower_id_fkey") + + drop constraint(:follows, "follows_following_id_fkey") + + drop table(:follows) + end +end diff --git a/priv/resource_snapshots/repo/follows/20260403012655.json b/priv/resource_snapshots/repo/follows/20260403012655.json new file mode 100644 index 0000000..6559c9d --- /dev/null +++ b/priv/resource_snapshots/repo/follows/20260403012655.json @@ -0,0 +1,125 @@ +{ + "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": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "created_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": true, + "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": "follows_follower_id_fkey", + "on_delete": "delete", + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "users" + }, + "scale": null, + "size": null, + "source": "follower_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": true, + "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": "follows_following_id_fkey", + "on_delete": "delete", + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "users" + }, + "scale": null, + "size": null, + "source": "following_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "create_table_options": null, + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "F54EC67C792D0DBDA389BB372D0403325AEAD4C232678C19BB951AA058813F0A", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "follows_unique_follow_index", + "keys": [ + { + "type": "atom", + "value": "follower_id" + }, + { + "type": "atom", + "value": "following_id" + } + ], + "name": "unique_follow", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mixer.Repo", + "schema": null, + "table": "follows" +} \ No newline at end of file