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
+111
View File
@@ -0,0 +1,111 @@
require "test_helper"
class DocumentsApiTest < ActionDispatch::IntegrationTest
setup do
@anna = create_user("anna@example.com")
@bob = create_user("bob@example.com")
@doc = create_document(@anna)
end
def put_content(doc, base_version, headers: {})
patch "/api/documents/#{doc.id}", params: { content: DOC_JSON, base_version: }, headers:, as: :json
end
test "listing and creating need an account" do
get "/api/documents"
assert_response :unauthorized
post "/api/documents", params: { title: "x", content: DOC_JSON }, as: :json
assert_response :unauthorized
end
test "create (upload), list, read" do
sign_in @anna
post "/api/documents", params: { title: "Hochgeladen", content: DOC_JSON }, as: :json
assert_response :created
id = json.dig("document", "id")
get "/api/documents"
assert_equal [ id, @doc.id ].sort, json["documents"].map { _1["id"] }.sort
get "/api/documents/#{id}"
assert_equal DOC_JSON, json.dig("document", "content")
assert_equal "owner", json.dig("document", "role")
end
test "someone else's document is a 404" do
sign_in @bob
get "/api/documents/#{@doc.id}"
assert_response :not_found
end
test "save with version check; stale base_version is a conflict" do
sign_in @anna
put_content(@doc, 1)
assert_response :success
assert_equal 2, json.dig("document", "version")
put_content(@doc, 1)
assert_response :conflict
assert_equal 2, json["version"]
end
test "known_version returns metadata only when unchanged" do
sign_in @anna
get "/api/documents/#{@doc.id}", params: { known_version: 1 }
assert json["unchanged"]
assert_nil json.dig("document", "content")
end
test "edit lock blocks saves from another tab" do
sign_in @anna
post "/api/documents/#{@doc.id}/lock", headers: { "X-Client-Id" => "tab-a" }
assert json.dig("lock", "mine")
post "/api/documents/#{@doc.id}/lock", headers: { "X-Client-Id" => "tab-b" }
assert_not json.dig("lock", "mine")
put_content(@doc, 1, headers: { "X-Client-Id" => "tab-b" })
assert_response :locked
delete "/api/documents/#{@doc.id}/lock", headers: { "X-Client-Id" => "tab-a" }
put_content(@doc, 1, headers: { "X-Client-Id" => "tab-b" })
assert_response :success
end
test "share link without account: read with token; viewer cannot write" do
link = @doc.share_links.create!(role: :viewer, created_by: @anna)
headers = { "X-Share-Token" => link.token }
post "/api/documents/#{@doc.id}/join", params: { token: link.token }, as: :json
assert_equal "viewer", json.dig("document", "role")
get "/api/documents/#{@doc.id}", headers: headers
assert_response :success
put_content(@doc, 1, headers:)
assert_response :forbidden
get "/api/documents/#{@doc.id}", headers: { "X-Share-Token" => "wrong" }
assert_response :not_found
end
test "share link with account: joining adds it to the list" do
link = @doc.share_links.create!(role: :editor, created_by: @anna)
sign_in @bob
post "/api/documents/#{@doc.id}/join", params: { token: link.token }, as: :json
get "/api/documents"
assert_equal [ [ @doc.id, "editor" ] ], json["documents"].map { [ _1["id"], _1["role"] ] }
put_content(@doc, 1)
assert_response :success
end
test "owner deletes; a member only leaves" do
DocumentMembership.grant!(document: @doc, user: @bob, role: :editor, source: :invite)
sign_in @bob
delete "/api/documents/#{@doc.id}"
assert_response :no_content
assert Document.exists?(@doc.id)
assert_nil @doc.role_for(user: @bob)
sign_in @anna
delete "/api/documents/#{@doc.id}"
assert_not Document.exists?(@doc.id)
end
end