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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +11 -5
  3. data/CHANGELOG.md +54 -3
  4. data/README.md +60 -4
  5. data/Rakefile +9 -1
  6. data/SECURITY.md +50 -0
  7. data/app/controllers/concerns/product_tours/request_context.rb +3 -3
  8. data/app/controllers/product_tours/dashboard_controller.rb +1 -1
  9. data/app/controllers/product_tours/interactions_controller.rb +77 -0
  10. data/app/controllers/product_tours/media_controller.rb +23 -4
  11. data/app/controllers/product_tours/tours_controller.rb +203 -52
  12. data/app/helpers/product_tours/{posts_helper.rb → tours_helper.rb} +1 -1
  13. data/app/models/product_tours/{post.rb → tour.rb} +15 -15
  14. data/app/views/product_tours/{posts → tours}/_form.html.erb +34 -34
  15. data/app/views/product_tours/{posts/_post_panel.html.erb → tours/_tour_panel.html.erb} +33 -33
  16. data/app/views/product_tours/{posts → tours}/edit.html.erb +3 -3
  17. data/app/views/product_tours/{posts → tours}/index.html.erb +23 -23
  18. data/app/views/product_tours/tours/new.html.erb +7 -0
  19. data/app/views/product_tours/{posts → tours}/show.html.erb +2 -2
  20. data/config/routes.rb +4 -4
  21. data/lib/generators/product_tours/install/install_generator.rb +3 -3
  22. data/lib/generators/product_tours/install/templates/{create_product_tours_posts.rb.tt → create_product_tours_tours.rb.tt} +5 -5
  23. data/lib/product_tours/dashboard.css +1 -1
  24. data/lib/product_tours/dashboard.js +16 -16
  25. data/lib/product_tours/seeds.rb +22 -22
  26. data/lib/product_tours/version.rb +1 -1
  27. data/lib/product_tours/widget.js +26 -26
  28. data/{app/helpers → lib}/product_tours/widget_helper.rb +4 -0
  29. data/lib/product_tours.rb +1 -0
  30. data/lib/tasks/product_tours_tasks.rake +3 -3
  31. metadata +20 -13
  32. data/app/controllers/product_tours/posts_controller.rb +0 -228
  33. 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: 397e2fbd273b707e9a16302852704ed8e5cc6daf410f3255fe3fa819047163b8
4
- data.tar.gz: c040cac638d52a7484f4ef109732211e181f0b103bdbcd938f0d6c3c7b8d10e1
3
+ metadata.gz: 8c3fecc3261d2acf6fb9267139897e7e0c4c6fa9dd3a8d52466380f2fe040c9e
4
+ data.tar.gz: 0112d06e16cd88171023f3dd32988348e8dd642334db67c5f33cb1c4194363a4
5
5
  SHA512:
6
- metadata.gz: b22e2391759e9a49d90728b9709650e5f0f2d1001fdf36d71e4c8d2c494e853007f70794b93c92762363502872f024970cf62660fa4639702aac82c854b2f9ab
7
- data.tar.gz: e2ceef66890c00c8a5f3f9293f946e35c366215b08a584a52e49408cb858f245affe2eab62caece0f1b7286f5b5944da9358f3315777b95208e962e715b83b2f
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 (`product_tours_posts`), 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.
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 `product_tours_posts`, 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.
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 (`Post.video_upload_supported?`, `Post.description_supported?`) and simply offers less.
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, `Post`, 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.
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.0
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/v0.3.2...HEAD
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="demo_missing_post">Try a missing tutorial</button>
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: `post_id`, `key`, `locale`, query-free `page_url`, and `source`.
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
- CI runs Rails 7.1 / 7.2 / 8.0 / 8.1 against Ruby 3.2 / 3.3 / 3.4.
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.pattern = 'test/**/*_test.rb'
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 find_post_by_locale(scope, key)
33
+ def find_tour_by_locale(scope, key)
34
34
  locales = [current_product_tours_locale, I18n.default_locale.to_s].uniq
35
- posts = scope.where(locale: locales, key: key).index_by(&:locale)
36
- locales.filter_map { |locale| posts[locale] }.first
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 post editor and its list.
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
- post = Post.find(params[:id])
7
- allowed = ProductTours.admin?(request) || (ProductTours.enabled?(request) && post.published?)
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 post.uploaded_video?
11
+ return head :not_found unless tour.uploaded_video?
12
+
13
+ stream(tour.video.blob)
14
+ end
15
+
16
+ private
10
17
 
11
- redirect_to main_app.rails_blob_path(post.video, disposition: 'inline')
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