From bc455fa6f3dbd95826e87117d88d7277f5bf73e0 Mon Sep 17 00:00:00 2001 From: Louie Weng <56288712+lourw@users.noreply.github.com> Date: Sun, 24 Aug 2025 09:22:53 -0700 Subject: [PATCH 1/5] chore(repo): add claude config files (#2) --- CLAUDE.md | 137 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5c1dbc1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,137 @@ +# CLAUDE.md + +## Project Overview + +SplitApp is a Phoenix Framework application built with Elixir. It uses Phoenix LiveView for interactive UI, SQLite for database storage, and includes user authentication functionality. + +## Tech Stack + +- **Backend**: Elixir with Phoenix Framework 1.7.20 +- **Database**: SQLite via Ecto +- **Frontend**: Phoenix LiveView, TailwindCSS, and esbuild +- **Authentication**: Built-in Phoenix authentication with bcrypt +- **Email**: Swoosh for email delivery +- **Server**: Bandit web server + +## Essential Commands + +### Development Setup + +```bash +mix setup # Install dependencies, create database, run migrations, and build assets +mix deps.get # Install Elixir dependencies +mix ecto.setup # Create database, run migrations, and seed data +``` + +### Running the Application + +```bash +mix phx.server # Start Phoenix server (visit http://localhost:4000) +iex -S mix phx.server # Start Phoenix server with interactive Elixir shell +``` + +### Database Management + +```bash +mix ecto.create # Create the database +mix ecto.migrate # Run pending migrations +mix ecto.reset # Drop, create, and migrate database +mix ecto.gen.migration NAME # Generate a new migration +``` + +### Testing + +```bash +mix test # Run all tests +mix test FILE # Run specific test file +mix test FILE:LINE # Run specific test at line number +mix test --stale # Run only stale tests +``` + +### Assets + +```bash +mix assets.build # Build CSS and JavaScript assets +mix assets.deploy # Build and minify assets for production +``` + +## Architecture + +### Directory Structure + +- **lib/split_app/**: Core business logic and contexts + - `accounts/`: User authentication domain (User, UserToken, UserNotifier) + - `application.ex`: OTP application supervision tree + - `repo.ex`: Ecto repository + +- **lib/split_app_web/**: Web layer + - `router.ex`: HTTP routes and pipelines + - `controllers/`: Traditional Phoenix controllers + - `live/`: LiveView modules for user authentication flows + - `components/`: Reusable UI components + - `user_auth.ex`: Authentication plugs and helpers + +- **priv/**: Private application files + - `repo/migrations/`: Database migrations + - `static/`: Static assets served directly + +### Key Architectural Patterns + +1. **Context Pattern**: Business logic is organized in contexts (e.g., Accounts). Each context manages its own domain and exposes a public API to the web layer. + +2. **LiveView Authentication**: User registration, login, password reset, and settings are implemented as LiveView modules with real-time validation. + +3. **Pipeline Architecture**: The router uses pipelines (`:browser`, `:api`, `:require_authenticated_user`) to apply common plugs to groups of routes. + +4. **Session Management**: User sessions are managed through database-backed tokens (UserToken) with configurable expiration times. + +### Authentication Flow + +The application includes complete user authentication with: + +- Registration with email confirmation +- Login/logout with session tokens +- Password reset via email +- User settings management +- Protected routes requiring authentication + +Routes are protected using custom plugs defined in `UserAuth`: + +- `redirect_if_user_is_authenticated`: Redirects logged-in users away from auth pages +- `require_authenticated_user`: Ensures user is logged in +- `fetch_current_user`: Loads current user into assigns + +### Database Schema + +The application uses SQLite with Ecto. Current tables: + +- `users`: User accounts with email and hashed passwords +- `users_tokens`: Multi-purpose tokens for sessions, email confirmation, and password reset + +## Development Workflow + +1. Migrations are automatically run in development via `mix ecto.migrate` +2. LiveView enables real-time UI updates without JavaScript +3. TailwindCSS is configured for utility-first styling +4. Hot code reloading is enabled in development for both Elixir and assets + +## Testing Approach + +Tests use ExUnit with: + +- `ConnCase`: For testing controllers and LiveViews with connection +- `DataCase`: For testing contexts and schemas with database +- Fixtures in `test/support/fixtures/` for test data +- Ecto Sandbox for database isolation between tests + +# Purpose + +You are a software developer that is trying to create a new app that can help people track their expenses and properly split how much they spend. You want to create a web app using elixir and the Pheonix framework. + +# Development + +Use the following principles when making changes to this project: + +1. Prioritize pheonix framework code generators over coding from scratch. Utilize these tools to bootstrap the core domain models and structure of the app. You should not be creating too much custom code except within the concrete implementation of services that the generators have bootstrapped. + +2. When working on front end code, make sure that every new component or UI change conforms do the existing design language. Prioritize reusability instead of customizing every individual piece. From 11890e1da461186bb683ee7162afdae42257784a Mon Sep 17 00:00:00 2001 From: lourw <56288712+lourw@users.noreply.github.com> Date: Sun, 24 Aug 2025 09:31:59 -0700 Subject: [PATCH 2/5] chore(repo): remove unnecessary config files --- .claude/CLAUDE.md | 11 ----------- .claude/settings.json | 0 2 files changed, 11 deletions(-) delete mode 100644 .claude/CLAUDE.md delete mode 100644 .claude/settings.json diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md deleted file mode 100644 index 65ea57d..0000000 --- a/.claude/CLAUDE.md +++ /dev/null @@ -1,11 +0,0 @@ -# Purpose - -You are a software developer that is trying to create a new app that can help people track their expenses and properly split how much they spend. You want to create a web app using elixir and the Pheonix framework. - -# Development - -Use the following principles when making changes to this project: - -1. Prioritize pheonix framework code generators over coding from scratch. Utilize these tools to bootstrap the core domain models and structure of the app. You should not be creating too much custom code except within the concrete implementation of services that the generators have bootstrapped. - -2. When working on front end code, make sure that every new component or UI change conforms do the existing design language. Prioritize reusability instead of customizing every individual piece. diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index e69de29..0000000 From feaf8edb4fae3346c9e45df071d42e6332f8099c Mon Sep 17 00:00:00 2001 From: lourw <56288712+lourw@users.noreply.github.com> Date: Sun, 24 Aug 2025 10:40:00 -0700 Subject: [PATCH 3/5] feat(app): add groups for users and make it the default view --- docs/logs/add-groups-feature.md | 160 ++++++++++++++++++ lib/split_app/accounts/user.ex | 4 + lib/split_app/groups.ex | 155 +++++++++++++++++ lib/split_app/groups/group.ex | 22 +++ lib/split_app/groups/user_group.ex | 19 +++ lib/split_app_web/live/dashboard_live.ex | 93 ++++++++++ .../live/group_live/form_component.ex | 85 ++++++++++ lib/split_app_web/live/group_live/index.ex | 47 +++++ .../live/group_live/index.html.heex | 43 +++++ lib/split_app_web/live/group_live/show.ex | 21 +++ .../live/group_live/show.html.heex | 27 +++ lib/split_app_web/router.ex | 8 + lib/split_app_web/user_auth.ex | 2 +- .../20250824171051_create_groups.exs | 12 ++ .../20250824171107_create_user_groups.exs | 16 ++ priv/repo/seeds.exs | 41 +++++ test/split_app/groups_test.exs | 61 +++++++ test/split_app_web/live/group_live_test.exs | 113 +++++++++++++ test/support/fixtures/groups_fixtures.ex | 21 +++ 19 files changed, 949 insertions(+), 1 deletion(-) create mode 100644 docs/logs/add-groups-feature.md create mode 100644 lib/split_app/groups.ex create mode 100644 lib/split_app/groups/group.ex create mode 100644 lib/split_app/groups/user_group.ex create mode 100644 lib/split_app_web/live/dashboard_live.ex create mode 100644 lib/split_app_web/live/group_live/form_component.ex create mode 100644 lib/split_app_web/live/group_live/index.ex create mode 100644 lib/split_app_web/live/group_live/index.html.heex create mode 100644 lib/split_app_web/live/group_live/show.ex create mode 100644 lib/split_app_web/live/group_live/show.html.heex create mode 100644 priv/repo/migrations/20250824171051_create_groups.exs create mode 100644 priv/repo/migrations/20250824171107_create_user_groups.exs create mode 100644 test/split_app/groups_test.exs create mode 100644 test/split_app_web/live/group_live_test.exs create mode 100644 test/support/fixtures/groups_fixtures.ex diff --git a/docs/logs/add-groups-feature.md b/docs/logs/add-groups-feature.md new file mode 100644 index 0000000..2d65349 --- /dev/null +++ b/docs/logs/add-groups-feature.md @@ -0,0 +1,160 @@ +--- +title: Adding Groups Feature to SplitApp +date: 2025-08-24 +time: 17:28:00 +author: Claude +tags: [groups, feature, authentication, liveview] +--- + +# Adding Groups Feature to SplitApp + +## Overview +This document outlines the implementation of the Groups feature, which allows users to organize themselves into groups for expense tracking. Users can create groups, join groups, and view all groups they belong to on a dashboard landing page. + +## Database Changes + +### 1. Groups Table +Created a new `groups` table with the following fields: +- `id` - Primary key +- `name` - String field for group name +- `description` - Text field for group description +- `inserted_at` - Timestamp +- `updated_at` - Timestamp + +**Migration file:** `priv/repo/migrations/20250824171051_create_groups.exs` + +### 2. User-Groups Join Table +Created a `user_groups` join table to establish many-to-many relationship: +- `user_id` - Foreign key to users table +- `group_id` - Foreign key to groups table +- `inserted_at` - Timestamp +- `updated_at` - Timestamp +- Composite unique index on `[user_id, group_id]` +- Individual indexes on `user_id` and `group_id` + +**Migration file:** `priv/repo/migrations/20250824171107_create_user_groups.exs` + +## Code Changes + +### 1. Context and Schemas + +#### Groups Context (`lib/split_app/groups.ex`) +Generated using Phoenix context generator with additional functions: +- `list_groups/0` - List all groups +- `get_group!/1` - Get a single group +- `create_group/1` - Create a new group +- `update_group/2` - Update a group +- `delete_group/1` - Delete a group +- `list_user_groups/1` - List all groups for a specific user +- `add_user_to_group/2` - Add a user to a group +- `remove_user_from_group/2` - Remove a user from a group + +#### Group Schema (`lib/split_app/groups/group.ex`) +- Basic fields: `name`, `description` +- Association: `many_to_many :users` through `UserGroup` + +#### UserGroup Schema (`lib/split_app/groups/user_group.ex`) +Join table schema with: +- Belongs to both User and Group +- Timestamps for tracking when users joined groups + +#### User Schema Updates (`lib/split_app/accounts/user.ex`) +Added association: `many_to_many :groups` through `UserGroup` + +### 2. Web Layer + +#### Dashboard LiveView (`lib/split_app_web/live/dashboard_live.ex`) +Custom landing page that: +- Displays welcome message with user email +- Shows all groups the user belongs to in card format +- Provides empty state when user has no groups +- Links to create new groups and browse all groups + +#### Group LiveViews (Generated) +- `lib/split_app_web/live/group_live/index.ex` - List all groups +- `lib/split_app_web/live/group_live/show.ex` - Show single group +- `lib/split_app_web/live/group_live/form_component.ex` - Create/edit groups + - Modified to automatically add creator to new groups + +### 3. Router Updates (`lib/split_app_web/router.ex`) + +Added authenticated routes: +```elixir +live "/dashboard", DashboardLive, :index +live "/groups", GroupLive.Index, :index +live "/groups/new", GroupLive.Index, :new +live "/groups/:id/edit", GroupLive.Index, :edit +live "/groups/:id", GroupLive.Show, :show +live "/groups/:id/show/edit", GroupLive.Show, :edit +``` + +### 4. Authentication Updates (`lib/split_app_web/user_auth.ex`) + +Changed `signed_in_path/1` to redirect authenticated users to `/dashboard` instead of home page. + +## UI/UX Features + +### Dashboard Page +- **Welcome Header**: Personalized greeting with user email +- **Group Cards**: Each group displayed as a card with: + - Group icon + - Group name + - Group description (or placeholder if none) + - Hover effects for better interactivity + - Click to navigate to group details +- **Empty State**: When user has no groups: + - Informative icon and message + - Call-to-action button to create first group +- **Actions**: + - "Create New Group" button in header + - "Browse All Groups" link at bottom + +### Group Management +- Create new groups with name and description +- Creator automatically added as member +- List view of all groups with edit/delete actions +- Individual group pages (ready for future expense tracking features) + +## Test Data (`priv/repo/seeds.exs`) + +Created sample data for testing: + +**Users:** +- user1@example.com (password: password12345) + - Member of: Weekend Trip, Monthly Groceries +- user2@example.com (password: password12345) + - Member of: Weekend Trip, Birthday Party + +**Groups:** +- Weekend Trip - "Expenses for our weekend getaway" +- Monthly Groceries - "Shared grocery expenses for the apartment" +- Birthday Party - "Planning and expenses for Sarah's birthday" + +## Migration Commands + +```bash +# Generate the Groups context +mix phx.gen.context Groups Group groups name:string description:text + +# Generate LiveView pages (without context/schema) +mix phx.gen.live Groups Group groups name:string description:text --no-context --no-schema + +# Generate user_groups migration +mix ecto.gen.migration create_user_groups + +# Run migrations +mix ecto.migrate + +# Reset database and run seeds +mix ecto.reset +``` + +## Future Enhancements + +This foundation sets up the groups feature for future expense tracking functionality: +- Add expenses to groups +- Track who paid for what +- Calculate splits and balances +- Show group activity/history +- Invite users to groups via email +- Group member management and permissions \ No newline at end of file diff --git a/lib/split_app/accounts/user.ex b/lib/split_app/accounts/user.ex index 91c345e..cf86dd0 100644 --- a/lib/split_app/accounts/user.ex +++ b/lib/split_app/accounts/user.ex @@ -9,6 +9,10 @@ defmodule SplitApp.Accounts.User do field :current_password, :string, virtual: true, redact: true field :confirmed_at, :utc_datetime + many_to_many :groups, SplitApp.Groups.Group, + join_through: SplitApp.Groups.UserGroup, + on_replace: :delete + timestamps(type: :utc_datetime) end diff --git a/lib/split_app/groups.ex b/lib/split_app/groups.ex new file mode 100644 index 0000000..639c75f --- /dev/null +++ b/lib/split_app/groups.ex @@ -0,0 +1,155 @@ +defmodule SplitApp.Groups do + @moduledoc """ + The Groups context. + """ + + import Ecto.Query, warn: false + alias SplitApp.Repo + + alias SplitApp.Groups.Group + alias SplitApp.Accounts.User + + @doc """ + Returns the list of groups. + + ## Examples + + iex> list_groups() + [%Group{}, ...] + + """ + def list_groups do + Repo.all(Group) + end + + @doc """ + Gets a single group. + + Raises `Ecto.NoResultsError` if the Group does not exist. + + ## Examples + + iex> get_group!(123) + %Group{} + + iex> get_group!(456) + ** (Ecto.NoResultsError) + + """ + def get_group!(id), do: Repo.get!(Group, id) + + @doc """ + Creates a group. + + ## Examples + + iex> create_group(%{field: value}) + {:ok, %Group{}} + + iex> create_group(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_group(attrs \\ %{}) do + %Group{} + |> Group.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a group. + + ## Examples + + iex> update_group(group, %{field: new_value}) + {:ok, %Group{}} + + iex> update_group(group, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_group(%Group{} = group, attrs) do + group + |> Group.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a group. + + ## Examples + + iex> delete_group(group) + {:ok, %Group{}} + + iex> delete_group(group) + {:error, %Ecto.Changeset{}} + + """ + def delete_group(%Group{} = group) do + Repo.delete(group) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking group changes. + + ## Examples + + iex> change_group(group) + %Ecto.Changeset{data: %Group{}} + + """ + def change_group(%Group{} = group, attrs \\ %{}) do + Group.changeset(group, attrs) + end + + @doc """ + Returns the list of groups for a given user. + + ## Examples + + iex> list_user_groups(user) + [%Group{}, ...] + + """ + def list_user_groups(%User{} = user) do + user + |> Repo.preload(:groups) + |> Map.get(:groups) + end + + @doc """ + Adds a user to a group. + + ## Examples + + iex> add_user_to_group(user, group) + {:ok, %Group{}} + + """ + def add_user_to_group(%User{} = user, %Group{} = group) do + group = Repo.preload(group, :users) + + group + |> Ecto.Changeset.change() + |> Ecto.Changeset.put_assoc(:users, [user | group.users]) + |> Repo.update() + end + + @doc """ + Removes a user from a group. + + ## Examples + + iex> remove_user_from_group(user, group) + {:ok, %Group{}} + + """ + def remove_user_from_group(%User{} = user, %Group{} = group) do + group + |> Repo.preload(:users) + |> Ecto.Changeset.change() + |> Ecto.Changeset.put_assoc(:users, Enum.reject(group.users, &(&1.id == user.id))) + |> Repo.update() + end +end diff --git a/lib/split_app/groups/group.ex b/lib/split_app/groups/group.ex new file mode 100644 index 0000000..46c1b04 --- /dev/null +++ b/lib/split_app/groups/group.ex @@ -0,0 +1,22 @@ +defmodule SplitApp.Groups.Group do + use Ecto.Schema + import Ecto.Changeset + + schema "groups" do + field :name, :string + field :description, :string + + many_to_many :users, SplitApp.Accounts.User, + join_through: SplitApp.Groups.UserGroup, + on_replace: :delete + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(group, attrs) do + group + |> cast(attrs, [:name, :description]) + |> validate_required([:name, :description]) + end +end diff --git a/lib/split_app/groups/user_group.ex b/lib/split_app/groups/user_group.ex new file mode 100644 index 0000000..d98232c --- /dev/null +++ b/lib/split_app/groups/user_group.ex @@ -0,0 +1,19 @@ +defmodule SplitApp.Groups.UserGroup do + use Ecto.Schema + import Ecto.Changeset + + @primary_key false + schema "user_groups" do + belongs_to :user, SplitApp.Accounts.User, primary_key: true + belongs_to :group, SplitApp.Groups.Group, primary_key: true + + timestamps(type: :utc_datetime) + end + + def changeset(user_group, attrs) do + user_group + |> cast(attrs, [:user_id, :group_id]) + |> validate_required([:user_id, :group_id]) + |> unique_constraint([:user_id, :group_id]) + end +end \ No newline at end of file diff --git a/lib/split_app_web/live/dashboard_live.ex b/lib/split_app_web/live/dashboard_live.ex new file mode 100644 index 0000000..db5e30e --- /dev/null +++ b/lib/split_app_web/live/dashboard_live.ex @@ -0,0 +1,93 @@ +defmodule SplitAppWeb.DashboardLive do + use SplitAppWeb, :live_view + alias SplitApp.Groups + + @impl true + def mount(_params, _session, socket) do + user = socket.assigns.current_user + groups = Groups.list_user_groups(user) + + {:ok, + socket + |> assign(:groups, groups) + |> assign(:page_title, "My Groups")} + end + + @impl true + def render(assigns) do + ~H""" +
+ <.header> + Welcome, <%= @current_user.email %>! + <:subtitle>Here are all the groups you're a part of + <:actions> + <.link navigate={~p"/groups/new"}> + <.button>Create New Group + + + + +
+ <%= if @groups == [] do %> +
+
+ + + +
+

No groups yet

+

Get started by creating a new group.

+
+ <.link navigate={~p"/groups/new"}> + <.button> + + + + Create New Group + + +
+
+ <% else %> +
+ <%= for group <- @groups do %> + <.link navigate={~p"/groups/#{group}"} class="group"> +
+
+ + + + + +
+
+

+ <%= group.name %> +

+

+ <%= group.description || "No description provided" %> +

+
+ +
+ + <% end %> +
+ <% end %> +
+ +
+
+ <.link navigate={~p"/groups"} class="text-sm font-medium text-indigo-600 hover:text-indigo-500"> + Browse All Groups + +
+
+
+ """ + end +end \ No newline at end of file diff --git a/lib/split_app_web/live/group_live/form_component.ex b/lib/split_app_web/live/group_live/form_component.ex new file mode 100644 index 0000000..536fbf2 --- /dev/null +++ b/lib/split_app_web/live/group_live/form_component.ex @@ -0,0 +1,85 @@ +defmodule SplitAppWeb.GroupLive.FormComponent do + use SplitAppWeb, :live_component + + alias SplitApp.Groups + + @impl true + def render(assigns) do + ~H""" +
+ <.header> + {@title} + <:subtitle>Use this form to manage group records in your database. + + + <.simple_form + for={@form} + id="group-form" + phx-target={@myself} + phx-change="validate" + phx-submit="save" + > + <.input field={@form[:name]} type="text" label="Name" /> + <.input field={@form[:description]} type="text" label="Description" /> + <:actions> + <.button phx-disable-with="Saving...">Save Group + + +
+ """ + end + + @impl true + def update(%{group: group} = assigns, socket) do + {:ok, + socket + |> assign(assigns) + |> assign_new(:form, fn -> + to_form(Groups.change_group(group)) + end)} + end + + @impl true + def handle_event("validate", %{"group" => group_params}, socket) do + changeset = Groups.change_group(socket.assigns.group, group_params) + {:noreply, assign(socket, form: to_form(changeset, action: :validate))} + end + + def handle_event("save", %{"group" => group_params}, socket) do + save_group(socket, socket.assigns.action, group_params) + end + + defp save_group(socket, :edit, group_params) do + case Groups.update_group(socket.assigns.group, group_params) do + {:ok, group} -> + notify_parent({:saved, group}) + + {:noreply, + socket + |> put_flash(:info, "Group updated successfully") + |> push_patch(to: socket.assigns.patch)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end + end + + defp save_group(socket, :new, group_params) do + case Groups.create_group(group_params) do + {:ok, group} -> + # Add the current user to the newly created group + Groups.add_user_to_group(socket.assigns.current_user, group) + notify_parent({:saved, group}) + + {:noreply, + socket + |> put_flash(:info, "Group created successfully") + |> push_patch(to: socket.assigns.patch)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end + end + + defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) +end diff --git a/lib/split_app_web/live/group_live/index.ex b/lib/split_app_web/live/group_live/index.ex new file mode 100644 index 0000000..c0746fc --- /dev/null +++ b/lib/split_app_web/live/group_live/index.ex @@ -0,0 +1,47 @@ +defmodule SplitAppWeb.GroupLive.Index do + use SplitAppWeb, :live_view + + alias SplitApp.Groups + alias SplitApp.Groups.Group + + @impl true + def mount(_params, _session, socket) do + {:ok, stream(socket, :groups, Groups.list_groups())} + end + + @impl true + def handle_params(params, _url, socket) do + {:noreply, apply_action(socket, socket.assigns.live_action, params)} + end + + defp apply_action(socket, :edit, %{"id" => id}) do + socket + |> assign(:page_title, "Edit Group") + |> assign(:group, Groups.get_group!(id)) + end + + defp apply_action(socket, :new, _params) do + socket + |> assign(:page_title, "New Group") + |> assign(:group, %Group{}) + end + + defp apply_action(socket, :index, _params) do + socket + |> assign(:page_title, "Listing Groups") + |> assign(:group, nil) + end + + @impl true + def handle_info({SplitAppWeb.GroupLive.FormComponent, {:saved, group}}, socket) do + {:noreply, stream_insert(socket, :groups, group)} + end + + @impl true + def handle_event("delete", %{"id" => id}, socket) do + group = Groups.get_group!(id) + {:ok, _} = Groups.delete_group(group) + + {:noreply, stream_delete(socket, :groups, group)} + end +end diff --git a/lib/split_app_web/live/group_live/index.html.heex b/lib/split_app_web/live/group_live/index.html.heex new file mode 100644 index 0000000..5b7324c --- /dev/null +++ b/lib/split_app_web/live/group_live/index.html.heex @@ -0,0 +1,43 @@ +<.header> + Listing Groups + <:actions> + <.link patch={~p"/groups/new"}> + <.button>New Group + + + + +<.table + id="groups" + rows={@streams.groups} + row_click={fn {_id, group} -> JS.navigate(~p"/groups/#{group}") end} +> + <:col :let={{_id, group}} label="Name">{group.name} + <:col :let={{_id, group}} label="Description">{group.description} + <:action :let={{_id, group}}> +
+ <.link navigate={~p"/groups/#{group}"}>Show +
+ <.link patch={~p"/groups/#{group}/edit"}>Edit + + <:action :let={{id, group}}> + <.link + phx-click={JS.push("delete", value: %{id: group.id}) |> hide("##{id}")} + data-confirm="Are you sure?" + > + Delete + + + + +<.modal :if={@live_action in [:new, :edit]} id="group-modal" show on_cancel={JS.patch(~p"/groups")}> + <.live_component + module={SplitAppWeb.GroupLive.FormComponent} + id={@group.id || :new} + title={@page_title} + action={@live_action} + group={@group} + patch={~p"/groups"} + current_user={@current_user} + /> + diff --git a/lib/split_app_web/live/group_live/show.ex b/lib/split_app_web/live/group_live/show.ex new file mode 100644 index 0000000..fb1155a --- /dev/null +++ b/lib/split_app_web/live/group_live/show.ex @@ -0,0 +1,21 @@ +defmodule SplitAppWeb.GroupLive.Show do + use SplitAppWeb, :live_view + + alias SplitApp.Groups + + @impl true + def mount(_params, _session, socket) do + {:ok, socket} + end + + @impl true + def handle_params(%{"id" => id}, _, socket) do + {:noreply, + socket + |> assign(:page_title, page_title(socket.assigns.live_action)) + |> assign(:group, Groups.get_group!(id))} + end + + defp page_title(:show), do: "Show Group" + defp page_title(:edit), do: "Edit Group" +end diff --git a/lib/split_app_web/live/group_live/show.html.heex b/lib/split_app_web/live/group_live/show.html.heex new file mode 100644 index 0000000..6df0525 --- /dev/null +++ b/lib/split_app_web/live/group_live/show.html.heex @@ -0,0 +1,27 @@ +<.header> + Group {@group.id} + <:subtitle>This is a group record from your database. + <:actions> + <.link patch={~p"/groups/#{@group}/show/edit"} phx-click={JS.push_focus()}> + <.button>Edit group + + + + +<.list> + <:item title="Name">{@group.name} + <:item title="Description">{@group.description} + + +<.back navigate={~p"/groups"}>Back to groups + +<.modal :if={@live_action == :edit} id="group-modal" show on_cancel={JS.patch(~p"/groups/#{@group}")}> + <.live_component + module={SplitAppWeb.GroupLive.FormComponent} + id={@group.id} + title={@page_title} + action={@live_action} + group={@group} + patch={~p"/groups/#{@group}"} + /> + diff --git a/lib/split_app_web/router.ex b/lib/split_app_web/router.ex index 9ca15b7..2e4c547 100644 --- a/lib/split_app_web/router.ex +++ b/lib/split_app_web/router.ex @@ -67,6 +67,14 @@ defmodule SplitAppWeb.Router do live_session :require_authenticated_user, on_mount: [{SplitAppWeb.UserAuth, :ensure_authenticated}] do + live "/dashboard", DashboardLive, :index + + live "/groups", GroupLive.Index, :index + live "/groups/new", GroupLive.Index, :new + live "/groups/:id/edit", GroupLive.Index, :edit + live "/groups/:id", GroupLive.Show, :show + live "/groups/:id/show/edit", GroupLive.Show, :edit + live "/users/settings", UserSettingsLive, :edit live "/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email end diff --git a/lib/split_app_web/user_auth.ex b/lib/split_app_web/user_auth.ex index 74f723c..4b96b13 100644 --- a/lib/split_app_web/user_auth.ex +++ b/lib/split_app_web/user_auth.ex @@ -225,5 +225,5 @@ defmodule SplitAppWeb.UserAuth do defp maybe_store_return_to(conn), do: conn - defp signed_in_path(_conn), do: ~p"/" + defp signed_in_path(_conn), do: ~p"/dashboard" end diff --git a/priv/repo/migrations/20250824171051_create_groups.exs b/priv/repo/migrations/20250824171051_create_groups.exs new file mode 100644 index 0000000..c196aca --- /dev/null +++ b/priv/repo/migrations/20250824171051_create_groups.exs @@ -0,0 +1,12 @@ +defmodule SplitApp.Repo.Migrations.CreateGroups do + use Ecto.Migration + + def change do + create table(:groups) do + add :name, :string + add :description, :text + + timestamps(type: :utc_datetime) + end + end +end diff --git a/priv/repo/migrations/20250824171107_create_user_groups.exs b/priv/repo/migrations/20250824171107_create_user_groups.exs new file mode 100644 index 0000000..422197f --- /dev/null +++ b/priv/repo/migrations/20250824171107_create_user_groups.exs @@ -0,0 +1,16 @@ +defmodule SplitApp.Repo.Migrations.CreateUserGroups do + use Ecto.Migration + + def change do + create table(:user_groups, primary_key: false) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :group_id, references(:groups, on_delete: :delete_all), null: false + + timestamps(type: :utc_datetime) + end + + create unique_index(:user_groups, [:user_id, :group_id]) + create index(:user_groups, [:user_id]) + create index(:user_groups, [:group_id]) + end +end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index c202d8c..5d39c7f 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -9,3 +9,44 @@ # # We recommend using the bang functions (`insert!`, `update!` # and so on) as they will fail if something goes wrong. + +alias SplitApp.Accounts +alias SplitApp.Groups + +# Create test users +{:ok, user1} = Accounts.register_user(%{ + email: "user1@example.com", + password: "password12345" +}) + +{:ok, user2} = Accounts.register_user(%{ + email: "user2@example.com", + password: "password12345" +}) + +# Create test groups +{:ok, group1} = Groups.create_group(%{ + name: "Weekend Trip", + description: "Expenses for our weekend getaway" +}) + +{:ok, group2} = Groups.create_group(%{ + name: "Monthly Groceries", + description: "Shared grocery expenses for the apartment" +}) + +{:ok, group3} = Groups.create_group(%{ + name: "Birthday Party", + description: "Planning and expenses for Sarah's birthday" +}) + +# Add users to groups +Groups.add_user_to_group(user1, group1) +Groups.add_user_to_group(user1, group2) +Groups.add_user_to_group(user2, group1) +Groups.add_user_to_group(user2, group3) + +IO.puts("Seed data created successfully!") +IO.puts("Test users:") +IO.puts(" - user1@example.com (password: password12345)") +IO.puts(" - user2@example.com (password: password12345)") diff --git a/test/split_app/groups_test.exs b/test/split_app/groups_test.exs new file mode 100644 index 0000000..a5244d1 --- /dev/null +++ b/test/split_app/groups_test.exs @@ -0,0 +1,61 @@ +defmodule SplitApp.GroupsTest do + use SplitApp.DataCase + + alias SplitApp.Groups + + describe "groups" do + alias SplitApp.Groups.Group + + import SplitApp.GroupsFixtures + + @invalid_attrs %{name: nil, description: nil} + + test "list_groups/0 returns all groups" do + group = group_fixture() + assert Groups.list_groups() == [group] + end + + test "get_group!/1 returns the group with given id" do + group = group_fixture() + assert Groups.get_group!(group.id) == group + end + + test "create_group/1 with valid data creates a group" do + valid_attrs = %{name: "some name", description: "some description"} + + assert {:ok, %Group{} = group} = Groups.create_group(valid_attrs) + assert group.name == "some name" + assert group.description == "some description" + end + + test "create_group/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Groups.create_group(@invalid_attrs) + end + + test "update_group/2 with valid data updates the group" do + group = group_fixture() + update_attrs = %{name: "some updated name", description: "some updated description"} + + assert {:ok, %Group{} = group} = Groups.update_group(group, update_attrs) + assert group.name == "some updated name" + assert group.description == "some updated description" + end + + test "update_group/2 with invalid data returns error changeset" do + group = group_fixture() + assert {:error, %Ecto.Changeset{}} = Groups.update_group(group, @invalid_attrs) + assert group == Groups.get_group!(group.id) + end + + test "delete_group/1 deletes the group" do + group = group_fixture() + assert {:ok, %Group{}} = Groups.delete_group(group) + assert_raise Ecto.NoResultsError, fn -> Groups.get_group!(group.id) end + end + + test "change_group/1 returns a group changeset" do + group = group_fixture() + assert %Ecto.Changeset{} = Groups.change_group(group) + end + end +end diff --git a/test/split_app_web/live/group_live_test.exs b/test/split_app_web/live/group_live_test.exs new file mode 100644 index 0000000..a9453be --- /dev/null +++ b/test/split_app_web/live/group_live_test.exs @@ -0,0 +1,113 @@ +defmodule SplitAppWeb.GroupLiveTest do + use SplitAppWeb.ConnCase + + import Phoenix.LiveViewTest + import SplitApp.GroupsFixtures + + @create_attrs %{name: "some name", description: "some description"} + @update_attrs %{name: "some updated name", description: "some updated description"} + @invalid_attrs %{name: nil, description: nil} + + defp create_group(_) do + group = group_fixture() + %{group: group} + end + + describe "Index" do + setup [:create_group] + + test "lists all groups", %{conn: conn, group: group} do + {:ok, _index_live, html} = live(conn, ~p"/groups") + + assert html =~ "Listing Groups" + assert html =~ group.name + end + + test "saves new group", %{conn: conn} do + {:ok, index_live, _html} = live(conn, ~p"/groups") + + assert index_live |> element("a", "New Group") |> render_click() =~ + "New Group" + + assert_patch(index_live, ~p"/groups/new") + + assert index_live + |> form("#group-form", group: @invalid_attrs) + |> render_change() =~ "can't be blank" + + assert index_live + |> form("#group-form", group: @create_attrs) + |> render_submit() + + assert_patch(index_live, ~p"/groups") + + html = render(index_live) + assert html =~ "Group created successfully" + assert html =~ "some name" + end + + test "updates group in listing", %{conn: conn, group: group} do + {:ok, index_live, _html} = live(conn, ~p"/groups") + + assert index_live |> element("#groups-#{group.id} a", "Edit") |> render_click() =~ + "Edit Group" + + assert_patch(index_live, ~p"/groups/#{group}/edit") + + assert index_live + |> form("#group-form", group: @invalid_attrs) + |> render_change() =~ "can't be blank" + + assert index_live + |> form("#group-form", group: @update_attrs) + |> render_submit() + + assert_patch(index_live, ~p"/groups") + + html = render(index_live) + assert html =~ "Group updated successfully" + assert html =~ "some updated name" + end + + test "deletes group in listing", %{conn: conn, group: group} do + {:ok, index_live, _html} = live(conn, ~p"/groups") + + assert index_live |> element("#groups-#{group.id} a", "Delete") |> render_click() + refute has_element?(index_live, "#groups-#{group.id}") + end + end + + describe "Show" do + setup [:create_group] + + test "displays group", %{conn: conn, group: group} do + {:ok, _show_live, html} = live(conn, ~p"/groups/#{group}") + + assert html =~ "Show Group" + assert html =~ group.name + end + + test "updates group within modal", %{conn: conn, group: group} do + {:ok, show_live, _html} = live(conn, ~p"/groups/#{group}") + + assert show_live |> element("a", "Edit") |> render_click() =~ + "Edit Group" + + assert_patch(show_live, ~p"/groups/#{group}/show/edit") + + assert show_live + |> form("#group-form", group: @invalid_attrs) + |> render_change() =~ "can't be blank" + + assert show_live + |> form("#group-form", group: @update_attrs) + |> render_submit() + + assert_patch(show_live, ~p"/groups/#{group}") + + html = render(show_live) + assert html =~ "Group updated successfully" + assert html =~ "some updated name" + end + end +end diff --git a/test/support/fixtures/groups_fixtures.ex b/test/support/fixtures/groups_fixtures.ex new file mode 100644 index 0000000..c265d05 --- /dev/null +++ b/test/support/fixtures/groups_fixtures.ex @@ -0,0 +1,21 @@ +defmodule SplitApp.GroupsFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `SplitApp.Groups` context. + """ + + @doc """ + Generate a group. + """ + def group_fixture(attrs \\ %{}) do + {:ok, group} = + attrs + |> Enum.into(%{ + description: "some description", + name: "some name" + }) + |> SplitApp.Groups.create_group() + + group + end +end From cedb6a00105c4a77ec08c3b005adb7e7e812b7c2 Mon Sep 17 00:00:00 2001 From: lourw <56288712+lourw@users.noreply.github.com> Date: Sun, 24 Aug 2025 11:17:47 -0700 Subject: [PATCH 4/5] chore(groups): remove unnecessary ui elements and redirects --- .../components/layouts/app.html.heex | 5 +---- .../controllers/page_html/home.html.heex | 3 --- lib/split_app_web/live/dashboard_live.ex | 21 ++++--------------- .../live/group_live/show.html.heex | 16 +++++++------- 4 files changed, 13 insertions(+), 32 deletions(-) diff --git a/lib/split_app_web/components/layouts/app.html.heex b/lib/split_app_web/components/layouts/app.html.heex index 545673f..9650302 100644 --- a/lib/split_app_web/components/layouts/app.html.heex +++ b/lib/split_app_web/components/layouts/app.html.heex @@ -1,7 +1,7 @@
diff --git a/lib/split_app_web/live/dashboard_live.ex b/lib/split_app_web/live/dashboard_live.ex index db5e30e..b22a477 100644 --- a/lib/split_app_web/live/dashboard_live.ex +++ b/lib/split_app_web/live/dashboard_live.ex @@ -6,8 +6,8 @@ defmodule SplitAppWeb.DashboardLive do def mount(_params, _session, socket) do user = socket.assigns.current_user groups = Groups.list_user_groups(user) - - {:ok, + + {:ok, socket |> assign(:groups, groups) |> assign(:page_title, "My Groups")} @@ -16,15 +16,10 @@ defmodule SplitAppWeb.DashboardLive do @impl true def render(assigns) do ~H""" -
+
<.header> Welcome, <%= @current_user.email %>! <:subtitle>Here are all the groups you're a part of - <:actions> - <.link navigate={~p"/groups/new"}> - <.button>Create New Group - -
@@ -79,15 +74,7 @@ defmodule SplitAppWeb.DashboardLive do
<% end %>
- -
-
- <.link navigate={~p"/groups"} class="text-sm font-medium text-indigo-600 hover:text-indigo-500"> - Browse All Groups - -
-
""" end -end \ No newline at end of file +end diff --git a/lib/split_app_web/live/group_live/show.html.heex b/lib/split_app_web/live/group_live/show.html.heex index 6df0525..c2344d2 100644 --- a/lib/split_app_web/live/group_live/show.html.heex +++ b/lib/split_app_web/live/group_live/show.html.heex @@ -1,5 +1,5 @@ <.header> - Group {@group.id} + {@group.name} <:subtitle>This is a group record from your database. <:actions> <.link patch={~p"/groups/#{@group}/show/edit"} phx-click={JS.push_focus()}> @@ -8,14 +8,14 @@ -<.list> - <:item title="Name">{@group.name} - <:item title="Description">{@group.description} - +<.back navigate={~p"/dashboard"}>Back to groups -<.back navigate={~p"/groups"}>Back to groups - -<.modal :if={@live_action == :edit} id="group-modal" show on_cancel={JS.patch(~p"/groups/#{@group}")}> +<.modal + :if={@live_action == :edit} + id="group-modal" + show + on_cancel={JS.patch(~p"/groups/#{@group}")} +> <.live_component module={SplitAppWeb.GroupLive.FormComponent} id={@group.id} From 5de4ef1ce29dd5e101cceb3aaea4bf4221c692c1 Mon Sep 17 00:00:00 2001 From: lourw <56288712+lourw@users.noreply.github.com> Date: Sun, 24 Aug 2025 11:42:34 -0700 Subject: [PATCH 5/5] chore(groups): update tests --- .claude/commands/pre-push.md | 5 ++ README.md | 70 +++++++++++++-- lib/split_app/groups.ex | 88 ++++++++++++++++++- lib/split_app/groups/user_group.ex | 2 +- .../components/layouts/app.html.heex | 1 + lib/split_app_web/live/dashboard_live.ex | 64 +++++++++++--- .../live/group_live/form_component.ex | 20 +++-- lib/split_app_web/live/group_live/index.ex | 27 ++++-- .../live/group_live/index.html.heex | 7 +- lib/split_app_web/live/group_live/show.ex | 29 +++++- .../live/group_live/show.html.heex | 1 + lib/split_app_web/router.ex | 4 +- .../20250824171107_create_user_groups.exs | 2 +- priv/repo/seeds.exs | 51 ++++++----- .../user_session_controller_test.exs | 8 +- test/split_app_web/live/group_live_test.exs | 8 +- .../live/user_forgot_password_live_test.exs | 4 +- .../live/user_login_live_test.exs | 4 +- .../live/user_registration_live_test.exs | 6 +- test/split_app_web/user_auth_test.exs | 4 +- test/support/fixtures/groups_fixtures.ex | 9 ++ 21 files changed, 330 insertions(+), 84 deletions(-) create mode 100644 .claude/commands/pre-push.md diff --git a/.claude/commands/pre-push.md b/.claude/commands/pre-push.md new file mode 100644 index 0000000..d82f741 --- /dev/null +++ b/.claude/commands/pre-push.md @@ -0,0 +1,5 @@ +Before pushing to remote version control, ensure that the following is completed: + +1. run linters and formatters. +2. run all tests using `mix test`. +3. check any files that were created in the recent commits that part within the `docs/logs` folder and update any relevant content that is out of date with the code changes. diff --git a/README.md b/README.md index ca0db70..7162ed1 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,63 @@ # SplitApp -To start your Phoenix server: +SplitApp is a Phoenix Framework application for tracking expenses and splitting costs between users. - * Run `mix setup` to install and setup dependencies - * Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server` +## Getting Started + +### Development Setup + +```bash +mix setup # Install dependencies, create database, run migrations, and build assets +mix deps.get # Install Elixir dependencies +mix ecto.setup # Create database, run migrations, and seed data +``` + +### Running the Application + +```bash +mix phx.server # Start Phoenix server (visit http://localhost:4000) +iex -S mix phx.server # Start Phoenix server with interactive Elixir shell +``` + +### Database Management + +```bash +mix ecto.create # Create the database +mix ecto.migrate # Run pending migrations +mix ecto.reset # Drop, create, and migrate database +mix ecto.gen.migration NAME # Generate a new migration +``` + +### Testing + +```bash +mix test # Run all tests +mix test FILE # Run specific test file +mix test FILE:LINE # Run specific test at line number +mix test --stale # Run only stale tests +``` + +### Assets + +```bash +mix assets.build # Build CSS and JavaScript assets +mix assets.deploy # Build and minify assets for production +``` + +### Code Generation + +```bash +mix phx.gen.live Context Schema table field:type # Generate LiveView CRUD +mix phx.gen.context Context Schema table field:type # Generate context +mix phx.gen.schema Context.Schema table field:type # Generate schema +``` + +### Linting and Formatting + +```bash +mix format # Format Elixir code +mix format --check-formatted # Check if code is formatted +``` Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. @@ -11,8 +65,8 @@ Ready to run in production? Please [check our deployment guides](https://hexdocs ## Learn more - * Official website: https://www.phoenixframework.org/ - * Guides: https://hexdocs.pm/phoenix/overview.html - * Docs: https://hexdocs.pm/phoenix - * Forum: https://elixirforum.com/c/phoenix-forum - * Source: https://github.com/phoenixframework/phoenix +- Official website: https://www.phoenixframework.org/ +- Guides: https://hexdocs.pm/phoenix/overview.html +- Docs: https://hexdocs.pm/phoenix +- Forum: https://elixirforum.com/c/phoenix-forum +- Source: https://github.com/phoenixframework/phoenix diff --git a/lib/split_app/groups.ex b/lib/split_app/groups.ex index 639c75f..e3edf5c 100644 --- a/lib/split_app/groups.ex +++ b/lib/split_app/groups.ex @@ -10,7 +10,8 @@ defmodule SplitApp.Groups do alias SplitApp.Accounts.User @doc """ - Returns the list of groups. + Returns the list of all groups. + WARNING: This returns ALL groups. Use list_user_groups/1 for user-specific groups. ## Examples @@ -24,6 +25,7 @@ defmodule SplitApp.Groups do @doc """ Gets a single group. + WARNING: This gets ANY group. Use get_user_group!/2 for user-authorized access. Raises `Ecto.NoResultsError` if the Group does not exist. @@ -38,6 +40,30 @@ defmodule SplitApp.Groups do """ def get_group!(id), do: Repo.get!(Group, id) + @doc """ + Gets a single group only if the user is a member. + + Raises `Ecto.NoResultsError` if the Group does not exist or user is not a member. + + ## Examples + + iex> get_user_group!(user, 123) + %Group{} + + iex> get_user_group!(user, 456) + ** (Ecto.NoResultsError) + + """ + def get_user_group!(%User{} = user, group_id) do + query = + from g in Group, + join: ug in "user_groups", + on: ug.group_id == g.id and ug.user_id == ^user.id, + where: g.id == ^group_id + + Repo.one!(query) + end + @doc """ Creates a group. @@ -129,7 +155,7 @@ defmodule SplitApp.Groups do """ def add_user_to_group(%User{} = user, %Group{} = group) do group = Repo.preload(group, :users) - + group |> Ecto.Changeset.change() |> Ecto.Changeset.put_assoc(:users, [user | group.users]) @@ -152,4 +178,62 @@ defmodule SplitApp.Groups do |> Ecto.Changeset.put_assoc(:users, Enum.reject(group.users, &(&1.id == user.id))) |> Repo.update() end + + @doc """ + Updates a group only if the user is a member. + + ## Examples + + iex> update_user_group(user, group, %{field: new_value}) + {:ok, %Group{}} + + iex> update_user_group(non_member, group, %{field: new_value}) + {:error, :unauthorized} + + """ + def update_user_group(%User{} = user, %Group{} = group, attrs) do + if user_in_group?(user, group) do + update_group(group, attrs) + else + {:error, :unauthorized} + end + end + + @doc """ + Deletes a group only if the user is a member. + + ## Examples + + iex> delete_user_group(user, group) + {:ok, %Group{}} + + iex> delete_user_group(non_member, group) + {:error, :unauthorized} + + """ + def delete_user_group(%User{} = user, %Group{} = group) do + if user_in_group?(user, group) do + delete_group(group) + else + {:error, :unauthorized} + end + end + + @doc """ + Checks if a user is a member of a group. + + ## Examples + + iex> user_in_group?(user, group) + true + + """ + def user_in_group?(%User{} = user, %Group{} = group) do + query = + from ug in "user_groups", + where: ug.user_id == ^user.id and ug.group_id == ^group.id, + select: count(ug.user_id) + + Repo.one(query) > 0 + end end diff --git a/lib/split_app/groups/user_group.ex b/lib/split_app/groups/user_group.ex index d98232c..793d578 100644 --- a/lib/split_app/groups/user_group.ex +++ b/lib/split_app/groups/user_group.ex @@ -16,4 +16,4 @@ defmodule SplitApp.Groups.UserGroup do |> validate_required([:user_id, :group_id]) |> unique_constraint([:user_id, :group_id]) end -end \ No newline at end of file +end diff --git a/lib/split_app_web/components/layouts/app.html.heex b/lib/split_app_web/components/layouts/app.html.heex index 9650302..31c274e 100644 --- a/lib/split_app_web/components/layouts/app.html.heex +++ b/lib/split_app_web/components/layouts/app.html.heex @@ -23,6 +23,7 @@ <% end %> + <.flash_group flash={@flash} />
{@inner_content}
diff --git a/lib/split_app_web/live/dashboard_live.ex b/lib/split_app_web/live/dashboard_live.ex index b22a477..1d76520 100644 --- a/lib/split_app_web/live/dashboard_live.ex +++ b/lib/split_app_web/live/dashboard_live.ex @@ -18,7 +18,7 @@ defmodule SplitAppWeb.DashboardLive do ~H"""
<.header> - Welcome, <%= @current_user.email %>! + Welcome, {@current_user.email}! <:subtitle>Here are all the groups you're a part of @@ -26,8 +26,18 @@ defmodule SplitAppWeb.DashboardLive do <%= if @groups == [] do %>
- - + +

No groups yet

@@ -35,7 +45,14 @@ defmodule SplitAppWeb.DashboardLive do
<.link navigate={~p"/groups/new"}> <.button> - + Create New Group @@ -50,22 +67,47 @@ defmodule SplitAppWeb.DashboardLive do
- - + +

- <%= group.name %> + {group.name}

- <%= group.description || "No description provided" %> + {group.description || "No description provided"}

-
diff --git a/lib/split_app_web/live/group_live/form_component.ex b/lib/split_app_web/live/group_live/form_component.ex index 536fbf2..bf8ee62 100644 --- a/lib/split_app_web/live/group_live/form_component.ex +++ b/lib/split_app_web/live/group_live/form_component.ex @@ -50,17 +50,21 @@ defmodule SplitAppWeb.GroupLive.FormComponent do end defp save_group(socket, :edit, group_params) do - case Groups.update_group(socket.assigns.group, group_params) do + user = socket.assigns.current_user + + case Groups.update_user_group(user, socket.assigns.group, group_params) do {:ok, group} -> notify_parent({:saved, group}) + notify_parent({:put_flash, {:info, "Group updated successfully"}}) - {:noreply, - socket - |> put_flash(:info, "Group updated successfully") - |> push_patch(to: socket.assigns.patch)} + {:noreply, push_patch(socket, to: socket.assigns.patch)} {:error, %Ecto.Changeset{} = changeset} -> {:noreply, assign(socket, form: to_form(changeset))} + + {:error, :unauthorized} -> + notify_parent({:put_flash, {:error, "You are not authorized to update this group"}}) + {:noreply, push_patch(socket, to: socket.assigns.patch)} end end @@ -70,11 +74,9 @@ defmodule SplitAppWeb.GroupLive.FormComponent do # Add the current user to the newly created group Groups.add_user_to_group(socket.assigns.current_user, group) notify_parent({:saved, group}) + notify_parent({:put_flash, {:info, "Group created successfully"}}) - {:noreply, - socket - |> put_flash(:info, "Group created successfully") - |> push_patch(to: socket.assigns.patch)} + {:noreply, push_patch(socket, to: socket.assigns.patch)} {:error, %Ecto.Changeset{} = changeset} -> {:noreply, assign(socket, form: to_form(changeset))} diff --git a/lib/split_app_web/live/group_live/index.ex b/lib/split_app_web/live/group_live/index.ex index c0746fc..ce82df7 100644 --- a/lib/split_app_web/live/group_live/index.ex +++ b/lib/split_app_web/live/group_live/index.ex @@ -6,7 +6,9 @@ defmodule SplitAppWeb.GroupLive.Index do @impl true def mount(_params, _session, socket) do - {:ok, stream(socket, :groups, Groups.list_groups())} + user = socket.assigns.current_user + groups = Groups.list_user_groups(user) + {:ok, stream(socket, :groups, groups)} end @impl true @@ -15,9 +17,11 @@ defmodule SplitAppWeb.GroupLive.Index do end defp apply_action(socket, :edit, %{"id" => id}) do + user = socket.assigns.current_user + socket |> assign(:page_title, "Edit Group") - |> assign(:group, Groups.get_group!(id)) + |> assign(:group, Groups.get_user_group!(user, id)) end defp apply_action(socket, :new, _params) do @@ -37,11 +41,24 @@ defmodule SplitAppWeb.GroupLive.Index do {:noreply, stream_insert(socket, :groups, group)} end + def handle_info({SplitAppWeb.GroupLive.FormComponent, {:put_flash, {type, message}}}, socket) do + {:noreply, put_flash(socket, type, message)} + end + @impl true def handle_event("delete", %{"id" => id}, socket) do - group = Groups.get_group!(id) - {:ok, _} = Groups.delete_group(group) + user = socket.assigns.current_user + group = Groups.get_user_group!(user, id) + + case Groups.delete_user_group(user, group) do + {:ok, _} -> + {:noreply, stream_delete(socket, :groups, group)} - {:noreply, stream_delete(socket, :groups, group)} + {:error, :unauthorized} -> + {:noreply, + socket + |> put_flash(:error, "You are not authorized to delete this group") + |> push_navigate(to: ~p"/groups")} + end end end diff --git a/lib/split_app_web/live/group_live/index.html.heex b/lib/split_app_web/live/group_live/index.html.heex index 5b7324c..a9b545c 100644 --- a/lib/split_app_web/live/group_live/index.html.heex +++ b/lib/split_app_web/live/group_live/index.html.heex @@ -30,7 +30,12 @@ -<.modal :if={@live_action in [:new, :edit]} id="group-modal" show on_cancel={JS.patch(~p"/groups")}> +<.modal + :if={@live_action in [:new, :edit]} + id="group-modal" + show + on_cancel={JS.patch(~p"/groups")} +> <.live_component module={SplitAppWeb.GroupLive.FormComponent} id={@group.id || :new} diff --git a/lib/split_app_web/live/group_live/show.ex b/lib/split_app_web/live/group_live/show.ex index fb1155a..9c4b23f 100644 --- a/lib/split_app_web/live/group_live/show.ex +++ b/lib/split_app_web/live/group_live/show.ex @@ -10,10 +10,31 @@ defmodule SplitAppWeb.GroupLive.Show do @impl true def handle_params(%{"id" => id}, _, socket) do - {:noreply, - socket - |> assign(:page_title, page_title(socket.assigns.live_action)) - |> assign(:group, Groups.get_group!(id))} + user = socket.assigns.current_user + + try do + group = Groups.get_user_group!(user, id) + + {:noreply, + socket + |> assign(:page_title, page_title(socket.assigns.live_action)) + |> assign(:group, group)} + rescue + Ecto.NoResultsError -> + {:noreply, + socket + |> put_flash(:error, "Group not found or you don't have access") + |> push_navigate(to: ~p"/groups")} + end + end + + @impl true + def handle_info({SplitAppWeb.GroupLive.FormComponent, {:saved, group}}, socket) do + {:noreply, assign(socket, :group, group)} + end + + def handle_info({SplitAppWeb.GroupLive.FormComponent, {:put_flash, {type, message}}}, socket) do + {:noreply, put_flash(socket, type, message)} end defp page_title(:show), do: "Show Group" diff --git a/lib/split_app_web/live/group_live/show.html.heex b/lib/split_app_web/live/group_live/show.html.heex index c2344d2..e57f64d 100644 --- a/lib/split_app_web/live/group_live/show.html.heex +++ b/lib/split_app_web/live/group_live/show.html.heex @@ -23,5 +23,6 @@ action={@live_action} group={@group} patch={~p"/groups/#{@group}"} + current_user={@current_user} /> diff --git a/lib/split_app_web/router.ex b/lib/split_app_web/router.ex index 2e4c547..ad140fa 100644 --- a/lib/split_app_web/router.ex +++ b/lib/split_app_web/router.ex @@ -68,13 +68,13 @@ defmodule SplitAppWeb.Router do live_session :require_authenticated_user, on_mount: [{SplitAppWeb.UserAuth, :ensure_authenticated}] do live "/dashboard", DashboardLive, :index - + live "/groups", GroupLive.Index, :index live "/groups/new", GroupLive.Index, :new live "/groups/:id/edit", GroupLive.Index, :edit live "/groups/:id", GroupLive.Show, :show live "/groups/:id/show/edit", GroupLive.Show, :edit - + live "/users/settings", UserSettingsLive, :edit live "/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email end diff --git a/priv/repo/migrations/20250824171107_create_user_groups.exs b/priv/repo/migrations/20250824171107_create_user_groups.exs index 422197f..e6efa35 100644 --- a/priv/repo/migrations/20250824171107_create_user_groups.exs +++ b/priv/repo/migrations/20250824171107_create_user_groups.exs @@ -5,7 +5,7 @@ defmodule SplitApp.Repo.Migrations.CreateUserGroups do create table(:user_groups, primary_key: false) do add :user_id, references(:users, on_delete: :delete_all), null: false add :group_id, references(:groups, on_delete: :delete_all), null: false - + timestamps(type: :utc_datetime) end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 5d39c7f..1e36cb7 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -14,31 +14,36 @@ alias SplitApp.Accounts alias SplitApp.Groups # Create test users -{:ok, user1} = Accounts.register_user(%{ - email: "user1@example.com", - password: "password12345" -}) - -{:ok, user2} = Accounts.register_user(%{ - email: "user2@example.com", - password: "password12345" -}) +{:ok, user1} = + Accounts.register_user(%{ + email: "user1@example.com", + password: "password12345" + }) + +{:ok, user2} = + Accounts.register_user(%{ + email: "user2@example.com", + password: "password12345" + }) # Create test groups -{:ok, group1} = Groups.create_group(%{ - name: "Weekend Trip", - description: "Expenses for our weekend getaway" -}) - -{:ok, group2} = Groups.create_group(%{ - name: "Monthly Groceries", - description: "Shared grocery expenses for the apartment" -}) - -{:ok, group3} = Groups.create_group(%{ - name: "Birthday Party", - description: "Planning and expenses for Sarah's birthday" -}) +{:ok, group1} = + Groups.create_group(%{ + name: "Weekend Trip", + description: "Expenses for our weekend getaway" + }) + +{:ok, group2} = + Groups.create_group(%{ + name: "Monthly Groceries", + description: "Shared grocery expenses for the apartment" + }) + +{:ok, group3} = + Groups.create_group(%{ + name: "Birthday Party", + description: "Planning and expenses for Sarah's birthday" + }) # Add users to groups Groups.add_user_to_group(user1, group1) diff --git a/test/split_app_web/controllers/user_session_controller_test.exs b/test/split_app_web/controllers/user_session_controller_test.exs index 4a77d65..14dd5d5 100644 --- a/test/split_app_web/controllers/user_session_controller_test.exs +++ b/test/split_app_web/controllers/user_session_controller_test.exs @@ -15,10 +15,10 @@ defmodule SplitAppWeb.UserSessionControllerTest do }) assert get_session(conn, :user_token) - assert redirected_to(conn) == ~p"/" + assert redirected_to(conn) == ~p"/dashboard" # Now do a logged in request and assert on the menu - conn = get(conn, ~p"/") + conn = get(conn, ~p"/dashboard") response = html_response(conn, 200) assert response =~ user.email assert response =~ ~p"/users/settings" @@ -36,7 +36,7 @@ defmodule SplitAppWeb.UserSessionControllerTest do }) assert conn.resp_cookies["_split_app_web_user_remember_me"] - assert redirected_to(conn) == ~p"/" + assert redirected_to(conn) == ~p"/dashboard" end test "logs the user in with return to", %{conn: conn, user: user} do @@ -65,7 +65,7 @@ defmodule SplitAppWeb.UserSessionControllerTest do } }) - assert redirected_to(conn) == ~p"/" + assert redirected_to(conn) == ~p"/dashboard" assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Account created successfully" end diff --git a/test/split_app_web/live/group_live_test.exs b/test/split_app_web/live/group_live_test.exs index a9453be..2ab7508 100644 --- a/test/split_app_web/live/group_live_test.exs +++ b/test/split_app_web/live/group_live_test.exs @@ -8,13 +8,13 @@ defmodule SplitAppWeb.GroupLiveTest do @update_attrs %{name: "some updated name", description: "some updated description"} @invalid_attrs %{name: nil, description: nil} - defp create_group(_) do - group = group_fixture() + defp create_group(%{user: user}) do + group = group_fixture_with_user(user) %{group: group} end describe "Index" do - setup [:create_group] + setup [:register_and_log_in_user, :create_group] test "lists all groups", %{conn: conn, group: group} do {:ok, _index_live, html} = live(conn, ~p"/groups") @@ -78,7 +78,7 @@ defmodule SplitAppWeb.GroupLiveTest do end describe "Show" do - setup [:create_group] + setup [:register_and_log_in_user, :create_group] test "displays group", %{conn: conn, group: group} do {:ok, _show_live, html} = live(conn, ~p"/groups/#{group}") diff --git a/test/split_app_web/live/user_forgot_password_live_test.exs b/test/split_app_web/live/user_forgot_password_live_test.exs index 7331a55..8a095d3 100644 --- a/test/split_app_web/live/user_forgot_password_live_test.exs +++ b/test/split_app_web/live/user_forgot_password_live_test.exs @@ -9,7 +9,7 @@ defmodule SplitAppWeb.UserForgotPasswordLiveTest do describe "Forgot password page" do test "renders email page", %{conn: conn} do - {:ok, lv, html} = live(conn, ~p"/users/reset_password") + {:ok, _lv, html} = live(conn, ~p"/users/reset_password") assert html =~ "Forgot your password?" end @@ -19,7 +19,7 @@ defmodule SplitAppWeb.UserForgotPasswordLiveTest do conn |> log_in_user(user_fixture()) |> live(~p"/users/reset_password") - |> follow_redirect(conn, ~p"/") + |> follow_redirect(conn, ~p"/dashboard") assert {:ok, _conn} = result end diff --git a/test/split_app_web/live/user_login_live_test.exs b/test/split_app_web/live/user_login_live_test.exs index b7726e4..ea001a9 100644 --- a/test/split_app_web/live/user_login_live_test.exs +++ b/test/split_app_web/live/user_login_live_test.exs @@ -18,7 +18,7 @@ defmodule SplitAppWeb.UserLoginLiveTest do conn |> log_in_user(user_fixture()) |> live(~p"/users/log_in") - |> follow_redirect(conn, "/") + |> follow_redirect(conn, "/dashboard") assert {:ok, _conn} = result end @@ -36,7 +36,7 @@ defmodule SplitAppWeb.UserLoginLiveTest do conn = submit_form(form, conn) - assert redirected_to(conn) == ~p"/" + assert redirected_to(conn) == ~p"/dashboard" end test "redirects to login page with a flash error if there are no valid credentials", %{ diff --git a/test/split_app_web/live/user_registration_live_test.exs b/test/split_app_web/live/user_registration_live_test.exs index 5f236d0..dea4a69 100644 --- a/test/split_app_web/live/user_registration_live_test.exs +++ b/test/split_app_web/live/user_registration_live_test.exs @@ -17,7 +17,7 @@ defmodule SplitAppWeb.UserRegistrationLiveTest do conn |> log_in_user(user_fixture()) |> live(~p"/users/register") - |> follow_redirect(conn, "/") + |> follow_redirect(conn, "/dashboard") assert {:ok, _conn} = result end @@ -45,10 +45,10 @@ defmodule SplitAppWeb.UserRegistrationLiveTest do render_submit(form) conn = follow_trigger_action(form, conn) - assert redirected_to(conn) == ~p"/" + assert redirected_to(conn) == ~p"/dashboard" # Now do a logged in request and assert on the menu - conn = get(conn, "/") + conn = get(conn, "/dashboard") response = html_response(conn, 200) assert response =~ email assert response =~ "Settings" diff --git a/test/split_app_web/user_auth_test.exs b/test/split_app_web/user_auth_test.exs index 308f64b..d637869 100644 --- a/test/split_app_web/user_auth_test.exs +++ b/test/split_app_web/user_auth_test.exs @@ -22,7 +22,7 @@ defmodule SplitAppWeb.UserAuthTest do conn = UserAuth.log_in_user(conn, user) assert token = get_session(conn, :user_token) assert get_session(conn, :live_socket_id) == "users_sessions:#{Base.url_encode64(token)}" - assert redirected_to(conn) == ~p"/" + assert redirected_to(conn) == ~p"/dashboard" assert Accounts.get_user_by_session_token(token) end @@ -216,7 +216,7 @@ defmodule SplitAppWeb.UserAuthTest do test "redirects if user is authenticated", %{conn: conn, user: user} do conn = conn |> assign(:current_user, user) |> UserAuth.redirect_if_user_is_authenticated([]) assert conn.halted - assert redirected_to(conn) == ~p"/" + assert redirected_to(conn) == ~p"/dashboard" end test "does not redirect if user is not authenticated", %{conn: conn} do diff --git a/test/support/fixtures/groups_fixtures.ex b/test/support/fixtures/groups_fixtures.ex index c265d05..ba0706c 100644 --- a/test/support/fixtures/groups_fixtures.ex +++ b/test/support/fixtures/groups_fixtures.ex @@ -18,4 +18,13 @@ defmodule SplitApp.GroupsFixtures do group end + + @doc """ + Generate a group and add a user to it. + """ + def group_fixture_with_user(user, attrs \\ %{}) do + group = group_fixture(attrs) + {:ok, group} = SplitApp.Groups.add_user_to_group(user, group) + group + end end