Add Rails backend: accounts via magic link, server documents, sharing
The app becomes a Rails 8 application; the Vue frontend moves to app/frontend and is served through vite_rails. - Without an account documents stay in the browser (IndexedDB). Sign-in is by magic link or the 6-digit code from the same mail; afterwards local documents can be uploaded into the account. - Server documents (/d/<id>) with versioned saves and a single-editor lock per browser tab; others see them read-only and follow by polling. - Sharing via link (works without an account) and via e-mail invitation, roles viewer/editor; the owner manages links and people. - Store gains a read-only mode that blocks all content changes. - Solid Queue, Cache and Cable share the one PostgreSQL database. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
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
|
||||
Reference in New Issue
Block a user