Files
rsh26_pool_example/lib/rsh26pool/voting.ex
T

524 lines
15 KiB
Elixir
Raw Normal View History

2026-07-04 19:27:17 +09:00
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