Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/controllers/admin/schedule_items_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def set_schedule_item
def schedule_item_params
attrs = params.require(:schedule_item).permit(
:day, :time_label, :sort_time, :title, :host,
:location, :map_url, :description, :kind, :flexible, :is_public,
:location, :map_url, :description, :kind, :flexible, :is_public, :audience,
:embassy_mode, :embassy_capacity
)
unless attrs[:kind] == "embassy"
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/plan_items_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ class PlanItemsController < ApplicationController
before_action :set_plan_item, only: %i[update destroy]

def create
schedule_item = ScheduleItem.find(params[:schedule_item_id])
schedule_item = ScheduleItem.visible_to(current_user).find(params[:schedule_item_id])
@plan_item = current_user.plan_items.find_or_create_by(schedule_item: schedule_item)

respond_to do |format|
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/schedule_controller.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
class ScheduleController < ApplicationController
def index
@selected_kind = ScheduleItem.kinds.key?(params[:kind].to_s) ? params[:kind] : nil
@items_by_day = ScheduleItem.public_items.by_kind(@selected_kind).ordered.group_by(&:day)
@items_by_day = ScheduleItem.visible_to(current_user).by_kind(@selected_kind).ordered.group_by(&:day)
@planned_ids = current_user.plan_items.pluck(:schedule_item_id).to_set
end
end
9 changes: 9 additions & 0 deletions app/models/schedule_item.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ class ScheduleItem < ApplicationRecord
reception: 4, meal: 5, community: 6, volunteer: 7
}

enum :audience, { everyone: "everyone", volunteers_only: "volunteers_only" }, prefix: :audience

validates :title, presence: true
validates :day, presence: true
validates :kind, presence: true
Expand All @@ -26,6 +28,13 @@ class ScheduleItem < ApplicationRecord
}.freeze

scope :public_items, -> { where(is_public: true) }
# Public items filtered to what `user` is allowed to see. Admins and
# volunteers see all public items; everyone else (attendees, signed-out)
# sees only items with audience: "everyone".
scope :visible_to, ->(user) {
return public_items if user&.admin? || user&.volunteer?
public_items.where(audience: "everyone")
}
scope :ordered, -> {
order(
Arel.sql(
Expand Down
11 changes: 11 additions & 0 deletions app/views/admin/schedule_items/_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,17 @@
<%= f.label :is_public, "Public (appears on /schedule for all attendees)" %>
</div>

<div>
<%= f.label :audience, "Visible to", class: "label" %>
<%= f.select :audience,
[ [ "Everyone", "everyone" ], [ "Volunteers only", "volunteers_only" ] ],
{}, class: "select" %>
<p class="text-muted text-sm mt-1">
"Volunteers only" hides the event from regular attendees on /schedule.
Admins always see all public events.
</p>
</div>

<div class="embassy-form-extras"
data-embassy-settings-target="extras"
<%= "hidden".html_safe unless schedule_item.kind == "embassy" %>>
Expand Down
7 changes: 6 additions & 1 deletion app/views/admin/schedule_items/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,12 @@
<span class="item-badge text-muted">unknown (<%= item.read_attribute_before_type_cast(:kind) %>)</span>
<% end %>
</td>
<td><%= item.is_public? ? "Public" : "Private" %></td>
<td>
<%= item.is_public? ? "Public" : "Private" %>
<% if item.is_public? && item.audience_volunteers_only? %>
<span class="text-muted text-sm">(volunteers)</span>
<% end %>
</td>
<td><%= item.host.presence || "—" %></td>
<td class="text-right">
<div class="flex gap-2 justify-end">
Expand Down
6 changes: 6 additions & 0 deletions db/migrate/20260426063932_add_audience_to_schedule_items.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class AddAudienceToScheduleItems < ActiveRecord::Migration[8.1]
def change
add_column :schedule_items, :audience, :string, default: "everyone", null: false
add_index :schedule_items, :audience
end
end
4 changes: 3 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 18 additions & 0 deletions test/controllers/admin/schedule_items_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -115,4 +115,22 @@ def valid_form_params(overrides = {})
assert_select "option[selected='selected']", text: "John Athayde"
end
end

test "admin can create a volunteers_only public item" do
sign_in_as users(:jeremy)
post admin_schedule_items_path, params: valid_form_params(
title: "Volunteer briefing", audience: "volunteers_only"
)
item = ScheduleItem.find_by(title: "Volunteer briefing")
assert_equal "volunteers_only", item.audience
end

test "admin can update an item's audience" do
item = ScheduleItem.create!(day: "thu", title: "Update test", kind: :talk, is_public: true)
assert_equal "everyone", item.audience

sign_in_as users(:jeremy)
patch admin_schedule_item_path(item), params: valid_form_params(audience: "volunteers_only")
assert_equal "volunteers_only", item.reload.audience
end
end
25 changes: 25 additions & 0 deletions test/controllers/plan_items_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,31 @@ class PlanItemsControllerTest < ActionDispatch::IntegrationTest
assert_not_equal "injected", other_plan.reload.notes
end

test "attendee POST to a volunteers_only item returns 404 (visibility guard)" do
hidden = ScheduleItem.create!(
slug: "vol-only", day: "fri", title: "Vol-only briefing",
kind: :volunteer, is_public: true, audience: "volunteers_only"
)
sign_in_as users(:attendee_one)

assert_no_difference -> { users(:attendee_one).plan_items.count } do
post plan_items_path, params: { schedule_item_id: hidden.id }
end
assert_response :not_found
end

test "volunteer can RSVP to a volunteers_only item" do
hidden = ScheduleItem.create!(
slug: "vol-only", day: "fri", title: "Vol-only briefing",
kind: :volunteer, is_public: true, audience: "volunteers_only"
)
sign_in_as users(:volunteer_one)

assert_difference -> { users(:volunteer_one).plan_items.count }, 1 do
post plan_items_path, params: { schedule_item_id: hidden.id }
end
end

test "turbo_stream DELETE response removes plan_item frame AND updates schedule_item frame" do
sign_in_as users(:attendee_one)
plan = users(:attendee_one).plan_items.create!(schedule_item: @item)
Expand Down
36 changes: 36 additions & 0 deletions test/integration/schedule_browsing_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -103,4 +103,40 @@ class ScheduleBrowsingTest < ActionDispatch::IntegrationTest
assert sat_index, "Saturday header should render"
assert thu_index < sat_index, "Thursday should render before Saturday"
end

test "attendees do not see volunteers_only items on /schedule" do
ScheduleItem.create!(
slug: "volunteer-briefing",
day: "fri", time_label: "8:00 AM", sort_time: 800,
title: "Volunteer Briefing",
kind: :volunteer, is_public: true, audience: "volunteers_only"
)
sign_in_as users(:attendee_one)
get schedule_path
assert_no_match "Volunteer Briefing", response.body
end

test "volunteers see volunteers_only items on /schedule" do
ScheduleItem.create!(
slug: "volunteer-briefing",
day: "fri", time_label: "8:00 AM", sort_time: 800,
title: "Volunteer Briefing",
kind: :volunteer, is_public: true, audience: "volunteers_only"
)
sign_in_as users(:volunteer_one)
get schedule_path
assert_match "Volunteer Briefing", response.body
end

test "admins see volunteers_only items on /schedule" do
ScheduleItem.create!(
slug: "volunteer-briefing",
day: "fri", time_label: "8:00 AM", sort_time: 800,
title: "Volunteer Briefing",
kind: :volunteer, is_public: true, audience: "volunteers_only"
)
sign_in_as users(:jeremy)
get schedule_path
assert_match "Volunteer Briefing", response.body
end
end
51 changes: 51 additions & 0 deletions test/models/schedule_item_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,57 @@ def valid_attrs(overrides = {})
assert_not_includes ScheduleItem.public_items, private_item
end

test "audience defaults to everyone" do
item = ScheduleItem.create!(valid_attrs)
assert_equal "everyone", item.audience
assert item.audience_everyone?
end

test "visible_to admin returns all public items including volunteers_only" do
everyone = ScheduleItem.create!(valid_attrs(title: "Everyone", audience: "everyone"))
volunteers = ScheduleItem.create!(valid_attrs(title: "Volunteers", audience: "volunteers_only"))
private_ = ScheduleItem.create!(valid_attrs(title: "Private", is_public: false))

visible = ScheduleItem.visible_to(users(:jeremy))
assert_includes visible, everyone
assert_includes visible, volunteers
assert_not_includes visible, private_
end

test "visible_to volunteer returns all public items including volunteers_only" do
everyone = ScheduleItem.create!(valid_attrs(title: "Everyone", audience: "everyone"))
volunteers = ScheduleItem.create!(valid_attrs(title: "Volunteers", audience: "volunteers_only"))

visible = ScheduleItem.visible_to(users(:volunteer_one))
assert_includes visible, everyone
assert_includes visible, volunteers
end

test "visible_to attendee returns only audience: everyone items" do
everyone = ScheduleItem.create!(valid_attrs(title: "Everyone", audience: "everyone"))
volunteers = ScheduleItem.create!(valid_attrs(title: "Volunteers", audience: "volunteers_only"))

visible = ScheduleItem.visible_to(users(:attendee_one))
assert_includes visible, everyone
assert_not_includes visible, volunteers
end

test "visible_to nil (signed-out) returns only audience: everyone items" do
everyone = ScheduleItem.create!(valid_attrs(title: "Everyone", audience: "everyone"))
volunteers = ScheduleItem.create!(valid_attrs(title: "Volunteers", audience: "volunteers_only"))

visible = ScheduleItem.visible_to(nil)
assert_includes visible, everyone
assert_not_includes visible, volunteers
end

test "visible_to never returns private items regardless of role" do
private_ = ScheduleItem.create!(valid_attrs(title: "Private", is_public: false))
[ users(:jeremy), users(:volunteer_one), users(:attendee_one), nil ].each do |user|
assert_not_includes ScheduleItem.visible_to(user), private_, "private items must not appear for #{user&.role || 'nil'}"
end
end

test "ordered scope orders by day then sort_time" do
wed_am = ScheduleItem.create!(valid_attrs(day: "wed", sort_time: 900, title: "Wed AM"))
wed_pm = ScheduleItem.create!(valid_attrs(day: "wed", sort_time: 1800, title: "Wed PM"))
Expand Down
Loading