activity_notification 2.3.2 → 2.4.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/build.yml +9 -36
  3. data/CHANGELOG.md +26 -1
  4. data/Gemfile +1 -1
  5. data/README.md +9 -1
  6. data/activity_notification.gemspec +5 -5
  7. data/ai-curated-specs/issues/172/design.md +220 -0
  8. data/ai-curated-specs/issues/172/tasks.md +326 -0
  9. data/ai-curated-specs/issues/188/design.md +227 -0
  10. data/ai-curated-specs/issues/188/requirements.md +78 -0
  11. data/ai-curated-specs/issues/188/tasks.md +203 -0
  12. data/ai-curated-specs/issues/188/upstream-contributions.md +592 -0
  13. data/ai-curated-specs/issues/50/design.md +235 -0
  14. data/ai-curated-specs/issues/50/requirements.md +49 -0
  15. data/ai-curated-specs/issues/50/tasks.md +232 -0
  16. data/app/controllers/activity_notification/notifications_api_controller.rb +22 -0
  17. data/app/controllers/activity_notification/notifications_controller.rb +27 -1
  18. data/app/mailers/activity_notification/mailer.rb +2 -2
  19. data/app/views/activity_notification/notifications/default/_index.html.erb +6 -1
  20. data/app/views/activity_notification/notifications/default/destroy_all.js.erb +6 -0
  21. data/docs/Setup.md +43 -6
  22. data/gemfiles/Gemfile.rails-7.0 +2 -0
  23. data/gemfiles/Gemfile.rails-7.2 +0 -2
  24. data/gemfiles/Gemfile.rails-8.0 +24 -0
  25. data/lib/activity_notification/apis/notification_api.rb +51 -2
  26. data/lib/activity_notification/controllers/concerns/swagger/notifications_api.rb +59 -0
  27. data/lib/activity_notification/helpers/view_helpers.rb +28 -0
  28. data/lib/activity_notification/mailers/helpers.rb +14 -7
  29. data/lib/activity_notification/models/concerns/target.rb +16 -0
  30. data/lib/activity_notification/models.rb +1 -1
  31. data/lib/activity_notification/notification_resilience.rb +115 -0
  32. data/lib/activity_notification/orm/dynamoid/extension.rb +4 -87
  33. data/lib/activity_notification/orm/dynamoid/notification.rb +19 -2
  34. data/lib/activity_notification/orm/dynamoid.rb +42 -6
  35. data/lib/activity_notification/rails/routes.rb +3 -2
  36. data/lib/activity_notification/version.rb +1 -1
  37. data/lib/activity_notification.rb +1 -0
  38. data/lib/generators/templates/controllers/notifications_api_controller.rb +5 -0
  39. data/lib/generators/templates/controllers/notifications_api_with_devise_controller.rb +5 -0
  40. data/lib/generators/templates/controllers/notifications_controller.rb +5 -0
  41. data/lib/generators/templates/controllers/notifications_with_devise_controller.rb +5 -0
  42. data/spec/concerns/apis/notification_api_spec.rb +161 -5
  43. data/spec/concerns/models/target_spec.rb +7 -0
  44. data/spec/controllers/controller_spec_utility.rb +1 -1
  45. data/spec/controllers/notifications_api_controller_shared_examples.rb +113 -0
  46. data/spec/controllers/notifications_controller_shared_examples.rb +150 -0
  47. data/spec/helpers/view_helpers_spec.rb +14 -0
  48. data/spec/jobs/notification_resilience_job_spec.rb +167 -0
  49. data/spec/mailers/notification_resilience_spec.rb +263 -0
  50. data/spec/models/notification_spec.rb +1 -1
  51. data/spec/models/subscription_spec.rb +1 -1
  52. data/spec/rails_app/app/helpers/devise_helper.rb +2 -0
  53. data/spec/rails_app/config/application.rb +1 -0
  54. data/spec/rails_app/config/initializers/zeitwerk.rb +10 -0
  55. metadata +67 -53
@@ -0,0 +1,592 @@
1
+ # Upstream Contributions for Dynamoid
2
+
3
+ This document outlines the improvements made to ActivityNotification's Dynamoid integration that could be contributed back to the Dynamoid project.
4
+
5
+ ## 1. None Method Implementation
6
+
7
+ ### Overview
8
+ The `none()` method provides an empty query result set, similar to ActiveRecord's `none` method. This is useful for conditional queries and maintaining consistent interfaces.
9
+
10
+ ### Implementation
11
+
12
+ ```ruby
13
+ module Dynamoid
14
+ module Criteria
15
+ class None < Chain
16
+ def ==(other)
17
+ other.is_a?(None)
18
+ end
19
+
20
+ def records
21
+ []
22
+ end
23
+
24
+ def count
25
+ 0
26
+ end
27
+
28
+ def delete_all
29
+ end
30
+
31
+ def empty?
32
+ true
33
+ end
34
+ end
35
+
36
+ class Chain
37
+ # Return new none object
38
+ def none
39
+ None.new(self.source)
40
+ end
41
+ end
42
+
43
+ module ClassMethods
44
+ define_method(:none) do |*args, &blk|
45
+ chain = Dynamoid::Criteria::Chain.new(self)
46
+ chain.send(:none, *args, &blk)
47
+ end
48
+ end
49
+ end
50
+ end
51
+ ```
52
+
53
+ ### Benefits
54
+ - Provides consistent API with ActiveRecord
55
+ - Enables conditional query building without complex logic
56
+ - Returns predictable empty results for edge cases
57
+ - Maintains chainable query interface
58
+
59
+ ### Usage Examples
60
+ ```ruby
61
+ # Conditional queries
62
+ users = condition ? User.where(active: true) : User.none
63
+
64
+ # Default empty state
65
+ def search_results(query)
66
+ return User.none if query.blank?
67
+ User.where(name: query)
68
+ end
69
+ ```
70
+
71
+ ### Tests
72
+ ```ruby
73
+ describe "none method" do
74
+ it "returns empty results" do
75
+ expect(User.none.count).to eq(0)
76
+ expect(User.none.to_a).to eq([])
77
+ expect(User.none).to be_empty
78
+ end
79
+
80
+ it "is chainable" do
81
+ expect(User.where(active: true).none.count).to eq(0)
82
+ end
83
+ end
84
+ ```
85
+
86
+ ## 2. Limit Method Implementation
87
+
88
+ ### Overview
89
+ The `limit()` method provides a more intuitive alias for Dynamoid's `record_limit()` method, matching ActiveRecord's interface and improving developer experience.
90
+
91
+ ### Implementation
92
+
93
+ ```ruby
94
+ module Dynamoid
95
+ module Criteria
96
+ class Chain
97
+ # Set query result limit as record_limit of Dynamoid
98
+ # @scope class
99
+ # @param [Integer] limit Query result limit as record_limit
100
+ # @return [Dynamoid::Criteria::Chain] Database query of filtered notifications or subscriptions
101
+ def limit(limit)
102
+ record_limit(limit)
103
+ end
104
+ end
105
+ end
106
+ end
107
+ ```
108
+
109
+ ### Benefits
110
+ - Provides familiar ActiveRecord-style method name
111
+ - Improves code readability and developer experience
112
+ - Maintains backward compatibility with existing `record_limit` method
113
+ - Reduces cognitive load when switching between ORMs
114
+
115
+ ### Usage Examples
116
+ ```ruby
117
+ # More intuitive than record_limit(10)
118
+ User.limit(10)
119
+
120
+ # Chainable with other methods
121
+ User.where(active: true).limit(5)
122
+
123
+ # Consistent with ActiveRecord patterns
124
+ def recent_users(count = 10)
125
+ User.where(created_at: Time.current.beginning_of_day..).limit(count)
126
+ end
127
+ ```
128
+
129
+ ### Tests
130
+ ```ruby
131
+ describe "limit method" do
132
+ it "limits query results" do
133
+ create_list(:user, 20)
134
+ expect(User.limit(5).count).to eq(5)
135
+ end
136
+
137
+ it "is chainable" do
138
+ create_list(:user, 20, active: true)
139
+ result = User.where(active: true).limit(3)
140
+ expect(result.count).to eq(3)
141
+ end
142
+
143
+ it "behaves identically to record_limit" do
144
+ create_list(:user, 10)
145
+ expect(User.limit(5).to_a).to eq(User.record_limit(5).to_a)
146
+ end
147
+ end
148
+ ```
149
+
150
+ ## 3. Exists? Method Implementation
151
+
152
+ ### Overview
153
+ The `exists?()` method provides an efficient way to check if any records match the current query criteria without loading the actual records, similar to ActiveRecord's `exists?` method.
154
+
155
+ ### Implementation
156
+
157
+ ```ruby
158
+ module Dynamoid
159
+ module Criteria
160
+ class Chain
161
+ # Return if records exist
162
+ # @scope class
163
+ # @return [Boolean] If records exist
164
+ def exists?
165
+ record_limit(1).count > 0
166
+ end
167
+ end
168
+ end
169
+ end
170
+ ```
171
+
172
+ ### Benefits
173
+ - Provides efficient existence checking without loading full records
174
+ - Matches ActiveRecord's interface for consistency
175
+ - Optimizes performance by limiting query to single record
176
+ - Enables cleaner conditional logic in applications
177
+
178
+ ### Performance Considerations
179
+ - Uses `record_limit(1)` to minimize data transfer
180
+ - Only performs count operation, not full record retrieval
181
+ - Significantly faster than loading all records just to check existence
182
+
183
+ ### Usage Examples
184
+ ```ruby
185
+ # Check if any active users exist
186
+ if User.where(active: true).exists?
187
+ # Process active users
188
+ end
189
+
190
+ # Conditional processing
191
+ def process_notifications
192
+ return unless Notification.where(unread: true).exists?
193
+ # Process unread notifications
194
+ end
195
+
196
+ # Validation logic
197
+ def validate_unique_email
198
+ errors.add(:email, 'already taken') if User.where(email: email).exists?
199
+ end
200
+ ```
201
+
202
+ ### Tests
203
+ ```ruby
204
+ describe "exists? method" do
205
+ it "returns true when records exist" do
206
+ create(:user, active: true)
207
+ expect(User.where(active: true).exists?).to be true
208
+ end
209
+
210
+ it "returns false when no records exist" do
211
+ expect(User.where(active: true).exists?).to be false
212
+ end
213
+
214
+ it "is efficient and doesn't load full records" do
215
+ create_list(:user, 100, active: true)
216
+
217
+ # Should be much faster than loading all records
218
+ expect {
219
+ User.where(active: true).exists?
220
+ }.to perform_faster_than {
221
+ User.where(active: true).to_a.any?
222
+ }
223
+ end
224
+ end
225
+ ```
226
+
227
+ ## 4. Update_all Method Implementation
228
+
229
+ ### Overview
230
+ The `update_all()` method provides batch update functionality for Dynamoid queries, similar to ActiveRecord's `update_all` method. This enables efficient bulk updates without loading individual records.
231
+
232
+ ### Implementation
233
+
234
+ ```ruby
235
+ module Dynamoid
236
+ module Criteria
237
+ class Chain
238
+ # Update all records matching the current criteria
239
+ # TODO: Make this batch operation more efficient
240
+ def update_all(conditions = {})
241
+ each do |document|
242
+ document.update_attributes(conditions)
243
+ end
244
+ end
245
+ end
246
+ end
247
+ end
248
+ ```
249
+
250
+ ### Benefits
251
+ - Provides familiar ActiveRecord-style batch update interface
252
+ - Enables bulk operations on query results
253
+ - Maintains consistency with other ORM patterns
254
+ - Simplifies common bulk update scenarios
255
+
256
+ ### Current Implementation Notes
257
+ - Current implementation iterates through each record individually
258
+ - Future optimization could implement true batch operations
259
+ - Maintains compatibility with existing Dynamoid update patterns
260
+
261
+ ### Usage Examples
262
+ ```ruby
263
+ # Bulk status updates
264
+ User.where(active: false).update_all(status: 'inactive')
265
+
266
+ # Batch timestamp updates
267
+ Notification.where(read: false).update_all(updated_at: Time.current)
268
+
269
+ # Conditional bulk updates
270
+ def mark_old_notifications_as_read
271
+ Notification.where(created_at: ..1.week.ago).update_all(read: true)
272
+ end
273
+ ```
274
+
275
+ ### Future Optimization Opportunities
276
+ ```ruby
277
+ # Potential batch implementation using DynamoDB batch operations
278
+ def update_all(conditions = {})
279
+ # Group updates into batches of 25 (DynamoDB limit)
280
+ all.each_slice(25) do |batch|
281
+ batch_requests = batch.map do |document|
282
+ {
283
+ update_item: {
284
+ table_name: document.class.table_name,
285
+ key: document.key,
286
+ update_expression: build_update_expression(conditions),
287
+ expression_attribute_values: conditions
288
+ }
289
+ }
290
+ end
291
+
292
+ dynamodb_client.batch_write_item(request_items: {
293
+ document.class.table_name => batch_requests
294
+ })
295
+ end
296
+ end
297
+ ```
298
+
299
+ ### Tests
300
+ ```ruby
301
+ describe "update_all method" do
302
+ it "updates all matching records" do
303
+ users = create_list(:user, 5, active: true)
304
+ User.where(active: true).update_all(status: 'updated')
305
+
306
+ users.each(&:reload)
307
+ expect(users.map(&:status)).to all(eq('updated'))
308
+ end
309
+
310
+ it "works with empty result sets" do
311
+ expect {
312
+ User.where(active: false).update_all(status: 'updated')
313
+ }.not_to raise_error
314
+ end
315
+
316
+ it "updates only matching records" do
317
+ active_users = create_list(:user, 3, active: true)
318
+ inactive_users = create_list(:user, 2, active: false)
319
+
320
+ User.where(active: true).update_all(status: 'updated')
321
+
322
+ active_users.each(&:reload)
323
+ inactive_users.each(&:reload)
324
+
325
+ expect(active_users.map(&:status)).to all(eq('updated'))
326
+ expect(inactive_users.map(&:status)).to all(be_nil)
327
+ end
328
+ end
329
+ ```
330
+
331
+ ## 5. Null Operator Extensions
332
+
333
+ ### Overview
334
+ Enhanced null value handling in Dynamoid queries, providing more intuitive ways to query for null and non-null values. This improves the developer experience when working with optional attributes.
335
+
336
+ ### Implementation Context
337
+ The null operator extensions are primarily used within the UniquenessValidator but demonstrate a pattern that could be useful throughout Dynamoid:
338
+
339
+ ```ruby
340
+ # From UniquenessValidator implementation
341
+ def filter_criteria(criteria, document, attribute)
342
+ value = document.read_attribute(attribute)
343
+ value.nil? ? criteria.where("#{attribute}.null" => true) : criteria.where(attribute => value)
344
+ end
345
+ ```
346
+
347
+ ### Benefits
348
+ - Provides intuitive null value querying
349
+ - Improves validation logic for optional fields
350
+ - Enables more expressive query conditions
351
+ - Maintains consistency with DynamoDB's null handling
352
+
353
+ ### Usage Examples
354
+ ```ruby
355
+ # Query for records with null values
356
+ User.where("email.null" => true)
357
+
358
+ # Query for records with non-null values
359
+ User.where("email.null" => false)
360
+
361
+ # In validation contexts
362
+ def validate_uniqueness_with_nulls
363
+ scope_criteria = base_criteria
364
+ if email.nil?
365
+ scope_criteria.where("email.null" => true)
366
+ else
367
+ scope_criteria.where(email: email)
368
+ end
369
+ end
370
+ ```
371
+
372
+ ### Potential Extensions
373
+ ```ruby
374
+ module Dynamoid
375
+ module Criteria
376
+ class Chain
377
+ # Add convenience methods for null queries
378
+ def where_null(attribute)
379
+ where("#{attribute}.null" => true)
380
+ end
381
+
382
+ def where_not_null(attribute)
383
+ where("#{attribute}.null" => false)
384
+ end
385
+ end
386
+ end
387
+ end
388
+ ```
389
+
390
+ ### Tests
391
+ ```ruby
392
+ describe "null operator extensions" do
393
+ it "finds records with null values" do
394
+ user_with_email = create(:user, email: 'test@example.com')
395
+ user_without_email = create(:user, email: nil)
396
+
397
+ results = User.where("email.null" => true)
398
+ expect(results).to include(user_without_email)
399
+ expect(results).not_to include(user_with_email)
400
+ end
401
+
402
+ it "finds records with non-null values" do
403
+ user_with_email = create(:user, email: 'test@example.com')
404
+ user_without_email = create(:user, email: nil)
405
+
406
+ results = User.where("email.null" => false)
407
+ expect(results).to include(user_with_email)
408
+ expect(results).not_to include(user_without_email)
409
+ end
410
+ end
411
+ ```
412
+
413
+ ## 6. Uniqueness Validator Implementation
414
+
415
+ ### Overview
416
+ A comprehensive UniquenessValidator for Dynamoid that provides ActiveRecord-style uniqueness validation with support for scoped validation and null value handling.
417
+
418
+ ### Implementation
419
+
420
+ ```ruby
421
+ module Dynamoid
422
+ module Validations
423
+ # Validates whether or not a field is unique against the records in the database.
424
+ class UniquenessValidator < ActiveModel::EachValidator
425
+ # Validate the document for uniqueness violations.
426
+ # @param [Document] document The document to validate.
427
+ # @param [Symbol] attribute The name of the attribute.
428
+ # @param [Object] value The value of the object.
429
+ def validate_each(document, attribute, value)
430
+ return unless validation_required?(document, attribute)
431
+ if not_unique?(document, attribute, value)
432
+ error_options = options.except(:scope).merge(value: value)
433
+ document.errors.add(attribute, :taken, **error_options)
434
+ end
435
+ end
436
+
437
+ private
438
+
439
+ # Are we required to validate the document?
440
+ # @api private
441
+ def validation_required?(document, attribute)
442
+ document.new_record? ||
443
+ document.send("attribute_changed?", attribute.to_s) ||
444
+ scope_value_changed?(document)
445
+ end
446
+
447
+ # Scope reference has changed?
448
+ # @api private
449
+ def scope_value_changed?(document)
450
+ Array.wrap(options[:scope]).any? do |item|
451
+ document.send("attribute_changed?", item.to_s)
452
+ end
453
+ end
454
+
455
+ # Check whether a record is uniqueness.
456
+ # @api private
457
+ def not_unique?(document, attribute, value)
458
+ klass = document.class
459
+ while klass.superclass.respond_to?(:validators) && klass.superclass.validators.include?(self)
460
+ klass = klass.superclass
461
+ end
462
+ criteria = create_criteria(klass, document, attribute, value)
463
+ criteria.exists?
464
+ end
465
+
466
+ # Create the validation criteria.
467
+ # @api private
468
+ def create_criteria(base, document, attribute, value)
469
+ criteria = scope(base, document)
470
+ filter_criteria(criteria, document, attribute)
471
+ end
472
+
473
+ # @api private
474
+ def scope(criteria, document)
475
+ Array.wrap(options[:scope]).each do |item|
476
+ criteria = filter_criteria(criteria, document, item)
477
+ end
478
+ criteria
479
+ end
480
+
481
+ # Filter the criteria.
482
+ # @api private
483
+ def filter_criteria(criteria, document, attribute)
484
+ value = document.read_attribute(attribute)
485
+ value.nil? ? criteria.where("#{attribute}.null" => true) : criteria.where(attribute => value)
486
+ end
487
+ end
488
+ end
489
+ end
490
+ ```
491
+
492
+ ### Benefits
493
+ - Provides ActiveRecord-compatible uniqueness validation
494
+ - Supports scoped uniqueness validation
495
+ - Handles null values correctly
496
+ - Optimizes validation by only checking when necessary
497
+ - Supports inheritance hierarchies
498
+
499
+ ### Key Features
500
+ 1. **Conditional Validation**: Only validates when record is new or attribute has changed
501
+ 2. **Scope Support**: Validates uniqueness within specified scopes
502
+ 3. **Null Handling**: Properly handles null values in uniqueness checks
503
+ 4. **Inheritance Support**: Works correctly with model inheritance
504
+ 5. **Performance Optimization**: Uses `exists?` method for efficient checking
505
+
506
+ ### Usage Examples
507
+ ```ruby
508
+ class User
509
+ include Dynamoid::Document
510
+
511
+ field :email, :string
512
+ field :username, :string
513
+ field :organization_id, :string
514
+
515
+ # Basic uniqueness validation
516
+ validates :email, uniqueness: true
517
+
518
+ # Scoped uniqueness validation
519
+ validates :username, uniqueness: { scope: :organization_id }
520
+
521
+ # With custom error message
522
+ validates :email, uniqueness: { message: 'is already registered' }
523
+ end
524
+
525
+ # Usage in models
526
+ user = User.new(email: 'existing@example.com')
527
+ user.valid? # => false
528
+ user.errors[:email] # => ["has already been taken"]
529
+ ```
530
+
531
+ ### Tests
532
+ ```ruby
533
+ describe "UniquenessValidator" do
534
+ describe "basic uniqueness" do
535
+ it "validates uniqueness of email" do
536
+ create(:user, email: 'test@example.com')
537
+ duplicate = build(:user, email: 'test@example.com')
538
+
539
+ expect(duplicate).not_to be_valid
540
+ expect(duplicate.errors[:email]).to include('has already been taken')
541
+ end
542
+
543
+ it "allows unique emails" do
544
+ create(:user, email: 'test1@example.com')
545
+ unique = build(:user, email: 'test2@example.com')
546
+
547
+ expect(unique).to be_valid
548
+ end
549
+ end
550
+
551
+ describe "scoped uniqueness" do
552
+ it "validates uniqueness within scope" do
553
+ org1 = create(:organization)
554
+ org2 = create(:organization)
555
+
556
+ create(:user, username: 'john', organization: org1)
557
+
558
+ # Same username in different org should be valid
559
+ user_different_org = build(:user, username: 'john', organization: org2)
560
+ expect(user_different_org).to be_valid
561
+
562
+ # Same username in same org should be invalid
563
+ user_same_org = build(:user, username: 'john', organization: org1)
564
+ expect(user_same_org).not_to be_valid
565
+ end
566
+ end
567
+
568
+ describe "null value handling" do
569
+ it "allows multiple records with null values" do
570
+ create(:user, email: nil)
571
+ user_with_null = build(:user, email: nil)
572
+
573
+ expect(user_with_null).to be_valid
574
+ end
575
+ end
576
+
577
+ describe "performance optimization" do
578
+ it "only validates when necessary" do
579
+ user = create(:user, email: 'test@example.com')
580
+
581
+ # Should not validate when no changes
582
+ expect(User).not_to receive(:where)
583
+ user.valid?
584
+
585
+ # Should validate when email changes
586
+ user.email = 'new@example.com'
587
+ expect(User).to receive(:where).and_call_original
588
+ user.valid?
589
+ end
590
+ end
591
+ end
592
+ ```