feat(documents): single-call sidebar tree, public docs, create-in-folder, invite landing (G24) - #70
Merged
Merged
Conversation
…der, invite landing (G24) Closes the four Documents routes from GitHub #52 (work-consolidation.md G24), plus the Move to folder affordance the issue's acceptance criteria require. Every shape was probed read-only against live (GET/OPTIONS only) before any decoder was written; three of the four probes changed the plan. Kit - `Documents.tree()` → `DocumentTreeResponse`. The live shape is flat in its folders (nesting via `parentId`) and nested in its documents, with `rootDocuments` as a *sibling* array. Both arrays decode tolerantly. - `Documents.publicDocuments(username:)`, `auth: .none` — verified to answer identically with and without a bearer token. - `Documents.createInFolder(folderId:_:)` — the body deliberately carries no `folderId`; the folder is the path. - `Documents.move(id:toFolderId:)` + `MoveDocumentRequest`, which always writes the `folderId` key so `null` can mean "no folder (root)". Codable's synthesised encoding omits nil optionals, and an omitted key means "leave it alone" — a document could never have reached root without this. - `Documents.invite(token:)`, `auth: .none`. No accept builder: the claim route is `x-auth-type: session`, so a Bearer client cannot claim at all. Domain - `DocumentTreeSnapshot` + `DocumentSummary`. The tree's inline document rows carry only id/title/relativePath/isPublic — no body, no `updatedAt`, no `folderId` — so they are modelled as summaries rather than as `Document`s. That is also why the tree retires only the sidebar's folder fetch, not the document list's own read. - `documentTree()`, `publicDocuments(ofUser:)`, `moveDocument(id:toFolder:)`, `invite(token:)`, and a subscriber-gated `createDocument(inFolder:…)`. - The gate consumes the existing entitlements seam via an `entitlementsProvider` closure and reads `isSubscriber`; adding a `Feature` case would mean editing the file issue #40 owns, so it leaves that enum alone behind a TODO(#40). Gating is creation-only — moving, editing and deleting stay free on every tier. App - The sidebar paints from the one tree call and now shows per-folder document counts, which the single call makes free. `_templates` is filtered out of the sidebar, of the cache paint, and of move destinations, but stays findable by the template picker. - Move to folder in both the editor's settings menu and the list's context menu, sharing one `MoveToFolderMenu` and one optimistic-rollback implementation. - New Document inside a folder now uses the folder route. `POST /api/documents` is documented as "always creates at root: there is no `folderId` in its body", so the previous call silently filed every such document at root. - A public-documents column on the profile page, and an invite landing that resolves the token and ends in "Accept in Browser". Seed defaults needed no work: the issue asked to verify, and the whole chain already ships (`Documents.seedDefaultTemplates()` → `DocumentTemplatesService` → `ServerTemplatesViewModel.seedDefaults()`, wired in the picker). Presence (`POST`/`DELETE /api/documents/{id}/presence`) stays deferred per the issue. Gate: build SUCCEEDED; App 788 tests / Kit 420 / Domain 796 / Persistence 135, all 0 failures; ContractTests ran live (4/4); zero `import InterlinedKit` in App/Features, App/Navigation, App/MenuCommands; project.pbxproj untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DyYcAazrEEnVCNfffJpkJc
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes the four Documents routes in #52 (work-consolidation.md G24), plus the Move to folder affordance the issue's acceptance criteria require. The sidebar is now built from one call (
GET /api/documents/tree) instead of assembling it from several, a document can be moved between folders and out to root from the Mac, public documents appear on the profile page, and an invite link opens a landing that hands the accept step to the browser.Every shape was probed read-only against live (GET/OPTIONS only,
.envtest account, zero writes) before any decoder was written. Three of those probes changed the plan — see below. The probing also turned up a live OpenAPI spec at/api/openapi.jsoncarryingx-auth-typeandx-subscription-tierper operation, which is much faster than probing for auth/tier questions.What the live probe changed
1. The tree does not retire the document-list fetch. Its inline document rows carry only
id,title,relativePath,isPublic— no body, noupdatedAt, nofolderId. Only the sidebar's folder fetch retired; the middle column and the editor still need their own reads. Those rows are modelled as a newDocumentSummary, deliberately not asDocument, so nothing can mistake a.distantPastfloor for a real timestamp. The single call does pay off elsewhere: per-folder document counts are now free.2. A pre-existing defect —
POST /api/documentssilently ignored the folder. The live reference is explicit that it "always creates at root: there is nofolderIdin its body". The Mac's New Document flow had been passingfolderIdthere, so every document created with a folder selected was landing at root. Now routed throughPOST /api/documents/folders/{id}/documents.3. Moving to root needs an explicit JSON
null. Codable's synthesised encoding usesencodeIfPresent, so a nilfolderIdis omitted — and an omitted key means "leave the folder alone". A dedicatedMoveDocumentRequestalways writes the key.The invite accept half is genuinely out of reach.
POST /api/documents/invite/{token}isx-auth-type: session, so a Bearer client cannot claim however the request is shaped. The landing resolves and stops at "Accept in Browser"; there is no accept method at any layer, and that is asserted as a test rather than left as a comment. Accepting is documented as always free, so the landing carries no entitlement gate.Changes
DocumentsEndpoint.swift,DocumentDTO.swift):tree(),publicDocuments(username:)(auth: .none, verified to answer identically with and without a bearer token),createInFolder(folderId:_:),invite(token:)(auth: .none), andmove(id:toFolderId:)+MoveDocumentRequest. Tree/public DTOs decode tolerantly — folders are flat withparentId, documents nest inline, androotDocumentsstays a sibling array, never flattened.DocumentTreeSnapshot,DocumentSummary,PublicUserDocuments,DocumentInvite+ mappers;documentTree(),publicDocuments(ofUser:),moveDocument(id:toFolder:),invite(token:), and a subscriber-gatedcreateDocument(inFolder:…). The tree write-throughs only its folders to the cache — upserting summary rows would overwrite real cached documents with emptier ones.entitlementsProviderclosure readingisSubscriber.EntitlementsService.swiftis not touched — bug(entitlements): subscriber gating covers 3 of ~10 documented features (G37) #40 owns it — so the follow-up is aTODO(#40)namingCapabilityGateand a.documentCreationcase. Gating is creation-only; moving, editing and deleting stay free on every tier._templatesfiltered out of the sidebar, the cache paint, and move destinations, but still findable by the template picker. Move to folder in both the editor settings menu and the list context menu, sharing oneMoveToFolderMenuand one optimistic-rollback path. New Document files into the selected folder. Public-documents column on the profile. Invite landing + deep-link parser (kept separate fromShareURLParserso a claim button can never appear one boolean away from a surface that must not have one).Documents.seedDefaultTemplates()→DocumentTemplatesService→ServerTemplatesViewModel.seedDefaults(), wired inDocumentTemplatePickerView). That issue bullet closes as already-done.POST/DELETE /api/documents/{id}/presence(live-cursor heartbeat).88 new tests (22 Kit, 24 Domain, 42 App), quartet per behaviour — including the issue's named cases: cache-fallback when the tree call fails, rollback on a move to a folder that no longer exists, an empty folder, a folder of only sub-folders, and
_templatesnot offered as a destination.Verification
Full E2E gate, run against this branch after merging
origin/dev(PR #37) in:xcodebuild -scheme InterlinedList -destination 'platform=macOS' build→** BUILD SUCCEEDED **xcodebuild -scheme InterlinedList -destination 'platform=macOS' test→Executed 796 tests, with 0 failures (0 unexpected)·** TEST SUCCEEDED **swift test --package-path Packages/InterlinedKit→Executed 420 tests, with 0 failures (0 unexpected)swift test --package-path Packages/InterlinedDomain→Executed 796 tests, with 0 failures (0 unexpected)swift test --package-path Packages/InterlinedPersistence→Executed 135 tests, with 0 failures (0 unexpected)grep -rn "import InterlinedKit" App/Features App/Navigation App/MenuCommands→ zero hits (Decision 0003)ContractTests(env-gated) → ran live, 4 tests, 0 failuresproject.pbxproj→ untouchedNotes for review
Seven sibling sessions were running in parallel, so the shared files are kept to small additive blocks:
AppEnvironment.swift+5 lines (one argument),MainWindowView.swift+23 (state + one.onReceive/.sheetpair),InterlinedListApp.swift+7 (one deep-link line),ProfileRootView.swift+21 (one section). Everything else is new files underApp/Features/Documents.Closes #52
🤖 Generated with Claude Code
https://claude.ai/code/session_01DyYcAazrEEnVCNfffJpkJc