diff --git a/Gemfile b/Gemfile index b94b5a131..ed351306e 100644 --- a/Gemfile +++ b/Gemfile @@ -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 diff --git a/Gemfile.lock b/Gemfile.lock index 076112146..f90fde0bd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -824,6 +824,7 @@ DEPENDENCIES rails (~> 8.1.0) rspec-rails rubocop-rails-omakase + rubyzip search_cop selenium-webdriver shoulda-matchers diff --git a/app/controllers/events/callouts_controller.rb b/app/controllers/events/callouts_controller.rb index 056f94e3d..b9f6140e0 100644 --- a/app/controllers/events/callouts_controller.rb +++ b/app/controllers/events/callouts_controller.rb @@ -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 @@ -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 + 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 @@ -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). @@ -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) + 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. diff --git a/app/views/events/callouts/_callout_page.html.erb b/app/views/events/callouts/_callout_page.html.erb index 983defbb5..272dde551 100644 --- a/app/views/events/callouts/_callout_page.html.erb +++ b/app/views/events/callouts/_callout_page.html.erb @@ -30,6 +30,13 @@

<%= title %>

<%= @event.title %>

+ <%# 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 %> +
<%= render "events/callouts/download_all_button", url: @download_all_url %>
+ <% end %>
diff --git a/app/views/events/callouts/_download_all_button.html.erb b/app/views/events/callouts/_download_all_button.html.erb new file mode 100644 index 000000000..794f83b58 --- /dev/null +++ b/app/views/events/callouts/_download_all_button.html.erb @@ -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 %> + + +<% end %> diff --git a/config/routes.rb b/config/routes.rb index f1641c215..d55bbf514 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/spec/requests/events/callouts_spec.rb b/spec/requests/events/callouts_spec.rb index ba6818a9f..e744e8933 100644 --- a/spec/requests/events/callouts_spec.rb +++ b/spec/requests/events/callouts_spec.rb @@ -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) }