studio-engine 0.45.0 → 0.46.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d2291a2d35bfd35a6f54666fd0e3590a9a59275e453cf5ff81ddd2af49717301
4
- data.tar.gz: 5ca713c12be81b7353ebd98f281ef89017a02e6fd348e77d6468383e8d85a90d
3
+ metadata.gz: 026c018bce499134d15e23ca2f9101e05da752380be210e5c6136babbac97a5a
4
+ data.tar.gz: ff33bee4cbe01664a9f2385716107d0a7319e0b267cc9a1f6c4a47e0555c4c5a
5
5
  SHA512:
6
- metadata.gz: d8bc026356b0f6e2e1d0b0781719960001dc7fd5bb629b6fd3e7e59a7d3b1ac298f94dc4b88941c709956f49368675089e507ace0851a6f234321f14b5868f07
7
- data.tar.gz: cceface91adc12a32ad02246d67a58a93306fd44dce24a12ffd8a54a4350d840f52c8e7537634cddd524d0a5372d5ab4ff216fc6c9760bd725fc4a408ef11f55
6
+ metadata.gz: 25dedda1d9c8f5c7cc2f485cfcea80e94ce3446952029015d5e53ab1dd41fdbfd014dc1c1251f9c637ab7fbd3605b7aa50e0593e4d4e85b1a81c6349536dd7ea
7
+ data.tar.gz: 8f71ee45ddf77e12851991857aff2a72bfe3082719b7505c16b8fc7cd08d17c5bf502c07af2393aa315c22146067ef63af1d275d9566d327193d9fa935a94a72
data/CHANGELOG.md CHANGED
@@ -35,6 +35,83 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
35
35
 
36
36
  ### Added
37
37
 
38
+ - **The standard user profile columns — and the engine's first migration against
39
+ a host-owned table.** Every other engine migration creates a `studio_*` table
40
+ the engine owns outright; `users` belongs to the host. That boundary is crossed
41
+ deliberately, on the operator's call, because the alternative was three
42
+ hand-written migrations kept identical by discipline alone — and column drift
43
+ across apps is the exact thing a standard prevents.
44
+
45
+ | Column | Type | Why |
46
+ |---|---|---|
47
+ | `first_name` | string | The shared onboarding ask. MS and TM already had it; MI did not. |
48
+ | `birth_day` / `birth_month` / `birth_year` | integer | Age needs the year plus month/day for the boundary case; "whose birthday is today" needs month + day and no year at all. |
49
+ | `ip_locations` | jsonb, default `[]` | The distinct places an account has been seen from. |
50
+
51
+ Three integers rather than one date is the point: **no single date-of-birth
52
+ column is stored.** turf-monster's `AgePolicy` composes a `Date` from the trio.
53
+ turf already had `birth_year` as an integer, so it matches exactly.
54
+
55
+ **Every add is `if_not_exists: true`, and the migration no-ops when the app has
56
+ no `users` table.** Both matter: the apps disagree today (MS and TM already have
57
+ `first_name`, turf already has `birth_year`), and an unguarded add would mean
58
+ the engine breaking consumers that did nothing wrong. Migrations are
59
+ install-copied (`bin/rails studio_engine:install:migrations`), so each app still
60
+ adopts deliberately.
61
+
62
+ `Studio::IpLocations` owns the analytics shape so it cannot drift: string keys
63
+ and ISO-8601 times (it round-trips through jsonb), dedupe on the **place**
64
+ rather than the address — two sign-ins from Austin are one entry however many
65
+ IPs they arrive from — a 50-entry cap, and loopback/private ranges skipped so
66
+ dev sessions don't crowd out real ones. `Studio.record_ip_location!(user, ip:,
67
+ country:, region:, city:)` writes only when the place is **new**: this sits on
68
+ the request path, and refreshing a counter per hit would be a database write
69
+ per page view. The host resolves the location — turf already has Geocoder wired
70
+ in `ApplicationController#detect_geo_state` — and an app that has not run the
71
+ migration records nothing rather than raising.
72
+
73
+ `ip_locations` holds IP-derived location data, which is personal data in most
74
+ jurisdictions. The cap bounds it, and no app writes to it until it opts in.
75
+
76
+ - **First-name capture as a shared onboarding step.** Every Studio app addresses
77
+ people by name in email, and all three were going to write the same modal and
78
+ the same controller. Ported out of turf-monster, where it shipped first:
79
+
80
+ | Piece | What it is |
81
+ |---|---|
82
+ | `studio/modals/onboarding/_first_name` | The step's UI. Every endpoint and label is a local with a default, so a host mounting it elsewhere does not fork the partial. Previewable at `/admin/style#modals`. |
83
+ | `Studio::OnboardingController` | The two writes — `#first_name` and `#skip_first_name`, JSON only. |
84
+ | `Studio.first_name_outstanding?(user, session)` | The one rule every app agrees on: blank field, not skipped this session. |
85
+ | `Studio.onboarding_steps_resolver` | How a host declares what comes NEXT. |
86
+
87
+ **The engine owns ONE STEP; the host owns the SEQUENCE.** turf walks
88
+ welcome → first name → age → wallet, which means nothing in a hub app. The
89
+ partial never opens another modal — it reports the remaining steps upward and
90
+ closes — and the controller's `next` array is whatever the host's resolver
91
+ returns. The default is empty, which is the right answer for an app whose only
92
+ ask is the name: opt in, and it works with no further configuration.
93
+
94
+ Skipping is **session-scoped on purpose**. It means "not now", not "never" —
95
+ the column stays blank, so a later session may ask again. That is the whole
96
+ reason it is not a users column.
97
+
98
+ **The endpoints are OPT-IN (`config.draw_onboarding_routes = true`), and they
99
+ have to be** — the same hard constraint as `/admin/emails`. turf-monster owns
100
+ `onboarding_first_name_path` and `onboarding_skip_first_name_path` TODAY, and
101
+ drawing them there raises `Invalid route name, already in use` while that app's
102
+ routes load, taking down its entire route set. Consumer CI runs each consumer's
103
+ default branch, so this cannot be fixed from inside the engine. Each app's
104
+ adoption task flips the flag as it deletes its local copy.
105
+
106
+ Two consumer notes:
107
+
108
+ - **turf-monster** deletes `OnboardingController`, its two routes, and points
109
+ its `OnboardingFlow` at `Studio.first_name_outstanding?`. Net deletion.
110
+ - **mcritchie-industries** has **no `users.first_name` column**. The rule
111
+ tolerates that — an app that has not run the migration is simply never asked,
112
+ rather than raising on every signed-in request — so the gem can land there
113
+ before the migration does.
114
+
38
115
  - **`Studio::NewsletterMailer` — a sendable "you're on the list" email.**
39
116
  Namespaced under `Studio::` on purpose: a host that defines its own top-level
40
117
  `UserMailer` (McRitchie Studio does) SHADOWS the engine's outright, so an
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # The first-name onboarding step's two writes — capture a name, or record that
5
+ # the account skipped it. Ported from turf-monster (2026-08), where this ran as
6
+ # a local OnboardingController, because the same ask is wanted in every Studio
7
+ # app: McRitchie Studio, McRitchie Industries, and turf-monster all address
8
+ # people by name in email, and all three were going to write this controller.
9
+ #
10
+ # What the ENGINE owns here is ONE STEP. What each HOST still owns is the
11
+ # SEQUENCE around it — turf walks welcome → first name → age → wallet; a hub app
12
+ # may ask nothing else. Hosts declare that through
13
+ # Studio.onboarding_steps_resolver, whose value this returns as `next` so the
14
+ # client keeps walking without a second round trip.
15
+ #
16
+ # A plain host-inherited controller (same shape as StyleController and
17
+ # Studio::EmailsController): it renders no layout of its own, answers JSON only,
18
+ # and picks up the host's authentication from ApplicationController.
19
+ #
20
+ # Routes are OPT-IN via Studio.draw_onboarding_routes — turf-monster owns these
21
+ # helper names until its adoption task deletes the local pair.
22
+ class OnboardingController < ::ApplicationController
23
+ # Both actions are for the freshly signed-in user, so authentication is the
24
+ # host's default require_authentication — no skip_before_action here.
25
+
26
+ MAX_FIRST_NAME = 40
27
+
28
+ # POST /onboarding/first_name
29
+ #
30
+ # Writes users.first_name, and backfills `name` when it is blank so the
31
+ # display-name chain has something better than an email prefix to show.
32
+ def first_name
33
+ value = params[:first_name].to_s.strip.gsub(/\s+/, " ").first(MAX_FIRST_NAME)
34
+
35
+ if value.blank?
36
+ return render json: { ok: false, error: "Enter your first name, or skip for now." },
37
+ status: :unprocessable_entity
38
+ end
39
+
40
+ rescue_and_log(target: current_user) do
41
+ # update_columns, not update!: this runs seconds after signup, on an
42
+ # account that may be mid-onboarding, and a validation failure elsewhere
43
+ # on the record (a grandfathered reserved username, say) must not block a
44
+ # first name. It also steps around any host before_save that DERIVES
45
+ # first_name FROM name — turf's set_name_parts does exactly that, and
46
+ # writing through it would discard the value we were just handed.
47
+ attrs = { first_name: value }
48
+ attrs[:name] = value if current_user.name.blank?
49
+ current_user.update_columns(attrs)
50
+
51
+ render json: { ok: true, first_name: value, next: remaining_steps }
52
+ end
53
+ end
54
+
55
+ # POST /onboarding/skip_first_name
56
+ #
57
+ # Session-scoped, deliberately: skipping means "not now", not "never". A later
58
+ # session can ask again (the field is still blank), which is the whole reason
59
+ # this is not a users column.
60
+ def skip_first_name
61
+ session[Studio::FIRST_NAME_SKIP_SESSION_KEY] = true
62
+ render json: { ok: true, next: remaining_steps }
63
+ end
64
+
65
+ private
66
+
67
+ # What is left AFTER this write. The host's resolver decides; the engine's
68
+ # default is "nothing further", which is the right answer for an app whose
69
+ # only onboarding ask is the name.
70
+ def remaining_steps
71
+ resolver = Studio.onboarding_steps_resolver
72
+ return [] unless resolver.respond_to?(:call)
73
+
74
+ Array(resolver.call(current_user, session)).map(&:to_s)
75
+ end
76
+ end
77
+ end
@@ -211,14 +211,6 @@ module Studio
211
211
  name: "Alex McRitchie", email: "alex@#{sample_domain}")
212
212
  end
213
213
 
214
- # Deliberately NAMELESS, and always present. This is the only recipient that
215
- # exercises the name-free fallback header — the case a magic link hits every
216
- # time it reaches someone with no account yet, and the one nobody thinks to
217
- # check because whoever is previewing has a name on file.
218
- def sample_member
219
- new(id: "sample-member", label: "No name on file", admin: false,
220
- name: nil, email: "someone@#{sample_domain}")
221
- end
222
214
  end
223
215
  end
224
216
  end
@@ -0,0 +1,138 @@
1
+ <%#
2
+ First-name capture — the shared onboarding step.
3
+
4
+ Asks a freshly signed-in account for a first name, once, and lets them skip.
5
+ Ported from turf-monster's post-auth onboarding chain (2026-08) because the
6
+ same beat is wanted in McRitchie Studio: the field exists in every Studio app
7
+ (`users.first_name`), and the ask is identical everywhere. What is NOT shared —
8
+ and deliberately stays in the host — is the ORDER of the chain around it: turf
9
+ walks welcome → first name → age → wallet, which means nothing in a hub app.
10
+ This partial owns ONE step; the host owns the sequence.
11
+
12
+ Locals (all optional, defaults via local_assigns.fetch):
13
+ submit_path — POST target for the name (default "/onboarding/first_name")
14
+ skip_path — POST target for the skip (default "/onboarding/skip_first_name")
15
+ heading — (default "What should we call you?")
16
+ subtext — the one-line why (default speaks about emails)
17
+ placeholder — (default "Alex")
18
+ max_length — (default 40; the server stays the real bound)
19
+ progress — [current, total] to render the segmented pill, or nil for none
20
+ modal_store — Alpine store name (default "modals"; the living style guide
21
+ mounts its own page-scoped host and passes "dsModals")
22
+ done_event — window event dispatched with { next: [...] } when this step is
23
+ finished, saved or skipped (default "onboarding-step-done").
24
+ The HOST decides what happens next; this partial never knows.
25
+
26
+ CONTRACT WITH THE SERVER. Both endpoints answer JSON `{ ok: true, next: [...] }`
27
+ — `next` being whatever steps the host says remain. A non-ok response must carry
28
+ `{ error: "…" }`, which is rendered inline. The step reports `next` upward and
29
+ closes; it never opens another modal itself.
30
+
31
+ CRITICAL (Alpine): this partial is cloned from a <template x-if> by the modal
32
+ host, so it must have ONE root element, and the x-data below is a
33
+ DOUBLE-QUOTED attribute — a single " anywhere inside it (a code comment
34
+ included) closes it early and the whole component mounts as a silent no-op that
35
+ still renders markup. Keep every inner string SINGLE-quoted.
36
+ %>
37
+ <%
38
+ submit_path = local_assigns.fetch(:submit_path, "/onboarding/first_name")
39
+ skip_path = local_assigns.fetch(:skip_path, "/onboarding/skip_first_name")
40
+ heading = local_assigns.fetch(:heading, "What should we call you?")
41
+ subtext = local_assigns.fetch(:subtext,
42
+ "Just your first name — we use it to address you in emails.")
43
+ placeholder = local_assigns.fetch(:placeholder, "Alex")
44
+ max_length = local_assigns.fetch(:max_length, 40)
45
+ progress = local_assigns.fetch(:progress, nil)
46
+ modal_store = local_assigns.fetch(:modal_store, "modals")
47
+ done_event = local_assigns.fetch(:done_event, "onboarding-step-done")
48
+ field_id = local_assigns.fetch(:id, "onboarding-first-name")
49
+ %>
50
+ <div x-data="{
51
+ get props() { var c = $store.<%= modal_store %>.current(); return (c && c.props) || {}; },
52
+ firstName: '',
53
+ submitting: false,
54
+ error: '',
55
+ async save() {
56
+ if (this.submitting) return;
57
+ var value = (this.firstName || '').trim();
58
+ if (!value) { this.error = 'Enter your first name, or skip for now.'; return; }
59
+ this.submitting = true; this.error = '';
60
+ var data = await this.post('<%= submit_path %>', { first_name: value });
61
+ this.submitting = false;
62
+ if (!data || !data.ok) {
63
+ this.error = (data && data.error) || 'Could not save that — try again.';
64
+ return;
65
+ }
66
+ this.finish(data.next || []);
67
+ },
68
+ async skip() {
69
+ if (this.submitting) return;
70
+ this.submitting = true; this.error = '';
71
+ var data = await this.post('<%= skip_path %>', {});
72
+ this.submitting = false;
73
+ this.finish((data && data.next) || []);
74
+ },
75
+ async post(url, body) {
76
+ var meta = document.querySelector('meta[name=csrf-token]');
77
+ try {
78
+ var res = await fetch(url, {
79
+ method: 'POST',
80
+ headers: {
81
+ 'Content-Type': 'application/json',
82
+ 'Accept': 'application/json',
83
+ 'X-CSRF-Token': meta ? meta.content : ''
84
+ },
85
+ body: JSON.stringify(body)
86
+ });
87
+ return await res.json();
88
+ } catch (e) { return null; }
89
+ },
90
+ finish(next) {
91
+ window.dispatchEvent(new CustomEvent('<%= done_event %>', { detail: { next: next } }));
92
+ $store.<%= modal_store %>.close();
93
+ }
94
+ }"
95
+ class="relative">
96
+
97
+ <div class="relative mb-3 -mt-2">
98
+ <h3 class="text-heading font-bold text-lg leading-tight text-center pt-1"><%= heading %></h3>
99
+ <%# The × SKIPS rather than merely closing: this lands seconds after signup,
100
+ and a close that silently abandons the chain is how a host ends up with a
101
+ step nobody can answer again. %>
102
+ <button @click="skip()"
103
+ class="absolute top-0 right-0 -mr-2 text-secondary hover:text-heading text-xl leading-none"
104
+ aria-label="Skip">&times;</button>
105
+ </div>
106
+ <% if progress %>
107
+ <%= render "studio/modals/blocks/progress_pill", current: progress.first, total: progress.last %>
108
+ <% end %>
109
+ <p class="text-xs text-body mb-4 text-center"><%= subtext %></p>
110
+
111
+ <form @submit.prevent="save()" novalidate>
112
+ <div class="mb-3">
113
+ <label class="block text-xs text-secondary mb-1 font-medium" for="<%= field_id %>">First name</label>
114
+ <%# Focused on open. The HTML autofocus attribute is not enough for a modal
115
+ mounted from a <template x-if> after the document parsed, so Alpine does
116
+ it; preventScroll keeps the mount animation from being yanked. %>
117
+ <input id="<%= field_id %>" type="text" x-model="firstName"
118
+ maxlength="<%= max_length %>" autocomplete="given-name" enterkeyhint="done"
119
+ placeholder="<%= placeholder %>"
120
+ :disabled="submitting"
121
+ x-init="$nextTick(() => $el.focus({ preventScroll: true }))"
122
+ class="input-field">
123
+ </div>
124
+ <template x-if="error">
125
+ <p class="text-red-400 text-xs mb-3" x-text="error"></p>
126
+ </template>
127
+ <button type="submit" :disabled="submitting"
128
+ class="btn btn-primary btn-lg w-full gap-2 disabled:cursor-wait">
129
+ <span x-show="submitting" class="cta-spinner" aria-hidden="true"></span>
130
+ <span x-text="submitting ? 'Saving…' : 'Save and continue'"></span>
131
+ </button>
132
+ </form>
133
+
134
+ <button type="button" @click="skip()" :disabled="submitting"
135
+ class="mt-3 w-full text-center text-sm text-secondary hover:text-heading disabled:opacity-50">
136
+ Skip for now
137
+ </button>
138
+ </div>
@@ -309,6 +309,14 @@
309
309
  <div><%= render "style/modals/auth" %></div>
310
310
  </template>
311
311
 
312
+ <%# --- Onboarding --- %>
313
+ <%# The REAL engine partial, mounted on the page-scoped host: what the
314
+ style page shows is exactly what a consumer renders, not a copy. %>
315
+ <template x-if="$store.dsModals.current().id === 'onboarding-first-name'">
316
+ <div><%= render "studio/modals/onboarding/first_name",
317
+ modal_store: "dsModals", done_event: "ds-onboarding-step-done" %></div>
318
+ </template>
319
+
312
320
  <%# --- Eligibility + entry --- %>
313
321
  <template x-if="$store.dsModals.current().id === 'age-verify'">
314
322
  <div><%= render "style/modals/age_verify" %></div>
@@ -463,6 +471,43 @@
463
471
  </template>
464
472
 
465
473
  <%# ===================================================================== %>
474
+ <%# 0. ONBOARDING — the post-auth chain's shared step %>
475
+ <%# ===================================================================== %>
476
+ <section class="space-y-5">
477
+ <div class="space-y-1">
478
+ <h3 class="text-xl font-bold text-heading">Onboarding</h3>
479
+ <p class="text-muted text-sm">
480
+ The step every Studio app asks a brand-new account, ported from Turf
481
+ Monster
482
+ (<code class="font-mono text-2xs">studio/modals/onboarding/_first_name</code>).
483
+ It is <strong>one step, not a flow</strong>: the host owns the SEQUENCE
484
+ around it — Turf walks <strong>welcome &rarr; first name &rarr; age gate
485
+ &rarr; wallet setup</strong>, while a hub app may ask nothing else — so
486
+ this partial never opens another modal. It reports what remains on its
487
+ <code class="font-mono text-2xs">done_event</code> and closes, and the host
488
+ decides what comes next. Every endpoint and label is a local with a
489
+ default, so mounting it under different routes needs no fork.
490
+ <strong>Both</strong> the &times; and the "Skip for now" link SKIP (a close
491
+ that silently abandons the chain is how a host ends up with a step nobody
492
+ can answer again), and the field focuses itself on open.
493
+ </p>
494
+ </div>
495
+ <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
496
+ <%= render layout: "style/modal_specimen", locals: {
497
+ label: "First name",
498
+ reference: %(the shared onboarding first-name step (studio-engine studio/modals/onboarding/_first_name) — open with $store.dsModals.open('onboarding-first-name'). Posts to submit_path/skip_path (defaults /onboarding/first_name and /onboarding/skip_first_name) and dispatches its done_event with the steps the SERVER says remain),
499
+ open_expr: "$store.dsModals.open('onboarding-first-name')",
500
+ glow_when: ds_glow.call("onboarding-first-name")
501
+ } do %>
502
+ <p class="text-muted text-2xs">
503
+ Saving or skipping dispatches
504
+ <code class="font-mono">ds-onboarding-step-done</code> here rather than
505
+ the app-level event, so the specimen cannot drive a real chain.
506
+ </p>
507
+ <% end %>
508
+ </div>
509
+ </section>
510
+
466
511
  <%# 1. AUTH suite %>
467
512
  <%# ===================================================================== %>
468
513
  <section class="space-y-5">
@@ -0,0 +1,51 @@
1
+ # The standard Studio user profile columns — the small set every Studio app
2
+ # carries whether or not it uses them today, so an app never has to invent its
3
+ # own spelling for the same fact.
4
+ #
5
+ # THIS IS THE ENGINE'S FIRST MIGRATION AGAINST A HOST TABLE. Every other engine
6
+ # migration creates a `studio_*` table the engine owns outright; `users` belongs
7
+ # to the host. That boundary is crossed deliberately, on the operator's call
8
+ # (2026-08-13), because the alternative is three hand-written migrations in
9
+ # mcritchie-studio, turf-monster and mcritchie-industries that must be kept
10
+ # identical by discipline alone — and column drift across apps is exactly what a
11
+ # standard is supposed to prevent. One definition, one place.
12
+ #
13
+ # Two properties make that safe:
14
+ #
15
+ # 1. EVERY add is `if_not_exists: true`. The apps disagree TODAY — McRitchie
16
+ # Studio and turf-monster already have `first_name`, and turf already has
17
+ # `birth_year` (as an integer, matching below). An unguarded add would raise
18
+ # on those apps, which is the failure mode that makes engine migrations
19
+ # against host tables frightening in the first place.
20
+ # 2. It no-ops on an app with no `users` table at all, rather than raising.
21
+ # The engine's own dummy is such an app.
22
+ #
23
+ # Migrations are install-copied (`bin/rails studio_engine:install:migrations`),
24
+ # not auto-run, so each app still adopts this deliberately.
25
+ #
26
+ # ON THE BIRTH COLUMNS: three integers, not one date. Splitting them keeps the
27
+ # two questions apps actually ask cheap and separable — age (needs the year, and
28
+ # the month/day for the boundary case) and "whose birthday is today" (needs
29
+ # month + day, and no year at all). turf-monster's AgePolicy composes a Date from
30
+ # the trio. Storing no single date-of-birth column is the point.
31
+ #
32
+ # ON ip_locations: a jsonb ARRAY of the distinct places an account has signed in
33
+ # from, appended to when a NEW location appears. Shape, dedupe and the growth cap
34
+ # belong to Studio::IpLocations — the column is just where it lands. It holds
35
+ # IP-derived location data, which is personal data in most jurisdictions; the
36
+ # 50-entry cap there bounds it, and no app writes to it until it opts in.
37
+ class AddStandardUserProfileColumns < ActiveRecord::Migration[7.2]
38
+ def change
39
+ # An app that has no users table (the engine's dummy, and any future
40
+ # consumer that names its accounts something else) is simply skipped.
41
+ return unless table_exists?(:users)
42
+
43
+ add_column :users, :first_name, :string, if_not_exists: true
44
+
45
+ add_column :users, :birth_day, :integer, if_not_exists: true
46
+ add_column :users, :birth_month, :integer, if_not_exists: true
47
+ add_column :users, :birth_year, :integer, if_not_exists: true
48
+
49
+ add_column :users, :ip_locations, :jsonb, default: [], null: false, if_not_exists: true
50
+ end
51
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # The shape, dedupe rule and growth cap for `users.ip_locations` — the jsonb
5
+ # array recording the distinct places an account has been seen from.
6
+ #
7
+ # Deliberately PURE: it takes the existing array and returns a new one. No
8
+ # ActiveRecord, no clock beyond what the caller passes, no geo lookup. That
9
+ # keeps the analytics shape identical in every app while leaving the RESOLUTION
10
+ # to the host — turf-monster already resolves IP → state/country through
11
+ # Geocoder in ApplicationController#detect_geo_state and can hand the result
12
+ # straight here; an app with no geo lookup at all still gets a useful record of
13
+ # the distinct IPs.
14
+ #
15
+ # An entry:
16
+ #
17
+ # { "ip" => "203.0.113.7", "country" => "US", "region" => "TX",
18
+ # "city" => "Austin", "first_seen_at" => "2026-08-13T21:00:00Z",
19
+ # "last_seen_at" => "2026-08-14T09:30:00Z", "count" => 4 }
20
+ #
21
+ # String keys throughout, ISO-8601 times: this round-trips through jsonb, and a
22
+ # symbol-keyed hash written today would come back string-keyed tomorrow.
23
+ module IpLocations
24
+ # Distinct locations kept per user, most-recently-seen first. A cap is not
25
+ # optional for a column that grows on sign-in: an account that travels, or
26
+ # sits behind a rotating consumer IP, would otherwise grow this row without
27
+ # bound and drag every SELECT that loads the user along with it.
28
+ MAX_ENTRIES = 50
29
+
30
+ # Loopback and private ranges. A dev session signs in from 127.0.0.1 all day
31
+ # and a container from 10.x; recording those buys nothing and would crowd the
32
+ # real entries out of the cap.
33
+ SKIPPED_IP = /\A(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|169\.254\.|::1\z|fe80:|f[cd])/i
34
+
35
+ module_function
36
+
37
+ # Returns the array to store. Appends a new entry when this location has not
38
+ # been seen before; otherwise refreshes the existing one in place.
39
+ def push(entries, ip:, country: nil, region: nil, city: nil, at: nil)
40
+ at = normalize_time(at)
41
+ ip = ip.to_s.strip
42
+ key = location_key(ip: ip, country: country, region: region, city: city)
43
+ return normalize(entries) if key.nil?
44
+
45
+ existing = normalize(entries)
46
+ match = existing.find { |e| e["key"] == key }
47
+
48
+ if match
49
+ match["last_seen_at"] = at
50
+ match["count"] = match["count"].to_i + 1
51
+ # The IP within a location legitimately changes (a reassigned lease); the
52
+ # LOCATION is what we deduped on, so keep the freshest address for it.
53
+ match["ip"] = ip if ip.present?
54
+ else
55
+ existing << {
56
+ "key" => key, "ip" => ip.presence, "country" => presence(country),
57
+ "region" => presence(region), "city" => presence(city),
58
+ "first_seen_at" => at, "last_seen_at" => at, "count" => 1
59
+ }
60
+ end
61
+
62
+ existing.sort_by { |e| e["last_seen_at"].to_s }.reverse.first(MAX_ENTRIES)
63
+ end
64
+
65
+ # Has this account been seen here before? The question a caller asks to avoid
66
+ # a write on every single request.
67
+ def seen?(entries, ip: nil, country: nil, region: nil, city: nil)
68
+ key = location_key(ip: ip.to_s.strip, country: country, region: region, city: city)
69
+ return false if key.nil?
70
+
71
+ normalize(entries).any? { |e| e["key"] == key }
72
+ end
73
+
74
+ # What the location is, for deduping. Geo when we have any of it — two
75
+ # sign-ins from Austin are ONE location even from different addresses, which
76
+ # is the whole point of tracking places rather than addresses. Falls back to
77
+ # the IP when the lookup gave us nothing, so an app with no geo still records
78
+ # something useful. nil (record nothing) when there is neither.
79
+ def location_key(ip:, country: nil, region: nil, city: nil)
80
+ geo = [country, region, city].map { |v| presence(v) }
81
+ return "geo:" + geo.map(&:to_s).join("|").downcase if geo.any?
82
+ return nil if ip.blank? || ip.match?(SKIPPED_IP)
83
+
84
+ "ip:#{ip.downcase}"
85
+ end
86
+
87
+ def normalize(entries)
88
+ Array(entries).filter_map do |entry|
89
+ next unless entry.respond_to?(:to_h)
90
+
91
+ row = entry.to_h.transform_keys(&:to_s)
92
+ next if row.empty?
93
+
94
+ # Backfill for rows written before these fields existed, so an upgrade in
95
+ # place neither duplicates locations it already holds nor treats them as
96
+ # the stalest thing in the column.
97
+ row["key"] ||= location_key(ip: row["ip"].to_s, country: row["country"],
98
+ region: row["region"], city: row["city"])
99
+ # A stored row is evidence of at least one sighting.
100
+ row["count"] = 1 if row["count"].to_i < 1
101
+ row["last_seen_at"] ||= row["first_seen_at"]
102
+ row
103
+ end
104
+ end
105
+
106
+ def normalize_time(value)
107
+ return value if value.is_a?(String) && value.present?
108
+ return Time.now.utc.iso8601 if value.nil?
109
+
110
+ value.respond_to?(:utc) ? value.utc.iso8601 : value.to_s
111
+ end
112
+
113
+ def presence(value)
114
+ str = value.to_s.strip
115
+ str.empty? ? nil : str
116
+ end
117
+ end
118
+ end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.45.0"
2
+ VERSION = "0.46.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  require "studio/version"
2
2
  require "studio/log_rotation"
3
+ require "studio/ip_locations"
3
4
  require "studio/engine"
4
5
  require "studio/color_scale"
5
6
  require "studio/environment_banner"
@@ -141,6 +142,90 @@ module Studio
141
142
  # resolution are always on, so an app sends branded email either way.
142
143
  mattr_accessor :draw_admin_emails_routes, default: false
143
144
 
145
+ # Draw the shared first-name onboarding endpoints
146
+ # (Studio::OnboardingController#first_name / #skip_first_name). OFF by default,
147
+ # and for the same hard reason as draw_admin_emails_routes above:
148
+ # turf-monster ALREADY owns `post "/onboarding/first_name"` with the helper
149
+ # names onboarding_first_name_path and onboarding_skip_first_name_path, so
150
+ # drawing these unconditionally raises `Invalid route name, already in use`
151
+ # while that app's routes.rb loads — which takes down its ENTIRE route set, not
152
+ # just this page. Consumer CI runs each consumer's main, so a default-on flag
153
+ # cannot be fixed from inside the engine. Each app's adoption task turns it on
154
+ # as it deletes its local copy:
155
+ #
156
+ # config.draw_onboarding_routes = true
157
+ #
158
+ # Gates only the ENDPOINTS. The modal partial
159
+ # (studio/modals/onboarding/_first_name) and its /admin/style specimen are
160
+ # always available, so a host can preview the step before it wires the writes.
161
+ mattr_accessor :draw_onboarding_routes, default: false
162
+
163
+ # What the onboarding endpoints report back as still-remaining after a write,
164
+ # so the client can keep walking its chain without a second round trip.
165
+ #
166
+ # The engine owns ONE STEP (the first-name ask); the HOST owns the SEQUENCE
167
+ # around it. turf-monster walks welcome → first name → age → wallet, which
168
+ # means nothing in a hub app, so this resolver is how a host says what comes
169
+ # next. It takes (user, session) and returns an array of step names; the
170
+ # default — no further steps — is correct for an app whose only ask is the name.
171
+ #
172
+ # config.onboarding_steps_resolver = ->(user, session) {
173
+ # OnboardingFlow.new(user, session).remaining.map(&:to_s)
174
+ # }
175
+ mattr_accessor :onboarding_steps_resolver, default: ->(_user, _session) { [] }
176
+
177
+ # Session key recording "asked, and they said not now". Session-scoped
178
+ # DELIBERATELY: skipping means not now, not never — the field stays blank, so a
179
+ # later session may ask again. That is the whole reason this is not a column.
180
+ FIRST_NAME_SKIP_SESSION_KEY = :onboarding_skipped_first_name
181
+
182
+ # The shared rule for "does this account still owe us a first name?" — the one
183
+ # piece of onboarding logic every app agrees on. Hosts compose it into their own
184
+ # flow rather than re-deriving it (turf's OnboardingFlow calls straight through).
185
+ #
186
+ # Tolerates a host whose users table has no first_name column: an app that has
187
+ # not run the migration yet is simply never asked, instead of raising on every
188
+ # signed-in request.
189
+ def self.first_name_outstanding?(user, session = {})
190
+ return false if user.blank?
191
+ return false unless user.respond_to?(:first_name)
192
+ return false if session.present? && session[FIRST_NAME_SKIP_SESSION_KEY]
193
+
194
+ user.first_name.blank?
195
+ end
196
+
197
+ # Record a place this account has been seen from, if it is a place we have not
198
+ # seen it from before. Returns true when something was actually written.
199
+ #
200
+ # NEW LOCATIONS ONLY, and that is the design rather than a shortcut: this is
201
+ # called from the request path, so refreshing a counter on every hit would mean
202
+ # a database write per request for no analytic gain. The first sign-in from a
203
+ # place writes; the next thousand do not. A host wanting last-seen/count
204
+ # refresh can call Studio::IpLocations.push directly on its own cadence.
205
+ #
206
+ # The host resolves the location — turf-monster already has Geocoder wired in
207
+ # ApplicationController#detect_geo_state — and passes whatever it got. Pass
208
+ # only an IP and the IP is what gets deduped on.
209
+ #
210
+ # Tolerates an app that has not run the migration (no ip_locations column):
211
+ # it records nothing rather than raising on a request path.
212
+ def self.record_ip_location!(user, ip:, country: nil, region: nil, city: nil, at: nil)
213
+ return false if user.blank?
214
+ return false unless user.respond_to?(:ip_locations)
215
+
216
+ current = user.ip_locations
217
+ return false if IpLocations.seen?(current, ip: ip, country: country, region: region, city: city)
218
+
219
+ updated = IpLocations.push(current, ip: ip, country: country, region: region,
220
+ city: city, at: at)
221
+ return false if updated == IpLocations.normalize(current)
222
+
223
+ # update_columns, not update!: analytics must never block a request, and a
224
+ # validation failure elsewhere on the record is not this write's business.
225
+ user.update_columns(ip_locations: updated)
226
+ true
227
+ end
228
+
144
229
  # Optional admin Act As / impersonation session conventions. Consumers that
145
230
  # include Studio::Impersonation get current_user layered over true_user with
146
231
  # these session keys, but still own authorization, audit logging, and routes.
@@ -584,6 +669,20 @@ module Studio
584
669
  as: :admin_email_logo, constraints: { key: /[a-z0-9_]+/ }
585
670
  end
586
671
 
672
+ # The shared first-name onboarding step's two writes. OPT-IN — see
673
+ # Studio.draw_onboarding_routes above: turf-monster owns these exact helper
674
+ # names today, and drawing them there before its adoption task deletes the
675
+ # local pair kills every route in that app.
676
+ #
677
+ # The paths match the partial's defaults, so a host that opts in and has no
678
+ # other onboarding of its own needs no further wiring.
679
+ if Studio.draw_onboarding_routes
680
+ post "onboarding/first_name", to: "studio/onboarding#first_name",
681
+ as: :onboarding_first_name
682
+ post "onboarding/skip_first_name", to: "studio/onboarding#skip_first_name",
683
+ as: :onboarding_skip_first_name
684
+ end
685
+
587
686
  # DEPRECATED, kept for ONE release. Not a redirect: consumer-ci.yml runs
588
687
  # each consumer's DEFAULT-BRANCH suite against this engine, and both
589
688
  # mcritchie-studio and turf-monster have tests on `main` that GET this page
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.45.0
4
+ version: 0.46.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
@@ -203,6 +203,7 @@ files:
203
203
  - app/controllers/studio/local_emails_controller.rb
204
204
  - app/controllers/studio/local_reviews_controller.rb
205
205
  - app/controllers/studio/models_controller.rb
206
+ - app/controllers/studio/onboarding_controller.rb
206
207
  - app/controllers/style_controller.rb
207
208
  - app/controllers/theme_settings_controller.rb
208
209
  - app/helpers/studio/admin_models_table_helper.rb
@@ -321,6 +322,7 @@ files:
321
322
  - app/views/studio/modals/blocks/_solana_tx_link.html.erb
322
323
  - app/views/studio/modals/blocks/_success_card.html.erb
323
324
  - app/views/studio/modals/blocks/_wallet_brand_sprite.html.erb
325
+ - app/views/studio/modals/onboarding/_first_name.html.erb
324
326
  - app/views/studio/modals/shared/_age_attestation.html.erb
325
327
  - app/views/studio/modals/shared/_email_field.html.erb
326
328
  - app/views/studio/modals/templates/_action.html.erb
@@ -356,6 +358,7 @@ files:
356
358
  - db/migrate/20260812210000_add_copy_to_studio_email_settings.rb
357
359
  - db/migrate/20260812220000_add_subject_to_studio_email_settings.rb
358
360
  - db/migrate/20260813010000_add_body_cta_footer_to_studio_email_settings.rb
361
+ - db/migrate/20260813220000_add_standard_user_profile_columns.rb
359
362
  - lib/studio-engine.rb
360
363
  - lib/studio.rb
361
364
  - lib/studio/cable.rb
@@ -365,6 +368,7 @@ files:
365
368
  - lib/studio/engine.rb
366
369
  - lib/studio/environment_banner.rb
367
370
  - lib/studio/image_cache.rb
371
+ - lib/studio/ip_locations.rb
368
372
  - lib/studio/link_resolution.rb
369
373
  - lib/studio/link_token.rb
370
374
  - lib/studio/log_rotation.rb