diff --git a/app/controllers/cards/readings_controller.rb b/app/controllers/cards/readings_controller.rb
index 2d8b51ac6..efd06e601 100644
--- a/app/controllers/cards/readings_controller.rb
+++ b/app/controllers/cards/readings_controller.rb
@@ -2,7 +2,7 @@ class Cards::ReadingsController < ApplicationController
include CardScoped
def create
- @notifications = Current.user.notifications.where(card: @card)
+ @notifications = Current.user.notifications.where(source: @card.events)
@notifications.each(&:read)
end
end
diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb
index 656edad54..f1cd88a6c 100644
--- a/app/helpers/notifications_helper.rb
+++ b/app/helpers/notifications_helper.rb
@@ -1,21 +1,25 @@
module NotificationsHelper
- def notification_title(notification)
- if notification.resource.is_a? Comment
- "RE: " + notification.card.title
- elsif notification_event_action(notification) == "assigned"
- "Assigned to you: " + notification.card.title
+ def event_notification_title(event)
+ if event.commented?
+ "RE: " + event.card.title
+ elsif event.assignment?
+ "Assigned to you: " + event.card.title
else
- notification.card.title
+ event.card.title
end
end
- def notification_body(notification)
- name = notification.creator.name
+ def event_notification_body(event)
+ name = event.creator.name
- case notification_event_action(notification)
- when "closed" then "Closed by #{name}"
- when "published" then "Added by #{name}"
- else name
+ if event.closed?
+ "Closed by #{name}"
+ elsif event.published?
+ "Added by #{name}"
+ elsif event.commented?
+ "#{strip_tags(event.comment.body_html).blank? ? "#{name} replied" : "#{event.creator.name}:" } #{strip_tags(event.comment.body_html).truncate(200)}"
+ else
+ name
end
end
@@ -36,8 +40,8 @@ module NotificationsHelper
class: "notification__unread_indicator btn borderless",
title: "Mark as read",
data: { turbo_frame: "_top" } do
- concat(image_tag("remove-med.svg", class: "unread_icon", size: 12, aria: { hidden: true }))
- concat(tag.span("Mark as read", class: "for-screen-reader"))
+ concat(image_tag("remove-med.svg", class: "unread_icon", size: 12, aria: { hidden: true }))
+ concat(tag.span("Mark as read", class: "for-screen-reader"))
end
end
@@ -46,17 +50,4 @@ module NotificationsHelper
tag.div id: "next_page", data: { controller: "fetch-on-visible", fetch_on_visible_url_value: notifications_path(page: @page.next_param) }
end
end
-
- private
- def notification_event_action(notification)
- if notification_is_for_initial_assignement?(notification)
- "assigned"
- else
- notification.event.action
- end
- end
-
- def notification_is_for_initial_assignement?(notification)
- notification.event.action == "published" && notification.card.assigned_to?(notification.user)
- end
end
diff --git a/app/jobs/generate_notifications_job.rb b/app/jobs/generate_notifications_job.rb
deleted file mode 100644
index e386b7b37..000000000
--- a/app/jobs/generate_notifications_job.rb
+++ /dev/null
@@ -1,7 +0,0 @@
-class GenerateNotificationsJob < ApplicationJob
- queue_as :default
-
- def perform(event)
- event.generate_notifications
- end
-end
diff --git a/app/jobs/mention/collect_job.rb b/app/jobs/mention/collect_job.rb
new file mode 100644
index 000000000..088e240dd
--- /dev/null
+++ b/app/jobs/mention/collect_job.rb
@@ -0,0 +1,7 @@
+class Mention::CollectJob < ApplicationJob
+ queue_as :default
+
+ def perform(record, mentioner:)
+ record.collect_mentions(mentioner:)
+ end
+end
diff --git a/app/jobs/notify_recipients_job.rb b/app/jobs/notify_recipients_job.rb
new file mode 100644
index 000000000..2f14c2e96
--- /dev/null
+++ b/app/jobs/notify_recipients_job.rb
@@ -0,0 +1,7 @@
+class NotifyRecipientsJob < ApplicationJob
+ queue_as :default
+
+ def perform(notifiable)
+ notifiable.notify_recipients
+ end
+end
diff --git a/app/models/card.rb b/app/models/card.rb
index 541d761a9..57ff542da 100644
--- a/app/models/card.rb
+++ b/app/models/card.rb
@@ -1,13 +1,10 @@
class Card < ApplicationRecord
- include Assignable, Colored, Engageable, Eventable, Golden,
- Messages, Notifiable, Pinnable, Closeable, Searchable, Staged,
- Statuses, Taggable, Watchable
+ include Assignable, Colored, Engageable, Eventable, Golden, Mentions,
+ Messages, Pinnable, Closeable, Searchable, Staged, Statuses, Taggable, Watchable
belongs_to :collection, touch: true
belongs_to :creator, class_name: "User", default: -> { Current.user }
- has_many :notifications, dependent: :destroy
-
has_one_attached :image, dependent: :purge_later
has_markdown :description
diff --git a/app/models/card/eventable.rb b/app/models/card/eventable.rb
index 1d3c4e13b..55a0245ea 100644
--- a/app/models/card/eventable.rb
+++ b/app/models/card/eventable.rb
@@ -12,8 +12,7 @@ module Card::Eventable
def track_event(action, creator: Current.user, **particulars)
if published?
- event = find_or_capture_event_summary.events.create! action: action, creator: creator, card: self, particulars: particulars
- event.generate_notifications_later
+ find_or_capture_event_summary.events.create! action: action, creator: creator, card: self, particulars: particulars
end
end
diff --git a/app/models/card/mentions.rb b/app/models/card/mentions.rb
new file mode 100644
index 000000000..dd135f24c
--- /dev/null
+++ b/app/models/card/mentions.rb
@@ -0,0 +1,12 @@
+module Card::Mentions
+ extend ActiveSupport::Concern
+
+ included do
+ include ::Mentions
+ end
+
+ private
+ def mentionable_content
+ description.to_plain_text
+ end
+end
diff --git a/app/models/concerns/mentions.rb b/app/models/concerns/mentions.rb
new file mode 100644
index 000000000..8de010a98
--- /dev/null
+++ b/app/models/concerns/mentions.rb
@@ -0,0 +1,33 @@
+module Mentions
+ extend ActiveSupport::Concern
+
+ included do
+ has_many :mentions, as: :container, dependent: :destroy
+ after_commit :collect_mentions_later, on: %i[ create update ]
+ end
+
+ def collect_mentions(mentioner: Current.user)
+ scan_mentionees.each do |mentionee|
+ mentionee.mentioned_by mentioner, at: self
+ end
+ end
+
+ private
+ def collect_mentions_later
+ Mention::CollectJob.perform_later(self, mentioner: Current.user)
+ end
+
+ def scan_mentionees
+ scan_mentioned_handles.filter_map do |mention|
+ mentionable_users.find { |user| user.mentionable_handles.include?(mention) }
+ end
+ end
+
+ def mentionable_users
+ collection.users
+ end
+
+ def scan_mentioned_handles
+ (mentionable_content || "").scan(/(? { card.touch(:last_active_at) }
- def commented?
- action == "commented"
+ def assignment?
+ action == "assigned" || initial_assignment?
end
- def generate_notifications
- Notifier.for(self)&.generate
+ # E.g: completed? is true if action == "completed"
+ def method_missing(method_name, *args, &block)
+ if method_name.to_s.end_with?("?")
+ action == method_name.to_s.chomp("?")
+ else
+ super
+ end
end
- def generate_notifications_later
- GenerateNotificationsJob.perform_later self
- end
+ private
+ def initial_assignment?
+ action == "published" && card.assigned_to?(creator)
+ end
end
diff --git a/app/models/mention.rb b/app/models/mention.rb
new file mode 100644
index 000000000..5f263d9be
--- /dev/null
+++ b/app/models/mention.rb
@@ -0,0 +1,7 @@
+class Mention < ApplicationRecord
+ include Notifiable
+
+ belongs_to :container, polymorphic: true
+ belongs_to :mentionee, class_name: "User", inverse_of: :mentions
+ belongs_to :mentioner, class_name: "User"
+end
diff --git a/app/models/message.rb b/app/models/message.rb
index 7d950b3b7..2947c99b5 100644
--- a/app/models/message.rb
+++ b/app/models/message.rb
@@ -1,7 +1,11 @@
class Message < ApplicationRecord
+ include Mentions
+
belongs_to :card, touch: true
delegated_type :messageable, types: Messageable::TYPES, inverse_of: :message, dependent: :destroy
scope :chronologically, -> { order created_at: :asc, id: :desc }
+
+ delegate :collection, to: :card
end
diff --git a/app/models/message/mentions.rb b/app/models/message/mentions.rb
new file mode 100644
index 000000000..fc8282413
--- /dev/null
+++ b/app/models/message/mentions.rb
@@ -0,0 +1,12 @@
+module Message::Mentions
+ extend ActiveSupport::Concern
+
+ included do
+ include ::Mentions
+ end
+
+ private
+ def mentionable_content
+ messageable.try(:body_plain_text)
+ end
+end
diff --git a/app/models/notification.rb b/app/models/notification.rb
index 9fbb94a5b..b4b323a3d 100644
--- a/app/models/notification.rb
+++ b/app/models/notification.rb
@@ -1,14 +1,13 @@
class Notification < ApplicationRecord
belongs_to :user
- belongs_to :event
- belongs_to :card
+ belongs_to :source, polymorphic: true
belongs_to :resource, polymorphic: true
scope :unread, -> { where(read_at: nil) }
scope :read, -> { where.not(read_at: nil) }
scope :ordered, -> { order(read_at: :desc, created_at: :desc) }
- delegate :creator, to: :event
+ delegate :creator, to: :source
after_create_commit :broadcast_unread
def self.read_all
diff --git a/app/models/notifier.rb b/app/models/notifier.rb
index 19626be5f..455a900e3 100644
--- a/app/models/notifier.rb
+++ b/app/models/notifier.rb
@@ -1,40 +1,37 @@
class Notifier
- attr_reader :event
+ attr_reader :source
- delegate :creator, to: :event
+ delegate :creator, to: :source
class << self
- def for(event)
- "Notifier::#{event.action.classify}".safe_constantize&.new(event)
+ def for(source)
+ case source
+ when Event
+ "Notifier::Events::#{source.action.classify}".safe_constantize&.new(source)
+ when Mention
+ Notifier::Mentions
+ end
end
end
- def generate
+ def notify
if should_notify?
recipients.map do |recipient|
- Notification.create! user: recipient, event: event, card: card, resource: resource
+ Notification.create! user: recipient, source: source, resource: resource
end
end
end
private
- def initialize(event)
- @event = event
+ def initialize(source)
+ @source = source
end
def should_notify?
- !event.creator.system?
- end
-
- def recipients
- card.watchers_and_subscribers.without(creator)
+ !creator.system?
end
def resource
- card
- end
-
- def card
- event.summary.message.card
+ source
end
end
diff --git a/app/models/notifier/assigned.rb b/app/models/notifier/assigned.rb
deleted file mode 100644
index 843376435..000000000
--- a/app/models/notifier/assigned.rb
+++ /dev/null
@@ -1,6 +0,0 @@
-class Notifier::Assigned < Notifier
- private
- def recipients
- event.assignees.excluding(card.collection.access_only_users)
- end
-end
diff --git a/app/models/notifier/closed.rb b/app/models/notifier/closed.rb
deleted file mode 100644
index f090b0308..000000000
--- a/app/models/notifier/closed.rb
+++ /dev/null
@@ -1,2 +0,0 @@
-class Notifier::Closed < Notifier
-end
diff --git a/app/models/notifier/commented.rb b/app/models/notifier/commented.rb
deleted file mode 100644
index 42ad4e5c8..000000000
--- a/app/models/notifier/commented.rb
+++ /dev/null
@@ -1,6 +0,0 @@
-class Notifier::Commented < Notifier
- private
- def resource
- event.comment
- end
-end
diff --git a/app/models/notifier/events/assigned.rb b/app/models/notifier/events/assigned.rb
new file mode 100644
index 000000000..fb106c677
--- /dev/null
+++ b/app/models/notifier/events/assigned.rb
@@ -0,0 +1,6 @@
+class Notifier::Events::Assigned < Notifier::Events::Base
+ private
+ def recipients
+ source.assignees.excluding(card.collection.access_only_users)
+ end
+end
diff --git a/app/models/notifier/events/base.rb b/app/models/notifier/events/base.rb
new file mode 100644
index 000000000..b0e9d0489
--- /dev/null
+++ b/app/models/notifier/events/base.rb
@@ -0,0 +1,12 @@
+class Notifier::Events::Base < Notifier
+ delegate :card, to: :source
+
+ private
+ def resource
+ card
+ end
+
+ def recipients
+ card.watchers_and_subscribers.without(creator)
+ end
+end
diff --git a/app/models/notifier/events/closed.rb b/app/models/notifier/events/closed.rb
new file mode 100644
index 000000000..133ca75c8
--- /dev/null
+++ b/app/models/notifier/events/closed.rb
@@ -0,0 +1,2 @@
+class Notifier::Events::Closed < Notifier::Events::Base
+end
diff --git a/app/models/notifier/events/commented.rb b/app/models/notifier/events/commented.rb
new file mode 100644
index 000000000..55497a117
--- /dev/null
+++ b/app/models/notifier/events/commented.rb
@@ -0,0 +1,6 @@
+class Notifier::Events::Commented < Notifier::Events::Base
+ private
+ def resource
+ source.comment
+ end
+end
diff --git a/app/models/notifier/published.rb b/app/models/notifier/events/published.rb
similarity index 67%
rename from app/models/notifier/published.rb
rename to app/models/notifier/events/published.rb
index ce3917e8f..ff30d0219 100644
--- a/app/models/notifier/published.rb
+++ b/app/models/notifier/events/published.rb
@@ -1,4 +1,4 @@
-class Notifier::Published < Notifier
+class Notifier::Events::Published < Notifier::Events::Base
private
def recipients
card.watchers_and_subscribers(include_only_watching: true).without(creator)
diff --git a/app/models/user.rb b/app/models/user.rb
index a22841c42..c09455bfb 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -1,5 +1,5 @@
class User < ApplicationRecord
- include Accessor, Assignee, Avatar, Role, Transferable
+ include Accessor, Assignee, Avatar, Mentionable, Named, Role, Transferable
has_many :sessions, dependent: :destroy
has_secure_password validations: false
@@ -15,12 +15,6 @@ class User < ApplicationRecord
normalizes :email_address, with: ->(value) { value.strip.downcase }
- scope :alphabetically, -> { order("lower(name)") }
-
- def initials
- name.to_s.scan(/\b\p{L}/).join.upcase
- end
-
def deactivate
transaction do
sessions.delete_all
diff --git a/app/models/user/mentionable.rb b/app/models/user/mentionable.rb
new file mode 100644
index 000000000..0d1c9fe36
--- /dev/null
+++ b/app/models/user/mentionable.rb
@@ -0,0 +1,20 @@
+module User::Mentionable
+ extend ActiveSupport::Concern
+
+ included do
+ has_many :mentions, dependent: :destroy, inverse_of: :mentionee
+ end
+
+ def mentioned_by(mentioner, at:)
+ mentions.create! container: at, mentioner:
+ end
+
+ def mentionable_handles
+ [ initials, first_name, first_name_with_last_name_initial ].collect(&:downcase)
+ end
+
+ private
+ def first_name_with_last_name_initial
+ "#{first_name}#{last_name&.first}"
+ end
+end
diff --git a/app/models/user/named.rb b/app/models/user/named.rb
new file mode 100644
index 000000000..67420ec72
--- /dev/null
+++ b/app/models/user/named.rb
@@ -0,0 +1,19 @@
+module User::Named
+ extend ActiveSupport::Concern
+
+ included do
+ scope :alphabetically, -> { order("lower(name)") }
+ end
+
+ def first_name
+ name.split(/\s/).first
+ end
+
+ def last_name
+ name.split(/\s/, 2).last
+ end
+
+ def initials
+ name.scan(/\b\p{L}/).join.upcase
+ end
+end
diff --git a/app/views/notifications/_notification.html.erb b/app/views/notifications/_notification.html.erb
index 8cb22eeb1..36ec74370 100644
--- a/app/views/notifications/_notification.html.erb
+++ b/app/views/notifications/_notification.html.erb
@@ -5,15 +5,7 @@
-
<%= notification_title(notification) %>
-
- <% if notification.event.action == "commented" %>
- <%= "#{strip_tags(notification.event.comment.body_html).blank? ? "#{notification.event.creator.name} replied" : "#{notification.event.creator.name}:" } #{strip_tags(notification.event.comment.body_html).truncate(200)}" %>
- <% else %>
- <%= notification_body(notification) %>
- <% end %>
-
-
<%= notification.card.collection.name %> · <%= local_datetime_tag(notification.created_at, style: :ago) %>
+ <%= render "notifications/notification/#{notification.source_type.underscore}", notification: notification %>
<% end %>
<% end %>
diff --git a/app/views/notifications/notification/_event.html.erb b/app/views/notifications/notification/_event.html.erb
new file mode 100644
index 000000000..b7b1231db
--- /dev/null
+++ b/app/views/notifications/notification/_event.html.erb
@@ -0,0 +1,7 @@
+<%= event = notification.source %>
+
+<%= event_notification_title(event) %>
+
+ <%= event_notification_body(event) %>
+
+<%= notification.source.card.collection.name %> · <%= local_datetime_tag(notification.created_at, style: :ago) %>
diff --git a/app/views/notifications/notification/_mention.html.erb b/app/views/notifications/notification/_mention.html.erb
new file mode 100644
index 000000000..996095e6d
--- /dev/null
+++ b/app/views/notifications/notification/_mention.html.erb
@@ -0,0 +1,11 @@
+<% event = notification.event %>
+
+<%= event_notification_title(notification) %>
+
+ <% if event.action == "commented" %>
+ <%= "#{strip_tags(event.comment.body_html).blank? ? "#{event.creator.name} replied" : "#{event.creator.name}:" } #{strip_tags(event.comment.body_html).truncate(200)}" %>
+ <% else %>
+ <%= event_notification_body(notification) %>
+ <% end %>
+
+<%= event.card.collection.name %> · <%= local_datetime_tag(notification.created_at, style: :ago) %>
diff --git a/db/migrate/20250421120008_create_mentions.rb b/db/migrate/20250421120008_create_mentions.rb
new file mode 100644
index 000000000..da4923950
--- /dev/null
+++ b/db/migrate/20250421120008_create_mentions.rb
@@ -0,0 +1,11 @@
+class CreateMentions < ActiveRecord::Migration[8.1]
+ def change
+ create_table :mentions do |t|
+ t.references :container, polymorphic: true, null: false, index: true
+ t.references :mentionee, foreign_key: { to_table: :users }, null: false
+ t.references :mentioner, foreign_key: { to_table: :users }, null: false
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20250422054950_add_source_to_notifications.rb b/db/migrate/20250422054950_add_source_to_notifications.rb
new file mode 100644
index 000000000..7108d680b
--- /dev/null
+++ b/db/migrate/20250422054950_add_source_to_notifications.rb
@@ -0,0 +1,11 @@
+class AddSourceToNotifications < ActiveRecord::Migration[8.1]
+ def change
+ add_reference :notifications, :source, polymorphic: true, index: true
+
+ execute <<~SQL
+ update notifications set source_type = 'Event'
+ SQL
+
+ change_column_null :notifications, :source_type, false
+ end
+end
diff --git a/db/migrate/20250422062930_remove_unused_columns_from_notifications.rb b/db/migrate/20250422062930_remove_unused_columns_from_notifications.rb
new file mode 100644
index 000000000..30f689c88
--- /dev/null
+++ b/db/migrate/20250422062930_remove_unused_columns_from_notifications.rb
@@ -0,0 +1,6 @@
+class RemoveUnusedColumnsFromNotifications < ActiveRecord::Migration[8.1]
+ def change
+ remove_column :notifications, :event_id
+ remove_column :notifications, :card_id
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 3a56a34ae..12c2edba1 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.1].define(version: 2025_04_18_150259) do
+ActiveRecord::Schema[8.1].define(version: 2025_04_22_062930) do
create_table "accesses", force: :cascade do |t|
t.integer "collection_id", null: false
t.datetime "created_at", null: false
@@ -212,6 +212,18 @@ ActiveRecord::Schema[8.1].define(version: 2025_04_18_150259) do
t.index ["tag_id"], name: "index_filters_tags_on_tag_id"
end
+ create_table "mentions", force: :cascade do |t|
+ t.integer "container_id", null: false
+ t.string "container_type", null: false
+ t.datetime "created_at", null: false
+ t.integer "mentionee_id", null: false
+ t.integer "mentioner_id", null: false
+ t.datetime "updated_at", null: false
+ t.index ["container_type", "container_id"], name: "index_mentions_on_container"
+ t.index ["mentionee_id"], name: "index_mentions_on_mentionee_id"
+ t.index ["mentioner_id"], name: "index_mentions_on_mentioner_id"
+ end
+
create_table "messages", force: :cascade do |t|
t.integer "card_id", null: false
t.datetime "created_at", null: false
@@ -223,17 +235,16 @@ ActiveRecord::Schema[8.1].define(version: 2025_04_18_150259) do
end
create_table "notifications", force: :cascade do |t|
- t.integer "card_id", null: false
t.datetime "created_at", null: false
- t.integer "event_id", null: false
t.datetime "read_at"
t.integer "resource_id", null: false
t.string "resource_type", null: false
+ t.integer "source_id"
+ t.string "source_type", null: false
t.datetime "updated_at", null: false
t.integer "user_id", null: false
- t.index ["card_id"], name: "index_notifications_on_card_id"
- t.index ["event_id"], name: "index_notifications_on_event_id"
t.index ["resource_type", "resource_id"], name: "index_notifications_on_resource"
+ t.index ["source_type", "source_id"], name: "index_notifications_on_source"
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"
end
@@ -328,9 +339,9 @@ ActiveRecord::Schema[8.1].define(version: 2025_04_18_150259) do
add_foreign_key "collections", "workflows"
add_foreign_key "events", "cards"
add_foreign_key "events", "event_summaries", column: "summary_id"
+ add_foreign_key "mentions", "users", column: "mentionee_id"
+ add_foreign_key "mentions", "users", column: "mentioner_id"
add_foreign_key "messages", "cards"
- add_foreign_key "notifications", "cards"
- add_foreign_key "notifications", "events"
add_foreign_key "notifications", "users"
add_foreign_key "pins", "cards"
add_foreign_key "pins", "users"
diff --git a/db/schema_cache.yml b/db/schema_cache.yml
index 8152c393d..bab7bb58f 100644
--- a/db/schema_cache.yml
+++ b/db/schema_cache.yml
@@ -637,6 +637,50 @@ columns:
default_function:
collation:
comment:
+ mentions:
+ - *5
+ - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
+ auto_increment:
+ name: container_type
+ cast_type: *7
+ sql_type_metadata: *8
+ 'null': false
+ default:
+ default_function:
+ collation:
+ comment:
+ - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
+ auto_increment:
+ name: container_id
+ cast_type: *1
+ sql_type_metadata: *2
+ 'null': false
+ default:
+ default_function:
+ collation:
+ comment:
+ - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
+ auto_increment:
+ name: mentionee_id
+ cast_type: *1
+ sql_type_metadata: *2
+ 'null': false
+ default:
+ default_function:
+ collation:
+ comment:
+ - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
+ auto_increment:
+ name: mentioner_id
+ cast_type: *1
+ sql_type_metadata: *2
+ 'null': false
+ default:
+ default_function:
+ collation:
+ comment:
+ - *6
+ - *9
messages:
- *5
- *21
@@ -664,18 +708,7 @@ columns:
- *9
notifications:
- *5
- - *21
- *6
- - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- auto_increment:
- name: event_id
- cast_type: *1
- sql_type_metadata: *2
- 'null': false
- default:
- default_function:
- collation:
- comment:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: read_at
@@ -708,6 +741,26 @@ columns:
comment:
- *9
- *28
+ - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
+ auto_increment:
+ name: source_type
+ cast_type: *7
+ sql_type_metadata: *8
+ 'null': false
+ default:
+ default_function:
+ collation:
+ comment:
+ - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
+ auto_increment:
+ name: source_id
+ cast_type: *1
+ sql_type_metadata: *2
+ 'null': true
+ default:
+ default_function:
+ collation:
+ comment:
pins:
- *5
- *21
@@ -921,6 +974,7 @@ primary_keys:
filters: id
filters_stages:
filters_tags:
+ mentions: id
messages: id
notifications: id
pins: id
@@ -958,6 +1012,7 @@ data_sources:
filters: true
filters_stages: true
filters_tags: true
+ mentions: true
messages: true
notifications: true
pins: true
@@ -1598,6 +1653,56 @@ indexes:
nulls_not_distinct:
comment:
valid: true
+ mentions:
+ - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
+ table: mentions
+ name: index_mentions_on_mentioner_id
+ unique: false
+ columns:
+ - mentioner_id
+ lengths: {}
+ orders: {}
+ opclasses: {}
+ where:
+ type:
+ using:
+ include:
+ nulls_not_distinct:
+ comment:
+ valid: true
+ - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
+ table: mentions
+ name: index_mentions_on_mentionee_id
+ unique: false
+ columns:
+ - mentionee_id
+ lengths: {}
+ orders: {}
+ opclasses: {}
+ where:
+ type:
+ using:
+ include:
+ nulls_not_distinct:
+ comment:
+ valid: true
+ - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
+ table: mentions
+ name: index_mentions_on_container
+ unique: false
+ columns:
+ - container_type
+ - container_id
+ lengths: {}
+ orders: {}
+ opclasses: {}
+ where:
+ type:
+ using:
+ include:
+ nulls_not_distinct:
+ comment:
+ valid: true
messages:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: messages
@@ -1633,6 +1738,23 @@ indexes:
comment:
valid: true
notifications:
+ - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
+ table: notifications
+ name: index_notifications_on_source
+ unique: false
+ columns:
+ - source_type
+ - source_id
+ lengths: {}
+ orders: {}
+ opclasses: {}
+ where:
+ type:
+ using:
+ include:
+ nulls_not_distinct:
+ comment:
+ valid: true
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: notifications
name: index_notifications_on_user_id
@@ -1686,38 +1808,6 @@ indexes:
nulls_not_distinct:
comment:
valid: true
- - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
- table: notifications
- name: index_notifications_on_event_id
- unique: false
- columns:
- - event_id
- lengths: {}
- orders: {}
- opclasses: {}
- where:
- type:
- using:
- include:
- nulls_not_distinct:
- comment:
- valid: true
- - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
- table: notifications
- name: index_notifications_on_card_id
- unique: false
- columns:
- - card_id
- lengths: {}
- orders: {}
- opclasses: {}
- where:
- type:
- using:
- include:
- nulls_not_distinct:
- comment:
- valid: true
pins:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: pins
@@ -1938,4 +2028,4 @@ indexes:
comment:
valid: true
workflows: []
-version: 20250418150259
+version: 20250422062930
diff --git a/test/fixtures/notifications.yml b/test/fixtures/notifications.yml
index 7579f8eb6..ef44452f7 100644
--- a/test/fixtures/notifications.yml
+++ b/test/fixtures/notifications.yml
@@ -1,20 +1,17 @@
logo_published_kevin:
user: kevin
- event: logo_published
- card: logo
+ source: logo_published (Event)
resource: logo (Card)
created_at: <%= 1.week.ago %>
logo_assignment_kevin:
user: kevin
- event: logo_assignment_km
- card: logo
+ source: logo_assignment_km (Event)
resource: logo (Card)
created_at: <%= 1.week.ago %>
layout_commented_kevin:
user: kevin
- event: layout_commented
- card: layout
+ source: layout_commented (Event)
resource: layout_overflowing_david (Comment)
created_at: <%= 1.week.ago %>
diff --git a/test/models/concerns/mentions_test.rb b/test/models/concerns/mentions_test.rb
new file mode 100644
index 000000000..3ed1c4d49
--- /dev/null
+++ b/test/models/concerns/mentions_test.rb
@@ -0,0 +1,23 @@
+require "test_helper"
+
+class MentionsTest < ActiveSupport::TestCase
+ setup do
+ Current.session = sessions(:david)
+ end
+
+ test "collect mentions on create" do
+ assert_difference -> { Mention.count }, +1 do
+ perform_enqueued_jobs only: Mention::CollectJob do
+ collections(:writebook).cards.create title: "Cleanup", description: "Did you finish up with the cleanup, @david?"
+ end
+ end
+ end
+
+ test "collect mentions on update" do
+ assert_difference -> { Mention.count }, +1 do
+ perform_enqueued_jobs only: Mention::CollectJob do
+ cards(:logo).update! description: "Did you finish up with the cleanup, @david?"
+ end
+ end
+ end
+end
diff --git a/test/models/notifier_test.rb b/test/models/notifier_test.rb
index 1e01e18e8..2e915bded 100644
--- a/test/models/notifier_test.rb
+++ b/test/models/notifier_test.rb
@@ -2,7 +2,7 @@ require "test_helper"
class NotifierTest < ActiveSupport::TestCase
test "for returns the matching notifier class for the event" do
- assert_kind_of Notifier::Published, Notifier.for(events(:logo_published))
+ assert_kind_of Notifier::Events::Published, Notifier.for(events(:logo_published))
end
test "generate does not create notifications if the event was system-generated" do
@@ -10,12 +10,12 @@ class NotifierTest < ActiveSupport::TestCase
events(:logo_published).update!(creator: User.system)
assert_no_difference -> { Notification.count } do
- Notifier.for(events(:logo_published)).generate
+ Notifier.for(events(:logo_published)).notify
end
end
test "creates a notification for each watcher, other than the event creator" do
- notifications = Notifier.for(events(:layout_commented)).generate
+ notifications = Notifier.for(events(:layout_commented)).notify
assert_equal [ users(:kevin) ], notifications.map(&:user)
end
@@ -23,19 +23,19 @@ class NotifierTest < ActiveSupport::TestCase
test "does not create a notification for access-only users" do
collections(:writebook).access_for(users(:kevin)).access_only!
- notifications = Notifier.for(events(:layout_commented)).generate
+ notifications = Notifier.for(events(:layout_commented)).notify
assert_equal [ users(:kevin) ], notifications.map(&:user)
end
test "the published event creates notifications for subscribers as well as watchers" do
- notifications = Notifier.for(events(:logo_published)).generate
+ notifications = Notifier.for(events(:logo_published)).notify
assert_equal users(:kevin, :jz).sort, notifications.map(&:user).sort
end
test "links to the card" do
- Notifier.for(events(:logo_published)).generate
+ Notifier.for(events(:logo_published)).notify
assert_equal cards(:logo), Notification.last.resource
end
@@ -44,7 +44,7 @@ class NotifierTest < ActiveSupport::TestCase
collections(:writebook).access_for(users(:jz)).watching!
collections(:writebook).access_for(users(:kevin)).everything!
- notifications = Notifier.for(events(:logo_assignment_jz)).generate
+ notifications = Notifier.for(events(:logo_assignment_jz)).notify
assert_equal [ users(:jz) ], notifications.map(&:user)
end
@@ -52,7 +52,7 @@ class NotifierTest < ActiveSupport::TestCase
test "assignment events do not notify users who are access-only for the collection" do
collections(:writebook).access_for(users(:jz)).access_only!
- notifications = Notifier.for(events(:logo_assignment_jz)).generate
+ notifications = Notifier.for(events(:logo_assignment_jz)).notify
assert_empty notifications
end
diff --git a/test/models/user/mentionable_test.rb b/test/models/user/mentionable_test.rb
new file mode 100644
index 000000000..43e70792c
--- /dev/null
+++ b/test/models/user/mentionable_test.rb
@@ -0,0 +1,7 @@
+require 'test_helper'
+
+class User::MentionableTest < ActiveSupport::TestCase
+ test "mentionable handles" do
+ assert_equal [ "dhh", "david", "davidh" ], User.new(name: "David Heinemeier-Hansson").mentionable_handles
+ end
+end
diff --git a/test/models/user/named_test.rb b/test/models/user/named_test.rb
new file mode 100644
index 000000000..8993dbbf8
--- /dev/null
+++ b/test/models/user/named_test.rb
@@ -0,0 +1,38 @@
+require "test_helper"
+
+class User::NamedTest < ActiveSupport::TestCase
+ test "initials" do
+ assert_initials "M", name: "Michael"
+ assert_initials "SD", name: "Salvador Dali"
+ assert_initials "LMM", name: "Lin-Manuel Miranda"
+ assert_initials "OCD", name: "O'Conor Díez"
+ assert_initials "ACG", name: "Anne Christine García"
+ assert_initials "ÁL", name: "Ángela López"
+ end
+
+ test "first name" do
+ assert_first_name "Michael", "Michael"
+ assert_first_name "Salvador", "Salvador Dali"
+ assert_first_name "Lin-Manuel", "Lin-Manuel Miranda"
+ assert_first_name "Anne", "Anne Christine García"
+ end
+
+ test "last name" do
+ assert_last_name "Dali", "Salvador Dali"
+ assert_last_name "Miranda", "Lin_Manuel Miranda"
+ assert_last_name "Christine García", "Anne Christine García"
+ end
+
+ private
+ def assert_initials(expected, **attributes)
+ assert_equal expected, User.new(attributes).initials
+ end
+
+ def assert_first_name(expected, name)
+ assert_equal expected, User.new(name: name).first_name
+ end
+
+ def assert_last_name(expected, name)
+ assert_equal expected, User.new(name: name).last_name
+ end
+end
diff --git a/test/test_helper.rb b/test/test_helper.rb
index 1447430b1..cb34fd812 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -10,6 +10,7 @@ module ActiveSupport
# Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
fixtures :all
+ include ActiveJob::TestHelper
include CardTestHelper, ChangeTestHelper, SessionTestHelper
end
end