some reformatting and adjusting so logged in users get moved directly to their feed

This commit is contained in:
2026-04-03 19:40:17 -04:00
parent a926733f1b
commit 874fec835d
19 changed files with 233 additions and 158 deletions

View File

@@ -126,7 +126,17 @@ config :esbuild,
args:
~w(js/index.tsx js/app.js --bundle --target=es2022 --outdir=../priv/static/assets --external:/fonts/* --external:/images/* --alias:@=. --splitting --format=esm),
cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => Enum.join([Path.expand("../deps", __DIR__), Path.expand(Mix.Project.build_path()), Path.expand("../_build/dev", __DIR__)], ":")}
env: %{
"NODE_PATH" =>
Enum.join(
[
Path.expand("../deps", __DIR__),
Path.expand(Mix.Project.build_path()),
Path.expand("../_build/dev", __DIR__)
],
":"
)
}
]
# Configure tailwind (the version is required)

View File

@@ -1,6 +1,18 @@
defmodule Mixer.Accounts do
use Ash.Domain, otp_app: :mixer, extensions: [AshTypescript.Rpc, AshAdmin.Domain]
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
admin do
show? true
end
@@ -12,15 +24,4 @@ defmodule Mixer.Accounts do
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

View File

@@ -1,5 +1,6 @@
defmodule Mixer.Accounts.Follow do
require Ash.Query
use Ash.Resource,
domain: Mixer.Accounts,
data_layer: AshPostgres.DataLayer,
@@ -20,25 +21,6 @@ defmodule Mixer.Accounts.Follow 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]
@@ -48,6 +30,7 @@ defmodule Mixer.Accounts.Follow do
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)
@@ -82,10 +65,6 @@ defmodule Mixer.Accounts.Follow do
end
end
identities do
identity :unique_follow, [:follower_id, :following_id]
end
policies do
policy action_type(:read) do
authorize_if always()
@@ -99,4 +78,27 @@ defmodule Mixer.Accounts.Follow do
authorize_if actor_present()
end
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
identities do
identity :unique_follow, [:follower_id, :following_id]
end
end

View File

@@ -31,14 +31,21 @@ defmodule Mixer.Accounts.User.Senders.SendMagicLinkEmail do
defp body(params) do
# NOTE: You may have to change this to match your magic link acceptance URL.
link = url(~p"/magic_link/#{params[:token]}")
email_template("Your magic link", "Hello, #{params[:email]}!", """
<p style="margin:0 0 20px 0;color:#4B5563;font-size:16px;line-height:1.6;">
Use the button below to sign in to Mixer. This link is valid for a short time and can only be used once.
</p>
<p style="margin:0 0 32px 0;color:#4B5563;font-size:16px;line-height:1.6;">
If you didn't request this, you can safely ignore this email.
</p>
""", link, "Sign In to Mixer")
email_template(
"Your magic link",
"Hello, #{params[:email]}!",
"""
<p style="margin:0 0 20px 0;color:#4B5563;font-size:16px;line-height:1.6;">
Use the button below to sign in to Mixer. This link is valid for a short time and can only be used once.
</p>
<p style="margin:0 0 32px 0;color:#4B5563;font-size:16px;line-height:1.6;">
If you didn't request this, you can safely ignore this email.
</p>
""",
link,
"Sign In to Mixer"
)
end
defp email_template(title, greeting, content, button_url, button_label) do

View File

@@ -22,14 +22,21 @@ defmodule Mixer.Accounts.User.Senders.SendNewUserConfirmationEmail do
defp body(params) do
link = url(~p"/confirm_new_user/#{params[:token]}")
email_template("Confirm your email", "Welcome to Mixer!", """
<p style="margin:0 0 20px 0;color:#4B5563;font-size:16px;line-height:1.6;">
Thanks for signing up. Just one more step — confirm your email address to activate your account.
</p>
<p style="margin:0 0 32px 0;color:#4B5563;font-size:16px;line-height:1.6;">
If you didn't create an account on Mixer, you can safely ignore this email.
</p>
""", link, "Confirm Email Address")
email_template(
"Confirm your email",
"Welcome to Mixer!",
"""
<p style="margin:0 0 20px 0;color:#4B5563;font-size:16px;line-height:1.6;">
Thanks for signing up. Just one more step — confirm your email address to activate your account.
</p>
<p style="margin:0 0 32px 0;color:#4B5563;font-size:16px;line-height:1.6;">
If you didn't create an account on Mixer, you can safely ignore this email.
</p>
""",
link,
"Confirm Email Address"
)
end
defp email_template(title, greeting, content, button_url, button_label) do

View File

@@ -22,14 +22,21 @@ defmodule Mixer.Accounts.User.Senders.SendPasswordResetEmail do
defp body(params) do
link = url(~p"/password-reset/#{params[:token]}")
email_template("Reset your password", "Password reset request", """
<p style="margin:0 0 20px 0;color:#4B5563;font-size:16px;line-height:1.6;">
We received a request to reset the password for your Mixer account. Click the button below to choose a new one.
</p>
<p style="margin:0 0 32px 0;color:#4B5563;font-size:16px;line-height:1.6;">
If you didn't request a password reset, you can safely ignore this email — your password will not change.
</p>
""", link, "Reset My Password")
email_template(
"Reset your password",
"Password reset request",
"""
<p style="margin:0 0 20px 0;color:#4B5563;font-size:16px;line-height:1.6;">
We received a request to reset the password for your Mixer account. Click the button below to choose a new one.
</p>
<p style="margin:0 0 32px 0;color:#4B5563;font-size:16px;line-height:1.6;">
If you didn't request a password reset, you can safely ignore this email — your password will not change.
</p>
""",
link,
"Reset My Password"
)
end
defp email_template(title, greeting, content, button_url, button_label) do

View File

@@ -3,16 +3,6 @@ defmodule Mixer.Posts do
otp_app: :mixer,
extensions: [AshTypescript.Rpc, AshAdmin.Domain]
admin do
show? true
end
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
@@ -27,4 +17,14 @@ defmodule Mixer.Posts do
rpc_action :read_media, :read
end
end
admin do
show? true
end
resources do
resource Mixer.Posts.Tweet
resource Mixer.Posts.TweetLike
resource Mixer.Posts.Media
end
end

View File

@@ -38,6 +38,24 @@ defmodule Mixer.Posts.Media do
end
end
policies do
policy action_type(:read) do
authorize_if always()
end
policy action(:upload) do
authorize_if actor_present()
end
policy action(:link_to_tweet) do
authorize_if relates_to_actor_via(:user)
end
policy action_type(:destroy) do
authorize_if relates_to_actor_via(:user)
end
end
attributes do
uuid_primary_key :id
@@ -64,22 +82,4 @@ defmodule Mixer.Posts.Media do
public? true
end
end
policies do
policy action_type(:read) do
authorize_if always()
end
policy action(:upload) do
authorize_if actor_present()
end
policy action(:link_to_tweet) do
authorize_if relates_to_actor_via(:user)
end
policy action_type(:destroy) do
authorize_if relates_to_actor_via(:user)
end
end
end

View File

@@ -10,7 +10,8 @@ defmodule Mixer.Posts.MediaUploader do
if ext in @extensions, do: :ok, else: {:error, "unsupported file type #{ext}"}
end
def storage_dir(_version, {_file, scope}), do: "uploads/media/#{scope.user_id}/#{scope.media_id}"
def storage_dir(_version, {_file, scope}),
do: "uploads/media/#{scope.user_id}/#{scope.media_id}"
def filename(_version, {file, _scope}) do
Path.basename(file.file_name, Path.extname(file.file_name))

View File

@@ -14,10 +14,6 @@ defmodule Mixer.Posts.Tweet do
repo Mixer.Repo
end
typescript do
type_name "tweets"
end
state_machine do
initial_states [:drafted, :posted]
default_initial_state :drafted
@@ -27,6 +23,10 @@ defmodule Mixer.Posts.Tweet do
end
end
typescript do
type_name "tweets"
end
actions do
defaults [:read, :destroy]
@@ -36,6 +36,7 @@ defmodule Mixer.Posts.Tweet do
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 ->
@@ -45,7 +46,9 @@ defmodule Mixer.Posts.Tweet do
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.Changeset.for_update(:link_to_tweet, %{tweet_id: tweet.id},
actor: context.actor
)
|> Ash.update!()
{:ok, tweet}
@@ -111,6 +114,32 @@ defmodule Mixer.Posts.Tweet do
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)
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
@@ -165,32 +194,6 @@ defmodule Mixer.Posts.Tweet do
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)
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

View File

@@ -23,6 +23,20 @@ defmodule Mixer.Posts.TweetLike do
end
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
attributes do
uuid_primary_key :id
@@ -52,18 +66,4 @@ defmodule Mixer.Posts.TweetLike do
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

View File

@@ -2,7 +2,11 @@ defmodule MixerWeb.PageController do
use MixerWeb, :controller
def home(conn, _params) do
render(conn, :home)
if conn.assigns[:current_user] do
redirect(conn, to: ~p"/feed")
else
render(conn, :home)
end
end
def index(conn, _params) do
@@ -28,11 +32,11 @@ defmodule MixerWeb.PageController do
conn
|> put_root_layout(html: {MixerWeb.Layouts, :spa_root})
|> render(:index,
current_user: conn.assigns[:current_user],
media_host: "#{asset_host}/#{bucket}",
page: page,
tweet_id: tweet_id,
user_id: user_id
)
current_user: conn.assigns[:current_user],
media_host: "#{asset_host}/#{bucket}",
page: page,
tweet_id: tweet_id,
user_id: user_id
)
end
end

View File

@@ -1,8 +1,10 @@
<div id="app"
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-page={@page}
data-tweet-id={@tweet_id || ""}
data-user-id={@user_id || ""}>
<div
id="app"
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-page={@page}
data-tweet-id={@tweet_id || ""}
data-user-id={@user_id || ""}
>
</div>

13
mix.exs
View File

@@ -133,18 +133,21 @@ defmodule Mixer.MixProject do
build: [
"ash-framework": [
# The description tells people how to use this skill.
description: "Use this skill working with Ash Framework or any of its extensions. Always consult this when making any domain changes, features or fixes.",
description:
"Use this skill working with Ash Framework or any of its extensions. Always consult this when making any domain changes, features or fixes.",
# Include all Ash dependencies
usage_rules: [:ash, ~r/^ash_/]
],
"phoenix-framework": [
description: "Use this skill working with Phoenix Framework. Consult this when working with the web layer, controllers, views, liveviews etc.",
description:
"Use this skill working with Phoenix Framework. Consult this when working with the web layer, controllers, views, liveviews etc.",
# Include all Phoenix dependencies
usage_rules: [:phoenix, ~r/^phoenix_/]
]
]
]
]
[
file: "AGENTS.md",
usage_rules: ["usage_rules:all"],
@@ -152,11 +155,13 @@ defmodule Mixer.MixProject do
location: ".agents/skills",
build: [
"ash-framework": [
description: "Use this skill working with Ash Framework or any of its extensions. Always consult this when making any domain changes, features or fixes.",
description:
"Use this skill working with Ash Framework or any of its extensions. Always consult this when making any domain changes, features or fixes.",
usage_rules: [:ash, ~r/^ash_/]
],
"phoenix-framework": [
description: "Use this skill working with Phoenix Framework. Consult this when working with the web layer, controllers, views, liveviews etc.",
description:
"Use this skill working with Phoenix Framework. Consult this when working with the web layer, controllers, views, liveviews etc.",
usage_rules: [:phoenix, ~r/^phoenix_/]
]
]

View File

@@ -18,7 +18,8 @@ defmodule Mixer.Repo.Migrations.SetupPostsAndTweets do
name: "tweets_user_id_fkey",
type: :uuid,
prefix: "public"
), null: false
),
null: false
add :state, :text, null: false, default: "drafted"
end

View File

@@ -22,7 +22,8 @@ defmodule Mixer.Repo.Migrations.AddPostsMediaS3 do
name: "media_tweet_id_fkey",
type: :uuid,
prefix: "public"
), null: false
),
null: false
end
end

View File

@@ -17,7 +17,8 @@ defmodule Mixer.Repo.Migrations.AddUserIdToMediaAndAllowNullTweetId do
name: "media_user_id_fkey",
type: :uuid,
prefix: "public"
), null: false
),
null: false
end
end

View File

@@ -17,7 +17,8 @@ defmodule Mixer.Repo.Migrations.AddTweetLikes do
name: "tweet_likes_tweet_id_fkey",
type: :uuid,
prefix: "public"
), null: false
),
null: false
add :user_id,
references(:users,
@@ -25,7 +26,8 @@ defmodule Mixer.Repo.Migrations.AddTweetLikes do
name: "tweet_likes_user_id_fkey",
type: :uuid,
prefix: "public"
), null: false
),
null: false
end
create unique_index(:tweet_likes, [:tweet_id, :user_id],

View File

@@ -1,8 +1,29 @@
defmodule MixerWeb.PageControllerTest do
use MixerWeb.ConnCase
test "GET /", %{conn: conn} do
test "GET / redirects to /feed when logged in", %{conn: conn} do
user =
Mixer.Accounts.User
|> Ash.Changeset.for_create(
:register_with_password,
%{
email: "test@example.com",
password: "Password1!",
password_confirmation: "Password1!"
}, authorize?: false)
|> Ash.create!()
conn =
conn
|> Plug.Test.init_test_session(%{})
|> AshAuthentication.Plug.Helpers.store_in_session(user)
|> get(~p"/")
assert redirected_to(conn) == ~p"/feed"
end
test "GET / renders the home page for unauthenticated users", %{conn: conn} do
conn = get(conn, ~p"/")
assert html_response(conn, 200) =~ "Peace of mind from prototype to production"
assert html_response(conn, 200) =~ "Mixer"
end
end