testimonials 0.7.10 → 0.8.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 +4 -1
- data/CHANGELOG.md +45 -0
- data/README.md +2 -0
- data/app/controllers/concerns/testimonials/request_context.rb +75 -0
- data/app/controllers/testimonials/application_controller.rb +10 -56
- data/app/controllers/testimonials/dashboard_controller.rb +40 -0
- data/app/controllers/testimonials/nps_responses_controller.rb +1 -4
- data/app/controllers/testimonials/submissions_controller.rb +169 -0
- data/app/controllers/testimonials/testimonials_controller.rb +6 -166
- data/app/views/layouts/testimonials/application.html.erb +5 -8
- data/app/views/testimonials/nps_responses/index.html.erb +154 -152
- data/app/views/testimonials/nps_responses/show.html.erb +7 -5
- data/app/views/testimonials/shared/_dashboard.html.erb +18 -0
- data/app/views/testimonials/testimonials/index.html.erb +110 -108
- data/app/views/testimonials/testimonials/show.html.erb +7 -5
- data/config/routes.rb +18 -5
- data/lib/generators/testimonials/install/install_generator.rb +2 -6
- data/lib/generators/testimonials/install/templates/create_testimonials_tables.rb.tt +3 -3
- data/lib/generators/testimonials/install/templates/initializer.rb.tt +10 -2
- data/lib/generators/testimonials/migration_helpers.rb +35 -0
- data/lib/generators/testimonials/nps/nps_generator.rb +2 -6
- data/lib/generators/testimonials/nps/templates/create_testimonials_nps_responses.rb.tt +1 -1
- data/lib/generators/testimonials/prompt_events/prompt_events_generator.rb +2 -6
- data/lib/generators/testimonials/prompt_events/templates/create_testimonials_prompt_events.rb.tt +1 -1
- data/lib/generators/testimonials/tenant/tenant_generator.rb +2 -6
- data/lib/testimonials/configuration.rb +24 -1
- data/lib/testimonials/dashboard.css +278 -221
- data/lib/testimonials/version.rb +1 -1
- data/lib/testimonials.rb +8 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1472a2172dce3259de44e1af6de8a7c6466d0589a6afeb037900cb154b08e5b9
|
|
4
|
+
data.tar.gz: a928753201311457628c7e38adf8164227d329d5bb753395a267c916dbdeaa08
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0c312e4bdff8a87463bbcbdd842a4bbe711ac2557ead095ce86f70918f70eba937ec08f00a6477c969ed1bcbfa1f6ca654c31c414c040b40fa64852f14a250d8
|
|
7
|
+
data.tar.gz: 272c88fc2b1bad4baf46849be45c2b5b9c6e2ce969cd2f0285c7cd04121e805e716026c17d4c9b8312792e85a5b35f3ad8c6d43de6876774f412c99cfe479cbf
|
data/AGENTS.md
CHANGED
|
@@ -148,7 +148,7 @@ organization.testimonials.approved
|
|
|
148
148
|
|
|
149
149
|
- **Do not hand-write the migration or the initializer.** Use the generators; the templates track the schema.
|
|
150
150
|
- **Do not copy `widget.js` into `app/javascript`, or add a `<script>` tag for it.** `testimonials_tag` renders both tags itself, and the engine serves the code at `/testimonials/widget.js` with a content fingerprint. There is no build step and nothing for esbuild/importmap/Tailwind to know about.
|
|
151
|
-
- **Do not edit views inside the gem.**
|
|
151
|
+
- **Do not edit views inside the gem.** To put the dashboard inside an existing admin, set `config.base_controller_class = "Admin::BaseController"` (it inherits that controller's layout, helpers, authentication and request context) or, for the shell alone, `config.admin_layout = "admin/application"`. The dashboard's stylesheet and script are declared by its views, so both work with nothing else wired up.
|
|
152
152
|
- **Do not check whether a table exists to decide if a feature is on.** `Testimonials.config.nps` and `Testimonials.config.prompt_events` are the source of truth, deliberately — nothing in the gem introspects the schema at runtime.
|
|
153
153
|
- **Do not set config outside the initializer.** `rate_limit` in particular is read once when the controller class loads; assigning config in a controller or per-request is a global mutation across the process.
|
|
154
154
|
- **Do not add `testimonials_tag` more than once per page,** and do not put it in a partial that some pages skip while still calling `testimonial_prompt!` — the prompt is consumed from the flash by whatever renders next.
|
|
@@ -163,6 +163,7 @@ Everything is optional; a fresh install works with zero config. Full list with c
|
|
|
163
163
|
| `current_user`, `user_display`, `tenant`, `enabled` | no-ops | Lambdas over the raw request. |
|
|
164
164
|
| `nps` | `true` | `false` = no prompt, no public NPS page, no dashboard tab, no `nps_score`. |
|
|
165
165
|
| `prompt_events` | `true` | `false` = no prompt history, and therefore no auto-prompts at all. |
|
|
166
|
+
| `base_controller_class` | `ActionController::Base` | Controller the DASHBOARD inherits. Public endpoints never do, so an admin base controller here cannot gate the widget. |
|
|
166
167
|
| `reprompt_after`, `max_prompts` | `90.days`, `3` | Throttle for auto-prompts only. |
|
|
167
168
|
| `video`, `avatars` | `true` | Need Active Storage; they self-disable when it isn't loaded. |
|
|
168
169
|
| `public_collection` | `true` | `/testimonials/new` and `/testimonials/nps/new`. Shareable links. |
|
|
@@ -183,6 +184,8 @@ Turbo Drive and nonce-based CSP work out of the box; the widget config rides in
|
|
|
183
184
|
| `no such table: testimonials_nps_responses` (or `..._prompt_events`) | A flag was flipped to `true` before running the matching generator and `db:migrate`. |
|
|
184
185
|
| `/testimonials/nps_responses` 404s | `config.nps` is false. The rows are still there; flip it back to read them. |
|
|
185
186
|
| Video recording missing | Active Storage not installed, or `config.video = false`. |
|
|
187
|
+
| `NameError` for a host helper in the dashboard | `isolate_namespace` scopes `helper` to the engine. Use `config.base_controller_class` so the dashboard inherits your helpers, rather than `admin_layout` alone. |
|
|
188
|
+
| `NotNullViolation` attaching a video on a uuid-keyed app | The tables were generated bigint. Set `config.generators` `primary_key_type` before installing, or migrate the tables to uuid. |
|
|
186
189
|
| `undefined local variable current_user` in the initializer | A gate lambda treated its argument as a controller. It is a `request`. |
|
|
187
190
|
|
|
188
191
|
---
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,50 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.0
|
|
4
|
+
|
|
5
|
+
- **`config.admin_layout` now works on its own.** The dashboard's stylesheet and
|
|
6
|
+
script were declared in the gem's layout, so replacing that layout dropped
|
|
7
|
+
both: the dashboard rendered unstyled with its delete confirmations and
|
|
8
|
+
auto-submitting filter dead. They move into the views (a shared
|
|
9
|
+
`testimonials/shared/_dashboard` partial), so every layout gets them with
|
|
10
|
+
nothing asked of the host.
|
|
11
|
+
- **The dashboard stylesheet no longer claims selectors it does not own.** It
|
|
12
|
+
styled bare `body`, `a`, `table` and `*`, and its `.card`, `.badge`, `.tabs`
|
|
13
|
+
and `.container` are names Bootstrap and daisyUI use too, so a host that did
|
|
14
|
+
load it had its sidebar and topbar restyled. Component rules now nest inside a
|
|
15
|
+
`.tml-dashboard` wrapper the views render, every custom property is `--tml-`
|
|
16
|
+
prefixed, and `.container`/`.nav` became `.tml-page`/`.tml-nav`. The
|
|
17
|
+
full-viewport rules stay keyed to the `tm-index`/`tm-nps-index`/`tm-show` body
|
|
18
|
+
classes that only the gem's own layout sets, so inside a host admin the
|
|
19
|
+
dashboard scrolls with the host's page instead of fighting it for the
|
|
20
|
+
viewport. Two tests fail the build if a selector or property escapes again.
|
|
21
|
+
- **Added `config.base_controller_class`.** Name the controller your own admin
|
|
22
|
+
inherits from and the dashboard adopts its layout, helpers, authentication and
|
|
23
|
+
request context — the things `admin_layout` cannot give you, and which hosts
|
|
24
|
+
were hand-wiring as a shim layout plus a concern to populate `Current`. Same
|
|
25
|
+
hook pgbus, avo and mission_control-jobs use. Default is unchanged.
|
|
26
|
+
Layout precedence with it: a host base controller keeps its own layout, unless the host also named an `admin_layout` explicitly.
|
|
27
|
+
- **Migrations follow the host's `primary_key_type`,** the same
|
|
28
|
+
`Rails.configuration.generators` lookup Rails' own Active Storage, Action Text
|
|
29
|
+
and Action Mailbox migrations do. A uuid-keyed app has a uuid
|
|
30
|
+
`active_storage_attachments.record_id`, so bigint tables here could never hold
|
|
31
|
+
a video or avatar — `attach` raised `NotNullViolation`. A host that set
|
|
32
|
+
nothing gets no `id:` option and an identical migration to before.
|
|
33
|
+
- **Dropped the `id: /\d+/` route constraints,** which were what forced the
|
|
34
|
+
tables to be bigint in the first place: uuid tables would have 404d show,
|
|
35
|
+
update, destroy and all three media routes. The constraint was never
|
|
36
|
+
load-bearing, since every fixed-name route is declared before the flat
|
|
37
|
+
`/:id` routes.
|
|
38
|
+
- **`create` moved to `Testimonials::SubmissionsController`** and `POST` to the
|
|
39
|
+
mount path routes there. One controller used to serve both the widget's write
|
|
40
|
+
endpoint and the triage actions, which meant `base_controller_class` would
|
|
41
|
+
have put staff authentication in front of every member leaving a review. If
|
|
42
|
+
you referenced `Testimonials::TestimonialsController#create`, that is the
|
|
43
|
+
breaking change in this release; the URL is unchanged.
|
|
44
|
+
- The shared request context (`current_author`, `current_tenant`,
|
|
45
|
+
`tenant_scope`, the gates) is now a `Testimonials::RequestContext` concern,
|
|
46
|
+
since the engine has two controller roots.
|
|
47
|
+
|
|
3
48
|
## 0.7.10
|
|
4
49
|
|
|
5
50
|
- The prompt-history ledger is now optional too. `bin/rails generate
|
data/README.md
CHANGED
|
@@ -163,6 +163,8 @@ Everything is optional — a fresh install works with zero config. In
|
|
|
163
163
|
| Option | Default | What it does |
|
|
164
164
|
| --- | --- | --- |
|
|
165
165
|
| `authorize_admin` | development only | **Who can read the dashboard.** Override before deploying |
|
|
166
|
+
| `base_controller_class` | `ActionController::Base` | The controller the dashboard inherits — name your admin's and it adopts its layout, helpers and auth |
|
|
167
|
+
| `admin_layout` | the gem's own | Just the shell, if you don't want the whole controller |
|
|
166
168
|
| `app_name` | Rails app name | Shown as "Enjoying %{app}?" and in the questions |
|
|
167
169
|
| `enabled` | everyone | Who gets the widget. `false` hides it and rejects posts |
|
|
168
170
|
| `current_user` | `nil` | Attribute a submission to a user. Receives the request |
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Testimonials
|
|
4
|
+
# Who is asking, which tenant they are in, and the gates that answer both.
|
|
5
|
+
#
|
|
6
|
+
# A concern rather than inherited behaviour because the engine has two
|
|
7
|
+
# controller roots: the public endpoints hang off ActionController::Base, and
|
|
8
|
+
# the dashboard hangs off whatever the host set as `base_controller_class`.
|
|
9
|
+
# Both need everything here.
|
|
10
|
+
module RequestContext
|
|
11
|
+
extend ActiveSupport::Concern
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def testimonials_admin_layout
|
|
16
|
+
Testimonials.config.admin_layout
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def current_author
|
|
20
|
+
return @current_author if defined?(@current_author)
|
|
21
|
+
|
|
22
|
+
@current_author = Testimonials.config.current_user.call(request)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def current_author_id
|
|
26
|
+
current_author.respond_to?(:id) ? current_author.id : nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The tenant for this request (nil = the single global collection). Every
|
|
30
|
+
# read and write in the engine scopes to it, so a resolved-tenant admin
|
|
31
|
+
# only ever sees and writes their own tenant's records.
|
|
32
|
+
def current_tenant
|
|
33
|
+
return @current_tenant if defined?(@current_tenant)
|
|
34
|
+
|
|
35
|
+
@current_tenant = Testimonials.tenant(request)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Every dashboard query and the create endpoint's one-per-user lookup start
|
|
39
|
+
# here, so neither can reach across tenants.
|
|
40
|
+
def tenant_scope
|
|
41
|
+
Testimonial.for_tenant(current_tenant)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def require_enabled
|
|
45
|
+
head :forbidden unless Testimonials.enabled?(request)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def render_rate_limited
|
|
49
|
+
message = I18n.t('testimonials.error_rate_limited',
|
|
50
|
+
default: 'Too many submissions. Please wait a moment and try again.')
|
|
51
|
+
render json: { errors: [message] }, status: :too_many_requests
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Server-side gate for the dashboard. Default: development only.
|
|
55
|
+
def require_admin
|
|
56
|
+
return if Testimonials.admin?(request)
|
|
57
|
+
|
|
58
|
+
render plain: 'Forbidden. Set Testimonials.config.authorize_admin to grant access.',
|
|
59
|
+
status: :forbidden
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Guests get a permanent random token so the throttle ledger can
|
|
63
|
+
# remember them across visits. Signed-in users are keyed by author_id
|
|
64
|
+
# instead and never receive the cookie.
|
|
65
|
+
def ensure_visitor_token
|
|
66
|
+
return if current_author
|
|
67
|
+
|
|
68
|
+
cookies[:testimonials_vid].presence || begin
|
|
69
|
+
token = SecureRandom.base58(24)
|
|
70
|
+
cookies.permanent[:testimonials_vid] = { value: token, httponly: true, same_site: :lax }
|
|
71
|
+
token
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -1,63 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Testimonials
|
|
4
|
+
# Root of the engine's PUBLIC surface: widget.js, the submission endpoints,
|
|
5
|
+
# the prompt-event ledger, the public collection pages, media, and the read
|
|
6
|
+
# API. These stay on a plain ActionController::Base deliberately — a member
|
|
7
|
+
# posting a testimonial must not be routed through a host's admin controller,
|
|
8
|
+
# which would demand a staff session for the widget.
|
|
9
|
+
#
|
|
10
|
+
# The dashboard's root is DashboardController, and that is where
|
|
11
|
+
# `config.base_controller_class` applies.
|
|
4
12
|
class ApplicationController < ActionController::Base
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
private
|
|
8
|
-
|
|
9
|
-
def testimonials_admin_layout
|
|
10
|
-
Testimonials.config.admin_layout
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
def current_author
|
|
14
|
-
return @current_author if defined?(@current_author)
|
|
15
|
-
|
|
16
|
-
@current_author = Testimonials.config.current_user.call(request)
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def current_author_id
|
|
20
|
-
current_author.respond_to?(:id) ? current_author.id : nil
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
# The tenant for this request (nil = the single global collection). Every
|
|
24
|
-
# read and write in the engine scopes to it, so a resolved-tenant admin
|
|
25
|
-
# only ever sees and writes their own tenant's records.
|
|
26
|
-
def current_tenant
|
|
27
|
-
return @current_tenant if defined?(@current_tenant)
|
|
13
|
+
include RequestContext
|
|
28
14
|
|
|
29
|
-
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def require_enabled
|
|
33
|
-
head :forbidden unless Testimonials.enabled?(request)
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def render_rate_limited
|
|
37
|
-
message = I18n.t('testimonials.error_rate_limited',
|
|
38
|
-
default: 'Too many submissions. Please wait a moment and try again.')
|
|
39
|
-
render json: { errors: [message] }, status: :too_many_requests
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
# Server-side gate for the dashboard. Default: development only.
|
|
43
|
-
def require_admin
|
|
44
|
-
return if Testimonials.admin?(request)
|
|
45
|
-
|
|
46
|
-
render plain: 'Forbidden. Set Testimonials.config.authorize_admin to grant access.',
|
|
47
|
-
status: :forbidden
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
# Guests get a permanent random token so the throttle ledger can
|
|
51
|
-
# remember them across visits. Signed-in users are keyed by author_id
|
|
52
|
-
# instead and never receive the cookie.
|
|
53
|
-
def ensure_visitor_token
|
|
54
|
-
return if current_author
|
|
55
|
-
|
|
56
|
-
cookies[:testimonials_vid].presence || begin
|
|
57
|
-
token = SecureRandom.base58(24)
|
|
58
|
-
cookies.permanent[:testimonials_vid] = { value: token, httponly: true, same_site: :lax }
|
|
59
|
-
token
|
|
60
|
-
end
|
|
61
|
-
end
|
|
15
|
+
protect_from_forgery with: :exception
|
|
62
16
|
end
|
|
63
17
|
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Testimonials
|
|
4
|
+
# Root of the STAFF surface: the testimonial queue and the NPS pages.
|
|
5
|
+
#
|
|
6
|
+
# Inherits from `config.base_controller_class` — by default a plain
|
|
7
|
+
# ActionController::Base, which is why `authorize_admin` exists. Point it at
|
|
8
|
+
# the controller your own admin already inherits from and the dashboard picks
|
|
9
|
+
# up that stack wholesale: your layout, your helpers, your authentication,
|
|
10
|
+
# and whatever request context your before_actions establish (a `Current`
|
|
11
|
+
# attribute the layout reads, say). `config.admin_layout` only ever solved
|
|
12
|
+
# the first of those.
|
|
13
|
+
#
|
|
14
|
+
# Only the dashboard hangs off it. The widget's endpoints stay on
|
|
15
|
+
# ApplicationController, so wiring an admin base controller here can never
|
|
16
|
+
# demand a staff session from a member leaving a review.
|
|
17
|
+
class DashboardController < Testimonials.base_controller
|
|
18
|
+
include RequestContext
|
|
19
|
+
|
|
20
|
+
# A host base controller brings its own layout, and declaring one here would
|
|
21
|
+
# override it — the dashboard would render in the gem's standalone shell
|
|
22
|
+
# inside an app that just told us where its admin lives. So the gem only
|
|
23
|
+
# claims the layout when it owns the decision: no host base controller, or a
|
|
24
|
+
# host that named an `admin_layout` explicitly.
|
|
25
|
+
layout :testimonials_admin_layout unless superclass != ActionController::Base &&
|
|
26
|
+
Testimonials.config.admin_layout ==
|
|
27
|
+
Configuration::DEFAULT_ADMIN_LAYOUT
|
|
28
|
+
|
|
29
|
+
before_action :require_admin
|
|
30
|
+
|
|
31
|
+
# `authorize_admin` is a gate of last resort, and it is the only one when
|
|
32
|
+
# the host supplied no base controller. When they did, its default is
|
|
33
|
+
# development-only, so a host whose base controller already authenticates
|
|
34
|
+
# staff should widen or replace it.
|
|
35
|
+
#
|
|
36
|
+
# CSRF likewise: a host base controller has configured it already, and
|
|
37
|
+
# declaring it twice would run the check twice.
|
|
38
|
+
protect_from_forgery with: :exception if superclass == ActionController::Base
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -2,12 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
module Testimonials
|
|
4
4
|
# The NPS side of the dashboard: overall score plus the response list.
|
|
5
|
-
class NpsResponsesController <
|
|
5
|
+
class NpsResponsesController < DashboardController
|
|
6
6
|
PER_PAGE = 50
|
|
7
7
|
|
|
8
|
-
layout :testimonials_admin_layout
|
|
9
|
-
|
|
10
|
-
before_action :require_admin
|
|
11
8
|
# An install run with --skip-nps has no table behind these pages, and the
|
|
12
9
|
# nav already hides the tab whenever config.nps is off.
|
|
13
10
|
before_action :require_nps
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Testimonials
|
|
4
|
+
# The widget's write endpoint: POST to the mount path.
|
|
5
|
+
#
|
|
6
|
+
# Public, so it stays on ApplicationController and never inherits a host's
|
|
7
|
+
# admin base controller — a member leaving a review must not be asked for a
|
|
8
|
+
# staff session. The dashboard's read/triage actions live in
|
|
9
|
+
# TestimonialsController, which does inherit it.
|
|
10
|
+
class SubmissionsController < ApplicationController
|
|
11
|
+
before_action :require_enabled
|
|
12
|
+
|
|
13
|
+
# Throttle the public endpoint per IP so one user or bot can't flood the
|
|
14
|
+
# table (a submission may carry a video). Uses the rate limiter built
|
|
15
|
+
# into Rails 7.2+ (backed by Rails.cache); on Rails 7.1 this is a no-op.
|
|
16
|
+
# Tune or disable via config.rate_limit — read once at boot.
|
|
17
|
+
if respond_to?(:rate_limit) && Testimonials.config.rate_limit
|
|
18
|
+
rate_limit(**Testimonials.config.rate_limit, only: :create, with: -> { render_rate_limited })
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def create
|
|
22
|
+
testimonial = build_testimonial
|
|
23
|
+
|
|
24
|
+
error = attach_video(testimonial) || attach_avatar(testimonial)
|
|
25
|
+
return render json: { errors: [error] }, status: :unprocessable_entity if error
|
|
26
|
+
|
|
27
|
+
if testimonial.save
|
|
28
|
+
# Purge only after a valid save, so a failed update can't strand a
|
|
29
|
+
# review with its video already gone.
|
|
30
|
+
if remove_video? && testimonial.video_attached?
|
|
31
|
+
testimonial.video_file.purge
|
|
32
|
+
testimonial.poster.purge if testimonial.poster_attached?
|
|
33
|
+
end
|
|
34
|
+
record_submission
|
|
35
|
+
notify_host(testimonial)
|
|
36
|
+
head :created
|
|
37
|
+
else
|
|
38
|
+
render json: { errors: testimonial.errors.full_messages }, status: :unprocessable_entity
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# One review per signed-in user: a re-submission edits their existing
|
|
45
|
+
# review in place (the App Store model). The edit goes back through
|
|
46
|
+
# moderation, and the admin's best_line is cleared — it may no longer
|
|
47
|
+
# match the new wording. Guests have no reliable identity, so each guest
|
|
48
|
+
# submission stays a new record.
|
|
49
|
+
def build_testimonial
|
|
50
|
+
testimonial = existing_testimonial || Testimonial.new
|
|
51
|
+
testimonial.assign_attributes(testimonial_params)
|
|
52
|
+
if testimonial.persisted?
|
|
53
|
+
testimonial.status = 'pending'
|
|
54
|
+
testimonial.best_line = nil
|
|
55
|
+
end
|
|
56
|
+
testimonial.kind = wants_video?(testimonial) ? 'video' : 'text'
|
|
57
|
+
testimonial.source = 'widget' unless Testimonial::SOURCES.include?(testimonial.source)
|
|
58
|
+
testimonial.consent_text = Testimonials.consent_text_for(testimonial.consent_given?)
|
|
59
|
+
testimonial.locale = I18n.locale.to_s
|
|
60
|
+
testimonial.tenant = current_tenant
|
|
61
|
+
testimonial.user_agent = request.user_agent
|
|
62
|
+
attribute_author(testimonial)
|
|
63
|
+
testimonial
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# One review per signed-in user, *per tenant*: the same person can leave a
|
|
67
|
+
# separate review in each tenant, but only one within any single tenant.
|
|
68
|
+
def existing_testimonial
|
|
69
|
+
return if current_author_id.blank?
|
|
70
|
+
|
|
71
|
+
tenant_scope.where(author_id: current_author_id.to_s).newest_first.first
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# A fresh upload makes it a video review; on an edit without a new
|
|
75
|
+
# upload, an already-attached video stays — unless the user removed it.
|
|
76
|
+
def wants_video?(testimonial)
|
|
77
|
+
return true if params.dig(:testimonial, :video_file).present?
|
|
78
|
+
|
|
79
|
+
testimonial.video_attached? && !remove_video?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def remove_video?
|
|
83
|
+
params.dig(:testimonial, :remove_video).present? && params.dig(:testimonial, :video_file).blank?
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def testimonial_params
|
|
87
|
+
params.require(:testimonial)
|
|
88
|
+
.permit(:body, :rating, :consent_given, :page_url, :source,
|
|
89
|
+
:name, :email, :title_company)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Signed-in users are attributed server-side — the widget never sends
|
|
93
|
+
# (and the endpoint never trusts) contact fields for them.
|
|
94
|
+
def attribute_author(testimonial)
|
|
95
|
+
author = current_author
|
|
96
|
+
return if author.nil?
|
|
97
|
+
|
|
98
|
+
testimonial.author_id = author.id.to_s if author.respond_to?(:id)
|
|
99
|
+
display = Testimonials.config.user_display.call(author) || {}
|
|
100
|
+
testimonial.name = display[:name].presence || testimonial.name
|
|
101
|
+
testimonial.email = display[:email].presence || testimonial.email
|
|
102
|
+
testimonial.title_company = display[:title_company].presence || testimonial.title_company
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Returns an error message, or nil when everything is fine.
|
|
106
|
+
def attach_video(testimonial)
|
|
107
|
+
file = params.dig(:testimonial, :video_file)
|
|
108
|
+
return nil if file.blank?
|
|
109
|
+
return error_label(:error_save) unless Testimonials.config.video_enabled?
|
|
110
|
+
return error_label(:error_video_too_large) if file.size > Testimonials.config.max_video_size
|
|
111
|
+
return error_label(:error_save) unless file.content_type.to_s.start_with?('video/', 'audio/')
|
|
112
|
+
|
|
113
|
+
testimonial.video_file.attach(file)
|
|
114
|
+
attach_poster(testimonial)
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# The poster rides with a new video upload. It's best-effort — a missing
|
|
119
|
+
# or malformed poster never blocks the testimonial. Attaching replaces any
|
|
120
|
+
# previous poster; a new video with no usable poster (e.g. an uploaded
|
|
121
|
+
# file, which we can't frame-grab) drops the stale one so it can't show a
|
|
122
|
+
# thumbnail from the wrong video.
|
|
123
|
+
def attach_poster(testimonial)
|
|
124
|
+
file = params.dig(:testimonial, :poster)
|
|
125
|
+
usable = file.present? && file.content_type.to_s.start_with?('image/') &&
|
|
126
|
+
file.size <= Testimonials.config.max_avatar_size
|
|
127
|
+
|
|
128
|
+
if usable
|
|
129
|
+
testimonial.poster.attach(file)
|
|
130
|
+
elsif testimonial.poster_attached?
|
|
131
|
+
testimonial.poster.purge
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def attach_avatar(testimonial)
|
|
136
|
+
file = params.dig(:testimonial, :avatar)
|
|
137
|
+
return nil if file.blank?
|
|
138
|
+
return error_label(:error_save) unless Testimonials.config.avatars_enabled?
|
|
139
|
+
return error_label(:error_save) if file.size > Testimonials.config.max_avatar_size
|
|
140
|
+
return error_label(:error_save) unless file.content_type.to_s.start_with?('image/')
|
|
141
|
+
|
|
142
|
+
testimonial.avatar.attach(file)
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def record_submission
|
|
147
|
+
PromptEvent.record!(kind: 'testimonial', action: 'submitted', tenant: current_tenant,
|
|
148
|
+
author_id: current_author_id, visitor_token: ensure_visitor_token)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# The host's hook must never turn a saved submission into a 500 — the
|
|
152
|
+
# testimonial is in the database; notification failures are the host's
|
|
153
|
+
# logs' problem.
|
|
154
|
+
def notify_host(record)
|
|
155
|
+
Testimonials.config.on_submit.call(record)
|
|
156
|
+
rescue StandardError => e
|
|
157
|
+
Rails.logger.error("testimonials: on_submit hook raised #{e.class}: #{e.message}")
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def error_label(key)
|
|
161
|
+
defaults = {
|
|
162
|
+
error_save: 'Could not send. Please try again.',
|
|
163
|
+
error_video_too_large: 'The video is too large (max %{size} MB).'
|
|
164
|
+
}
|
|
165
|
+
I18n.t(key, scope: :testimonials, default: defaults[key],
|
|
166
|
+
size: Testimonials.config.max_video_size / (1024 * 1024))
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -1,24 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Testimonials
|
|
4
|
-
|
|
4
|
+
# The testimonial queue: triage, search, best lines. Staff only.
|
|
5
|
+
#
|
|
6
|
+
# POST to the mount path is SubmissionsController — the public write endpoint
|
|
7
|
+
# cannot share a controller with these, because this one inherits whatever the
|
|
8
|
+
# host set as base_controller_class.
|
|
9
|
+
class TestimonialsController < DashboardController
|
|
5
10
|
PER_PAGE = 50
|
|
6
11
|
|
|
7
|
-
layout :testimonials_admin_layout, except: :create
|
|
8
|
-
|
|
9
|
-
# create is the public widget endpoint; everything else is the dashboard.
|
|
10
|
-
before_action :require_enabled, only: :create
|
|
11
|
-
before_action :require_admin, except: :create
|
|
12
12
|
before_action :set_testimonial, only: %i[show update destroy]
|
|
13
13
|
|
|
14
|
-
# Throttle the public endpoint per IP so one user or bot can't flood the
|
|
15
|
-
# table (a submission may carry a video). Uses the rate limiter built
|
|
16
|
-
# into Rails 7.2+ (backed by Rails.cache); on Rails 7.1 this is a no-op.
|
|
17
|
-
# Tune or disable via config.rate_limit — read once at boot.
|
|
18
|
-
if respond_to?(:rate_limit) && Testimonials.config.rate_limit
|
|
19
|
-
rate_limit(**Testimonials.config.rate_limit, only: :create, with: -> { render_rate_limited })
|
|
20
|
-
end
|
|
21
|
-
|
|
22
14
|
def index
|
|
23
15
|
@status = Testimonial::STATUSES.include?(params[:status]) ? params[:status] : 'pending'
|
|
24
16
|
@kind = Testimonial::KINDS.include?(params[:kind]) ? params[:kind] : nil
|
|
@@ -48,39 +40,12 @@ module Testimonials
|
|
|
48
40
|
redirect_to root_path, status: :see_other
|
|
49
41
|
end
|
|
50
42
|
|
|
51
|
-
def create
|
|
52
|
-
testimonial = build_testimonial
|
|
53
|
-
|
|
54
|
-
error = attach_video(testimonial) || attach_avatar(testimonial)
|
|
55
|
-
return render json: { errors: [error] }, status: :unprocessable_entity if error
|
|
56
|
-
|
|
57
|
-
if testimonial.save
|
|
58
|
-
# Purge only after a valid save, so a failed update can't strand a
|
|
59
|
-
# review with its video already gone.
|
|
60
|
-
if remove_video? && testimonial.video_attached?
|
|
61
|
-
testimonial.video_file.purge
|
|
62
|
-
testimonial.poster.purge if testimonial.poster_attached?
|
|
63
|
-
end
|
|
64
|
-
record_submission
|
|
65
|
-
notify_host(testimonial)
|
|
66
|
-
head :created
|
|
67
|
-
else
|
|
68
|
-
render json: { errors: testimonial.errors.full_messages }, status: :unprocessable_entity
|
|
69
|
-
end
|
|
70
|
-
end
|
|
71
|
-
|
|
72
43
|
private
|
|
73
44
|
|
|
74
45
|
def set_testimonial
|
|
75
46
|
@testimonial = tenant_scope.find(params[:id])
|
|
76
47
|
end
|
|
77
48
|
|
|
78
|
-
# Every dashboard query starts here, so an admin can only ever load,
|
|
79
|
-
# triage, or delete records in their own tenant — a cross-tenant id 404s.
|
|
80
|
-
def tenant_scope
|
|
81
|
-
Testimonial.for_tenant(current_tenant)
|
|
82
|
-
end
|
|
83
|
-
|
|
84
49
|
def admin_params
|
|
85
50
|
params.require(:testimonial).permit(:status, :featured, :best_line)
|
|
86
51
|
end
|
|
@@ -97,130 +62,5 @@ module Testimonials
|
|
|
97
62
|
q: pattern
|
|
98
63
|
)
|
|
99
64
|
end
|
|
100
|
-
|
|
101
|
-
# One review per signed-in user: a re-submission edits their existing
|
|
102
|
-
# review in place (the App Store model). The edit goes back through
|
|
103
|
-
# moderation, and the admin's best_line is cleared — it may no longer
|
|
104
|
-
# match the new wording. Guests have no reliable identity, so each guest
|
|
105
|
-
# submission stays a new record.
|
|
106
|
-
def build_testimonial
|
|
107
|
-
testimonial = existing_testimonial || Testimonial.new
|
|
108
|
-
testimonial.assign_attributes(testimonial_params)
|
|
109
|
-
if testimonial.persisted?
|
|
110
|
-
testimonial.status = 'pending'
|
|
111
|
-
testimonial.best_line = nil
|
|
112
|
-
end
|
|
113
|
-
testimonial.kind = wants_video?(testimonial) ? 'video' : 'text'
|
|
114
|
-
testimonial.source = 'widget' unless Testimonial::SOURCES.include?(testimonial.source)
|
|
115
|
-
testimonial.consent_text = Testimonials.consent_text_for(testimonial.consent_given?)
|
|
116
|
-
testimonial.locale = I18n.locale.to_s
|
|
117
|
-
testimonial.tenant = current_tenant
|
|
118
|
-
testimonial.user_agent = request.user_agent
|
|
119
|
-
attribute_author(testimonial)
|
|
120
|
-
testimonial
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
# One review per signed-in user, *per tenant*: the same person can leave a
|
|
124
|
-
# separate review in each tenant, but only one within any single tenant.
|
|
125
|
-
def existing_testimonial
|
|
126
|
-
return if current_author_id.blank?
|
|
127
|
-
|
|
128
|
-
tenant_scope.where(author_id: current_author_id.to_s).newest_first.first
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
# A fresh upload makes it a video review; on an edit without a new
|
|
132
|
-
# upload, an already-attached video stays — unless the user removed it.
|
|
133
|
-
def wants_video?(testimonial)
|
|
134
|
-
return true if params.dig(:testimonial, :video_file).present?
|
|
135
|
-
|
|
136
|
-
testimonial.video_attached? && !remove_video?
|
|
137
|
-
end
|
|
138
|
-
|
|
139
|
-
def remove_video?
|
|
140
|
-
params.dig(:testimonial, :remove_video).present? && params.dig(:testimonial, :video_file).blank?
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
def testimonial_params
|
|
144
|
-
params.require(:testimonial)
|
|
145
|
-
.permit(:body, :rating, :consent_given, :page_url, :source,
|
|
146
|
-
:name, :email, :title_company)
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
# Signed-in users are attributed server-side — the widget never sends
|
|
150
|
-
# (and the endpoint never trusts) contact fields for them.
|
|
151
|
-
def attribute_author(testimonial)
|
|
152
|
-
author = current_author
|
|
153
|
-
return if author.nil?
|
|
154
|
-
|
|
155
|
-
testimonial.author_id = author.id.to_s if author.respond_to?(:id)
|
|
156
|
-
display = Testimonials.config.user_display.call(author) || {}
|
|
157
|
-
testimonial.name = display[:name].presence || testimonial.name
|
|
158
|
-
testimonial.email = display[:email].presence || testimonial.email
|
|
159
|
-
testimonial.title_company = display[:title_company].presence || testimonial.title_company
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
# Returns an error message, or nil when everything is fine.
|
|
163
|
-
def attach_video(testimonial)
|
|
164
|
-
file = params.dig(:testimonial, :video_file)
|
|
165
|
-
return nil if file.blank?
|
|
166
|
-
return error_label(:error_save) unless Testimonials.config.video_enabled?
|
|
167
|
-
return error_label(:error_video_too_large) if file.size > Testimonials.config.max_video_size
|
|
168
|
-
return error_label(:error_save) unless file.content_type.to_s.start_with?('video/', 'audio/')
|
|
169
|
-
|
|
170
|
-
testimonial.video_file.attach(file)
|
|
171
|
-
attach_poster(testimonial)
|
|
172
|
-
nil
|
|
173
|
-
end
|
|
174
|
-
|
|
175
|
-
# The poster rides with a new video upload. It's best-effort — a missing
|
|
176
|
-
# or malformed poster never blocks the testimonial. Attaching replaces any
|
|
177
|
-
# previous poster; a new video with no usable poster (e.g. an uploaded
|
|
178
|
-
# file, which we can't frame-grab) drops the stale one so it can't show a
|
|
179
|
-
# thumbnail from the wrong video.
|
|
180
|
-
def attach_poster(testimonial)
|
|
181
|
-
file = params.dig(:testimonial, :poster)
|
|
182
|
-
usable = file.present? && file.content_type.to_s.start_with?('image/') &&
|
|
183
|
-
file.size <= Testimonials.config.max_avatar_size
|
|
184
|
-
|
|
185
|
-
if usable
|
|
186
|
-
testimonial.poster.attach(file)
|
|
187
|
-
elsif testimonial.poster_attached?
|
|
188
|
-
testimonial.poster.purge
|
|
189
|
-
end
|
|
190
|
-
end
|
|
191
|
-
|
|
192
|
-
def attach_avatar(testimonial)
|
|
193
|
-
file = params.dig(:testimonial, :avatar)
|
|
194
|
-
return nil if file.blank?
|
|
195
|
-
return error_label(:error_save) unless Testimonials.config.avatars_enabled?
|
|
196
|
-
return error_label(:error_save) if file.size > Testimonials.config.max_avatar_size
|
|
197
|
-
return error_label(:error_save) unless file.content_type.to_s.start_with?('image/')
|
|
198
|
-
|
|
199
|
-
testimonial.avatar.attach(file)
|
|
200
|
-
nil
|
|
201
|
-
end
|
|
202
|
-
|
|
203
|
-
def record_submission
|
|
204
|
-
PromptEvent.record!(kind: 'testimonial', action: 'submitted', tenant: current_tenant,
|
|
205
|
-
author_id: current_author_id, visitor_token: ensure_visitor_token)
|
|
206
|
-
end
|
|
207
|
-
|
|
208
|
-
# The host's hook must never turn a saved submission into a 500 — the
|
|
209
|
-
# testimonial is in the database; notification failures are the host's
|
|
210
|
-
# logs' problem.
|
|
211
|
-
def notify_host(record)
|
|
212
|
-
Testimonials.config.on_submit.call(record)
|
|
213
|
-
rescue StandardError => e
|
|
214
|
-
Rails.logger.error("testimonials: on_submit hook raised #{e.class}: #{e.message}")
|
|
215
|
-
end
|
|
216
|
-
|
|
217
|
-
def error_label(key)
|
|
218
|
-
defaults = {
|
|
219
|
-
error_save: 'Could not send. Please try again.',
|
|
220
|
-
error_video_too_large: 'The video is too large (max %{size} MB).'
|
|
221
|
-
}
|
|
222
|
-
I18n.t(key, scope: :testimonials, default: defaults[key],
|
|
223
|
-
size: Testimonials.config.max_video_size / (1024 * 1024))
|
|
224
|
-
end
|
|
225
65
|
end
|
|
226
66
|
end
|