From ba6a0b2c179c9596101b3ae6739602842c28100a Mon Sep 17 00:00:00 2001 From: Nikaidou Haruki Date: Sat, 4 Jul 2026 19:27:17 +0900 Subject: [PATCH] oneshot --- config/dev.exs | 19 +- config/test.exs | 47 +- lib/rsh26pool/application.ex | 4 +- lib/rsh26pool/jst.ex | 52 ++ lib/rsh26pool/room_closer.ex | 45 ++ lib/rsh26pool/voting.ex | 523 ++++++++++++++++++ lib/rsh26pool/voting/hungarian.ex | 114 ++++ lib/rsh26pool/voting/idea.ex | 27 + lib/rsh26pool/voting/participant.ex | 37 ++ lib/rsh26pool/voting/room.ex | 81 +++ lib/rsh26pool/voting/vote.ex | 29 + lib/rsh26pool_web/auth.ex | 99 ++++ lib/rsh26pool_web/components/layouts.ex | 58 +- .../components/layouts/root.html.heex | 4 +- .../controllers/page_html/home.html.heex | 262 +++------ .../controllers/room_controller.ex | 89 +++ lib/rsh26pool_web/controllers/room_html.ex | 8 + .../controllers/room_html/enter.html.heex | 40 ++ .../controllers/room_html/new.html.heex | 65 +++ lib/rsh26pool_web/live/manage_live.ex | 237 ++++++++ lib/rsh26pool_web/live/vote_live.ex | 367 ++++++++++++ lib/rsh26pool_web/router.ex | 19 + .../20260704092338_create_voting_tables.exs | 58 ++ test/rsh26pool/jst_test.exs | 61 ++ test/rsh26pool/voting/hungarian_test.exs | 92 +++ test/rsh26pool/voting_test.exs | 425 ++++++++++++++ .../controllers/page_controller_test.exs | 10 +- .../controllers/room_controller_test.exs | 92 +++ test/rsh26pool_web/live/manage_live_test.exs | 94 ++++ test/rsh26pool_web/live/vote_live_test.exs | 124 +++++ 30 files changed, 2935 insertions(+), 247 deletions(-) create mode 100644 lib/rsh26pool/jst.ex create mode 100644 lib/rsh26pool/room_closer.ex create mode 100644 lib/rsh26pool/voting.ex create mode 100644 lib/rsh26pool/voting/hungarian.ex create mode 100644 lib/rsh26pool/voting/idea.ex create mode 100644 lib/rsh26pool/voting/participant.ex create mode 100644 lib/rsh26pool/voting/room.ex create mode 100644 lib/rsh26pool/voting/vote.ex create mode 100644 lib/rsh26pool_web/auth.ex create mode 100644 lib/rsh26pool_web/controllers/room_controller.ex create mode 100644 lib/rsh26pool_web/controllers/room_html.ex create mode 100644 lib/rsh26pool_web/controllers/room_html/enter.html.heex create mode 100644 lib/rsh26pool_web/controllers/room_html/new.html.heex create mode 100644 lib/rsh26pool_web/live/manage_live.ex create mode 100644 lib/rsh26pool_web/live/vote_live.ex create mode 100644 priv/repo/migrations/20260704092338_create_voting_tables.exs create mode 100644 test/rsh26pool/jst_test.exs create mode 100644 test/rsh26pool/voting/hungarian_test.exs create mode 100644 test/rsh26pool/voting_test.exs create mode 100644 test/rsh26pool_web/controllers/room_controller_test.exs create mode 100644 test/rsh26pool_web/live/manage_live_test.exs create mode 100644 test/rsh26pool_web/live/vote_live_test.exs diff --git a/config/dev.exs b/config/dev.exs index 82991af..9ed00ea 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -1,15 +1,24 @@ import Config -# Configure your database +# Configure your database. +# +# The connection is read from the DATABASE_URL environment variable (see .env) +# when present, otherwise it falls back to a local Postgres instance. config :rsh26pool, Rsh26pool.Repo, - username: "postgres", - password: "postgres", - hostname: "localhost", - database: "rsh26pool_dev", stacktrace: true, show_sensitive_data_on_connection_error: true, pool_size: 10 +if url = System.get_env("DATABASE_URL") do + config :rsh26pool, Rsh26pool.Repo, url: url +else + config :rsh26pool, Rsh26pool.Repo, + username: "postgres", + password: "postgres", + hostname: "localhost", + database: "rsh26pool_dev" +end + # For development, we disable any cache and enable # debugging and code reloading. # diff --git a/config/test.exs b/config/test.exs index 1928560..bc82e47 100644 --- a/config/test.exs +++ b/config/test.exs @@ -5,13 +5,46 @@ import Config # The MIX_TEST_PARTITION environment variable can be used # to provide built-in test partitioning in CI environment. # Run `mix help test` for more information. -config :rsh26pool, Rsh26pool.Repo, - username: "postgres", - password: "postgres", - hostname: "localhost", - database: "rsh26pool_test#{System.get_env("MIX_TEST_PARTITION")}", - pool: Ecto.Adapters.SQL.Sandbox, - pool_size: System.schedulers_online() * 2 +# +# When DATABASE_URL is set (see .env) we reuse its server but connect to a +# dedicated "_test" database so tests never touch development data. +partition = System.get_env("MIX_TEST_PARTITION") + +if url = System.get_env("DATABASE_URL") do + uri = URI.parse(url) + + {username, password} = + case uri.userinfo do + nil -> + {"postgres", "postgres"} + + info -> + case String.split(info, ":", parts: 2) do + [u, p] -> {u, p} + [u] -> {u, nil} + end + end + + base = uri.path |> to_string() |> String.trim_leading("/") + base = if base == "", do: "rsh26pool", else: base + + config :rsh26pool, Rsh26pool.Repo, + username: username, + password: password, + hostname: uri.host || "localhost", + port: uri.port || 5432, + database: "#{base}_test#{partition}", + pool: Ecto.Adapters.SQL.Sandbox, + pool_size: System.schedulers_online() * 2 +else + config :rsh26pool, Rsh26pool.Repo, + username: "postgres", + password: "postgres", + hostname: "localhost", + database: "rsh26pool_test#{partition}", + pool: Ecto.Adapters.SQL.Sandbox, + pool_size: System.schedulers_online() * 2 +end # We don't run a server during test. If one is required, # you can enable the server option below. diff --git a/lib/rsh26pool/application.ex b/lib/rsh26pool/application.ex index 02651bb..c6e26e2 100644 --- a/lib/rsh26pool/application.ex +++ b/lib/rsh26pool/application.ex @@ -12,8 +12,8 @@ defmodule Rsh26pool.Application do Rsh26pool.Repo, {DNSCluster, query: Application.get_env(:rsh26pool, :dns_cluster_query) || :ignore}, {Phoenix.PubSub, name: Rsh26pool.PubSub}, - # Start a worker by calling: Rsh26pool.Worker.start_link(arg) - # {Rsh26pool.Worker, arg}, + # Periodically closes rooms that have reached their auto-end time. + Rsh26pool.RoomCloser, # Start to serve requests, typically the last entry Rsh26poolWeb.Endpoint ] diff --git a/lib/rsh26pool/jst.ex b/lib/rsh26pool/jst.ex new file mode 100644 index 0000000..c49d615 --- /dev/null +++ b/lib/rsh26pool/jst.ex @@ -0,0 +1,52 @@ +defmodule Rsh26pool.JST do + @moduledoc """ + Helpers for converting between UTC (how timestamps are stored) and + Japan Standard Time (JST, UTC+9) which is what the UI shows and accepts. + + A full time zone database is not configured for this demo, and JST has no + daylight saving time, so a fixed +9h offset is sufficient and dependency free. + """ + + @offset_seconds 9 * 3600 + + @doc """ + Parses a `datetime-local` form value (e.g. `"2026-07-04T15:30"`), interpreting + it as wall-clock JST, and returns the corresponding UTC `DateTime`. + """ + @spec parse_local(String.t()) :: {:ok, DateTime.t()} | :error + def parse_local(value) when is_binary(value) do + with {:ok, naive} <- NaiveDateTime.from_iso8601(ensure_seconds(value)) do + utc = + naive + |> DateTime.from_naive!("Etc/UTC") + |> DateTime.add(-@offset_seconds, :second) + |> DateTime.truncate(:second) + + {:ok, utc} + else + _ -> :error + end + end + + def parse_local(_), do: :error + + @doc "Converts a stored UTC `DateTime` to a JST `DateTime` for display." + @spec to_local(DateTime.t()) :: DateTime.t() + def to_local(%DateTime{} = dt), do: DateTime.add(dt, @offset_seconds, :second) + + @doc "Formats a stored UTC `DateTime` as a human friendly JST string." + @spec format(DateTime.t() | nil) :: String.t() + def format(nil), do: "" + + def format(%DateTime{} = dt) do + l = to_local(dt) + + "~4..0B/~2..0B/~2..0B ~2..0B:~2..0B" + |> :io_lib.format([l.year, l.month, l.day, l.hour, l.minute]) + |> to_string() + end + + defp ensure_seconds(value) do + if Regex.match?(~r/T\d{2}:\d{2}$/, value), do: value <> ":00", else: value + end +end diff --git a/lib/rsh26pool/room_closer.ex b/lib/rsh26pool/room_closer.ex new file mode 100644 index 0000000..6e09463 --- /dev/null +++ b/lib/rsh26pool/room_closer.ex @@ -0,0 +1,45 @@ +defmodule Rsh26pool.RoomCloser do + @moduledoc """ + Periodically closes rooms whose `closes_at` deadline has passed (the "auto + end" feature). Closing broadcasts to any connected LiveViews so open pages + transition to the results/grouping view without a manual refresh. + """ + use GenServer + + require Logger + + alias Rsh26pool.Voting + + @interval :timer.seconds(10) + + def start_link(opts) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + @impl true + def init(_opts) do + schedule() + {:ok, %{}} + end + + @impl true + def handle_info(:sweep, state) do + sweep() + schedule() + {:noreply, state} + end + + defp sweep do + for room <- Voting.list_rooms_to_auto_close() do + case Voting.close_room(room) do + {:ok, _} -> + :ok + + other -> + Logger.warning("RoomCloser failed to close room #{room.id}: #{inspect(other)}") + end + end + end + + defp schedule, do: Process.send_after(self(), :sweep, @interval) +end diff --git a/lib/rsh26pool/voting.ex b/lib/rsh26pool/voting.ex new file mode 100644 index 0000000..8b6e716 --- /dev/null +++ b/lib/rsh26pool/voting.ex @@ -0,0 +1,523 @@ +defmodule Rsh26pool.Voting do + @moduledoc """ + The Voting context: rooms, participants, ideas/opinions, votes, results and + (in grouping mode) team assignment via the Hungarian algorithm. + + Timestamps are stored in UTC. Real-time updates are published over + `Phoenix.PubSub`; interested LiveViews call `subscribe/1` and handle + `{:voting, event}` messages. + """ + + import Ecto.Query, warn: false + + alias Rsh26pool.Repo + alias Rsh26pool.Voting.{Hungarian, Idea, Participant, Room, Vote} + + @pubsub Rsh26pool.PubSub + + ## Real-time ------------------------------------------------------------- + + @doc "Subscribe the calling process to a room's update stream." + def subscribe(room_id) do + Phoenix.PubSub.subscribe(@pubsub, topic(room_id)) + end + + defp broadcast(room_id, event) do + Phoenix.PubSub.broadcast(@pubsub, topic(room_id), {:voting, event}) + end + + defp topic(room_id), do: "room:#{room_id}" + + ## Rooms ---------------------------------------------------------------- + + @doc "Builds a creation changeset for the room settings form." + def change_room_creation(%Room{} = room \\ %Room{}, attrs \\ %{}) do + Room.create_changeset(room, attrs) + end + + @doc "Builds a settings changeset for the manage page rename form." + def change_room_settings(%Room{} = room, attrs \\ %{}) do + Room.settings_changeset(room, attrs) + end + + @doc """ + Creates a room from the settings form and its owner participant. + + Returns `{:ok, %{room: room, owner: owner}}` or `{:error, changeset}`. + """ + def create_room(attrs) do + changeset = Room.create_changeset(%Room{}, attrs) + + if changeset.valid? do + changeset = Ecto.Changeset.put_change(changeset, :room_number, unique_room_number()) + + Repo.transaction(fn -> + room = Repo.insert!(changeset) + + owner = + Repo.insert!(%Participant{ + room_id: room.id, + role: "owner", + name: "オーナー", + token: generate_token() + }) + + %{room: room, owner: owner} + end) + else + {:error, %{changeset | action: :insert}} + end + end + + def get_room_by_number(number) when is_binary(number) do + Repo.get_by(Room, room_number: number) + end + + def get_room_by_number(_), do: nil + + def get_room!(id), do: Repo.get!(Room, id) + + def reload_room(%Room{id: id}), do: Repo.get(Room, id) + + @doc "Updates owner-editable settings (currently the room name)." + def update_room_settings(%Room{} = room, attrs) do + room + |> Room.settings_changeset(attrs) + |> Repo.update() + |> case do + {:ok, room} = ok -> + broadcast(room.id, :room_updated) + ok + + other -> + other + end + end + + @doc """ + The status a room should be treated as *right now*, accounting for a passed + `closes_at` deadline even if the background closer has not run yet. + """ + def effective_status(%Room{status: "closed"}), do: "closed" + def effective_status(%Room{closes_at: nil}), do: "open" + + def effective_status(%Room{closes_at: closes_at}) do + if DateTime.compare(closes_at, DateTime.utc_now()) != :gt, do: "closed", else: "open" + end + + def room_open?(%Room{} = room), do: effective_status(room) == "open" + + @doc """ + Closes a room: marks it closed and, in grouping mode, computes the team + assignments (idempotent — safe to call more than once). + """ + def close_room(%Room{} = room) do + room = Repo.get!(Room, room.id) + + if room.status == "closed" do + {:ok, room} + else + {:ok, closed} = + room + |> Ecto.Changeset.change(status: "closed") + |> Repo.update() + + closed = if closed.grouping_mode, do: compute_grouping(closed), else: closed + broadcast(closed.id, :room_updated) + {:ok, closed} + end + end + + @doc """ + Ensures a room past its deadline is actually closed. Returns the (possibly + updated) room. Used lazily by pages so viewers never see a stale open room. + """ + def ensure_closed(%Room{} = room) do + if room.status == "open" and effective_status(room) == "closed" do + {:ok, closed} = close_room(room) + closed + else + room + end + end + + @doc "Lists rooms that are open but past their auto-close deadline." + def list_rooms_to_auto_close do + now = DateTime.utc_now() + + Repo.all( + from r in Room, + where: r.status == "open" and not is_nil(r.closes_at) and r.closes_at <= ^now + ) + end + + ## Participants --------------------------------------------------------- + + @doc "Changeset for the join form." + def change_participant(%Participant{} = participant \\ %Participant{}, attrs \\ %{}) do + Participant.changeset(participant, attrs) + end + + @doc """ + Joins a room as a member, generating an auth token. + + Returns `{:ok, participant}` or `{:error, changeset}`. + """ + def join_room(%Room{} = room, attrs) do + %Participant{room_id: room.id, role: "member", token: generate_token()} + |> Participant.changeset(attrs) + |> Repo.insert() + end + + def get_participant_by_token(nil), do: nil + + def get_participant_by_token(token) when is_binary(token) do + Repo.one(from p in Participant, where: p.token == ^token, preload: [:room]) + end + + def list_participants(%Room{} = room) do + Repo.all(from p in Participant, where: p.room_id == ^room.id, order_by: [asc: p.id]) + end + + def count_participants(%Room{} = room) do + Repo.aggregate(from(p in Participant, where: p.room_id == ^room.id), :count) + end + + ## Ideas ---------------------------------------------------------------- + + def change_idea(%Idea{} = idea \\ %Idea{}, attrs \\ %{}) do + Idea.changeset(idea, attrs) + end + + @doc """ + Posts an idea/opinion. In grouping mode each participant has a single opinion, + so an existing one is updated instead of inserting a second. + """ + def create_idea(%Room{} = room, %Participant{} = participant, attrs) do + cond do + not room_open?(room) -> + {:error, :closed} + + room.grouping_mode -> + case get_participant_idea(room, participant) do + nil -> do_insert_idea(room, participant, attrs) + existing -> do_update_idea(room, existing, attrs) + end + + true -> + do_insert_idea(room, participant, attrs) + end + end + + defp do_insert_idea(room, participant, attrs) do + %Idea{room_id: room.id, participant_id: participant.id} + |> Idea.changeset(attrs) + |> Repo.insert() + |> tap_broadcast(room, :ideas_changed) + end + + defp do_update_idea(room, idea, attrs) do + idea + |> Idea.changeset(attrs) + |> Repo.update() + |> tap_broadcast(room, :ideas_changed) + end + + def get_participant_idea(%Room{} = room, %Participant{} = participant) do + Repo.one( + from i in Idea, + where: i.room_id == ^room.id and i.participant_id == ^participant.id, + order_by: [asc: i.id], + limit: 1 + ) + end + + def list_ideas(%Room{} = room) do + Repo.all( + from i in Idea, + where: i.room_id == ^room.id, + order_by: [asc: i.id], + preload: [:participant] + ) + end + + @doc """ + Ideas ordered by a per-viewer deterministic shuffle. Order is stable for a + given `seed` (so it does not jump around on live updates) but differs between + viewers, satisfying the "shuffled choices" requirement. + """ + def shuffled_ideas(%Room{} = room, seed) do + room + |> list_ideas() + |> Enum.sort_by(fn idea -> :erlang.phash2({seed, idea.id}) end) + end + + ## Votes ---------------------------------------------------------------- + + @doc """ + Casts a vote for `idea_id` by `participant`, enforcing the room's per-user + vote limit, one-vote-per-idea, and that the room is still open. + """ + def vote(%Participant{} = participant, idea_id) do + room = Repo.get!(Room, participant.room_id) + idea = Repo.get_by(Idea, id: idea_id, room_id: room.id) + + cond do + not room_open?(room) -> {:error, :closed} + is_nil(idea) -> {:error, :not_found} + voted?(participant, idea.id) -> {:error, :already_voted} + votes_used(participant) >= room.votes_per_user -> {:error, :limit_reached} + true -> insert_vote(room, participant, idea) + end + end + + defp insert_vote(room, participant, idea) do + %Vote{} + |> Vote.changeset(%{idea_id: idea.id, participant_id: participant.id}) + |> Repo.insert() + |> case do + {:ok, vote} -> + broadcast(room.id, :votes_changed) + {:ok, vote} + + {:error, changeset} -> + {:error, changeset} + end + end + + @doc "Removes a participant's vote for an idea, if the room is still open." + def unvote(%Participant{} = participant, idea_id) do + room = Repo.get!(Room, participant.room_id) + + cond do + not room_open?(room) -> + {:error, :closed} + + true -> + case Repo.get_by(Vote, idea_id: idea_id, participant_id: participant.id) do + nil -> + {:error, :not_found} + + vote -> + {:ok, _} = Repo.delete(vote) + broadcast(room.id, :votes_changed) + :ok + end + end + end + + def voted?(%Participant{} = participant, idea_id) do + Repo.exists?( + from v in Vote, where: v.participant_id == ^participant.id and v.idea_id == ^idea_id + ) + end + + def votes_used(%Participant{} = participant) do + Repo.aggregate(from(v in Vote, where: v.participant_id == ^participant.id), :count) + end + + def votes_remaining(%Room{} = room, %Participant{} = participant) do + max(room.votes_per_user - votes_used(participant), 0) + end + + @doc "Set of idea ids the participant has voted for (for UI highlighting)." + def voted_idea_ids(%Participant{} = participant) do + Repo.all(from v in Vote, where: v.participant_id == ^participant.id, select: v.idea_id) + |> MapSet.new() + end + + ## Results -------------------------------------------------------------- + + @doc "Map of `idea_id => vote_count` for a room." + def vote_counts(%Room{} = room) do + Repo.all( + from v in Vote, + join: i in Idea, + on: i.id == v.idea_id, + where: i.room_id == ^room.id, + group_by: v.idea_id, + select: {v.idea_id, count(v.id)} + ) + |> Map.new() + end + + @doc "Ideas with their vote counts, highest first (for the manage page)." + def results(%Room{} = room) do + counts = vote_counts(room) + + room + |> list_ideas() + |> Enum.map(fn idea -> %{idea: idea, count: Map.get(counts, idea.id, 0)} end) + |> Enum.sort_by(& &1.count, :desc) + end + + ## Grouping mode -------------------------------------------------------- + + @doc """ + Returns the computed grouping as `%{groups: [...], ungrouped: [...]}` where + each group is `%{leader: participant, opinion: idea, members: [participant]}`. + Only meaningful after a grouping-mode room has been closed. + """ + def group_assignments(%Room{} = room) do + members = room |> list_participants() |> Enum.filter(&(&1.role == "member")) + counts = vote_counts(room) + idea_by_participant = idea_by_participant(room) + + leaders = + members + |> Enum.filter(& &1.is_leader) + |> Enum.sort_by(&(-opinion_votes(&1, idea_by_participant, counts))) + + groups = + Enum.map(leaders, fn leader -> + group_members = + Enum.filter(members, fn p -> + not p.is_leader and p.assigned_leader_id == leader.id + end) + + %{ + leader: leader, + opinion: Map.get(idea_by_participant, leader.id), + members: group_members + } + end) + + ungrouped = + Enum.filter(members, fn p -> not p.is_leader and is_nil(p.assigned_leader_id) end) + + %{groups: groups, ungrouped: ungrouped} + end + + defp compute_grouping(room) do + members = room |> list_participants() |> Enum.filter(&(&1.role == "member")) + counts = vote_counts(room) + idea_by_participant = idea_by_participant(room) + + leaders = + Enum.filter(members, fn p -> + Map.has_key?(idea_by_participant, p.id) and + opinion_votes(p, idea_by_participant, counts) >= room.leader_threshold + end) + + regular = members -- leaders + + Repo.transaction(fn -> + if leaders == [] do + Enum.each(members, &set_group(&1, false, nil)) + else + Enum.each(leaders, &set_group(&1, true, &1.id)) + + regular + |> assign_regular_members(leaders, room, idea_by_participant) + |> Enum.each(fn {member, leader_id} -> set_group(member, false, leader_id) end) + end + + now = DateTime.utc_now() |> DateTime.truncate(:second) + Repo.update!(Ecto.Changeset.change(room, grouped_at: now)) + end) + |> case do + {:ok, updated_room} -> updated_room + end + end + + # Assigns non-leader members to leader groups minimising unmet preferences via + # the Hungarian algorithm. Returns `[{member, leader_id}]`. + defp assign_regular_members([], _leaders, _room, _idea_by_participant), do: [] + + defp assign_regular_members(regular, leaders, room, idea_by_participant) do + m = length(regular) + l = length(leaders) + base = div(m, l) + remainder = rem(m, l) + + # Balanced capacity per leader; expanded into one slot per capacity unit. + caps = Enum.map(0..(l - 1), fn i -> base + if(i < remainder, do: 1, else: 0) end) + + slot_leaders = + Enum.flat_map(Enum.zip(leaders, caps), fn {leader, cap} -> + List.duplicate(leader, cap) + end) + + voted_by = votes_by_participant(room) + + cost = + Enum.map(regular, fn member -> + voted = Map.get(voted_by, member.id, MapSet.new()) + + Enum.map(slot_leaders, fn leader -> + opinion = Map.get(idea_by_participant, leader.id) + + if opinion && MapSet.member?(voted, opinion.id), do: 0, else: 1 + end) + end) + + {_total, assignment} = Hungarian.min_cost_assignment(cost) + + regular + |> Enum.zip(assignment) + |> Enum.map(fn {member, slot_index} -> + leader = Enum.at(slot_leaders, slot_index) + {member, leader.id} + end) + end + + defp set_group(participant, is_leader, leader_id) do + participant + |> Ecto.Changeset.change(is_leader: is_leader, assigned_leader_id: leader_id) + |> Repo.update!() + end + + defp idea_by_participant(room) do + room + |> list_ideas() + |> Map.new(fn idea -> {idea.participant_id, idea} end) + end + + defp opinion_votes(participant, idea_by_participant, counts) do + case Map.get(idea_by_participant, participant.id) do + nil -> 0 + idea -> Map.get(counts, idea.id, 0) + end + end + + defp votes_by_participant(room) do + Repo.all( + from v in Vote, + join: i in Idea, + on: i.id == v.idea_id, + where: i.room_id == ^room.id, + select: {v.participant_id, v.idea_id} + ) + |> Enum.group_by(&elem(&1, 0), &elem(&1, 1)) + |> Map.new(fn {participant_id, idea_ids} -> {participant_id, MapSet.new(idea_ids)} end) + end + + ## Helpers -------------------------------------------------------------- + + defp generate_token do + :crypto.strong_rand_bytes(24) |> Base.url_encode64(padding: false) + end + + defp unique_room_number(attempts \\ 0) + + defp unique_room_number(attempts) when attempts < 20 do + number = Integer.to_string(:rand.uniform(900_000) + 99_999) + + if Repo.exists?(from r in Room, where: r.room_number == ^number) do + unique_room_number(attempts + 1) + else + number + end + end + + defp unique_room_number(_attempts) do + raise "could not allocate a unique room number" + end + + defp tap_broadcast({:ok, _} = result, room, event) do + broadcast(room.id, event) + result + end + + defp tap_broadcast(other, _room, _event), do: other +end diff --git a/lib/rsh26pool/voting/hungarian.ex b/lib/rsh26pool/voting/hungarian.ex new file mode 100644 index 0000000..1fcd6b6 --- /dev/null +++ b/lib/rsh26pool/voting/hungarian.ex @@ -0,0 +1,114 @@ +defmodule Rsh26pool.Voting.Hungarian do + @moduledoc """ + Solves the assignment problem — a minimum-cost perfect matching in a weighted + bipartite graph — with the Hungarian (Kuhn–Munkres) algorithm in O(n^3). + + This is the algorithm the proposal calls for when placing members into groups + in grouping mode: given a cost of assigning each member to each group slot, it + finds the assignment with the lowest total cost. + + `min_cost_assignment/1` takes an `n x m` cost matrix (a list of `n` rows, each + a list of `m` numbers) with `n <= m`, and returns `{total_cost, assignment}` + where `assignment` is a list of length `n` and `Enum.at(assignment, i)` is the + 0-based column matched to row `i`. + """ + + @inf 1_000_000_000 + + @spec min_cost_assignment([[number()]]) :: {number(), [non_neg_integer()]} + def min_cost_assignment([]), do: {0, []} + + def min_cost_assignment(cost) when is_list(cost) do + n = length(cost) + m = length(hd(cost)) + + if n > m do + raise ArgumentError, "cost matrix must have rows <= cols (got #{n} x #{m})" + end + + # 1-indexed access into an immutable tuple-of-tuples. + c = cost |> Enum.map(&List.to_tuple/1) |> List.to_tuple() + cost_at = fn i, j -> c |> elem(i - 1) |> elem(j - 1) end + + u = fill(n + 1, 0) + v = fill(m + 1, 0) + p = fill(m + 1, 0) + way = fill(m + 1, 0) + + {_u, _v, p, _way} = + Enum.reduce(1..n, {u, v, p, way}, fn i, {u, v, p, way} -> + phase(i, m, cost_at, u, v, p, way) + end) + + # p[j] holds the row matched to column j; invert to get column-per-row. + row_to_col = + Enum.reduce(1..m, %{}, fn j, acc -> + row = elem(p, j) + if row >= 1 and row <= n, do: Map.put(acc, row, j - 1), else: acc + end) + + assignment = Enum.map(1..n, &Map.fetch!(row_to_col, &1)) + + total = + assignment + |> Enum.with_index(1) + |> Enum.reduce(0, fn {col0, i}, sum -> sum + cost_at.(i, col0 + 1) end) + + {total, assignment} + end + + # Augmenting phase for row `i` (see the classic e-maxx Hungarian description). + defp phase(i, m, cost_at, u, v, p, way) do + p = put_elem(p, 0, i) + minv = fill(m + 1, @inf) + used = fill(m + 1, false) + walk(0, m, cost_at, u, v, p, way, minv, used) + end + + defp walk(j0, m, cost_at, u, v, p, way, minv, used) do + used = put_elem(used, j0, true) + i0 = elem(p, j0) + ui0 = elem(u, i0) + + {delta, j1, minv, way} = + Enum.reduce(1..m, {@inf, -1, minv, way}, fn j, {delta, j1, minv, way} -> + if elem(used, j) do + {delta, j1, minv, way} + else + cur = cost_at.(i0, j) - ui0 - elem(v, j) + + {minv, way} = + if cur < elem(minv, j), + do: {put_elem(minv, j, cur), put_elem(way, j, j0)}, + else: {minv, way} + + mvj = elem(minv, j) + if mvj < delta, do: {mvj, j, minv, way}, else: {delta, j1, minv, way} + end + end) + + {u, v, minv} = + Enum.reduce(0..m, {u, v, minv}, fn j, {u, v, minv} -> + if elem(used, j) do + pj = elem(p, j) + {put_elem(u, pj, elem(u, pj) + delta), put_elem(v, j, elem(v, j) - delta), minv} + else + {u, v, put_elem(minv, j, elem(minv, j) - delta)} + end + end) + + if elem(p, j1) == 0 do + {u, v, reconstruct(j1, p, way), way} + else + walk(j1, m, cost_at, u, v, p, way, minv, used) + end + end + + defp reconstruct(j0, p, way) do + j1 = elem(way, j0) + p = put_elem(p, j0, elem(p, j1)) + if j1 == 0, do: p, else: reconstruct(j1, p, way) + end + + defp fill(size, value), do: value |> List.duplicate(size) |> List.to_tuple() +end diff --git a/lib/rsh26pool/voting/idea.ex b/lib/rsh26pool/voting/idea.ex new file mode 100644 index 0000000..5cb2fef --- /dev/null +++ b/lib/rsh26pool/voting/idea.ex @@ -0,0 +1,27 @@ +defmodule Rsh26pool.Voting.Idea do + @moduledoc """ + A voting option. In normal mode it is an idea posted by a participant; in + grouping mode it is the participant's opinion (one per participant). + """ + use Ecto.Schema + import Ecto.Changeset + + alias Rsh26pool.Voting.{Participant, Room, Vote} + + schema "ideas" do + field :text, :string + + belongs_to :room, Room + belongs_to :participant, Participant + has_many :votes, Vote + + timestamps(type: :utc_datetime) + end + + def changeset(idea, attrs) do + idea + |> cast(attrs, [:text]) + |> validate_required([:text], message: "内容を入力してください") + |> validate_length(:text, max: 200, message: "200文字以内で入力してください") + end +end diff --git a/lib/rsh26pool/voting/participant.ex b/lib/rsh26pool/voting/participant.ex new file mode 100644 index 0000000..015e83a --- /dev/null +++ b/lib/rsh26pool/voting/participant.ex @@ -0,0 +1,37 @@ +defmodule Rsh26pool.Voting.Participant do + @moduledoc """ + A user taking part in a room. Identified across requests by a random `token` + stored in the session cookie. Owners have `role == "owner"`. + """ + use Ecto.Schema + import Ecto.Changeset + + alias Rsh26pool.Voting.Room + + schema "participants" do + field :token, :string + field :name, :string + field :role, :string, default: "member" + + # Grouping-mode results, filled in when voting closes. + field :is_leader, :boolean, default: false + + belongs_to :room, Room + belongs_to :assigned_leader, __MODULE__, foreign_key: :assigned_leader_id + + timestamps(type: :utc_datetime) + end + + @doc "Changeset for the user-supplied fields (their display name)." + def changeset(participant, attrs) do + participant + |> cast(attrs, [:name]) + |> validate_required([:name], message: "名前を入力してください") + |> validate_length(:name, max: 40, message: "名前は40文字以内で入力してください") + |> unique_constraint(:token) + end + + @doc "Owner? convenience predicate." + def owner?(%__MODULE__{role: "owner"}), do: true + def owner?(%__MODULE__{}), do: false +end diff --git a/lib/rsh26pool/voting/room.ex b/lib/rsh26pool/voting/room.ex new file mode 100644 index 0000000..43ce21d --- /dev/null +++ b/lib/rsh26pool/voting/room.ex @@ -0,0 +1,81 @@ +defmodule Rsh26pool.Voting.Room do + @moduledoc """ + A voting room. Identified publicly by a short `room_number` used in URLs. + """ + use Ecto.Schema + import Ecto.Changeset + + alias Rsh26pool.Voting.{Idea, Participant} + + schema "rooms" do + field :room_number, :string + field :name, :string + field :votes_per_user, :integer, default: 3 + field :status, :string, default: "open" + field :grouping_mode, :boolean, default: false + field :leader_threshold, :integer, default: 2 + field :closes_at, :utc_datetime + field :grouped_at, :utc_datetime + + # Virtual field backing the `datetime-local` input (wall-clock JST). + field :closes_at_local, :string, virtual: true + + has_many :participants, Participant + has_many :ideas, Idea + + timestamps(type: :utc_datetime) + end + + @doc "Changeset used when the owner creates a new room." + def create_changeset(room, attrs) do + room + |> cast(attrs, [ + :name, + :votes_per_user, + :grouping_mode, + :leader_threshold, + :closes_at_local + ]) + |> validate_required([:name, :votes_per_user], message: "入力してください") + |> validate_length(:name, max: 80, message: "80文字以内で入力してください") + |> validate_number(:votes_per_user, + greater_than_or_equal_to: 1, + less_than_or_equal_to: 50, + message: "1〜50の数値を入力してください" + ) + |> validate_number(:leader_threshold, + greater_than_or_equal_to: 1, + less_than_or_equal_to: 50, + message: "1〜50の数値を入力してください" + ) + |> put_closes_at() + end + + @doc "Changeset for owner-editable settings on the manage page." + def settings_changeset(room, attrs) do + room + |> cast(attrs, [:name]) + |> validate_required([:name], message: "入力してください") + |> validate_length(:name, max: 80, message: "80文字以内で入力してください") + end + + defp put_closes_at(changeset) do + case get_field(changeset, :closes_at_local) do + blank when blank in [nil, ""] -> + put_change(changeset, :closes_at, nil) + + value -> + case Rsh26pool.JST.parse_local(value) do + {:ok, dt} -> + if DateTime.compare(dt, DateTime.utc_now()) == :gt do + put_change(changeset, :closes_at, dt) + else + add_error(changeset, :closes_at_local, "未来の日時を指定してください") + end + + :error -> + add_error(changeset, :closes_at_local, "日時の形式が正しくありません") + end + end + end +end diff --git a/lib/rsh26pool/voting/vote.ex b/lib/rsh26pool/voting/vote.ex new file mode 100644 index 0000000..902310e --- /dev/null +++ b/lib/rsh26pool/voting/vote.ex @@ -0,0 +1,29 @@ +defmodule Rsh26pool.Voting.Vote do + @moduledoc """ + A single vote cast by a participant for an idea. A participant may vote for a + given idea at most once (enforced by a unique index). + """ + use Ecto.Schema + import Ecto.Changeset + + alias Rsh26pool.Voting.{Idea, Participant} + + schema "votes" do + belongs_to :idea, Idea + belongs_to :participant, Participant + + timestamps(type: :utc_datetime) + end + + def changeset(vote, attrs) do + vote + |> cast(attrs, [:idea_id, :participant_id]) + |> validate_required([:idea_id, :participant_id]) + |> unique_constraint([:idea_id, :participant_id], + name: :votes_idea_id_participant_id_index, + message: "は既に投票済みです" + ) + |> foreign_key_constraint(:idea_id) + |> foreign_key_constraint(:participant_id) + end +end diff --git a/lib/rsh26pool_web/auth.ex b/lib/rsh26pool_web/auth.ex new file mode 100644 index 0000000..04d7f0c --- /dev/null +++ b/lib/rsh26pool_web/auth.ex @@ -0,0 +1,99 @@ +defmodule Rsh26poolWeb.Auth do + @moduledoc """ + Token-based authentication. + + When a user creates or joins a room we generate a random token, persist it on + their `Participant` row, and store that token in the session cookie. On every + request the token is looked up to identify the current participant. This is + the token scheme described in the proposal (a random string whose owner is + recorded in the database, kept in a cookie). + """ + use Rsh26poolWeb, :verified_routes + + import Plug.Conn + + alias Phoenix.LiveView + alias Rsh26pool.Voting + + @token_key "participant_token" + + ## Plug (controllers) --------------------------------------------------- + + @doc "Assigns `:current_participant` from the session token (may be nil)." + def fetch_current_participant(conn, _opts) do + participant = participant_from_token(get_session(conn, @token_key)) + assign(conn, :current_participant, participant) + end + + @doc "Persists a participant's token in the session and assigns it." + def log_in_participant(conn, participant) do + conn + |> put_session(@token_key, participant.token) + |> assign(:current_participant, participant) + end + + @doc "Clears the session token (logout)." + def log_out_participant(conn) do + conn + |> delete_session(@token_key) + |> assign(:current_participant, nil) + end + + ## LiveView on_mount hooks --------------------------------------------- + + @doc """ + `on_mount` hooks: + + * `:mount_current_participant` — assigns `:current_participant` (may be nil) + * `:require_participant` — requires the viewer to belong to the room in the + URL, otherwise redirects to the enter page + * `:require_owner` — requires the viewer to be the owner of the room + """ + def on_mount(:mount_current_participant, _params, session, socket) do + {:cont, assign_current_participant(socket, session)} + end + + def on_mount(:require_participant, params, session, socket) do + socket = assign_current_participant(socket, session) + participant = socket.assigns.current_participant + room_number = params["room_number"] + + if participant && participant.room && participant.room.room_number == room_number do + {:cont, socket} + else + socket = + socket + |> LiveView.put_flash(:error, "このルームに参加するには入室してください。") + |> LiveView.redirect(to: ~p"/enter?#{[room: room_number]}") + + {:halt, socket} + end + end + + def on_mount(:require_owner, params, session, socket) do + socket = assign_current_participant(socket, session) + participant = socket.assigns.current_participant + room_number = params["room_number"] + + if participant && participant.room && participant.room.room_number == room_number && + participant.role == "owner" do + {:cont, socket} + else + socket = + socket + |> LiveView.put_flash(:error, "この管理ページにアクセスする権限がありません。") + |> LiveView.redirect(to: ~p"/") + + {:halt, socket} + end + end + + defp assign_current_participant(socket, session) do + Phoenix.Component.assign_new(socket, :current_participant, fn -> + participant_from_token(session[@token_key]) + end) + end + + defp participant_from_token(nil), do: nil + defp participant_from_token(token), do: Voting.get_participant_by_token(token) +end diff --git a/lib/rsh26pool_web/components/layouts.ex b/lib/rsh26pool_web/components/layouts.ex index 74fb5f2..ce14e44 100644 --- a/lib/rsh26pool_web/components/layouts.ex +++ b/lib/rsh26pool_web/components/layouts.ex @@ -27,43 +27,39 @@ defmodule Rsh26poolWeb.Layouts do """ attr :flash, :map, required: true, doc: "the map of flash messages" - attr :current_scope, :map, + attr :current_participant, :map, default: nil, - doc: "the current [scope](https://phoenix.hexdocs.pm/scopes.html)" + doc: "the currently logged-in participant, if any" slot :inner_block, required: true def app(assigns) do ~H""" -