diff --git a/assets/js/ash_rpc.ts b/assets/js/ash_rpc.ts index a7d44b4..18e48b0 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 } from "./ash_types"; +import type { AshRpcError, ConditionalPaginatedResultMixed, InferResult, SortString, UUID, UnifiedFieldSelection, ValidationResult, mediaFilterInput, mediaResourceSchema, mediaSortField, tweetsFilterInput, tweetsResourceSchema, tweetsSortField, usersFilterInput, usersResourceSchema, usersSortField } from "./ash_types"; export type * from "./ash_types"; // Helper Functions @@ -201,6 +201,107 @@ export async function executeValidationRpcRequest( +export type ReadUserFields = UnifiedFieldSelection[]; + + +export type InferReadUserResult< + Fields extends ReadUserFields | undefined, + Page extends ReadUserConfig["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 ReadUserConfig = { + tenant?: string; + fields: ReadUserFields; + filter?: usersFilterInput; + 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 ReadUserResult = | { success: true; data: InferReadUserResult; } +| { success: false; errors: AshRpcError[]; } + +; + +/** + * Read User records + * + * @ashActionType :read + */ +export async function readUser( + config: Config & { fields: Fields } +): Promise> { + const payload = { + action: "read_user", + ...(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 User records + * + * @ashActionType :read + * @validation true + */ +export async function validateReadUser( + config: { + tenant?: string; + headers?: Record; + fetchOptions?: RequestInit; + customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; +} +): Promise { + const payload = { + action: "read_user", + ...(config.tenant !== undefined && { tenant: config.tenant }) + }; + + return executeValidationRpcRequest( + payload, + config + ); +} + + export type ReadMediaFields = UnifiedFieldSelection[]; diff --git a/assets/js/ash_types.ts b/assets/js/ash_types.ts index e7600d6..c4cc722 100644 --- a/assets/js/ash_types.ts +++ b/assets/js/ash_types.ts @@ -6,6 +6,24 @@ export type UUID = string; export type UtcDateTimeUsec = string; +// users Schema +export type usersResourceSchema = { + __type: "Resource"; + __primitiveFields: "id" | "email"; + id: UUID; + email: string; +}; + + + +export type usersAttributesOnlySchema = { + __type: "Resource"; + __primitiveFields: "id" | "email"; + id: UUID; + email: string; +}; + + // media Schema export type mediaResourceSchema = { __type: "Resource"; @@ -14,6 +32,7 @@ export type mediaResourceSchema = { s3Key: string; userId: UUID; tweetId: UUID | null; + user: { __type: "Relationship"; __resource: usersResourceSchema; }; tweet: { __type: "Relationship"; __resource: tweetsResourceSchema | null; }; }; @@ -41,6 +60,7 @@ export type tweetsResourceSchema = { state: "posted" | "drafted"; likedByMe: boolean; userEmail: string | null; + user: { __type: "Relationship"; __resource: usersResourceSchema; }; media: { __type: "Relationship"; __array: true; __resource: mediaResourceSchema; }; }; @@ -58,6 +78,26 @@ export type tweetsAttributesOnlySchema = { }; +export type usersFilterInput = { + and?: Array; + or?: Array; + not?: Array; + + id?: { + eq?: UUID; + notEq?: UUID; + in?: Array; + }; + + email?: { + eq?: string; + notEq?: string; + in?: Array; + }; + + + +}; export type mediaFilterInput = { and?: Array; or?: Array; @@ -89,6 +129,8 @@ export type mediaFilterInput = { }; + user?: usersFilterInput; + tweet?: tweetsFilterInput; }; @@ -154,11 +196,16 @@ export type tweetsFilterInput = { isNil?: boolean; }; + user?: usersFilterInput; + media?: mediaFilterInput; }; +export const usersFilterFields = ["id", "email"] as const; +export type usersFilterField = (typeof usersFilterFields)[number]; + export const mediaFilterFields = ["id", "s3Key", "userId", "tweetId", "user", "tweet"] as const; export type mediaFilterField = (typeof mediaFilterFields)[number]; @@ -166,6 +213,9 @@ export const tweetsFilterFields = ["id", "content", "likes", "userId", "inserted export type tweetsFilterField = (typeof tweetsFilterFields)[number]; +export const usersSortFields = ["id", "email"] as const; +export type usersSortField = (typeof usersSortFields)[number]; + export const mediaSortFields = ["id", "s3Key", "userId", "tweetId"] as const; export type mediaSortField = (typeof mediaSortFields)[number]; diff --git a/assets/js/index.tsx b/assets/js/index.tsx index 91a479c..f7ed4e3 100644 --- a/assets/js/index.tsx +++ b/assets/js/index.tsx @@ -15,6 +15,7 @@ import { likeTweet, unlikeTweet, updateTweet, + readUser, buildCSRFHeaders, } from "./ash_rpc"; import { uploadFile } from "./upload"; @@ -25,6 +26,7 @@ const queryClient = new QueryClient({ // ── Types ────────────────────────────────────────────────────────────────────── +type User = { id: string; email: string }; type MediaItem = { id: string; s3Key: string }; type Tweet = { id: string; @@ -772,11 +774,169 @@ function RefreshButton() { ); } +function UserCard({ user }: { user: User }) { + return ( +
{ window.location.href = `/users/${user.id}`; }} + > +
+ M +
+
+
+ {user.email} +
+
+
+ ); +} + +function UserList() { + const { data, isLoading, isError, error } = useQuery({ + queryKey: ["users"], + queryFn: async () => { + const res = await readUser({ + fields: ["id", "email"], + headers: buildCSRFHeaders(), + }); + if (!res.success) throw new Error("Failed to load users"); + const users = Array.isArray(res.data) ? res.data : (res.data as any)?.results ?? []; + return users as User[]; + }, + }); + + if (isLoading) return ; + if (isError) return ; + + const users = data ?? []; + + if (users.length === 0) { + return ( +
+
+

No users yet

+

Be the first to sign up.

+
+ ); + } + + return ( +
+ {users.map((u) => ( + + ))} +
+ ); +} + +function UserDetail({ userId }: { userId: string }) { + const { data: user, isLoading, isError } = useQuery({ + queryKey: ["user", userId], + queryFn: async () => { + const res = await readUser({ + fields: ["id", "email"], + filter: { id: { eq: userId } }, + headers: buildCSRFHeaders(), + }); + if (!res.success) throw new Error("Failed to load user"); + const results = Array.isArray(res.data) ? res.data : (res.data as any)?.results ?? []; + return (results[0] as User) ?? null; + }, + }); + + if (isLoading) return ; + if (isError || !user) return ; + + return ( +
+ +
+
+
+ M +
+ {user.email} +
+
+
+ ); +} + function App() { const appEl = document.getElementById("app")!; const email = appEl.dataset.currentUserEmail ?? ""; const userId = appEl.dataset.currentUserId ?? ""; const tweetId = appEl.dataset.tweetId || null; + const page = appEl.dataset.page ?? "feed"; + const profileUserId = appEl.dataset.userId || null; + + const onFeedPage = page === "feed" || page === "tweet"; + const onUsersPage = page === "users" || page === "user-detail"; + + function renderMain() { + switch (page) { + case "tweet": + return ( + <> +
+

Tweet

+
+ + + ); + case "users": + return ( + <> +
+

Users

+
+ + + ); + case "user-detail": + return ( + <> +
+

Profile

+
+ + + ); + default: + return ( + <> +
+

Feed

+ +
+ +
+ {email ? ( + + ) : ( +
+

Sign in to start mixing.

+ Sign in +
+ )} +
+ +
+ + + + ); + } + } return ( @@ -788,12 +948,18 @@ function App() { Mixer
{email ? ( @@ -812,36 +978,7 @@ function App() {
- {tweetId ? ( - <> -
-

Tweet

-
- - - ) : ( - <> -
-

Feed

- -
- -
- {email ? ( - - ) : ( -
-

Sign in to start mixing.

- Sign in -
- )} -
- -
- - - - )} + {renderMain()}
diff --git a/lib/mixer/accounts.ex b/lib/mixer/accounts.ex index 237a9cd..cf73236 100644 --- a/lib/mixer/accounts.ex +++ b/lib/mixer/accounts.ex @@ -1,5 +1,5 @@ defmodule Mixer.Accounts do - use Ash.Domain, otp_app: :mixer, extensions: [AshAdmin.Domain] + use Ash.Domain, otp_app: :mixer, extensions: [AshTypescript.Rpc, AshAdmin.Domain] admin do show? true @@ -10,4 +10,10 @@ defmodule Mixer.Accounts do resource Mixer.Accounts.User resource Mixer.Accounts.ApiKey end + + typescript_rpc do + resource Mixer.Accounts.User do + rpc_action :read_user, :read + end + end end diff --git a/lib/mixer/accounts/user.ex b/lib/mixer/accounts/user.ex index d25676a..f31f306 100644 --- a/lib/mixer/accounts/user.ex +++ b/lib/mixer/accounts/user.ex @@ -4,7 +4,7 @@ defmodule Mixer.Accounts.User do domain: Mixer.Accounts, data_layer: AshPostgres.DataLayer, authorizers: [Ash.Policy.Authorizer], - extensions: [AshAuthentication] + extensions: [AshAuthentication, AshTypescript.Resource] authentication do add_ons do @@ -66,6 +66,10 @@ defmodule Mixer.Accounts.User do repo Mixer.Repo end + typescript do + type_name "users" + end + actions do defaults [:read] @@ -282,6 +286,10 @@ defmodule Mixer.Accounts.User do bypass AshAuthentication.Checks.AshAuthenticationInteraction do authorize_if always() end + + policy action_type(:read) do + authorize_if always() + end end attributes do diff --git a/lib/mixer_web/controllers/page_controller.ex b/lib/mixer_web/controllers/page_controller.ex index 7d7b88b..82a79d0 100644 --- a/lib/mixer_web/controllers/page_controller.ex +++ b/lib/mixer_web/controllers/page_controller.ex @@ -6,14 +6,22 @@ defmodule MixerWeb.PageController do end def index(conn, _params) do - render_spa(conn, nil) + render_spa(conn, %{page: "feed", tweet_id: nil, user_id: nil}) end def show(conn, %{"tweet_id" => tweet_id}) do - render_spa(conn, tweet_id) + render_spa(conn, %{page: "tweet", tweet_id: tweet_id, user_id: nil}) end - defp render_spa(conn, tweet_id) do + def users_index(conn, _params) do + render_spa(conn, %{page: "users", tweet_id: nil, user_id: nil}) + end + + def user_show(conn, %{"user_id" => user_id}) do + render_spa(conn, %{page: "user-detail", tweet_id: nil, user_id: user_id}) + end + + defp render_spa(conn, %{page: page, tweet_id: tweet_id, user_id: user_id}) do asset_host = Application.get_env(:waffle, :asset_host, "http://localhost:3900") bucket = Application.get_env(:waffle, :bucket, "mixer-bucket") @@ -22,7 +30,9 @@ defmodule MixerWeb.PageController do |> render(:index, current_user: conn.assigns[:current_user], media_host: "#{asset_host}/#{bucket}", - tweet_id: tweet_id + page: page, + tweet_id: tweet_id, + user_id: user_id ) end end diff --git a/lib/mixer_web/controllers/page_html/index.html.heex b/lib/mixer_web/controllers/page_html/index.html.heex index 3b9618f..295e0ec 100644 --- a/lib/mixer_web/controllers/page_html/index.html.heex +++ b/lib/mixer_web/controllers/page_html/index.html.heex @@ -2,5 +2,7 @@ data-current-user-id={if @current_user, do: @current_user.id, else: ""} data-current-user-email={if @current_user, do: @current_user.email, else: ""} data-asset-host={@media_host} - data-tweet-id={@tweet_id || ""}> + data-page={@page} + data-tweet-id={@tweet_id || ""} + data-user-id={@user_id || ""}>
diff --git a/lib/mixer_web/router.ex b/lib/mixer_web/router.ex index 016055b..1973da9 100644 --- a/lib/mixer_web/router.ex +++ b/lib/mixer_web/router.ex @@ -40,6 +40,8 @@ defmodule MixerWeb.Router do get "/", PageController, :home get "/feed", PageController, :index get "/feed/:tweet_id", PageController, :show + get "/users", PageController, :users_index + get "/users/:user_id", PageController, :user_show post "/rpc/run", AshTypescriptRpcController, :run post "/rpc/validate", AshTypescriptRpcController, :validate post "/upload", UploadController, :create