Skip to content
Closed
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
3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ gem "will_paginate", "~> 3.1.7"
# gem "ckeditor", "~> 4.3.0" # removed given gh security scan results. still need a replacement.
gem "image_processing"

# Bundle a callout's attached documents into a single downloadable zip
gem "rubyzip", require: "zip"

# Visit and event tracking
gem "ahoy_matey"
# To give group_by_day and similar methods to ActiveRecord relations
Expand Down
1 change: 1 addition & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -824,6 +824,7 @@ DEPENDENCIES
rails (~> 8.1.0)
rspec-rails
rubocop-rails-omakase
rubyzip
search_cop
selenium-webdriver
shoulda-matchers
Expand Down
64 changes: 64 additions & 0 deletions app/controllers/events/callouts_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ def handouts
return redirect_to registration_ticket_path(@event_registration.slug) unless builtin_published?("handouts")
callout = @event.registration_ticket_callouts.find_by(builtin_key: "handouts")
@handout_cards = resource_cards_for(callout, icon: "fa-solid fa-file-pdf", return_to: "handouts")
@download_all_url = download_all_url_for(callout)
end

# Registrant-facing page for a single Resource, shown in the shared callout
Expand Down Expand Up @@ -178,6 +179,17 @@ def faq
@faq_content = callout&.description.presence || BuiltinCallouts.faq_html
end

# Streams every attached document on a callout as a single zip (the "Download
# all" button). Payment is excluded β€” it manages its own documents β€” and a
# callout with fewer than two files 404s, since the button only shows past one.
def download_all
callout = @event.registration_ticket_callouts.find_by(builtin_key: params[:builtin_key])
resources = callout && callout.builtin_key != "payment" ? downloadable_resources(callout) : []
raise ActiveRecord::RecordNotFound if resources.size < 2

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ€– From Claude: Guards the endpoint independently of the button: payment is excluded and anything with fewer than two files 404s, so a hand-crafted URL can't pull a zip the UI would never offer.

send_data build_documents_zip(resources),
filename: documents_zip_filename(callout), type: "application/zip"
end

private

# Whether the event's built-in callout for this key is materialized and
Expand Down Expand Up @@ -228,6 +240,7 @@ def set_builtin_content
@builtin_intro = callout&.description.presence
callout = nil if action_name == "payment"
@builtin_resource_cards = resource_cards_for(callout, icon: "fa-solid fa-file-lines", return_to: action_name)
@download_all_url = download_all_url_for(callout)
end

# This registrant's cards for a callout's linked resources (nil callout β†’ none).
Expand All @@ -239,6 +252,57 @@ def resource_cards_for(callout, icon:, return_to:)
callout.decorate.resource_cards(registrant_slug: @event_registration.slug, return_to:, icon:)
end

# The zip endpoint for a callout's attached documents, or nil when the button
# shouldn't show. Backs the "Download all" button the shared callout chrome
# renders whenever a page has more than one downloadable resource (handouts and
# any built-in callout with attachments β€” Payment passes a nil callout here, so
# its own invoice/receipt/W-9 documents are excluded). Hidden in the sample
# preview, whose sentinel slug wouldn't resolve at the zip endpoint.
def download_all_url_for(callout)
return if callout.nil? || sample_preview?
return unless downloadable_resources(callout).many?
registration_download_all_path(@event_registration.slug, callout.builtin_key)
end

# A callout's linked resources that carry an attached file, in display order.
def downloadable_resources(callout)
callout.registration_ticket_callout_resources.ordered
.includes(resource: { downloadable_asset: :file_attachment })
.filter_map(&:resource)
.select { |resource| resource.downloadable_asset&.file&.attached? }
end

# Builds an in-memory zip of the resources' files, named by resource title and
# de-duped so two documents sharing a title don't collide inside the archive.
def build_documents_zip(resources)
used = Set.new
Zip::OutputStream.write_buffer do |zip|
resources.each do |resource|
file = resource.downloadable_asset.file
zip.put_next_entry(unique_entry_name(resource, file, used))
zip.write(file.download)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ€– From Claude: Builds the whole zip in memory (each file.download loads a blob) β€” fine for a handful of PDFs per callout; would want streaming if these grew large.

end
end.string
end

def unique_entry_name(resource, file, used)
base = resource.title.to_s.strip.gsub(%r{[/\\]}, "-").presence || "document"
ext = File.extname(file.filename.to_s)
name = "#{base}#{ext}"
index = 1
while used.include?(name.downcase)
index += 1
name = "#{base} (#{index})#{ext}"
end
used << name.downcase
name
end

def documents_zip_filename(callout)
base = callout.title.presence || callout.builtin_key.humanize
"#{base.parameterize}-documents.zip"
end

# The join row for @resource on the callout the registrant arrived from
# (via return_to / callout_id), so the resource page can show that callout's
# editable page content under the title.
Expand Down
7 changes: 7 additions & 0 deletions app/views/events/callouts/_callout_page.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@
<h1 class="text-xl font-semibold tracking-wide leading-tight"><%= title %></h1>
<p class="text-sm text-blue-200"><%= @event.title %></p>
</div>
<%# Download every attached document on this page as one zip. The controller
sets @download_all_url only when there's more than one downloadable
resource β€” a single document is grabbed from its own card β€” and never for
Payment, which manages its own invoice/receipt/W-9 documents. %>
<% if @download_all_url %>
<div class="shrink-0"><%= render "events/callouts/download_all_button", url: @download_all_url %></div>
<% end %>
</div>
<div class="absolute inset-x-0 bottom-0 h-1 bg-gradient-to-r from-accent via-amber-400 to-accent"></div>
</div>
Expand Down
7 changes: 7 additions & 0 deletions app/views/events/callouts/_download_all_button.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<%# "Download all" header action for a callout page: a plain link to the callout's
documents.zip endpoint (see CalloutsController#download_all), which bundles every
attached document into one archive. url is the zip path. %>
<%= link_to url, class: "inline-flex items-center gap-2 rounded-lg bg-white/15 px-3 py-1.5 text-sm font-medium text-white ring-1 ring-inset ring-white/30 hover:bg-white/25 transition-colors" do %>
<i class="fa-solid fa-download"></i>
<span class="hidden sm:inline">Download all</span>
<% end %>
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@
get "registration/:slug/handouts", to: "events/callouts#handouts", as: :registration_handouts
get "registration/:slug/resource/:resource_id", to: "events/callouts#resource", as: :registration_resource
get "registration/:slug/videoconference", to: "events/callouts#videoconference", as: :registration_videoconference
get "registration/:slug/:builtin_key/documents.zip", to: "events/callouts#download_all", as: :registration_download_all
post "registration/:slug/resend_confirmation", to: "events/registrations#resend_confirmation", as: :registration_resend_confirmation
post "registration/:slug/cancel", to: "events/registrations#cancel", as: :registration_cancel
post "registration/:slug/reactivate", to: "events/registrations#reactivate", as: :registration_reactivate
Expand Down
90 changes: 90 additions & 0 deletions spec/requests/events/callouts_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,96 @@
end
end

# A single "Download all" header button bundles every attached document on the
# page into one zip. It appears on any callout page with more than one
# downloadable resource, except Payment (which manages its own W-9/invoice).
describe "the Download all button and its zip endpoint" do
def attach_resource_to(callout, title)
resource = create(:resource, title:)
create(:registration_ticket_callout_resource, registration_ticket_callout: callout, resource:)
create(:downloadable_asset, owner: resource)
resource
end

describe "button visibility on the callout page" do
let(:callout) { create(:registration_ticket_callout, event:, builtin_key: "handouts", hidden: false) }

it "links to the documents zip when there's more than one attached resource" do
attach_resource_to(callout, "Aha Moments")
attach_resource_to(callout, "AWBW Training Workshop Worksheets")

get registration_handouts_path(registration.slug)

expect(response.body).to include(registration_download_all_path(registration.slug, "handouts"))
end

it "is omitted when only one resource carries an attached file" do
attach_resource_to(callout, "Aha Moments")

get registration_handouts_path(registration.slug)

expect(response.body).not_to include("documents.zip")
end

it "is omitted when no linked resource carries an attached file" do
resource = create(:resource, title: "Aha Moments")
create(:registration_ticket_callout_resource, registration_ticket_callout: callout, resource:)

get registration_handouts_path(registration.slug)

expect(response.body).not_to include("documents.zip")
end

it "never shows on the payment page, even with downloadable documents linked" do
paid = create(:event, cost_cents: 10_000)
paid_reg = create(:event_registration, event: paid)
payment = create(:registration_ticket_callout, event: paid, builtin_key: "payment")
attach_resource_to(payment, "W-9")
attach_resource_to(payment, "Terms")

get registration_payment_path(paid_reg.slug)

expect(response.body).not_to include("documents.zip")
end
end

describe "GET /registration/:slug/:builtin_key/documents.zip" do
let(:callout) { create(:registration_ticket_callout, event:, builtin_key: "handouts", hidden: false) }

it "streams a zip of every attached document, named by resource title" do
attach_resource_to(callout, "Aha Moments")
attach_resource_to(callout, "Workshop Worksheets")

get registration_download_all_path(registration.slug, "handouts")

expect(response).to have_http_status(:success)
expect(response.media_type).to eq("application/zip")
entries = Zip::File.open_buffer(response.body).map(&:name)
expect(entries).to contain_exactly("Aha Moments.pdf", "Workshop Worksheets.pdf")
end

it "404s when fewer than two documents are attached" do
attach_resource_to(callout, "Only One")

get registration_download_all_path(registration.slug, "handouts")

expect(response).to have_http_status(:not_found)
end

it "404s for the payment callout" do
paid = create(:event, cost_cents: 10_000)
paid_reg = create(:event_registration, event: paid)
payment = create(:registration_ticket_callout, event: paid, builtin_key: "payment")
attach_resource_to(payment, "W-9")
attach_resource_to(payment, "Terms")

get registration_download_all_path(paid_reg.slug, "payment")

expect(response).to have_http_status(:not_found)
end
end
end

describe "GET /registration/:slug/payment" do
let(:event) { create(:event, cost_cents: 10_000) }

Expand Down