Skip to content

Commit e3f52be

Browse files
committed
Add instance-level subscription support (#202)
Allow targets to subscribe to notifications from a specific notifiable instance, not just by notification key. For example, a user can subscribe to comment notifications on Post #1 only, similar to GitHub's issue subscription model. Changes: - Add notifiable polymorphic columns to subscriptions table (all ORMs) - Update unique constraint to include notifiable_type/notifiable_id - Extend find_subscription, find_or_create_subscription, and subscribes_to_notification? to accept optional notifiable: keyword - Add instance_subscription_targets to Notifiable concern - Merge instance subscription targets in notify with deduplication - Check instance-level subscriptions in generate_notification - Add key_level_only, instance_level_only, for_notifiable scopes - Add migration generator for existing installations - Permit notifiable_type/notifiable_id in subscription controllers - Fix Mongoid filtered_by_group ternary for coverage Breaking changes: - Migration required: new columns and updated unique index on subscriptions - Key-level subscription queries now filter by notifiable_type IS NULL
1 parent b8d8e26 commit e3f52be

22 files changed

Lines changed: 1062 additions & 67 deletions

File tree

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,28 @@
1+
## 2.6.0 / Unreleased
2+
[Full Changelog](http://github.com/simukappu/activity_notification/compare/v2.5.1...v2.6.0)
3+
4+
Enhancements:
5+
6+
* Add instance-level subscription support — subscribe to notifications from a specific notifiable instance (e.g., a particular Post or Issue), not just by notification key - [#202](https://github.com/simukappu/activity_notification/issues/202)
7+
* Add `notifiable_type` and `notifiable_id` polymorphic columns to the subscriptions table
8+
* Add `instance_subscription_targets` method to Notifiable concern for discovering targets with instance-level subscriptions
9+
* Add `key_level_only`, `instance_level_only`, and `for_notifiable` scopes to Subscription model (ActiveRecord and Mongoid)
10+
* Extend `find_subscription` and `find_or_create_subscription` to accept optional `notifiable:` keyword argument
11+
* Extend `subscribes_to_notification?` to accept optional `notifiable:` keyword argument for instance-level checks
12+
* Extend `generate_notification` to check instance-level subscriptions in addition to key-level subscriptions
13+
* Extend `notify` to merge instance subscription targets with `notification_targets`, with deduplication
14+
* Support all three ORMs: ActiveRecord, Mongoid, and Dynamoid
15+
* Add migration generator for existing installations: `rails generate activity_notification:add_notifiable_to_subscriptions`
16+
* Permit `notifiable_type` and `notifiable_id` parameters in subscription controllers
17+
18+
Breaking Changes:
19+
20+
* **Migration required**: The subscriptions table schema has changed. Existing installations **must** run a migration before upgrading. See the [Upgrade Guide](docs/Upgrade-to-2.6.md) for details.
21+
* New columns: `notifiable_type` (string, nullable) and `notifiable_id` (integer, nullable)
22+
* Unique index changed from `[:target_type, :target_id, :key]` to `[:target_type, :target_id, :key, :notifiable_type, :notifiable_id]`
23+
* Subscription uniqueness validation scope updated to include `notifiable_type` and `notifiable_id` across all ORMs
24+
* Key-level subscription queries now explicitly filter by `notifiable_type IS NULL` — this requires the new columns to exist in the database
25+
126
## 2.5.1 / 2026-01-03
227
[Full Changelog](http://github.com/simukappu/activity_notification/compare/v2.5.0...v2.5.1)
328

ai-docs/issues/202/design.md

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
# Issue #202: Instance-Level Subscriptions - Design
2+
3+
## Schema Changes
4+
5+
### Subscription Table
6+
7+
Add two nullable polymorphic columns to the `subscriptions` table:
8+
9+
```
10+
notifiable_type :string, index: true, null: true
11+
notifiable_id :bigint, index: true, null: true (or :string for Mongoid/Dynamoid)
12+
```
13+
14+
- `NULL` notifiable fields = key-level subscription (existing behavior)
15+
- Non-NULL notifiable fields = instance-level subscription
16+
17+
### Unique Constraint
18+
19+
Replace the existing unique index:
20+
```
21+
# Before
22+
add_index :subscriptions, [:target_type, :target_id, :key], unique: true
23+
24+
# After
25+
add_index :subscriptions, [:target_type, :target_id, :key, :notifiable_type, :notifiable_id],
26+
unique: true, name: 'index_subscriptions_uniqueness'
27+
```
28+
29+
This allows:
30+
- One key-level subscription per (target, key) where notifiable is NULL
31+
- One instance-level subscription per (target, key, notifiable) combination
32+
33+
**Note:** Most databases treat NULL as distinct in unique indexes, so `(user1, 'comment.default', NULL, NULL)` and `(user1, 'comment.default', 'Post', 1)` are considered different. For databases that don't, a partial/conditional index may be needed.
34+
35+
### Model Validations
36+
37+
Update uniqueness validation in all three ORM implementations:
38+
39+
```ruby
40+
# ActiveRecord
41+
validates :key, presence: true, uniqueness: { scope: [:target, :notifiable_type, :notifiable_id] }
42+
43+
# Mongoid
44+
validates :key, presence: true, uniqueness: { scope: [:target_type, :target_id, :notifiable_type, :notifiable_id] }
45+
46+
# Dynamoid
47+
validates :key, presence: true, uniqueness: { scope: :target_key }
48+
# (Dynamoid uses composite keys, needs separate handling)
49+
```
50+
51+
## Core Logic Changes
52+
53+
### 1. Subscription Model (All ORMs)
54+
55+
Add `belongs_to :notifiable` polymorphic association (optional):
56+
57+
```ruby
58+
# ActiveRecord
59+
belongs_to :notifiable, polymorphic: true, optional: true
60+
61+
# Mongoid
62+
belongs_to_polymorphic_xdb_record :notifiable, optional: true
63+
64+
# Dynamoid
65+
belongs_to_composite_xdb_record :notifiable, optional: true
66+
```
67+
68+
Add scopes for filtering:
69+
70+
```ruby
71+
scope :key_level_only, -> { where(notifiable_type: nil) }
72+
scope :instance_level_only, -> { where.not(notifiable_type: nil) }
73+
scope :for_notifiable, ->(notifiable) { where(notifiable_type: notifiable.class.name, notifiable_id: notifiable.id) }
74+
```
75+
76+
### 2. Subscriber Concern (`models/concerns/subscriber.rb`)
77+
78+
Update `_subscribes_to_notification?` to only check key-level subscriptions:
79+
80+
```ruby
81+
def _subscribes_to_notification?(key, subscribe_as_default = ...)
82+
evaluate_subscription(
83+
subscriptions.where(key: key, notifiable_type: nil).first,
84+
:subscribing?,
85+
subscribe_as_default
86+
)
87+
end
88+
```
89+
90+
Add new method for instance-level subscription check:
91+
92+
```ruby
93+
def _subscribes_to_notification_for_instance?(key, notifiable, subscribe_as_default = ...)
94+
instance_sub = subscriptions.where(key: key, notifiable_type: notifiable.class.name, notifiable_id: notifiable.id).first
95+
instance_sub.present? && instance_sub.subscribing?
96+
end
97+
```
98+
99+
Update `find_subscription` to support optional notifiable:
100+
101+
```ruby
102+
def find_subscription(key, notifiable: nil)
103+
if notifiable
104+
subscriptions.where(key: key, notifiable_type: notifiable.class.name, notifiable_id: notifiable.id).first
105+
else
106+
subscriptions.where(key: key, notifiable_type: nil).first
107+
end
108+
end
109+
```
110+
111+
### 3. Target Concern (`models/concerns/target.rb`)
112+
113+
Update `subscribes_to_notification?` to accept optional notifiable:
114+
115+
```ruby
116+
def subscribes_to_notification?(key, subscribe_as_default = ..., notifiable: nil)
117+
return true unless subscription_allowed?(key)
118+
_subscribes_to_notification?(key, subscribe_as_default) ||
119+
(notifiable.present? && _subscribes_to_notification_for_instance?(key, notifiable, subscribe_as_default))
120+
end
121+
```
122+
123+
### 4. Notification API (`apis/notification_api.rb`)
124+
125+
#### `generate_notification` - Add instance-level check
126+
127+
```ruby
128+
def generate_notification(target, notifiable, options = {})
129+
key = options[:key] || notifiable.default_notification_key
130+
if target.subscribes_to_notification?(key, notifiable: notifiable)
131+
store_notification(target, notifiable, key, options)
132+
end
133+
end
134+
```
135+
136+
This is the minimal change. The existing `subscribes_to_notification?` check stays in `generate_notification` (not moved to `notify`), and we extend it to also consider instance-level subscriptions.
137+
138+
#### `notify` - Add instance subscription targets
139+
140+
```ruby
141+
def notify(target_type, notifiable, options = {})
142+
if options[:notify_later]
143+
notify_later(target_type, notifiable, options)
144+
else
145+
targets = notifiable.notification_targets(target_type, options[:pass_full_options] ? options : options[:key])
146+
# Merge instance subscription targets, deduplicate
147+
instance_targets = notifiable.instance_subscription_targets(target_type, options[:key])
148+
targets = merge_targets(targets, instance_targets)
149+
unless targets_empty?(targets)
150+
notify_all(targets, notifiable, options)
151+
end
152+
end
153+
end
154+
```
155+
156+
#### New helper: `merge_targets`
157+
158+
```ruby
159+
def merge_targets(targets, instance_targets)
160+
return targets if instance_targets.blank?
161+
# Convert to array for deduplication
162+
all_targets = targets.respond_to?(:to_a) ? targets.to_a : Array(targets)
163+
(all_targets + instance_targets).uniq
164+
end
165+
```
166+
167+
### 5. Notifiable Concern (`models/concerns/notifiable.rb`)
168+
169+
Add `instance_subscription_targets`:
170+
171+
```ruby
172+
def instance_subscription_targets(target_type, key = nil)
173+
key ||= default_notification_key
174+
target_class_name = target_type.to_s.to_model_name
175+
Subscription.where(
176+
notifiable_type: self.class.name,
177+
notifiable_id: self.id,
178+
key: key,
179+
subscribing: true
180+
).where(target_type: target_class_name)
181+
.map(&:target)
182+
.compact
183+
end
184+
```
185+
186+
### 6. Subscription API (`apis/subscription_api.rb`)
187+
188+
Add `key_level_only` and `instance_level_only` scopes. No changes to existing subscribe/unsubscribe methods — they work on individual subscription records regardless of whether they're key-level or instance-level.
189+
190+
### 7. Controllers
191+
192+
Update `subscription_params` in `CommonController` to permit `notifiable_type` and `notifiable_id`.
193+
194+
Update `create` action to pass through notifiable params.
195+
196+
Update `find` action to support optional notifiable filtering.
197+
198+
## Async Path (`notify_later`)
199+
200+
The `notify_later` path serializes arguments and delegates to `NotifyJob`, which calls `notify` synchronously. Since our changes are in `notify` and `generate_notification`, the async path is automatically covered — no separate changes needed for `NotifyJob`.
201+
202+
## Migration Template
203+
204+
Update `lib/generators/templates/migrations/migration.rb` to include the new columns and updated index.
205+
206+
Provide a separate migration generator for existing installations:
207+
`lib/generators/activity_notification/migration/add_notifiable_to_subscriptions_generator.rb`
208+
209+
## Backward Compatibility
210+
211+
- All existing key-level subscriptions have `notifiable_type = NULL` and `notifiable_id = NULL`
212+
- `_subscribes_to_notification?` filters by `notifiable_type: nil`, so existing behavior is preserved
213+
- `subscribes_to_notification?` without `notifiable:` parameter returns the same result as before
214+
- `find_subscription(key)` without `notifiable:` returns key-level subscription as before

ai-docs/issues/202/requirements.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Issue #202: Instance-Level Subscriptions - Requirements
2+
3+
## Overview
4+
5+
Allow targets (e.g., users) to subscribe to notifications from a specific notifiable instance, not just by notification key. For example, a user can subscribe to comment notifications on Post #1 and Post #4 only, similar to GitHub's issue subscription model.
6+
7+
## Background
8+
9+
Currently, subscriptions are key-based only. A subscription record ties a target to a notification key (e.g., `comment.default`). When `subscribes_to_notification?(key)` is checked, it looks up the subscription by `(target, key)`. This is an all-or-nothing approach — you either subscribe to all notifications of a given key or none.
10+
11+
## Functional Requirements
12+
13+
### FR-1: Instance-Level Subscription Records
14+
- A target MUST be able to create a subscription scoped to a specific notifiable instance (e.g., Post #1) and key.
15+
- Instance-level subscriptions are stored in the same `subscriptions` table with additional `notifiable_type` and `notifiable_id` columns (nullable).
16+
- Existing key-only subscriptions (where `notifiable_type` and `notifiable_id` are NULL) MUST continue to work unchanged.
17+
18+
### FR-2: Subscription Check During Notification Generation
19+
- When generating a notification for a target, the system MUST check:
20+
1. Key-level subscription (existing behavior): Does the target subscribe to this key globally?
21+
2. Instance-level subscription (new): Does the target have an active instance-level subscription for this specific notifiable and key?
22+
- A notification MUST be generated if EITHER the key-level subscription allows it OR an active instance-level subscription exists for the notifiable.
23+
24+
### FR-3: Instance Subscription Targets Discovery
25+
- When `notify` is called for a notifiable, the system MUST also discover targets that have instance-level subscriptions for that specific notifiable, in addition to the targets returned by `notification_targets`.
26+
- Duplicate targets (appearing in both `notification_targets` and instance subscriptions) MUST be deduplicated.
27+
28+
### FR-4: Async Path Support
29+
- Instance-level subscriptions MUST work with both synchronous (`notify`) and asynchronous (`notify_later`) notification paths.
30+
31+
### FR-5: Multi-ORM Support
32+
- Instance-level subscriptions MUST work with all three supported ORMs: ActiveRecord, Mongoid, and Dynamoid.
33+
34+
### FR-6: API and Controller Support
35+
- The subscription API and controllers MUST support creating, finding, and managing instance-level subscriptions.
36+
- The `create` action MUST accept optional `notifiable_type` and `notifiable_id` parameters.
37+
- The `find` action MUST support finding subscriptions by key and optionally by notifiable.
38+
39+
### FR-7: Backward Compatibility
40+
- All existing subscription behavior MUST remain unchanged.
41+
- Existing subscriptions without notifiable fields MUST continue to function as key-level subscriptions.
42+
- The unique constraint MUST be updated to accommodate both key-level and instance-level subscriptions.
43+
44+
## Non-Functional Requirements
45+
46+
### NFR-1: Performance
47+
- Instance-level subscription checks MUST NOT introduce N+1 query problems.
48+
- The implementation SHOULD batch-load instance subscriptions where possible.
49+
50+
### NFR-2: Test Coverage
51+
- Test coverage MUST NOT decrease from the current level (~99.7%).
52+
- New functionality MUST have comprehensive test coverage including edge cases.
53+
54+
### NFR-3: Migration
55+
- A migration generator or template MUST be provided for adding the new columns.
56+
- The migration MUST be safe to run on existing installations (additive only, nullable columns).

ai-docs/issues/202/tasks.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Issue #202: Instance-Level Subscriptions - Tasks
2+
3+
## Task 1: Schema & Model Changes (ActiveRecord) ✅
4+
- [x] Add `notifiable_type` (string, nullable) and `notifiable_id` (bigint, nullable) to subscriptions table in migration template
5+
- [x] Update unique index from `[:target_type, :target_id, :key]` to `[:target_type, :target_id, :key, :notifiable_type, :notifiable_id]`
6+
- [x] Add `belongs_to :notifiable, polymorphic: true, optional: true` to ActiveRecord Subscription model
7+
- [x] Update uniqueness validation to `scope: [:target_type, :target_id, :notifiable_type, :notifiable_id]`
8+
- [x] Add scopes: `key_level_only`, `instance_level_only`, `for_notifiable`
9+
- [x] Update spec/rails_app migration
10+
11+
## Task 2: Schema & Model Changes (Mongoid) ✅
12+
- [x] Add `belongs_to_polymorphic_xdb_record :notifiable` (optional) — creates notifiable_type/notifiable_id fields
13+
- [x] Update uniqueness validation scope to include notifiable fields
14+
- [x] Add scopes: `key_level_only`, `instance_level_only`, `for_notifiable`
15+
16+
## Task 3: Schema & Model Changes (Dynamoid) ✅
17+
- [x] Add `belongs_to_composite_xdb_record :notifiable` (optional) — creates notifiable_key composite field
18+
- [x] Uniqueness validation uses composite target_key (unchanged, Dynamoid-specific)
19+
20+
## Task 4: Subscriber Concern Updates ✅
21+
- [x] Update `_subscribes_to_notification?` to filter by key-level only (notifiable_type: nil)
22+
- [x] Add `_subscribes_to_notification_for_instance?(key, notifiable)` method
23+
- [x] Update `_subscribes_to_notification_email?` to filter by key-level only
24+
- [x] Update `_subscribes_to_optional_target?` to filter by key-level only
25+
- [x] Update `find_subscription` to accept optional `notifiable:` keyword argument
26+
- [x] Update `find_or_create_subscription` to accept optional `notifiable:` keyword argument
27+
- [x] All methods handle Dynamoid composite key format
28+
29+
## Task 5: Target Concern Updates ✅
30+
- [x] Update `subscribes_to_notification?` to accept optional `notifiable:` keyword and check both key-level and instance-level
31+
32+
## Task 6: Notification API Updates ✅
33+
- [x] Update `generate_notification` to pass `notifiable` to `subscribes_to_notification?`
34+
- [x] Update `notify` to merge instance subscription targets with deduplication
35+
- [x] Add `merge_targets` private helper method
36+
37+
## Task 7: Notifiable Concern Updates ✅
38+
- [x] Add `instance_subscription_targets(target_type, key)` method with ORM-aware queries
39+
40+
## Task 8: Controller & API Updates ✅
41+
- [x] Update `subscription_params` in SubscriptionsController to permit `notifiable_type` and `notifiable_id`
42+
43+
## Task 9: Migration Generator ✅
44+
- [x] Update `lib/generators/templates/migrations/migration.rb` for new installations
45+
- [x] Create `add_notifiable_to_subscriptions` migration generator for existing installations
46+
47+
## Task 10: Tests ✅
48+
- [x] Add instance-level subscription model tests (find, create, uniqueness)
49+
- [x] Add `subscribes_to_notification?` tests with notifiable parameter
50+
- [x] Add notification generation tests with instance subscriptions
51+
- [x] Add `instance_subscription_targets` tests
52+
- [x] Add deduplication tests for notify with instance subscription targets
53+
- [x] Verify all existing tests still pass (1815 examples, 0 failures)

app/controllers/activity_notification/subscriptions_controller.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ def subscription_params
191191
params[:subscription][:optional_targets][optional_target_key] = boolean_value
192192
end
193193
end
194-
params.require(:subscription).permit(:key, :subscribing, :subscribing_to_email, optional_targets: optional_target_keys)
194+
params.require(:subscription).permit(:key, :subscribing, :subscribing_to_email, :notifiable_type, :notifiable_id, optional_targets: optional_target_keys)
195195
end
196196

197197
# Sets options to load subscription index from request parameters.

0 commit comments

Comments
 (0)