testimonials 0.4.2 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +30 -0
- data/README.md +41 -0
- data/app/controllers/testimonials/api/stats_controller.rb +2 -2
- data/app/controllers/testimonials/api/testimonials_controller.rb +1 -1
- data/app/controllers/testimonials/application_controller.rb +9 -0
- data/app/controllers/testimonials/collection_controller.rb +5 -2
- data/app/controllers/testimonials/events_controller.rb +1 -1
- data/app/controllers/testimonials/media_controller.rb +3 -1
- data/app/controllers/testimonials/nps_controller.rb +3 -1
- data/app/controllers/testimonials/nps_responses_controller.rb +7 -6
- data/app/controllers/testimonials/testimonials_controller.rb +14 -5
- data/app/helpers/testimonials/widget_helper.rb +7 -3
- data/app/models/testimonials/nps_response.rb +1 -0
- data/app/models/testimonials/prompt_event.rb +9 -6
- data/app/models/testimonials/testimonial.rb +5 -0
- data/lib/generators/testimonials/install/templates/create_testimonials_tables.rb.tt +8 -4
- data/lib/generators/testimonials/install/templates/initializer.rb +13 -0
- data/lib/generators/testimonials/tenant/templates/add_tenant_to_testimonials.rb.tt +15 -0
- data/lib/generators/testimonials/tenant/tenant_generator.rb +40 -0
- data/lib/testimonials/configuration.rb +10 -0
- data/lib/testimonials/engine.rb +8 -0
- data/lib/testimonials/has_testimonials.rb +29 -0
- data/lib/testimonials/version.rb +1 -1
- data/lib/testimonials/widget.js +7 -1
- data/lib/testimonials.rb +29 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 49da88f23d3967043d08ff98641b737b357b67b1bffc65cf4ac2f5e9ffad181c
|
|
4
|
+
data.tar.gz: '091174dcf7d158e560f6c047593ef9d8b9dd7c1a4be3461f4cde4d1923c9ea84'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e45f348b081c38c7e85bcad7fa2823594c75100e4ace6be42390e793f7438e748a4048a0a753d5f246222333fc241ddd227e8059e6e94ea0f63c11899269335d
|
|
7
|
+
data.tar.gz: 17c9adb10e7922c9d32fc7931e809c3f398983571f109e1705e6f224f22328463d1d06ff8e7416ed210a802a841822bfa3d84d3f060f7cc9b01c7d1897d8aaa6
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.1
|
|
4
|
+
|
|
5
|
+
- The widget's injected stylesheet now refreshes when its content changes
|
|
6
|
+
instead of once-and-never-again — so a shipped widget update takes effect on
|
|
7
|
+
the next Turbo visit instead of needing a full page reload (Turbo keeps
|
|
8
|
+
`<head>` across visits, which could otherwise pin old CSS while fresh
|
|
9
|
+
widget.js runs). Backported from livechat 0.4.5.
|
|
10
|
+
|
|
11
|
+
## 0.5.0
|
|
12
|
+
|
|
13
|
+
- **Multi-tenancy.** Scope testimonials to a tenant — each Organization (or
|
|
14
|
+
Product, Course, Store) with its own collection, dashboard, widget, and read
|
|
15
|
+
API — via one resolver: `config.tenant = ->(request) {
|
|
16
|
+
Current.organization&.to_gid&.to_s }`. The key is an opaque string (GlobalID,
|
|
17
|
+
id, subdomain, slug); the gem takes no foreign key into your models, exactly
|
|
18
|
+
like author attribution. Every write is stamped and every read — dashboard,
|
|
19
|
+
`/api/testimonials`, `/api/stats`, NPS, the public collection page, media —
|
|
20
|
+
is scoped to the resolved tenant. Authorization composes: `authorize_admin`
|
|
21
|
+
says who's an admin, `tenant` says which tenant, so an org admin sees only
|
|
22
|
+
their own. "One review per user" becomes one per user *per tenant*, and a
|
|
23
|
+
cross-tenant id 404s instead of leaking.
|
|
24
|
+
- Optional `has_testimonials` model concern (veneer over the string key, no
|
|
25
|
+
new coupling): `organization.testimonials.approved`,
|
|
26
|
+
`organization.testimonials_nps` — plus `Testimonials.for(record)` /
|
|
27
|
+
`.nps_for(record)`.
|
|
28
|
+
- **Single-tenant apps are unchanged** — `config.tenant` defaults to nil, one
|
|
29
|
+
global collection. Upgrading: the `tenant` column is additive and nullable;
|
|
30
|
+
run `bin/rails generate testimonials:tenant && bin/rails db:migrate` (fresh
|
|
31
|
+
installs already include it). Existing rows keep a nil tenant.
|
|
32
|
+
|
|
3
33
|
## 0.4.2
|
|
4
34
|
|
|
5
35
|
- Uploaded videos now get a poster frame too (grabbed from the file
|
data/README.md
CHANGED
|
@@ -177,6 +177,47 @@ quick-approve, inline video playback, feature toggle, and a “Best line” pick
|
|
|
177
177
|
the customer's words are never editable, but you choose the pull-quote.
|
|
178
178
|
Gated by `config.authorize_admin` (development-only until you set it).
|
|
179
179
|
|
|
180
|
+
## Multi-tenancy
|
|
181
|
+
|
|
182
|
+
Scope testimonials to a tenant — each Organization (or Product, Course, Store)
|
|
183
|
+
with its own collection, dashboard, widget, and read API — with one resolver:
|
|
184
|
+
|
|
185
|
+
```ruby
|
|
186
|
+
config.tenant = ->(request) { Current.organization&.to_gid&.to_s }
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Return an **opaque key** — a GlobalID, an id, a subdomain, a slug. The gem
|
|
190
|
+
never takes a foreign key into your models and never needs to know what an
|
|
191
|
+
Organization is; it just stamps the key on each submission and scopes every
|
|
192
|
+
read (dashboard, `/api/testimonials`, `/api/stats`, NPS, the public collection
|
|
193
|
+
page) to it. `nil` (the default) is a single global collection — single-tenant
|
|
194
|
+
apps need none of this and keep working unchanged.
|
|
195
|
+
|
|
196
|
+
Authorization composes: your `authorize_admin` says *who* is an admin, the
|
|
197
|
+
`tenant` resolver says *which* tenant they're in, so an org admin sees only
|
|
198
|
+
their own tenant's dashboard and API. "One review per user" becomes one per
|
|
199
|
+
user **per tenant**.
|
|
200
|
+
|
|
201
|
+
Optional model sugar — a veneer over the string key, not a new coupling:
|
|
202
|
+
|
|
203
|
+
```ruby
|
|
204
|
+
class Organization < ApplicationRecord
|
|
205
|
+
has_testimonials # keyed by to_gid.to_s (match config.tenant)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
organization.testimonials.approved # a normal Active Record relation
|
|
209
|
+
organization.testimonials_nps # NPS responses for this tenant
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Apps that skip the concern still get full multi-tenancy from the resolver
|
|
213
|
+
alone. Key by something other than GlobalID? Pass a matching resolver to both:
|
|
214
|
+
`has_testimonials(key: ->(o){ o.subdomain })` and the same in `config.tenant`.
|
|
215
|
+
|
|
216
|
+
**Upgrading an existing install:** the `tenant` column is additive and
|
|
217
|
+
nullable. Run `bin/rails generate testimonials:tenant && bin/rails db:migrate`
|
|
218
|
+
(a fresh install already includes it). Existing rows keep a `nil` tenant — the
|
|
219
|
+
global collection — so nothing changes until you set `config.tenant`.
|
|
220
|
+
|
|
180
221
|
## Testing
|
|
181
222
|
|
|
182
223
|
```bash
|
|
@@ -5,14 +5,14 @@ module Testimonials
|
|
|
5
5
|
# Powers rating badges: "★ 4.9 from 87 reviews".
|
|
6
6
|
class StatsController < BaseController
|
|
7
7
|
def show
|
|
8
|
-
scope = Testimonial.publishable
|
|
8
|
+
scope = Testimonial.for_tenant(current_tenant).publishable
|
|
9
9
|
rated = scope.where.not(rating: nil)
|
|
10
10
|
|
|
11
11
|
render json: {
|
|
12
12
|
count: scope.count,
|
|
13
13
|
average_rating: rated.average(:rating)&.to_f&.round(2),
|
|
14
14
|
ratings_count: rated.count,
|
|
15
|
-
nps_score: (NpsResponse.score if Testimonials.config.nps)
|
|
15
|
+
nps_score: (NpsResponse.score(NpsResponse.for_tenant(current_tenant)) if Testimonials.config.nps)
|
|
16
16
|
}
|
|
17
17
|
end
|
|
18
18
|
end
|
|
@@ -6,7 +6,7 @@ module Testimonials
|
|
|
6
6
|
MAX_LIMIT = 100
|
|
7
7
|
|
|
8
8
|
def index
|
|
9
|
-
scope = Testimonial.publishable.featured_first
|
|
9
|
+
scope = Testimonial.for_tenant(current_tenant).publishable.featured_first
|
|
10
10
|
scope = scope.where(featured: true) if params[:featured].present?
|
|
11
11
|
scope = scope.where(kind: params[:kind]) if Testimonial::KINDS.include?(params[:kind])
|
|
12
12
|
scope = scope.where(rating: params[:min_rating].to_i..) if params[:min_rating].present?
|
|
@@ -16,6 +16,15 @@ module Testimonials
|
|
|
16
16
|
current_author.respond_to?(:id) ? current_author.id : nil
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
+
# The tenant for this request (nil = the single global collection). Every
|
|
20
|
+
# read and write in the engine scopes to it, so a resolved-tenant admin
|
|
21
|
+
# only ever sees and writes their own tenant's records.
|
|
22
|
+
def current_tenant
|
|
23
|
+
return @current_tenant if defined?(@current_tenant)
|
|
24
|
+
|
|
25
|
+
@current_tenant = Testimonials.tenant(request)
|
|
26
|
+
end
|
|
27
|
+
|
|
19
28
|
def require_enabled
|
|
20
29
|
head :forbidden unless Testimonials.enabled?(request)
|
|
21
30
|
end
|
|
@@ -11,8 +11,11 @@ module Testimonials
|
|
|
11
11
|
head :not_found and return unless Testimonials.config.public_collection
|
|
12
12
|
head :forbidden and return unless Testimonials.enabled?(request)
|
|
13
13
|
|
|
14
|
-
# A signed-in visitor edits their existing review
|
|
15
|
-
|
|
14
|
+
# A signed-in visitor edits their existing review (in this tenant)
|
|
15
|
+
# instead of adding one.
|
|
16
|
+
@existing = current_author_id &&
|
|
17
|
+
Testimonial.for_tenant(current_tenant)
|
|
18
|
+
.where(author_id: current_author_id.to_s).newest_first.first
|
|
16
19
|
end
|
|
17
20
|
end
|
|
18
21
|
end
|
|
@@ -17,7 +17,7 @@ module Testimonials
|
|
|
17
17
|
head :unprocessable_entity and return unless PromptEvent::KINDS.include?(kind) &&
|
|
18
18
|
CLIENT_ACTIONS.include?(action)
|
|
19
19
|
|
|
20
|
-
PromptEvent.record!(kind: kind, action: action,
|
|
20
|
+
PromptEvent.record!(kind: kind, action: action, tenant: current_tenant,
|
|
21
21
|
author_id: current_author_id, visitor_token: ensure_visitor_token)
|
|
22
22
|
head :no_content
|
|
23
23
|
end
|
|
@@ -37,8 +37,10 @@ module Testimonials
|
|
|
37
37
|
params[:download].present? ? 'attachment' : 'inline'
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
# Scoped to the tenant, so a cross-tenant id can never reach another
|
|
41
|
+
# tenant's media — it simply isn't found.
|
|
40
42
|
def set_testimonial
|
|
41
|
-
@testimonial = Testimonial.find(params[:id])
|
|
43
|
+
@testimonial = Testimonial.for_tenant(current_tenant).find(params[:id])
|
|
42
44
|
end
|
|
43
45
|
|
|
44
46
|
def authorize_media
|
|
@@ -12,11 +12,12 @@ module Testimonials
|
|
|
12
12
|
def create
|
|
13
13
|
nps = NpsResponse.new(nps_params)
|
|
14
14
|
nps.locale = I18n.locale.to_s
|
|
15
|
+
nps.tenant = current_tenant
|
|
15
16
|
nps.user_agent = request.user_agent
|
|
16
17
|
attribute_author(nps)
|
|
17
18
|
|
|
18
19
|
if nps.save
|
|
19
|
-
PromptEvent.record!(kind: 'nps', action: 'submitted',
|
|
20
|
+
PromptEvent.record!(kind: 'nps', action: 'submitted', tenant: current_tenant,
|
|
20
21
|
author_id: current_author_id, visitor_token: ensure_visitor_token)
|
|
21
22
|
notify_host(nps)
|
|
22
23
|
notify_detractor(nps) if nps.detractor?
|
|
@@ -51,6 +52,7 @@ module Testimonials
|
|
|
51
52
|
def offer_testimonial?(nps)
|
|
52
53
|
nps.promoter? && PromptEvent.eligible?(
|
|
53
54
|
kind: 'testimonial',
|
|
55
|
+
tenant: current_tenant,
|
|
54
56
|
author_id: current_author_id,
|
|
55
57
|
visitor_token: cookies[:testimonials_vid]
|
|
56
58
|
)
|
|
@@ -10,14 +10,15 @@ module Testimonials
|
|
|
10
10
|
before_action :require_admin
|
|
11
11
|
|
|
12
12
|
def index
|
|
13
|
-
|
|
14
|
-
@
|
|
15
|
-
@
|
|
16
|
-
@
|
|
17
|
-
@
|
|
13
|
+
scope = NpsResponse.for_tenant(current_tenant)
|
|
14
|
+
@score = NpsResponse.score(scope)
|
|
15
|
+
@total = scope.count
|
|
16
|
+
@promoters = scope.where(score: 9..10).count
|
|
17
|
+
@passives = scope.where(score: 7..8).count
|
|
18
|
+
@detractors = scope.where(score: 0..6).count
|
|
18
19
|
|
|
19
20
|
@page = [params[:page].to_i, 1].max
|
|
20
|
-
@responses =
|
|
21
|
+
@responses = scope.newest_first.offset((@page - 1) * PER_PAGE).limit(PER_PAGE + 1).to_a
|
|
21
22
|
@more = @responses.size > PER_PAGE
|
|
22
23
|
@responses = @responses.first(PER_PAGE)
|
|
23
24
|
end
|
|
@@ -23,9 +23,9 @@ module Testimonials
|
|
|
23
23
|
@status = Testimonial::STATUSES.include?(params[:status]) ? params[:status] : 'pending'
|
|
24
24
|
@kind = Testimonial::KINDS.include?(params[:kind]) ? params[:kind] : nil
|
|
25
25
|
@query = params[:q].to_s.strip.presence
|
|
26
|
-
@counts =
|
|
26
|
+
@counts = tenant_scope.group(:status).count
|
|
27
27
|
|
|
28
|
-
scope =
|
|
28
|
+
scope = tenant_scope.where(status: @status)
|
|
29
29
|
scope = scope.where(kind: @kind) if @kind
|
|
30
30
|
scope = search(scope) if @query
|
|
31
31
|
@page = [params[:page].to_i, 1].max
|
|
@@ -70,7 +70,13 @@ module Testimonials
|
|
|
70
70
|
private
|
|
71
71
|
|
|
72
72
|
def set_testimonial
|
|
73
|
-
@testimonial =
|
|
73
|
+
@testimonial = tenant_scope.find(params[:id])
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Every dashboard query starts here, so an admin can only ever load,
|
|
77
|
+
# triage, or delete records in their own tenant — a cross-tenant id 404s.
|
|
78
|
+
def tenant_scope
|
|
79
|
+
Testimonial.for_tenant(current_tenant)
|
|
74
80
|
end
|
|
75
81
|
|
|
76
82
|
def admin_params
|
|
@@ -106,15 +112,18 @@ module Testimonials
|
|
|
106
112
|
testimonial.source = 'widget' unless Testimonial::SOURCES.include?(testimonial.source)
|
|
107
113
|
testimonial.consent_text = Testimonials.consent_text_for(testimonial.consent_given?)
|
|
108
114
|
testimonial.locale = I18n.locale.to_s
|
|
115
|
+
testimonial.tenant = current_tenant
|
|
109
116
|
testimonial.user_agent = request.user_agent
|
|
110
117
|
attribute_author(testimonial)
|
|
111
118
|
testimonial
|
|
112
119
|
end
|
|
113
120
|
|
|
121
|
+
# One review per signed-in user, *per tenant*: the same person can leave a
|
|
122
|
+
# separate review in each tenant, but only one within any single tenant.
|
|
114
123
|
def existing_testimonial
|
|
115
124
|
return if current_author_id.blank?
|
|
116
125
|
|
|
117
|
-
|
|
126
|
+
tenant_scope.where(author_id: current_author_id.to_s).newest_first.first
|
|
118
127
|
end
|
|
119
128
|
|
|
120
129
|
# A fresh upload makes it a video review; on an edit without a new
|
|
@@ -190,7 +199,7 @@ module Testimonials
|
|
|
190
199
|
end
|
|
191
200
|
|
|
192
201
|
def record_submission
|
|
193
|
-
PromptEvent.record!(kind: 'testimonial', action: 'submitted',
|
|
202
|
+
PromptEvent.record!(kind: 'testimonial', action: 'submitted', tenant: current_tenant,
|
|
194
203
|
author_id: current_author_id, visitor_token: ensure_visitor_token)
|
|
195
204
|
end
|
|
196
205
|
|
|
@@ -52,14 +52,17 @@ module Testimonials
|
|
|
52
52
|
@testimonials_author = Testimonials.config.current_user.call(request)
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
-
# One review per signed-in user: this is the record the widget
|
|
56
|
-
# pre-filled, and the one the create endpoint updates in place.
|
|
55
|
+
# One review per signed-in user (per tenant): this is the record the widget
|
|
56
|
+
# opens pre-filled, and the one the create endpoint updates in place.
|
|
57
57
|
def testimonials_existing_testimonial
|
|
58
58
|
return @testimonials_existing_testimonial if defined?(@testimonials_existing_testimonial)
|
|
59
59
|
|
|
60
60
|
author = testimonials_author
|
|
61
61
|
@testimonials_existing_testimonial =
|
|
62
|
-
author.respond_to?(:id)
|
|
62
|
+
if author.respond_to?(:id)
|
|
63
|
+
Testimonial.for_tenant(Testimonials.tenant(request))
|
|
64
|
+
.where(author_id: author.id.to_s).newest_first.first
|
|
65
|
+
end
|
|
63
66
|
end
|
|
64
67
|
|
|
65
68
|
# A prompt requested via testimonial_prompt! rides the flash, so it survives
|
|
@@ -73,6 +76,7 @@ module Testimonials
|
|
|
73
76
|
author = testimonials_author
|
|
74
77
|
return unless Testimonials::PromptEvent.eligible?(
|
|
75
78
|
kind: kind,
|
|
79
|
+
tenant: Testimonials.tenant(request),
|
|
76
80
|
author_id: author.respond_to?(:id) ? author.id : nil,
|
|
77
81
|
visitor_token: cookies[:testimonials_vid]
|
|
78
82
|
)
|
|
@@ -5,6 +5,7 @@ module Testimonials
|
|
|
5
5
|
class NpsResponse < ApplicationRecord
|
|
6
6
|
validates :score, presence: true, inclusion: { in: 0..10 }
|
|
7
7
|
|
|
8
|
+
scope :for_tenant, ->(tenant) { where(tenant: tenant.presence) }
|
|
8
9
|
scope :newest_first, -> { order(id: :desc) }
|
|
9
10
|
|
|
10
11
|
def promoter? = score >= 9
|
|
@@ -20,20 +20,22 @@ module Testimonials
|
|
|
20
20
|
validates :action, inclusion: { in: ACTIONS }
|
|
21
21
|
|
|
22
22
|
class << self
|
|
23
|
-
def record!(kind:, action:, author_id: nil, visitor_token: nil)
|
|
23
|
+
def record!(kind:, action:, author_id: nil, visitor_token: nil, tenant: nil)
|
|
24
24
|
return if author_id.blank? && visitor_token.blank?
|
|
25
25
|
|
|
26
|
-
create!(kind: kind.to_s, action: action.to_s,
|
|
26
|
+
create!(kind: kind.to_s, action: action.to_s, tenant: tenant.presence,
|
|
27
27
|
author_id: author_id.presence, visitor_token: visitor_token.presence)
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
# Throttling is per-tenant: a user prompted in one tenant is still
|
|
31
|
+
# eligible in another, so each collection can ask independently.
|
|
32
|
+
def eligible?(kind:, author_id: nil, visitor_token: nil, tenant: nil)
|
|
31
33
|
kind = kind.to_s
|
|
32
34
|
return false unless KINDS.include?(kind)
|
|
33
35
|
# No identity, no history: a brand-new visitor is always eligible.
|
|
34
36
|
return true if author_id.blank? && visitor_token.blank?
|
|
35
37
|
|
|
36
|
-
history = subject(author_id, visitor_token).where(kind: kind)
|
|
38
|
+
history = subject(author_id, visitor_token, tenant).where(kind: kind)
|
|
37
39
|
config = Testimonials.config
|
|
38
40
|
|
|
39
41
|
return false if submitted_recently?(history, kind, config)
|
|
@@ -45,8 +47,9 @@ module Testimonials
|
|
|
45
47
|
|
|
46
48
|
private
|
|
47
49
|
|
|
48
|
-
def subject(author_id, visitor_token)
|
|
49
|
-
|
|
50
|
+
def subject(author_id, visitor_token, tenant = nil)
|
|
51
|
+
scope = where(tenant: tenant.presence)
|
|
52
|
+
author_id.present? ? scope.where(author_id: author_id.to_s) : scope.where(visitor_token: visitor_token)
|
|
50
53
|
end
|
|
51
54
|
|
|
52
55
|
def submitted_recently?(history, kind, config)
|
|
@@ -21,6 +21,11 @@ module Testimonials
|
|
|
21
21
|
validates :body, presence: true, if: -> { kind == 'text' }
|
|
22
22
|
validates :rating, inclusion: { in: 1..5 }, allow_nil: true
|
|
23
23
|
|
|
24
|
+
# Multi-tenancy: everything is scoped to an opaque tenant key (nil = the
|
|
25
|
+
# single global collection). Loose-coupled like author_id — a string, no
|
|
26
|
+
# FK into the host's tables. See Testimonials.config.tenant.
|
|
27
|
+
scope :for_tenant, ->(tenant) { where(tenant: tenant.presence) }
|
|
28
|
+
|
|
24
29
|
scope :newest_first, -> { order(id: :desc) }
|
|
25
30
|
scope :approved, -> { where(status: 'approved') }
|
|
26
31
|
# What the read API serves: approved by an admin AND consented by the
|
|
@@ -19,11 +19,13 @@ class CreateTestimonialsTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
19
19
|
t.string :page_url
|
|
20
20
|
t.string :user_agent
|
|
21
21
|
t.string :locale
|
|
22
|
+
t.string :tenant # opaque per-tenant key; nil = single global collection
|
|
22
23
|
|
|
23
24
|
t.timestamps
|
|
24
25
|
end
|
|
25
26
|
add_index :testimonials_testimonials, :status
|
|
26
|
-
add_index :testimonials_testimonials, %i[status
|
|
27
|
+
add_index :testimonials_testimonials, %i[tenant status]
|
|
28
|
+
add_index :testimonials_testimonials, %i[tenant status consent_given]
|
|
27
29
|
|
|
28
30
|
create_table :testimonials_nps_responses do |t|
|
|
29
31
|
t.integer :score, null: false
|
|
@@ -34,20 +36,22 @@ class CreateTestimonialsTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
34
36
|
t.string :page_url
|
|
35
37
|
t.string :user_agent
|
|
36
38
|
t.string :locale
|
|
39
|
+
t.string :tenant
|
|
37
40
|
|
|
38
41
|
t.timestamps
|
|
39
42
|
end
|
|
40
|
-
add_index :testimonials_nps_responses,
|
|
43
|
+
add_index :testimonials_nps_responses, %i[tenant score]
|
|
41
44
|
|
|
42
45
|
create_table :testimonials_prompt_events do |t|
|
|
43
46
|
t.string :kind, null: false
|
|
44
47
|
t.string :action, null: false
|
|
45
48
|
t.string :author_id
|
|
46
49
|
t.string :visitor_token
|
|
50
|
+
t.string :tenant
|
|
47
51
|
|
|
48
52
|
t.timestamps
|
|
49
53
|
end
|
|
50
|
-
add_index :testimonials_prompt_events, %i[author_id kind]
|
|
51
|
-
add_index :testimonials_prompt_events, %i[visitor_token kind]
|
|
54
|
+
add_index :testimonials_prompt_events, %i[tenant author_id kind]
|
|
55
|
+
add_index :testimonials_prompt_events, %i[tenant visitor_token kind]
|
|
52
56
|
end
|
|
53
57
|
end
|
|
@@ -27,6 +27,19 @@ Testimonials.configure do |config|
|
|
|
27
27
|
# Attribution stored with a signed-in user's submission.
|
|
28
28
|
# config.user_display = ->(user) { { name: user.name, email: user.email } }
|
|
29
29
|
|
|
30
|
+
# Multi-tenancy (optional). Scope testimonials, NPS, the dashboard and the
|
|
31
|
+
# read API to a tenant — each Organization/Product/Store its own collection.
|
|
32
|
+
# Return an opaque key (nil = one global collection, the default). A
|
|
33
|
+
# GlobalID is the recommended key and matches the `has_testimonials` model
|
|
34
|
+
# concern; an id, subdomain or slug work too. The gem never needs to know
|
|
35
|
+
# what your tenant is. An admin then sees only their tenant's dashboard.
|
|
36
|
+
# config.tenant = ->(request) { Current.organization&.to_gid&.to_s }
|
|
37
|
+
#
|
|
38
|
+
# Then, optionally, `has_testimonials` for `organization.testimonials`:
|
|
39
|
+
# class Organization < ApplicationRecord
|
|
40
|
+
# has_testimonials
|
|
41
|
+
# end
|
|
42
|
+
|
|
30
43
|
# Guiding prompts shown above the message field (not form fields).
|
|
31
44
|
# Default nil = the gem's built-in localized questions. Override with
|
|
32
45
|
# literal strings, or a lambda for host-side i18n. [] hides the section.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class AddTenantToTestimonials < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
def change
|
|
5
|
+
add_column :testimonials_testimonials, :tenant, :string
|
|
6
|
+
add_column :testimonials_nps_responses, :tenant, :string
|
|
7
|
+
add_column :testimonials_prompt_events, :tenant, :string
|
|
8
|
+
|
|
9
|
+
add_index :testimonials_testimonials, %i[tenant status]
|
|
10
|
+
add_index :testimonials_testimonials, %i[tenant status consent_given]
|
|
11
|
+
add_index :testimonials_nps_responses, %i[tenant score]
|
|
12
|
+
add_index :testimonials_prompt_events, %i[tenant author_id kind]
|
|
13
|
+
add_index :testimonials_prompt_events, %i[tenant visitor_token kind]
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/generators'
|
|
4
|
+
require 'rails/generators/active_record'
|
|
5
|
+
|
|
6
|
+
module Testimonials
|
|
7
|
+
module Generators
|
|
8
|
+
# For apps that installed testimonials before multi-tenancy: adds the
|
|
9
|
+
# nullable `tenant` column (and its indexes) to the three tables. A fresh
|
|
10
|
+
# install already gets these via the install generator — run this one only
|
|
11
|
+
# to upgrade an existing install. Additive and safe: existing rows keep a
|
|
12
|
+
# nil tenant (the single global collection), so nothing changes until you
|
|
13
|
+
# set config.tenant.
|
|
14
|
+
#
|
|
15
|
+
# bin/rails generate testimonials:tenant && bin/rails db:migrate
|
|
16
|
+
class TenantGenerator < Rails::Generators::Base
|
|
17
|
+
include ActiveRecord::Generators::Migration
|
|
18
|
+
|
|
19
|
+
source_root File.expand_path('templates', __dir__)
|
|
20
|
+
|
|
21
|
+
desc 'Adds the tenant column to existing testimonials tables (multi-tenancy upgrade).'
|
|
22
|
+
|
|
23
|
+
def create_migration_file
|
|
24
|
+
migration_template 'add_tenant_to_testimonials.rb.tt',
|
|
25
|
+
'db/migrate/add_tenant_to_testimonials.rb'
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def post_install
|
|
29
|
+
say "\ntenant column queued. Run `rails db:migrate`, then set", :green
|
|
30
|
+
say 'config.tenant in config/initializers/testimonials.rb to scope per tenant.'
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def migration_version
|
|
36
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -21,6 +21,15 @@ module Testimonials
|
|
|
21
21
|
# responding to #id, or nil. Receives the request.
|
|
22
22
|
attr_accessor :current_user
|
|
23
23
|
|
|
24
|
+
# Resolve the current tenant (optional, for multi-tenant apps). Return an
|
|
25
|
+
# opaque key — a GlobalID, an id, a subdomain, a slug — or nil. Receives
|
|
26
|
+
# the request; shaped exactly like current_user/authorize_admin. nil (the
|
|
27
|
+
# default) is a single, global collection: today's behavior, unchanged.
|
|
28
|
+
# Testimonials, NPS, the dashboard and the read API all scope to whatever
|
|
29
|
+
# this returns. The recommended key is a GlobalID (`record.to_gid.to_s`),
|
|
30
|
+
# which also matches the `has_testimonials` model concern.
|
|
31
|
+
attr_accessor :tenant
|
|
32
|
+
|
|
24
33
|
# Turn a resolved user into the attribution stored with a submission.
|
|
25
34
|
# Return a hash with :name, :email, and optionally :title_company.
|
|
26
35
|
# Receives whatever #current_user returned.
|
|
@@ -90,6 +99,7 @@ module Testimonials
|
|
|
90
99
|
@enabled = ->(_request) { true }
|
|
91
100
|
@authorize_admin = ->(_request) { Rails.env.development? }
|
|
92
101
|
@current_user = ->(_request) {}
|
|
102
|
+
@tenant = ->(_request) {}
|
|
93
103
|
@user_display = lambda { |user|
|
|
94
104
|
{ name: user.try(:name), email: user.try(:email) }
|
|
95
105
|
}
|
data/lib/testimonials/engine.rb
CHANGED
|
@@ -12,5 +12,13 @@ module Testimonials
|
|
|
12
12
|
include Testimonials::PromptHelper
|
|
13
13
|
end
|
|
14
14
|
end
|
|
15
|
+
|
|
16
|
+
# Make the optional `has_testimonials` model macro available on every
|
|
17
|
+
# Active Record model, without requiring the host to include anything.
|
|
18
|
+
initializer 'testimonials.model' do
|
|
19
|
+
ActiveSupport.on_load(:active_record) do
|
|
20
|
+
extend Testimonials::HasTestimonials
|
|
21
|
+
end
|
|
22
|
+
end
|
|
15
23
|
end
|
|
16
24
|
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Testimonials
|
|
4
|
+
# Optional sugar for multi-tenant apps. `has_testimonials` on a host model
|
|
5
|
+
# gives it `.testimonials` (and `.testimonials_nps`) relations, keyed by the
|
|
6
|
+
# record's GlobalID — the documented tenant convention:
|
|
7
|
+
#
|
|
8
|
+
# class Organization < ApplicationRecord
|
|
9
|
+
# has_testimonials
|
|
10
|
+
# end
|
|
11
|
+
# organization.testimonials.approved # a normal Active Record relation
|
|
12
|
+
#
|
|
13
|
+
# It's a pure veneer over `Testimonial.for_tenant(...)` — no association, no
|
|
14
|
+
# foreign key, no `owner_type` class-name coupling. Apps that don't want it
|
|
15
|
+
# still get full multi-tenancy through `config.tenant` alone.
|
|
16
|
+
#
|
|
17
|
+
# The write side lines up automatically when the tenant resolver produces the
|
|
18
|
+
# same GlobalID key, e.g. `config.tenant = ->(r){ Current.org.to_gid.to_s }`.
|
|
19
|
+
# Key by something else? Pass a matching resolver: `has_testimonials(key:
|
|
20
|
+
# ->(org){ org.subdomain })` — and use the same in `config.tenant`.
|
|
21
|
+
module HasTestimonials
|
|
22
|
+
def has_testimonials(as: :testimonials, key: nil) # rubocop:disable Naming/PredicatePrefix
|
|
23
|
+
resolver = key || ->(record) { record.to_gid.to_s }
|
|
24
|
+
|
|
25
|
+
define_method(as) { Testimonials::Testimonial.for_tenant(resolver.call(self)) }
|
|
26
|
+
define_method(:"#{as}_nps") { Testimonials::NpsResponse.for_tenant(resolver.call(self)) }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
data/lib/testimonials/version.rb
CHANGED
data/lib/testimonials/widget.js
CHANGED
|
@@ -1052,7 +1052,6 @@
|
|
|
1052
1052
|
// --- styles -------------------------------------------------------------------
|
|
1053
1053
|
|
|
1054
1054
|
function injectStyles() {
|
|
1055
|
-
if (document.getElementById("tml-styles")) return;
|
|
1056
1055
|
var css = [
|
|
1057
1056
|
"#tml-overlay{position:fixed;inset:0;z-index:" + Z + ";background:rgba(0,0,0,.45);",
|
|
1058
1057
|
"display:flex;align-items:center;justify-content:center;padding:16px}",
|
|
@@ -1154,6 +1153,13 @@
|
|
|
1154
1153
|
"#tml-dialog .tml-actions{padding-bottom:calc(0px + env(safe-area-inset-bottom))}",
|
|
1155
1154
|
"}"
|
|
1156
1155
|
].join("");
|
|
1156
|
+
// Re-inject only when the CSS changed. Turbo keeps <head> across visits, so
|
|
1157
|
+
// a stale <style> would otherwise pin old CSS after a shipped update, even
|
|
1158
|
+
// while fresh widget.js runs — as self-freshening as the fingerprinted URL.
|
|
1159
|
+
var existing = document.getElementById("tml-styles");
|
|
1160
|
+
if (existing && existing.textContent === css) return;
|
|
1161
|
+
if (existing) existing.remove();
|
|
1162
|
+
|
|
1157
1163
|
var style = document.createElement("style");
|
|
1158
1164
|
style.id = "tml-styles";
|
|
1159
1165
|
style.textContent = css;
|
data/lib/testimonials.rb
CHANGED
|
@@ -4,6 +4,7 @@ require 'testimonials/version'
|
|
|
4
4
|
require 'testimonials/configuration'
|
|
5
5
|
require 'testimonials/widget'
|
|
6
6
|
require 'testimonials/prompt_helper'
|
|
7
|
+
require 'testimonials/has_testimonials'
|
|
7
8
|
require 'testimonials/engine'
|
|
8
9
|
|
|
9
10
|
# Testimonials, reviews and NPS for Rails. An iOS-style in-app widget collects
|
|
@@ -38,6 +39,26 @@ module Testimonials
|
|
|
38
39
|
config.app_name.presence || rails_app_name
|
|
39
40
|
end
|
|
40
41
|
|
|
42
|
+
# The tenant key for this request, or nil for the single global collection.
|
|
43
|
+
# Normalized to a string so `where(tenant:)` is consistent whether the
|
|
44
|
+
# resolver returns a GlobalID, an integer id, or a slug. Blank resolves to
|
|
45
|
+
# nil — an app that only sometimes has a tenant falls back to global.
|
|
46
|
+
def tenant(request)
|
|
47
|
+
config.tenant.call(request).presence&.to_s
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Testimonials belonging to a host record, keyed by its GlobalID — the
|
|
51
|
+
# documented tenant convention. Sugar for `Testimonial.for_tenant(...)`;
|
|
52
|
+
# the `has_testimonials` model concern is built on this.
|
|
53
|
+
def for(record)
|
|
54
|
+
Testimonial.for_tenant(tenant_key_for(record))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The NPS responses belonging to a host record, same keying as `.for`.
|
|
58
|
+
def nps_for(record)
|
|
59
|
+
NpsResponse.for_tenant(tenant_key_for(record))
|
|
60
|
+
end
|
|
61
|
+
|
|
41
62
|
# The guiding questions for the current locale, with %{app} filled in.
|
|
42
63
|
# I18n leaves the token alone when no interpolation values are passed,
|
|
43
64
|
# so a plain gsub covers built-in, literal, and lambda-provided strings
|
|
@@ -70,6 +91,14 @@ module Testimonials
|
|
|
70
91
|
|
|
71
92
|
private
|
|
72
93
|
|
|
94
|
+
# A host record's tenant key: its GlobalID string. Kept in one place so
|
|
95
|
+
# `.for`, `.nps_for` and the `has_testimonials` concern agree — and so a
|
|
96
|
+
# host wiring `config.tenant = ->(r){ Current.org.to_gid.to_s }` lines up
|
|
97
|
+
# with `organization.testimonials`.
|
|
98
|
+
def tenant_key_for(record)
|
|
99
|
+
record.respond_to?(:to_gid) ? record.to_gid.to_s : record.to_s
|
|
100
|
+
end
|
|
101
|
+
|
|
73
102
|
# The application's module name, verbatim ("EthicsPortal", "SupeRails") —
|
|
74
103
|
# no inflection games. Set config.app_name for anything fancier.
|
|
75
104
|
def rails_app_name
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: testimonials
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yaroslav Shmarov
|
|
@@ -94,10 +94,13 @@ files:
|
|
|
94
94
|
- lib/generators/testimonials/install/install_generator.rb
|
|
95
95
|
- lib/generators/testimonials/install/templates/create_testimonials_tables.rb.tt
|
|
96
96
|
- lib/generators/testimonials/install/templates/initializer.rb
|
|
97
|
+
- lib/generators/testimonials/tenant/templates/add_tenant_to_testimonials.rb.tt
|
|
98
|
+
- lib/generators/testimonials/tenant/tenant_generator.rb
|
|
97
99
|
- lib/testimonials.rb
|
|
98
100
|
- lib/testimonials/configuration.rb
|
|
99
101
|
- lib/testimonials/dashboard.js
|
|
100
102
|
- lib/testimonials/engine.rb
|
|
103
|
+
- lib/testimonials/has_testimonials.rb
|
|
101
104
|
- lib/testimonials/prompt_helper.rb
|
|
102
105
|
- lib/testimonials/version.rb
|
|
103
106
|
- lib/testimonials/widget.js
|