class Document < ApplicationRecord ROLES = %w[viewer editor owner].freeze 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 has_many :updates, class_name: "DocumentUpdate", 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 def meta_json(role:) { id:, title:, role:, owner_name: owner.name, updated_at: content_updated_at.iso8601(3), updated_by_name: updated_by&.name } end # Tell everyone connected to this document (see DocumentChannel). def broadcast(message) = DocumentChannel.broadcast_to(self, message) # Access changed: every connection checks its role again. def revalidate_access! = broadcast({ type: "revalidate" }) def self.valid_snapshot?(json) parsed = JSON.parse(json) parsed.is_a?(Hash) && parsed["sheets"].is_a?(Array) rescue JSON::ParserError, TypeError false 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