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" %> + +
+ 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. +
+ +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? %> +VOTE_API_KEY (or the vote.api_key credential) first.
+ | Event | +Gallery | +Actions | +
|---|---|---|
|
+ <%= 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" %>
+
+ |
+
This series has no events yet.
+