studio-engine 0.36.0 → 0.38.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: 8c692fc135af1ba83c08b27398c875bc547ae07bbcc492540ffd83250fccf680
4
- data.tar.gz: 891c561fe2bef1386137af4fbbc22547f38613efe8bd373bf6ec31894086feef
3
+ metadata.gz: d497957db77f7b61823d1d12422672bf4339d71381163b79a6c42d705abf3492
4
+ data.tar.gz: e31717daca9b7722e4b9e87d44a507d9ec8c8055421e192bb3de78aa31175542
5
5
  SHA512:
6
- metadata.gz: 9cb8bb03d5adeec99336b5fab4d28b29c57be8686ca2afef58932ce866c321fe44f6ab0123609e6845151f938ab28790685bda6b9b21815ef3a3594d6c589921
7
- data.tar.gz: 2c3be4aadf2a88d7838dab26b27c4a22604268138bba818f0b82890774f7bc5b81424d53a645d3fcc5ebf59a8f10197a9b37bd9cd44246c988d8dca3dfd2cd62
6
+ metadata.gz: 6f75c493e66ebae0fd31571d237fa6a4f1546e9c50143382a4f54bfe88d5614c1f582af6d758cafe93ffc47b744b01d875a280de92823012bf224f028859627d
7
+ data.tar.gz: dfa14e14dbe5796816a0247bd965da477b69e27e8c81750f554e1d2d497fbf486da5d14f1ae900aa26b28dcfe22c14a51f2e83ead2089d98f9667b0142d43b6d
data/CHANGELOG.md CHANGED
@@ -2,8 +2,218 @@
2
2
 
3
3
  The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) — `MAJOR.MINOR.PATCH`. Consumer Rails apps install the released RubyGems package with `gem "studio-engine", "~> 0.6"`; bumping the gem version and updating consumer lockfiles is a release.
4
4
 
5
+ ## Unreleased
6
+
7
+ **The email registry becomes the email catalog.** 0.37 gave every app a shared
8
+ page for its transactional email banners. This folds in the rest — **what each
9
+ email is, and what it actually looks like** — so an app has ONE email manager
10
+ instead of two.
11
+
12
+ **`Studio::EmailImage` is now `Studio::EmailCatalog`.** The old name is a
13
+ **delegating shim** with its full surface intact (`url`, `store`, `record`,
14
+ `register`, `label`, `known?`, `variants`, `resolved_url`, `preview_url`,
15
+ `source`, and the constants), so nothing that names it breaks. It is deprecated
16
+ and will be deleted once no consumer's `main` references it.
17
+
18
+ Named for the prior art it absorbs: `turf-monster` built `::EmailCatalog` +
19
+ `Admin::EmailsController` first, and left a note on both saying this manager
20
+ *"moves into the shared studio-engine email framework (Phase 2)"*. This is
21
+ Phase 2. turf-monster deletes its copy in its adoption task rather than running a
22
+ second email page.
23
+
24
+ **Registry entries carry a type and a preview builder:**
25
+
26
+ ```ruby
27
+ Studio::EmailCatalog.register("winnings",
28
+ label: "Contest winnings",
29
+ description: "Sent when a player wins a contest.",
30
+ type: :marketing, # or :transactional (default)
31
+ preview: -> { ContestMailer.winnings(sample_entry) })
32
+ ```
33
+
34
+ Every keyword is optional, and omitting one on a re-register **keeps** the
35
+ existing value — so a host can attach a preview to an inherited email, or
36
+ relabel it, without restating its artwork. An unknown `type` falls back to
37
+ `:transactional` rather than raising: a typo in an initializer must not take a
38
+ host's boot down over a badge.
39
+
40
+ **New: `/admin/emails/:key`** — one email's own page: its banner, its type, its
41
+ subject, and a **live preview** of the real rendered email in an iframe
42
+ (`/admin/emails/:key/raw`). The emails list links each row to it.
43
+
44
+ **A preview can never take the manager down.** A `preview:` builder is host code
45
+ run against whatever sample data an environment happens to hold — an empty table,
46
+ a moved fixture, a mailer whose signature changed. Every call is contained: a
47
+ raising builder yields `nil`, records why, and renders the reason inside the
48
+ iframe. One broken builder costs one preview, not the page. Builders run **only**
49
+ on that page — listing the emails never executes one.
50
+
51
+ **Consumer note.** Nothing is required to upgrade; the shim keeps 0.37 call sites
52
+ working. To adopt the preview: pass `preview:` when registering, and switch any
53
+ `Studio::EmailImage` reference to `Studio::EmailCatalog`.
54
+
55
+ ### Added
56
+
57
+ - **`Studio::EmailCatalog`** — the email registry, renamed from
58
+ `Studio::EmailImage` and now carrying each entry's `type:` and `preview:`
59
+ callable. The old name stays as a delegating shim.
60
+ - **`/admin/emails/:key`** — one email's own page: its banner, its type, its
61
+ subject, and a live preview of the real rendered email in an iframe.
62
+
63
+ ## 0.37.0 — 2026-08-10
64
+
65
+ ### Added
66
+ - Standard transactional email primitive with a registry-backed catalog.
67
+
68
+ ### Fixed
69
+ - The crop confirm is guarded by owner, so one confirmed crop can no longer fan out
70
+ and overwrite every row's banner on /admin/emails.
71
+
5
72
  ## 0.36.0 — 2026-08-10
6
73
 
74
+ **Transactional emails become an engine primitive.** Every consuming app now
75
+ ships working, branded transactional email the moment it boots, and can grow its
76
+ own workflows and artwork. Additive — no host change is required to take this
77
+ release.
78
+
79
+ **`Studio::EmailImage` is now a registry.** It was a one-entry `VARIANTS` hash;
80
+ it is now a host-declared registry following the `Studio::ModelPage.register`
81
+ precedent. A registered email is mostly symbolic of a workflow — a key, a label,
82
+ a description — and its only real asset is its banner image.
83
+
84
+ - The engine **pre-registers the two every Studio app sends**, `magic_link` and
85
+ `email_change_confirmation`, so a host inherits both without declaring
86
+ anything.
87
+ - A host registers its own from an initializer:
88
+ `Studio::EmailImage.register("winnings", label: "Contest winnings")`.
89
+ Re-registering an inherited key **updates it in place**, keeping its page
90
+ position and its inherited artwork.
91
+ - `variants`, `label`, `known?`, `record`, `url`, and `store` keep their old
92
+ shapes, so `Studio::EmailImage.url(:magic_link)` — the only external caller
93
+ today — is untouched.
94
+
95
+ **Inheritance with per-app override.** Resolution is now two-layered:
96
+
97
+ ```
98
+ resolved_url(:magic_link) -> this app's ImageCache row (its own S3 bucket) app-owned
99
+ -> the engine's default gem asset inherited
100
+ -> nil bannerless
101
+ ```
102
+
103
+ `resolved_url`, **not** `url`. `url` deliberately keeps its pre-registry meaning —
104
+ this app's own image, or nil — so a caller written before the registry keeps the
105
+ behavior it was built against. A mailer that falls back itself
106
+ (`url(...) || own_banner`) must stay on `url`; switching it to `resolved_url`
107
+ would make that fallback dead code and replace the app's own artwork with the
108
+ engine default.
109
+
110
+ Defaults **ride the gem** (`app/assets/images/emails/*`), so a brand-new app with
111
+ an empty bucket sends good-looking email on day one with no cross-app S3
112
+ permission. Uploading writes to that app's own bucket and its own `image_caches`
113
+ row — which is exactly "the asset now belongs to this app". Placeholder artwork
114
+ ships for both standard emails; real artwork lands in a later release.
115
+
116
+ **New: `/admin/emails`**, modelled on the living style guide — one shared engine
117
+ page, admin-gated, rendering inside each host's layout.
118
+
119
+ **It is OPT-IN, and must be.** Add to `config/initializers/studio.rb`:
120
+
121
+ ```ruby
122
+ config.draw_admin_emails_routes = true
123
+ ```
124
+
125
+ `turf-monster` already owns `/admin/emails` (its `EmailCatalog` manager:
126
+ `namespace :admin { get "emails", as: :emails }`) and therefore both helper
127
+ names, `admin_emails_path` and `admin_email_path`. Drawing the engine's page
128
+ unconditionally raises `ArgumentError: Invalid route name, already in use:
129
+ 'admin_emails'` *while turf-monster's own routes are loading*, which takes down
130
+ its **entire** route set — every `admin_*_path` in the app goes undefined. A host
131
+ cannot opt out of a failure that happens before its config is read, so the
132
+ default is off until no consumer owns the name.
133
+
134
+ The gate covers only the **page**. The registry and the inherited-default
135
+ resolution are always on, and the engine's `UserMailer` already calls
136
+ `resolved_url`, so an app sends branded email whether or not it draws the page.
137
+
138
+ **`/admin/email_images` is deprecated, not deleted.** It still renders, and its
139
+ `admin_email_images_path` / `admin_email_image_path` helpers still resolve —
140
+ `mcritchie-studio` and `turf-monster` both have tests on `main` that drive it,
141
+ and consumer CI runs each consumer's default branch. A later engine minor deletes
142
+ the controller, view, and routes once no consumer references them. In the
143
+ meantime the old page had its own reporting bug fixed too: it now reads
144
+ `preview_url`, so it no longer claims "No image yet" about an email that is
145
+ visibly sending one.
146
+
147
+ - The table is the primary view: **name + live image on every row**, so an email
148
+ is identifiable at a glance.
149
+ - Each row says whether the live image is the **inherited default** or
150
+ **app-owned**, and an app-owned image can be reverted back to the default.
151
+ - Uploading goes through the **standard crop modal**, not a bare file input.
152
+
153
+ **Fix — the page misreported what was actually shipping.** It read only the S3
154
+ override, so it announced *"No image yet — emails send without a banner until you
155
+ upload one"* for an email that was visibly sending a banner from a committed repo
156
+ asset. `current_url` now resolves what really ships, and the copy distinguishes
157
+ "inherited default" from "no image at all".
158
+
159
+ **New: `Studio.s3_key_prefix`** — an optional key namespace inside the bucket, so
160
+ a satellite app can share an already-provisioned bucket instead of standing up
161
+ its own pair:
162
+
163
+ ```ruby
164
+ config.s3_bucket_prefix = "mcritchie-studio"
165
+ config.s3_key_prefix = "mcritchie-industries/"
166
+ # -> s3://mcritchie-studio-dev/mcritchie-industries/email_banners/...
167
+ ```
168
+
169
+ `Studio::S3` applies it to `upload`, `download`, `url`, `signed_url`, `exists?`,
170
+ `delete`, and `list` — callers keep passing logical keys and never see it, and
171
+ `list` strips it back off so a result feeds straight into `download`/`url`.
172
+ **Unset by default, so every already-shipped app's keys are byte-identical.**
173
+ Also new: `Studio::S3.configured?`, for callers that must degrade rather than
174
+ rescue `NotConfigured`.
175
+
176
+ **Honest degradation.** An app whose host never set `s3_bucket_prefix` renders
177
+ `/admin/emails` **read-only** — showing the inherited defaults it is genuinely
178
+ sending, naming the one setting that turns uploads on — instead of 500ing on the
179
+ first upload.
180
+
181
+ **New partial: `studio/modals/scoped_host`** — a self-contained modal host on its
182
+ own Alpine store, for a page that must bring its own modals. `/admin/emails` uses
183
+ it (`store: "emailModals"`) to ship its crop + saving modals, so the page works
184
+ identically in an app that renders a shared modal host and one that renders none
185
+ at all. `imageUploadHost` and `submitFormWithProgress` gained a matching `store`
186
+ option (default `"modals"`, so existing call sites are unchanged); the crop-photo
187
+ and saving partials already had one.
188
+
189
+ It is a **separate partial rather than a local on `studio/modals/_host`** for a
190
+ reason worth knowing: this is a non-isolated engine, so an app view at the same
191
+ path shadows the engine's — and `mcritchie-studio` and `turf-monster` both ship
192
+ their own `app/views/studio/modals/_host.html.erb`. A page rendering
193
+ `studio/modals/host` in those apps silently gets the app's fork, which knows
194
+ nothing of a `store:` local, so the page-scoped store is never registered and
195
+ every modal trigger on the page does nothing. `scoped_host` is unforked in every
196
+ app. The living style guide's hand-rolled `dsModals` host can rebase onto it.
197
+
198
+ Register modals with `current()?.id`, not `current().id` — the outer template
199
+ unmounts one tick after the stack empties, so a bare `.id` throws on close.
200
+
201
+ **Compatibility.** `Studio::EmailImage.url(key)` keeps its **pre-registry
202
+ contract** — this app's own uploaded image, or `nil`. The two-layer resolution
203
+ lives in the new `resolved_url(key)`. This matters: `turf-monster`'s mailer is
204
+ `Studio::EmailImage.url(:magic_link) || email_banner_url("magic-link-banner.jpg")`,
205
+ and making `url` resolve to the engine default would have turned that `||` into
206
+ dead code and silently replaced turf-monster's committed branded banner with the
207
+ engine's **placeholder** in live sign-in email. A method whose signature is
208
+ unchanged but whose return value flips from `nil` to a value is not additive.
209
+
210
+ **Consumer note.** Nothing here is required to upgrade. To adopt: set
211
+ `config.draw_admin_emails_routes = true`, link `admin_emails_path` from the app's
212
+ admin sidebar, register any extra workflows, switch mailers from `url` to
213
+ `resolved_url` where you want the inherited default, and drop any local
214
+ `branded_mailer.html.erb` fork — the engine's copy is app-name-aware through
215
+ `Studio.app_name`.
216
+
7
217
  **The local-review link now signs the reviewer in as someone who can SEE the
8
218
  page.** `/_studio/local_review` — the local half of the task board's WAITING
9
219
  APPROVAL button — provisions the account before it mints, at the new
data/README.md CHANGED
@@ -24,6 +24,7 @@ Then `bundle install`. The current release is **v0.6.1**; see [`CHANGELOG.md`](.
24
24
  - **Operator tooling**: Shared `studio/banners/environment` banner with Dev Mode + email connector controls, `studio/banners/impersonation`, and an opt-in `Studio::Impersonation` concern for Act As session conventions.
25
25
  - **Sluggable concern**: `before_save :set_slug` with `to_param` for human-readable URLs
26
26
  - **ThemeSetting model**: Per-app DB overrides with fallback to config defaults
27
+ - **Transactional emails**: `Studio::EmailCatalog` — every email an app sends, its type, a live preview, and its banner — plus the shared `/admin/emails` page. Every app inherits the standard emails and their artwork on day one, and can register its own workflows and upload its own banners. See [Transactional emails](#transactional-emails).
27
28
 
28
29
  ## Configuration
29
30
 
@@ -195,6 +196,39 @@ the block:
195
196
  <% end %>
196
197
  ```
197
198
 
199
+ **Page-scoped hosts — `studio/modals/scoped_host`.** When a page must bring its
200
+ own modals (because not every consuming app renders a shared host, and the ones
201
+ that do register their own modal set), render a second host on its own Alpine
202
+ store:
203
+
204
+ ```erb
205
+ <%= render "studio/modals/scoped_host", store: "emailModals" do %>
206
+ <template x-if="$store.emailModals.current()?.id === 'crop-photo'">
207
+ <%= render "studio/modals/crop_photo", store: "emailModals" %>
208
+ </template>
209
+ <% end %>
210
+ ```
211
+
212
+ `studio/modals/_crop_photo` and `_saving` take the same `store:` local, and
213
+ `imageUploadHost({ store: "emailModals", ... })` /
214
+ `submitFormWithProgress(form, { store: "emailModals" })` route through it — all
215
+ default to `"modals"`, so existing call sites are unchanged. The engine's
216
+ `/admin/emails` is the live example.
217
+
218
+ Two things that will bite you:
219
+
220
+ - **Render `scoped_host`, not `host`.** This is a non-isolated engine, so an app
221
+ view at the same path shadows the engine's — and `mcritchie-studio` and
222
+ `turf-monster` both ship their own `app/views/studio/modals/_host.html.erb`.
223
+ A page rendering `studio/modals/host` in those apps silently gets the app's
224
+ fork. `scoped_host` is unforked everywhere.
225
+ - **Guard registrations with `current()?.id`.** The outer template unmounts one
226
+ tick *after* the stack empties, so a bare `.id` throws on every close.
227
+
228
+ The scoped host takes its animations from `engine-motion.css` rather than an
229
+ inline copy, so a consumer bundling that layer gets the same spring as the shared
230
+ host.
231
+
198
232
  Store API (`Alpine.store('modals')`):
199
233
 
200
234
  | Call | Behavior |
@@ -268,6 +302,153 @@ The legacy string locals (`balance_html`, `extra_icons_html`, `div2_html` —
268
302
  pre-rendered HTML injected via `raw`) are deprecated but still honored when the
269
303
  matching slot is absent, so existing call sites render unchanged.
270
304
 
305
+ ## Transactional emails
306
+
307
+ Every consuming app can render the same admin page — **`/admin/emails`** —
308
+ listing each transactional email it sends with the banner riding at the top of
309
+ that email, and whether that banner is the **inherited default** or an
310
+ **app-owned override**. Supersedes the old `/admin/email_images`, which is
311
+ deprecated but still renders for one release.
312
+
313
+ **The page is opt-in:**
314
+
315
+ ```ruby
316
+ # config/initializers/studio.rb
317
+ config.draw_admin_emails_routes = true
318
+ ```
319
+
320
+ Off by default because `turf-monster` already owns `/admin/emails` and both of
321
+ its helper names; drawing them there raises at route-load and kills every route
322
+ in the app. The gate covers only the page — the registry and the inherited
323
+ defaults are always on.
324
+
325
+ ### The catalog
326
+
327
+ A registered email carries a key, a label, a description, what **type** it is,
328
+ how to build a **live preview** of it, and its banner image. The engine
329
+ pre-registers the two every Studio app sends, so a new app inherits both without
330
+ declaring anything:
331
+
332
+ | Key | Label |
333
+ |-----|-------|
334
+ | `magic_link` | Magic-link sign-in |
335
+ | `email_change_confirmation` | Email change confirmation |
336
+
337
+ A host adds its own workflows from an initializer, mirroring
338
+ `Studio::ModelPage.register`:
339
+
340
+ ```ruby
341
+ # config/initializers/studio_emails.rb
342
+ Rails.application.config.to_prepare do
343
+ Studio::EmailCatalog.register("winnings",
344
+ label: "Contest winnings",
345
+ description: "Sent when a player wins a contest.",
346
+ type: :transactional, # or :marketing
347
+ preview: -> { ContestMailer.winnings(Entry.where.not(rank: nil).first) })
348
+
349
+ Studio::EmailCatalog.register("wallet_export", label: "Wallet export")
350
+ end
351
+ ```
352
+
353
+ Every keyword is optional, and omitting one on a re-register **keeps** the
354
+ existing value — attach a preview to an inherited email, or relabel it, without
355
+ restating its artwork. An unknown `type` falls back to `:transactional` rather
356
+ than raising.
357
+
358
+ > `Studio::EmailImage` is the old name for this module and still works as a
359
+ > delegating shim. It is deprecated; prefer `Studio::EmailCatalog`.
360
+
361
+ ### Live preview
362
+
363
+ `preview:` is any callable returning a `Mail`. It powers `/admin/emails/:key`,
364
+ which renders the real email in an iframe from `/admin/emails/:key/raw`.
365
+
366
+ Builders run **only** on that page — listing the emails never executes one — and
367
+ every call is contained. A builder that raises yields no preview, records why,
368
+ and shows the reason in the frame. One broken builder costs one preview, not the
369
+ manager. That matters because a builder runs against whatever sample data an
370
+ environment happens to hold, which is exactly the thing that rots.
371
+
372
+ Re-registering an inherited key updates it in place and keeps its position, so
373
+ relabeling `magic_link` does not reorder the page or drop its default artwork.
374
+
375
+ ### Resolution — inherit, then own
376
+
377
+ ```
378
+ Studio::EmailCatalog.resolved_url(:magic_link)
379
+ 1. this app's ImageCache row (its own S3 bucket) -> app-owned override
380
+ 2. the engine's default gem asset -> inherited default
381
+ 3. nil -> sends bannerless
382
+ ```
383
+
384
+ Note the method: **`resolved_url` walks all three layers; `url` returns only
385
+ layer 1** (this app's own image, or nil). That split is deliberate — it keeps
386
+ every caller written before the registry behaving exactly as it did. A mailer
387
+ that already falls back on its own, `url(:magic_link) || own_banner`, must stay
388
+ on `url`; moving it to `resolved_url` makes that fallback unreachable and swaps
389
+ the app's committed artwork for the engine's default.
390
+
391
+ Defaults **ride the gem** (`app/assets/images/emails/*`), so a brand-new app with
392
+ an empty bucket sends branded email on day one and needs no cross-app S3
393
+ permission. Uploading on an app's `/admin/emails` writes to **that** app's bucket
394
+ and **that** app's `image_caches` row — which is exactly "the asset now belongs
395
+ to this app". Every app has its own bucket and table, so an override never leaks
396
+ between apps.
397
+
398
+ Three accessors, and picking the wrong one changes what real people receive:
399
+
400
+ | Call | Returns | Use for |
401
+ |---|---|---|
402
+ | `url(key)` | this app's **own** image, or `nil` | the pre-registry contract; a host doing its own `\|\| fallback` |
403
+ | `resolved_url(key)` | own → inherited default → `nil`, **absolute** | mailers |
404
+ | `preview_url(key)` | own → inherited default (root-relative) | the admin page |
405
+
406
+ `url` deliberately does **not** resolve to the default. `turf-monster`'s mailer
407
+ reads `Studio::EmailCatalog.url(:magic_link) || email_banner_url("magic-link-banner.jpg")`
408
+ — making `url` resolve would turn that `||` into dead code and swap its committed
409
+ branded banner for the engine placeholder in live email.
410
+
411
+ ```ruby
412
+ class UserMailer < ApplicationMailer
413
+ layout "branded_mailer"
414
+
415
+ def magic_link(email, token)
416
+ @banner_url = Studio::EmailCatalog.resolved_url(:magic_link) # nil renders bannerless
417
+ # ...
418
+ end
419
+ end
420
+ ```
421
+
422
+ Do **not** fork `layouts/branded_mailer.html.erb` — the engine's copy is
423
+ app-name-aware through `Studio.app_name`.
424
+
425
+ ### Sharing a bucket — `s3_key_prefix`
426
+
427
+ Uploads need `Studio.s3_bucket_prefix`. A satellite app that has no bucket of its
428
+ own can share an existing one under its own key namespace:
429
+
430
+ ```ruby
431
+ config.s3_bucket_prefix = "mcritchie-studio"
432
+ config.s3_key_prefix = "mcritchie-industries/"
433
+ # -> s3://mcritchie-studio-dev/mcritchie-industries/email_banners/...
434
+ ```
435
+
436
+ `Studio::S3` applies the prefix to every operation, so callers keep passing
437
+ logical keys and never see it. Unset (the default) leaves keys byte-identical to
438
+ what every already-shipped app wrote.
439
+
440
+ An app with **no** bucket configured does not error — `/admin/emails` renders
441
+ read-only, showing the inherited defaults it is genuinely sending and naming the
442
+ one setting that turns uploads on.
443
+
444
+ ### Linking it
445
+
446
+ Add it to the app's admin sidebar section:
447
+
448
+ ```ruby
449
+ { label: "Emails", href: admin_emails_path, emoji: "✉️", desc: "Transactional email banners" }
450
+ ```
451
+
271
452
  ## Overriding Views
272
453
 
273
454
  This is a non-isolated engine -- app views at the same path automatically override engine views. For example, placing `app/views/sessions/new.html.erb` in the consuming app replaces the engine's login page.
@@ -275,18 +456,24 @@ This is a non-isolated engine -- app views at the same path automatically overri
275
456
  ## Releasing
276
457
 
277
458
  Engine releases use semantic versions and are published to RubyGems. The full
278
- operator checklist lives in [`docs/RELEASE.md`](./docs/RELEASE.md).
279
-
280
- Short form:
281
-
282
- 1. Update [`CHANGELOG.md`](./CHANGELOG.md) and `lib/studio/version.rb`.
283
- 2. Run `bin/release-check --build`.
284
- 3. Publish the gem only after explicit approval.
285
- 4. Tag the release after RubyGems accepts the gem.
286
- 5. In each consumer app, run `bundle update studio-engine`, verify the lockfile,
287
- and run app smoke checks.
288
-
289
- **Semver guide**
459
+ checklist split by whether you are building or conducting the release — lives
460
+ in [`docs/RELEASE.md`](./docs/RELEASE.md).
461
+
462
+ **Building an engine change?** Do **not** edit `lib/studio/version.rb`. The
463
+ release owns the version, and McRitchie Studio's `bin/dor-check` refuses any PR
464
+ that touches that file. Update [`CHANGELOG.md`](./CHANGELOG.md) under
465
+ `Unreleased` (that is *not* gated), run `bin/release-check --build`, and open
466
+ your PR into `accepted`. That is the whole of your part.
467
+
468
+ **Conducting the release?** Commit the computed version — with `Gemfile.lock`,
469
+ which pins the engine's own path-gem version — directly onto `accepted`, then
470
+ run `bin/release prepare` from mcritchie-studio; it publishes, tags, and bumps
471
+ each consumer's lock. Details and the exact commands are in
472
+ [`docs/RELEASE.md`](./docs/RELEASE.md).
473
+
474
+ **Semver guide** — the release *derives* the bump from its members (a `breaking`
475
+ risk tag → major, a `feature` → minor, otherwise patch), so this is what those
476
+ levels mean, not a menu to pick from:
290
477
  - **PATCH**: bug fix; no API change. Consumers can update the gem with zero diff elsewhere.
291
478
  - **MINOR**: backward-compatible feature add. Consumers may opt in to new APIs.
292
479
  - **MAJOR**: breaking change. Consumers will need code changes alongside the tag bump.
@@ -1,20 +1,55 @@
1
1
  module Studio
2
- # Admin page to manage the banner image on transactional emails (Studio::
3
- # EmailImage). One managed variant today (magic_link); the registry is the
4
- # extension point. Shared by every app surfaced from each app's admin hub.
2
+ # DEPRECATED superseded by Studio::EmailsController (/admin/emails), which
3
+ # shows every registered email with its live image, says whether that image is
4
+ # the inherited default or app-owned, and uploads through the crop modal.
5
+ #
6
+ # Kept alive for ONE release on purpose, not by neglect. consumer-ci.yml checks
7
+ # out each consumer repo with no `ref:` — i.e. its DEFAULT BRANCH — and runs
8
+ # that suite against the engine PR. mcritchie-studio and turf-monster both have
9
+ # tests on `main` that drive this page and its admin_email_image_path helper,
10
+ # so deleting it here would redden their lanes from the moment the PR opens,
11
+ # and nothing inside the engine PR could fix it: the consumer PRs would have to
12
+ # travel accepted -> release -> main in BOTH apps before this could go green.
13
+ #
14
+ # So the retirement is staged: this release adds /admin/emails and leaves this
15
+ # page working; each app's adoption task moves its link and its tests; a later
16
+ # engine minor deletes this file, its view, and its two routes once no
17
+ # consumer's main references them.
18
+ #
19
+ # It is not frozen in its broken state, though — see #index.
5
20
  class EmailImagesController < ApplicationController
6
21
  before_action :require_admin
7
22
 
8
23
  MAX_BYTES = 8.megabytes
9
24
 
10
25
  def index
11
- @variants = Studio::EmailImage.variants
26
+ @variants = Studio::EmailCatalog.variants
12
27
  end
13
28
 
29
+ # The canonical page this one is being retired in favour of, or nil when this
30
+ # app has not drawn it. Rendered as a banner at the top of the old view so an
31
+ # admin who lands here by a stale link is walked forward.
32
+ #
33
+ # The nil case is REAL, not defensive padding: /admin/emails is opt-in
34
+ # (Studio.draw_admin_emails_routes, default off because turf-monster owns the
35
+ # name), so on any app that has not opted in the helper does not exist and a
36
+ # bare call raises NameError — which 500s this page. Consumer CI caught
37
+ # exactly that on mcritchie-studio.
38
+ #
39
+ # Asks the ROUTER rather than the config flag: the flag is read at route-draw
40
+ # time, so a host that flips it afterwards would still have no route, and the
41
+ # router is the thing that actually knows.
42
+ def successor_path
43
+ return nil unless Rails.application.routes.named_routes.key?(:admin_emails)
44
+
45
+ admin_emails_path
46
+ end
47
+ helper_method :successor_path
48
+
14
49
  # PATCH /admin/email_images/:variant — upload/replace a banner.
15
50
  def update
16
51
  variant = params[:variant].to_s
17
- return head :not_found unless Studio::EmailImage.known?(variant)
52
+ return head :not_found unless Studio::EmailCatalog.known?(variant)
18
53
 
19
54
  file = params[:image]
20
55
  unless valid_image?(file)
@@ -23,8 +58,8 @@ module Studio
23
58
  end
24
59
 
25
60
  rescue_and_log do
26
- Studio::EmailImage.store(variant, io: file, content_type: file.content_type)
27
- redirect_to admin_email_images_path, notice: "#{Studio::EmailImage.label(variant)} banner updated."
61
+ Studio::EmailCatalog.store(variant, io: file, content_type: file.content_type)
62
+ redirect_to admin_email_images_path, notice: "#{Studio::EmailCatalog.label(variant)} banner updated."
28
63
  end
29
64
  rescue StandardError
30
65
  redirect_to admin_email_images_path, alert: "Couldn't save the image. Please try again.", status: :see_other