Skip to content

Commit 40d8eb0

Browse files
authored
Merge branch 'main' into dependabot/bundler/aws-sdk-s3-1.199.0
2 parents 58f45d5 + e15eb68 commit 40d8eb0

File tree

24 files changed

+1237
-52
lines changed

24 files changed

+1237
-52
lines changed

app/controllers/better_together/notifications_controller.rb

Lines changed: 83 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,32 +13,84 @@ def index
1313
@unread_count = helpers.current_person.notifications.unread.size
1414
end
1515

16+
def dropdown # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
17+
# Get basic info needed for cache key (minimal queries)
18+
max_updated_at = helpers.current_person.notifications.maximum(:updated_at)
19+
unread_count = helpers.current_person.notifications.unread.size
20+
21+
# Create cache key based on max updated_at of user's notifications
22+
cache_key = "notifications_dropdown/#{helpers.current_person.id}/#{max_updated_at&.to_i}/#{unread_count}"
23+
24+
cached_content = Rails.cache.fetch(cache_key, expires_in: 1.hour) do
25+
# Only fetch detailed data when cache misses
26+
notifications = helpers.recent_notifications
27+
28+
# Warm up fragment caches for individual notifications in background
29+
warm_notification_fragment_caches(notifications) if Rails.env.production?
30+
31+
render_to_string(
32+
partial: 'better_together/notifications/dropdown_content',
33+
locals: { notifications: notifications, unread_count: unread_count }
34+
)
35+
end
36+
37+
render html: cached_content.html_safe
38+
end
39+
1640
# TODO: Make a Stimulus controller to dispatch this action async when messages are viewed
17-
def mark_as_read # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
41+
def mark_as_read
42+
process_mark_as_read_request
43+
44+
respond_to do |format|
45+
format.html { redirect_to notifications_path }
46+
format.turbo_stream { render_turbo_stream_response }
47+
end
48+
end
49+
50+
private
51+
52+
def process_mark_as_read_request
1853
if params[:id]
1954
mark_notification_as_read(params[:id])
2055
elsif params[:record_id]
2156
mark_record_notification_as_read(params[:record_id])
2257
else
2358
helpers.current_person.notifications.unread.update_all(read_at: Time.current)
2459
end
60+
end
2561

26-
respond_to do |format|
27-
format.html { redirect_to notifications_path }
28-
format.turbo_stream do
29-
if @notification
30-
render turbo_stream: turbo_stream.replace(helpers.dom_id(@notification), @notification)
31-
else
32-
render turbo_stream: turbo_stream.replace(
33-
'notifications',
34-
partial: 'better_together/notifications/notifications',
35-
locals: { notifications: helpers.current_person.notifications, unread_count: 0 }
36-
)
37-
end
38-
end
62+
def render_turbo_stream_response
63+
if @notification
64+
render_notification_turbo_stream
65+
else
66+
render_notifications_list_turbo_stream
3967
end
4068
end
4169

70+
def render_notification_turbo_stream
71+
render turbo_stream: turbo_stream.replace(
72+
helpers.dom_id(@notification),
73+
partial: 'better_together/notifications/notification',
74+
locals: notification_locals
75+
)
76+
end
77+
78+
def render_notifications_list_turbo_stream
79+
render turbo_stream: turbo_stream.replace(
80+
'notifications',
81+
partial: 'better_together/notifications/notifications',
82+
locals: { notifications: helpers.current_person.notifications, unread_count: 0 }
83+
)
84+
end
85+
86+
def notification_locals
87+
{
88+
notification: @notification,
89+
notification_title: @notification.event.record&.try(:title) || 'Notification',
90+
notification_url: @notification.try(:url) || '#'
91+
}
92+
end
93+
4294
def mark_notification_as_read(id)
4395
@notification = helpers.current_person.notifications.find(id)
4496
@notification.update(read_at: Time.current)
@@ -47,5 +99,22 @@ def mark_notification_as_read(id)
4799
def mark_record_notification_as_read(id)
48100
mark_notifications_read_for_record_id(id)
49101
end
102+
103+
# Warm fragment caches for notifications to improve subsequent renders
104+
def warm_notification_fragment_caches(notifications)
105+
notifications.each do |notification|
106+
next unless helpers.should_cache_notification?(notification)
107+
108+
# Pre-warm the fragment cache keys
109+
fragment_key = helpers.notification_fragment_cache_key(notification)
110+
type_key = helpers.notification_type_fragment_cache_key(notification)
111+
112+
# Check if fragments are already cached to avoid unnecessary work
113+
unless Rails.cache.exist?(fragment_key) && Rails.cache.exist?(type_key)
114+
# This could be moved to a background job for better performance
115+
Rails.logger.debug "Warming fragment cache for notification #{notification.id}"
116+
end
117+
end
118+
end
50119
end
51120
end

app/helpers/better_together/notifications_helper.rb

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,67 @@ def unread_notification_count
2424
def recent_notifications
2525
current_person.notifications.joins(:event).order(created_at: :desc).limit(5)
2626
end
27+
28+
# Fragment cache key for notification types
29+
def notification_fragment_cache_key(notification)
30+
# Base cache key with notification's cache_key_with_version
31+
base_key = [notification.cache_key_with_version]
32+
33+
# Add related record version if it exists and has cache_key_with_version
34+
if notification.event&.record.respond_to?(:cache_key_with_version)
35+
base_key << notification.event.record.cache_key_with_version
36+
end
37+
38+
# Add event cache key for consistency
39+
base_key << notification.event.cache_key_with_version if notification.event.respond_to?(:cache_key_with_version)
40+
41+
# Add locale for i18n support
42+
base_key << I18n.locale
43+
44+
base_key
45+
end
46+
47+
# Type-specific fragment cache key for notification patterns
48+
def notification_type_fragment_cache_key(notification)
49+
type_key = [
50+
notification.type || notification.class.name,
51+
notification.cache_key_with_version,
52+
I18n.locale
53+
]
54+
55+
# Add related record cache key if available
56+
if notification.event&.record.respond_to?(:cache_key_with_version)
57+
type_key << notification.event.record.cache_key_with_version
58+
end
59+
60+
type_key
61+
end
62+
63+
# Cache expiration utilities for notifications
64+
def expire_notification_fragments(notification)
65+
# Expire all fragments related to this notification
66+
expire_fragment(notification_fragment_cache_key(notification))
67+
expire_fragment(notification_type_fragment_cache_key(notification))
68+
69+
# Expire header/content/footer fragments
70+
expire_fragment(['notification_header', notification.cache_key_with_version])
71+
expire_fragment(['notification_content', notification.cache_key_with_version])
72+
expire_fragment(['notification_footer', notification.cache_key_with_version])
73+
end
74+
75+
# Expire fragments for a specific notification type pattern
76+
def expire_notification_type_fragments(notification_type)
77+
# This is useful when you want to expire all cached fragments for a specific notification type
78+
# Note: This requires more specific cache management based on your needs
79+
Rails.cache.delete_matched("*#{notification_type}*")
80+
end
81+
82+
# Check if a notification should use fragment caching
83+
def should_cache_notification?(notification)
84+
# Only cache if the notification and its record are present and have cache keys
85+
notification.present? &&
86+
notification.event&.record.present? &&
87+
notification.respond_to?(:cache_key_with_version)
88+
end
2789
end
2890
end
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// app/assets/javascripts/controllers/better_together/lazy_notifications_controller.js
2+
import { Controller } from "@hotwired/stimulus"
3+
4+
export default class extends Controller {
5+
static targets = [ "dropdown", "content" ]
6+
static values = {
7+
loaded: Boolean,
8+
url: String
9+
}
10+
11+
connect() {
12+
this.loadedValue = false
13+
console.log('Lazy notifications controller connected with URL:', this.urlValue)
14+
15+
// Find the dropdown toggle element (the <a> tag with data-bs-toggle="dropdown")
16+
const dropdownToggle = this.element.querySelector('[data-bs-toggle="dropdown"]')
17+
18+
if (dropdownToggle) {
19+
console.log('Found dropdown toggle, adding event listener')
20+
// Listen for Bootstrap dropdown show event on the dropdown toggle
21+
dropdownToggle.addEventListener('show.bs.dropdown', (event) => {
22+
console.log('Bootstrap dropdown show event triggered')
23+
if (!this.loadedValue) {
24+
this.loadContent()
25+
}
26+
})
27+
} else {
28+
console.log('No dropdown toggle found')
29+
}
30+
}
31+
32+
toggle(event) {
33+
// This method can be removed or kept for debugging
34+
console.log('Dropdown toggle clicked')
35+
}
36+
37+
async loadContent() {
38+
if (this.loadedValue) return
39+
40+
console.log('Loading notifications from:', this.urlValue)
41+
42+
try {
43+
// Show loading state
44+
this.contentTarget.innerHTML = `
45+
<div class="text-center p-3">
46+
<div class="spinner-border spinner-border-sm" role="status">
47+
<span class="visually-hidden">Loading...</span>
48+
</div>
49+
<div class="mt-2">Loading notifications...</div>
50+
</div>
51+
`
52+
53+
const response = await fetch(this.urlValue, {
54+
headers: {
55+
'Accept': 'text/html'
56+
}
57+
})
58+
59+
if (response.ok) {
60+
const html = await response.text()
61+
this.contentTarget.innerHTML = html
62+
this.loadedValue = true
63+
} else {
64+
this.contentTarget.innerHTML = `
65+
<div class="text-danger text-center p-3">
66+
Failed to load notifications
67+
</div>
68+
`
69+
}
70+
} catch (error) {
71+
console.error('Error loading notifications:', error)
72+
this.contentTarget.innerHTML = `
73+
<div class="text-danger text-center p-3">
74+
Error loading notifications
75+
</div>
76+
`
77+
}
78+
}
79+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# frozen_string_literal: true
2+
3+
module BetterTogether
4+
# Background job for warming notification fragment caches
5+
class NotificationCacheWarmingJob < ApplicationJob
6+
queue_as :low_priority
7+
8+
def perform(notification_ids)
9+
notifications = Noticed::Notification.where(id: notification_ids).includes(event: :record)
10+
11+
notifications.find_each do |notification|
12+
warm_notification_fragments(notification) if should_warm_cache?(notification)
13+
end
14+
end
15+
16+
private
17+
18+
def warm_notification_fragments(notification)
19+
# Generate cache keys without actually rendering to check existence
20+
fragment_key = notification_fragment_cache_key(notification)
21+
type_key = notification_type_fragment_cache_key(notification)
22+
23+
# Only warm if not already cached
24+
return if Rails.cache.exist?(fragment_key) && Rails.cache.exist?(type_key)
25+
26+
# Render the notification to warm the cache
27+
ApplicationController.renderer.render(
28+
partial: notification,
29+
locals: {},
30+
formats: [:html]
31+
)
32+
rescue StandardError => e
33+
Rails.logger.warn "Failed to warm cache for notification #{notification.id}: #{e.message}"
34+
end
35+
36+
def should_warm_cache?(notification)
37+
notification.event&.record.present? &&
38+
notification.respond_to?(:cache_key_with_version) &&
39+
notification.created_at > 1.week.ago # Only warm recent notifications
40+
end
41+
42+
def notification_fragment_cache_key(notification)
43+
base_key = [notification.cache_key_with_version]
44+
if notification.event&.record.respond_to?(:cache_key_with_version)
45+
base_key << notification.event.record.cache_key_with_version
46+
end
47+
base_key << notification.event.cache_key_with_version if notification.event.respond_to?(:cache_key_with_version)
48+
base_key << I18n.locale
49+
base_key
50+
end
51+
52+
def notification_type_fragment_cache_key(notification)
53+
type_key = [
54+
notification.type || notification.class.name,
55+
notification.cache_key_with_version,
56+
I18n.locale
57+
]
58+
if notification.event&.record.respond_to?(:cache_key_with_version)
59+
type_key << notification.event.record.cache_key_with_version
60+
end
61+
type_key
62+
end
63+
end
64+
end
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# frozen_string_literal: true
2+
3+
module BetterTogether
4+
# Concern for managing notification fragment caches
5+
module NotificationCacheManagement
6+
extend ActiveSupport::Concern
7+
8+
included do
9+
# Automatically expire fragment caches when notification changes
10+
after_update :expire_notification_caches, if: :should_expire_caches?
11+
after_destroy :expire_notification_caches
12+
end
13+
14+
private
15+
16+
def expire_notification_caches
17+
# Use the helper methods to expire relevant fragments
18+
ApplicationController.helpers.expire_notification_fragments(self) if respond_to_cache_methods?
19+
end
20+
21+
def should_expire_caches?
22+
# Expire caches on status changes (read/unread) or content changes
23+
saved_change_to_read_at? || saved_change_to_created_at? || saved_change_to_updated_at?
24+
end
25+
26+
def respond_to_cache_methods?
27+
ApplicationController.helpers.respond_to?(:expire_notification_fragments)
28+
rescue StandardError
29+
false
30+
end
31+
end
32+
end
Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
<!-- app/views/better_together/event_invitation_notifier/notifications/_notification.html.erb -->
2-
<!-- app/views/better_together/event_invitation_notifier/notifications/_notification.html.erb -->
32
<%= render layout: 'better_together/notifications/notification',
43
locals: { notification: notification,
54
notification_title: notification.title,
65
notification_url: notification.invitable } do %>
7-
<p class="mb-1">
8-
<%= notification.body %>
9-
</p>
6+
<% # Fragment cache for event invitation-specific content %>
7+
<% cache ["event_invitation_content", notification.cache_key_with_version, notification.invitable.cache_key_with_version, I18n.locale] do %>
8+
<p class="mb-1">
9+
<%= notification.body %>
10+
</p>
11+
<% end %>
1012
<% end %>

app/views/better_together/joatu/agreement_notifier/notifications/_notification.html.erb

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
locals: { notification: notification,
44
notification_title: notification.title,
55
notification_url: notification.agreement } do %>
6-
<p class="mb-1">
7-
<%= notification.body %>
8-
</p>
6+
<% # Fragment cache for agreement-specific content %>
7+
<% cache ["agreement_notification_content", notification.cache_key_with_version, notification.agreement.cache_key_with_version, I18n.locale] do %>
8+
<p class="mb-1">
9+
<%= notification.body %>
10+
</p>
11+
<% end %>
912
<% end %>

0 commit comments

Comments
 (0)