studio-engine 0.37.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 +4 -4
- data/CHANGELOG.md +59 -2
- data/README.md +53 -23
- data/app/controllers/studio/email_images_controller.rb +4 -4
- data/app/controllers/studio/emails_controller.rb +55 -11
- data/app/mailers/user_mailer.rb +1 -1
- data/app/models/image_cache.rb +1 -1
- data/app/services/studio/email_catalog.rb +450 -0
- data/app/services/studio/email_image.rb +56 -317
- data/app/views/layouts/branded_mailer.html.erb +1 -1
- data/app/views/studio/email_images/index.html.erb +2 -2
- data/app/views/studio/emails/_row.html.erb +8 -3
- data/app/views/studio/emails/index.html.erb +4 -4
- data/app/views/studio/emails/show.html.erb +91 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +10 -3
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d497957db77f7b61823d1d12422672bf4339d71381163b79a6c42d705abf3492
|
|
4
|
+
data.tar.gz: e31717daca9b7722e4b9e87d44a507d9ec8c8055421e192bb3de78aa31175542
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6f75c493e66ebae0fd31571d237fa6a4f1546e9c50143382a4f54bfe88d5614c1f582af6d758cafe93ffc47b744b01d875a280de92823012bf224f028859627d
|
|
7
|
+
data.tar.gz: dfa14e14dbe5796816a0247bd965da477b69e27e8c81750f554e1d2d497fbf486da5d14f1ae900aa26b28dcfe22c14a51f2e83ead2089d98f9667b0142d43b6d
|
data/CHANGELOG.md
CHANGED
|
@@ -2,11 +2,68 @@
|
|
|
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
|
+
|
|
5
63
|
## 0.37.0 — 2026-08-10
|
|
6
64
|
|
|
7
65
|
### Added
|
|
8
|
-
- Standard transactional email primitive with a registry-backed catalog
|
|
9
|
-
email preview folded into that registry.
|
|
66
|
+
- Standard transactional email primitive with a registry-backed catalog.
|
|
10
67
|
|
|
11
68
|
### Fixed
|
|
12
69
|
- The crop confirm is guarded by owner, so one confirmed crop can no longer fan out
|
data/README.md
CHANGED
|
@@ -24,7 +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::
|
|
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).
|
|
28
28
|
|
|
29
29
|
## Configuration
|
|
30
30
|
|
|
@@ -322,11 +322,12 @@ its helper names; drawing them there raises at route-load and kills every route
|
|
|
322
322
|
in the app. The gate covers only the page — the registry and the inherited
|
|
323
323
|
defaults are always on.
|
|
324
324
|
|
|
325
|
-
### The
|
|
325
|
+
### The catalog
|
|
326
326
|
|
|
327
|
-
A registered email
|
|
328
|
-
|
|
329
|
-
every Studio app sends, so a new app inherits both without
|
|
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:
|
|
330
331
|
|
|
331
332
|
| Key | Label |
|
|
332
333
|
|-----|-------|
|
|
@@ -339,19 +340,42 @@ A host adds its own workflows from an initializer, mirroring
|
|
|
339
340
|
```ruby
|
|
340
341
|
# config/initializers/studio_emails.rb
|
|
341
342
|
Rails.application.config.to_prepare do
|
|
342
|
-
Studio::
|
|
343
|
-
|
|
344
|
-
|
|
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")
|
|
345
350
|
end
|
|
346
351
|
```
|
|
347
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
|
+
|
|
348
372
|
Re-registering an inherited key updates it in place and keeps its position, so
|
|
349
373
|
relabeling `magic_link` does not reorder the page or drop its default artwork.
|
|
350
374
|
|
|
351
375
|
### Resolution — inherit, then own
|
|
352
376
|
|
|
353
377
|
```
|
|
354
|
-
Studio::
|
|
378
|
+
Studio::EmailCatalog.resolved_url(:magic_link)
|
|
355
379
|
1. this app's ImageCache row (its own S3 bucket) -> app-owned override
|
|
356
380
|
2. the engine's default gem asset -> inherited default
|
|
357
381
|
3. nil -> sends bannerless
|
|
@@ -380,7 +404,7 @@ Three accessors, and picking the wrong one changes what real people receive:
|
|
|
380
404
|
| `preview_url(key)` | own → inherited default (root-relative) | the admin page |
|
|
381
405
|
|
|
382
406
|
`url` deliberately does **not** resolve to the default. `turf-monster`'s mailer
|
|
383
|
-
reads `Studio::
|
|
407
|
+
reads `Studio::EmailCatalog.url(:magic_link) || email_banner_url("magic-link-banner.jpg")`
|
|
384
408
|
— making `url` resolve would turn that `||` into dead code and swap its committed
|
|
385
409
|
branded banner for the engine placeholder in live email.
|
|
386
410
|
|
|
@@ -389,7 +413,7 @@ class UserMailer < ApplicationMailer
|
|
|
389
413
|
layout "branded_mailer"
|
|
390
414
|
|
|
391
415
|
def magic_link(email, token)
|
|
392
|
-
@banner_url = Studio::
|
|
416
|
+
@banner_url = Studio::EmailCatalog.resolved_url(:magic_link) # nil renders bannerless
|
|
393
417
|
# ...
|
|
394
418
|
end
|
|
395
419
|
end
|
|
@@ -432,18 +456,24 @@ This is a non-isolated engine -- app views at the same path automatically overri
|
|
|
432
456
|
## Releasing
|
|
433
457
|
|
|
434
458
|
Engine releases use semantic versions and are published to RubyGems. The full
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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:
|
|
447
477
|
- **PATCH**: bug fix; no API change. Consumers can update the gem with zero diff elsewhere.
|
|
448
478
|
- **MINOR**: backward-compatible feature add. Consumers may opt in to new APIs.
|
|
449
479
|
- **MAJOR**: breaking change. Consumers will need code changes alongside the tag bump.
|
|
@@ -23,7 +23,7 @@ module Studio
|
|
|
23
23
|
MAX_BYTES = 8.megabytes
|
|
24
24
|
|
|
25
25
|
def index
|
|
26
|
-
@variants = Studio::
|
|
26
|
+
@variants = Studio::EmailCatalog.variants
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
# The canonical page this one is being retired in favour of, or nil when this
|
|
@@ -49,7 +49,7 @@ module Studio
|
|
|
49
49
|
# PATCH /admin/email_images/:variant — upload/replace a banner.
|
|
50
50
|
def update
|
|
51
51
|
variant = params[:variant].to_s
|
|
52
|
-
return head :not_found unless Studio::
|
|
52
|
+
return head :not_found unless Studio::EmailCatalog.known?(variant)
|
|
53
53
|
|
|
54
54
|
file = params[:image]
|
|
55
55
|
unless valid_image?(file)
|
|
@@ -58,8 +58,8 @@ module Studio
|
|
|
58
58
|
end
|
|
59
59
|
|
|
60
60
|
rescue_and_log do
|
|
61
|
-
Studio::
|
|
62
|
-
redirect_to admin_email_images_path, notice: "#{Studio::
|
|
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."
|
|
63
63
|
end
|
|
64
64
|
rescue StandardError
|
|
65
65
|
redirect_to admin_email_images_path, alert: "Couldn't save the image. Please try again.", status: :see_other
|
|
@@ -4,7 +4,7 @@ module Studio
|
|
|
4
4
|
# controller whose view is a bare content wrapper, so it renders inside each
|
|
5
5
|
# host's application layout and picks up that app's navbar and theme.
|
|
6
6
|
#
|
|
7
|
-
# It lists Studio::
|
|
7
|
+
# It lists Studio::EmailCatalog's registry — one row per registered email, each
|
|
8
8
|
# showing its live banner and whether that banner is the INHERITED engine
|
|
9
9
|
# default or an APP-OWNED override — and writes an override through the shared
|
|
10
10
|
# crop modal. Replaces /admin/email_images, which now redirects here.
|
|
@@ -15,14 +15,37 @@ module Studio
|
|
|
15
15
|
# is currently sending.
|
|
16
16
|
class EmailsController < ApplicationController
|
|
17
17
|
before_action :require_admin
|
|
18
|
-
before_action :load_entry, only: %i[update destroy]
|
|
18
|
+
before_action :load_entry, only: %i[show raw update destroy]
|
|
19
19
|
before_action :require_uploads, only: %i[update destroy]
|
|
20
20
|
|
|
21
21
|
MAX_BYTES = 8.megabytes
|
|
22
22
|
|
|
23
23
|
def index
|
|
24
|
-
@entries = Studio::
|
|
25
|
-
@uploads_available = Studio::
|
|
24
|
+
@entries = Studio::EmailCatalog.entries
|
|
25
|
+
@uploads_available = Studio::EmailCatalog.uploads_available?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# GET /admin/emails/:key — one email: its banner, its type, and a live
|
|
29
|
+
# preview built from the host's sample data.
|
|
30
|
+
def show
|
|
31
|
+
@entry = Studio::EmailCatalog.entry(@key)
|
|
32
|
+
@subject = Studio::EmailCatalog.preview_subject(@key)
|
|
33
|
+
@preview_error = Studio::EmailCatalog.preview_error(@key)
|
|
34
|
+
@uploads_available = Studio::EmailCatalog.uploads_available?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# GET /admin/emails/:key/raw — the rendered email itself, as the iframe
|
|
38
|
+
# source on #show. Layout-less on purpose: this response IS the email.
|
|
39
|
+
#
|
|
40
|
+
# A preview builder is host code run against whatever sample data this
|
|
41
|
+
# environment happens to hold, so it is expected to fail sometimes. It
|
|
42
|
+
# renders the failure as a readable page inside the iframe rather than
|
|
43
|
+
# 500ing, so one broken builder costs one preview, not the manager.
|
|
44
|
+
def raw
|
|
45
|
+
html = Studio::EmailCatalog.preview_html(@key)
|
|
46
|
+
return render(html: preview_unavailable_html.html_safe, layout: false) if html.nil?
|
|
47
|
+
|
|
48
|
+
render html: html.html_safe, layout: false
|
|
26
49
|
end
|
|
27
50
|
|
|
28
51
|
# PATCH /admin/emails/:key — upload/replace this app's own banner.
|
|
@@ -34,8 +57,8 @@ module Studio
|
|
|
34
57
|
end
|
|
35
58
|
|
|
36
59
|
rescue_and_log do
|
|
37
|
-
Studio::
|
|
38
|
-
redirect_to admin_emails_path, notice: "#{Studio::
|
|
60
|
+
Studio::EmailCatalog.store(@key, io: file, content_type: file.content_type)
|
|
61
|
+
redirect_to admin_emails_path, notice: "#{Studio::EmailCatalog.label(@key)} banner updated.", status: :see_other
|
|
39
62
|
end
|
|
40
63
|
rescue StandardError
|
|
41
64
|
redirect_to admin_emails_path, alert: "Couldn't save the image. Please try again.", status: :see_other
|
|
@@ -50,11 +73,11 @@ module Studio
|
|
|
50
73
|
# failure is invisible — the admin sees "try again" and nothing reaches
|
|
51
74
|
# ErrorLog to say why.
|
|
52
75
|
rescue_and_log do
|
|
53
|
-
reverted = Studio::
|
|
76
|
+
reverted = Studio::EmailCatalog.revert(@key)
|
|
54
77
|
notice = if reverted
|
|
55
|
-
"#{Studio::
|
|
78
|
+
"#{Studio::EmailCatalog.label(@key)} reverted to the inherited default."
|
|
56
79
|
else
|
|
57
|
-
"#{Studio::
|
|
80
|
+
"#{Studio::EmailCatalog.label(@key)} was already using the inherited default."
|
|
58
81
|
end
|
|
59
82
|
redirect_to admin_emails_path, notice: notice, status: :see_other
|
|
60
83
|
end
|
|
@@ -66,16 +89,37 @@ module Studio
|
|
|
66
89
|
|
|
67
90
|
def load_entry
|
|
68
91
|
@key = params[:key].to_s
|
|
69
|
-
head :not_found unless Studio::
|
|
92
|
+
head :not_found unless Studio::EmailCatalog.known?(@key)
|
|
70
93
|
end
|
|
71
94
|
|
|
72
95
|
def require_uploads
|
|
73
|
-
return if Studio::
|
|
96
|
+
return if Studio::EmailCatalog.uploads_available?
|
|
74
97
|
|
|
75
98
|
redirect_to admin_emails_path, status: :see_other,
|
|
76
99
|
alert: "This app has no object storage configured, so email images can't be changed here yet."
|
|
77
100
|
end
|
|
78
101
|
|
|
102
|
+
# Shown INSIDE the preview iframe when the builder is missing or raised.
|
|
103
|
+
# Deliberately plain inline HTML: the iframe is its own document and does not
|
|
104
|
+
# inherit the host app's stylesheet.
|
|
105
|
+
def preview_unavailable_html
|
|
106
|
+
reason = Studio::EmailCatalog.preview_error(@key)
|
|
107
|
+
message = if reason
|
|
108
|
+
"This email's preview builder raised:<br><code style=\"color:#b91c1c\">" \
|
|
109
|
+
"#{ERB::Util.html_escape(reason)}</code>"
|
|
110
|
+
else
|
|
111
|
+
"No preview is registered for this email. Add a <code>preview:</code> " \
|
|
112
|
+
"callable when registering it to see it rendered here."
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
<<~HTML
|
|
116
|
+
<!doctype html>
|
|
117
|
+
<html><body style="margin:0;padding:32px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#334155;background:#f8fafc;">
|
|
118
|
+
<p style="font-size:14px;line-height:1.6;max-width:52ch;">#{message}</p>
|
|
119
|
+
</body></html>
|
|
120
|
+
HTML
|
|
121
|
+
end
|
|
122
|
+
|
|
79
123
|
def valid_image?(file)
|
|
80
124
|
file.respond_to?(:content_type) &&
|
|
81
125
|
file.content_type.to_s.start_with?("image/") &&
|
data/app/mailers/user_mailer.rb
CHANGED
|
@@ -24,7 +24,7 @@ class UserMailer < ApplicationMailer
|
|
|
24
24
|
# resolved_url, not url: this app's own upload if it has one, otherwise the
|
|
25
25
|
# engine's default banner — which is what makes a brand-new app's sign-in
|
|
26
26
|
# email branded on day one. nil renders bannerless.
|
|
27
|
-
@banner_url = Studio::
|
|
27
|
+
@banner_url = Studio::EmailCatalog.resolved_url(:magic_link)
|
|
28
28
|
@banner_alt = "Your #{@app_name} sign-in link"
|
|
29
29
|
mail(to: email, subject: "Your #{@app_name} sign-in link")
|
|
30
30
|
end
|
data/app/models/image_cache.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
class ImageCache < ApplicationRecord
|
|
2
2
|
# Optional so app-GLOBAL images (no owning record) can be cached too — e.g.
|
|
3
|
-
# Studio::
|
|
3
|
+
# Studio::EmailCatalog stores the admin-managed email banners owner-less. Per-
|
|
4
4
|
# record images (athlete/coach headshots) still set an owner; the
|
|
5
5
|
# variant-uniqueness scope below keeps both shapes distinct.
|
|
6
6
|
belongs_to :owner, polymorphic: true, optional: true
|