class Document < ApplicationRecord ROLES = %w[viewer editor owner].freeze LOCK_TTL = 90.seconds MAX_CONTENT_BYTES = 20.megabytes belongs_to :owner, class_name: "User" belongs_to :updated_by, class_name: "User", optional: true has_many :document_memberships, dependent: :delete_all has_many :members, through: :document_memberships, source: :user has_many :share_links, dependent: :delete_all validates :title, presence: true, length: { maximum: 200 } validates :content, presence: true validate :content_is_document_json, if: :content_changed? before_validation { self.content_updated_at ||= Time.current } # :owner / :editor / :viewer / nil for a signed-in user and/or a share # link token (a visitor without an account holds only the token). The # strongest of all applicable grants wins. def role_for(user: nil, token: nil) roles = [] if user return :owner if owner_id == user.id m = document_memberships.find_by(user_id: user.id) roles << m.role.to_sym if m end if token.present? link = share_links.active.find_by(token: token) roles << link.role.to_sym if link end roles.max_by { |r| ROLES.index(r.to_s) } end def self.role_at_least?(role, minimum) role.present? && ROLES.index(role.to_s) >= ROLES.index(minimum.to_s) end # ---- edit lock -------------------------------------------------------- def locked_by_other?(holder) lock_holder.present? && lock_holder != holder && lock_expires_at&.future? end # Take or refresh the lock for `holder`. Returns true when `holder` has it. def acquire_lock!(holder, name) with_lock do return false if locked_by_other?(holder) update_columns(lock_holder: holder, lock_holder_name: name, lock_expires_at: LOCK_TTL.from_now) true end end def release_lock!(holder) with_lock do update_columns(lock_holder: nil, lock_holder_name: nil, lock_expires_at: nil) if lock_holder == holder end end def lock_info(holder) { locked_by_other: locked_by_other?(holder), holder_name: (lock_holder_name if locked_by_other?(holder)) } end def meta_json(role:) { id:, title:, version:, role:, owner_name: owner.name, updated_at: content_updated_at.iso8601(3), updated_by_name: updated_by&.name } end private def content_is_document_json if content.bytesize > MAX_CONTENT_BYTES errors.add(:content, "ist zu groß (max. #{MAX_CONTENT_BYTES / 1.megabyte} MB)") return end parsed = JSON.parse(content) errors.add(:content, "ist kein numolus-Dokument") unless parsed.is_a?(Hash) && parsed["sheets"].is_a?(Array) rescue JSON::ParserError errors.add(:content, "ist kein gültiges JSON") end end