product_tours 0.3.3 → 1.0.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/AGENTS.md +11 -5
- data/CHANGELOG.md +54 -3
- data/README.md +60 -4
- data/Rakefile +9 -1
- data/SECURITY.md +50 -0
- data/app/controllers/concerns/product_tours/request_context.rb +3 -3
- data/app/controllers/product_tours/dashboard_controller.rb +1 -1
- data/app/controllers/product_tours/interactions_controller.rb +77 -0
- data/app/controllers/product_tours/media_controller.rb +23 -4
- data/app/controllers/product_tours/tours_controller.rb +203 -52
- data/app/helpers/product_tours/{posts_helper.rb → tours_helper.rb} +1 -1
- data/app/models/product_tours/{post.rb → tour.rb} +15 -15
- data/app/views/product_tours/{posts → tours}/_form.html.erb +34 -34
- data/app/views/product_tours/{posts/_post_panel.html.erb → tours/_tour_panel.html.erb} +33 -33
- data/app/views/product_tours/{posts → tours}/edit.html.erb +3 -3
- data/app/views/product_tours/{posts → tours}/index.html.erb +23 -23
- data/app/views/product_tours/tours/new.html.erb +7 -0
- data/app/views/product_tours/{posts → tours}/show.html.erb +2 -2
- data/config/routes.rb +4 -4
- data/lib/generators/product_tours/install/install_generator.rb +3 -3
- data/lib/generators/product_tours/install/templates/{create_product_tours_posts.rb.tt → create_product_tours_tours.rb.tt} +5 -5
- data/lib/product_tours/dashboard.css +1 -1
- data/lib/product_tours/dashboard.js +16 -16
- data/lib/product_tours/seeds.rb +22 -22
- data/lib/product_tours/version.rb +1 -1
- data/lib/product_tours/widget.js +26 -26
- data/{app/helpers → lib}/product_tours/widget_helper.rb +4 -0
- data/lib/product_tours.rb +1 -0
- data/lib/tasks/product_tours_tasks.rake +3 -3
- metadata +20 -13
- data/app/controllers/product_tours/posts_controller.rb +0 -228
- data/app/views/product_tours/posts/new.html.erb +0 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8c3fecc3261d2acf6fb9267139897e7e0c4c6fa9dd3a8d52466380f2fe040c9e
|
|
4
|
+
data.tar.gz: 0112d06e16cd88171023f3dd32988348e8dd642334db67c5f33cb1c4194363a4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 670382b8c322e3803495d1a294aebfc21036aa69400fb7e9055a98de1243c8ba1e716dadacfcd0909885cbd18e77914f7114f54cb2caf1b388e8e9e386ec00da
|
|
7
|
+
data.tar.gz: 54eb9697c17e7fd50f170dd21e7d15ee994459acd4b7151d98def0f2622586ca40f913dba9f451ceb7676ac5c79cb33bb946b55469e2c342372c83ba4fecc189
|
data/AGENTS.md
CHANGED
|
@@ -5,7 +5,7 @@ Instructions for coding agents. Two audiences:
|
|
|
5
5
|
- **[Installing product_tours into a Rails app](#installing-into-a-rails-app)** — you are working in a host app and were asked to add product tours, onboarding guides, or video tutorials.
|
|
6
6
|
- **[Working on the gem itself](#working-on-the-gem-itself)** — you are working in this repository.
|
|
7
7
|
|
|
8
|
-
Requirements: Ruby >= 3.2, Rails >= 7.1. Active Storage only for uploaded videos, Action Text only for rich descriptions — both optional and both degrade rather than raise.
|
|
8
|
+
Requirements: Ruby >= 3.2, Rails >= 7.1 and < 9. Active Storage only for uploaded videos, Action Text only for rich descriptions — both optional and both degrade rather than raise.
|
|
9
9
|
|
|
10
10
|
If you are in a host app and this file is not in front of you, it ships inside the gem: `cat "$(bundle show product_tours)/AGENTS.md"`.
|
|
11
11
|
|
|
@@ -21,7 +21,7 @@ bin/rails generate product_tours:install
|
|
|
21
21
|
bin/rails db:migrate
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
The generator writes `config/initializers/product_tours.rb`, one migration (`
|
|
24
|
+
The generator writes `config/initializers/product_tours.rb`, one migration (`product_tours_tours`), and `mount_product_tours at: "/product_tours"` into `config/routes.rb`. **In development the migration also inserts working demo tutorials**, so the modal has something to open before anyone has written content. Read the initializer it wrote — it is the source of truth over any summary of it, including this file.
|
|
25
25
|
|
|
26
26
|
### 2. Wire the three things the generator cannot
|
|
27
27
|
|
|
@@ -69,7 +69,7 @@ Then in the running app: open `/product_tours`, confirm the demo tutorials are l
|
|
|
69
69
|
|
|
70
70
|
### Tutorials are content, not code
|
|
71
71
|
|
|
72
|
-
A tutorial is a row in `
|
|
72
|
+
A tutorial is a row in `product_tours_tours`, written and published in the mounted dashboard. **Do not create tutorials from host-app migrations, seeds, or fixtures** — that is not how this gem is meant to be used, and it puts editorial content in schema history. If the app needs sample content, `bin/rails product_tours:seed_demo` is the supported path.
|
|
73
73
|
|
|
74
74
|
What a tutorial carries: a `key`, a title, an optional rich description, an optional video, and at most one primary action (a URL, or the key of the next tutorial — that link is what makes a walkthrough). Keys must match `/\A[a-z0-9]+(?:[._-][a-z0-9]+)*\z/` — lowercase, digits, and `. _ -` as separators. `Billing Setup` and `billingSetup` are invalid; `billing_setup` and `billing.step-1` are fine. `status` is `draft` or `published`, and the key is unique **per locale**, which is how translations work: one row per language for the same key, with locale fallback at resolve time.
|
|
75
75
|
|
|
@@ -112,7 +112,7 @@ exceptions without breaking the visitor flow. Keep it fast or enqueue a job.
|
|
|
112
112
|
- **Do not create or edit tutorials in code** (see above).
|
|
113
113
|
- **Do not add provider hosts to `frame-src` by hand** — the engine already does it.
|
|
114
114
|
- **Do not serve uploaded videos by blob URL** — the gated media route exists so a leaked signed URL cannot hand over your content.
|
|
115
|
-
- **Do not install Active Storage or Action Text "to make it work"** unless the app actually wants uploads or rich text. Both are optional; the gem checks for them (`
|
|
115
|
+
- **Do not install Active Storage or Action Text "to make it work"** unless the app actually wants uploads or rich text. Both are optional; the gem checks for them (`Tour.video_upload_supported?`, `Tour.description_supported?`) and simply offers less.
|
|
116
116
|
|
|
117
117
|
### Configuration
|
|
118
118
|
|
|
@@ -152,13 +152,19 @@ There are seven options. That is the whole surface.
|
|
|
152
152
|
|
|
153
153
|
## Working on the gem itself
|
|
154
154
|
|
|
155
|
+
The development toolchain is pinned to Ruby 4.0.5 in `mise.toml`. Do not use
|
|
156
|
+
macOS's `/usr/bin/ruby`. In shells, hooks, or automation where mise activation
|
|
157
|
+
is uncertain, explicitly prepend the configured Ruby, for example
|
|
158
|
+
`PATH="$(mise where ruby)/bin:$PATH" bundle exec rake test`.
|
|
159
|
+
|
|
155
160
|
```bash
|
|
156
161
|
bundle exec rake test # minitest, dummy app under test/dummy
|
|
162
|
+
bundle exec rake test:system # real Next/Back/finish flow in headless Chrome
|
|
157
163
|
bundle exec rubocop # must be clean
|
|
158
164
|
BUNDLE_GEMFILE=gemfiles/rails_7.1.gemfile bundle exec rake test # 7.1, 7.2, 8.0, 8.1 in gemfiles/
|
|
159
165
|
```
|
|
160
166
|
|
|
161
|
-
Layout: `app/` controllers, `
|
|
167
|
+
Layout: `app/` controllers, `Tour`, dashboard views · `lib/product_tours/` config, widget JS, seeds, engine, CSP patch · `lib/generators/product_tours/install/` the one generator · `config/locales/` 26 locales · `test/` minitest with `test/dummy` as the host app.
|
|
162
168
|
|
|
163
169
|
Conventions this codebase holds to — follow them rather than the first thing that works:
|
|
164
170
|
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,40 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [1.0.0] - 2026-08-11
|
|
6
|
+
|
|
7
|
+
- **The record is a tour, all the way down.** `ProductTours::Post` is now
|
|
8
|
+
`ProductTours::Tour`; fresh installs create `product_tours_tours`, linked
|
|
9
|
+
steps use `action_tour_key`, lifecycle payloads use `tour_id`, the widget
|
|
10
|
+
resolves `/widget/tour`, and dashboard resources live under `/tours`. There
|
|
11
|
+
is deliberately no `Post` compatibility alias: v1 is the point where the
|
|
12
|
+
public vocabulary becomes honest and then freezes under semantic versioning.
|
|
13
|
+
- Existing 0.3.x installs must rename the `product_tours_posts` table to
|
|
14
|
+
`product_tours_tours` and its `action_post_key` column to `action_tour_key`
|
|
15
|
+
before booting 1.0.0. Host code must replace `ProductTours::Post`, old
|
|
16
|
+
dashboard paths, and the old `post_id` lifecycle payload key. Fresh installs
|
|
17
|
+
need no upgrade migration.
|
|
18
|
+
- Added one headless-Chrome system test that opens a linked walkthrough, moves
|
|
19
|
+
Next and Back, and finishes it. Capybara and Selenium are development/test
|
|
20
|
+
dependencies only; the runtime gem API and dependency surface are unchanged.
|
|
21
|
+
- Refreshed the PRD's shipped version, database-index contract, acceptance
|
|
22
|
+
wording, compatibility wording, and the missing release comparison links.
|
|
23
|
+
- **Uploaded videos now stay behind the engine gate.** The media controller
|
|
24
|
+
streams full and byte-range responses directly after authorization instead of
|
|
25
|
+
redirecting to a reusable signed Active Storage blob URL. Hosts gain the
|
|
26
|
+
privacy promised by the existing docs without losing playback or seeking.
|
|
27
|
+
- **Hosts that load Action View from an initializer boot normally.**
|
|
28
|
+
`WidgetHelper` is required from `lib` before the engine registers its Action
|
|
29
|
+
View hook; a dummy-host initializer reproduces that early load on every test
|
|
30
|
+
boot. `product_tours_tag` is unchanged.
|
|
31
|
+
- The documented model, configuration, route/helper, key, event, and callback
|
|
32
|
+
surface is now the stable 1.x API. Added a security policy with explicit
|
|
33
|
+
content, event, deletion, and retention boundaries.
|
|
34
|
+
- Supported combinations are Ruby 3.2 through 4.0 and Rails 7.1 through 8.1.
|
|
35
|
+
Rails now has a `< 9` upper bound. Release validation builds the gem, verifies
|
|
36
|
+
RubyGems indexing, and creates the GitHub Release. Generator tests write only
|
|
37
|
+
under ignored root `tmp/`, so tests cannot contaminate later lint runs.
|
|
38
|
+
|
|
5
39
|
## [0.3.3] - 2026-08-08
|
|
6
40
|
|
|
7
41
|
- **The demo walkthrough is now a tour of Product Tours itself.** Its linked
|
|
@@ -36,7 +70,7 @@
|
|
|
36
70
|
are logged and never turn a working tutorial action into a visitor-facing
|
|
37
71
|
failure.
|
|
38
72
|
|
|
39
|
-
## 0.3.0
|
|
73
|
+
## [0.3.0] - 2026-08-05
|
|
40
74
|
|
|
41
75
|
- **One design system across the family.** The stylesheet now opens with a
|
|
42
76
|
shared core — the colour tokens, `.page-head`, `.tabs`, `.filters`, `.card`,
|
|
@@ -61,7 +95,17 @@
|
|
|
61
95
|
button on one line, and a `code.key` truncates inside a list row instead of
|
|
62
96
|
wrapping over three lines.
|
|
63
97
|
|
|
64
|
-
## 0.2.
|
|
98
|
+
## [0.2.2] - 2026-08-04
|
|
99
|
+
|
|
100
|
+
- Restored the intended dashboard width after CSS scoping and documented the
|
|
101
|
+
shared five-gem family conventions.
|
|
102
|
+
|
|
103
|
+
## [0.2.1] - 2026-08-04
|
|
104
|
+
|
|
105
|
+
- Restored the dashboard layout rules that the new scoped wrapper had
|
|
106
|
+
accidentally overridden.
|
|
107
|
+
|
|
108
|
+
## [0.2.0] - 2026-08-04
|
|
65
109
|
|
|
66
110
|
- **`config.admin_layout` now works on its own.** The dashboard's stylesheet and
|
|
67
111
|
script were declared in the gem's layout, so replacing that layout dropped
|
|
@@ -156,8 +200,15 @@
|
|
|
156
200
|
- Removed generic tutorial duplication now that translations provide the only
|
|
157
201
|
intentional content-copying workflow.
|
|
158
202
|
|
|
159
|
-
[Unreleased]: https://github.com/yshmarov/product_tours/compare/
|
|
203
|
+
[Unreleased]: https://github.com/yshmarov/product_tours/compare/v1.0.0...HEAD
|
|
204
|
+
[1.0.0]: https://github.com/yshmarov/product_tours/compare/v0.3.3...v1.0.0
|
|
205
|
+
[0.3.3]: https://github.com/yshmarov/product_tours/compare/v0.3.2...v0.3.3
|
|
160
206
|
[0.3.2]: https://github.com/yshmarov/product_tours/compare/v0.3.1...v0.3.2
|
|
161
207
|
[0.3.1]: https://github.com/yshmarov/product_tours/compare/v0.3.0...v0.3.1
|
|
208
|
+
[0.3.0]: https://github.com/yshmarov/product_tours/compare/v0.2.2...v0.3.0
|
|
209
|
+
[0.2.2]: https://github.com/yshmarov/product_tours/compare/v0.2.1...v0.2.2
|
|
210
|
+
[0.2.1]: https://github.com/yshmarov/product_tours/compare/v0.2.0...v0.2.1
|
|
211
|
+
[0.2.0]: https://github.com/yshmarov/product_tours/compare/v0.1.2...v0.2.0
|
|
212
|
+
[0.1.2]: https://github.com/yshmarov/product_tours/compare/v0.1.1...v0.1.2
|
|
162
213
|
[0.1.1]: https://github.com/yshmarov/product_tours/compare/v0.1.0...v0.1.1
|
|
163
214
|
[0.1.0]: https://github.com/yshmarov/product_tours/releases/tag/v0.1.0
|
data/README.md
CHANGED
|
@@ -51,7 +51,7 @@ small set of working tutorials so you can try the modal immediately.
|
|
|
51
51
|
> The dashboard defaults to **development only**. Set `authorize_admin` before
|
|
52
52
|
> deploying it — see [Configure](#configure).
|
|
53
53
|
|
|
54
|
-
Ruby >= 3.2 · Rails >= 7.1 · Active Storage only for uploaded videos · Action
|
|
54
|
+
Ruby >= 3.2 · Rails >= 7.1 and < 9 · Active Storage only for uploaded videos · Action
|
|
55
55
|
Text only for rich descriptions.
|
|
56
56
|
|
|
57
57
|
Installing with a coding agent? Point it at [AGENTS.md](AGENTS.md) — the same
|
|
@@ -213,7 +213,7 @@ key. Running the task again updates those records instead of duplicating them.
|
|
|
213
213
|
<button type="button" data-product-tour="demo_direct_video">Open the direct video tutorial</button>
|
|
214
214
|
<button type="button" data-product-tour="demo_getting_started">Open the getting started guide</button>
|
|
215
215
|
<button type="button" data-product-tour="demo_draft">Try an unpublished tutorial</button>
|
|
216
|
-
<button type="button" data-product-tour="
|
|
216
|
+
<button type="button" data-product-tour="demo_missing_tour">Try a missing tutorial</button>
|
|
217
217
|
</div>
|
|
218
218
|
<%= product_tours_tag %>
|
|
219
219
|
```
|
|
@@ -296,7 +296,10 @@ table. It emits three Rails notifications:
|
|
|
296
296
|
| `product_tours.dismissed` | The visitor closed it before using the primary action |
|
|
297
297
|
| `product_tours.completed` | The visitor used the primary action |
|
|
298
298
|
|
|
299
|
-
Payload: `
|
|
299
|
+
Payload: `tour_id`, `key`, `locale`, query-free `page_url`, and `source`.
|
|
300
|
+
The `source` vocabulary is also stable: viewed events use `modal`,
|
|
301
|
+
`linked_tour`, or `back`; completed events use `action` or `linked_tour`; and
|
|
302
|
+
dismissed events use `modal`.
|
|
300
303
|
|
|
301
304
|
Bridge them to Ahoy—or anything else—in your host app:
|
|
302
305
|
|
|
@@ -335,8 +338,51 @@ Invalid, missing, draft, or disabled keys open nothing for the visitor.
|
|
|
335
338
|
|
|
336
339
|
No end user gets a Rails error page because somebody renamed a tutorial.
|
|
337
340
|
|
|
341
|
+
## Compatibility and public API
|
|
342
|
+
|
|
343
|
+
The following are the model and integration contracts that 1.x will keep
|
|
344
|
+
stable under semantic versioning:
|
|
345
|
+
|
|
346
|
+
- `ProductTours::Tour`, including `key`, `locale`, `status`, `title`,
|
|
347
|
+
`video_url`, `video_metadata`, `action_label`, `action_url`,
|
|
348
|
+
`action_tour_key`, timestamps, `STATUSES`, status methods/scopes,
|
|
349
|
+
`newest_first`, `action_tour`, `description_supported?`,
|
|
350
|
+
`video_upload_supported?`, and optional rich-description/video behavior.
|
|
351
|
+
- `ProductTours.configure` and the documented configuration options.
|
|
352
|
+
- `mount_product_tours`, `product_tours_tag`, and `data-product-tour="key"`.
|
|
353
|
+
- The tutorial key grammar and per-locale uniqueness contract.
|
|
354
|
+
- `product_tours.viewed`, `product_tours.dismissed`, and
|
|
355
|
+
`product_tours.completed`, with `tour_id`, `key`, `locale`, query-free
|
|
356
|
+
`page_url`, and the documented `source` values.
|
|
357
|
+
- `config.on_event(name, payload, request)` and
|
|
358
|
+
`product_tours.unresolved_trigger`, whose payload has `key`, `locale`,
|
|
359
|
+
query-free `page_url`, and one of `invalid_key`, `missing`, `unpublished`, or
|
|
360
|
+
`disabled` as its `reason`.
|
|
361
|
+
|
|
362
|
+
Dashboard controllers, view partials, CSS classes, generated HTML, and widget
|
|
363
|
+
implementation objects are private. Incompatible changes to the public list
|
|
364
|
+
above wait for a new major version; a deprecation normally ships first.
|
|
365
|
+
|
|
366
|
+
### Upgrading from 0.3.x
|
|
367
|
+
|
|
368
|
+
Version 1.0.0 is the deliberate terminology break from the pre-1.0 API.
|
|
369
|
+
Existing installs must rename their generated table and linked-step column:
|
|
370
|
+
|
|
371
|
+
```ruby
|
|
372
|
+
rename_table :product_tours_posts, :product_tours_tours
|
|
373
|
+
rename_column :product_tours_tours, :action_post_key, :action_tour_key
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
Replace `ProductTours::Post` with `ProductTours::Tour`, `post_id` event payloads
|
|
377
|
+
with `tour_id`, and any dashboard links under `/product_tours/posts` with
|
|
378
|
+
`/product_tours/tours`. Fresh installs already generate the new schema.
|
|
379
|
+
|
|
338
380
|
## Security
|
|
339
381
|
|
|
382
|
+
See [SECURITY.md](https://github.com/yshmarov/product_tours/blob/main/SECURITY.md)
|
|
383
|
+
for supported versions, private vulnerability reporting, the stored-data and
|
|
384
|
+
lifecycle-event boundary, and deletion/retention guidance.
|
|
385
|
+
|
|
340
386
|
- Admin authorization runs server-side on every dashboard request.
|
|
341
387
|
- Public resolution returns only published tutorials allowed by `enabled`.
|
|
342
388
|
- Video providers are an HTTPS allowlist; unsupported URLs render no iframe.
|
|
@@ -346,6 +392,11 @@ No end user gets a Rails error page because somebody renamed a tutorial.
|
|
|
346
392
|
`frame-src` without replacing the host policy.
|
|
347
393
|
- For direct videos on another origin, allow that origin in the host app's
|
|
348
394
|
`media-src` policy.
|
|
395
|
+
- Uploaded videos stream from the gated engine route with byte-range support and
|
|
396
|
+
private, non-stored responses; no reusable signed blob URL is exposed.
|
|
397
|
+
- The host owns retention and deletion. Remove a row with
|
|
398
|
+
`ProductTours::Tour.find(id).destroy!`, then apply object-store, backup,
|
|
399
|
+
analytics, cache, and export retention policies separately.
|
|
349
400
|
|
|
350
401
|
## What it doesn't do
|
|
351
402
|
|
|
@@ -360,10 +411,15 @@ stack owns persistence and reporting. The gem stays small enough to understand.
|
|
|
360
411
|
|
|
361
412
|
```bash
|
|
362
413
|
bundle exec rake test
|
|
414
|
+
bundle exec rake test:system
|
|
363
415
|
bundle exec rubocop
|
|
364
416
|
```
|
|
365
417
|
|
|
366
|
-
|
|
418
|
+
The normal suite covers the Rails engine contract. The system task drives the
|
|
419
|
+
real widget through Next, Back, and finish in headless Chrome. CI runs Rails
|
|
420
|
+
7.1, 7.2, 8.0, and 8.1 against Ruby 3.2 through 4.0, plus that browser test once
|
|
421
|
+
on Ruby 4.0. Rails 9 is outside the 1.x dependency contract until it passes the
|
|
422
|
+
suite.
|
|
367
423
|
|
|
368
424
|
Bug reports and pull requests are welcome. The most useful report is a real
|
|
369
425
|
Rails app and the exact point where installation or authoring felt confusing.
|
data/Rakefile
CHANGED
|
@@ -5,7 +5,15 @@ require 'rake/testtask'
|
|
|
5
5
|
|
|
6
6
|
Rake::TestTask.new(:test) do |task|
|
|
7
7
|
task.libs << 'test'
|
|
8
|
-
task.
|
|
8
|
+
task.test_files = FileList['test/**/*_test.rb'].exclude('test/system/**/*')
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
namespace :test do
|
|
12
|
+
desc 'Run browser (system) tests'
|
|
13
|
+
Rake::TestTask.new(:system) do |task|
|
|
14
|
+
task.libs << 'test'
|
|
15
|
+
task.test_files = FileList['test/system/**/*_test.rb']
|
|
16
|
+
end
|
|
9
17
|
end
|
|
10
18
|
|
|
11
19
|
task default: :test
|
data/SECURITY.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Security policy
|
|
2
|
+
|
|
3
|
+
## Supported versions
|
|
4
|
+
|
|
5
|
+
Security fixes are released for the latest 1.x version. If a report also
|
|
6
|
+
affects the newest 0.x release, a backport may be published when the fix is
|
|
7
|
+
small and the affected application cannot upgrade immediately.
|
|
8
|
+
|
|
9
|
+
## Reporting a vulnerability
|
|
10
|
+
|
|
11
|
+
Please do not open a public issue for a vulnerability. Use
|
|
12
|
+
[GitHub private vulnerability reporting](https://github.com/yshmarov/product_tours/security/advisories/new)
|
|
13
|
+
and include the affected version, a minimal reproduction, impact, and any known
|
|
14
|
+
workaround. Credentials, session cookies, private application URLs, customer
|
|
15
|
+
content, uploaded videos, and production database contents should not be
|
|
16
|
+
included.
|
|
17
|
+
|
|
18
|
+
Public disclosure should wait until a fixed version is available and affected
|
|
19
|
+
users have had a reasonable opportunity to upgrade.
|
|
20
|
+
|
|
21
|
+
## Data and deployment boundary
|
|
22
|
+
|
|
23
|
+
The public widget resolves only published tours allowed by `config.enabled`,
|
|
24
|
+
which defaults to everyone because showing tutorials in production is the gem's
|
|
25
|
+
purpose. The dashboard is separately protected by `authorize_admin` and fails
|
|
26
|
+
closed outside development until the host explicitly grants access.
|
|
27
|
+
|
|
28
|
+
A tour can store its key, locale, status, title, video URL and metadata, primary
|
|
29
|
+
action label/destination, timestamps, optional Action Text description, and an
|
|
30
|
+
optional Active Storage video. Uploaded videos stream through the engine's
|
|
31
|
+
authorization gate with private, non-stored responses; the route never redirects
|
|
32
|
+
to a reusable signed blob URL. Hosts still own the storage service, bucket
|
|
33
|
+
policy, backups, retention period, access controls, and incident response.
|
|
34
|
+
|
|
35
|
+
The gem emits lifecycle events containing a tour id, key, locale, query-free
|
|
36
|
+
page URL, and source, but does not persist visitor events, identity, progress,
|
|
37
|
+
or completion. A host's `on_event` hook receives the raw request; anything it
|
|
38
|
+
derives or persists is part of the host application's own privacy boundary.
|
|
39
|
+
|
|
40
|
+
Delete a tour with ordinary Active Record when an application policy requires
|
|
41
|
+
erasure:
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
ProductTours::Tour.find(id).destroy!
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
That removes the record and lets Rails apply the configured Action Text and
|
|
48
|
+
Active Storage attachment lifecycles. Confirm deletion separately in object
|
|
49
|
+
storage, replicas, backups, caches, analytics, and exports; deleting the live
|
|
50
|
+
row does not rewrite them.
|
|
@@ -30,10 +30,10 @@ module ProductTours
|
|
|
30
30
|
ProductTours.locale(request)
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
def
|
|
33
|
+
def find_tour_by_locale(scope, key)
|
|
34
34
|
locales = [current_product_tours_locale, I18n.default_locale.to_s].uniq
|
|
35
|
-
|
|
36
|
-
locales.filter_map { |locale|
|
|
35
|
+
tours = scope.where(locale: locales, key: key).index_by(&:locale)
|
|
36
|
+
locales.filter_map { |locale| tours[locale] }.first
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
def clean_page_url(value)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module ProductTours
|
|
4
|
-
# Root of the STAFF surface: the
|
|
4
|
+
# Root of the STAFF surface: the tour editor and its list.
|
|
5
5
|
#
|
|
6
6
|
# Inherits from `config.base_controller_class` — by default a plain
|
|
7
7
|
# ActionController::Base, which is why `authorize_admin` exists. Point it at
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ProductTours
|
|
4
|
+
class InteractionsController < ApplicationController
|
|
5
|
+
SIGNALS = %w[viewed dismissed completed].freeze
|
|
6
|
+
|
|
7
|
+
def resolve
|
|
8
|
+
key = params[:key].to_s
|
|
9
|
+
return handle_unresolved_trigger(key, :disabled) unless ProductTours.enabled?(request)
|
|
10
|
+
return handle_unresolved_trigger(key, :invalid_key) unless key.match?(Tour::KEY_FORMAT)
|
|
11
|
+
|
|
12
|
+
tour = find_tour_by_locale(Tour.all, key)
|
|
13
|
+
return handle_unresolved_trigger(key, :missing) unless tour
|
|
14
|
+
return handle_unresolved_trigger(key, :unpublished) unless tour.published?
|
|
15
|
+
|
|
16
|
+
render json: tour_payload(tour)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def signal
|
|
20
|
+
return head :forbidden unless ProductTours.enabled?(request)
|
|
21
|
+
|
|
22
|
+
action = params[:event_action].to_s
|
|
23
|
+
return head :unprocessable_entity unless SIGNALS.include?(action)
|
|
24
|
+
|
|
25
|
+
tour = find_tour_by_locale(Tour.published, params[:key].to_s)
|
|
26
|
+
return head :not_found unless tour
|
|
27
|
+
|
|
28
|
+
payload = {
|
|
29
|
+
tour_id: tour.id,
|
|
30
|
+
key: tour.key,
|
|
31
|
+
locale: tour.locale,
|
|
32
|
+
page_url: clean_page_url(params[:page_url]),
|
|
33
|
+
source: params[:source].to_s.presence
|
|
34
|
+
}
|
|
35
|
+
event_name = "product_tours.#{action}"
|
|
36
|
+
ActiveSupport::Notifications.instrument(event_name, payload)
|
|
37
|
+
notify_host(event_name, payload)
|
|
38
|
+
head :no_content
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def notify_host(event_name, payload)
|
|
44
|
+
ProductTours.config.on_event.call(event_name, payload, request)
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
Rails.logger.error("product_tours: on_event hook raised #{e.class}: #{e.message}")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def tour_payload(tour)
|
|
50
|
+
video = tour.resolved_video
|
|
51
|
+
video[:url] = media_path(tour) if video&.dig(:kind) == 'upload'
|
|
52
|
+
{
|
|
53
|
+
key: tour.key,
|
|
54
|
+
locale: tour.locale,
|
|
55
|
+
title: tour.title,
|
|
56
|
+
descriptionHtml: description_html(tour),
|
|
57
|
+
video: video,
|
|
58
|
+
action: {
|
|
59
|
+
label: tour.action_label.presence || default_action_label(tour),
|
|
60
|
+
url: tour.action_url.presence,
|
|
61
|
+
tourKey: tour.action_tour_key.presence
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def default_action_label(tour)
|
|
67
|
+
key = tour.action_tour_key.present? ? :next : :done
|
|
68
|
+
I18n.t(key, scope: :product_tours, default: key.to_s.humanize)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def description_html(tour)
|
|
72
|
+
return unless Tour.description_supported? && tour.description.present?
|
|
73
|
+
|
|
74
|
+
tour.description.body.to_s
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -2,13 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
module ProductTours
|
|
4
4
|
class MediaController < ApplicationController
|
|
5
|
+
include ActiveStorage::Streaming if defined?(::ActiveStorage::Streaming)
|
|
6
|
+
|
|
5
7
|
def show
|
|
6
|
-
|
|
7
|
-
allowed = ProductTours.admin?(request) || (ProductTours.enabled?(request) &&
|
|
8
|
+
tour = Tour.find(params[:id])
|
|
9
|
+
allowed = ProductTours.admin?(request) || (ProductTours.enabled?(request) && tour.published?)
|
|
8
10
|
return head :forbidden unless allowed
|
|
9
|
-
return head :not_found unless
|
|
11
|
+
return head :not_found unless tour.uploaded_video?
|
|
12
|
+
|
|
13
|
+
stream(tour.video.blob)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
10
17
|
|
|
11
|
-
|
|
18
|
+
# Keep the browser on the authorized engine URL while retaining the byte
|
|
19
|
+
# range behavior video playback and seeking need. Active Storage's own
|
|
20
|
+
# streaming concern supports both full and ranged responses without a
|
|
21
|
+
# permanent signed blob URL escaping this controller's gate.
|
|
22
|
+
def stream(blob)
|
|
23
|
+
response.headers['Cache-Control'] = 'private, no-store'
|
|
24
|
+
if request.headers['Range'].present?
|
|
25
|
+
send_blob_byte_range_data blob, request.headers['Range']
|
|
26
|
+
else
|
|
27
|
+
response.headers['Accept-Ranges'] = 'bytes'
|
|
28
|
+
response.headers['Content-Length'] = blob.byte_size.to_s
|
|
29
|
+
send_blob_stream blob, disposition: 'inline'
|
|
30
|
+
end
|
|
12
31
|
end
|
|
13
32
|
end
|
|
14
33
|
end
|