From aa3acfeaa84158f192daf5e4969171e48055a2cf Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Tue, 22 Apr 2025 11:21:30 +0200 Subject: [PATCH] Add plain text mentions --- app/controllers/cards/readings_controller.rb | 2 +- app/helpers/notifications_helper.rb | 45 ++--- app/jobs/generate_notifications_job.rb | 7 - app/jobs/mention/collect_job.rb | 7 + app/jobs/notify_recipients_job.rb | 7 + app/models/card.rb | 7 +- app/models/card/eventable.rb | 3 +- app/models/card/mentions.rb | 12 ++ app/models/concerns/mentions.rb | 33 ++++ app/models/concerns/notifiable.rb | 13 +- app/models/event.rb | 22 ++- app/models/mention.rb | 7 + app/models/message.rb | 4 + app/models/message/mentions.rb | 12 ++ app/models/notification.rb | 5 +- app/models/notifier.rb | 33 ++-- app/models/notifier/assigned.rb | 6 - app/models/notifier/closed.rb | 2 - app/models/notifier/commented.rb | 6 - app/models/notifier/events/assigned.rb | 6 + app/models/notifier/events/base.rb | 12 ++ app/models/notifier/events/closed.rb | 2 + app/models/notifier/events/commented.rb | 6 + app/models/notifier/{ => events}/published.rb | 2 +- app/models/user.rb | 8 +- app/models/user/mentionable.rb | 20 ++ app/models/user/named.rb | 19 ++ .../notifications/_notification.html.erb | 10 +- .../notification/_event.html.erb | 7 + .../notification/_mention.html.erb | 11 ++ db/migrate/20250421120008_create_mentions.rb | 11 ++ ...50422054950_add_source_to_notifications.rb | 11 ++ ...emove_unused_columns_from_notifications.rb | 6 + db/schema.rb | 25 ++- db/schema_cache.yml | 178 +++++++++++++----- test/fixtures/notifications.yml | 9 +- test/models/concerns/mentions_test.rb | 23 +++ test/models/notifier_test.rb | 16 +- test/models/user/mentionable_test.rb | 7 + test/models/user/named_test.rb | 38 ++++ test/test_helper.rb | 1 + 41 files changed, 493 insertions(+), 168 deletions(-) delete mode 100644 app/jobs/generate_notifications_job.rb create mode 100644 app/jobs/mention/collect_job.rb create mode 100644 app/jobs/notify_recipients_job.rb create mode 100644 app/models/card/mentions.rb create mode 100644 app/models/concerns/mentions.rb create mode 100644 app/models/mention.rb create mode 100644 app/models/message/mentions.rb delete mode 100644 app/models/notifier/assigned.rb delete mode 100644 app/models/notifier/closed.rb delete mode 100644 app/models/notifier/commented.rb create mode 100644 app/models/notifier/events/assigned.rb create mode 100644 app/models/notifier/events/base.rb create mode 100644 app/models/notifier/events/closed.rb create mode 100644 app/models/notifier/events/commented.rb rename app/models/notifier/{ => events}/published.rb (67%) create mode 100644 app/models/user/mentionable.rb create mode 100644 app/models/user/named.rb create mode 100644 app/views/notifications/notification/_event.html.erb create mode 100644 app/views/notifications/notification/_mention.html.erb create mode 100644 db/migrate/20250421120008_create_mentions.rb create mode 100644 db/migrate/20250422054950_add_source_to_notifications.rb create mode 100644 db/migrate/20250422062930_remove_unused_columns_from_notifications.rb create mode 100644 test/models/concerns/mentions_test.rb create mode 100644 test/models/user/mentionable_test.rb create mode 100644 test/models/user/named_test.rb 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