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
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,29 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- `Category` and `Envelope` resource and automatic creation of these records on `Book` creation. ([#23])

## [0.1.0] - 2023-11-22

### Added

- This repo which is a fresh Phoenix 1.7.9 web app.
- Use [AGPL-3.0](https://choosealicense.com/licenses/agpl-3.0/) as the license for this project. ([#1])
- CI pipeline through [GitHub Actions](https://github.com/features/actions). ([#3] [#4] [#5])
- Factories instead of Fixtures for testing. ([#6])
- Dialyzer integration and enforcement of spec types. ([#7])
- Authentication System courtesy of `mix phx.gen.auth`. ([#8])
- Book resource. ([#10])
- `Book` resource. ([#10])
- Layout based on [Flowbite](https://flowbite.com). ([#11])
- Containerization and Continuous Deployment to [Fly.io](https://fly.io). ([#12] [#17] [#18])
- Integration of [PostMark](https://postmarkapp.com) for sending emails. ([#19])

[Unreleased]: https://github.com/DashFloat/dashfloat/compare/v0.1.0...HEAD
[0.1.0]: https://github.com/DashFloat/dashfloat/releases/tag/v0.1.0

[#23]: https://github.com/DashFloat/dashfloat/pull/23
[#19]: https://github.com/DashFloat/dashfloat/pull/19
[#18]: https://github.com/DashFloat/dashfloat/pull/18
[#17]: https://github.com/DashFloat/dashfloat/pull/17
[#12]: https://github.com/DashFloat/dashfloat/pull/12
[#11]: https://github.com/DashFloat/dashfloat/pull/11
[#10]: https://github.com/DashFloat/dashfloat/pull/10
[#8]: https://github.com/DashFloat/dashfloat/pull/8
[#7]: https://github.com/DashFloat/dashfloat/pull/7
[#6]: https://github.com/DashFloat/dashfloat/pull/6
[#5]: https://github.com/DashFloat/dashfloat/pull/5
[#4]: https://github.com/DashFloat/dashfloat/pull/4
[#3]: https://github.com/DashFloat/dashfloat/pull/3
Expand Down
19 changes: 19 additions & 0 deletions lib/dashfloat/contexts/budgeting/repositories/book_repository.ex
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,25 @@ defmodule DashFloat.Budgeting.Repositories.BookRepository do
Book.changeset(book, attrs)
end

@doc """
Creates a new `Book` with the given attributes.

## Examples

iex> create(%{name: "My Book"})
{:ok, %Book{}}

iex> create(%{name: nil})
{:error, %Ecto.Changeset{}}

"""
@spec create(map()) :: {:ok, Book.t()} | {:error, Ecto.Changeset.t()}
def create(attrs) do
%Book{}
|> Book.changeset(attrs)
|> Repo.insert()
end

@doc """
Deletes a `Book` associated with the given `user_id`.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,30 @@ defmodule DashFloat.Budgeting.Repositories.BookUserRepository do
alias DashFloat.Budgeting.Schemas.User
alias DashFloat.Repo

@doc """
Creates a new `BookUser` with the given attributes.

The default `role` is `:viewer` if not specified.

Creating a `BookUser` with a `Book` and `User` that are already associated
will return an error.

## Examples

iex> create(%{book_id: 123, user_id: 123, role: :viewer})
{:ok, %BookUser{}}

iex> create(%{book_id: paired_book_id, user_id: paired_user_id})
{:error, %Ecto.Changeset{}

"""
@spec create(map()) :: {:ok, BookUser.t()} | {:error, Ecto.Changeset.t()}
def create(attrs) do
%BookUser{}
|> BookUser.changeset(attrs)
|> Repo.insert()
end

@doc """
Gets a single `BookUser` with the given `Book` and `User`.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
defmodule DashFloat.Budgeting.Repositories.CategoryRepository do
@moduledoc """
Repository for the `Category` schema.
"""

alias DashFloat.Budgeting.Schemas.Category
alias DashFloat.Repo

@doc """
Creates a new `Category` with the given attributes.

## Examples

iex> create(%{name: "Monthly Bills", book_id: 1})
{:ok, %Category{}}
"""
@spec create(map()) :: {:ok, Category.t()} | {:error, Ecto.Changeset.t()}
def create(attrs) do
%Category{}
|> Category.changeset(attrs)
|> Repo.insert()
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
defmodule DashFloat.Budgeting.Repositories.EnvelopeRepository do
@moduledoc """
Repository for the `Envelope` schema.
"""

alias DashFloat.Budgeting.Schemas.Envelope
alias DashFloat.Repo

@doc """
Creates a new `Envelope` with the given attributes.

## Examples

iex> create(%{name: "Groceries", category_id: 1})
{:ok, %Envelope{}}
"""
@spec create(map()) :: {:ok, Envelope.t()} | {:error, Ecto.Changeset.t()}
def create(attrs) do
%Envelope{}
|> Envelope.changeset(attrs)
|> Repo.insert()
end
end
1 change: 1 addition & 0 deletions lib/dashfloat/contexts/budgeting/schemas/book_user.ex
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,6 @@ defmodule DashFloat.Budgeting.Schemas.BookUser do
book_user
|> cast(attrs, [:role, :book_id, :user_id])
|> validate_required([:book_id, :user_id])
|> unique_constraint(:book_id, name: :books_users_book_id_user_id_unique_index)
end
end
39 changes: 39 additions & 0 deletions lib/dashfloat/contexts/budgeting/schemas/category.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
defmodule DashFloat.Budgeting.Schemas.Category do
@moduledoc """
`Category` is a group of `Envelope`.
"""

use Ecto.Schema

import Ecto.Changeset

alias DashFloat.Budgeting.Schemas.Book
alias DashFloat.Budgeting.Schemas.Envelope

@type t :: %__MODULE__{
__meta__: Ecto.Schema.Metadata.t(),
id: integer() | nil,
name: String.t() | nil,
book_id: integer() | nil,
inserted_at: DateTime.t() | nil,
updated_at: DateTime.t() | nil
}

schema "categories" do
field :name, :string

belongs_to :book, Book
has_many :envelopes, Envelope

timestamps(type: :utc_datetime_usec)
end

@doc false
@spec changeset(t(), map()) :: Ecto.Changeset.t()
def changeset(category, attrs) do
category
|> cast(attrs, [:name, :book_id])
|> validate_required([:name, :book_id])
|> cast_assoc(:envelopes, with: &Envelope.from_category_changeset/2)
end
end
44 changes: 44 additions & 0 deletions lib/dashfloat/contexts/budgeting/schemas/envelope.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
defmodule DashFloat.Budgeting.Schemas.Envelope do
@moduledoc """
`Envelope` is the representation of a specific thing that you want to budget for.
"""

use Ecto.Schema

import Ecto.Changeset

alias DashFloat.Budgeting.Schemas.Category

@type t :: %__MODULE__{
__meta__: Ecto.Schema.Metadata.t(),
id: integer() | nil,
name: String.t() | nil,
category_id: integer() | nil,
inserted_at: DateTime.t() | nil,
updated_at: DateTime.t() | nil
}

schema "envelopes" do
field :name, :string

belongs_to :category, Category

timestamps(type: :utc_datetime_usec)
end

@doc false
@spec changeset(t(), map()) :: Ecto.Changeset.t()
def changeset(envelope, attrs) do
envelope
|> cast(attrs, [:name, :category_id])
|> validate_required([:name, :category_id])
end

@doc false
@spec from_category_changeset(t(), map()) :: Ecto.Changeset.t()
def from_category_changeset(envelope, attrs) do
envelope
|> cast(attrs, [:name, :category_id])
|> validate_required([:name])
end
end
104 changes: 90 additions & 14 deletions lib/dashfloat/contexts/budgeting/services/create_book.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,42 +4,118 @@ defmodule DashFloat.Budgeting.Services.CreateBook do

## Examples

iex> CreateBook.call(%{field: value}, user_id)
iex> CreateBook.call(%{name: "My Book"}, user_id)
{:ok, %Book{}}

iex> CreateBook.call(%{field: bad_value}, user_id)
iex> CreateBook.call(%{name: nil}, user_id)
{:error, %Ecto.Changeset{}}

iex> CreateBook.call(%{field: value}, non_existing_user_id)
iex> CreateBook.call(%{name: "My Book"}, non_existing_user_id)
{:error, :user_not_found}

"""

alias DashFloat.Budgeting.Repositories.BookRepository
alias DashFloat.Budgeting.Repositories.BookUserRepository
alias DashFloat.Budgeting.Repositories.CategoryRepository
alias DashFloat.Budgeting.Repositories.UserRepository
alias DashFloat.Budgeting.Schemas.Book
alias DashFloat.Budgeting.Schemas.BookUser
alias DashFloat.Repo
alias Ecto.Multi

@spec call(map(), integer()) ::
{:ok, Book.t()} | {:error, Ecto.Changeset.t()} | {:error, :user_not_found}
def call(attrs, user_id) do
Multi.new()
|> Multi.run(:user, fn _repo, _changes_so_far ->
case UserRepository.get(user_id) do
nil -> {:error, :user_not_found}
user -> {:ok, user}
end
end)
|> Multi.insert(:book, Book.changeset(%Book{}, attrs))
|> Multi.insert(:book_user, fn %{book: book, user: user} ->
BookUser.changeset(%BookUser{}, %{book_id: book.id, user_id: user.id, role: :admin})
end)
|> get_user(user_id)
|> create_book(attrs)
|> create_book_user()
|> create_categories_and_envelopes()
|> Repo.transaction()
|> case do
{:ok, %{book: book}} -> {:ok, book}
{:error, :book, changeset, _changes_so_far} -> {:error, changeset}
{:error, :user, error_message, _changes_so_far} -> {:error, error_message}
end
end

defp get_user(multi, user_id) do
Multi.run(multi, :user, fn _repo, _changes_so_far ->
case UserRepository.get(user_id) do
nil -> {:error, :user_not_found}
user -> {:ok, user}
end
end)
end

defp create_book(multi, attrs) do
Multi.run(multi, :book, fn _repo, _changes_so_far ->
BookRepository.create(attrs)
end)
end

defp create_book_user(multi) do
Multi.run(multi, :book_user, fn _repo, %{book: book, user: user} ->
BookUserRepository.create(%{book_id: book.id, user_id: user.id, role: :admin})
end)
end

defp create_categories_and_envelopes(multi) do
Enum.reduce(default_categories_and_envelopes(), multi, fn category, multi ->
Multi.run(multi, {:category, category.name}, fn _repo, %{book: book} ->
CategoryRepository.create(%{
book_id: book.id,
name: category.name,
envelopes: category.envelopes
})
end)
end)
end

defp default_categories_and_envelopes do
[
%{
name: "Bills",
envelopes: [
%{name: "Rent / Mortgage"},
%{name: "Electric"},
%{name: "Water"},
%{name: "Internet"},
%{name: "Cellphone"}
]
},
%{
name: "Frequent",
envelopes: [
%{name: "Groceries"},
%{name: "Eating Out"},
%{name: "Transportation"}
]
},
%{
name: "Non-Monthly",
envelopes: [
%{name: "Home Maintenance"},
%{name: "Auto Maintenance"},
%{name: "Gifts"}
]
},
%{
name: "Goals",
envelopes: [
%{name: "Vacation"},
%{name: "Education"},
%{name: "Home Improvement"}
]
},
%{
name: "Quality of Life",
envelopes: [
%{name: "Hobbies"},
%{name: "Entertainment"},
%{name: "Health & Wellness"}
]
}
]
end
end
14 changes: 14 additions & 0 deletions priv/repo/migrations/20231123094223_create_categories.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
defmodule DashFloat.Repo.Migrations.CreateCategories do
use Ecto.Migration

def change do
create table(:categories) do
add :name, :string, null: false
add :book_id, references(:books, on_delete: :delete_all)

timestamps(type: :timestamptz)
end

create index(:categories, [:book_id])
end
end
14 changes: 14 additions & 0 deletions priv/repo/migrations/20231123094230_create_envelopes.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
defmodule DashFloat.Repo.Migrations.CreateEnvelopes do
use Ecto.Migration

def change do
create table(:envelopes) do
add :name, :string, null: false
add :category_id, references(:categories, on_delete: :delete_all)

timestamps(type: :timestamptz)
end

create index(:envelopes, [:category_id])
end
end
Loading