12 changes: 6 additions & 6 deletions lib/todo_backend/router.ex
Expand Up @@ -2,11 +2,11 @@ defmodule TodoBackend.Router do
use Commanded.Commands.Router

alias TodoBackend.Todos.Aggregates.Todo
alias TodoBackend.Todos.Commands.CreateTodo
alias TodoBackend.Todos.Commands.DeleteTodo
alias TodoBackend.Todos.Commands.UpdateTodo
alias TodoBackend.Todos.Commands.{CreateTodo, DeleteTodo, UpdateTodo, RestoreTodo}

dispatch([CreateTodo], to: Todo, identity: :uuid)
dispatch([DeleteTodo], to: Todo, identity: :uuid)
dispatch([UpdateTodo], to: Todo, identity: :uuid)
dispatch([CreateTodo, DeleteTodo, UpdateTodo, RestoreTodo],
to: Todo,
identity: :uuid,
lifespan: Todo
)
end
41 changes: 34 additions & 7 deletions lib/todo_backend/todos.ex
Expand Up @@ -6,10 +6,7 @@ defmodule TodoBackend.Todos do
import Ecto.Query, warn: false
alias TodoBackend.App
alias TodoBackend.Repo
alias TodoBackend.Todos.Commands.CreateTodo
alias TodoBackend.Todos.Commands.DeleteTodo
alias TodoBackend.Todos.Commands.UpdateTodo

alias TodoBackend.Todos.Commands.{CreateTodo, DeleteTodo, UpdateTodo, RestoreTodo}
alias TodoBackend.Todos.Projections.Todo

@doc """
Expand All @@ -22,7 +19,10 @@ defmodule TodoBackend.Todos do
"""
def list_todos do
Repo.all(Todo)
from(t in Todo,
where: is_nil(t.deleted_at)
)
|> Repo.all()
end

@doc """
Expand All @@ -39,7 +39,12 @@ defmodule TodoBackend.Todos do
** (Ecto.NoResultsError)
"""
def get_todo!(uuid), do: Repo.get_by!(Todo, uuid: uuid)
def get_todo!(uuid) do
from(t in Todo,
where: is_nil(t.deleted_at)
)
|> Repo.get_by!(uuid: uuid)
end

@doc """
Creates a todo.
Expand Down Expand Up @@ -81,6 +86,7 @@ defmodule TodoBackend.Todos do
"""
def update_todo(%Todo{uuid: uuid}, attrs) do
# TODO: ensure todo is active
command =
attrs
|> UpdateTodo.new()
Expand All @@ -106,6 +112,7 @@ defmodule TodoBackend.Todos do
"""
def delete_todo(%Todo{uuid: uuid}) do
# TODO: ensure todo exists
command = DeleteTodo.new(%{uuid: uuid})

with :ok <- App.dispatch(command) do
Expand All @@ -115,6 +122,16 @@ defmodule TodoBackend.Todos do
end
end

def restore_todo(id) do
command = %RestoreTodo{uuid: id}

with :ok <- App.dispatch(command, consistency: :strong) do
{:ok, get_todo!(id)}
else
reply -> reply
end
end

@doc """
Deletes all todos.
Expand All @@ -125,6 +142,16 @@ defmodule TodoBackend.Todos do
"""
def delete_all_todos() do
Repo.delete_all(Todo)
stream = Repo.stream(Todo)
correlation_id = Ecto.UUID.generate()

Repo.transaction(fn ->
Enum.each(stream, fn todo ->
App.dispatch(
DeleteTodo.new(%{uuid: todo.uuid}),
correlation_id: correlation_id
)
end)
end)
end
end
58 changes: 44 additions & 14 deletions lib/todo_backend/todos/aggregates/todo.ex
Expand Up @@ -3,21 +3,25 @@ defmodule TodoBackend.Todos.Aggregates.Todo do
:uuid,
:title,
:completed,
:order
:order,
deleted_at: nil
]

@behaviour Commanded.Aggregates.AggregateLifespan

alias TodoBackend.Todos.Aggregates.Todo

alias TodoBackend.Todos.Commands.CreateTodo
alias TodoBackend.Todos.Commands.DeleteTodo
alias TodoBackend.Todos.Commands.UpdateTodo
alias TodoBackend.Todos.Commands.{CreateTodo, DeleteTodo, UpdateTodo, RestoreTodo}

alias TodoBackend.Todos.Events.TodoCreated
alias TodoBackend.Todos.Events.TodoDeleted
alias TodoBackend.Todos.Events.TodoCompleted
alias TodoBackend.Todos.Events.TodoUncompleted
alias TodoBackend.Todos.Events.TodoTitleUpdated
alias TodoBackend.Todos.Events.TodoOrderUpdated
alias TodoBackend.Todos.Events.{
TodoCreated,
TodoDeleted,
TodoRestored,
TodoCompleted,
TodoUncompleted,
TodoTitleUpdated,
TodoOrderUpdated
}

def execute(%Todo{uuid: nil}, %CreateTodo{} = create) do
%TodoCreated{
Expand All @@ -28,8 +32,20 @@ defmodule TodoBackend.Todos.Aggregates.Todo do
}
end

def execute(%Todo{uuid: uuid}, %DeleteTodo{uuid: uuid}) do
%TodoDeleted{uuid: uuid}
def execute(%Todo{uuid: uuid, deleted_at: nil}, %DeleteTodo{uuid: uuid}) do
%TodoDeleted{uuid: uuid, datetime: DateTime.utc_now()}
end

def execute(%Todo{}, %DeleteTodo{}) do
{:error, "Can not delete todo that is already deleted"}
end

def execute(%Todo{deleted_at: nil}, %RestoreTodo{}) do
{:error, "Can only restore deleted todos"}
end

def execute(%Todo{uuid: uuid}, %RestoreTodo{uuid: uuid}) do
%TodoRestored{uuid: uuid}
end

# TODO: validate
Expand Down Expand Up @@ -80,7 +96,21 @@ defmodule TodoBackend.Todos.Aggregates.Todo do
%Todo{todo | order: order}
end

def apply(%Todo{uuid: uuid}, %TodoDeleted{uuid: uuid}) do
nil
def apply(%Todo{uuid: uuid, deleted_at: nil} = todo, %TodoDeleted{
uuid: uuid,
datetime: effective_datetime
}) do
%Todo{todo | deleted_at: effective_datetime}
end

def apply(%Todo{uuid: uuid} = todo, %TodoRestored{uuid: uuid}) do
%Todo{todo | deleted_at: nil}
end

def after_command(_command), do: :timer.minutes(1)

def after_event(%TodoDeleted{}), do: :stop
def after_event(_event), do: :timer.minutes(1)

def after_error(_error), do: :timer.minutes(1)
end
5 changes: 5 additions & 0 deletions lib/todo_backend/todos/commands/restore_todo.ex
@@ -0,0 +1,5 @@
defmodule TodoBackend.Todos.Commands.RestoreTodo do
defstruct [
:uuid
]
end
22 changes: 21 additions & 1 deletion lib/todo_backend/todos/events/todo_deleted.ex
@@ -1,6 +1,26 @@
defmodule TodoBackend.Todos.Events.TodoDeleted do
@derive Jason.Encoder
defstruct [
:uuid
:uuid,
:datetime
]

alias TodoBackend.Todos.Events.TodoDeleted

defimpl Commanded.Serialization.JsonDecoder, for: TodoDeleted do
@doc """
Parse the datetime included in the aggregate state
"""
def decode(%TodoDeleted{} = state) do
%TodoDeleted{datetime: datetime} = state

if datetime == nil do
%TodoDeleted{state | datetime: DateTime.utc_now()}
else
{:ok, dt, _} = DateTime.from_iso8601(datetime)

%TodoDeleted{state | datetime: dt}
end
end
end
end
6 changes: 6 additions & 0 deletions lib/todo_backend/todos/events/todo_restored.ex
@@ -0,0 +1,6 @@
defmodule TodoBackend.Todos.Events.TodoRestored do
@derive Jason.Encoder
defstruct [
:uuid
]
end
6 changes: 6 additions & 0 deletions lib/todo_backend/todos/projections/todo.ex
Expand Up @@ -9,6 +9,7 @@ defmodule TodoBackend.Todos.Projections.Todo do
field :completed, :boolean, default: false
field :title, :string
field :order, :integer, default: 0
field :deleted_at, :naive_datetime_usec, default: nil

timestamps()
end
Expand All @@ -17,4 +18,9 @@ defmodule TodoBackend.Todos.Projections.Todo do
todo
|> cast(attrs, [:title, :completed, :order])
end

def delete_changeset(todo, attrs \\ %{}) do
todo
|> cast(attrs, [:deleted_at])
end
end
39 changes: 31 additions & 8 deletions lib/todo_backend/todos/projectors/todo.ex
Expand Up @@ -6,12 +6,15 @@ defmodule TodoBackend.Todos.Projectors.Todo do

alias TodoBackend.Repo

alias TodoBackend.Todos.Events.TodoCreated
alias TodoBackend.Todos.Events.TodoDeleted
alias TodoBackend.Todos.Events.TodoCompleted
alias TodoBackend.Todos.Events.TodoUncompleted
alias TodoBackend.Todos.Events.TodoTitleUpdated
alias TodoBackend.Todos.Events.TodoOrderUpdated
alias TodoBackend.Todos.Events.{
TodoCreated,
TodoDeleted,
TodoRestored,
TodoCompleted,
TodoUncompleted,
TodoTitleUpdated,
TodoOrderUpdated
}

alias TodoBackend.Todos.Projections.Todo

Expand All @@ -24,8 +27,28 @@ defmodule TodoBackend.Todos.Projectors.Todo do
})
end)

project(%TodoDeleted{uuid: uuid}, _, fn multi ->
Ecto.Multi.delete(multi, :todo, fn _ -> %Todo{uuid: uuid} end)
project(%TodoDeleted{uuid: uuid, datetime: effective_datetime}, _, fn multi ->
case Repo.get(Todo, uuid) do
nil ->
multi

todo ->
Ecto.Multi.update(
multi,
:todo,
Todo.delete_changeset(todo, %{deleted_at: effective_datetime})
)
end
end)

project(%TodoRestored{uuid: uuid}, _, fn multi ->
case Repo.get(Todo, uuid) do
nil ->
multi

todo ->
Ecto.Multi.update(multi, :todo, Todo.delete_changeset(todo, %{deleted_at: nil}))
end
end)

project(%TodoCompleted{uuid: uuid}, _, fn multi ->
Expand Down
6 changes: 6 additions & 0 deletions lib/todo_backend_web/controllers/todo_controller.ex
Expand Up @@ -25,6 +25,12 @@ defmodule TodoBackendWeb.TodoController do
render(conn, "show.json", todo: todo)
end

def restore(conn, %{"id" => id}) do
with {:ok, %Todo{} = todo} <- Todos.restore_todo(id) do
render(conn, "show.json", todo: todo)
end
end

def update(conn, %{"id" => id}) do
todo = Todos.get_todo!(id)

Expand Down
1 change: 1 addition & 0 deletions lib/todo_backend_web/router.ex
Expand Up @@ -10,5 +10,6 @@ defmodule TodoBackendWeb.Router do

resources "/todos", TodoController
delete "/todos", TodoController, :delete_all
put "/todos/:id/restore", TodoController, :restore
end
end
11 changes: 11 additions & 0 deletions priv/repo/migrations/20220508052654_add_deleted_at_to_todo.exs
@@ -0,0 +1,11 @@
defmodule TodoBackend.Repo.Migrations.AddDeletedAtToTodo do
use Ecto.Migration

def change do
alter table(:todos) do
add :deleted_at, :naive_datetime_usec
end

create index(:todos, [:deleted_at])
end
end