bd6c1cf34f
* Storage: harden reconcile for concurrent writes and fix board transfer Reconcile now uses two-cursor approach: captures cursor before and after the storage scan, aborting if they differ (entries added during scan). Job retries 3x with 1-minute waits and limits concurrency to 1 per owner. Board transfer now correctly moves storage for card description embeds and comment embeds, not just direct attachments. Uses batched queries to handle cards with thousands of comments efficiently. Also fixes N+1 queries in attachment grouping via lookup maps. * Storage: fix per-attachment reconcile and enforce no blob reuse The storage ledger tracks per-attachment (not per-blob) as a business abstraction for quotas. This fixes reconcile to match that model and adds enforcement to prevent blob reuse in tracked contexts. * Reconcile now uses joins(:blob).sum() for per-attachment counting * New validation prevents reusing blobs across tracked attachments * Race-safe storage_total creation with create_or_find_by * Job efficiency: skip find_by when object already available * Backfill script checks per-attachment, not just per-blob
148 lines
4.5 KiB
Ruby
148 lines
4.5 KiB
Ruby
class Card < ApplicationRecord
|
|
include Assignable, Attachments, Broadcastable, Closeable, Colored, Entropic, Eventable,
|
|
Exportable, Golden, Mentions, Multistep, Pinnable, Postponable, Promptable,
|
|
Readable, Searchable, Stallable, Statuses, Storage::Tracked, Taggable, Triageable, Watchable
|
|
|
|
belongs_to :account, default: -> { board.account }
|
|
belongs_to :board
|
|
belongs_to :creator, class_name: "User", default: -> { Current.user }
|
|
|
|
has_many :comments, dependent: :destroy
|
|
has_one_attached :image, dependent: :purge_later
|
|
|
|
has_rich_text :description
|
|
|
|
before_save :set_default_title, if: :published?
|
|
before_create :assign_number
|
|
|
|
after_save -> { board.touch }, if: :published?
|
|
after_touch -> { board.touch }, if: :published?
|
|
after_update :handle_board_change, if: :saved_change_to_board_id?
|
|
|
|
scope :reverse_chronologically, -> { order created_at: :desc, id: :desc }
|
|
scope :chronologically, -> { order created_at: :asc, id: :asc }
|
|
scope :latest, -> { order last_active_at: :desc, id: :desc }
|
|
scope :with_users, -> { preload(creator: [ :avatar_attachment, :account ], assignees: [ :avatar_attachment, :account ]) }
|
|
scope :preloaded, -> { with_users.preload(:column, :tags, :steps, :closure, :goldness, :activity_spike, :image_attachment, board: [ :entropy, :columns ], not_now: [ :user ]).with_rich_text_description_and_embeds }
|
|
|
|
scope :indexed_by, ->(index) do
|
|
case index
|
|
when "stalled" then stalled
|
|
when "postponing_soon" then postponing_soon
|
|
when "closed" then closed
|
|
when "not_now" then postponed.latest
|
|
when "golden" then golden
|
|
when "draft" then drafted
|
|
else all
|
|
end
|
|
end
|
|
|
|
scope :sorted_by, ->(sort) do
|
|
case sort
|
|
when "newest" then reverse_chronologically
|
|
when "oldest" then chronologically
|
|
when "latest" then latest
|
|
else latest
|
|
end
|
|
end
|
|
|
|
delegate :accessible_to?, to: :board
|
|
|
|
def card
|
|
self
|
|
end
|
|
|
|
def to_param
|
|
number.to_s
|
|
end
|
|
|
|
def move_to(new_board)
|
|
transaction do
|
|
card.update!(board: new_board)
|
|
card.events.update_all(board_id: new_board.id)
|
|
end
|
|
end
|
|
|
|
def filled?
|
|
title.present? || description.present?
|
|
end
|
|
|
|
private
|
|
STORAGE_BATCH_SIZE = 1000
|
|
|
|
# Override to include comments, but only load comments that have attachments.
|
|
# Cards can have thousands of comments; most won't have attachments.
|
|
# Streams in batches to avoid loading all IDs into memory at once.
|
|
def storage_transfer_records
|
|
comment_ids_with_attachments = storage_comment_ids_with_attachments
|
|
|
|
if comment_ids_with_attachments.any?
|
|
[ self, *comments.where(id: comment_ids_with_attachments).to_a ]
|
|
else
|
|
[ self ]
|
|
end
|
|
end
|
|
|
|
def storage_comment_ids_with_attachments
|
|
direct = []
|
|
rich_text_map = {}
|
|
|
|
# Stream comment IDs in batches to avoid loading all into memory
|
|
comments.in_batches(of: STORAGE_BATCH_SIZE) do |batch|
|
|
batch_ids = batch.pluck(:id)
|
|
|
|
direct.concat \
|
|
ActiveStorage::Attachment
|
|
.where(record_type: "Comment", record_id: batch_ids)
|
|
.distinct
|
|
.pluck(:record_id)
|
|
|
|
ActionText::RichText
|
|
.where(record_type: "Comment", record_id: batch_ids)
|
|
.pluck(:id, :record_id)
|
|
.each { |rt_id, comment_id| rich_text_map[rt_id] = comment_id }
|
|
end
|
|
|
|
embed_comment_ids = if rich_text_map.any?
|
|
rich_text_map.keys.each_slice(STORAGE_BATCH_SIZE).flat_map do |batch_ids|
|
|
ActiveStorage::Attachment
|
|
.where(record_type: "ActionText::RichText", record_id: batch_ids)
|
|
.distinct
|
|
.pluck(:record_id)
|
|
end.filter_map { |rt_id| rich_text_map[rt_id] }
|
|
else
|
|
[]
|
|
end
|
|
|
|
(direct + embed_comment_ids).uniq
|
|
end
|
|
|
|
def set_default_title
|
|
self.title = "Untitled" if title.blank?
|
|
end
|
|
|
|
def handle_board_change
|
|
old_board = account.boards.find_by(id: board_id_before_last_save)
|
|
|
|
transaction do
|
|
update! column: nil
|
|
track_board_change_event(old_board.name)
|
|
grant_access_to_assignees unless board.all_access?
|
|
end
|
|
|
|
remove_inaccessible_notifications_later
|
|
end
|
|
|
|
def track_board_change_event(old_board_name)
|
|
track_event "board_changed", particulars: { old_board: old_board_name, new_board: board.name }
|
|
end
|
|
|
|
def grant_access_to_assignees
|
|
board.accesses.grant_to(assignees)
|
|
end
|
|
|
|
def assign_number
|
|
self.number ||= account.increment!(:cards_count).cards_count
|
|
end
|
|
end
|