From cbafe9c50a6adca05e5fca66dd23bf9eaa97f86a Mon Sep 17 00:00:00 2001
From: Sebastian Gottfried
Date: Sun, 29 Mar 2026 23:48:33 +0200
Subject: [PATCH 1/2] Integrate URLS to Video Conference Rooms
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- A new `TeamVideoConferenceRoom` schema for session-specific remote
rooms, associated with events.
- Updated `DevRound.Events.Event` to store a
`main_video_conference_room_url` and manage
`team_video_conference_rooms`.
- Added URL validation logic to `DevRound.Changeset` ensuring only valid
HTTP(S) URLs are used for conference links.
- Refactored `DevRound.Hosting` team generation algorithms to accept
video conference room URLs and assign them to remote teams, including
validation for sufficient availability.
- Update the event admin view for video conference URLÂ entry.
- Updated templates to display conference room links for users.
---
lib/dev_round/changeset.ex | 27 +++++
lib/dev_round/events.ex | 8 +-
lib/dev_round/events/event.ex | 14 ++-
.../events/team_video_conference_room.ex | 18 ++++
lib/dev_round/hosting.ex | 98 ++++++++++++-------
lib/dev_round/hosting/team.ex | 3 +-
lib/dev_round_web/admin/event.ex | 71 ++++++++++----
.../item_actions/duplicate_event_action.ex | 12 ++-
.../components/core_components.ex | 13 +++
.../components/event_components.ex | 24 +++--
.../live/event_live/show.html.heex | 8 +-
.../live/event_slides_live/show.html.heex | 11 ++-
lib/dev_round_web/live/hosting_base.ex | 3 +-
.../live/hosting_session_live/show.ex | 7 +-
.../live/hosting_session_live/show.html.heex | 1 +
lib/dev_round_web/live/user_events_live.ex | 9 +-
...60325195246_add_video_conference_rooms.exs | 20 ++++
test/dev_round/changeset_test.exs | 55 +++++++++++
test/dev_round/hosting_test.exs | 33 ++++++-
19 files changed, 361 insertions(+), 74 deletions(-)
create mode 100644 lib/dev_round/events/team_video_conference_room.ex
create mode 100644 priv/repo/migrations/20260325195246_add_video_conference_rooms.exs
diff --git a/lib/dev_round/changeset.ex b/lib/dev_round/changeset.ex
index fe42598..3a0c989 100644
--- a/lib/dev_round/changeset.ex
+++ b/lib/dev_round/changeset.ex
@@ -157,4 +157,31 @@ defmodule DevRound.Changeset do
end
end
end
+
+ @doc """
+ Validates that given field contains a valid HTTP or HTTPS URL.
+
+ ## Examples
+
+ iex> changeset = %Ecto.Changeset{changes: %{url: "http://example.com"}}
+ iex> Changeset.validate_http_url(changeset, :url)
+ # No error added
+
+ iex> changeset = %Ecto.Changeset{changes: %{url: "invalid"}}
+ iex> Changeset.validate_http_url(changeset, :url)
+ # Adds error to url field
+
+ """
+ def validate_http_url(changeset, field) do
+ validate_change(changeset, field, fn field, url ->
+ case URI.parse(url) do
+ %URI{scheme: scheme, host: host}
+ when scheme in ["http", "https"] and not is_nil(host) and host != "" ->
+ []
+
+ _ ->
+ [{field, "Must be a valid HTTP(S) URL."}]
+ end
+ end)
+ end
end
diff --git a/lib/dev_round/events.ex b/lib/dev_round/events.ex
index 44349e9..615d936 100644
--- a/lib/dev_round/events.ex
+++ b/lib/dev_round/events.ex
@@ -105,7 +105,13 @@ defmodule DevRound.Events do
sessions_query = from s in EventSession, order_by: s.begin
event
- |> Repo.preload([:langs, :hosts, :last_live_session, sessions: sessions_query])
+ |> Repo.preload([
+ :langs,
+ :hosts,
+ :last_live_session,
+ :team_video_conference_rooms,
+ sessions: sessions_query
+ ])
|> Repo.preload(events_attendees: {attendee_query, [:user, :langs]})
end
diff --git a/lib/dev_round/events/event.ex b/lib/dev_round/events/event.ex
index 03614f8..3d68404 100644
--- a/lib/dev_round/events/event.ex
+++ b/lib/dev_round/events/event.ex
@@ -31,9 +31,14 @@ defmodule DevRound.Events.Event do
field :slides_page_number, :integer
field :live, :boolean
field :modified_at, :utc_datetime
+ field :main_video_conference_room_url, :string
many_to_many :langs, Lang, join_through: "event_langs", on_replace: :delete
+ has_many :team_video_conference_rooms, DevRound.Events.TeamVideoConferenceRoom,
+ on_replace: :delete,
+ on_delete: :delete_all
+
has_many :event_hosts, EventHost,
preload_order: [asc: :position],
on_replace: :delete,
@@ -60,7 +65,8 @@ defmodule DevRound.Events.Event do
:location,
:published,
:registration_deadline_local,
- :slides_filename
+ :slides_filename,
+ :main_video_conference_room_url
])
|> cast_assoc(:event_hosts,
with: &EventHost.changeset/3,
@@ -76,6 +82,11 @@ defmodule DevRound.Events.Event do
sort_param: :sessions_order,
drop_param: :sessions_delete
)
+ |> cast_assoc(:team_video_conference_rooms,
+ with: &DevRound.Events.TeamVideoConferenceRoom.changeset/2,
+ sort_param: :team_video_conference_rooms_order,
+ drop_param: :team_video_conference_rooms_delete
+ )
|> put_langs_assoc(Keyword.get(opts, :put_langs))
|> validate_required(
[
@@ -107,6 +118,7 @@ defmodule DevRound.Events.Event do
_ -> []
end
end)
+ |> validate_http_url(:main_video_conference_room_url)
|> unique_constraint(:slug)
|> change(modified_at: DateTime.utc_now(:second))
end
diff --git a/lib/dev_round/events/team_video_conference_room.ex b/lib/dev_round/events/team_video_conference_room.ex
new file mode 100644
index 0000000..0cc1158
--- /dev/null
+++ b/lib/dev_round/events/team_video_conference_room.ex
@@ -0,0 +1,18 @@
+defmodule DevRound.Events.TeamVideoConferenceRoom do
+ use Ecto.Schema
+ import Ecto.Changeset
+ import DevRound.Changeset
+ alias DevRound.Events.Event
+
+ schema "team_video_conference_rooms" do
+ field :url, :string
+ belongs_to :event, Event
+ end
+
+ def changeset(room, attrs) do
+ room
+ |> cast(attrs, [:url])
+ |> validate_required([:url])
+ |> validate_http_url(:url)
+ end
+end
diff --git a/lib/dev_round/hosting.ex b/lib/dev_round/hosting.ex
index 0b67247..99f05a7 100644
--- a/lib/dev_round/hosting.ex
+++ b/lib/dev_round/hosting.ex
@@ -124,11 +124,11 @@ defmodule DevRound.Hosting do
EventAttendee.check_changeset(attendee, %{checked: checked})
end
- def validate_team_generation_constraints(attendees, team_names) do
+ def validate_team_generation_constraints(attendees, team_names, team_rooms) do
attendees = filter_checked(attendees)
if Enum.count(attendees) >= 2 do
- messages = build_validation_messages(attendees, team_names)
+ messages = build_validation_messages(attendees, team_names, team_rooms)
case messages do
[] -> {:ok, []}
@@ -139,7 +139,7 @@ defmodule DevRound.Hosting do
end
end
- defp build_validation_messages(attendees, team_names) do
+ defp build_validation_messages(attendees, team_names, team_rooms) do
attendee_messages =
for attendee <- attendees do
potential_team_mates =
@@ -162,7 +162,18 @@ defmodule DevRound.Hosting do
[]
end
- attendee_messages ++ team_names_message
+ remote_attendees = Enum.filter(attendees, & &1.is_remote)
+
+ video_conference_rooms_message =
+ if Integer.floor_div(length(remote_attendees), 2) > length(team_rooms) do
+ [
+ "Not enough session video conference room URLs to build teams for checked remote participants."
+ ]
+ else
+ []
+ end
+
+ attendee_messages ++ team_names_message ++ video_conference_rooms_message
end
defp filter_checked(attendees) do
@@ -197,29 +208,32 @@ defmodule DevRound.Hosting do
|> Enum.into(%{}, fn team -> {team.session_id, team} end)
end
- def build_teams_for_session(%EventSession{} = session, attendees, team_names) do
+ def build_teams_for_session(%EventSession{} = session, attendees, team_names, team_rooms) do
attendees = filter_checked(attendees)
- {:ok, []} = validate_team_generation_constraints(attendees, team_names)
+ {:ok, []} = validate_team_generation_constraints(attendees, team_names, team_rooms)
Multi.new()
|> Multi.delete_all(:teams, Ecto.assoc(session, :teams))
- |> insert_teams(session, attendees, team_names)
+ |> insert_teams(session, attendees, team_names, team_rooms)
|> Repo.transaction()
end
- defp insert_teams(multi, session, attendees, team_names) do
- generate_team_changesets(session, attendees, team_names)
+ defp insert_teams(multi, session, attendees, team_names, team_rooms) do
+ generate_team_changesets(session, attendees, team_names, team_rooms)
|> Enum.reduce(multi, &Multi.insert(&2, Changeset.get_change(&1, :slug), &1))
end
- defp generate_team_changesets(session, attendees, team_names) do
+ defp generate_team_changesets(session, attendees, team_names, team_rooms) do
{local_teams, local_langs} = generate_teams_langs(attendees, false)
{remote_teams, remote_langs} = generate_teams_langs(attendees, true)
+ room_urls = team_rooms |> Enum.map(& &1.url)
+
create_team_changesets(
{local_teams ++ remote_teams, local_langs ++ remote_langs},
session,
- team_names
+ team_names,
+ room_urls
)
end
@@ -293,34 +307,48 @@ defmodule DevRound.Hosting do
MapSet.new()
end
- defp create_team_changesets({teams_attendees, teams_langs}, session, team_names) do
+ defp create_team_changesets({teams_attendees, teams_langs}, session, team_names, room_urls) do
names = team_names |> Enum.shuffle() |> Enum.take(length(teams_attendees))
- Enum.zip([teams_attendees, teams_langs, names])
- |> Enum.map(fn {team_attendees, team_langs, name} ->
- lang = Enum.random(team_langs)
- is_remote = hd(team_attendees).is_remote
+ {changesets, _remaining_urls} =
+ Enum.zip([teams_attendees, teams_langs, names])
+ |> Enum.map_reduce(room_urls, fn {team_attendees, team_langs, name}, available_urls ->
+ lang = Enum.random(team_langs)
+ is_remote = hd(team_attendees).is_remote
+
+ {room_url, next_urls} =
+ if is_remote && available_urls != [] do
+ {hd(available_urls), tl(available_urls)}
+ else
+ {nil, available_urls}
+ end
+
+ members =
+ Enum.map(team_attendees, fn attendee ->
+ %TeamMember{}
+ |> Changeset.change(
+ is_remote: attendee.is_remote,
+ experience_level: attendee.experience_level,
+ user: attendee.user
+ )
+ |> Changeset.put_assoc(:langs, attendee.langs)
+ end)
- members =
- Enum.map(team_attendees, fn attendee ->
- %TeamMember{}
+ changeset =
+ %Team{}
|> Changeset.change(
- is_remote: attendee.is_remote,
- experience_level: attendee.experience_level,
- user: attendee.user
+ name: name.name,
+ slug: name.slug,
+ is_remote: is_remote,
+ session: session,
+ lang: lang,
+ video_conference_room_url: room_url
)
- |> Changeset.put_assoc(:langs, attendee.langs)
- end)
-
- %Team{}
- |> Changeset.change(
- name: name.name,
- slug: name.slug,
- is_remote: is_remote,
- session: session,
- lang: lang
- )
- |> Changeset.put_assoc(:members, members)
- end)
+ |> Changeset.put_assoc(:members, members)
+
+ {changeset, next_urls}
+ end)
+
+ changesets
end
end
diff --git a/lib/dev_round/hosting/team.ex b/lib/dev_round/hosting/team.ex
index 829f73d..3065c2d 100644
--- a/lib/dev_round/hosting/team.ex
+++ b/lib/dev_round/hosting/team.ex
@@ -17,6 +17,7 @@ defmodule DevRound.Hosting.Team do
field :name, :string
field :slug, :string
field :is_remote, :boolean, default: false
+ field :video_conference_room_url, :string
belongs_to :session, EventSession
belongs_to :lang, Lang
@@ -28,7 +29,7 @@ defmodule DevRound.Hosting.Team do
@doc false
def changeset(team, attrs) do
team
- |> cast(attrs, [:name, :slug, :is_remote])
+ |> cast(attrs, [:name, :slug, :is_remote, :video_conference_room_url])
|> validate_required([:name, :slug, :is_remote])
end
end
diff --git a/lib/dev_round_web/admin/event.ex b/lib/dev_round_web/admin/event.ex
index 4370732..1167eb8 100644
--- a/lib/dev_round_web/admin/event.ex
+++ b/lib/dev_round_web/admin/event.ex
@@ -52,6 +52,15 @@ defmodule DevRoundWeb.Admin.Event do
]
end
+ @impl Backpex.LiveResource
+ def panels do
+ [
+ content: "Content",
+ video_conference_rooms: "Video Conference Rooms",
+ settings: "Settings"
+ ]
+ end
+
@impl Backpex.LiveResource
def fields do
[
@@ -59,6 +68,22 @@ defmodule DevRoundWeb.Admin.Event do
module: Backpex.Fields.Text,
label: "Title"
},
+ event_hosts: %{
+ module: DevRoundWeb.Admin.Fields.InlineCRUD,
+ label: "Hosts",
+ type: :assoc,
+ child_fields: [
+ user: %{
+ module: DevRoundWeb.Admin.Fields.BelongsTo,
+ label: "User",
+ display_field: :full_name,
+ live_resource: DevRoundWeb.Admin.User,
+ prompt: "Select user",
+ options_query: fn query, _field -> query |> order_by(asc: :full_name) end
+ }
+ ],
+ except: [:index]
+ },
begin_local: %{
module: Backpex.Fields.DateTime,
label: "Begin"
@@ -86,22 +111,7 @@ defmodule DevRoundWeb.Admin.Event do
live_resource: DevRoundWeb.Admin.Lang,
prompt: "Select",
not_found_text: "No languages found",
- except: [:index]
- },
- event_hosts: %{
- module: DevRoundWeb.Admin.Fields.InlineCRUD,
- label: "Hosts",
- type: :assoc,
- child_fields: [
- user: %{
- module: DevRoundWeb.Admin.Fields.BelongsTo,
- label: "User",
- display_field: :full_name,
- live_resource: DevRoundWeb.Admin.User,
- prompt: "Select user",
- options_query: fn query, _field -> query |> order_by(asc: :full_name) end
- }
- ],
+ panel: :content,
except: [:index]
},
teaser: %{
@@ -109,6 +119,7 @@ defmodule DevRoundWeb.Admin.Event do
label: "Teaser",
help_text: "Shown on event listing page.",
rows: 5,
+ panel: :content,
except: [:index]
},
body: %{
@@ -116,6 +127,7 @@ defmodule DevRoundWeb.Admin.Event do
label: "Body",
help_text: "Markdown is supported.",
rows: 15,
+ panel: :content,
except: [:index]
},
slides_filename: %{
@@ -141,6 +153,7 @@ defmodule DevRoundWeb.Admin.Event do
"""
end,
+ panel: :content,
except: [:index]
},
sessions: %{
@@ -161,11 +174,33 @@ defmodule DevRoundWeb.Admin.Event do
module: Backpex.Fields.DateTime,
label: "End"
}
- ]
+ ],
+ panel: :content
+ },
+ main_video_conference_room_url: %{
+ module: Backpex.Fields.URL,
+ label: "Main Video Conference Room URL",
+ panel: :video_conference_rooms,
+ except: [:index]
+ },
+ team_video_conference_rooms: %{
+ module: Backpex.Fields.InlineCRUD,
+ type: :assoc,
+ label: "Team Video Conference Rooms",
+ child_fields: [
+ url: %{
+ module: Backpex.Fields.URL,
+ label: "URL"
+ }
+ ],
+ help_text: "Used for teams with remote attendees.",
+ panel: :video_conference_rooms,
+ except: [:index]
},
published: %{
module: Backpex.Fields.Boolean,
- label: "Published"
+ label: "Published",
+ panel: :settings
}
]
end
diff --git a/lib/dev_round_web/admin/item_actions/duplicate_event_action.ex b/lib/dev_round_web/admin/item_actions/duplicate_event_action.ex
index 2222a53..f939bc3 100644
--- a/lib/dev_round_web/admin/item_actions/duplicate_event_action.ex
+++ b/lib/dev_round_web/admin/item_actions/duplicate_event_action.ex
@@ -85,11 +85,21 @@ defmodule DevRoundWeb.Admin.ItemActions.DuplicateEventAction do
|> Map.merge(data)
|> shift_event_dates(date_diff)
|> Map.put(:sessions, Enum.map(item.sessions, &process_session(&1, date_diff)))
+ |> Map.put(
+ :event_hosts,
+ Enum.map(item.event_hosts, &Map.take(&1, [:event_id, :user_id, :position]))
+ )
+ |> Map.put(
+ :team_video_conference_rooms,
+ Enum.map(item.team_video_conference_rooms, &Map.take(&1, [:url]))
+ )
|> Map.put(:published, false)
opts = [
put_langs: item.langs,
- put_hosts: item.hosts
+ put_hosts: item.hosts,
+ put_team_video_conference_rooms:
+ Enum.map(item.team_video_conference_rooms, &Map.take(&1, [:url]))
]
case Events.create_event(attrs, opts) do
diff --git a/lib/dev_round_web/components/core_components.ex b/lib/dev_round_web/components/core_components.ex
index 1ae011b..a67a0b9 100644
--- a/lib/dev_round_web/components/core_components.ex
+++ b/lib/dev_round_web/components/core_components.ex
@@ -788,4 +788,17 @@ defmodule DevRoundWeb.CoreComponents do
"""
end
+
+ attr :href, :string
+ attr :class, :any, default: nil, doc: "additional classes for the link element"
+ slot :inner_block
+
+ def external_link(assigns) do
+ ~H"""
+ <.link href={@href} target="_blank" class={["inline-flex gap-1 items-center", @class]}>
+ {render_slot(@inner_block)}
+ <.icon name="hero-arrow-top-right-on-square" class="w-[1em] h-[1em] opacity-70" />
+
+ """
+ end
end
diff --git a/lib/dev_round_web/components/event_components.ex b/lib/dev_round_web/components/event_components.ex
index e8cf74f..f14dd3e 100644
--- a/lib/dev_round_web/components/event_components.ex
+++ b/lib/dev_round_web/components/event_components.ex
@@ -70,6 +70,7 @@ defmodule DevRoundWeb.EventComponents do
attr :show_member_experience_level, :boolean, required: true
attr :show_member_langs, :boolean, required: true
attr :multiple_langs, :boolean, required: true
+ attr :show_video_conference_room_url, :boolean, default: false
attr :class, :string, default: nil
attr :zoom, :float, default: nil
@@ -78,15 +79,15 @@ defmodule DevRoundWeb.EventComponents do
-
-
+
+
{@team.name}
+
-
@@ -116,6 +117,15 @@ defmodule DevRoundWeb.EventComponents do
<% end %>
<% end %>
+
+ <.external_link
+ :if={@show_video_conference_room_url && @team.video_conference_room_url}
+ href={@team.video_conference_room_url}
+ class="link link-info"
+ >
+ Video Conference
+
+
"""
diff --git a/lib/dev_round_web/live/event_live/show.html.heex b/lib/dev_round_web/live/event_live/show.html.heex
index ee17e61..4314626 100644
--- a/lib/dev_round_web/live/event_live/show.html.heex
+++ b/lib/dev_round_web/live/event_live/show.html.heex
@@ -94,6 +94,12 @@
{@event.location}
+ <%= if @event.main_video_conference_room_url do %>
+ /
+ <.external_link href={@event.main_video_conference_room_url} class="link">
+ Video Conference
+
+ <% end %>
<%= if not @archived do %>
@@ -129,7 +135,7 @@
<%= if @pdf_url do %>
- <.link href={@pdf_url} class="flex gap-1 font-semibold" target="_blank">
+ <.link href={@pdf_url} class="flex gap-1 font-semibold link" target="_blank">
<.icon name="hero-arrow-down-tray" class="h-5 w-5" /> Download
<% else %>
diff --git a/lib/dev_round_web/live/event_slides_live/show.html.heex b/lib/dev_round_web/live/event_slides_live/show.html.heex
index 942ffca..04e15c0 100644
--- a/lib/dev_round_web/live/event_slides_live/show.html.heex
+++ b/lib/dev_round_web/live/event_slides_live/show.html.heex
@@ -60,6 +60,15 @@
<.usage_hint>
- You can follow the presentation through the slides here. Audio is not provided.
+ You can follow the presentation through the slides here.
+ <%= if @event.main_video_conference_room_url do %>
+ Join the
+ <.external_link href={@event.main_video_conference_room_url} class="link">
+ video conference
+
+ for full video/audio content.
+ <% else %>
+ Audio is not provided.
+ <% end %>
diff --git a/lib/dev_round_web/live/hosting_base.ex b/lib/dev_round_web/live/hosting_base.ex
index 69f0ddd..c6f45e3 100644
--- a/lib/dev_round_web/live/hosting_base.ex
+++ b/lib/dev_round_web/live/hosting_base.ex
@@ -38,7 +38,8 @@ defmodule DevRoundWeb.HostingBase do
{_, messages} =
Hosting.validate_team_generation_constraints(
socket.assigns.event.events_attendees,
- socket.assigns.team_names
+ socket.assigns.team_names,
+ socket.assigns.event.team_video_conference_rooms
)
assign(socket, :messages, messages)
diff --git a/lib/dev_round_web/live/hosting_session_live/show.ex b/lib/dev_round_web/live/hosting_session_live/show.ex
index 07625c5..395f19e 100644
--- a/lib/dev_round_web/live/hosting_session_live/show.ex
+++ b/lib/dev_round_web/live/hosting_session_live/show.ex
@@ -83,7 +83,12 @@ defmodule DevRoundWeb.HostingSessionLive.Show do
false = session.teams_locked
{:ok, _} =
- Hosting.build_teams_for_session(session, event.events_attendees, team_names)
+ Hosting.build_teams_for_session(
+ session,
+ event.events_attendees,
+ team_names,
+ event.team_video_conference_rooms
+ )
broadcast_teams_build(session)
diff --git a/lib/dev_round_web/live/hosting_session_live/show.html.heex b/lib/dev_round_web/live/hosting_session_live/show.html.heex
index 33b8b54..3e00edb 100644
--- a/lib/dev_round_web/live/hosting_session_live/show.html.heex
+++ b/lib/dev_round_web/live/hosting_session_live/show.html.heex
@@ -90,6 +90,7 @@
show_member_experience_level={true}
show_member_langs={true}
multiple_langs={@multiple_langs}
+ show_video_conference_room_url={true}
/>
<% end %>
diff --git a/lib/dev_round_web/live/user_events_live.ex b/lib/dev_round_web/live/user_events_live.ex
index 3ad9b1f..ce13626 100644
--- a/lib/dev_round_web/live/user_events_live.ex
+++ b/lib/dev_round_web/live/user_events_live.ex
@@ -49,7 +49,12 @@ defmodule DevRoundWeb.UserEventsLive do
Up Next
- <.event_card :for={event <- @underway_events} event={event} teams_map={@teams_map} />
+ <.event_card
+ :for={event <- @underway_events}
+ event={event}
+ teams_map={@teams_map}
+ show_video_conference_room_url={true}
+ />
@@ -97,6 +102,7 @@ defmodule DevRoundWeb.UserEventsLive do
attr :teams_map, :map, required: true
attr :collapsable, :boolean, default: false
attr :expanded, :boolean, default: true
+ attr :show_video_conference_room_url, :boolean, default: false
defp event_card(assigns) do
~H"""
@@ -151,6 +157,7 @@ defmodule DevRoundWeb.UserEventsLive do
show_member_experience_level={false}
show_member_langs={false}
multiple_langs={tl(@event.langs) != []}
+ show_video_conference_room_url={@show_video_conference_room_url}
class="border border-base-content/5"
/>
<% else %>
diff --git a/priv/repo/migrations/20260325195246_add_video_conference_rooms.exs b/priv/repo/migrations/20260325195246_add_video_conference_rooms.exs
new file mode 100644
index 0000000..c08249a
--- /dev/null
+++ b/priv/repo/migrations/20260325195246_add_video_conference_rooms.exs
@@ -0,0 +1,20 @@
+defmodule DevRound.Repo.Migrations.AddVideoConferenceRooms do
+ use Ecto.Migration
+
+ def change do
+ alter table(:events) do
+ add :main_video_conference_room_url, :string
+ end
+
+ alter table(:teams) do
+ add :video_conference_room_url, :string
+ end
+
+ create table(:team_video_conference_rooms) do
+ add :url, :string, null: false
+ add :event_id, references(:events, on_delete: :delete_all), null: false
+ end
+
+ create index(:team_video_conference_rooms, [:event_id])
+ end
+end
diff --git a/test/dev_round/changeset_test.exs b/test/dev_round/changeset_test.exs
index 4447549..f5d2014 100644
--- a/test/dev_round/changeset_test.exs
+++ b/test/dev_round/changeset_test.exs
@@ -310,4 +310,59 @@ defmodule DevRound.ChangesetTest do
refute Map.has_key?(changeset.changes, :slug)
end
end
+
+ describe "validate_http_url/2" do
+ test "accepts valid http url" do
+ data = %{}
+ types = %{url: :string}
+ params = %{url: "http://example.com"}
+
+ changeset =
+ {data, types}
+ |> Ecto.Changeset.cast(params, [:url])
+ |> DevRound.Changeset.validate_http_url(:url)
+
+ refute changeset.errors[:url]
+ end
+
+ test "accepts valid https url" do
+ data = %{}
+ types = %{url: :string}
+ params = %{url: "https://example.com"}
+
+ changeset =
+ {data, types}
+ |> Ecto.Changeset.cast(params, [:url])
+ |> DevRound.Changeset.validate_http_url(:url)
+
+ refute changeset.errors[:url]
+ end
+
+ test "rejects invalid url scheme" do
+ data = %{}
+ types = %{url: :string}
+ params = %{url: "ftp://example.com"}
+
+ changeset =
+ {data, types}
+ |> Ecto.Changeset.cast(params, [:url])
+ |> DevRound.Changeset.validate_http_url(:url)
+
+ assert %{errors: [url: {"Must be a valid HTTP(S) URL.", _}]} = changeset
+ end
+
+ test "rejects invalid url (no host)" do
+ data = %{}
+ types = %{url: :string}
+ params = %{url: "http://"}
+
+ changeset =
+ {data, types}
+ |> Ecto.Changeset.cast(params, [:url])
+ |> Ecto.Changeset.validate_required([:url])
+ |> DevRound.Changeset.validate_http_url(:url)
+
+ assert %{errors: [url: {"Must be a valid HTTP(S) URL.", _}]} = changeset
+ end
+ end
end
diff --git a/test/dev_round/hosting_test.exs b/test/dev_round/hosting_test.exs
index b6b715a..cb22530 100644
--- a/test/dev_round/hosting_test.exs
+++ b/test/dev_round/hosting_test.exs
@@ -182,7 +182,13 @@ defmodule DevRound.HostingTest do
attendees = [r1, r2, r3, r4, r5, l1, l2, l3, l4, l5, u1_remote, u2_local]
- assert {:ok, _} = Hosting.build_teams_for_session(session, attendees, team_names)
+ # We need at least as many rooms as remote teams (5 remote -> 3 teams needed: 2 + 3, wait, 5 remote? `Integer.floor_div(5, 2) = 2`? No, 5 remote participants in 2 categories (Split into [A, B] and [C, D, E])), wait `order_attendees_by_experience` sorts them first.
+ # 5 remote participants -> 3 teams. 3 rooms needed.
+ team_rooms =
+ for _ <- 1..3, do: %DevRound.Events.TeamVideoConferenceRoom{url: "http://room.com"}
+
+ assert {:ok, _} =
+ Hosting.build_teams_for_session(session, attendees, team_names, team_rooms)
teams = Hosting.list_teams_for_session(session)
@@ -240,13 +246,13 @@ defmodule DevRound.HostingTest do
u1 = register_attendee(event, "u1", false, [lang1])
assert {:error, ["Not enough checked participants to build teams."]} =
- Hosting.validate_team_generation_constraints([u1], team_names)
+ Hosting.validate_team_generation_constraints([u1], team_names, [])
# 2. No compatible mate (different remote status)
u2 = register_attendee(event, "u2", true, [lang1])
assert {:error, messages} =
- Hosting.validate_team_generation_constraints([u1, u2], team_names)
+ Hosting.validate_team_generation_constraints([u1, u2], team_names, [])
assert Enum.any?(messages, fn m -> m =~ "No team mate for u1" end)
assert Enum.any?(messages, fn m -> m =~ "No team mate for u2" end)
@@ -255,7 +261,7 @@ defmodule DevRound.HostingTest do
u3 = register_attendee(event, "u3", false, [lang2])
assert {:error, messages} =
- Hosting.validate_team_generation_constraints([u1, u3], team_names)
+ Hosting.validate_team_generation_constraints([u1, u3], team_names, [])
assert Enum.any?(messages, fn m -> m =~ "No team mate for u1" end)
@@ -264,9 +270,26 @@ defmodule DevRound.HostingTest do
# 3 checked in-person, needs 1 team. 1 remote (u2), not enough.
# Total 4. Needs 2 teams.
assert {:error, messages} =
- Hosting.validate_team_generation_constraints([u1, u3, u4, u2], [hd(team_names)])
+ Hosting.validate_team_generation_constraints(
+ [u1, u3, u4, u2],
+ [hd(team_names)],
+ []
+ )
assert Enum.member?(messages, "Not enough team names for checked participants.")
+
+ # 5. Not enough video conference rooms (needs 1 team for u2)
+ assert {:error, messages} =
+ Hosting.validate_team_generation_constraints(
+ [u2, register_attendee(event, "u5", true, [lang1])],
+ team_names,
+ []
+ )
+
+ assert Enum.member?(
+ messages,
+ "Not enough session video conference room URLs to build teams for checked remote participants."
+ )
end
end
end
From 3dc00e05d7e88b81763be7ef0d39eb142f631057 Mon Sep 17 00:00:00 2001
From: Sebastian Gottfried
Date: Mon, 30 Mar 2026 20:00:30 +0200
Subject: [PATCH 2/2] Hosting: Simplify Video Conference Assignment
---
lib/dev_round/hosting.ex | 65 +++++++++++++++++-----------------------
1 file changed, 27 insertions(+), 38 deletions(-)
diff --git a/lib/dev_round/hosting.ex b/lib/dev_round/hosting.ex
index 99f05a7..26c1082 100644
--- a/lib/dev_round/hosting.ex
+++ b/lib/dev_round/hosting.ex
@@ -228,12 +228,14 @@ defmodule DevRound.Hosting do
{remote_teams, remote_langs} = generate_teams_langs(attendees, true)
room_urls = team_rooms |> Enum.map(& &1.url)
+ local_room_urls = List.duplicate(nil, length(local_teams))
+ remote_room_urls = Enum.take(room_urls, length(remote_teams))
create_team_changesets(
{local_teams ++ remote_teams, local_langs ++ remote_langs},
session,
team_names,
- room_urls
+ local_room_urls ++ remote_room_urls
)
end
@@ -310,45 +312,32 @@ defmodule DevRound.Hosting do
defp create_team_changesets({teams_attendees, teams_langs}, session, team_names, room_urls) do
names = team_names |> Enum.shuffle() |> Enum.take(length(teams_attendees))
- {changesets, _remaining_urls} =
- Enum.zip([teams_attendees, teams_langs, names])
- |> Enum.map_reduce(room_urls, fn {team_attendees, team_langs, name}, available_urls ->
- lang = Enum.random(team_langs)
- is_remote = hd(team_attendees).is_remote
-
- {room_url, next_urls} =
- if is_remote && available_urls != [] do
- {hd(available_urls), tl(available_urls)}
- else
- {nil, available_urls}
- end
-
- members =
- Enum.map(team_attendees, fn attendee ->
- %TeamMember{}
- |> Changeset.change(
- is_remote: attendee.is_remote,
- experience_level: attendee.experience_level,
- user: attendee.user
- )
- |> Changeset.put_assoc(:langs, attendee.langs)
- end)
+ Enum.zip([teams_attendees, teams_langs, names, room_urls])
+ |> Enum.map(fn {team_attendees, team_langs, name, room_url} ->
+ lang = Enum.random(team_langs)
+ is_remote = hd(team_attendees).is_remote
- changeset =
- %Team{}
+ members =
+ Enum.map(team_attendees, fn attendee ->
+ %TeamMember{}
|> Changeset.change(
- name: name.name,
- slug: name.slug,
- is_remote: is_remote,
- session: session,
- lang: lang,
- video_conference_room_url: room_url
+ is_remote: attendee.is_remote,
+ experience_level: attendee.experience_level,
+ user: attendee.user
)
- |> Changeset.put_assoc(:members, members)
-
- {changeset, next_urls}
- end)
-
- changesets
+ |> Changeset.put_assoc(:langs, attendee.langs)
+ end)
+
+ %Team{}
+ |> Changeset.change(
+ name: name.name,
+ slug: name.slug,
+ is_remote: is_remote,
+ session: session,
+ lang: lang,
+ video_conference_room_url: room_url
+ )
+ |> Changeset.put_assoc(:members, members)
+ end)
end
end