Stack notifications everywhere
We had client-side notification stacking in the tray since launch, but now we want to stack notifications in the notifications page, in API responses and in email bundles.
This commit is contained in:
@@ -2,12 +2,12 @@ class Cards::ReadingsController < ApplicationController
|
|||||||
include CardScoped
|
include CardScoped
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@notifications = @card.read_by(Current.user)
|
@card.read_by(Current.user)
|
||||||
record_board_access
|
record_board_access
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
@notifications = @card.unread_by(Current.user)
|
@card.unread_by(Current.user)
|
||||||
record_board_access
|
record_board_access
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
class Notifications::TraysController < ApplicationController
|
class Notifications::TraysController < ApplicationController
|
||||||
def show
|
def show
|
||||||
@notifications = Current.user.notifications.preloaded.unread.ordered.limit(100)
|
@notifications = Current.user.notifications.unread.preloaded.ordered.limit(100)
|
||||||
|
|
||||||
# Invalidate on the whole set instead of the unread set since the max updated at in the unread set
|
# Invalidate on the whole set instead of the unread set since the max updated at in the unread set
|
||||||
# can stay the same when reading old notifications.
|
# can stay the same when reading old notifications.
|
||||||
|
|||||||
@@ -29,9 +29,7 @@ module NotificationsHelper
|
|||||||
def notification_tag(notification, &)
|
def notification_tag(notification, &)
|
||||||
tag.div id: dom_id(notification), class: "tray__item tray__item--notification", data: {
|
tag.div id: dom_id(notification), class: "tray__item tray__item--notification", data: {
|
||||||
navigable_list_target: "item",
|
navigable_list_target: "item",
|
||||||
notifications_tray_target: "notification",
|
card_id: notification.card.id
|
||||||
card_id: notification.card.id,
|
|
||||||
timestamp: notification.created_at.to_i
|
|
||||||
} do
|
} do
|
||||||
link_to(notification,
|
link_to(notification,
|
||||||
class: [ "card card--notification", { "card--closed": notification.card.closed? }, { "unread": !notification.read? } ],
|
class: [ "card card--notification", { "card--closed": notification.card.closed? }, { "unread": !notification.read? } ],
|
||||||
@@ -58,7 +56,7 @@ module NotificationsHelper
|
|||||||
data: { action: "form#submit:stop badge#update:stop", form_target: "submit" },
|
data: { action: "form#submit:stop badge#update:stop", form_target: "submit" },
|
||||||
form: { data: { controller: "form" } } do
|
form: { data: { controller: "form" } } do
|
||||||
concat(icon_tag("remove"))
|
concat(icon_tag("remove"))
|
||||||
concat(tag.span("1", class: "badge-count", data: { group_count: "" }))
|
concat(tag.span(notification.unread_count, class: "badge-count")) if notification.unread_count > 1
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
import { Controller } from "@hotwired/stimulus"
|
|
||||||
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = [ "notification", "hiddenNotifications" ]
|
|
||||||
static classes = [ "grouped" ]
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
this.group()
|
|
||||||
}
|
|
||||||
|
|
||||||
group() {
|
|
||||||
const notificationsByCardId = this.#groupNotificationsByCardId()
|
|
||||||
|
|
||||||
for (const cardId in notificationsByCardId) {
|
|
||||||
const notifications = notificationsByCardId[cardId]
|
|
||||||
if (notifications.length > 1) {
|
|
||||||
this.#renderGroup(notifications)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.grouped = true
|
|
||||||
}
|
|
||||||
|
|
||||||
notificationTargetConnected(notification) {
|
|
||||||
if (this.grouped && notification.parentElement !== this.hiddenNotificationsTarget) {
|
|
||||||
this.#groupNotification(notification)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#groupNotificationsByCardId() {
|
|
||||||
const notificationsByCardId = {}
|
|
||||||
|
|
||||||
this.notificationTargets.forEach(notification => {
|
|
||||||
const cardId = notification.dataset.cardId
|
|
||||||
notificationsByCardId[cardId] ||= []
|
|
||||||
notificationsByCardId[cardId].push(notification)
|
|
||||||
})
|
|
||||||
|
|
||||||
return notificationsByCardId
|
|
||||||
}
|
|
||||||
|
|
||||||
#groupNotification(notification) {
|
|
||||||
const groupedNotifications = this.#groupedNotificationsFor(notification)
|
|
||||||
|
|
||||||
if (groupedNotifications.length > 1) {
|
|
||||||
this.#renderGroup(groupedNotifications)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#groupedNotificationsFor(notification) {
|
|
||||||
const cardId = notification.dataset.cardId
|
|
||||||
return this.notificationTargets
|
|
||||||
.filter(notification => notification.dataset.cardId === cardId)
|
|
||||||
}
|
|
||||||
|
|
||||||
#renderGroup(groupedNotifications) {
|
|
||||||
groupedNotifications.sort((notification_1, notification_2) => parseInt(notification_1.dataset.timestamp) - parseInt(notification_2.dataset.timestamp))
|
|
||||||
|
|
||||||
this.#showAsGrouped(groupedNotifications[0], groupedNotifications.length)
|
|
||||||
groupedNotifications.slice(1).forEach(notification => this.#hideInGroup(notification))
|
|
||||||
}
|
|
||||||
|
|
||||||
#showAsGrouped(notification, size) {
|
|
||||||
notification.classList.add(this.groupedClass)
|
|
||||||
this.#showGroupedNotification(notification)
|
|
||||||
this.#setGroupCount(notification, size)
|
|
||||||
}
|
|
||||||
|
|
||||||
#hideInGroup(notification) {
|
|
||||||
this.#hideGroupedNotification(notification)
|
|
||||||
this.#setGroupCount(notification, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
// We use a hidden container instead of hiding the notifications directly so that the CSS that sort the
|
|
||||||
// tray element indexes doesn't get messed up with the child positions changing.
|
|
||||||
#showGroupedNotification(notification) {
|
|
||||||
if (notification.parentElement === this.hiddenNotificationsTarget) {
|
|
||||||
this.hiddenNotificationsTarget.parentElement.insertBefore(notification, this.hiddenNotificationsTarget)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#hideGroupedNotification(notification) {
|
|
||||||
this.hiddenNotificationsTarget.appendChild(notification)
|
|
||||||
}
|
|
||||||
|
|
||||||
#setGroupCount(notification, count) {
|
|
||||||
notification.querySelector("[data-group-count]").textContent = count
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,15 +2,11 @@ module Card::Readable
|
|||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
def read_by(user)
|
def read_by(user)
|
||||||
notifications_for(user).tap do |notifications|
|
user.notifications.find_by(card: self)&.read
|
||||||
notifications.each(&:read)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def unread_by(user)
|
def unread_by(user)
|
||||||
all_notifications_for(user).tap do |notifications|
|
user.notifications.find_by(card: self)&.unread
|
||||||
notifications.each(&:unread)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def remove_inaccessible_notifications
|
def remove_inaccessible_notifications
|
||||||
@@ -25,18 +21,6 @@ module Card::Readable
|
|||||||
Card::RemoveInaccessibleNotificationsJob.perform_later(self)
|
Card::RemoveInaccessibleNotificationsJob.perform_later(self)
|
||||||
end
|
end
|
||||||
|
|
||||||
def notifications_for(user)
|
|
||||||
scope = user.notifications.unread
|
|
||||||
scope.where(source: event_notification_sources)
|
|
||||||
.or(scope.where(source: mention_notification_sources))
|
|
||||||
end
|
|
||||||
|
|
||||||
def all_notifications_for(user)
|
|
||||||
scope = user.notifications
|
|
||||||
scope.where(source: event_notification_sources)
|
|
||||||
.or(scope.where(source: mention_notification_sources))
|
|
||||||
end
|
|
||||||
|
|
||||||
def event_notification_sources
|
def event_notification_sources
|
||||||
events.or(comment_creation_events)
|
events.or(comment_creation_events)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ module PushNotifiable
|
|||||||
|
|
||||||
included do
|
included do
|
||||||
after_create_commit :push_notification_later
|
after_create_commit :push_notification_later
|
||||||
|
after_update_commit :push_notification_later, if: :source_id_previously_changed?
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
+28
-17
@@ -5,32 +5,39 @@ class Notification < ApplicationRecord
|
|||||||
belongs_to :user
|
belongs_to :user
|
||||||
belongs_to :creator, class_name: "User"
|
belongs_to :creator, class_name: "User"
|
||||||
belongs_to :source, polymorphic: true
|
belongs_to :source, polymorphic: true
|
||||||
|
belongs_to :card
|
||||||
|
|
||||||
scope :unread, -> { where(read_at: nil) }
|
scope :unread, -> { where(read_at: nil) }
|
||||||
scope :read, -> { where.not(read_at: nil) }
|
scope :read, -> { where.not(read_at: nil) }
|
||||||
scope :ordered, -> { order(read_at: :desc, created_at: :desc) }
|
scope :ordered, -> { order(read_at: :desc, updated_at: :desc) }
|
||||||
|
scope :preloaded, -> { preload(:card, :creator, :account, source: [ :board, :creator, { eventable: [ :closure, :board, :assignments ] } ]) }
|
||||||
|
|
||||||
after_create_commit :broadcast_unread
|
before_validation :set_card
|
||||||
after_destroy_commit :broadcast_read
|
|
||||||
after_create :bundle
|
after_create :bundle
|
||||||
|
after_update :bundle, if: :source_id_previously_changed?
|
||||||
|
|
||||||
scope :preloaded, -> { preload(:creator, :account, source: [ :board, :creator, { eventable: [ :closure, :board, :assignments ] } ]) }
|
after_create_commit -> { broadcast_prepend_later_to user, :notifications, target: "notifications" }
|
||||||
|
after_update_commit -> { broadcast_update }
|
||||||
|
after_destroy_commit -> { broadcast_remove_to user, :notifications }
|
||||||
|
|
||||||
delegate :notifiable_target, to: :source
|
delegate :notifiable_target, to: :source
|
||||||
delegate :card, to: :source
|
|
||||||
|
|
||||||
def self.read_all
|
class << self
|
||||||
all.each { |notification| notification.read }
|
def read_all
|
||||||
|
all.each(&:read)
|
||||||
|
end
|
||||||
|
|
||||||
|
def unread_all
|
||||||
|
all.each(&:unread)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def read
|
def read
|
||||||
update!(read_at: Time.current)
|
update!(read_at: Time.current, unread_count: 0)
|
||||||
broadcast_read
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def unread
|
def unread
|
||||||
update!(read_at: nil)
|
update!(read_at: nil, unread_count: 1)
|
||||||
broadcast_unread
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def read?
|
def read?
|
||||||
@@ -38,15 +45,19 @@ class Notification < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
def broadcast_unread
|
def set_card
|
||||||
broadcast_prepend_later_to user, :notifications, target: "notifications"
|
self.card = source.card
|
||||||
end
|
|
||||||
|
|
||||||
def broadcast_read
|
|
||||||
broadcast_remove_to user, :notifications
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def bundle
|
def bundle
|
||||||
user.bundle(self) if user.settings.bundling_emails?
|
user.bundle(self) if user.settings.bundling_emails?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def broadcast_update
|
||||||
|
if read?
|
||||||
|
broadcast_remove_to(user, :notifications)
|
||||||
|
else
|
||||||
|
broadcast_prepend_later_to(user, :notifications, target: "notifications")
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ class Notification::Bundle < ApplicationRecord
|
|||||||
enum :status, %i[ pending processing delivered ]
|
enum :status, %i[ pending processing delivered ]
|
||||||
|
|
||||||
scope :due, -> { pending.where("ends_at <= ?", Time.current) }
|
scope :due, -> { pending.where("ends_at <= ?", Time.current) }
|
||||||
scope :containing, ->(notification) { where("starts_at <= ? AND ends_at > ?", notification.created_at, notification.created_at) }
|
scope :containing, ->(notification) { where("starts_at <= ? AND ends_at > ?", notification.updated_at, notification.updated_at) }
|
||||||
scope :overlapping_with, ->(other_bundle) do
|
scope :overlapping_with, ->(other_bundle) do
|
||||||
where(
|
where(
|
||||||
"(starts_at <= ? AND ends_at >= ?) OR (starts_at <= ? AND ends_at >= ?) OR (starts_at >= ? AND ends_at <= ?)",
|
"(starts_at <= ? AND ends_at >= ?) OR (starts_at <= ? AND ends_at >= ?) OR (starts_at >= ? AND ends_at <= ?)",
|
||||||
@@ -33,7 +33,7 @@ class Notification::Bundle < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def notifications
|
def notifications
|
||||||
user.notifications.where(created_at: window).unread
|
user.notifications.where(updated_at: window).unread
|
||||||
end
|
end
|
||||||
|
|
||||||
def deliver
|
def deliver
|
||||||
|
|||||||
@@ -16,7 +16,13 @@ class Notifier
|
|||||||
if should_notify?
|
if should_notify?
|
||||||
# Processing recipients in order avoids deadlocks if notifications overlap.
|
# Processing recipients in order avoids deadlocks if notifications overlap.
|
||||||
recipients.sort_by(&:id).map do |recipient|
|
recipients.sort_by(&:id).map do |recipient|
|
||||||
Notification.create! user: recipient, source: source, creator: creator
|
notification = Notification.find_or_initialize_by(user: recipient, card: source.card)
|
||||||
|
notification.source = source
|
||||||
|
notification.creator = creator
|
||||||
|
notification.read_at = nil
|
||||||
|
notification.unread_count = (notification.unread_count || 0) + 1
|
||||||
|
notification.save!
|
||||||
|
notification
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -25,12 +25,12 @@ module User::Notifiable
|
|||||||
|
|
||||||
def expand_pending_bundle_for(notification)
|
def expand_pending_bundle_for(notification)
|
||||||
pending = notification_bundles.pending.last
|
pending = notification_bundles.pending.last
|
||||||
if pending.present? && notification.created_at < pending.starts_at
|
if pending.present? && notification.updated_at < pending.starts_at
|
||||||
pending.update!(starts_at: notification.created_at) # expand the window to include this notification
|
pending.update!(starts_at: notification.updated_at) # expand the window to include this notification
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_bundle_for(notification)
|
def create_bundle_for(notification)
|
||||||
notification_bundles.create!(starts_at: notification.created_at)
|
notification_bundles.create!(starts_at: notification.updated_at)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
<% @notifications.each do |notification| %>
|
|
||||||
<%= turbo_stream.remove notification %>
|
|
||||||
<% end %>
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<%# Helper Dependency Updated: avatar_image_tag 2025-12-15 %>
|
<%# Helper Dependency Updated: avatar_image_tag 2025-12-15 %>
|
||||||
<%= notification_tag notification do %>
|
<%= notification_tag notification do %>
|
||||||
<%= render "notifications/notification/header", notification: notification do %>
|
<%= render "notifications/notification/header", notification: notification do %>
|
||||||
<%= notification_toggle_read_button(notification, url: card_reading_path(notification.card)) %>
|
<%= notification_toggle_read_button(notification, url: notification_reading_path(notification)) %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= render "notifications/notification/body", notification: notification %>
|
<%= render "notifications/notification/body", notification: notification %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
json.cache! notification do
|
json.cache! notification do
|
||||||
json.(notification, :id)
|
json.(notification, :id, :unread_count)
|
||||||
json.read notification.read?
|
json.read notification.read?
|
||||||
json.read_at notification.read_at&.utc
|
json.read_at notification.read_at&.utc
|
||||||
json.created_at notification.created_at.utc
|
json.created_at notification.created_at.utc
|
||||||
|
|||||||
@@ -8,9 +8,8 @@
|
|||||||
data-navigable-list-reverse-navigation-value="true"
|
data-navigable-list-reverse-navigation-value="true"
|
||||||
data-action="keydown->navigable-list#navigate dialog:show@document->navigable-list#reset turbo-visit->navigable-list#reset keydown.esc->dialog#close:stop click@document->dialog#closeOnClickOutside">
|
data-action="keydown->navigable-list#navigate dialog:show@document->navigable-list#reset turbo-visit->navigable-list#reset keydown.esc->dialog#close:stop click@document->dialog#closeOnClickOutside">
|
||||||
<%= turbo_frame_tag "notifications", src: tray_notifications_path, refresh: "morph", data: {
|
<%= turbo_frame_tag "notifications", src: tray_notifications_path, refresh: "morph", data: {
|
||||||
controller: "notifications-tray frame-reloader",
|
controller: "frame-reloader",
|
||||||
notifications_tray_grouped_class: "notification--grouped",
|
action: "focus@window->frame-reloader#reload" } %>
|
||||||
action: "turbo:frame-render->notifications-tray#group focus@window->frame-reloader#reload" } %>
|
|
||||||
|
|
||||||
<div class="tray__item tray__item--hat txt-x-small gap-half">
|
<div class="tray__item tray__item--hat txt-x-small gap-half">
|
||||||
<div data-navigable-list-target="item" class="full-width">
|
<div data-navigable-list-target="item" class="full-width">
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
json.array! (@unread || []) + @page.records, partial: "notifications/notification", as: :notification
|
json.array! (@unread || []) + @page.records, partial: "notifications/notification", as: :notification, cached: true
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
<%= notification_tag notification do %>
|
|
||||||
<%= render "notifications/notification/header", notification: notification do %>
|
|
||||||
<%= notification_toggle_read_button(notification, url: notification_reading_path(notification)) %>
|
|
||||||
<% end %>
|
|
||||||
<%= render "notifications/notification/body", notification: notification %>
|
|
||||||
<% end %>
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<h2 class="txt-medium margin-block-start-double margin-block-end-half txt-uppercase translucent">Previously seen</h2>
|
<h2 class="txt-medium margin-block-start-double margin-block-end-half txt-uppercase translucent">Previously seen</h2>
|
||||||
|
|
||||||
<div id="notifications_list_read" contents>
|
<div id="notifications_list_read" contents>
|
||||||
<%= render partial: "notifications/index/notification", collection: page.records, cached: true %>
|
<%= render partial: "notifications/notification", collection: page.records, cached: true %>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -11,12 +11,12 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<div id="notifications_list" contents>
|
<div id="notifications_list" contents>
|
||||||
<%= render partial: "notifications/index/notification", collection: unread, cached: true %>
|
<%= render partial: "notifications/notification", collection: unread, cached: true %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if unread.any? %>
|
<% if unread.any? %>
|
||||||
<% total_unread_count = Current.user.notifications.unread.count %>
|
<% total_unread_count = Current.user.notifications.unread.count %>
|
||||||
<% if Current.user.notifications.unread.count > NotificationsController::MAX_UNREAD_NOTIFICATIONS %>
|
<% if total_unread_count > NotificationsController::MAX_UNREAD_NOTIFICATIONS %>
|
||||||
<div class="fill-highlight txt-x-small border-radius pad-block-half pad-inline">
|
<div class="fill-highlight txt-x-small border-radius pad-block-half pad-inline">
|
||||||
Showing the <%= NotificationsController::MAX_UNREAD_NOTIFICATIONS %> most recent (<%= total_unread_count - NotificationsController::MAX_UNREAD_NOTIFICATIONS %> are hidden)
|
Showing the <%= NotificationsController::MAX_UNREAD_NOTIFICATIONS %> most recent (<%= total_unread_count - NotificationsController::MAX_UNREAD_NOTIFICATIONS %> are hidden)
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
<%= turbo_frame_tag "notifications" do %>
|
<%= turbo_frame_tag "notifications" do %>
|
||||||
<%= render partial: "notifications/notification", collection: @notifications, cached: true %>
|
<%= render partial: "notifications/notification", collection: @notifications, cached: true %>
|
||||||
<div hidden data-notifications-tray-target="hiddenNotifications"></div>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
class NotificationsDataMigration < ActiveRecord::Migration[8.2]
|
||||||
|
BATCH_SIZE = 10_000
|
||||||
|
|
||||||
|
class Notification < ActiveRecord::Base
|
||||||
|
self.table_name = "notifications"
|
||||||
|
end
|
||||||
|
|
||||||
|
def change
|
||||||
|
reversible do |dir|
|
||||||
|
dir.up do
|
||||||
|
populate_card_id
|
||||||
|
collapse_duplicates
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
change_column_null :notifications, :card_id, false
|
||||||
|
add_index :notifications, [ :user_id, :card_id ], unique: true
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def populate_card_id
|
||||||
|
execute(<<~SQL)
|
||||||
|
UPDATE notifications
|
||||||
|
SET card_id = (
|
||||||
|
SELECT CASE events.eventable_type
|
||||||
|
WHEN 'Card' THEN events.eventable_id
|
||||||
|
WHEN 'Comment' THEN (SELECT comments.card_id FROM comments WHERE comments.id = events.eventable_id)
|
||||||
|
END
|
||||||
|
FROM events
|
||||||
|
WHERE events.id = notifications.source_id
|
||||||
|
)
|
||||||
|
WHERE notifications.card_id IS NULL
|
||||||
|
AND notifications.source_type = 'Event'
|
||||||
|
SQL
|
||||||
|
|
||||||
|
execute(<<~SQL)
|
||||||
|
UPDATE notifications
|
||||||
|
SET card_id = (
|
||||||
|
SELECT CASE mentions.source_type
|
||||||
|
WHEN 'Card' THEN mentions.source_id
|
||||||
|
WHEN 'Comment' THEN (SELECT comments.card_id FROM comments WHERE comments.id = mentions.source_id)
|
||||||
|
END
|
||||||
|
FROM mentions
|
||||||
|
WHERE mentions.id = notifications.source_id
|
||||||
|
)
|
||||||
|
WHERE notifications.card_id IS NULL
|
||||||
|
AND notifications.source_type = 'Mention'
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def collapse_duplicates
|
||||||
|
loop do
|
||||||
|
duplicates = Notification.find_by_sql(<<~SQL)
|
||||||
|
SELECT user_id, card_id,
|
||||||
|
MAX(id) AS keep_id,
|
||||||
|
COUNT(*) AS total,
|
||||||
|
SUM(CASE WHEN read_at IS NULL THEN 1 ELSE 0 END) AS unread_total
|
||||||
|
FROM notifications
|
||||||
|
WHERE card_id IS NOT NULL
|
||||||
|
GROUP BY user_id, card_id
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
LIMIT #{BATCH_SIZE}
|
||||||
|
SQL
|
||||||
|
|
||||||
|
break if duplicates.empty?
|
||||||
|
|
||||||
|
duplicates.each do |row|
|
||||||
|
Notification.where(user_id: row.user_id, card_id: row.card_id)
|
||||||
|
.where.not(id: row.keep_id)
|
||||||
|
.delete_all
|
||||||
|
|
||||||
|
Notification.where(id: row.keep_id)
|
||||||
|
.update_all(unread_count: row.unread_total.to_i)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Set unread_count for remaining non-collapsed notifications
|
||||||
|
execute(<<~SQL)
|
||||||
|
UPDATE notifications
|
||||||
|
SET unread_count = CASE WHEN read_at IS NULL THEN 1 ELSE 0 END
|
||||||
|
WHERE unread_count = 0 AND card_id IS NOT NULL
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
end
|
||||||
Generated
+2
-1
@@ -385,7 +385,7 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_11_122517) do
|
|||||||
|
|
||||||
create_table "notifications", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
create_table "notifications", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
t.uuid "account_id", null: false
|
t.uuid "account_id", null: false
|
||||||
t.uuid "card_id"
|
t.uuid "card_id", null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.uuid "creator_id"
|
t.uuid "creator_id"
|
||||||
t.datetime "read_at"
|
t.datetime "read_at"
|
||||||
@@ -397,6 +397,7 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_11_122517) do
|
|||||||
t.index ["account_id"], name: "index_notifications_on_account_id"
|
t.index ["account_id"], name: "index_notifications_on_account_id"
|
||||||
t.index ["creator_id"], name: "index_notifications_on_creator_id"
|
t.index ["creator_id"], name: "index_notifications_on_creator_id"
|
||||||
t.index ["source_type", "source_id"], name: "index_notifications_on_source"
|
t.index ["source_type", "source_id"], name: "index_notifications_on_source"
|
||||||
|
t.index ["user_id", "card_id"], name: "index_notifications_on_user_id_and_card_id", unique: true
|
||||||
t.index ["user_id", "read_at", "created_at"], name: "index_notifications_on_user_id_and_read_at_and_created_at", order: { read_at: :desc, created_at: :desc }
|
t.index ["user_id", "read_at", "created_at"], name: "index_notifications_on_user_id_and_read_at_and_created_at", order: { read_at: :desc, created_at: :desc }
|
||||||
t.index ["user_id"], name: "index_notifications_on_user_id"
|
t.index ["user_id"], name: "index_notifications_on_user_id"
|
||||||
end
|
end
|
||||||
|
|||||||
+2
-1
@@ -385,7 +385,7 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_11_122517) do
|
|||||||
|
|
||||||
create_table "notifications", id: :uuid, force: :cascade do |t|
|
create_table "notifications", id: :uuid, force: :cascade do |t|
|
||||||
t.uuid "account_id", null: false
|
t.uuid "account_id", null: false
|
||||||
t.uuid "card_id"
|
t.uuid "card_id", null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.uuid "creator_id"
|
t.uuid "creator_id"
|
||||||
t.datetime "read_at"
|
t.datetime "read_at"
|
||||||
@@ -397,6 +397,7 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_11_122517) do
|
|||||||
t.index ["account_id"], name: "index_notifications_on_account_id"
|
t.index ["account_id"], name: "index_notifications_on_account_id"
|
||||||
t.index ["creator_id"], name: "index_notifications_on_creator_id"
|
t.index ["creator_id"], name: "index_notifications_on_creator_id"
|
||||||
t.index ["source_type", "source_id"], name: "index_notifications_on_source"
|
t.index ["source_type", "source_id"], name: "index_notifications_on_source"
|
||||||
|
t.index ["user_id", "card_id"], name: "index_notifications_on_user_id_and_card_id", unique: true
|
||||||
t.index ["user_id", "read_at", "created_at"], name: "index_notifications_on_user_id_and_read_at_and_created_at", order: { read_at: :desc, created_at: :desc }
|
t.index ["user_id", "read_at", "created_at"], name: "index_notifications_on_user_id_and_read_at_and_created_at", order: { read_at: :desc, created_at: :desc }
|
||||||
t.index ["user_id"], name: "index_notifications_on_user_id"
|
t.index ["user_id"], name: "index_notifications_on_user_id"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ class Cards::ReadingsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
test "create" do
|
test "create" do
|
||||||
freeze_time
|
freeze_time
|
||||||
|
|
||||||
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do
|
assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do
|
||||||
assert_changes -> { accesses(:writebook_kevin).reload.accessed_at }, from: nil, to: Time.current do
|
assert_changes -> { accesses(:writebook_kevin).reload.accessed_at }, from: nil, to: Time.current do
|
||||||
post card_reading_url(cards(:logo)), as: :turbo_stream
|
post card_reading_url(cards(:logo)), as: :turbo_stream
|
||||||
end
|
end
|
||||||
@@ -17,31 +17,20 @@ class Cards::ReadingsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
assert_response :success
|
assert_response :success
|
||||||
end
|
end
|
||||||
|
|
||||||
test "read one notification on card visit" do
|
test "read notification on card visit" do
|
||||||
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do
|
assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do
|
||||||
post card_reading_path(cards(:logo)), as: :turbo_stream
|
post card_reading_path(cards(:logo)), as: :turbo_stream
|
||||||
end
|
end
|
||||||
|
|
||||||
assert_response :success
|
assert_response :success
|
||||||
end
|
end
|
||||||
|
|
||||||
test "read multiple notifications on card visit" do
|
|
||||||
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do
|
|
||||||
assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do
|
|
||||||
post card_reading_path(cards(:logo)), as: :turbo_stream
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
assert_response :success
|
|
||||||
end
|
|
||||||
|
|
||||||
test "destroy" do
|
test "destroy" do
|
||||||
freeze_time
|
freeze_time
|
||||||
|
|
||||||
notifications(:logo_published_kevin).read
|
|
||||||
notifications(:logo_assignment_kevin).read
|
notifications(:logo_assignment_kevin).read
|
||||||
|
|
||||||
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: true, to: false do
|
assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: true, to: false do
|
||||||
assert_changes -> { accesses(:writebook_kevin).reload.accessed_at }, to: Time.current do
|
assert_changes -> { accesses(:writebook_kevin).reload.accessed_at }, to: Time.current do
|
||||||
delete card_reading_url(cards(:logo)), as: :turbo_stream
|
delete card_reading_url(cards(:logo)), as: :turbo_stream
|
||||||
end
|
end
|
||||||
@@ -50,26 +39,13 @@ class Cards::ReadingsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
assert_response :success
|
assert_response :success
|
||||||
end
|
end
|
||||||
|
|
||||||
test "unread one notification on destroy" do
|
test "unread notification on destroy" do
|
||||||
notifications(:logo_published_kevin).read
|
notifications(:logo_assignment_kevin).read
|
||||||
|
|
||||||
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: true, to: false do
|
assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: true, to: false do
|
||||||
delete card_reading_path(cards(:logo)), as: :turbo_stream
|
delete card_reading_path(cards(:logo)), as: :turbo_stream
|
||||||
end
|
end
|
||||||
|
|
||||||
assert_response :success
|
assert_response :success
|
||||||
end
|
end
|
||||||
|
|
||||||
test "unread multiple notifications on destroy" do
|
|
||||||
notifications(:logo_published_kevin).read
|
|
||||||
notifications(:logo_assignment_kevin).read
|
|
||||||
|
|
||||||
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: true, to: false do
|
|
||||||
assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: true, to: false do
|
|
||||||
delete card_reading_path(cards(:logo)), as: :turbo_stream
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
assert_response :success
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ class Notifications::BulkReadingsControllerTest < ActionDispatch::IntegrationTes
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "create marks all notifications as read" do
|
test "create marks all notifications as read" do
|
||||||
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do
|
assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do
|
||||||
assert_changes -> { notifications(:layout_commented_kevin).reload.read? }, from: false, to: true do
|
assert_changes -> { notifications(:layout_commented_kevin).reload.read? }, from: false, to: true do
|
||||||
post bulk_reading_path
|
post bulk_reading_path
|
||||||
end
|
end
|
||||||
@@ -24,7 +24,7 @@ class Notifications::BulkReadingsControllerTest < ActionDispatch::IntegrationTes
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "create as JSON" do
|
test "create as JSON" do
|
||||||
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do
|
assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do
|
||||||
assert_changes -> { notifications(:layout_commented_kevin).reload.read? }, from: false, to: true do
|
assert_changes -> { notifications(:layout_commented_kevin).reload.read? }, from: false, to: true do
|
||||||
post bulk_reading_path, as: :json
|
post bulk_reading_path, as: :json
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,38 +3,37 @@ require "test_helper"
|
|||||||
class Notifications::ReadingsControllerTest < ActionDispatch::IntegrationTest
|
class Notifications::ReadingsControllerTest < ActionDispatch::IntegrationTest
|
||||||
setup do
|
setup do
|
||||||
sign_in_as :kevin
|
sign_in_as :kevin
|
||||||
|
@notification = notifications(:logo_assignment_kevin)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "create" do
|
test "create" do
|
||||||
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do
|
assert_changes -> { @notification.reload.read? }, from: false, to: true do
|
||||||
post notification_reading_path(notifications(:logo_published_kevin), format: :turbo_stream)
|
post notification_reading_path(@notification, format: :turbo_stream)
|
||||||
assert_response :success
|
assert_response :success
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "destroy" do
|
test "destroy" do
|
||||||
notification = notifications(:logo_published_kevin)
|
@notification.read
|
||||||
notification.read # Mark as read first
|
|
||||||
|
|
||||||
assert_changes -> { notification.reload.read? }, from: true, to: false do
|
assert_changes -> { @notification.reload.read? }, from: true, to: false do
|
||||||
delete notification_reading_path(notification, format: :turbo_stream)
|
delete notification_reading_path(@notification, format: :turbo_stream)
|
||||||
assert_response :success
|
assert_response :success
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "create as JSON" do
|
test "create as JSON" do
|
||||||
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do
|
assert_changes -> { @notification.reload.read? }, from: false, to: true do
|
||||||
post notification_reading_path(notifications(:logo_published_kevin)), as: :json
|
post notification_reading_path(@notification), as: :json
|
||||||
assert_response :no_content
|
assert_response :no_content
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "destroy as JSON" do
|
test "destroy as JSON" do
|
||||||
notification = notifications(:logo_published_kevin)
|
@notification.read
|
||||||
notification.read
|
|
||||||
|
|
||||||
assert_changes -> { notification.reload.read? }, from: true, to: false do
|
assert_changes -> { @notification.reload.read? }, from: true, to: false do
|
||||||
delete notification_reading_path(notification), as: :json
|
delete notification_reading_path(@notification), as: :json
|
||||||
assert_response :no_content
|
assert_response :no_content
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -10,18 +10,17 @@ class NotificationsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
assert_response :success
|
assert_response :success
|
||||||
assert_kind_of Array, @response.parsed_body
|
assert_kind_of Array, @response.parsed_body
|
||||||
assert @response.parsed_body.any? { |n| n["id"] == notifications(:logo_published_kevin).id }
|
assert @response.parsed_body.any? { |n| n["id"] == notifications(:logo_assignment_kevin).id }
|
||||||
end
|
end
|
||||||
|
|
||||||
test "index as JSON includes notification attributes" do
|
test "index as JSON includes notification attributes" do
|
||||||
get notifications_path, as: :json
|
get notifications_path, as: :json
|
||||||
|
|
||||||
notification = @response.parsed_body.find { |n| n["id"] == notifications(:logo_published_kevin).id }
|
notification = @response.parsed_body.find { |n| n["id"] == notifications(:logo_assignment_kevin).id }
|
||||||
|
|
||||||
assert_not_nil notification["title"]
|
|
||||||
assert_not_nil notification["body"]
|
|
||||||
assert_not_nil notification["created_at"]
|
assert_not_nil notification["created_at"]
|
||||||
assert_not_nil notification["card"]
|
assert_not_nil notification["card"]
|
||||||
assert_not_nil notification["creator"]
|
assert_not_nil notification["creator"]
|
||||||
|
assert_not_nil notification["unread_count"]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Vendored
+8
-18
@@ -1,15 +1,9 @@
|
|||||||
logo_published_kevin:
|
|
||||||
id: <%= ActiveRecord::FixtureSet.identify("logo_published_kevin", :uuid) %>
|
|
||||||
user: kevin_uuid
|
|
||||||
source: logo_published_uuid (Event)
|
|
||||||
created_at: <%= 1.week.ago %>
|
|
||||||
creator: david_uuid
|
|
||||||
account: 37s_uuid
|
|
||||||
|
|
||||||
logo_assignment_kevin:
|
logo_assignment_kevin:
|
||||||
id: <%= ActiveRecord::FixtureSet.identify("logo_assignment_kevin", :uuid) %>
|
id: <%= ActiveRecord::FixtureSet.identify("logo_assignment_kevin", :uuid) %>
|
||||||
user: kevin_uuid
|
user: kevin_uuid
|
||||||
source: logo_assignment_km_uuid (Event)
|
source: logo_assignment_km_uuid (Event)
|
||||||
|
card: logo_uuid
|
||||||
|
unread_count: 2
|
||||||
created_at: <%= 1.week.ago %>
|
created_at: <%= 1.week.ago %>
|
||||||
creator: david_uuid
|
creator: david_uuid
|
||||||
account: 37s_uuid
|
account: 37s_uuid
|
||||||
@@ -18,22 +12,18 @@ layout_commented_kevin:
|
|||||||
id: <%= ActiveRecord::FixtureSet.identify("layout_commented_kevin", :uuid) %>
|
id: <%= ActiveRecord::FixtureSet.identify("layout_commented_kevin", :uuid) %>
|
||||||
user: kevin_uuid
|
user: kevin_uuid
|
||||||
source: layout_commented_uuid (Event)
|
source: layout_commented_uuid (Event)
|
||||||
|
card: layout_uuid
|
||||||
|
unread_count: 1
|
||||||
created_at: <%= 1.week.ago %>
|
created_at: <%= 1.week.ago %>
|
||||||
creator: david_uuid
|
creator: david_uuid
|
||||||
account: 37s_uuid
|
account: 37s_uuid
|
||||||
|
|
||||||
logo_card_david_mention_by_jz:
|
logo_mentioned_david:
|
||||||
id: <%= ActiveRecord::FixtureSet.identify("logo_card_david_mention_by_jz_notif", :uuid) %>
|
id: <%= ActiveRecord::FixtureSet.identify("logo_mentioned_david", :uuid) %>
|
||||||
user: david_uuid
|
|
||||||
source: logo_card_david_mention_by_jz_uuid (Mention)
|
|
||||||
created_at: <%= 1.week.ago %>
|
|
||||||
creator: david_uuid
|
|
||||||
account: 37s_uuid
|
|
||||||
|
|
||||||
logo_comment_david_mention_by_jz:
|
|
||||||
id: <%= ActiveRecord::FixtureSet.identify("logo_comment_david_mention_by_jz_notif", :uuid) %>
|
|
||||||
user: david_uuid
|
user: david_uuid
|
||||||
source: logo_comment_david_mention_by_jz_uuid (Mention)
|
source: logo_comment_david_mention_by_jz_uuid (Mention)
|
||||||
|
card: logo_uuid
|
||||||
|
unread_count: 2
|
||||||
created_at: <%= 1.week.ago %>
|
created_at: <%= 1.week.ago %>
|
||||||
creator: david_uuid
|
creator: david_uuid
|
||||||
account: 37s_uuid
|
account: 37s_uuid
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ require "test_helper"
|
|||||||
class Notification::BundleMailerTest < ActionMailer::TestCase
|
class Notification::BundleMailerTest < ActionMailer::TestCase
|
||||||
setup do
|
setup do
|
||||||
@user = users(:david)
|
@user = users(:david)
|
||||||
|
@user.notifications.destroy_all
|
||||||
|
|
||||||
@bundle = Notification::Bundle.create!(
|
@bundle = Notification::Bundle.create!(
|
||||||
user: @user,
|
user: @user,
|
||||||
|
|||||||
@@ -1,60 +1,41 @@
|
|||||||
require "test_helper"
|
require "test_helper"
|
||||||
|
|
||||||
class Card::ReadableTest < ActiveSupport::TestCase
|
class Card::ReadableTest < ActiveSupport::TestCase
|
||||||
test "read clears events notifications" do
|
test "read marks notification as read" do
|
||||||
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do
|
assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do
|
||||||
assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do
|
cards(:logo).read_by(users(:kevin))
|
||||||
cards(:logo).read_by(users(:kevin))
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "read clear mentions in the description" do
|
test "read marks mention notification as read" do
|
||||||
assert_changes -> { notifications(:logo_card_david_mention_by_jz).reload.read? }, from: false, to: true do
|
assert_changes -> { notifications(:logo_mentioned_david).reload.read? }, from: false, to: true do
|
||||||
cards(:logo).read_by(users(:david))
|
cards(:logo).read_by(users(:david))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "read clear mentions in comments" do
|
test "read marks comment notification as read" do
|
||||||
assert_changes -> { notifications(:logo_comment_david_mention_by_jz).reload.read? }, from: false, to: true do
|
|
||||||
cards(:logo).read_by(users(:david))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
test "read clears notifications from the comments" do
|
|
||||||
assert_changes -> { notifications(:layout_commented_kevin).reload.read? }, from: false, to: true do
|
assert_changes -> { notifications(:layout_commented_kevin).reload.read? }, from: false, to: true do
|
||||||
cards(:layout).read_by(users(:kevin))
|
cards(:layout).read_by(users(:kevin))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "unread marks events notifications as unread" do
|
test "unread marks notification as unread" do
|
||||||
notifications(:logo_published_kevin).read
|
|
||||||
notifications(:logo_assignment_kevin).read
|
notifications(:logo_assignment_kevin).read
|
||||||
|
|
||||||
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: true, to: false do
|
assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: true, to: false do
|
||||||
assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: true, to: false do
|
cards(:logo).unread_by(users(:kevin))
|
||||||
cards(:logo).unread_by(users(:kevin))
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "unread marks mentions in the description as unread" do
|
test "unread marks mention notification as unread" do
|
||||||
notifications(:logo_card_david_mention_by_jz).read
|
notifications(:logo_mentioned_david).read
|
||||||
|
|
||||||
assert_changes -> { notifications(:logo_card_david_mention_by_jz).reload.read? }, from: true, to: false do
|
assert_changes -> { notifications(:logo_mentioned_david).reload.read? }, from: true, to: false do
|
||||||
cards(:logo).unread_by(users(:david))
|
cards(:logo).unread_by(users(:david))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "unread marks mentions in comments as unread" do
|
test "unread marks comment notification as unread" do
|
||||||
notifications(:logo_comment_david_mention_by_jz).read
|
|
||||||
|
|
||||||
assert_changes -> { notifications(:logo_comment_david_mention_by_jz).reload.read? }, from: true, to: false do
|
|
||||||
cards(:logo).unread_by(users(:david))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
test "unread marks notifications from the comments as unread" do
|
|
||||||
notifications(:layout_commented_kevin).read
|
notifications(:layout_commented_kevin).read
|
||||||
|
|
||||||
assert_changes -> { notifications(:layout_commented_kevin).reload.read? }, from: true, to: false do
|
assert_changes -> { notifications(:layout_commented_kevin).reload.read? }, from: true, to: false do
|
||||||
@@ -68,8 +49,8 @@ class Card::ReadableTest < ActiveSupport::TestCase
|
|||||||
david = users(:david)
|
david = users(:david)
|
||||||
|
|
||||||
assert card.accessible_to?(kevin)
|
assert card.accessible_to?(kevin)
|
||||||
kevin_notifications = [ notifications(:logo_published_kevin), notifications(:logo_assignment_kevin) ]
|
kevin_notification = notifications(:logo_assignment_kevin)
|
||||||
david_notifications = [ notifications(:logo_card_david_mention_by_jz), notifications(:logo_comment_david_mention_by_jz) ]
|
david_notification = notifications(:logo_mentioned_david)
|
||||||
|
|
||||||
# Kevin loses access
|
# Kevin loses access
|
||||||
card.board.accesses.find_by(user: kevin).destroy
|
card.board.accesses.find_by(user: kevin).destroy
|
||||||
@@ -78,14 +59,10 @@ class Card::ReadableTest < ActiveSupport::TestCase
|
|||||||
|
|
||||||
card.remove_inaccessible_notifications
|
card.remove_inaccessible_notifications
|
||||||
|
|
||||||
# Kevin's notifications removed
|
# Kevin's notification removed
|
||||||
kevin_notifications.each do |notification|
|
assert_not Notification.exists?(kevin_notification.id)
|
||||||
assert_not Notification.exists?(notification.id)
|
|
||||||
end
|
|
||||||
|
|
||||||
# David's notifications preserved
|
# David's notification preserved
|
||||||
david_notifications.each do |notification|
|
assert Notification.exists?(david_notification.id)
|
||||||
assert Notification.exists?(notification.id)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ class Notification::BundleTest < ActiveSupport::TestCase
|
|||||||
|
|
||||||
setup do
|
setup do
|
||||||
@user = users(:david)
|
@user = users(:david)
|
||||||
|
@user.notifications.destroy_all
|
||||||
@user.settings.bundle_email_every_few_hours!
|
@user.settings.bundle_email_every_few_hours!
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ class Notification::BundleTest < ActiveSupport::TestCase
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "notifications are bundled withing the aggregation period" do
|
test "notifications are bundled within the aggregation period" do
|
||||||
@user.notification_bundles.destroy_all
|
@user.notification_bundles.destroy_all
|
||||||
|
|
||||||
notification_1 = assert_difference -> { @user.notification_bundles.pending.count }, 1 do
|
notification_1 = assert_difference -> { @user.notification_bundles.pending.count }, 1 do
|
||||||
@@ -34,12 +35,12 @@ class Notification::BundleTest < ActiveSupport::TestCase
|
|||||||
travel_to 3.hours.from_now
|
travel_to 3.hours.from_now
|
||||||
|
|
||||||
notification_2 = assert_no_difference -> { @user.notification_bundles.count } do
|
notification_2 = assert_no_difference -> { @user.notification_bundles.count } do
|
||||||
@user.notifications.create!(source: events(:logo_published), creator: @user)
|
@user.notifications.create!(source: events(:layout_published), creator: @user)
|
||||||
end
|
end
|
||||||
travel_to 3.days.from_now
|
travel_to 3.days.from_now
|
||||||
|
|
||||||
notification_3 = assert_difference -> { @user.notification_bundles.pending.count }, 1 do
|
notification_3 = assert_difference -> { @user.notification_bundles.pending.count }, 1 do
|
||||||
@user.notifications.create!(source: events(:logo_published), creator: @user)
|
@user.notifications.create!(source: events(:text_published), creator: @user)
|
||||||
end
|
end
|
||||||
|
|
||||||
assert_equal 2, @user.notification_bundles.count
|
assert_equal 2, @user.notification_bundles.count
|
||||||
@@ -150,10 +151,10 @@ class Notification::BundleTest < ActiveSupport::TestCase
|
|||||||
|
|
||||||
test "out-of-order notification bundling should still work" do
|
test "out-of-order notification bundling should still work" do
|
||||||
first_notification = @user.notifications.create!(source: events(:logo_published), creator: @user)
|
first_notification = @user.notifications.create!(source: events(:logo_published), creator: @user)
|
||||||
second_notification = @user.notifications.create!(source: events(:logo_published), creator: @user)
|
second_notification = @user.notifications.create!(source: events(:layout_commented), creator: @user)
|
||||||
@user.notification_bundles.destroy_all
|
@user.notification_bundles.destroy_all
|
||||||
|
|
||||||
assert first_notification.created_at < second_notification.created_at
|
assert first_notification.updated_at <= second_notification.updated_at
|
||||||
@user.bundle(second_notification)
|
@user.bundle(second_notification)
|
||||||
@user.bundle(first_notification)
|
@user.bundle(first_notification)
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,7 @@ require "test_helper"
|
|||||||
class NotificationPusherTest < ActiveSupport::TestCase
|
class NotificationPusherTest < ActiveSupport::TestCase
|
||||||
setup do
|
setup do
|
||||||
@user = users(:david)
|
@user = users(:david)
|
||||||
@notification = @user.notifications.create!(
|
@notification = notifications(:logo_mentioned_david)
|
||||||
source: events(:logo_published),
|
|
||||||
creator: users(:jason)
|
|
||||||
)
|
|
||||||
@pusher = NotificationPusher.new(@notification)
|
@pusher = NotificationPusher.new(@notification)
|
||||||
|
|
||||||
@user.push_subscriptions.create!(
|
@user.push_subscriptions.create!(
|
||||||
|
|||||||
@@ -1,49 +1,67 @@
|
|||||||
require "test_helper"
|
require "test_helper"
|
||||||
|
|
||||||
class NotificationTest < ActiveSupport::TestCase
|
class NotificationTest < ActiveSupport::TestCase
|
||||||
test "unread marks notification as unread" do
|
|
||||||
notification = notifications(:logo_published_kevin)
|
|
||||||
notification.read # Mark as read first
|
|
||||||
|
|
||||||
assert_changes -> { notification.reload.read? }, from: true, to: false do
|
|
||||||
notification.unread
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
test "unread broadcasts to notifications" do
|
|
||||||
notification = notifications(:logo_published_kevin)
|
|
||||||
notification.read # Mark as read first
|
|
||||||
|
|
||||||
assert_turbo_stream_broadcasts([ notification.user, :notifications ], count: 1) do
|
|
||||||
perform_enqueued_jobs do
|
|
||||||
notification.unread
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
test "read marks notification as read" do
|
test "read marks notification as read" do
|
||||||
notification = notifications(:logo_published_kevin)
|
notification = notifications(:logo_assignment_kevin)
|
||||||
# Ensure it starts as unread
|
notification.update!(read_at: nil, unread_count: 2)
|
||||||
notification.update!(read_at: nil)
|
|
||||||
|
|
||||||
assert_changes -> { notification.reload.read? }, from: false, to: true do
|
assert_changes -> { notification.reload.read? }, from: false, to: true do
|
||||||
notification.read
|
notification.read
|
||||||
end
|
end
|
||||||
|
|
||||||
|
assert_equal 0, notification.unread_count
|
||||||
end
|
end
|
||||||
|
|
||||||
test "read broadcasts to notifications" do
|
test "unread marks notification as unread" do
|
||||||
notification = notifications(:logo_published_kevin)
|
notification = notifications(:logo_assignment_kevin)
|
||||||
# Ensure it starts as unread
|
notification.read
|
||||||
notification.update!(read_at: nil)
|
|
||||||
|
assert_changes -> { notification.reload.read? }, from: true, to: false do
|
||||||
|
notification.unread
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_equal 1, notification.unread_count
|
||||||
|
end
|
||||||
|
|
||||||
|
test "read_all marks all notifications and resets unread counts" do
|
||||||
|
kevin = users(:kevin)
|
||||||
|
|
||||||
|
kevin.notifications.unread.read_all
|
||||||
|
|
||||||
|
assert kevin.notifications.reload.all?(&:read?)
|
||||||
|
assert kevin.notifications.reload.all? { |n| n.unread_count == 0 }
|
||||||
|
end
|
||||||
|
|
||||||
|
test "unread_count tracks notification count per card" do
|
||||||
|
notification = notifications(:logo_assignment_kevin)
|
||||||
|
assert_equal 2, notification.unread_count
|
||||||
|
end
|
||||||
|
|
||||||
|
test "broadcasting on create prepends" do
|
||||||
|
kevin = users(:kevin)
|
||||||
|
layout = cards(:layout)
|
||||||
|
|
||||||
|
notifications(:layout_commented_kevin).destroy
|
||||||
|
|
||||||
|
perform_enqueued_jobs do
|
||||||
|
Notification.create!(user: kevin, source: events(:layout_commented), creator: users(:david))
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_turbo_stream_broadcasts [ kevin, :notifications ]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "broadcasting on update when read removes" do
|
||||||
|
notification = notifications(:layout_commented_kevin)
|
||||||
|
|
||||||
assert_turbo_stream_broadcasts([ notification.user, :notifications ], count: 1) do
|
assert_turbo_stream_broadcasts([ notification.user, :notifications ], count: 1) do
|
||||||
notification.read
|
perform_enqueued_jobs do
|
||||||
|
notification.read
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "deleting notification broadcasts its removal" do
|
test "broadcasting on destroy removes" do
|
||||||
notification = notifications(:logo_published_kevin)
|
notification = notifications(:logo_assignment_kevin)
|
||||||
notification.update!(read_at: nil)
|
|
||||||
|
|
||||||
assert_turbo_stream_broadcasts([ notification.user, :notifications ], count: 1) do
|
assert_turbo_stream_broadcasts([ notification.user, :notifications ], count: 1) do
|
||||||
notification.destroy
|
notification.destroy
|
||||||
|
|||||||
@@ -71,18 +71,18 @@ class Notifier::EventNotifierTest < ActiveSupport::TestCase
|
|||||||
test "create notifications on publish for mentionees" do
|
test "create notifications on publish for mentionees" do
|
||||||
users(:kevin).mentioned_by(users(:david), at: cards(:logo))
|
users(:kevin).mentioned_by(users(:david), at: cards(:logo))
|
||||||
|
|
||||||
assert_difference -> { users(:kevin).notifications.count }, +1 do
|
notifications = Notifier.for(events(:logo_published)).notify
|
||||||
Notifier.for(events(:logo_published)).notify
|
|
||||||
end
|
assert_includes notifications.map(&:user), users(:kevin)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "don'create notifications on publish for mentionees that are not watching" do
|
test "create notifications on publish for mentionees that are not watching" do
|
||||||
users(:kevin).mentioned_by(users(:david), at: cards(:logo))
|
users(:kevin).mentioned_by(users(:david), at: cards(:logo))
|
||||||
cards(:logo).unwatch_by(users(:kevin))
|
cards(:logo).unwatch_by(users(:kevin))
|
||||||
|
|
||||||
assert_difference -> { users(:kevin).notifications.count }, +1 do
|
notifications = Notifier.for(events(:logo_published)).notify
|
||||||
Notifier.for(events(:logo_published)).notify
|
|
||||||
end
|
assert_includes notifications.map(&:user), users(:kevin)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "don't create notifications on comment for mentionees" do
|
test "don't create notifications on comment for mentionees" do
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ require "test_helper"
|
|||||||
class User::NotifiableTest < ActiveSupport::TestCase
|
class User::NotifiableTest < ActiveSupport::TestCase
|
||||||
setup do
|
setup do
|
||||||
@user = users(:david)
|
@user = users(:david)
|
||||||
|
@user.notifications.destroy_all
|
||||||
@user.settings.bundle_email_every_few_hours!
|
@user.settings.bundle_email_every_few_hours!
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -12,7 +13,7 @@ class User::NotifiableTest < ActiveSupport::TestCase
|
|||||||
end
|
end
|
||||||
|
|
||||||
bundle = @user.notification_bundles.last
|
bundle = @user.notification_bundles.last
|
||||||
assert_equal notification.created_at, bundle.starts_at
|
assert_equal notification.updated_at, bundle.starts_at
|
||||||
assert bundle.pending?
|
assert bundle.pending?
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -20,7 +21,7 @@ class User::NotifiableTest < ActiveSupport::TestCase
|
|||||||
@user.notifications.create!(source: events(:logo_published), creator: @user)
|
@user.notifications.create!(source: events(:logo_published), creator: @user)
|
||||||
|
|
||||||
assert_no_difference -> { @user.notification_bundles.count } do
|
assert_no_difference -> { @user.notification_bundles.count } do
|
||||||
@user.notifications.create!(source: events(:logo_published), creator: @user)
|
@user.notifications.create!(source: events(:layout_published), creator: @user)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -67,13 +67,13 @@ class SmokeTest < ApplicationSystemTestCase
|
|||||||
test "dismissing notifications" do
|
test "dismissing notifications" do
|
||||||
sign_in_as(users(:david))
|
sign_in_as(users(:david))
|
||||||
|
|
||||||
notif = notifications(:logo_card_david_mention_by_jz)
|
notification = notifications(:logo_mentioned_david)
|
||||||
|
|
||||||
assert_selector "div##{dom_id(notif)}"
|
assert_selector "div##{dom_id(notification)}"
|
||||||
|
|
||||||
within_window(open_new_window) { visit card_url(notif.card) }
|
within_window(open_new_window) { visit card_url(notification.card) }
|
||||||
|
|
||||||
assert_no_selector "div##{dom_id(notif)}"
|
assert_no_selector "div##{dom_id(notification)}"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "dragging card to a new column" do
|
test "dragging card to a new column" do
|
||||||
|
|||||||
Reference in New Issue
Block a user