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