Skip to content

feat(documents): single-call sidebar tree, public docs, create-in-folder, invite landing (G24) - #70

Merged
Adron merged 2 commits into
devfrom
feat/documents-tree-g24
Sep 13, 2026
Merged

Adron merged 2 commits into
devfrom
feat/documents-tree-g24

Conversation

@Adron

@Adron Adron commented Sep 9, 2026

Copy link
Copy Markdown
Member

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, .env test 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.json carrying x-auth-type and x-subscription-tier per 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, no updatedAt, no folderId. Only the sidebar's folder fetch retired; the middle column and the editor still need their own reads. Those rows are modelled as a new DocumentSummary, deliberately not as Document, so nothing can mistake a .distantPast floor 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/documents silently ignored the folder. The live reference is explicit that it "always creates at root: there is no folderId in its body". The Mac's New Document flow had been passing folderId there, so every document created with a folder selected was landing at root. Now routed through POST /api/documents/folders/{id}/documents.

3. Moving to root needs an explicit JSON null. Codable's synthesised encoding uses encodeIfPresent, so a nil folderId is omitted — and an omitted key means "leave the folder alone". A dedicated MoveDocumentRequest always writes the key.

The invite accept half is genuinely out of reach. POST /api/documents/invite/{token} is x-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

  • Kit (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), and move(id:toFolderId:) + MoveDocumentRequest. Tree/public DTOs decode tolerantly — folders are flat with parentId, documents nest inline, and rootDocuments stays a sibling array, never flattened.
  • Domain: DocumentTreeSnapshot, DocumentSummary, PublicUserDocuments, DocumentInvite + mappers; documentTree(), publicDocuments(ofUser:), moveDocument(id:toFolder:), invite(token:), and a subscriber-gated createDocument(inFolder:…). The tree write-throughs only its folders to the cache — upserting summary rows would overwrite real cached documents with emptier ones.
  • Entitlements: consumed through the existing seam via an entitlementsProvider closure reading isSubscriber. EntitlementsService.swift is not touched — bug(entitlements): subscriber gating covers 3 of ~10 documented features (G37) #40 owns it — so the follow-up is a TODO(#40) naming CapabilityGate and a .documentCreation case. Gating is creation-only; moving, editing and deleting stay free on every tier.
  • App: sidebar paints from the one tree call with per-folder counts; _templates filtered 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 one MoveToFolderMenu and one optimistic-rollback path. New Document files into the selected folder. Public-documents column on the profile. Invite landing + deep-link parser (kept separate from ShareURLParser so a claim button can never appear one boolean away from a surface that must not have one).
  • Already done, verified not rebuilt: seed defaults ships end to end (Documents.seedDefaultTemplates()DocumentTemplatesServiceServerTemplatesViewModel.seedDefaults(), wired in DocumentTemplatePickerView). That issue bullet closes as already-done.
  • Still deferred per the issue: 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 _templates not 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' testExecuted 796 tests, with 0 failures (0 unexpected) · ** TEST SUCCEEDED **
  • swift test --package-path Packages/InterlinedKitExecuted 420 tests, with 0 failures (0 unexpected)
  • swift test --package-path Packages/InterlinedDomainExecuted 796 tests, with 0 failures (0 unexpected)
  • swift test --package-path Packages/InterlinedPersistenceExecuted 135 tests, with 0 failures (0 unexpected)
  • grep -rn "import InterlinedKit" App/Features App/Navigation App/MenuCommandszero hits (Decision 0003)
  • ContractTests (env-gated) → ran live, 4 tests, 0 failures
  • project.pbxprojuntouched

Notes 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/.sheet pair), InterlinedListApp.swift +7 (one deep-link line), ProfileRootView.swift +21 (one section). Everything else is new files under App/Features/Documents.

Closes #52

🤖 Generated with Claude Code

https://claude.ai/code/session_01DyYcAazrEEnVCNfffJpkJc

Adron and others added 2 commits September 9, 2026 09:58
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant