diff --git a/GEMINI.md b/GEMINI.md index ff48f8f..11b77c6 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -53,18 +53,26 @@ This is a non-negotiable security requirement. - **Close Issues in Commits:** Use `Closes #` 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. + --- ## @@ -72,10 +80,11 @@ This is a non-negotiable security requirement. - **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`. \ No newline at end of file +- **Testing with Scope:** Use the `log_in_user/2` helper in tests to set up the `current_scope`. diff --git a/lib/cms/accounts.ex b/lib/cms/accounts.ex index 9f88ae0..c12c355 100644 --- a/lib/cms/accounts.ex +++ b/lib/cms/accounts.ex @@ -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 @@ -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 """ @@ -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 diff --git a/lib/cms/accounts/group.ex b/lib/cms/accounts/group.ex index a21d820..43a8a3a 100644 --- a/lib/cms/accounts/group.ex +++ b/lib/cms/accounts/group.ex @@ -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 diff --git a/lib/cms/accounts/scope.ex b/lib/cms/accounts/scope.ex index ef136f4..06778fd 100644 --- a/lib/cms/accounts/scope.ex +++ b/lib/cms/accounts/scope.ex @@ -18,7 +18,7 @@ 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. @@ -26,9 +26,9 @@ defmodule CMS.Accounts.Scope do 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 diff --git a/lib/cms/accounts/user.ex b/lib/cms/accounts/user.ex index 21a10cc..f437212 100644 --- a/lib/cms/accounts/user.ex +++ b/lib/cms/accounts/user.ex @@ -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) diff --git a/lib/cms/prayers.ex b/lib/cms/prayers.ex index bff8aa0..2d61b29 100644 --- a/lib/cms/prayers.ex +++ b/lib/cms/prayers.ex @@ -4,6 +4,7 @@ defmodule CMS.Prayers do """ import Ecto.Query, warn: false + alias CMS.Repo alias CMS.Accounts.Scope @@ -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 @@ -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 """ @@ -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 diff --git a/lib/cms/prayers/prayer_request.ex b/lib/cms/prayers/prayer_request.ex index 34b8008..bc8efb6 100644 --- a/lib/cms/prayers/prayer_request.ex +++ b/lib/cms/prayers/prayer_request.ex @@ -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 diff --git a/lib/cms_web/live/prayer_live/index.ex b/lib/cms_web/live/prayer_live/index.ex index 51be857..b8d60fd 100644 --- a/lib/cms_web/live/prayer_live/index.ex +++ b/lib/cms_web/live/prayer_live/index.ex @@ -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 diff --git a/lib/cms_web/live/prayer_request_live/form.ex b/lib/cms_web/live/prayer_request_live/form.ex index c4a1d99..a36e280 100644 --- a/lib/cms_web/live/prayer_request_live/form.ex +++ b/lib/cms_web/live/prayer_request_live/form.ex @@ -1,7 +1,6 @@ defmodule CMSWeb.PrayerRequestLive.Form do use CMSWeb, :live_view - alias CMS.Accounts alias CMS.Prayers alias CMS.Prayers.PrayerRequest @@ -9,26 +8,16 @@ defmodule CMSWeb.PrayerRequestLive.Form do 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 @@ -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. - - + <.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"> -
- <.autocomplete - id="user-id-autocomplete" - label="Membro" - text_field={@form[:user_name]} - value_field={@form[:user_id]} - suggestions={@users} - suggestion_label={:name} +
+ <.input + field={@form[:group_id]} + type="select" + label="Grupo" + options={Enum.map(@groups, &{&1.name, &1.id})} + required />
- <.input field={@form[:body]} type="textarea" label="Pedido de Oração" required /> -