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 @@
<%= @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 %> +