Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions GEMINI.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,29 +53,38 @@ This is a non-negotiable security requirement.
- **Close Issues in Commits:** Use `Closes #<issue_number>` in commit messages.
- **Commit with Confidence:** Before amending a commit, run `git log -n 1` to ensure you are modifying the correct one. Prefer using `fixup!` commits for targeted changes to avoid interactive rebasing.
- **Escape Backticks:** When using `git commit -m`, escape backticks (`).
- **Atomic Commits:** Each commit should represent a single logical change. Do not group unrelated changes.
- **Descriptive Messages:** Commit messages should be descriptive and explain the "what" and "why" of the change.
- **Pull Before Pushing:** Always run `git pull --rebase` before pushing to ensure your local branch is up to date.
- **Check Status Before Stashing:** Run `git status` before `git stash` to be aware of untracked files.

### Elixir & Phoenix Conventions
- **Navigate with Precision:** Use `get_source_location` to find module/function definitions.
- **Data Integrity:** All validation must be in Ecto changesets. Keep `priv/repo/seeds.exs` updated.
- **Real-time Updates:** Broadcast only resource IDs via PubSub, not full data objects.
- **Migrations:** Prefer Ecto's migration helpers over raw SQL for schema changes.
- **UI & Components:**
- All user-facing text must be in Portuguese (`pt_PT`) using `gettext`.
- Use `Phoenix.VerifiedRoutes` in components that use the `~p` sigil.
- Verify `attr` definitions in `core_components.ex` before using them.
- Wrap all top-level LiveViews in the `<.main_layout>` component.

### Tool Usage
- **Use Existing Tools:** Before attempting to use a tool, ensure it exists in the provided list. Do not hallucinate tool names.
- **Prefer Framework Features:** When available, prefer using framework-specific features (e.g., Ecto migrations) over raw SQL or other low-level implementations.

---

##
Testing Strategy

- **ALWAYS Test, Then Format:** Run `mix test` after any change. If it passes, run `mix format`.
- **Write Feature Tests:** New features require new tests.
- **Treat Warnings as Errors:** Address all compiler warnings before committing.
- **The Two-Strike Rule:** If a second attempt to fix a test fails, stop and re-evaluate the approach.
- **Verify the Outcome:** Prioritize testing the business outcome over UI details.
- **Fixtures First:** Use existing fixtures from `test/support/fixtures/` for test data.
- **Fixtures First:** Use existing fixtures from `test/support/fixtures/` for test data. Note that fixture files use the `.ex` extension.
- **Debug the App, Not the Test:** Analyze the stack trace to find the root cause in the application code.
- **Testing LiveView Navigation:** Use the `follow_redirect(conn)` helper to test `push_navigate` or `push_redirect`.
- **Testing Stateless Components:** Use `render_component/2` for stateless functional components.
- **Testing with Scope:** Use the `log_in_user/2` helper in tests to set up the `current_scope`.
- **Testing with Scope:** Use the `log_in_user/2` helper in tests to set up the `current_scope`.
17 changes: 14 additions & 3 deletions lib/cms/accounts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ defmodule CMS.Accounts do
def list_groups(%Scope{organization: %Organization{id: org_id}}) do
from(g in Group,
where: g.organization_id == ^org_id,
order_by: [asc: g.name]
order_by: [asc: g.name],
preload: [:users]
)
|> Repo.all()
end
Expand All @@ -28,6 +29,12 @@ defmodule CMS.Accounts do
Group.changeset(group, attrs, scope)
end

def update_group(scope, group, attrs) do
group
|> Group.changeset(attrs, scope)
|> Repo.update()
end

## Database getters

@doc """
Expand Down Expand Up @@ -297,8 +304,12 @@ defmodule CMS.Accounts do
{:ok, query} = UserToken.verify_session_token_query(token)

case Repo.one(query) do
{user, token_inserted_at} -> {Repo.preload(user, :organization), token_inserted_at}
_ -> nil
{user, token_inserted_at} ->
user = Repo.preload(user, [:organization, :groups])
{user, token_inserted_at}

_ ->
nil
end
end

Expand Down
3 changes: 3 additions & 0 deletions lib/cms/accounts/group.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ defmodule CMS.Accounts.Group do
field :description, :string

belongs_to :organization, CMS.Accounts.Organization

many_to_many :users, CMS.Accounts.User, join_through: "groups_users"

timestamps()
end

Expand Down
6 changes: 3 additions & 3 deletions lib/cms/accounts/scope.ex
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,17 @@ defmodule CMS.Accounts.Scope do

alias CMS.Accounts.{User, Organization}

defstruct user: nil, organization: nil
defstruct user: nil, organization: nil, groups: []

@doc """
Creates a scope for the given user.

Expects the user struct to have the `:organization` association preloaded.
Returns nil if no user is given.
"""
def for_user(%User{} = user, %Organization{} = organization) do
def for_user(%User{} = user, %Organization{} = organization, groups \\ []) do
true = user.organization_id == organization.id

%__MODULE__{user: user, organization: organization}
%__MODULE__{user: user, organization: organization, groups: groups}
end
end
2 changes: 2 additions & 0 deletions lib/cms/accounts/user.ex
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ defmodule CMS.Accounts.User do

has_many :prayer_requests, CMS.Prayers.PrayerRequest

many_to_many :groups, CMS.Accounts.Group, join_through: "groups_users"

field :role, Ecto.Enum, values: @roles

timestamps(type: :utc_datetime)
Expand Down
29 changes: 11 additions & 18 deletions lib/cms/prayers.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ defmodule CMS.Prayers do
"""

import Ecto.Query, warn: false

alias CMS.Repo

alias CMS.Accounts.Scope
Expand All @@ -19,11 +20,16 @@ defmodule CMS.Prayers do

"""
def list_prayer_requests(%Scope{} = scope) do
user_group_ids = Enum.map(scope.groups, & &1.id)

from(p in PrayerRequest,
where: p.organization_id == ^scope.organization.id,
where:
p.organization_id == ^scope.organization.id and
((p.visibility == :private and p.user_id == ^scope.user.id) or
p.visibility == :organization or
(p.visibility == :group and p.group_id in ^user_group_ids)),
order_by: [desc: p.inserted_at],
# Preload `created_by` to display the name of the user who created the prayer request.
preload: [:organization, :user, :created_by]
preload: [:organization, :user]
)
|> Repo.all()
end
Expand All @@ -50,8 +56,8 @@ defmodule CMS.Prayers do
@doc """
Returns an `%Ecto.Changeset{}` for tracking prayer_request changes.
"""
def change_prayer_request(%PrayerRequest{} = prayer_request, attrs \\ %{}) do
PrayerRequest.changeset(prayer_request, attrs)
def change_prayer_request(%Scope{} = scope, %PrayerRequest{} = prayer_request, attrs \\ %{}) do
PrayerRequest.changeset(prayer_request, attrs, scope)
end

@doc """
Expand All @@ -69,19 +75,6 @@ defmodule CMS.Prayers do
def create_prayer_request(%Scope{} = scope, attrs) do
%PrayerRequest{}
|> PrayerRequest.changeset(attrs, scope)
|> ensure_user_in_organization(scope)
|> Repo.insert()
end

defp ensure_user_in_organization(changeset, %Scope{} = scope) do
if user_id = Ecto.Changeset.get_field(changeset, :user_id) do
user = CMS.Accounts.get_user(scope, user_id)

if is_nil(user) || user.organization_id != scope.organization.id do
raise "user #{user_id} is not in organization #{scope.organization.id}"
end
end

changeset
end
end
38 changes: 17 additions & 21 deletions lib/cms/prayers/prayer_request.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,40 +6,36 @@ defmodule CMS.Prayers.PrayerRequest do

schema "prayer_requests" do
field :body, :string
field :visibility, Ecto.Enum, values: [:private, :organization, :group], default: :private
# The user's name is used by the autocomplete component's text input.
field :user_name, :string, virtual: true
belongs_to :user, CMS.Accounts.User
belongs_to :organization, CMS.Accounts.Organization
belongs_to :created_by, CMS.Accounts.User, foreign_key: :created_by_id
belongs_to :group, CMS.Accounts.Group

timestamps()
end

@doc false
def changeset(prayer_request, attrs) do
prayer_request
|> cast(attrs, [:body, :user_id])
|> validate_required([:body])
end

def changeset(prayer_request, attrs, %Scope{} = scope) do
prayer_request
|> changeset(attrs)
|> put_assoc(:created_by, scope.user)
|> cast(attrs, [:body, :visibility, :group_id])
|> validate_required([:body, :visibility])
|> put_assoc(:user, scope.user)
|> put_assoc(:organization, scope.organization)
|> assign_user_if_not_present(scope.user)
|> validate_required([:user_id])
|> validate_group(scope)
end

defp assign_user_if_not_present(changeset, user) do
if get_field(changeset, :user_id) do
changeset
else
changeset
# `put_assoc` is used here because we are associating an existing user,
# not creating or updating one.
|> put_assoc(:user, user)
|> put_change(:user_id, user.id)
defp validate_group(changeset, scope) do
case get_field(changeset, :visibility) do
:group ->
if group_id = get_field(changeset, :group_id) do
true = group_id in Enum.map(scope.groups, & &1.id)
end

validate_required(changeset, :group_id)

_ ->
changeset
end
end
end
5 changes: 4 additions & 1 deletion lib/cms_web/live/prayer_live/index.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@ defmodule CMSWeb.PrayerLive.Index do

@impl true
def mount(_params, _session, socket) do
scope = socket.assigns.current_scope

{:ok,
socket
|> assign(:current_scope, scope)
|> assign(:page_title, "Pedidos de oração")
|> assign(:prayer_requests, Prayers.list_prayer_requests(socket.assigns.current_scope))}
|> assign(:prayer_requests, Prayers.list_prayer_requests(scope))}
end

@impl true
Expand Down
68 changes: 36 additions & 32 deletions lib/cms_web/live/prayer_request_live/form.ex
Original file line number Diff line number Diff line change
@@ -1,34 +1,23 @@
defmodule CMSWeb.PrayerRequestLive.Form do
use CMSWeb, :live_view

alias CMS.Accounts
alias CMS.Prayers
alias CMS.Prayers.PrayerRequest

@impl true
def mount(params, _session, socket) do
{:ok,
socket
|> assign_users()
|> assign(:groups, socket.assigns.current_scope.groups)
|> apply_action(socket.assigns.live_action, params)}
end

defp assign_users(socket) do
scope = socket.assigns.current_scope
users = if scope.user.role == :admin, do: Accounts.list_users(scope), else: []
assign(socket, :users, users)
end

defp apply_action(socket, :new, _params) do
changeset =
%PrayerRequest{}
|> Prayers.change_prayer_request()
# Initialize the form fields for the autocomplete component.
# The `:user_name` is the visible text field, and `:user_id` is the hidden value field.
|> Ecto.Changeset.put_change(:user_name, nil)
|> Ecto.Changeset.put_change(:user_id, nil)
prayer_request = %PrayerRequest{}
changeset = Prayers.change_prayer_request(socket.assigns.current_scope, prayer_request)

socket
|> assign(:prayer_request, prayer_request)
|> assign(:form, to_form(changeset))
|> assign(:page_title, "Novo Pedido de Oração")
end
Expand All @@ -37,27 +26,30 @@ defmodule CMSWeb.PrayerRequestLive.Form do
def render(assigns) do
~H"""
<.main_layout flash={@flash} current_scope={@current_scope} page_title={@page_title}>
<.header>
{@page_title}
<:subtitle>
Escreva o seu pedido de oração abaixo.
</:subtitle>
</.header>
<.form for={@form} id="prayer-request-form" phx-change="validate" phx-submit="save">
<.input field={@form[:body]} type="textarea" label="Pedido de Oração" required />
<.input
field={@form[:visibility]}
type="select"
label="Visibilidade"
options={[
{"Privado", "private"},
{"Organização", "organization"},
{"Grupo", "group"}
]}
required
/>

<.form for={@form} id="prayer-request-form" phx-submit="save">
<div :if={@current_scope.user.role == :admin}>
<.autocomplete
id="user-id-autocomplete"
label="Membro"
text_field={@form[:user_name]}
value_field={@form[:user_id]}
suggestions={@users}
suggestion_label={:name}
<div :if={@form[:visibility].value == :group}>
<.input
field={@form[:group_id]}
type="select"
label="Grupo"
options={Enum.map(@groups, &{&1.name, &1.id})}
required
/>
</div>

<.input field={@form[:body]} type="textarea" label="Pedido de Oração" required />

<footer>
<.button phx-disable-with="A guardar..." variant="primary">Guardar</.button>
<.button navigate={~p"/prayers"}>Cancelar</.button>
Expand All @@ -67,6 +59,18 @@ defmodule CMSWeb.PrayerRequestLive.Form do
"""
end

@impl true
def handle_event("validate", %{"prayer_request" => prayer_request_params}, socket) do
changeset =
Prayers.change_prayer_request(
socket.assigns.current_scope,
socket.assigns.prayer_request,
prayer_request_params
)

{:noreply, assign(socket, form: to_form(changeset, action: :validate))}
end

@impl true
def handle_event("save", %{"prayer_request" => prayer_request_params}, socket) do
case Prayers.create_prayer_request(socket.assigns.current_scope, prayer_request_params) do
Expand Down
4 changes: 2 additions & 2 deletions lib/cms_web/user_auth.ex
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ defmodule CMSWeb.UserAuth do
with {token, conn} <- ensure_user_token(conn),
{user, token_inserted_at} <- Accounts.get_user_by_session_token(token) do
conn
|> assign(:current_scope, Scope.for_user(user, organization))
|> assign(:current_scope, Scope.for_user(user, user.organization, user.groups))
|> maybe_reissue_user_session_token(user, token_inserted_at)
else
nil ->
Expand Down Expand Up @@ -274,7 +274,7 @@ defmodule CMSWeb.UserAuth do
if user_token = session["user_token"] do
case Accounts.get_user_by_session_token(user_token) do
{user, _} ->
Scope.for_user(user, organization)
Scope.for_user(user, user.organization, user.groups)

# If there is a user token but no user, treat the user as a guest
_ ->
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
defmodule CMS.Repo.Migrations.AddVisibilityToPrayerRequests do
use Ecto.Migration

def change do
alter table(:prayer_requests) do
add :visibility, :string, null: false, default: "private"
add :group_id, references(:groups, on_delete: :restrict)
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
defmodule CMS.Repo.Migrations.CreateGroupsUsersJoinTable do
use Ecto.Migration

def change do
create table(:groups_users, primary_key: false) do
add :group_id, references(:groups, on_delete: :delete_all)
add :user_id, references(:users, on_delete: :delete_all)
end

create index(:groups_users, [:group_id, :user_id], unique: true)
end
end
Loading
Loading