pin_flags 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +244 -0
  4. data/Rakefile +8 -0
  5. data/app/assets/javascripts/pin_flags/alpine.min.js +5 -0
  6. data/app/assets/stylesheets/pin_flags/application.css +25 -0
  7. data/app/assets/stylesheets/pin_flags/bulma.min.css +21564 -0
  8. data/app/assets/stylesheets/pin_flags/forms.css +8 -0
  9. data/app/controllers/pin_flags/application_controller.rb +6 -0
  10. data/app/controllers/pin_flags/feature_tags/exports_controller.rb +18 -0
  11. data/app/controllers/pin_flags/feature_tags/feature_subscriptions_controller.rb +138 -0
  12. data/app/controllers/pin_flags/feature_tags/imports_controller.rb +65 -0
  13. data/app/controllers/pin_flags/feature_tags_controller.rb +141 -0
  14. data/app/helpers/pin_flags/application_helper.rb +20 -0
  15. data/app/helpers/pin_flags/feature_tags/feature_subscriptions_helper.rb +23 -0
  16. data/app/helpers/pin_flags/feature_tags/imports_helper.rb +7 -0
  17. data/app/helpers/pin_flags/feature_tags_helper.rb +12 -0
  18. data/app/jobs/pin_flags/application_job.rb +4 -0
  19. data/app/mailers/pin_flags/application_mailer.rb +6 -0
  20. data/app/models/concerns/pin_flags/feature_taggable.rb +39 -0
  21. data/app/models/pin_flags/application_record.rb +5 -0
  22. data/app/models/pin_flags/feature_subscription/bulk_processor.rb +80 -0
  23. data/app/models/pin_flags/feature_subscription.rb +21 -0
  24. data/app/models/pin_flags/feature_tag/cacheable.rb +44 -0
  25. data/app/models/pin_flags/feature_tag.rb +98 -0
  26. data/app/models/pin_flags/page.rb +48 -0
  27. data/app/views/layouts/pin_flags/_flash.html.erb +6 -0
  28. data/app/views/layouts/pin_flags/_modal.html.erb +41 -0
  29. data/app/views/layouts/pin_flags/application.html.erb +34 -0
  30. data/app/views/pin_flags/feature_tags/_empty_feature_tags_table.html.erb +7 -0
  31. data/app/views/pin_flags/feature_tags/_feature_tags_table.html.erb +82 -0
  32. data/app/views/pin_flags/feature_tags/_feature_tags_table_row.html.erb +50 -0
  33. data/app/views/pin_flags/feature_tags/_new_feature_subscription_link.html.erb +8 -0
  34. data/app/views/pin_flags/feature_tags/_new_feature_tag_link.html.erb +6 -0
  35. data/app/views/pin_flags/feature_tags/create.turbo_stream.erb +7 -0
  36. data/app/views/pin_flags/feature_tags/destroy.turbo_stream.erb +7 -0
  37. data/app/views/pin_flags/feature_tags/feature_subscriptions/_empty_feature_subscriptions_table.html.erb +7 -0
  38. data/app/views/pin_flags/feature_tags/feature_subscriptions/_feature_subscriptions_table.html.erb +83 -0
  39. data/app/views/pin_flags/feature_tags/feature_subscriptions/_feature_subscriptions_table_row.html.erb +37 -0
  40. data/app/views/pin_flags/feature_tags/feature_subscriptions/create.turbo_stream.erb +10 -0
  41. data/app/views/pin_flags/feature_tags/feature_subscriptions/destroy.turbo_stream.erb +7 -0
  42. data/app/views/pin_flags/feature_tags/feature_subscriptions/new.html.erb +83 -0
  43. data/app/views/pin_flags/feature_tags/imports/_form.html.erb +66 -0
  44. data/app/views/pin_flags/feature_tags/index.html.erb +123 -0
  45. data/app/views/pin_flags/feature_tags/new.html.erb +34 -0
  46. data/app/views/pin_flags/feature_tags/show.html.erb +89 -0
  47. data/app/views/pin_flags/feature_tags/update.html.erb +2 -0
  48. data/app/views/pin_flags/feature_tags/update.turbo_stream.erb +3 -0
  49. data/app/views/pin_flags/shared/_error_messages.html.erb +11 -0
  50. data/config/routes.rb +12 -0
  51. data/db/migrate/20250626042529_create_feature_tag.rb +12 -0
  52. data/db/migrate/20250626050146_create_feature_subscriptions.rb +15 -0
  53. data/lib/generators/pin_flags/install/install_generator.rb +21 -0
  54. data/lib/generators/pin_flags/install/templates/create_feature_subscriptions.rb +14 -0
  55. data/lib/generators/pin_flags/install/templates/create_feature_tag.rb +12 -0
  56. data/lib/pin_flags/engine.rb +13 -0
  57. data/lib/pin_flags/version.rb +3 -0
  58. data/lib/pin_flags.rb +13 -0
  59. data/lib/tasks/pin_flags_tasks.rake +4 -0
  60. metadata +192 -0
@@ -0,0 +1,8 @@
1
+ /* Hide the HTML datalist wonky arrow in Safari and Chrome, since we add it via bulma. */
2
+ [list]::-webkit-calendar-picker-indicator {
3
+ opacity: 0;
4
+ }
5
+
6
+ a[disabled] {
7
+ pointer-events: none;
8
+ }
@@ -0,0 +1,6 @@
1
+ module PinFlags
2
+ class ApplicationController < ActionController::Base
3
+ layout "pin_flags/application"
4
+ protect_from_forgery with: :exception
5
+ end
6
+ end
@@ -0,0 +1,18 @@
1
+ module PinFlags
2
+ module FeatureTags
3
+ class ExportsController < ApplicationController
4
+ def index
5
+ respond_to do |format|
6
+ format.json { send_data(PinFlags::FeatureTag.export_as_json, filename: export_filename) }
7
+ end
8
+ end
9
+
10
+ private
11
+
12
+ def export_filename
13
+ timestamp = Time.zone.now.strftime("%Y%m%d%H%M%S")
14
+ "pin_flags_feature_tags_#{timestamp}.json"
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,138 @@
1
+ module PinFlags
2
+ module FeatureTags
3
+ class FeatureSubscriptionsController < ApplicationController
4
+ rescue_from ActiveRecord::RecordNotFound, with: :record_not_found
5
+
6
+ before_action :set_feature_tag
7
+ before_action :set_current_page, only: %i[create destroy]
8
+ before_action :set_filter_param, only: %i[create destroy]
9
+ before_action :set_feature_taggable_type, only: %i[create destroy]
10
+ before_action :set_feature_subscription, only: %i[destroy]
11
+
12
+ def new
13
+ @feature_subscription = @feature_tag.feature_subscriptions.new
14
+ end
15
+
16
+ def create
17
+ if feature_subscription_params[:bulk_upload] == "1"
18
+ create_feature_subscriptions_in_bulk
19
+ else
20
+ create_single_feature_subscription
21
+ end
22
+ end
23
+
24
+ def destroy
25
+ @feature_subscription.destroy
26
+
27
+ respond_to do |format|
28
+ format.html { redirect_to @feature_tag, notice: "Feature Subscription was successfully destroyed." }
29
+ format.turbo_stream
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def set_feature_tag
36
+ @feature_tag = PinFlags::FeatureTag.find_by(id: params[:feature_tag_id])
37
+ raise ActiveRecord::RecordNotFound if @feature_tag.blank?
38
+ end
39
+
40
+ def set_current_page
41
+ @current_page = params.fetch(:page, 1).to_i
42
+ end
43
+
44
+ def set_filter_param
45
+ @filter_param = params.fetch(:filter, nil)
46
+ end
47
+
48
+ def set_feature_taggable_type
49
+ @feature_taggable_type = params.fetch(:feature_taggable_type, nil)
50
+ end
51
+
52
+ def feature_subscription_params
53
+ params.expect(feature_subscription: [ :feature_taggable_type, :feature_taggable_id, :bulk_upload ])
54
+ end
55
+
56
+ def set_feature_subscription
57
+ @feature_subscription = @feature_tag.feature_subscriptions.find_by(id: params[:id])
58
+ raise ActiveRecord::RecordNotFound if @feature_subscription.blank?
59
+ end
60
+
61
+ def record_not_found
62
+ alert_message = @feature_tag.blank? ? "Feature Tag not found." : "Feature Subscription not found."
63
+ redirect_to pin_flags.feature_tags_path, alert: alert_message
64
+ end
65
+
66
+ def create_single_feature_subscription
67
+ @feature_subscription = @feature_tag.feature_subscriptions.new(feature_subscription_params.except(:bulk_upload))
68
+
69
+ if @feature_subscription.save
70
+ set_paginated_feature_subscriptions
71
+
72
+ respond_to do |format|
73
+ format.html { redirect_to pin_flags.feature_tag_path(@feature_tag, filter: @filter_param, feature_taggable_type: @feature_taggable_type), notice: "Feature Subscription was successfully created." }
74
+ format.turbo_stream
75
+ end
76
+ else
77
+ render :new, status: :unprocessable_content
78
+ end
79
+ end
80
+
81
+ def create_feature_subscriptions_in_bulk
82
+ feature_taggable_ids = feature_subscription_params[:feature_taggable_id].split(",")
83
+
84
+ if PinFlags::FeatureSubscription.create_in_bulk(
85
+ feature_tag: @feature_tag,
86
+ feature_taggable_type: feature_subscription_params[:feature_taggable_type],
87
+ feature_taggable_ids: feature_taggable_ids
88
+ )
89
+ handle_feature_subscriptions_in_bulk_success
90
+ else
91
+ handle_feature_subscriptions_in_bulk_failure
92
+ end
93
+ end
94
+
95
+ def handle_feature_subscriptions_in_bulk_success
96
+ set_paginated_feature_subscriptions
97
+
98
+ respond_to do |format|
99
+ format.html do
100
+ redirect_to pin_flags.feature_tag_path(@feature_tag, filter: @filter_param, feature_taggable_type: @feature_taggable_type), notice: "Feature Subscriptions were successfully created."
101
+ end
102
+ format.turbo_stream
103
+ end
104
+ end
105
+
106
+ def handle_feature_subscriptions_in_bulk_failure
107
+ alert_message = "One or more Feature Taggable IDs are invalid."
108
+ flash.now[:alert] = alert_message
109
+ @feature_subscription = @feature_tag.feature_subscriptions.new(
110
+ feature_taggable_type: feature_subscription_params[:feature_taggable_type],
111
+ feature_taggable_id: feature_subscription_params[:feature_taggable_id]
112
+ )
113
+ @feature_subscription.errors.add(:feature_taggable_id, alert_message)
114
+ render :new, status: :unprocessable_content
115
+ end
116
+
117
+ def fetch_filtered_feature_subscriptions
118
+ feature_subscriptions = @feature_tag.feature_subscriptions.order(created_at: :desc)
119
+
120
+ if @feature_taggable_type.present?
121
+ feature_subscriptions = feature_subscriptions.where(feature_taggable_type: @feature_taggable_type)
122
+ end
123
+
124
+ if @filter_param.present?
125
+ feature_subscriptions = feature_subscriptions.where(feature_taggable_id: @filter_param.strip)
126
+ end
127
+
128
+ feature_subscriptions
129
+ end
130
+
131
+ def set_paginated_feature_subscriptions
132
+ filtered_subscriptions = fetch_filtered_feature_subscriptions
133
+ @paginator = PinFlags::Page.new(filtered_subscriptions, page: @current_page, page_size: PinFlags::FeatureTagsController::PER_PAGE)
134
+ @feature_subscriptions = @paginator.records
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,65 @@
1
+ module PinFlags
2
+ module FeatureTags
3
+ class ImportsController < ApplicationController
4
+ MAX_FILE_SIZE ||= 2.megabytes
5
+
6
+ def create
7
+ if valid_json_file? && import_successful?
8
+ respond_with_success
9
+ else
10
+ respond_with_error
11
+ end
12
+ end
13
+
14
+ private
15
+
16
+ def import_params
17
+ params.permit(:json_file, :authenticity_token, :commit)
18
+ end
19
+
20
+ def valid_json_file?
21
+ return false if import_params[:json_file].blank?
22
+
23
+ file = import_params[:json_file]
24
+ correct_type = file.content_type == "application/json"
25
+ correct_size = file.size <= MAX_FILE_SIZE
26
+
27
+ correct_type && correct_size
28
+ end
29
+
30
+ def import_successful?
31
+ file_contents = import_params[:json_file].read
32
+ PinFlags::FeatureTag.import_from_json(file_contents)
33
+ rescue PinFlags::FeatureTagImportError => e
34
+ Rails.logger.error(e.message)
35
+ false
36
+ end
37
+
38
+ def respond_with_success
39
+ respond_to do |format|
40
+ format.html { redirect_to feature_tags_path, notice: "Feature tags were successfully imported." }
41
+ end
42
+ end
43
+
44
+ def error_message
45
+ if import_params[:json_file].blank?
46
+ "No file uploaded."
47
+ elsif import_params[:json_file].content_type != "application/json"
48
+ "File must be JSON format."
49
+ elsif import_params[:json_file].size > MAX_FILE_SIZE
50
+ "File size must be less than #{MAX_FILE_SIZE / 1.megabyte}MB."
51
+ else
52
+ "Invalid JSON file or import failed."
53
+ end
54
+ end
55
+
56
+ def respond_with_error
57
+ message = error_message
58
+
59
+ respond_to do |format|
60
+ format.html { redirect_to feature_tags_path, alert: message }
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,141 @@
1
+ module PinFlags
2
+ class FeatureTagsController < ApplicationController
3
+ rescue_from ActiveRecord::RecordNotFound, with: :handle_record_not_found
4
+
5
+ before_action :set_filter_param, only: %i[index show]
6
+ before_action :set_enabled_param, only: %i[index show]
7
+ before_action :set_subscriptions_param, only: %i[index]
8
+ before_action :set_current_page, only: %i[index show create]
9
+ before_action :set_feature_taggable_type, only: %i[show]
10
+ before_action :set_feature_tag, only: %i[show update destroy]
11
+
12
+ PER_PAGE ||= 10
13
+
14
+ def index
15
+ @paginator = PinFlags::Page.new(fetch_feature_tags, page: @current_page, page_size: PER_PAGE)
16
+ @feature_tags = @paginator.records
17
+
18
+ # Handle overflow (when page number is too high)
19
+ if @paginator.index > @paginator.pages_count && @paginator.pages_count&.positive?
20
+ redirect_to feature_tags_path(search: @filter_param, enabled: @enabled_param, subscriptions: @subscriptions_param)
21
+ end
22
+ end
23
+
24
+ def show
25
+ @paginator = PinFlags::Page.new(fetch_feature_subscriptions, page: @current_page, page_size: PER_PAGE)
26
+ @feature_subscriptions = @paginator.records
27
+
28
+ # Handle overflow for feature subscriptions
29
+ if @paginator.index > @paginator.pages_count && @paginator.pages_count&.positive?
30
+ redirect_to feature_tag_path(@feature_tag, feature_taggable_type: @feature_taggable_type, filter: @filter_param)
31
+ end
32
+ end
33
+
34
+ def new
35
+ @feature_tag = PinFlags::FeatureTag.new
36
+ end
37
+
38
+ def create
39
+ @feature_tag = PinFlags::FeatureTag.new(feature_tag_params)
40
+
41
+ if @feature_tag.save
42
+ @feature_tags = fetch_feature_tags
43
+ @paginator = PinFlags::Page.new(@feature_tags, page: @current_page, page_size: PER_PAGE)
44
+ @feature_tags = @paginator.records
45
+ handle_success(:create)
46
+ else
47
+ render :new, status: :unprocessable_content
48
+ end
49
+ end
50
+
51
+ def update
52
+ if @feature_tag.update(feature_tag_params)
53
+ handle_success(:update)
54
+ else
55
+ render :index, status: :unprocessable_content
56
+ end
57
+ end
58
+
59
+ def destroy
60
+ @feature_tag.destroy
61
+ handle_success(:delete)
62
+ end
63
+
64
+ private
65
+
66
+ def set_current_page
67
+ @current_page = params.fetch(:page, 1).to_i
68
+ end
69
+
70
+ def set_feature_tag
71
+ @feature_tag = PinFlags::FeatureTag.find(params[:id])
72
+ end
73
+
74
+ def feature_tag_params
75
+ params.expect(feature_tag: [ :name, :enabled ])
76
+ end
77
+
78
+ def set_filter_param
79
+ @filter_param = params.fetch(:filter, nil)
80
+ end
81
+
82
+ def set_enabled_param
83
+ @enabled_param = params.fetch(:enabled, nil)
84
+ end
85
+
86
+ def set_subscriptions_param
87
+ @subscriptions_param = params.fetch(:subscriptions, nil)
88
+ end
89
+
90
+ def set_feature_taggable_type
91
+ @feature_taggable_type = params.fetch(:feature_taggable_type, nil)
92
+ end
93
+
94
+ def fetch_feature_tags
95
+ scope = FeatureTag.all.includes(:feature_subscriptions).order(created_at: :desc)
96
+ scope = scope.with_name_like(@filter_param) if @filter_param.present?
97
+ scope = filter_by_enabled_status(scope) if @enabled_param.present?
98
+ scope = filter_by_subscriptions_status(scope) if @subscriptions_param.present?
99
+ scope
100
+ end
101
+
102
+ def fetch_feature_subscriptions
103
+ feature_subscriptions = @feature_tag.feature_subscriptions.order(created_at: :desc)
104
+
105
+ if @feature_taggable_type.present?
106
+ feature_subscriptions = feature_subscriptions.where(feature_taggable_type: @feature_taggable_type)
107
+ end
108
+
109
+ if @filter_param.present?
110
+ feature_subscriptions = feature_subscriptions.where(feature_taggable_id: @filter_param.strip)
111
+ end
112
+
113
+ feature_subscriptions
114
+ end
115
+
116
+
117
+ def filter_by_enabled_status(scope)
118
+ enabled = ActiveModel::Type::Boolean.new.cast(@enabled_param)
119
+ enabled ? scope.enabled : scope.disabled
120
+ end
121
+
122
+ def filter_by_subscriptions_status(scope)
123
+ has_subscriptions = ActiveModel::Type::Boolean.new.cast(@subscriptions_param)
124
+ has_subscriptions ? scope.joins(:feature_subscriptions).distinct : scope.left_joins(:feature_subscriptions).where(feature_subscriptions: { id: nil })
125
+ end
126
+
127
+ def handle_success(action)
128
+ flash_notice = "Feature tag was successfully #{action}d."
129
+
130
+ respond_to do |format|
131
+ format.html { redirect_to feature_tags_path, notice: flash_notice }
132
+ format.turbo_stream { flash.now[:notice] = flash_notice }
133
+ end
134
+ end
135
+
136
+ def handle_record_not_found
137
+ flash[:error] = "Feature tag not found."
138
+ redirect_to pin_flags.feature_tags_path, status: :see_other
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,20 @@
1
+ module PinFlags
2
+ module ApplicationHelper
3
+ def flash_class(type)
4
+ case type.to_s
5
+ when "notice", "success"
6
+ "is-success"
7
+ when "alert", "error"
8
+ "is-danger"
9
+ when "warning"
10
+ "is-warning"
11
+ else
12
+ "is-info"
13
+ end
14
+ end
15
+
16
+ def page_title
17
+ content_for?(:title) ? content_for(:title) : "Dashboard"
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,23 @@
1
+ module PinFlags
2
+ module FeatureTags
3
+ module FeatureSubscriptionsHelper
4
+ def display_feature_subscription_table_turbo_frame_row_id(feature_subscription)
5
+ "feature_subscriptions_table_row_#{dom_id(feature_subscription)}"
6
+ end
7
+
8
+ def display_feature_subscription_row_delete_confirmation(feature_subscription)
9
+ taggable_type = strip_tags(feature_subscription.feature_taggable_type)
10
+ taggable_id = feature_subscription.feature_taggable_id
11
+
12
+ "Are you sure you want to delete the feature subscription for '#{taggable_type}' with ID '#{taggable_id}'?"
13
+ end
14
+
15
+ def display_feature_subscription_delete_button_aria_label(feature_subscription)
16
+ feature_taggable_type = strip_tags(feature_subscription.feature_taggable_type)
17
+ feature_taggable_id = feature_subscription.feature_taggable_id
18
+
19
+ "Delete Feature Subscription for #{feature_taggable_type} with ID #{feature_taggable_id}"
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,7 @@
1
+ module PinFlags
2
+ module FeatureTags
3
+ module ImportsHelper
4
+ def display_import_form_modal_id = "pin-flags-import-form-modal"
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,12 @@
1
+ module PinFlags
2
+ module FeatureTagsHelper
3
+ def display_feature_tag_table_turbo_frame_row_id(feature_tag)
4
+ "feature_tags_table_row_#{dom_id(feature_tag)}"
5
+ end
6
+
7
+ def display_feature_tag_row_delete_confirmation(feature_tag)
8
+ sanitized_name = strip_tags(feature_tag.name)
9
+ "Are you sure you want to delete the feature tag '#{sanitized_name}'?"
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,4 @@
1
+ module PinFlags
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,6 @@
1
+ module PinFlags
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: "from@example.com"
4
+ layout "mailer"
5
+ end
6
+ end
@@ -0,0 +1,39 @@
1
+ module PinFlags
2
+ module FeatureTaggable
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ has_many :feature_subscriptions,
7
+ class_name: "PinFlags::FeatureSubscription",
8
+ as: :feature_taggable,
9
+ dependent: :destroy
10
+
11
+ has_many :feature_tags,
12
+ class_name: "PinFlags::FeatureTag",
13
+ through: :feature_subscriptions
14
+ end
15
+
16
+ def feature_tag_pin(tag_name)
17
+ feature_tags << PinFlags::FeatureTag.find_or_create_by(name: tag_name)
18
+ rescue ActiveRecord::RecordNotUnique
19
+ feature_tags
20
+ end
21
+
22
+ def feature_tag_unpin(tag_name)
23
+ existing_tag = feature_tags.find_by(name: tag_name)
24
+ feature_tags.delete(existing_tag) if existing_tag
25
+ end
26
+
27
+ def feature_tag?(tag_name)
28
+ feature_tags.exists?(name: tag_name)
29
+ end
30
+
31
+ def feature_tag_enabled?(tag_name)
32
+ feature_tags.enabled.exists?(name: tag_name)
33
+ end
34
+
35
+ def feature_tag_disabled?(tag_name)
36
+ feature_tags.disabled.exists?(name: tag_name)
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,5 @@
1
+ module PinFlags
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,80 @@
1
+ class PinFlags::FeatureSubscription::BulkProcessor
2
+ include ActiveModel::Model
3
+ include ActiveModel::Attributes
4
+ include ActiveModel::Validations
5
+
6
+ attribute :feature_tag
7
+ attribute :feature_taggable_type, :string
8
+
9
+ attr_accessor :feature_taggable_ids
10
+
11
+ validates :feature_tag, presence: true
12
+ validates :feature_taggable_type, presence: true
13
+ validates :feature_taggable_ids, presence: true
14
+ validate :validate_feature_taggable_type_is_valid_class
15
+ validate :validate_feature_taggable_ids_exist
16
+
17
+ def initialize(attributes = {})
18
+ super
19
+ @feature_taggable_ids = Array(attributes[:feature_taggable_ids]) if attributes[:feature_taggable_ids]
20
+ end
21
+
22
+ def process
23
+ return false unless valid?
24
+
25
+ # Prepare records for upsert_all
26
+ records = sanitized_feature_taggable_ids.map do |feature_taggable_id|
27
+ {
28
+ feature_tag_id: feature_tag.id,
29
+ feature_taggable_type: feature_taggable_type,
30
+ feature_taggable_id: feature_taggable_id,
31
+ created_at: Time.current,
32
+ updated_at: Time.current
33
+ }
34
+ end
35
+
36
+ begin
37
+ # Use upsert_all to insert or update records in bulk
38
+ PinFlags::FeatureSubscription.upsert_all(
39
+ records,
40
+ unique_by: [ :feature_tag_id, :feature_taggable_type, :feature_taggable_id ]
41
+ )
42
+ true
43
+ rescue ActiveRecord::RecordInvalid, ActiveRecord::StatementInvalid => _e
44
+ false
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def sanitized_feature_taggable_ids
51
+ @sanitized_feature_taggable_ids ||= Array(feature_taggable_ids).map(&:strip)
52
+ end
53
+
54
+ def feature_taggable_class
55
+ @feature_taggable_class ||= feature_taggable_type.constantize
56
+ rescue NameError
57
+ nil
58
+ end
59
+
60
+ def validate_feature_taggable_type_is_valid_class
61
+ return if feature_taggable_type.blank?
62
+
63
+ if feature_taggable_class.nil?
64
+ errors.add(:feature_taggable_type, "is not a valid class")
65
+ end
66
+ end
67
+
68
+ def validate_feature_taggable_ids_exist
69
+ return if feature_taggable_ids.blank? || feature_taggable_class.nil?
70
+
71
+ existing_ids = feature_taggable_class.where(id: sanitized_feature_taggable_ids)
72
+ .pluck(:id)
73
+ .map(&:to_s)
74
+ invalid_ids = sanitized_feature_taggable_ids - existing_ids
75
+
76
+ if invalid_ids.any?
77
+ errors.add(:feature_taggable_ids, "contain non-existent IDs: #{invalid_ids.join(', ')}")
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,21 @@
1
+ module PinFlags
2
+ class FeatureSubscription < ApplicationRecord
3
+ self.table_name = "pin_flags_feature_subscriptions"
4
+
5
+ belongs_to :feature_tag
6
+ belongs_to :feature_taggable, polymorphic: true
7
+
8
+ validates :feature_taggable_id, uniqueness: {
9
+ scope: %i[feature_tag_id feature_taggable_type],
10
+ message: "already has this feature tag applied"
11
+ }
12
+
13
+ def self.create_in_bulk(feature_tag:, feature_taggable_type:, feature_taggable_ids:)
14
+ BulkProcessor.new(
15
+ feature_tag: feature_tag,
16
+ feature_taggable_type: feature_taggable_type,
17
+ feature_taggable_ids: feature_taggable_ids
18
+ ).process
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,44 @@
1
+ module PinFlags::FeatureTag::Cacheable
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ after_commit :clear_cache
6
+ end
7
+
8
+ class_methods do
9
+ def clear_tag_cache(tag_name)
10
+ normalized_name = normalize_tag_name(tag_name)
11
+ Rails.cache.delete("#{PinFlags.cache_prefix}:enabled:#{normalized_name}")
12
+ end
13
+
14
+ def clear_all_cache
15
+ Rails.cache.delete_matched("#{PinFlags.cache_prefix}:*")
16
+ end
17
+
18
+ def enabled?(tag_name)
19
+ normalized_name = normalize_tag_name(tag_name)
20
+ cache_key = "#{PinFlags.cache_prefix}:enabled:#{normalized_name}"
21
+
22
+ Rails.cache.fetch(cache_key, expires_in: PinFlags.cache_expiry) do
23
+ enabled.exists?(name: normalized_name)
24
+ end
25
+ end
26
+
27
+ def disabled?(tag_name)
28
+ !enabled?(tag_name)
29
+ end
30
+
31
+ private
32
+
33
+ def cache_key_for(tag_name)
34
+ normalized_name = normalize_tag_name(tag_name)
35
+ "#{PinFlags.cache_prefix}:enabled:#{normalized_name}"
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def clear_cache
42
+ self.class.clear_tag_cache(name)
43
+ end
44
+ end