iachat/app/models/scheduled_message.rb
Cayo P. R. Oliveira 9a05ff5247
feat: find scheduled message (#237)
* feat(scheduled-messages): scroll to sent message from sidebar

- Expose message_id in JBuilder serialization and push_event_data
- Add HIGHLIGHT_MESSAGE bus event for in-page message highlighting
- Add 'Go to message' button on sent scheduled messages in sidebar
- Enhance onScrollToMessage to fetch messages around target when not in DOM
- Extend Message.vue highlight to work with bus events (not just route query)
- Add i18n keys for EN and pt-BR

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* refactor(scheduled-messages): make sent card clickable instead of button

Replace the 'Go to message' button with a clickable card. The entire
sent scheduled message card now has cursor-pointer, hover highlight,
and a tooltip — clicking anywhere on it scrolls to the message.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(scheduled-messages): address PR review feedback

- Use camelCase value for HIGHLIGHT_MESSAGE bus event ('highlightMessage')
- Show toast alert when message not found after fetch or on fetch error
- Use the MESSAGE_NOT_FOUND i18n key that was previously unused

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(scheduled-messages): use messageId query param for find message

Replace direct bus event emission with route navigation using
?messageId= query param, reusing the same proven mechanism used by
search results and copy-message-link.

Changes:
- ScheduledMessageItem: router.replace with ?messageId= instead of
  emitting SCROLL_TO_MESSAGE directly
- ConversationView: handle ?messageId= on same-conversation (was
  previously skipped), fetch messages around target and scroll
- MessagesView: clean up ?messageId= from URL after scroll/error

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(scheduled-messages): add toast feedback for find message

Show a persistent "Searching for message..." toast while fetching,
auto-dismissed on success. Show "Message not found" error toast if
the message cannot be located.

Uses usePendingAlert for the loading state in both ConversationView
(initial fetch) and MessagesView (fallback fetch).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: prevent scroll overshoot when navigating to message

Remove the immediate fetchPreviousMessages() call after
scrollIntoView({ behavior: smooth }). The fetch was prepending
messages above the target while the smooth scroll animation was
still running, shifting the DOM and causing the scroll to stop
short of the target message. The scroll event handler will
naturally trigger message loading when the user scrolls up later.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* chore(scheduled-messages): remove redundant clearMessageIdFromRoute calls

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-19 22:35:04 -03:00

179 lines
5.5 KiB
Ruby

# == Schema Information
#
# Table name: scheduled_messages
#
# id :bigint not null, primary key
# author_type :string
# content :text
# scheduled_at :datetime
# status :integer default("draft"), not null
# template_params :jsonb
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# author_id :bigint
# conversation_id :bigint not null
# inbox_id :bigint not null
# message_id :bigint
#
# Indexes
#
# idx_on_author_type_author_id_status_6997d67ef6 (author_type,author_id,status)
# index_scheduled_messages_on_account_id (account_id)
# index_scheduled_messages_on_account_id_and_status (account_id,status)
# index_scheduled_messages_on_author (author_type,author_id)
# index_scheduled_messages_on_conversation_id (conversation_id)
# index_scheduled_messages_on_conversation_id_and_scheduled_at (conversation_id,scheduled_at)
# index_scheduled_messages_on_conversation_id_and_status (conversation_id,status)
# index_scheduled_messages_on_inbox_id (inbox_id)
# index_scheduled_messages_on_inbox_id_and_status (inbox_id,status)
# index_scheduled_messages_on_message_id (message_id)
# index_scheduled_messages_on_status_and_scheduled_at (status,scheduled_at)
#
# Foreign Keys
#
# fk_rails_... (account_id => accounts.id)
# fk_rails_... (conversation_id => conversations.id)
# fk_rails_... (inbox_id => inboxes.id)
# fk_rails_... (message_id => messages.id)
#
class ScheduledMessage < ApplicationRecord
include Rails.application.routes.url_helpers
belongs_to :account
belongs_to :inbox
belongs_to :conversation
belongs_to :author, polymorphic: true, optional: true
belongs_to :message, optional: true
has_one_attached :attachment
enum status: { draft: 0, pending: 1, sent: 2, failed: 3 }
before_validation :process_message_variables, if: :content_changed?
validates :scheduled_at, presence: true, unless: -> { status == 'draft' }
validates :content, presence: true, unless: :content_optional?
validates :content, length: { maximum: 150_000 }
validate :status_must_be_draft_or_pending, on: :create
validate :must_be_editable, on: :update
validate :scheduled_at_must_be_in_future, if: :should_validate_future_schedule?
scope :due_for_sending, -> { pending.where('scheduled_at <= ?', Time.current) }
def due_for_sending?
scheduled_at.present? && scheduled_at <= Time.current
end
def push_event_data
data = {
id: id,
content: content,
inbox_id: inbox_id,
conversation_id: conversation.display_id,
account_id: account_id,
status: status,
scheduled_at: scheduled_at&.to_i,
template_params: template_params,
author_id: author_id,
author_type: author_type,
message_id: message_id,
created_at: created_at.to_i,
updated_at: updated_at.to_i
}
data[:author] = author_event_data if author.present?
data[:attachment] = attachment_data if attachment.attached?
data
end
def attachment_data
return unless attachment.attached?
{
id: attachment.id,
scheduled_message_id: id,
file_type: attachment.content_type,
account_id: account_id,
file_url: url_for(attachment),
blob_id: attachment.blob.signed_id,
filename: attachment.filename.to_s
}
end
private
def status_must_be_draft_or_pending
return if draft? || pending?
errors.add(:status, 'must be draft or pending when creating a scheduled message')
end
def must_be_editable
return if status_was.in?(%w[sent failed]) && only_status_changed? && status.in?(%w[sent failed])
return if status_was.in?(%w[draft pending])
errors.add(:base, 'Scheduled message can only be modified while draft or pending')
end
def only_status_changed?
changed_attributes.keys == ['status']
end
def scheduled_at_must_be_in_future
return if scheduled_at.blank?
return if scheduled_at > Time.current
errors.add(:scheduled_at, 'must be in the future')
end
def should_validate_future_schedule?
return false unless pending?
new_record? || scheduled_at_changed? || status_changed?
end
def content_optional?
template_params.present? || attachment.attached?
end
def author_event_data
return author.push_event_data if author.is_a?(User)
data = { id: author_id, type: author_type }
data[:name] = author.name if author.respond_to?(:name)
data
end
def process_message_variables
return if content.blank?
processed_content = modified_liquid_content(content)
template = Liquid::Template.parse(processed_content)
self.content = template.render(message_drops)
rescue Liquid::Error
# Keep original content if Liquid parsing/rendering fails
nil
end
def modified_liquid_content(raw_content)
return raw_content if raw_content.blank?
# Wrap inline code (text between single backticks) in Liquid raw blocks
# so that any {{ ... }} inside code is not interpreted by Liquid.
raw_content.gsub(/`([^`\n]+)`/) do
"{% raw %}`#{Regexp.last_match(1)}`{% endraw %}"
end
end
def message_drops
{
'contact' => ContactDrop.new(conversation.contact),
'conversation' => ConversationDrop.new(conversation),
'inbox' => InboxDrop.new(inbox),
'account' => AccountDrop.new(account)
}
end
end