Skip to content

Upload the register step_2 photo direct to R2 - #3974

Draft
sethherr wants to merge 131 commits into
mainfrom
direct-upload-activestorage
Draft

Upload the register step_2 photo direct to R2#3974
sethherr wants to merge 131 commits into
mainfrom
direct-upload-activestorage

Conversation

@sethherr

@sethherr sethherr commented Jul 28, 2026

Copy link
Copy Markdown
Member

The register step_2 photo now uploads browser → R2 the moment it's picked, so the form posts a signed blob id instead of megabytes of multipart body and the submit no longer carries a synchronous S3 store. It lands as a PublicImage on the ActiveStorage backend from #3969, which strips EXIF and builds variants off the request.

Stacked on #3850. The bottom two commits are #3969, which needs to merge first — reviewable diff is the top four.

  • The signed id rides on the b_param as params["image_signed_id"] rather than an attachment, so the PublicImage is the blob's only owner. attach_photo keeps its CarrierWave branch for the other upload paths (bikes_controller, organized/bikes_controller, the embed form).
  • Two direct upload endpoints, split by how they authorize, because ActiveStorage's own accepts any content type at any size from anyone. Register::DirectUploadsController takes a b_param_token (a photo is picked before there's an account); the ActiveStorage route stays shadowed and requires a signed-in user, so the stock controller isn't reachable and later signed-in surfaces have somewhere to upload. Both inherit the content type and size checks — the presigned URL is signed against what the browser declared, so refusing there is what makes an oversized or non-image upload impossible rather than merely unused. Per-IP throttles: 10/hour for the registration endpoint, 20/minute for the authenticated one.
  • PublicImage also validates an assigned file as a backstop, derived from ApplicationUploader.extensions. CSP gains the R2 endpoint, which the presigner addresses virtual-host style.
  • CleanUnattachedBlobsJob reaps unattached blobs after 30 days, long enough that a partial registration confirmed weeks later still finds its photo. Stolen alert images are excluded — StolenRecord keeps superseded ones unattached on purpose. A photo that does age out is dropped without taking the registration with it.

Both R2 buckets already allow PUT from any origin with any header, so no storage config change is needed.

sethherr and others added 30 commits July 13, 2026 15:58
Multi-step "quick start + complete" registration from the
kelsey_redesign/new_registration prototype: step 1 creates a BParam
partial registration and sends the confirmation email, step 2 collects
details, an e-vehicle attestation step appears for motorized bikes.
Signed-in users get a bike created immediately; anonymous users finish
via the emailed link with everything they entered saved on the b_param.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds registration_flow to Ownership::ORIGIN_ENUM and includes it in
BParam partial-registration scoping, so the new flow keeps the
partial-registration email, org incomplete lists, exports, and
AfterBikeSaveJob auto-matching.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces the hand-rolled manufacturer typeahead Stimulus controller;
free_text handles self-reported manufacturers. Documents the
Form::Combobox rule in the frontend-conventions skill.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Drops the prototype copies in app/assets/images/registration/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Motorized registrations now complete directly from the details step;
the electric checkbox on step 1 still sets propulsion_type_motorized.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The partial registration email for registration_flow b_params now links
to register/confirm with a confirmation token that only travels in the
email (the id_token is visible to the registrant, so it cannot prove
control of the email). Confirming creates the bike - creator falls back
to the email user or AUTO_ORG_MEMBER - or returns to the details step
if step 2 was skipped. The complete page now reads as registration
complete pending email verification instead of check-your-email.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… background, no card

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds an icon: option to UI::Alert for swapping the default info icon.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Also: frontend-screenshots skill now captures full pages with the
footer hidden at both viewports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds :muted color and :xs size to UI::Badge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tion-flow

# Conflicts:
#	app/components/ui/alert/component_preview.rb
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tion-flow

# Conflicts:
#	.claude/skills/frontend-conventions/SKILL.md
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds a purple_link color to UI::Button (bold #715eb2, no underline).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Purple add/remove links reveal the secondary and tertiary color selects
via the shared collapse utils; removing clears the select, and a posted
blank clears a previously saved additional color.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…moves to details

The cycle type select replaces the model field, with the electric
checkbox forced on/off for always/never-motorized types and throttle/
pedal-assist options revealed for motorized pedal vehicles - mirroring
the registration embed's UpdatePropulsionType behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The electric checkbox and its always/never-motorized locking stay;
throttle/pedal-assist selection is no longer collected in the flow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Swaps the select for Form::Combobox; the propulsion controller now
listens for hw-combobox:selection and reads the hidden field.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An input can't render two-tone text, so a mirror overlay covers the
combobox input while it isn't focused for filtering.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New registrations live at /register/new; the bare /register redirects
there. Path helpers become new_register/details_register/
confirm_register/complete_register.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Buttons and links go redesign blue (new UI::Button blue/blue_link,
replacing purple_link); step 1 gets the electric helper text and the
required email asterisk back. The details step adds model year, a
photo upload (Take Photo / Upload into b_param.image), a required
serial with a mark-missing checkbox (stored as unknown), per-position
color placeholders with +/× add-remove links, a registration type
dropdown replacing the radio cards, and a back link.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tion-flow

# Conflicts:
#	app/components/ui/button/component.rb
sethherr and others added 6 commits July 28, 2026 13:53
The trait declared image/jpeg for every image_path, which read as wrong next to
a .tif or .heic fixture. It was harmless - ActiveStorage re-identifies from the
bytes on upload, which is why the tiff spec saw image/tiff regardless - but it
would mislead the next person to add a fixture.

Co-Authored-By: Claude <noreply@anthropic.com>
Running the spec/services suite re-recorded them under record: :new_episodes,
changing only timestamps and Heroku NEL tokens, and `git add -A` picked them up.
Reverted to their prior content - no behaviour change either way.

Co-Authored-By: Claude <noreply@anthropic.com>
…into direct-upload-activestorage

# Conflicts:
#	app/components/register/step2/component.en.yml
#	app/controllers/register_controller.rb
#	spec/integration/registration/register_spec.rb
#	spec/requests/register_request_spec.rb
…o direct-upload-activestorage

# Conflicts:
#	spec/jobs/images/process_public_image_job_spec.rb
#	spec/vcr_cassettes/process_public_image_job-heic_r2.yml
ProcessPublicImageJob now converts HEIC (and TIFF) to webp instead of
re-saving in the source format, so heifsave is never called and the encoder
it needed isn't either. Confirmed against ruby:4.0.5-slim without the plugin:
HEIC decodes and webp encodes fine.

Reverts 2a70659. Worth not shipping a patent-encumbered codec we don't use.

Co-Authored-By: Claude <noreply@anthropic.com>
Extracts the parts of the in-progress registration flow that aren't
specific to it: UI::Forms::Combobox rich_display:, the form-persist
handling for checkboxes/radios/comboboxes, and the submit spinner's
bfcache reset. Adds a rich_display Lookbook preview and drives it from
the combobox system spec.

Co-Authored-By: Claude <noreply@anthropic.com>
sethherr and others added 15 commits July 28, 2026 15:19
hotwire_combobox cancels Enter whether or not its listbox is open, so a
combobox with nothing to pick swallowed the form's implicit submission --
unlike every other input in the form.

Co-Authored-By: Claude <noreply@anthropic.com>
Pulls the outline icon from the register flow branch into the shared icon
set, so the one inlined in UI::Alert stops being a private copy.

Co-Authored-By: Claude <noreply@anthropic.com>
- Organization: attach it from organization_id on any step (resolving slugs, not
  just on register/new), name it in the step 1 heading, and post it back from
  step 1 so a submission with an expired token keeps it
- Match the registration fields bikes/new offers - phone, address, extra
  registration number, affiliation and student id - each behind the same
  include_field_reg_ gate, rechecked client-side since status is picked in-form
- Step 1: a start over link, autofocus, a live cycle type in the heading, and
  building a registration rather than dropping a submission with a stale token
- Combobox: a stacked rich_display (muted second line) for registration type,
  holding the overlay until a query is typed; form-persist restores selects
- Rename the registration_flow origin to register_flow, and move the flow's
  derived logic off BParam onto BikeServices::Register

Co-Authored-By: Claude <noreply@anthropic.com>
Takes the latest shared UI from the register flow branch: rich_display:
:stacked for options that are a title over a muted description, a display
overlay that steps aside on a typed query rather than on focus, and
form-persist restoring selects from the draft.

Co-Authored-By: Claude <noreply@anthropic.com>
The dialog fills the input after the selection event and never focuses it,
so the query check blanked the overlay on mobile. A query is only possible
while the input has focus.

Co-Authored-By: Claude <noreply@anthropic.com>
A combobox always renders with a selection - BParam#status falls back to
status_with_owner - so the "only fill what the server left empty" rule meant
the draft never won, and the register flow's registration type reverted on
reload.

Co-Authored-By: Claude <noreply@anthropic.com>
… cleanups

- The status combobox renders BParam#status, which falls back to
  status_with_owner - so form-persist saw a value and skipped restoring the
  draft. UI::Forms::Combobox takes value_is_default: to mark a suggestion the
  draft outranks, and register--status-fields reconciles on the restore so the
  fields it gates reopen with it
- Move the organization's own fields under its section heading, beside the
  contact fields, rather than leaving them up with the bike details
- Skip the address block entirely when no organization asks for it - it was
  ~300 country/state options and two queries no status could reveal
- Reuse CycleType.slug_translation_hash_lowercase_short and
  BParam.address_record_attributes instead of rebuilding both
- Memoize the include_field_reg_ gates, split find_or_build_b_param out of
  find_b_param, and stop combobox_display repainting twice per keystroke

Co-Authored-By: Claude <noreply@anthropic.com>
… into sethherr/new-registration-flow

# Conflicts:
#	.claude/skills/frontend-conventions/SKILL.md
#	app/components/ui/forms/combobox/component.rb
#	app/javascript/controllers/form_persist_controller.js
#	app/javascript/controllers/ui/forms/combobox_display_controller.js
input and keyup both fire per keystroke, so sync ran twice for every
character - and whenever the input matched the selection (which hotwire's
inline autocomplete makes the common case) each run called reposition,
forcing layout. Typing "Stolen" into the registration type combobox went
from 10 getBoundingClientRect calls to 6.

Co-Authored-By: Claude <noreply@anthropic.com>
- form-persist dispatches change after restoring a select. Assigning the value
  fires nothing, so the org impound form's kind select and the address group's
  country select both restored without their dependent fields following - the
  impound one left transfer_email disabled, failing validation on submit
- Collapsed status fields are disabled, not just hidden. A stolen registration
  was still posting the address (its country select always has a value), and a
  phone the status no longer asks for
- register/new attaches its organization through assign_organization like every
  other action, so reusing the session's registration can't drop it; and start
  over carries the organization and status onto the replacement
- register/new takes email: "false" leaves it blank even for a signed-in user,
  any other value stands in for theirs, blank falls back to it
- Cover the org auto_user path: creator_available? counts auto_user_id and the
  service never assigns it, but BikeServices::Builder does while building

Co-Authored-By: Claude <noreply@anthropic.com>
The flow no longer emails on step 1, so save_step_1 was left doing nothing but
saving - inlined into the controller. Email::PartialRegistrationJob and the
mailer's register_flow branch stay: an organization can still resend from its
incomplete registrations, and that link still resolves through confirm_email.

- Awaiting confirmation is now BParam#email_unconfirmed? (an address is set and
  nothing has proven it) rather than "a partial email went out to this address",
  which nothing sets anymore
- Photos need an account behind them, so step 2 only offers the upload to a
  signed-in registrant or a confirmed address
- The finished page lists what confirming unlocks - reporting it stolen, impound
  details, photos - so the link is worth clicking

Co-Authored-By: Claude <noreply@anthropic.com>
…view

Nothing has written partial_email_sent_to since the flow stopped emailing, so
step 1's note keyed off a value that could only be blank. The reader and the
spec that hand-wrote it are gone.

- UI::Button::ComponentPreview#submitting was defined twice - the merge took
  both branches' copies without conflicting
- assign_owner_email writes through clean_params, matching assign_organization
  beside it, and register/new reuses the controller's own assign_organization
- Fold same-setup register spec examples together

Co-Authored-By: Claude <noreply@anthropic.com>
…into direct-upload-activestorage

# Conflicts:
#	app/components/register/step2/component.html.erb
#	app/models/b_param.rb
#	spec/integration/registration/register_spec.rb
#	spec/services/bike_services/register_spec.rb
Base's step 2 only renders the photo field for a signed-in or email-confirmed
registration, so the anonymous flow example can't reach it - it asserts the
field's absence instead, and the signed-in R2 example below still drives a real
upload. The request specs marked that section by the carrierwave field name,
which this branch replaced with a nameless input, so they key off its id.

Co-Authored-By: Claude <noreply@anthropic.com>
sethherr added a commit that referenced this pull request Jul 29, 2026
Lifts the parts of the registration flow (#3850, #3974) that aren't
specific to it, so they land and get reviewed on their own.

- `UI::Forms::Combobox` gains `rich_display:` — `:inline` or `:stacked`
— which mirrors the selected option's rich `content` onto the closed
input through an overlay, since an `<input>` can't render two-tone text
itself. `:stacked` puts a muted line under the title and grows the input
to fit. The combobox now always renders in a wrapper, so
`wrapper_class:` (for the parent's layout) applies either way and the
markup doesn't shift when the rich display is toggled; only a rich
display attaches the Stimulus controller.
- Enter now submits the form when the listbox is closed.
hotwire_combobox cancels the key whether or not it has anything to
select, so a combobox was the one input in a form that swallowed
implicit submission.
- `form-persist` now covers checkboxes, radios, selects and
hotwire_combobox pairs (hidden value + display text), skips file inputs,
and defers its restore a tick so it can announce `form-persist:restored`
to controllers whose UI hangs off restored fields. Fields that always
render with a selection let the draft win — a combobox has no empty
state, so "only fill what the server left blank" meant its draft never
restored.
- `UI::Button spinner: true` resets on `pageshow` as well as `connect` —
a bfcache restore resumes the page without reconnecting, so returning to
a submitted form left the button stuck disabled.
- Also here: an `autofocus` controller that focuses the first visible
field in its element, and the info icon `UI::Alert` had inlined moves to
`app/assets/images/icons/info.svg`, in the outline style the rest of
that directory uses.
Base automatically changed from sethherr/new-registration-flow to main July 29, 2026 05:17
sethherr and others added 6 commits July 28, 2026 22:29
…torage

# Conflicts:
#	app/components/register/step1/component.en.yml
#	app/components/register/step1/component.html.erb
#	app/components/register/step2/component.en.yml
#	app/components/register/step2/component.html.erb
#	app/components/register/step_finished/component.en.yml
#	app/components/register/step_finished/component.html.erb
#	app/components/register/step_finished/component.rb
#	app/components/ui/forms/combobox/component.rb
#	app/components/ui/forms/combobox/component_preview/rich_display.html.erb
#	app/controllers/register_controller.rb
#	app/javascript/controllers/form_persist_controller.js
#	app/javascript/controllers/register/photo_controller.js
#	app/javascript/controllers/ui/forms/combobox_display_controller.js
#	app/services/bike_services/register.rb
#	config/locales/en.yml
#	config/routes.rb
#	spec/components/ui/forms/combobox/component_spec.rb
#	spec/components/ui/forms/combobox/component_system_spec.rb
#	spec/integration/registration/register_spec.rb
#	spec/jobs/clean_b_params_job_spec.rb
#	spec/mailers/organized_mailer_spec.rb
#	spec/requests/register_request_spec.rb
#	spec/services/bike_services/register_spec.rb
main dropped the register flow's emailed confirmation link, so nothing sets
email_confirmed? any more and show_photo?'s second branch was unreachable -
the photo field only ever renders for a signed-in registrant. That leaves
Register::DirectUploadsController and its b_param token authorizing a case
that can't happen, so both go, along with their throttle and specs.

rails_direct_uploads_path is the one endpoint again, and its controller
absorbs the base class that existed only to be shared.

Co-Authored-By: Claude <noreply@anthropic.com>
b_param.rb auto-merged without a conflict and kept this branch's side of lines
main had deleted, so the whole register-flow confirmation-token machinery came
back: PARTIAL_REGISTRATION_ORIGINS with register_flow in it (main narrowed
partial_registration? to embed_partial), register_flow?, confirmation_token and
its before_create, plus the emailed-link branch in the partial registration
email. All of it is dead - main dropped that link.

CleanBParamsJob's 3-day window came along the same way; main kept 24 hours, and
it isn't this branch's change to carry. Also resets eleven cassettes this branch
had committed timestamp churn into.

Co-Authored-By: Claude <noreply@anthropic.com>
Matches STORAGE_HOST above, and drops the compact_blank that only existed
because the values could be missing.

Co-Authored-By: Claude <noreply@anthropic.com>
The register flow had its own file input, buttons and controller, which is what
the shared component already does - minus the direct upload, so that moves into
the component behind a direct_upload: flag: nameless field, hidden signed id,
and the upload in ui--forms--file-upload. Off by default, so the org avatar,
parking notification and registration sequence fields are untouched.

Register::Photo's controller and its four translations go with it.

Co-Authored-By: Claude <noreply@anthropic.com>
Every silent breakage in the direct-upload branch's merges came from hunks git
resolved without asking - a squash-merged base has unrelated history, so it kept
this branch's side of lines the base had deleted and reported the file as
auto-merged. Nothing in the skill said to look.

Adds the post-merge diff-against-base audit, running the linter (the only thing
that caught a duplicated method), the semantic conflicts specs catch and markers
don't, re-resolving a base whose PR has merged, and two resolution traps: order
matters when both sides append to a hunk, and don't blanket-replace renamed copy.

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant