studio-engine 0.69.4 → 0.69.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +61 -4
- data/app/controllers/studio/onboarding_controller.rb +70 -8
- data/app/views/studio/modals/onboarding/_first_name.html.erb +5 -2
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +34 -5
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 44ac04785253cb277ef70cd1d6398b81718e223adc7e300c1a19329183a88be5
|
|
4
|
+
data.tar.gz: 01c13c3974eb5da7887fc82e77f3526eb50af53a3e590b993a7602914e8daa81
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: db96cbe447a67732cc7d2babc54f605071ccd9e3d77d20da4fb7486c5e89f5354e21dc34066757ec7bde7f66fb7fb08e257a15c364753d3a98f34ea8065df25b
|
|
7
|
+
data.tar.gz: f8a47251bf78b0c3bab42dfc743565c2dba66502f6f204516718144cd9506a5206bf6342b7ce496fa12f6f67a34727dc8fc7cbc5e03529ce5d3ab1e3727b4761
|
data/CHANGELOG.md
CHANGED
|
@@ -55,6 +55,57 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
55
55
|
|
|
56
56
|
### Fixed
|
|
57
57
|
|
|
58
|
+
- **Onboarding no longer truncates a surname to fit a first name's cap.**
|
|
59
|
+
`Studio::OnboardingController#first_name` measured the WHOLE typed answer with
|
|
60
|
+
`Studio::FIRST_NAME_MAX_LENGTH` — the PER-FIELD cap, 40. The field asks for a
|
|
61
|
+
name and people type a full one, so "Bartholomew Fitzwilliam
|
|
62
|
+
Montgomery-Smythe" (41 characters) stored 40 of them and handed the account
|
|
63
|
+
back its own surname misspelled, **"Montgomery-Smyth"**, on every surface
|
|
64
|
+
`name` feeds.
|
|
65
|
+
|
|
66
|
+
**ONE CONSTANT WAS ANSWERING TWO QUESTIONS**, which is why neither call site
|
|
67
|
+
looked wrong. `/profile` applies the same number PER FIELD (`normalized_name`,
|
|
68
|
+
once to each of its two inputs); onboarding applied it to first name PLUS
|
|
69
|
+
surname in one string. The fix NAMES the second question rather than
|
|
70
|
+
re-scoping the first: **`Studio::FULL_NAME_MAX_LENGTH`**, the whole-answer
|
|
71
|
+
cap, `(FIRST_NAME_MAX_LENGTH * 2) + 1` = **81**. It is DERIVED, not picked —
|
|
72
|
+
first(40) + a space + last(40) is the longest answer whose two halves BOTH
|
|
73
|
+
still fit the per-field cap, so onboarding can never accept a name `/profile`
|
|
74
|
+
would silently shorten on the next save, which is the drift the shared
|
|
75
|
+
constant existed to prevent. `FIRST_NAME_MAX_LENGTH` keeps its value and its
|
|
76
|
+
meaning, and `test/integration/profile_name_field_caps_test.rb` now proves
|
|
77
|
+
that by sending BOTH fields over-long in one request and reading two 40s back
|
|
78
|
+
— a single-field assertion cannot tell "per field" from "per whole name".
|
|
79
|
+
|
|
80
|
+
**PAST THE CAP IT REFUSES INSTEAD OF TRUNCATING** (422 + `{ error: … }`,
|
|
81
|
+
rendered inline by the modal). `.first(cap)` on a name is not a cap but a
|
|
82
|
+
rewrite: it accepted the answer, stored something nobody typed, and reported
|
|
83
|
+
success — and raising the number alone would only move that cliff one
|
|
84
|
+
character further out. Two independent rules, because the whole answer becomes
|
|
85
|
+
`name` while each half becomes its own column: the answer must fit the
|
|
86
|
+
whole-answer cap, and each derived half must fit the per-field cap (a single
|
|
87
|
+
60-character word passes the first and would still land a `first_name`
|
|
88
|
+
`/profile` shortens). The onboarding input's `maxlength` default was a LITERAL
|
|
89
|
+
`40` — a third copy of the per-field number, on the one field that receives a
|
|
90
|
+
whole name — and now reads `FULL_NAME_MAX_LENGTH`, so the browser bound and
|
|
91
|
+
the server bound cannot drift.
|
|
92
|
+
|
|
93
|
+
**NEITHER CAP IS A COLUMN CEILING.** Measured 2026-09-06 across all six
|
|
94
|
+
consumer databases (mcritchie-studio, turf-monster, mcritchie-industries;
|
|
95
|
+
production and QA): `name`, `first_name` and `last_name` are unbounded
|
|
96
|
+
`character varying` — `character_maximum_length` NULL, no CHECK constraint, no
|
|
97
|
+
model length validation. Raising the bound cannot trade a silent truncation
|
|
98
|
+
for a raised error at any value, so this is a product decision start to
|
|
99
|
+
finish.
|
|
100
|
+
|
|
101
|
+
**NO DATA REPAIR SHIPS.** Re-measured for THIS defect on the same six
|
|
102
|
+
databases: **zero rows** carry a `name`, `first_name` or `last_name` at or
|
|
103
|
+
over 40 characters, and the longest `name` in the fleet is 27 (control:
|
|
104
|
+
5 / 6 / 46 / 15 / 4 / 4 users scanned, `max(length(name))` non-null in every
|
|
105
|
+
one). Nothing was truncated in production, so there is nothing to repair — and
|
|
106
|
+
a row whose halves disagree with its `name` because of a `/profile` edit must
|
|
107
|
+
never be auto-repaired anyway.
|
|
108
|
+
|
|
58
109
|
- **Onboarding no longer stores a two-word answer as the whole first name.**
|
|
59
110
|
`Studio::OnboardingController#first_name` writes with `update_columns`, which
|
|
60
111
|
skips callbacks — so the host's `before_save :set_name_parts` never ran and
|
|
@@ -72,10 +123,16 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
72
123
|
`test/integration/onboarding_name_parts_test.rb` carries that as a live
|
|
73
124
|
control (`update!` moves `pat-pat@example.com` → `ada-lovelace-pat@…`), not as
|
|
74
125
|
a claim. `last_name` is written only where the column exists: the engine's own
|
|
75
|
-
standard-columns migration does not carry it, so
|
|
76
|
-
|
|
77
|
-
`MissingAttributeError` 500 on every
|
|
78
|
-
(`test/integration/onboarding_thin_host_test.rb`).
|
|
126
|
+
standard-columns migration does not carry it, so a host that opts into these
|
|
127
|
+
routes before it runs that column would otherwise take a
|
|
128
|
+
`MissingAttributeError` 500 on every two-word answer
|
|
129
|
+
(`test/integration/onboarding_thin_host_test.rb`). **Corrected 2026-09-06:**
|
|
130
|
+
an earlier version of this entry named mcritchie-industries, moms-app and
|
|
131
|
+
acquisition-studio as apps that would have taken that 500. They carry no
|
|
132
|
+
`last_name`, but they do not mount these endpoints at all —
|
|
133
|
+
`Studio.draw_onboarding_routes` defaults to false and only mcritchie-studio
|
|
134
|
+
and turf-monster set it true, and both of those HAVE the column. The guard is
|
|
135
|
+
a standing one for the next host to opt in, not a live save.
|
|
79
136
|
NO DATA REPAIR SHIPS: measured 2026-09-05 across both production databases
|
|
80
137
|
(mcritchie-studio 5 users, turf-monster 43) and both QA ones, **zero rows**
|
|
81
138
|
carry a space in `first_name`. Repairing rows whose halves merely disagree
|
|
@@ -23,22 +23,40 @@ module Studio
|
|
|
23
23
|
# Both actions are for the freshly signed-in user, so authentication is the
|
|
24
24
|
# host's default require_authentication — no skip_before_action here.
|
|
25
25
|
|
|
26
|
-
# The
|
|
27
|
-
# here because it is referenced from outside
|
|
28
|
-
# it is no longer a SECOND definition of
|
|
29
|
-
# same
|
|
26
|
+
# The PER-FIELD cap (Studio::FIRST_NAME_MAX_LENGTH) — what one derived half
|
|
27
|
+
# may be. Kept under the old name here because it is referenced from outside
|
|
28
|
+
# this class; what changed is that it is no longer a SECOND definition of
|
|
29
|
+
# the same number. /profile writes the same columns and reads the same
|
|
30
|
+
# constant.
|
|
30
31
|
MAX_FIRST_NAME = Studio::FIRST_NAME_MAX_LENGTH
|
|
31
32
|
|
|
33
|
+
# The WHOLE-ANSWER cap (Studio::FULL_NAME_MAX_LENGTH) — what this endpoint
|
|
34
|
+
# actually bounds. It is a different question from the one above and now has
|
|
35
|
+
# a different name: the field asks for a name and people type a full one, so
|
|
36
|
+
# the string arriving here is first name PLUS surname, and measuring it with
|
|
37
|
+
# the per-field number stored 40 characters of a 41-character name and
|
|
38
|
+
# handed the account back a misspelled surname.
|
|
39
|
+
MAX_FULL_NAME = Studio::FULL_NAME_MAX_LENGTH
|
|
40
|
+
|
|
32
41
|
# POST /onboarding/first_name
|
|
33
42
|
#
|
|
34
43
|
# Writes users.first_name, and backfills `name` when it is blank so the
|
|
35
44
|
# display-name chain has something better than an email prefix to show.
|
|
36
45
|
def first_name
|
|
37
|
-
value = params[:first_name].to_s.strip.gsub(/\s+/, " ")
|
|
46
|
+
value = params[:first_name].to_s.strip.gsub(/\s+/, " ")
|
|
38
47
|
|
|
39
48
|
if value.blank?
|
|
40
|
-
return
|
|
41
|
-
|
|
49
|
+
return refuse("Enter your first name, or skip for now.")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# DERIVE FIRST, THEN GUARD, THEN WRITE. The halves are what the length
|
|
53
|
+
# rules are about, so they are computed once and both the guard and the
|
|
54
|
+
# write read the same hash — a guard measuring a second derivation could
|
|
55
|
+
# pass over values the write then stored.
|
|
56
|
+
attrs = name_columns(value)
|
|
57
|
+
|
|
58
|
+
if (message = length_refusal(value, attrs))
|
|
59
|
+
return refuse(message)
|
|
42
60
|
end
|
|
43
61
|
|
|
44
62
|
rescue_and_log(target: current_user) do
|
|
@@ -63,7 +81,7 @@ module Studio
|
|
|
63
81
|
# never self-healed, because set_name_parts is gated on `name_changed?`
|
|
64
82
|
# and no later save sees a name change. So the derivation comes to the
|
|
65
83
|
# writer instead: Studio::NameParts is the same rule the callback runs.
|
|
66
|
-
attrs
|
|
84
|
+
# (`attrs` was derived above, so the guard and this write cannot disagree.)
|
|
67
85
|
attrs[:name] = value if current_user.name.blank?
|
|
68
86
|
current_user.update_columns(attrs)
|
|
69
87
|
|
|
@@ -86,6 +104,50 @@ module Studio
|
|
|
86
104
|
|
|
87
105
|
private
|
|
88
106
|
|
|
107
|
+
# The one refusal shape both guards answer with. The modal renders `error`
|
|
108
|
+
# inline and leaves the field filled, so a refusal here is a correction the
|
|
109
|
+
# person can act on rather than a dead end.
|
|
110
|
+
def refuse(message)
|
|
111
|
+
render json: { ok: false, error: message }, status: :unprocessable_entity
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# WHY THIS REFUSES INSTEAD OF TRUNCATING, which is the product decision this
|
|
115
|
+
# endpoint used to make silently and badly.
|
|
116
|
+
#
|
|
117
|
+
# `.first(cap)` on a name is not a cap, it is a rewrite: it accepted the
|
|
118
|
+
# answer, stored something the person never typed, and reported success.
|
|
119
|
+
# "Bartholomew Fitzwilliam Montgomery-Smythe" came back as
|
|
120
|
+
# "…Montgomery-Smyth" — plausible enough to miss, which is the worst kind of
|
|
121
|
+
# wrong for a name. Raising the number alone only moves that cliff one
|
|
122
|
+
# character further out; a bound that rewrites its input is the defect at
|
|
123
|
+
# any value. So the bound goes up (MAX_FULL_NAME now fits a real full name)
|
|
124
|
+
# AND anything past it is refused with a sentence saying so.
|
|
125
|
+
#
|
|
126
|
+
# BOTH LENGTHS ARE CHECKED, and they are independent rather than redundant.
|
|
127
|
+
# The whole answer becomes `name`; each half becomes its own column that
|
|
128
|
+
# /profile caps separately. A single 60-character word passes the first rule
|
|
129
|
+
# and would still land a `first_name` /profile silently shortens on the next
|
|
130
|
+
# save — the very drift the two constants exist to prevent — so the halves
|
|
131
|
+
# are measured too.
|
|
132
|
+
#
|
|
133
|
+
# UNREACHABLE FROM THE MODAL, ON PURPOSE. The partial's input carries the
|
|
134
|
+
# same MAX_FULL_NAME as its maxlength, so the browser stops a typist first.
|
|
135
|
+
# This is the server being the real bound, for a direct POST or for a host
|
|
136
|
+
# that passes a larger `max_length` local.
|
|
137
|
+
#
|
|
138
|
+
# Returns the message to refuse with, or nil to proceed.
|
|
139
|
+
def length_refusal(value, attrs)
|
|
140
|
+
if value.length > MAX_FULL_NAME
|
|
141
|
+
return "Please shorten that to #{MAX_FULL_NAME} characters or fewer."
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
if attrs.values.compact.any? { |part| part.length > MAX_FIRST_NAME }
|
|
145
|
+
return "Please keep each name to #{MAX_FIRST_NAME} characters or fewer."
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
nil
|
|
149
|
+
end
|
|
150
|
+
|
|
89
151
|
# The name halves to write, derived exactly as the host's set_name_parts
|
|
90
152
|
# would derive them from the same string.
|
|
91
153
|
#
|
|
@@ -15,7 +15,10 @@
|
|
|
15
15
|
heading — (default "What should we call you?")
|
|
16
16
|
subtext — the one-line why (default speaks about emails)
|
|
17
17
|
placeholder — (default "Alex")
|
|
18
|
-
max_length — (default
|
|
18
|
+
max_length — (default Studio::FULL_NAME_MAX_LENGTH; the server stays the
|
|
19
|
+
real bound, and it REFUSES past that rather than truncating,
|
|
20
|
+
so a host raising this makes the endpoint answer 422 rather
|
|
21
|
+
than store a name nobody typed)
|
|
19
22
|
progress — [current, total] to render the segmented pill, or nil for none
|
|
20
23
|
modal_store — Alpine store name (default "modals"; the living style guide
|
|
21
24
|
mounts its own page-scoped host and passes "dsModals")
|
|
@@ -41,7 +44,7 @@
|
|
|
41
44
|
subtext = local_assigns.fetch(:subtext,
|
|
42
45
|
"Just your first name — we use it to address you in emails.")
|
|
43
46
|
placeholder = local_assigns.fetch(:placeholder, "Alex")
|
|
44
|
-
max_length = local_assigns.fetch(:max_length,
|
|
47
|
+
max_length = local_assigns.fetch(:max_length, Studio::FULL_NAME_MAX_LENGTH)
|
|
45
48
|
progress = local_assigns.fetch(:progress, nil)
|
|
46
49
|
modal_store = local_assigns.fetch(:modal_store, "modals")
|
|
47
50
|
done_event = local_assigns.fetch(:done_event, "onboarding-step-done")
|
data/lib/studio/version.rb
CHANGED
data/lib/studio.rb
CHANGED
|
@@ -407,17 +407,46 @@ module Studio
|
|
|
407
407
|
# later session may ask again. That is the whole reason this is not a column.
|
|
408
408
|
FIRST_NAME_SKIP_SESSION_KEY = :onboarding_skipped_first_name
|
|
409
409
|
|
|
410
|
-
# How long
|
|
411
|
-
#
|
|
412
|
-
# /profile
|
|
413
|
-
# maxlength. Two independently-correct caps that disagreed
|
|
414
|
-
# accept a name
|
|
410
|
+
# How long ONE name field may be — users.first_name, and users.last_name where
|
|
411
|
+
# a host carries it. PER FIELD, which is the only thing this number has ever
|
|
412
|
+
# measured: /profile caps each of the two inputs with it, and the profile
|
|
413
|
+
# form's maxlength renders it. Two independently-correct caps that disagreed
|
|
414
|
+
# would let one surface accept a name another then refused to save, a bug with
|
|
415
|
+
# no obvious owner.
|
|
415
416
|
#
|
|
416
417
|
# Keeping it here rather than on either controller also keeps the VIEW off a
|
|
417
418
|
# controller constant: the form needs the number, and a view reaching into
|
|
418
419
|
# Studio::ProfilesController to get it would couple the two for no reason.
|
|
419
420
|
FIRST_NAME_MAX_LENGTH = 40
|
|
420
421
|
|
|
422
|
+
# How long a WHOLE typed answer may be — one string holding a full name, which
|
|
423
|
+
# the onboarding step stores as users.name and SPLITS into the two halves
|
|
424
|
+
# above (Studio::NameParts).
|
|
425
|
+
#
|
|
426
|
+
# A SECOND CONSTANT BECAUSE THIS IS A SECOND QUESTION. Onboarding asked the
|
|
427
|
+
# per-field number for a whole-answer bound, and the two readings were both
|
|
428
|
+
# defensible at their own call site: "Bartholomew Fitzwilliam
|
|
429
|
+
# Montgomery-Smythe" is 41 characters, so it stored 40 and handed the account
|
|
430
|
+
# back its own surname misspelled — "Montgomery-Smyth". Nothing was wrong in
|
|
431
|
+
# either controller alone; the number was answering two questions with one
|
|
432
|
+
# value. Giving the whole-answer bound its own name makes that borrow
|
|
433
|
+
# unwriteable rather than merely discouraged.
|
|
434
|
+
#
|
|
435
|
+
# DERIVED, NOT PICKED. It is the longest answer whose two halves BOTH still
|
|
436
|
+
# fit the per-field cap — first(40) + a space + last(40) — so onboarding can
|
|
437
|
+
# never accept a name /profile would later shorten, which is the drift
|
|
438
|
+
# FIRST_NAME_MAX_LENGTH exists to prevent. Raising the per-field cap moves
|
|
439
|
+
# this one with it, by construction.
|
|
440
|
+
#
|
|
441
|
+
# NOT BOUNDED BY THE COLUMN. Measured 2026-09-06 across all six consumer
|
|
442
|
+
# databases (mcritchie-studio, turf-monster and mcritchie-industries, prod and
|
|
443
|
+
# QA): every one of `name`, `first_name` and `last_name` is an unbounded
|
|
444
|
+
# `character varying` — character_maximum_length NULL, no CHECK constraint, no
|
|
445
|
+
# model length validation. So this cap is a product decision start to finish,
|
|
446
|
+
# not a column ceiling, and raising it cannot trade a truncation for a
|
|
447
|
+
# PG::StringDataRightTruncation.
|
|
448
|
+
FULL_NAME_MAX_LENGTH = (FIRST_NAME_MAX_LENGTH * 2) + 1
|
|
449
|
+
|
|
421
450
|
# The shared rule for "does this account still owe us a first name?" — the one
|
|
422
451
|
# piece of onboarding logic every app agrees on. Hosts compose it into their own
|
|
423
452
|
# flow rather than re-deriving it (turf's OnboardingFlow calls straight through).
|