refactor: adress review comments
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Simon 2026-03-09 18:54:40 +01:00
parent 0614592674
commit 5deb102e45
Signed by: simon
GPG key ID: 40E7A58C4AA1EDB2
11 changed files with 111 additions and 52 deletions

View file

@ -31,6 +31,7 @@ defmodule Mv.Membership do
alias Ash.Error.Query.NotFound, as: NotFoundError
alias Mv.Membership.JoinRequest
alias MvWeb.Emails.JoinConfirmationEmail
require Logger
admin do
show? true
@ -369,8 +370,9 @@ defmodule Mv.Membership do
actor = Keyword.get(opts, :actor)
token = Map.get(attrs, :confirmation_token) || generate_confirmation_token()
attrs_with_token =
attrs |> Map.drop([:confirmation_token]) |> Map.put(:confirmation_token, token)
# Raw token is passed to the submit action; JoinRequest.Changes.SetConfirmationToken
# hashes it before persist. Only the hash is stored; the raw token is sent in the email link.
attrs_with_token = Map.put(attrs, :confirmation_token, token)
case Ash.create(JoinRequest, attrs_with_token,
action: :submit,
@ -378,8 +380,18 @@ defmodule Mv.Membership do
domain: __MODULE__
) do
{:ok, request} ->
JoinConfirmationEmail.send(request.email, token)
{:ok, request}
case JoinConfirmationEmail.send(request.email, token) do
{:ok, _email} ->
{:ok, request}
{:error, reason} ->
Logger.error(
"Join confirmation email failed for #{request.email}: #{inspect(reason)}"
)
# Request was created; return success so the user sees the confirmation message
{:ok, request}
end
error ->
error

View file

@ -17,6 +17,7 @@ defmodule Mix.Tasks.JoinRequests.CleanupExpired do
use Mix.Task
require Ash.Query
require Logger
alias Mv.Membership.JoinRequest
@ -34,23 +35,41 @@ defmodule Mix.Tasks.JoinRequests.CleanupExpired do
|> Ash.Query.filter(confirmation_token_expires_at < ^now)
# Bypass authorization: cleanup is a system maintenance task (cron/Oban).
case Ash.read(query, domain: Mv.Membership, authorize?: false) do
{:ok, requests} ->
count = delete_expired_requests(requests)
# Use bulk_destroy so the data layer can delete in one pass when supported.
opts = [domain: Mv.Membership, authorize?: false]
count =
case Ash.count(query, opts) do
{:ok, n} -> n
{:error, _} -> 0
end
do_run(query, opts, count)
end
defp do_run(_query, _opts, 0) do
Mix.shell().info("No expired join requests to delete.")
0
end
defp do_run(query, opts, count) do
case Ash.bulk_destroy(query, :destroy, %{}, opts) do
%{status: status, errors: errors} when status in [:success, :partial_success] ->
maybe_log_errors(errors)
Mix.shell().info("Deleted #{count} expired join request(s).")
count
{:error, error} ->
Mix.raise("Failed to list expired join requests: #{inspect(error)}")
%{status: :error, errors: errors} ->
Mix.raise("Failed to delete expired join requests: #{inspect(errors)}")
end
end
defp delete_expired_requests(requests) do
Enum.reduce_while(requests, 0, fn request, acc ->
case Ash.destroy(request, domain: Mv.Membership, authorize?: false) do
:ok -> {:cont, acc + 1}
{:error, _} -> {:halt, acc}
end
end)
defp maybe_log_errors(nil), do: :ok
defp maybe_log_errors([]), do: :ok
defp maybe_log_errors(errors) do
Logger.warning(
"Join requests cleanup: #{length(errors)} error(s) while deleting expired requests: #{inspect(errors)}"
)
end
end

View file

@ -37,13 +37,19 @@ defmodule Mv.Accounts.User.Senders.SendNewUserConfirmationEmail do
confirm_url = url(~p"/confirm_new_user/#{token}")
subject = gettext("Confirm your email address")
assigns = %{
confirm_url: confirm_url,
subject: subject,
app_name: Mailer.mail_from() |> elem(0),
locale: Gettext.get_locale(MvWeb.Gettext)
}
new()
|> from(Mailer.mail_from())
|> to(to_string(user.email))
|> subject(subject)
|> put_view(MvWeb.EmailsView)
|> put_layout({MvWeb.EmailLayoutView, "layout.html"})
|> render_body("user_confirmation.html", %{confirm_url: confirm_url, subject: subject})
|> render_body("user_confirmation.html", assigns)
|> Mailer.deliver!()
end
end

View file

@ -37,13 +37,19 @@ defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do
reset_url = url(~p"/password-reset/#{token}")
subject = gettext("Reset your password")
assigns = %{
reset_url: reset_url,
subject: subject,
app_name: Mailer.mail_from() |> elem(0),
locale: Gettext.get_locale(MvWeb.Gettext)
}
new()
|> from(Mailer.mail_from())
|> to(to_string(user.email))
|> subject(subject)
|> put_view(MvWeb.EmailsView)
|> put_layout({MvWeb.EmailLayoutView, "layout.html"})
|> render_body("password_reset.html", %{reset_url: reset_url, subject: subject})
|> render_body("password_reset.html", assigns)
|> Mailer.deliver!()
end
end

View file

@ -4,6 +4,9 @@ defmodule MvWeb.EmailLayoutView do
Renders a single layout template that wraps all email body content.
See docs/email-layout-mockup.md for the layout structure.
Uses Phoenix.View (legacy API) for compatibility with phoenix_swoosh email rendering.
Layout expects assigns :app_name and :locale (passed from each email sender).
"""
use Phoenix.View,
root: "lib/mv_web",

View file

@ -4,7 +4,7 @@ defmodule MvWeb.Emails.JoinConfirmationEmail do
"""
use Phoenix.Swoosh,
view: MvWeb.EmailsView,
layout: {MvWeb.EmailLayoutView, :layout}
layout: {MvWeb.EmailLayoutView, "layout.html"}
use MvWeb, :verified_routes
import Swoosh.Email
@ -16,18 +16,28 @@ defmodule MvWeb.Emails.JoinConfirmationEmail do
Sends the join confirmation email to the given address with the confirmation link.
Called from the domain after a JoinRequest is created (submit flow).
Returns `{:ok, email}` on success, `{:error, reason}` on delivery failure.
Callers should log errors and may still return success for the overall operation
(e.g. join request created) so the user is not shown a generic error when only
the email failed.
"""
def send(email_address, token) when is_binary(email_address) and is_binary(token) do
confirm_url = url(~p"/confirm_join/#{token}")
subject = gettext("Confirm your membership request")
assigns = %{
confirm_url: confirm_url,
subject: subject,
app_name: Mailer.mail_from() |> elem(0),
locale: Gettext.get_locale(MvWeb.Gettext)
}
new()
|> from(Mailer.mail_from())
|> to(email_address)
|> subject(subject)
|> put_view(MvWeb.EmailsView)
|> put_layout({MvWeb.EmailLayoutView, "layout.html"})
|> render_body("join_confirmation.html", %{confirm_url: confirm_url, subject: subject})
|> Mailer.deliver!()
|> render_body("join_confirmation.html", assigns)
|> Mailer.deliver()
end
end

View file

@ -1,9 +1,9 @@
<!DOCTYPE html>
<html lang="de">
<html lang={assigns[:locale] || "de"}>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{assigns[:subject] || "Mila"}</title>
<title>{assigns[:subject] || assigns[:app_name] || "Mila"}</title>
</head>
<body style="margin: 0; padding: 0; font-family: system-ui, -apple-system, sans-serif; background-color: #f3f4f6;">
<table
@ -15,7 +15,9 @@
>
<tr>
<td style="padding: 24px 16px 16px;">
<div style="font-size: 18px; font-weight: 600; color: #111827;">Mila</div>
<div style="font-size: 18px; font-weight: 600; color: #111827;">
{assigns[:app_name] || "Mila"}
</div>
</td>
</tr>
<tr>
@ -25,7 +27,7 @@
</tr>
<tr>
<td style="padding: 16px 24px; font-size: 12px; color: #6b7280;">
© {DateTime.utc_now().year} Mila · Mitgliederverwaltung
© {DateTime.utc_now().year} {assigns[:app_name] || "Mila"} · Mitgliederverwaltung
</td>
</tr>
</table>