Skip to content

Commit

Permalink
refactor: combine Ballots and Votes contexts into a single `Ranke…
Browse files Browse the repository at this point in the history
…dVoting` context (#20)
  • Loading branch information
zorn authored Jul 25, 2024
1 parent 99c00d9 commit 4c46b39
Show file tree
Hide file tree
Showing 16 changed files with 444 additions and 424 deletions.
35 changes: 27 additions & 8 deletions docs/decisions/1-timestamps.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,47 @@

# Problem Statement

Out of the box, when you use the Ecto [`timestamps/1` function][1], you end up with `:naive_datetime` values for in-memory entities. This lacks the level of timezone precision we would prefer.
Out of the box, when you use the Ecto [`timestamps/1` function][1], you end up
with `:naive_datetime` values for in-memory entities. This lacks the level of
timezone precision we would prefer.

[1]: https://hexdocs.pm/ecto/Ecto.Schema.html#timestamps/1

## Solution

Our upcoming Ecto schemas will use `timestamps(type::utc_datetime_usec)` to be explicit about UTC and use microseconds for more precision.
Our upcoming Ecto schemas will use `timestamps(type::utc_datetime_usec)` to be
explicit about UTC and use microseconds for more precision.

When creating the database columns in our migration files, we will also use `timestamps(type::utc_datetime_usec)`. This results in the database column having a type like `timestamp without time zone NOT NULL`.
When creating the database columns in our migration files, we will also use
`timestamps(type::utc_datetime_usec)`. This results in the database column
having a type like `timestamp without time zone NOT NULL`.

Take note this Postgres column type does not have any timezone information. We assume the database will always store timestamp values in UTC (which is a community norm).
Take note this Postgres column type does not have any timezone information. We
assume the database will always store timestamp values in UTC (which is a
community norm).

## Other Solutions Considered

### `timestamptz`

We could have used a database migration style with `timestamps(type: :timestamptz)`, which would store timezone information in the Postgres database, **but** that also encourages people to store non-UTC timestamps in the database. For clarity, we would prefer the database always be UTC.
We could have used a database migration style with `timestamps(type:
:timestamptz)`, which would store timezone information in the Postgres database,
**but** that also encourages people to store non-UTC timestamps in the database.
For clarity, we would prefer the database always be UTC.

This is not an irreversible decision and can be adjusted if wanted.

### What are timestamps?

An argument can be made that the timestamp columns of the database are metadata of the implementation and should not be viewed as domain-specific values. The logic goes: If you want to track when your domain entities are created with your specific domain perspective, you should have `created_at` and `edited_at` columns. Those database-specific `inserted_at` and `updated_at` columns may change due to implementation needs and not accurately represent the actual domain knowledge.

That said, for the sake of simplicity, we are **not** going to introduce `created_at` and `edited_at` and will continue to make the `inserted_at` and `updated_at` values available to the Elixir code. Should these columns deviate from the domain interpretation, we can add those other columns later.
An argument can be made that the timestamp columns of the database are metadata
of the implementation and should not be viewed as domain-specific values. The
logic goes: If you want to track when your domain entities are created with your
specific domain perspective, you should have `created_at` and `edited_at`
columns. Those database-specific `inserted_at` and `updated_at` columns may
change due to implementation needs and not accurately represent the actual
domain knowledge.

That said, for the sake of simplicity, we are **not** going to introduce
`created_at` and `edited_at` and will continue to make the `inserted_at` and
`updated_at` values available to the Elixir code. Should these columns deviate
from the domain interpretation, we can add those other columns later.
31 changes: 23 additions & 8 deletions docs/decisions/2-ballot-and-vote-schema-shape.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,35 @@

## Ballots

The `Flick.Ballots.Ballot` schema captures a simple one-question ballot using fields for `question_title` and `possible_answers`.
The `Flick.RankedVoting.Ballot` schema captures a simple one-question ballot
using fields for `question_title` and `possible_answers`.

We could have solved "possible answers" in the data model in multiple ways but have picked this specific style with intent.
We could have solved "possible answers" in the data model in multiple ways but
have picked this specific style with intent.

We expect `possible_answers` to be a comma-separated list of values and have some basic validations around this. This decision was made with frontend practicality in mind. We've simplified the frontend UI by eliminating the need for a second layer of `inputs_for` for an alternative `PossibleAnswer` `embeds_many` schema design.
We expect `possible_answers` to be a comma-separated list of values and have
some basic validations around this. This decision was made with frontend
practicality in mind. We've simplified the frontend UI by eliminating the need
for a second layer of `inputs_for` for an alternative `PossibleAnswer`
`embeds_many` schema design.

Additionally, early drafts of this schema allowed a ballot to have multiple questions, but again, we've chosen to simplify for now.
Additionally, early drafts of this schema allowed a ballot to have multiple
questions, but again, we've chosen to simplify for now.

## Votes

When capturing votes, one might imagine a vote to have a `ballot_id` and then a list of ordered `answer_id` values. Given the above-described storage of `possible_answers`, we don't have answer id values.
When capturing votes, one might imagine a vote to have a `ballot_id` and then a
list of ordered `answer_id` values. Given the above-described storage of
`possible_answers`, we don't have answer id values.

When we capture a vote, we store an answer as a string value, the same string defined in the possible answer.
When we capture a vote, we store an answer as a string value, the same string
defined in the possible answer.

This will duplicate the answer strings many times in the database; however, given the expected early usage of this app and the fact that a UUID value can cost as much as many of these smaller answer values, this is an acceptable tradeoff for now. If we want to refactor, we can do so in the future.
This will duplicate the answer strings many times in the database; however,
given the expected early usage of this app and the fact that a UUID value can
cost as much as many of these smaller answer values, this is an acceptable
tradeoff for now. If we want to refactor, we can do so in the future.

To prevent recorded votes from becoming misaligned with edited answers, we'll introduce a publish event for a ballot, [making it non-editable by users](https://github.com/zorn/flick/issues/13).
To prevent recorded votes from becoming misaligned with edited answers, we'll
introduce a publish event for a ballot, [making it non-editable by
users](https://github.com/zorn/flick/issues/13).
68 changes: 50 additions & 18 deletions lib/flick/ballots.ex → lib/flick/ranked_voting.ex
Original file line number Diff line number Diff line change
@@ -1,31 +1,30 @@
defmodule Flick.Ballots do
defmodule Flick.RankedVoting do
@moduledoc """
Provides functions related to managing `Flick.Ballots.Ballot` entities.
Provides functions related to managing `Flick.RankedVoting.Ballot` entities.
"""

alias Flick.Ballots.Ballot
alias Flick.RankedVoting.Ballot
alias Flick.RankedVoting.Vote
alias Flick.Repo

@typep changeset :: Ecto.Changeset.t(Ballot.t())

@doc """
Creates a new `Flick.Ballots.Ballot` entity with the given `title` and `questions`.
Creates a new `Flick.RankedVoting.Ballot` entity with the given `title` and `questions`.
"""
@spec create_ballot(map()) :: {:ok, Ballot.t()} | {:error, changeset()}
@spec create_ballot(map()) :: {:ok, Ballot.t()} | {:error, Ecto.Changeset.t(Ballot.t())}
def create_ballot(attrs) when is_map(attrs) do
%Ballot{}
|> change_ballot(attrs)
|> Repo.insert()
end

@doc """
Updates the given `Flick.Ballots.Ballot` entity with the given attributes.
Updates the given `Flick.RankedVoting.Ballot` entity with the given attributes.
If the `Flick.Ballots.Ballot` has already been published, an error is returned.
If the `Flick.RankedVoting.Ballot` has already been published, an error is returned.
"""
@spec update_ballot(Ballot.t(), map()) ::
{:ok, Ballot.t()}
| {:error, changeset()}
| {:error, Ecto.Changeset.t(Ballot.t())}
| {:error, :can_not_update_published_ballot}
def update_ballot(%Ballot{published_at: published_at}, _attrs)
when not is_nil(published_at) do
Expand All @@ -39,14 +38,14 @@ defmodule Flick.Ballots do
end

@doc """
Publishes the given `Flick.Ballots.Ballot` entity.
Publishes the given `Flick.RankedVoting.Ballot` entity.
Once a `Flick.Ballots.Ballot` entity is published, it can no longer be updated.
Once a `Flick.RankedVoting.Ballot` entity is published, it can no longer be updated.
Only a published ballot can be voted on.
"""
@spec publish_ballot(Ballot.t(), DateTime.t()) ::
{:ok, Ballot.t()}
| {:error, changeset()}
| {:error, Ecto.Changeset.t(Ballot.t())}
| {:error, :ballot_already_published}
def publish_ballot(ballot, published_at \\ DateTime.utc_now())

Expand All @@ -62,7 +61,7 @@ defmodule Flick.Ballots do
end

@doc """
Returns a list of all `Flick.Ballots.Ballot` entities.
Returns a list of all `Flick.RankedVoting.Ballot` entities.
"""
@spec list_ballots() :: [Ballot.t()]
def list_ballots() do
Expand All @@ -73,7 +72,7 @@ defmodule Flick.Ballots do
end

@doc """
Returns a `Flick.Ballots.Ballot` entity for the given id.
Returns a `Flick.RankedVoting.Ballot` entity for the given id.
Raises `Ecto.NoResultsError` if no entity was found.
"""
Expand All @@ -83,7 +82,7 @@ defmodule Flick.Ballots do
end

@doc """
Fetches a `Flick.Ballots.Ballot` entity for the given id.
Fetches a `Flick.RankedVoting.Ballot` entity for the given id.
"""
@spec fetch_ballot(Ballot.id()) :: {:ok, Ballot.t()} | {:error, :ballot_not_found}
def fetch_ballot(ballot_id) do
Expand All @@ -94,10 +93,43 @@ defmodule Flick.Ballots do
end

@doc """
Returns an `Ecto.Changeset` representing changes to a `Flick.Ballots.Ballot` entity.
Returns an `Ecto.Changeset` representing changes to a `Flick.RankedVoting.Ballot` entity.
"""
@spec change_ballot(Ballot.t() | Ballot.struct_t(), map()) :: changeset()
@spec change_ballot(Ballot.t() | Ballot.struct_t(), map()) :: Ecto.Changeset.t(Ballot.t())
def change_ballot(%Ballot{} = ballot, attrs) do
Ballot.changeset(ballot, attrs)
end

@doc """
Records a vote for the given `Flick.RankedVoting.Ballot` entity.
"""
@spec record_vote(Ballot.t(), map()) :: {:ok, Vote.t()} | {:error, Ecto.Changeset.t(Vote.t())}
def record_vote(ballot, attrs) do
attrs = Map.put(attrs, "ballot_id", ballot.id)

%Vote{}
|> Vote.changeset(attrs)
|> Repo.insert()
end

@doc """
Returns an `Ecto.Changeset` representing changes to a `Flick.RankedVoting.Vote`
entity.
## Options
* `:action` - An optional atom applied to the changeset, useful for forms that
look to a changeset's action to influence form behavior.
"""
@spec change_vote(Vote.t() | Vote.struct_t(), map()) :: Ecto.Changeset.t(Vote.t())
def change_vote(%Vote{} = vote, attrs, opts \\ []) do
opts = Keyword.validate!(opts, action: nil)
changeset = Vote.changeset(vote, attrs)

if opts[:action] do
Map.put(changeset, :action, opts[:action])
else
changeset
end
end
end
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
defmodule Flick.Ballots.Ballot do
defmodule Flick.RankedVoting.Ballot do
@moduledoc """
A prompt that will be presented to the user, asking them to provide a ranked
vote of answers to help make a group decision.
Expand All @@ -14,7 +14,7 @@ defmodule Flick.Ballots.Ballot do
@type id :: Ecto.UUID.t()

@typedoc """
A type for a persisted `Flick.Ballots.Ballot` entity.
A type for a persisted `Flick.RankedVoting.Ballot` entity.
"""
@type t :: %__MODULE__{
id: Ecto.UUID.t(),
Expand All @@ -24,10 +24,10 @@ defmodule Flick.Ballots.Ballot do
}

@typedoc """
A type for the empty `Flick.Ballots.Ballot` struct.
A type for the empty `Flick.RankedVoting.Ballot` struct.
This type is helpful when you want to typespec a function that needs to accept
a non-persisted `Flick.Ballots.Ballot` struct value.
a non-persisted `Flick.RankedVoting.Ballot` struct value.
"""
@type struct_t :: %__MODULE__{}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
defmodule Flick.Votes.RankedAnswer do
defmodule Flick.RankedVoting.RankedAnswer do
@moduledoc """
An embedded value that represents a ranked answer to a question of a ballot.
"""
Expand Down
12 changes: 6 additions & 6 deletions lib/flick/votes/vote.ex → lib/flick/ranked_voting/vote.ex
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
defmodule Flick.Votes.Vote do
defmodule Flick.RankedVoting.Vote do
@moduledoc """
A response to a `Flick.Ballots.Ballot`. A vote contains a collection of ranked
A response to a `Flick.RankedVoting.Ballot`. A vote contains a collection of ranked
answers for a ballot question.
"""

Expand All @@ -9,13 +9,13 @@ defmodule Flick.Votes.Vote do
import Ecto.Changeset
import FlickWeb.Gettext

alias Flick.Ballots.Ballot
alias Flick.Votes.RankedAnswer
alias Flick.RankedVoting.Ballot
alias Flick.RankedVoting.RankedAnswer

@type id :: Ecto.UUID.t()

@typedoc """
A type for a persisted `Flick.Votes.Vote` entity.
A type for a persisted `Flick.RankedVoting.Vote` entity.
"""
@type t :: %__MODULE__{
id: id(),
Expand Down Expand Up @@ -52,7 +52,7 @@ defmodule Flick.Votes.Vote do
end

defp validate_ranked_answers_are_present_in_ballot(changeset) do
ballot = Flick.Ballots.get_ballot!(get_field(changeset, :ballot_id))
ballot = Flick.RankedVoting.get_ballot!(get_field(changeset, :ballot_id))
possible_answers = Ballot.possible_answers_as_list(ballot.possible_answers) ++ ["", nil]

validate_change(changeset, :ranked_answers, fn :ranked_answers, new_ranked_answers ->
Expand Down
45 changes: 0 additions & 45 deletions lib/flick/votes.ex

This file was deleted.

Loading

0 comments on commit 4c46b39

Please sign in to comment.