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
66 changes: 2 additions & 64 deletions app/controllers/admin/dashboard_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions app/controllers/admin/series_integrations_controller.rb
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions app/helpers/admin/series_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions app/policies/event_series_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
98 changes: 98 additions & 0 deletions app/services/vote/event_linker.rb
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions app/views/admin/event_series/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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 %>
<svg class="w-4 h-4" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
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 %>
<svg class="w-4 h-4" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
Expand Down
110 changes: 110 additions & 0 deletions app/views/admin/series_integrations/show.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<% content_for :title, "#{@series.name} Integrations – Attend" %>

<div class="max-w-5xl mx-auto space-y-6">
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div class="flex items-center gap-3 min-w-0">
<%= render "admin/events/avatar", event: @series, size: 32, rounded: "rounded-lg" %>
<h1 class="text-2xl font-bold text-gray-900 truncate"><%= @series.name %> Integrations</h1>
</div>
<%= 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" %>
</div>

<p class="text-sm text-gray-600">
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.
</p>

<div class="bg-white border border-gray-200 rounded-lg">
<div class="p-4 border-b border-gray-100 flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-purple-100 flex items-center justify-center">
<svg class="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
</div>
<div class="min-w-0">
<h2 class="font-semibold text-gray-900">vote.hackclub.com</h2>
<p class="text-sm text-gray-500">A peer-voting gallery for one event at a time</p>
</div>
</div>

<div class="p-4 space-y-4">
<p class="text-sm text-gray-600">
Creates a <strong>DRAFT</strong> 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.
</p>

<% unless @vote_client.configured? %>
<div class="bg-amber-50 border border-amber-200 rounded px-3 py-2 text-sm text-amber-800">
⚠️ No vote.hackclub.com API key is configured on this server, so nothing can be created.
Set <code class="font-mono">VOTE_API_KEY</code> (or the <code class="font-mono">vote.api_key</code> credential) first.
</div>
<% end %>
</div>

<% if @events.any? %>
<div class="border-t border-gray-100">
<table class="min-w-full divide-y divide-gray-100">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Event</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Gallery</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-100">
<% @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) %>
<tr>
<td class="px-6 py-4">
<div class="text-sm font-medium text-gray-900"><%= event.name %></div>
<div class="text-sm text-gray-500 font-mono"><%= event.slug %></div>
</td>
<td class="px-6 py-4 text-sm">
<% if event.vote_event_linked? %>
<div class="flex flex-wrap items-center gap-2">
<span class="px-2 py-1 text-xs font-medium rounded bg-green-100 text-green-800">Linked</span>
<% if gallery_url %>
<%= link_to "View gallery", gallery_url, target: "_blank", rel: "noopener",
class: "text-[#ec3750] hover:underline font-medium" %>
<% end %>
</div>
<% elsif artwork_ready %>
<span class="text-gray-400">Not created yet</span>
<% else %>
<%# Same bar as the event's own page: vote.hackclub.com fetches both
images itself, so it cannot be created without them. %>
<span class="text-amber-700">Needs a logo and a banner first</span>
<% end %>
</td>
<td class="px-6 py-4 text-right text-sm font-medium">
<div class="flex items-center justify-end gap-4">
<% 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" %>
</div>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% else %>
<div class="border-t border-gray-100 px-6 py-8 text-center">
<p class="text-gray-500">This series has no events yet.</p>
</div>
<% end %>
</div>
</div>
5 changes: 5 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading