diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb index aaa2cb1..fac3614 100644 --- a/app/controllers/admin/dashboard_controller.rb +++ b/app/controllers/admin/dashboard_controller.rb @@ -75,56 +75,9 @@ def create_vote_event # hides it from every non-global admin's audit log. set_current_event(@event) - client = Vote::Client.new - - if @event.vote_event_linked? - redirect_to admin_event_integrations_path(@event), - notice: "This event is already linked to a vote.hackclub.com event." - return - end - - unless client.configured? - redirect_to admin_event_integrations_path(@event), - alert: "The vote.hackclub.com API key is not configured on the server." - return - end - - # Backfill: if a vote event already exists for this slug, link it instead of - # creating a duplicate. - if (existing = client.find_event(@event.slug)) - @event.link_vote_event!(existing) - redirect_to admin_event_integrations_path(@event), - notice: "Linked to the existing vote.hackclub.com event for this slug." - return - end - - unless @event.logo.attached? && @event.banner.attached? - redirect_to admin_event_integrations_path(@event), - alert: "This event needs both a logo and a banner before a vote.hackclub.com event can be created." - return - end - - result = client.create_event( - name: @event.name, - slug: @event.slug, - logo_url: public_attachment_url(@event.logo), - background_url: public_attachment_url(@event.banner), - admins: vote_admin_emails - ) - @event.link_vote_event!(result) - + result = Vote::EventLinker.new(@event).call redirect_to admin_event_integrations_path(@event), - notice: "Created vote.hackclub.com event." - rescue Vote::Error => e - # Lost a race (or slug taken): try to link the now-existing event. - if e.status == 409 && (existing = client.find_event(@event.slug)) - @event.link_vote_event!(existing) - redirect_to admin_event_integrations_path(@event), - notice: "Linked to the existing vote.hackclub.com event for this slug." - else - redirect_to admin_event_integrations_path(@event), - alert: "vote.hackclub.com: #{e.message.presence || 'Failed to create the vote event.'}" - end + (result.linked? ? :notice : :alert) => result.message end def update_integrations @@ -169,21 +122,6 @@ def airtable_settings_saved? AIRTABLE_SETTINGS.any? { |setting| @event.public_send("saved_change_to_#{setting}?") } end - # Emails granted event-admin access on the vote.hackclub.com event. Only - # Attend event admins qualify — ops, safeguarding, and read-only roles don't - # imply control over voting. - def vote_admin_emails - @event.event_role_assignments.event_admin.includes(:user).map { |a| a.user.email } - end - - def public_attachment_url(attachment) - Rails.application.routes.url_helpers.rails_storage_proxy_url( - attachment, - host: ENV.fetch("APP_HOST", "attend.hackclub.com"), - protocol: "https" - ) - end - def load_airtable_sync_status @airtable_sync_configured = current_event.airtable_sync_configured? @airtable_synced_at = current_event.airtable_synced_at diff --git a/app/controllers/admin/series_integrations_controller.rb b/app/controllers/admin/series_integrations_controller.rb new file mode 100644 index 0000000..fcc3479 --- /dev/null +++ b/app/controllers/admin/series_integrations_controller.rb @@ -0,0 +1,49 @@ +module Admin + # Series-level view of the vote.hackclub.com integration. + # + # A vote event is per Attend event, but deciding which of a series' events + # get a voting gallery is a decision about the series — so this lists them + # all with a button each, instead of making somebody walk every satellite's + # own integrations page. + class SeriesIntegrationsController < BaseController + skip_before_action :set_current_event_from_session + + before_action :set_series + before_action :require_series_owner_access + + def show + @vote_client = Vote::Client.new + @events = @series.events + .includes(logo_attachment: :blob, banner_attachment: :blob) + .order(Arel.sql("starts_at ASC NULLS LAST")) + end + + def create_vote_event + event = @series.events.find_by(slug: params[:event_id]) || + @series.events.find_by(id: params[:event_id]) + return redirect_to admin_series_integrations_path(@series), alert: "That event is not in this series." if event.nil? + + authorize event, :update? + + # Same reason as the event's own page: without a current event the audit + # row lands with a null event_id and hides from non-global admins. + set_current_event(event) + + result = Vote::EventLinker.new(event).call + redirect_to admin_series_integrations_path(@series), + (result.linked? ? :notice : :alert) => "#{event.name}: #{result.message}" + end + + private + + def set_series + @series = EventSeries.find_by!(slug: params[:series_slug]) + end + + def require_series_owner_access + return if policy(@series).manage_integrations? + + redirect_to admin_series_path(@series), alert: "Only series owners can manage integrations." + end + end +end diff --git a/app/helpers/admin/series_helper.rb b/app/helpers/admin/series_helper.rb index c08a12f..4030b18 100644 --- a/app/helpers/admin/series_helper.rb +++ b/app/helpers/admin/series_helper.rb @@ -55,6 +55,19 @@ def series_map_markers(rows) end end + # vote.hackclub.com decides its own admin and gallery URLs and we store + # whatever it sends, so they are external input by the time a view renders + # them: anything but http(s) — a `javascript:` URL above all — must never + # reach an href. Returns nil when there is nothing safe to link to, so the + # caller can drop the link entirely. + def external_http_url(url) + return nil if url.blank? + + URI.parse(url).is_a?(URI::HTTP) ? url : nil + rescue URI::InvalidURIError + nil + end + # The participant list for one event, filtered to the people stuck at a stage. # Passing the event slug in the path is what switches the admin event picker # (Admin::BaseController#switch_event_if_needed), so these links land on a diff --git a/app/policies/event_series_policy.rb b/app/policies/event_series_policy.rb index 23bc882..6e39b39 100644 --- a/app/policies/event_series_policy.rb +++ b/app/policies/event_series_policy.rb @@ -28,6 +28,12 @@ def manage_api_tokens? user.series_owner_for?(record) end + # Creating a vote.hackclub.com event publishes the event's name and artwork + # to another service, so the same bar as issuing an API key: owner-only. + def manage_integrations? + user.series_owner_for?(record) + end + def destroy? user.global_admin? end diff --git a/app/services/vote/event_linker.rb b/app/services/vote/event_linker.rb new file mode 100644 index 0000000..1a05f66 --- /dev/null +++ b/app/services/vote/event_linker.rb @@ -0,0 +1,98 @@ +module Vote + # Creates (or adopts) the vote.hackclub.com event for one Attend event, and + # records the linkage. + # + # Two pages drive this — an event's own integrations page and its series' + # integrations page — and the order of the checks is the whole substance of + # it, so it lives here rather than in either controller: already linked, then + # server configured, then adopt an existing event with this slug, then insist + # on artwork, then create. Losing the create race means adopting instead. + class EventLinker + Result = Struct.new(:status, :message, keyword_init: true) do + # Everything the caller shows as a notice rather than an alert: the event + # is linked afterwards, whether this call is what linked it. + def linked? + %i[created linked_existing already_linked].include?(status) + end + end + + def initialize(event, client: nil) + @event = event + @client = client || Vote::Client.new + end + + def call + if @event.vote_event_linked? + return result(:already_linked, "This event is already linked to a vote.hackclub.com event.") + end + + unless @client.configured? + return result(:not_configured, "The vote.hackclub.com API key is not configured on the server.") + end + + # Backfill: if a vote event already exists for this slug, link it instead + # of creating a duplicate. + if (existing = @client.find_event(@event.slug)) + return adopt(existing) + end + + unless artwork_ready? + return result( + :missing_artwork, + "This event needs both a logo and a banner before a vote.hackclub.com event can be created." + ) + end + + @event.link_vote_event!( + @client.create_event( + name: @event.name, + slug: @event.slug, + logo_url: public_attachment_url(@event.logo), + background_url: public_attachment_url(@event.banner), + admins: admin_emails + ) + ) + result(:created, "Created vote.hackclub.com event.") + rescue Vote::Error => e + # Lost a race (or slug taken): try to link the now-existing event. + if e.status == 409 && (existing = @client.find_event(@event.slug)) + adopt(existing) + else + result(:failed, "vote.hackclub.com: #{e.message.presence || 'Failed to create the vote event.'}") + end + end + + # Both pages disable the button without artwork, and say why. + def artwork_ready? + @event.logo.attached? && @event.banner.attached? + end + + private + + def adopt(existing) + @event.link_vote_event!(existing) + result(:linked_existing, "Linked to the existing vote.hackclub.com event for this slug.") + end + + # Emails granted event-admin access on the vote.hackclub.com event. Only + # Attend event admins qualify — ops, safeguarding, and read-only roles + # don't imply control over voting. + def admin_emails + @event.event_role_assignments.event_admin.includes(:user).map { |a| a.user.email } + end + + # vote.hackclub.com fetches these images itself, so they have to be public + # and absolute against the deployed host — not the host of this request. + def public_attachment_url(attachment) + Rails.application.routes.url_helpers.rails_storage_proxy_url( + attachment, + host: ENV.fetch("APP_HOST", "attend.hackclub.com"), + protocol: "https" + ) + end + + def result(status, message) + Result.new(status: status, message: message) + end + end +end diff --git a/app/views/admin/event_series/show.html.erb b/app/views/admin/event_series/show.html.erb index 083f6f0..daeb848 100644 --- a/app/views/admin/event_series/show.html.erb +++ b/app/views/admin/event_series/show.html.erb @@ -65,6 +65,12 @@ API Keys <% end %> <% end %> + <% if policy(@series).manage_integrations? %> + <%= link_to admin_series_integrations_path(@series), class: "inline-flex items-center gap-2 cursor-pointer border border-(--border-strong) bg-(--bg-elev) text-(--text) hover:bg-(--bg-elev-3) hover:text-(--text-strong) text-sm font-medium py-2 px-4 rounded-md transition-colors" do %> + + Integrations + <% end %> + <% end %> <% if policy(@series).update? %> <%= link_to edit_admin_series_path(@series), class: "inline-flex items-center gap-2 cursor-pointer border border-(--border-strong) bg-(--bg-elev) text-(--text) hover:bg-(--bg-elev-3) hover:text-(--text-strong) text-sm font-medium py-2 px-4 rounded-md transition-colors" do %> diff --git a/app/views/admin/series_integrations/show.html.erb b/app/views/admin/series_integrations/show.html.erb new file mode 100644 index 0000000..b833f63 --- /dev/null +++ b/app/views/admin/series_integrations/show.html.erb @@ -0,0 +1,110 @@ +<% content_for :title, "#{@series.name} Integrations – Attend" %> + +
+
+
+ <%= render "admin/events/avatar", event: @series, size: 32, rounded: "rounded-lg" %> +

<%= @series.name %> Integrations

+
+ <%= link_to "API Keys", admin_series_api_tokens_path(@series), class: "w-full sm:w-auto text-center px-4 py-2 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 transition-colors" %> +
+ +

+ Where this series' events get their projects judged. A gallery is per event, so a series + running satellites gets one each — create them here rather than event by event. +

+ +
+
+
+ +
+
+

vote.hackclub.com

+

A peer-voting gallery for one event at a time

+
+
+ +
+

+ Creates a DRAFT vote event from an Attend event's name, slug, logo and banner, + with its event admins as admins over there — you finish setting it up on vote.hackclub.com. + An event that already exists for the same slug is linked rather than duplicated. +

+ + <% unless @vote_client.configured? %> +
+ ⚠️ No vote.hackclub.com API key is configured on this server, so nothing can be created. + Set VOTE_API_KEY (or the vote.api_key credential) first. +
+ <% end %> +
+ + <% if @events.any? %> +
+ + + + + + + + + + <% @events.each do |event| %> + <% artwork_ready = event.logo.attached? && event.banner.attached? %> + <%# vote.hackclub.com sends these; only an http(s) one gets linked. %> + <% gallery_url = external_http_url(event.vote_event_gallery_url) %> + <% admin_url = external_http_url(event.vote_event_admin_url) %> + + + + + + <% end %> + +
EventGalleryActions
+
<%= event.name %>
+
<%= event.slug %>
+
+ <% if event.vote_event_linked? %> +
+ Linked + <% if gallery_url %> + <%= link_to "View gallery", gallery_url, target: "_blank", rel: "noopener", + class: "text-[#ec3750] hover:underline font-medium" %> + <% end %> +
+ <% elsif artwork_ready %> + Not created yet + <% else %> + <%# Same bar as the event's own page: vote.hackclub.com fetches both + images itself, so it cannot be created without them. %> + Needs a logo and a banner first + <% end %> +
+
+ <% if event.vote_event_linked? %> + <% if admin_url %> + <%= link_to "Manage", admin_url, target: "_blank", rel: "noopener", + class: "text-[#ec3750] hover:text-[#d42f46]" %> + <% end %> + <% else %> + <%= button_to "Create gallery", + create_vote_event_admin_series_integrations_path(@series, event_id: event.slug), + method: :post, + disabled: !artwork_ready || !@vote_client.configured?, + data: { turbo_confirm: "Create a DRAFT vote.hackclub.com event for \"#{event.name}\"?" }, + class: "text-[#ec3750] hover:text-[#d42f46] disabled:text-gray-400 disabled:cursor-not-allowed" %> + <% end %> + <%= link_to "Event page", admin_event_integrations_path(event), class: "text-gray-500 hover:text-gray-700" %> +
+
+
+ <% else %> +
+

This series has no events yet.

+
+ <% end %> +
+
diff --git a/config/routes.rb b/config/routes.rb index 7dd99a7..18c2455 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -332,6 +332,11 @@ end resources :series, controller: "event_series", param: :slug, except: [ :destroy ] do + resource :integrations, only: [ :show ], controller: "series_integrations" do + # Vote events are per event, so this one names which of the series' + # events it is for. + post "vote_event/:event_id", action: :create_vote_event, as: :create_vote_event + end resources :members, only: [ :index, :new, :create, :destroy ], controller: "series_members" resources :api_tokens, only: [ :index, :create, :destroy ], controller: "series_api_tokens" do member do diff --git a/spec/requests/admin/series_integrations_spec.rb b/spec/requests/admin/series_integrations_spec.rb new file mode 100644 index 0000000..88f39d0 --- /dev/null +++ b/spec/requests/admin/series_integrations_spec.rb @@ -0,0 +1,170 @@ +require "rails_helper" + +RSpec.describe "Admin::SeriesIntegrations", type: :request do + include Devise::Test::IntegrationHelpers + include ActiveJob::TestHelper + + let(:series) { create(:event_series, name: "Sunbeam", slug: "sunbeam-integrations") } + let!(:sub_event) { create(:event, event_series: series, slug: "sunbeam-one") } + + let(:owner) do + User.create!(email: "owner-integrations@example.com", name: "Owner").tap do |user| + SeriesRoleAssignment.create!(user: user, event_series: series, role: "owner") + end + end + let(:organizer) do + User.create!(email: "organizer-integrations@example.com", name: "Organizer").tap do |user| + SeriesRoleAssignment.create!(user: user, event_series: series, role: "organizer") + end + end + + # Whether the server holds a vote.hackclub.com key. Without one the page + # still has to render — it is where an organizer goes to find that out. + let(:configured) { false } + + before do + allow_any_instance_of(Vote::Client).to receive(:configured?).and_return(configured) + end + + describe "GET" do + it "tells an owner when no vote.hackclub.com key is configured on the server" do + sign_in owner + + get admin_series_integrations_path(series) + + expect(response).to have_http_status(:ok) + expect(response.body).to include("No vote.hackclub.com API key is configured") + end + + context "with a key configured" do + let(:configured) { true } + + it "lists the series' events with a button each" do + sign_in owner + + get admin_series_integrations_path(series) + + expect(response.body).to include("vote.hackclub.com") + expect(response.body).to include(sub_event.name) + expect(response.body).to include("Create gallery") + end + + it "shows an existing gallery instead of offering to create one" do + sub_event.link_vote_event!( + "id" => "vote-evt-1", + "slug" => sub_event.slug, + "adminUrl" => "https://vote.hackclub.com/admin/vote-evt-1", + "galleryUrl" => "https://vote.hackclub.com/vote-evt-1" + ) + sign_in owner + + get admin_series_integrations_path(series) + + expect(response.body).to include("https://vote.hackclub.com/vote-evt-1") + expect(response.body).to include("Linked") + expect(response.body).not_to include("Create gallery") + end + + # The URLs come back from vote.hackclub.com and we store what it sends, so + # a `javascript:` one must never end up in an href. + it "refuses to link a stored URL that is not http(s)" do + sub_event.link_vote_event!( + "id" => "vote-evt-1", + "slug" => sub_event.slug, + "adminUrl" => "javascript:alert(document.cookie)", + "galleryUrl" => "javascript:alert(1)" + ) + sign_in owner + + get admin_series_integrations_path(series) + + expect(response.body).to include("Linked") + expect(response.body).not_to include("javascript:") + expect(response.body).not_to include("View gallery") + expect(response.body).not_to include(">Manage<") + end + end + + it "keeps a series organizer out — creating a gallery publishes the event elsewhere" do + sign_in organizer + + get admin_series_integrations_path(series) + + expect(response).to redirect_to(admin_series_path(series)) + expect(flash[:alert]).to match(/Only series owners/) + end + end + + describe "POST create_vote_event" do + let(:vote_client) { instance_double(Vote::Client, configured?: true) } + + let(:vote_body) do + { + "id" => "vote-evt-1", + "slug" => sub_event.slug, + "adminUrl" => "https://vote.hackclub.com/admin/vote-evt-1", + "galleryUrl" => "https://vote.hackclub.com/vote-evt-1" + } + end + + before { allow(Vote::Client).to receive(:new).and_return(vote_client) } + + it "creates the gallery for one of the series' events and comes back here" do + allow(vote_client).to receive(:find_event).with(sub_event.slug).and_return(vote_body) + sign_in owner + + post create_vote_event_admin_series_integrations_path(series, event_id: sub_event.slug) + + expect(response).to redirect_to(admin_series_integrations_path(series)) + expect(flash[:notice]).to include(sub_event.name) + expect(sub_event.reload.vote_event_id).to eq("vote-evt-1") + end + + # The event is the audited record here, and it needs to be the current one + # or the row lands with a null event_id and hides from non-global admins. + it "audit-logs against the event, not the series" do + allow(vote_client).to receive(:find_event).and_return(vote_body) + sign_in owner + get admin_series_integrations_path(series) + + post create_vote_event_admin_series_integrations_path(series, event_id: sub_event.slug) + + log = AuditLog.find_by!(action: "create_vote_event") + expect(log.record).to eq(sub_event) + expect(log.event).to eq(sub_event) + expect(log.actor).to eq(owner) + end + + it "explains itself when the event has no artwork yet" do + allow(vote_client).to receive(:find_event).and_return(nil) + allow(vote_client).to receive(:create_event) + sign_in owner + + post create_vote_event_admin_series_integrations_path(series, event_id: sub_event.slug) + + expect(flash[:alert]).to include("needs both a logo and a banner") + expect(vote_client).not_to have_received(:create_event) + end + + it "refuses an event in another series" do + elsewhere = create(:event, slug: "not-in-this-series") + allow(vote_client).to receive(:find_event) + sign_in owner + + post create_vote_event_admin_series_integrations_path(series, event_id: elsewhere.slug) + + expect(flash[:alert]).to eq("That event is not in this series.") + expect(elsewhere.reload.vote_event_id).to be_nil + end + + it "keeps a series organizer out" do + allow(vote_client).to receive(:find_event) + sign_in organizer + + post create_vote_event_admin_series_integrations_path(series, event_id: sub_event.slug) + + expect(response).to redirect_to(admin_series_path(series)) + expect(sub_event.reload.vote_event_id).to be_nil + end + end +end diff --git a/spec/services/vote/event_linker_spec.rb b/spec/services/vote/event_linker_spec.rb new file mode 100644 index 0000000..b443708 --- /dev/null +++ b/spec/services/vote/event_linker_spec.rb @@ -0,0 +1,129 @@ +require "rails_helper" + +RSpec.describe Vote::EventLinker do + subject(:linker) { described_class.new(event, client: client) } + + let(:event) { create(:event, slug: "sunbeam-vote") } + let(:client) { instance_double(Vote::Client, configured?: true) } + + let(:created_body) do + { + "id" => "vote-evt-1", + "slug" => "sunbeam-vote", + "adminUrl" => "https://vote.hackclub.com/admin/vote-evt-1", + "galleryUrl" => "https://vote.hackclub.com/vote-evt-1" + } + end + + def attach_artwork + png = file_fixture("headshot.png").binread + event.logo.attach(io: StringIO.new(png), filename: "logo.png", content_type: "image/png") + event.banner.attach(io: StringIO.new(png), filename: "banner.png", content_type: "image/png") + end + + it "creates the vote event and records the linkage" do + attach_artwork + admin = User.create!(email: "vote-admin@example.com", name: "Vote Admin") + EventRoleAssignment.create!(user: admin, event: event, role: "event_admin") + allow(client).to receive(:find_event).with("sunbeam-vote").and_return(nil) + allow(client).to receive(:create_event).and_return(created_body) + + result = linker.call + + expect(result.status).to eq(:created) + expect(result).to be_linked + expect(event.reload.vote_event_id).to eq("vote-evt-1") + expect(event.vote_event_gallery_url).to eq("https://vote.hackclub.com/vote-evt-1") + expect(client).to have_received(:create_event).with( + hash_including( + name: event.name, + slug: "sunbeam-vote", + # vote.hackclub.com fetches the images itself, so they point at the + # deployed host rather than whatever host served this request. + logo_url: a_string_starting_with("https://"), + background_url: a_string_starting_with("https://"), + admins: [ "vote-admin@example.com" ] + ) + ) + end + + it "only offers Attend event admins as vote admins" do + attach_artwork + ops = User.create!(email: "vote-ops@example.com", name: "Ops") + EventRoleAssignment.create!(user: ops, event: event, role: "ops") + allow(client).to receive(:find_event).and_return(nil) + allow(client).to receive(:create_event).and_return(created_body) + + linker.call + + expect(client).to have_received(:create_event).with(hash_including(admins: [])) + end + + it "adopts an existing vote event with the same slug instead of duplicating it" do + attach_artwork + allow(client).to receive(:find_event).with("sunbeam-vote").and_return(created_body) + allow(client).to receive(:create_event) + + result = linker.call + + expect(result.status).to eq(:linked_existing) + expect(result).to be_linked + expect(event.reload.vote_event_id).to eq("vote-evt-1") + expect(client).not_to have_received(:create_event) + end + + it "adopts the winner when it loses the create race" do + attach_artwork + allow(client).to receive(:find_event).with("sunbeam-vote").and_return(nil, created_body) + allow(client).to receive(:create_event).and_raise(Vote::Error.new("Slug taken", status: 409)) + + result = linker.call + + expect(result.status).to eq(:linked_existing) + expect(event.reload.vote_event_id).to eq("vote-evt-1") + end + + it "refuses without both a logo and a banner" do + allow(client).to receive(:find_event).and_return(nil) + allow(client).to receive(:create_event) + + result = linker.call + + expect(result.status).to eq(:missing_artwork) + expect(result).not_to be_linked + expect(client).not_to have_received(:create_event) + end + + it "says so when the event is already linked, without calling out at all" do + event.link_vote_event!(created_body) + allow(client).to receive(:find_event) + + result = linker.call + + expect(result.status).to eq(:already_linked) + expect(result).to be_linked + expect(client).not_to have_received(:find_event) + end + + it "says so when the server has no vote.hackclub.com key" do + allow(client).to receive(:configured?).and_return(false) + allow(client).to receive(:find_event) + + result = linker.call + + expect(result.status).to eq(:not_configured) + expect(client).not_to have_received(:find_event) + end + + it "surfaces any other failure with the API's own message" do + attach_artwork + allow(client).to receive(:find_event).and_return(nil) + allow(client).to receive(:create_event).and_raise(Vote::Error.new("Name is required", status: 422)) + + result = linker.call + + expect(result.status).to eq(:failed) + expect(result.message).to eq("vote.hackclub.com: Name is required") + expect(event.reload.vote_event_id).to be_nil + end +end