ideasbugs 0.5.4 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8959223a89b198251b3fe0697330ebe2fe2054bfa8c0a258bb3ef9470d3f171b
4
- data.tar.gz: fb7b3d95aac04d6e70942377b714771cd33ac10732d29833405446425c1ab8f2
3
+ metadata.gz: 6658dd5686d24377bb3d968e620282473b8b4769a02f11cfcc4ef5fbf2ba4ae9
4
+ data.tar.gz: a12d3f136d6c693f020a56b703b1c3bd050527e2045585dc1a080197977827bb
5
5
  SHA512:
6
- metadata.gz: 304eb89d1bd1529e54356e8eaa647f004c85f7cc5eddf040fa746cc3c5a5a654a26da06235fd57d1010da3c477fbf7d45fb143d99775c73f38af02971c372b85
7
- data.tar.gz: e54f393078ca2e69afc9ecba2b81bb44849ce91c69501c14e84af4c820daa586bf421079f75c66b601086eee99be505832950804631d0f1624a0edaa4e58ac1a
6
+ metadata.gz: b002bc9672adf86e27a2b59e62c88b745d4b6a1449777f78308627abc8894d92b9538191ff562e9b3cf271fed9adaa3ec0425ba05b9148505f3bab963112c59d
7
+ data.tar.gz: f05923a9dd619623f615c756261100e0c726b6a80e592dd1dc9ada985226f88e76ed12a5333eb67d375e4478e94e1f40a160df4f46b44bebc779b592e55e1a93
data/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0 (2026-07-26)
4
+
5
+ - **Multi-tenancy.** Give each customer/tenant its own board — its own widget
6
+ submissions and its own triage dashboard — via one resolver:
7
+ `config.tenant = ->(request) { Current.organization&.to_gid&.to_s }`. The key
8
+ is an opaque string (GlobalID, id, subdomain, slug); the gem takes no foreign
9
+ key into your models, exactly like author attribution. Every submission is
10
+ stamped and the dashboard (plus screenshot access) scopes to the resolved
11
+ tenant. Authorization composes: `authorize_admin` says who's an admin,
12
+ `tenant` says which tenant, so a customer's admin sees only their own — and a
13
+ cross-tenant id 404s instead of leaking.
14
+ - Optional `has_feedback` model concern (veneer over the string key, no new
15
+ coupling): `customer.feedback.open` — plus `Ideasbugs.for(record)`.
16
+ - **Single-tenant apps are unchanged** — `config.tenant` defaults to nil, one
17
+ global board. Upgrading: the `tenant` column is additive and nullable; run
18
+ `bin/rails generate ideasbugs:tenant && bin/rails db:migrate` (fresh installs
19
+ already include it). Existing rows keep a nil tenant.
20
+
21
+ ## 0.5.5 (2026-07-25)
22
+
23
+ - The dashboard's **Section** column now only appears when it carries
24
+ information — when sections are configured (`config.sections`) or some
25
+ record already has one. Apps that don't use sections no longer see a
26
+ permanently blank column.
27
+
3
28
  ## 0.5.4 (2026-07-25)
4
29
 
5
30
  - Docs: show how to wire current_user with Rails 8's built-in authentication
data/README.md CHANGED
@@ -302,10 +302,48 @@ end
302
302
  ```
303
303
 
304
304
  Each row stores `kind`, `section`, `message`, `status` (`open`, `in_review`,
305
- `resolved`), `page_url`, `user_agent`, and optional `author_id` /
306
- `author_label`. Screenshots are Active Storage attachments
305
+ `resolved`), `page_url`, `user_agent`, an optional `tenant`, and optional
306
+ `author_id` / `author_label`. Screenshots are Active Storage attachments
307
307
  (`feedback.screenshots`).
308
308
 
309
+ ## Multi-tenancy
310
+
311
+ Give each customer/tenant its own board — its own widget submissions and its
312
+ own triage dashboard — with one resolver:
313
+
314
+ ```ruby
315
+ config.tenant = ->(request) { Current.organization&.to_gid&.to_s }
316
+ ```
317
+
318
+ Return an **opaque key** — a GlobalID, an id, a subdomain, a slug. The gem
319
+ never takes a foreign key into your models and never needs to know what a
320
+ Customer is; it just stamps the key on each submission and scopes the dashboard
321
+ (and screenshot access) to it. `nil` (the default) is a single global board —
322
+ single-tenant apps need none of this and keep working unchanged.
323
+
324
+ Authorization composes: your `authorize_admin` says *who* is an admin, the
325
+ `tenant` resolver says *which* tenant they're in, so a customer's admin sees
326
+ only their own board — and a cross-tenant id 404s.
327
+
328
+ Optional model sugar — a veneer over the string key, not a new coupling:
329
+
330
+ ```ruby
331
+ class Customer < ApplicationRecord
332
+ has_feedback # keyed by to_gid.to_s (match config.tenant)
333
+ end
334
+
335
+ customer.feedback.open # a normal Active Record relation
336
+ ```
337
+
338
+ Apps that skip the concern still get full multi-tenancy from the resolver
339
+ alone. Key by something other than GlobalID? Pass a matching resolver to both:
340
+ `has_feedback(key: ->(c){ c.subdomain })` and the same in `config.tenant`.
341
+
342
+ **Upgrading an existing install:** the `tenant` column is additive and
343
+ nullable. Run `bin/rails generate ideasbugs:tenant && bin/rails db:migrate`
344
+ (a fresh install already includes it). Existing rows keep a `nil` tenant — the
345
+ global board — so nothing changes until you set `config.tenant`.
346
+
309
347
  ## Security
310
348
 
311
349
  - Submission and dashboard access are both gated **on the server** for every
@@ -11,5 +11,14 @@ module Ideasbugs
11
11
 
12
12
  @current_author = Ideasbugs.config.current_user.call(request)
13
13
  end
14
+
15
+ # The tenant for this request (nil = the single global board). Every read
16
+ # and write scopes to it, so a resolved-tenant admin only ever sees and
17
+ # writes their own tenant's feedback.
18
+ def current_tenant
19
+ return @current_tenant if defined?(@current_tenant)
20
+
21
+ @current_tenant = Ideasbugs.tenant(request)
22
+ end
14
23
  end
15
24
  end
@@ -26,15 +26,20 @@ module Ideasbugs
26
26
  @status = Feedback::STATUSES.include?(params[:status]) ? params[:status] : 'open'
27
27
  @kind = Ideasbugs.config.kinds.map(&:to_s).include?(params[:kind]) ? params[:kind] : nil
28
28
  @query = params[:q].to_s.strip.presence
29
- @counts = Feedback.group(:status).count
29
+ @counts = tenant_scope.group(:status).count
30
30
 
31
- scope = Feedback.where(status: @status)
31
+ scope = tenant_scope.where(status: @status)
32
32
  scope = scope.where(kind: @kind) if @kind
33
33
  scope = search(scope) if @query
34
34
  @page = [params[:page].to_i, 1].max
35
35
  @feedbacks = scope.newest_first.offset((@page - 1) * PER_PAGE).limit(PER_PAGE + 1).to_a
36
36
  @more = @feedbacks.size > PER_PAGE
37
37
  @feedbacks = @feedbacks.first(PER_PAGE)
38
+
39
+ # Only surface the Section column when it can carry information: the host
40
+ # configured sections, or some record already has one (e.g. sections were
41
+ # configured historically). Otherwise it's a permanently blank column.
42
+ @show_section = Ideasbugs.config.sections.any? || @feedbacks.any? { |f| f.section.present? }
38
43
  end
39
44
 
40
45
  def show; end
@@ -42,6 +47,7 @@ module Ideasbugs
42
47
  def create
43
48
  feedback = Feedback.new(feedback_params)
44
49
  feedback.user_agent = request.user_agent
50
+ feedback.tenant = current_tenant
45
51
  attribute_author(feedback)
46
52
 
47
53
  error = attach_screenshots(feedback)
@@ -80,7 +86,13 @@ module Ideasbugs
80
86
  end
81
87
 
82
88
  def set_feedback
83
- @feedback = Feedback.find(params[:id])
89
+ @feedback = tenant_scope.find(params[:id])
90
+ end
91
+
92
+ # Every dashboard query starts here, so an admin can only ever load,
93
+ # triage, or delete feedback in their own tenant — a cross-tenant id 404s.
94
+ def tenant_scope
95
+ Feedback.for_tenant(current_tenant)
84
96
  end
85
97
 
86
98
  # Case-insensitive match on the free-text columns. LOWER() keeps it
@@ -10,7 +10,8 @@ module Ideasbugs
10
10
  before_action :require_admin
11
11
 
12
12
  def show
13
- screenshot = Feedback.find(params[:feedback_id]).screenshots.find(params[:id])
13
+ screenshot = Feedback.for_tenant(current_tenant)
14
+ .find(params[:feedback_id]).screenshots.find(params[:id])
14
15
 
15
16
  send_data screenshot.download,
16
17
  filename: screenshot.filename.to_s,
@@ -17,6 +17,11 @@ module Ideasbugs
17
17
  presence: true,
18
18
  inclusion: { in: ->(_) { Ideasbugs.config.kinds.map(&:to_s) } }
19
19
 
20
+ # Multi-tenancy: everything is scoped to an opaque tenant key (nil = the
21
+ # single global board). Loose-coupled like author_id — a string, no FK
22
+ # into the host's tables. See Ideasbugs.config.tenant.
23
+ scope :for_tenant, ->(tenant) { where(tenant: tenant.presence) }
24
+
20
25
  scope :newest_first, -> { order(id: :desc) }
21
26
 
22
27
  STATUSES.each do |status|
@@ -33,7 +33,9 @@
33
33
  <tr>
34
34
  <th><%= t('ideasbugs.kind', default: 'Type') %></th>
35
35
  <th><%= t('ideasbugs.dashboard.message', default: 'Message') %></th>
36
- <th><%= t('ideasbugs.section', default: 'Section') %></th>
36
+ <% if @show_section %>
37
+ <th><%= t('ideasbugs.section', default: 'Section') %></th>
38
+ <% end %>
37
39
  <th><%= t('ideasbugs.dashboard.from', default: 'From') %></th>
38
40
  <th><%= t('ideasbugs.dashboard.filed', default: 'Filed') %></th>
39
41
  </tr>
@@ -52,7 +54,9 @@
52
54
  <div class="muted">📎 <%= feedback.screenshots.count %></div>
53
55
  <% end %>
54
56
  </td>
55
- <td><%= feedback.section %></td>
57
+ <% if @show_section %>
58
+ <td><%= feedback.section %></td>
59
+ <% end %>
56
60
  <td><%= feedback.author_label %></td>
57
61
  <td class="muted" title="<%= feedback.created_at %>">
58
62
  <%= time_ago_in_words(feedback.created_at) %>
@@ -11,11 +11,13 @@ class CreateIdeasbugsFeedbacks < ActiveRecord::Migration<%= migration_version %>
11
11
  t.string :user_agent
12
12
  t.string :author_id
13
13
  t.string :author_label
14
+ t.string :tenant # opaque per-tenant key; nil = single global board
14
15
 
15
16
  t.timestamps
16
17
  end
17
18
 
18
19
  add_index :ideasbugs_feedbacks, :status
19
20
  add_index :ideasbugs_feedbacks, :kind
21
+ add_index :ideasbugs_feedbacks, %i[tenant status]
20
22
  end
21
23
  end
@@ -23,6 +23,19 @@ Ideasbugs.configure do |config|
23
23
  # Label stored for the author and shown in the dashboard.
24
24
  # config.author_label = ->(user) { user.try(:email) }
25
25
 
26
+ # Multi-tenancy (optional). Give each customer/tenant its own board — its own
27
+ # widget submissions and its own triage dashboard. Return an opaque key
28
+ # (nil = one global board, the default). A GlobalID is the recommended key
29
+ # and matches the `has_feedback` model concern; an id, subdomain or slug work
30
+ # too. The gem never needs to know what your tenant is. An admin then sees
31
+ # only their tenant's board.
32
+ # config.tenant = ->(request) { Current.organization&.to_gid&.to_s }
33
+ #
34
+ # Then, optionally, `has_feedback` for `customer.feedback`:
35
+ # class Customer < ApplicationRecord
36
+ # has_feedback
37
+ # end
38
+
26
39
  # Feedback types users can pick from. Labels come from I18n
27
40
  # (ideasbugs.kinds.<kind>).
28
41
  # config.kinds = %w[bug feature other]
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddTenantToIdeasbugsFeedbacks < ActiveRecord::Migration<%= migration_version %>
4
+ def change
5
+ add_column :ideasbugs_feedbacks, :tenant, :string
6
+ add_index :ideasbugs_feedbacks, %i[tenant status]
7
+ end
8
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'rails/generators/active_record'
5
+
6
+ module Ideasbugs
7
+ module Generators
8
+ # For apps that installed ideasbugs before multi-tenancy: adds the nullable
9
+ # `tenant` column (and its index) to ideasbugs_feedbacks. A fresh install
10
+ # already gets it via the install generator — run this one only to upgrade
11
+ # an existing install. Additive and safe: existing rows keep a nil tenant
12
+ # (the single global board), so nothing changes until you set config.tenant.
13
+ #
14
+ # bin/rails generate ideasbugs:tenant && bin/rails db:migrate
15
+ class TenantGenerator < Rails::Generators::Base
16
+ include ActiveRecord::Generators::Migration
17
+
18
+ source_root File.expand_path('templates', __dir__)
19
+
20
+ desc 'Adds the tenant column to ideasbugs_feedbacks (multi-tenancy upgrade).'
21
+
22
+ def create_migration_file
23
+ migration_template 'add_tenant_to_ideasbugs_feedbacks.rb.tt',
24
+ 'db/migrate/add_tenant_to_ideasbugs_feedbacks.rb'
25
+ end
26
+
27
+ def post_install
28
+ say "\ntenant column queued. Run `rails db:migrate`, then set", :green
29
+ say 'config.tenant in config/initializers/ideasbugs.rb to scope per tenant.'
30
+ end
31
+
32
+ private
33
+
34
+ def migration_version
35
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
36
+ end
37
+ end
38
+ end
39
+ end
@@ -20,6 +20,15 @@ module Ideasbugs
20
20
  # responding to #id, or nil. Receives the request.
21
21
  attr_accessor :current_user
22
22
 
23
+ # Resolve the current tenant (optional, for multi-tenant apps). Return an
24
+ # opaque key — a GlobalID, an id, a subdomain, a slug — or nil. Receives
25
+ # the request; shaped exactly like current_user/authorize_admin. nil (the
26
+ # default) is a single, global board: today's behavior, unchanged. Each
27
+ # submission is stamped with it and the dashboard scopes to it, so every
28
+ # tenant gets its own board. The recommended key is a GlobalID
29
+ # (`record.to_gid.to_s`), which also matches the `has_feedback` concern.
30
+ attr_accessor :tenant
31
+
23
32
  # Turn a resolved user into a short label stored alongside the feedback and
24
33
  # shown in the dashboard. Receives whatever #current_user returned.
25
34
  attr_accessor :author_label
@@ -67,6 +76,7 @@ module Ideasbugs
67
76
  @enabled = ->(_request) { true }
68
77
  @authorize_admin = ->(_request) { Rails.env.development? }
69
78
  @current_user = ->(_request) {}
79
+ @tenant = ->(_request) {}
70
80
  @author_label = ->(user) { user.respond_to?(:email) ? user.email : user&.to_s }
71
81
  @kinds = %w[bug feature other]
72
82
  @sections = []
@@ -9,5 +9,13 @@ module Ideasbugs
9
9
  include Ideasbugs::WidgetHelper
10
10
  end
11
11
  end
12
+
13
+ # Make the optional `has_feedback` model macro available on every Active
14
+ # Record model, without requiring the host to include anything.
15
+ initializer 'ideasbugs.model' do
16
+ ActiveSupport.on_load(:active_record) do
17
+ extend Ideasbugs::HasFeedback
18
+ end
19
+ end
12
20
  end
13
21
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ideasbugs
4
+ # Optional sugar for multi-tenant apps. `has_feedback` on a host model gives
5
+ # it a `.feedback` relation, keyed by the record's GlobalID — the documented
6
+ # tenant convention:
7
+ #
8
+ # class Customer < ApplicationRecord
9
+ # has_feedback
10
+ # end
11
+ # customer.feedback.open # a normal Active Record relation
12
+ #
13
+ # It's a pure veneer over `Feedback.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.customer.to_gid.to_s }`.
19
+ # Key by something else? Pass a matching resolver: `has_feedback(key:
20
+ # ->(c){ c.subdomain })` — and use the same in `config.tenant`.
21
+ module HasFeedback
22
+ def has_feedback(as: :feedback, key: nil) # rubocop:disable Naming/PredicatePrefix
23
+ resolver = key || ->(record) { record.to_gid.to_s }
24
+
25
+ define_method(as) { Ideasbugs::Feedback.for_tenant(resolver.call(self)) }
26
+ end
27
+ end
28
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ideasbugs
4
- VERSION = '0.5.4'
4
+ VERSION = '0.6.0'
5
5
  end
data/lib/ideasbugs.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require 'ideasbugs/version'
4
4
  require 'ideasbugs/configuration'
5
5
  require 'ideasbugs/widget'
6
+ require 'ideasbugs/has_feedback'
6
7
  require 'ideasbugs/engine'
7
8
 
8
9
  # In-app product feedback collection for Rails. A drop-in widget lets users
@@ -30,5 +31,29 @@ module Ideasbugs
30
31
  def admin?(request)
31
32
  !!config.authorize_admin.call(request)
32
33
  end
34
+
35
+ # The tenant key for this request, or nil for the single global board.
36
+ # Normalized to a string so `where(tenant:)` is consistent whether the
37
+ # resolver returns a GlobalID, an integer id, or a slug; blank becomes nil.
38
+ def tenant(request)
39
+ config.tenant.call(request).presence&.to_s
40
+ end
41
+
42
+ # The feedback belonging to a host record, keyed by its GlobalID — the
43
+ # documented tenant convention. Sugar for `Feedback.for_tenant(...)`; the
44
+ # `has_feedback` model concern is built on this.
45
+ def for(record)
46
+ Feedback.for_tenant(tenant_key_for(record))
47
+ end
48
+
49
+ private
50
+
51
+ # A host record's tenant key: its GlobalID string. Kept in one place so
52
+ # `.for` and `has_feedback` agree — and so a host wiring
53
+ # `config.tenant = ->(r){ Current.org.to_gid.to_s }` lines up with
54
+ # `organization.feedback`.
55
+ def tenant_key_for(record)
56
+ record.respond_to?(:to_gid) ? record.to_gid.to_s : record.to_s
57
+ end
33
58
  end
34
59
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ideasbugs
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.4
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yaroslav Shmarov
@@ -78,9 +78,12 @@ files:
78
78
  - lib/generators/ideasbugs/install/install_generator.rb
79
79
  - lib/generators/ideasbugs/install/templates/create_ideasbugs_feedbacks.rb.tt
80
80
  - lib/generators/ideasbugs/install/templates/initializer.rb
81
+ - lib/generators/ideasbugs/tenant/templates/add_tenant_to_ideasbugs_feedbacks.rb.tt
82
+ - lib/generators/ideasbugs/tenant/tenant_generator.rb
81
83
  - lib/ideasbugs.rb
82
84
  - lib/ideasbugs/configuration.rb
83
85
  - lib/ideasbugs/engine.rb
86
+ - lib/ideasbugs/has_feedback.rb
84
87
  - lib/ideasbugs/version.rb
85
88
  - lib/ideasbugs/widget.js
86
89
  - lib/ideasbugs/widget.rb