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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f32069743e1abbbe0f5ce913e32e0c37f1e23230c0f5d59bb903dd9189761153
4
- data.tar.gz: fbae328897d5aea39f1896e55324df8182f08bae09e7907a30510dfd3026c82d
3
+ metadata.gz: 49da88f23d3967043d08ff98641b737b357b67b1bffc65cf4ac2f5e9ffad181c
4
+ data.tar.gz: '091174dcf7d158e560f6c047593ef9d8b9dd7c1a4be3461f4cde4d1923c9ea84'
5
5
  SHA512:
6
- metadata.gz: c9345c6257864c1531782f4a7cdd1fd136118aab23898e57ae28723b13a27107f657d865b77947c0ac26de7ed06b143ce8cb2ffd0463a6cfc57c08f28cf90085
7
- data.tar.gz: 27d79110f0b6e043373e0e2989f7f1947810d2c52a1c6e21b5f71e759739c9762f668e6e05f0bbb8a9e9879e38dcb4b61de8d3d276d626a89b1440e4c3af6e35
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 instead of adding one.
15
- @existing = current_author_id && Testimonial.where(author_id: current_author_id.to_s).newest_first.first
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
- @score = NpsResponse.score
14
- @total = NpsResponse.count
15
- @promoters = NpsResponse.where(score: 9..10).count
16
- @passives = NpsResponse.where(score: 7..8).count
17
- @detractors = NpsResponse.where(score: 0..6).count
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 = NpsResponse.newest_first.offset((@page - 1) * PER_PAGE).limit(PER_PAGE + 1).to_a
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 = Testimonial.group(:status).count
26
+ @counts = tenant_scope.group(:status).count
27
27
 
28
- scope = Testimonial.where(status: @status)
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 = Testimonial.find(params[:id])
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
- Testimonial.where(author_id: current_author_id.to_s).newest_first.first
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 opens
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) ? Testimonial.where(author_id: author.id.to_s).newest_first.first : nil
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
- def eligible?(kind:, author_id: nil, visitor_token: nil)
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
- author_id.present? ? where(author_id: author_id.to_s) : where(visitor_token: visitor_token)
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 consent_given]
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, :score
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
  }
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Testimonials
4
- VERSION = '0.4.2'
4
+ VERSION = '0.5.1'
5
5
  end
@@ -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.2
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