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:
wkh
2026-09-28 20:09:06 +02:00
co-authored by Claude Opus 5.5
parent 5a0f1fcd78
commit e14bada114
190 changed files with 6077 additions and 722 deletions
+84
View File
@@ -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