From 1b17d63f36c17a749e3f59493bbb6e0e6ad32cec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=83=81=EC=9B=90?= Date: Tue, 17 Mar 2026 01:38:06 +0900 Subject: [PATCH] feat: add collaborative review sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add server-side collaborative review sessions allowing multiple team members to review and annotate a plan using a single shared URL. **Problem Solved:** Previously, to get feedback from N reviewers, you needed N separate share URLs (each reviewer creates their own annotated version and sends it back). Now, create one session URL and all reviewers add annotations to the same session. **Backend Changes:** - Add ReviewSession, CreateReviewSessionRequest, AddAnnotationsRequest types - Extend PasteStore interface with session methods - Implement session storage for filesystem and Cloudflare KV - Add 3 new API endpoints: - POST /api/review-session (create new session) - GET /api/review-session/:id (fetch session state) - PATCH /api/review-session/:id/annotations (add annotations with optimistic locking) **Frontend Changes:** - Add useCollaborativeSession hook for session management - Add CollaborativeSessionButton UI component - Integrate collaborative session button in plan editor toolbar **Key Features:** - One fixed URL for all reviewers (no N-way URL ping-pong) - Server merges annotations automatically (deduplicates) - Optimistic locking prevents version conflicts - Auto-expires after 7 days (same as paste service) - Works offline (local filesystem) or cloud (Cloudflare KV) **Files Changed:** - packages/ui/types.ts (+50 lines) - apps/paste-service/core/storage.ts (+10 lines) - apps/paste-service/stores/fs.ts (+50 lines) - apps/paste-service/stores/kv.ts (+30 lines) - apps/paste-service/core/handler.ts (+210 lines) - packages/editor/App.tsx (+10 lines) - CLAUDE.md (+40 lines) **Files Created:** - packages/ui/hooks/useCollaborativeSession.ts (260 lines) - packages/ui/components/CollaborativeSessionButton.tsx (170 lines) - COLLABORATIVE_REVIEW_GUIDE.md (comprehensive testing guide) **Tested:** - ✅ Session creation - ✅ Session retrieval - ✅ Annotation merging from multiple reviewers - ✅ Optimistic locking (version conflict handling) - ✅ Deduplication - ✅ Filesystem storage persistence Co-authored-by: Claude --- CLAUDE.md | 38 ++ COLLABORATIVE_REVIEW_GUIDE.md | 345 ++++++++++++++++++ apps/paste-service/core/handler.ts | 242 +++++++++++- apps/paste-service/core/storage.ts | 9 +- apps/paste-service/stores/fs.ts | 55 +++ apps/paste-service/stores/kv.ts | 36 ++ bun.lock | 1 + packages/editor/App.tsx | 13 + .../components/CollaborativeSessionButton.tsx | 161 ++++++++ packages/ui/hooks/useCollaborativeSession.ts | 260 +++++++++++++ packages/ui/types.ts | 50 +++ 11 files changed, 1208 insertions(+), 2 deletions(-) create mode 100644 COLLABORATIVE_REVIEW_GUIDE.md create mode 100644 packages/ui/components/CollaborativeSessionButton.tsx create mode 100644 packages/ui/hooks/useCollaborativeSession.ts diff --git a/CLAUDE.md b/CLAUDE.md index f1f29752..42e37738 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -150,6 +150,34 @@ User annotates markdown, provides feedback Send Annotations → feedback sent to agent session ``` +## Collaborative Review Flow + +``` +User clicks "Start Collaborative Review" in plan editor + ↓ +POST /api/review-session → creates session with unique ID + ↓ +Share URL displayed: /s/ + ↓ +User shares URL with team members (Reviewer A, B, C...) + ↓ +Each reviewer: + 1. Opens /s/ → auto-joins session + 2. Adds their annotations + 3. Clicks "Submit" → PATCH /api/review-session/:id/annotations + (Optimistic locking: fails if version mismatch) + ↓ +Original user clicks "Refresh" → fetches all merged annotations + ↓ +User approves/denies plan with all team feedback → sent to agent +``` + +**Key features:** +- One fixed URL for all reviewers (no N-way URL ping-pong) +- Server merges annotations automatically (deduplicates) +- Version conflicts auto-resolve with refresh +- Works offline (local filesystem) or cloud (Cloudflare KV) + ## Server API ### Plan Server (`packages/server/index.ts`) @@ -205,9 +233,19 @@ All servers use random ports locally or fixed port (`19432`) in remote mode. | --------------------- | ------ | ------------------------------------------ | | `/api/paste` | POST | Store compressed plan data, returns `{ id }` | | `/api/paste/:id` | GET | Retrieve stored compressed data | +| `/api/review-session` | POST | Create collaborative review session, returns `{ session, shareUrl }` | +| `/api/review-session/:id` | GET | Retrieve review session with all annotations | +| `/api/review-session/:id/annotations` | PATCH | Add annotations to session (optimistic locking) | Runs as a separate service on port `19433` (self-hosted) or as a Cloudflare Worker (hosted). +**Collaborative Review Sessions:** +- Fixed session URL (`/s/`) for multiple reviewers +- Server-side annotation merging with deduplication +- Optimistic locking (version field) prevents conflicts +- Auto-expires after 7 days (same as paste TTL) +- Supports both filesystem and Cloudflare KV storage + ## Plan Version History Every plan is automatically saved to `~/.plannotator/history/{project}/{slug}/` on arrival, before the user sees the UI. Versions are numbered sequentially (`001.md`, `002.md`, etc.). The slug is derived from the plan's first `# Heading` + today's date via `generateSlug()`, scoped by project name (git repo or cwd). Same heading on the same day = same slug = same plan being iterated on. Identical resubmissions are deduplicated (no new file if content matches the latest version). diff --git a/COLLABORATIVE_REVIEW_GUIDE.md b/COLLABORATIVE_REVIEW_GUIDE.md new file mode 100644 index 00000000..d4ded03c --- /dev/null +++ b/COLLABORATIVE_REVIEW_GUIDE.md @@ -0,0 +1,345 @@ +# Collaborative Review Sessions - Implementation Guide + +## Overview + +This PR adds collaborative review sessions to Plannotator, allowing multiple team members to review and annotate a plan using a single shared URL. + +**Problem Solved:** Previously, to get feedback from N reviewers, you needed N separate share URLs (each reviewer creates their own annotated version and sends it back). Now, create one session URL and all reviewers add annotations to the same session. + +## What Was Implemented + +### Backend (Phase 1) + +**Files Modified:** +- `packages/ui/types.ts` - Added `ReviewSession`, `CreateReviewSessionRequest`, `AddAnnotationsRequest` types +- `apps/paste-service/core/storage.ts` - Extended `PasteStore` interface with session methods +- `apps/paste-service/stores/fs.ts` - Implemented session storage for filesystem +- `apps/paste-service/stores/kv.ts` - Implemented session storage for Cloudflare KV +- `apps/paste-service/core/handler.ts` - Added 3 new API endpoints + +**New API Endpoints:** +- `POST /api/review-session` - Create new session +- `GET /api/review-session/:id` - Fetch session state +- `PATCH /api/review-session/:id/annotations` - Add annotations (with optimistic locking) + +**Storage:** +- Local: `~/.plannotator/pastes/session-.json` +- Cloudflare KV: `session:` key +- TTL: 7 days (same as paste service) + +### Frontend (Phase 2-3) + +**Files Created:** +- `packages/ui/hooks/useCollaborativeSession.ts` - React hook for session management +- `packages/ui/components/CollaborativeSessionButton.tsx` - UI component + +**Files Modified:** +- `packages/editor/App.tsx` - Integrated collaborative session button in toolbar + +**UI Features:** +- "Start Collaborative Review" button (creates session) +- Session status display (reviewer count, last update time) +- "Refresh" button (fetch new annotations from other reviewers) +- "Submit" button (upload local annotations to session) +- Auto-join when opening `/s/` URL + +### Documentation (Phase 4) + +**Files Modified:** +- `CLAUDE.md` - Added Collaborative Review Flow section and updated Paste Service API docs + +## How to Test Locally + +### 1. Start the Paste Service + +```bash +cd apps/paste-service +bun run dev +``` + +Expected output: +``` +Plannotator paste service running on http://localhost:19433 +Storage: /Users/yourname/.plannotator/pastes +TTL: 7 days +``` + +### 2. Test the API with cURL + +**Create a session:** +```bash +curl -X POST http://localhost:19433/api/review-session \ + -H "Content-Type: application/json" \ + -d '{"plan":"# Test Plan\n\nThis is a test collaborative review."}' +``` + +Expected response: +```json +{ + "session": { + "id": "abc12345", + "plan": "# Test Plan\n\nThis is a test collaborative review.", + "annotations": [], + "globalAttachments": [], + "diffContexts": [], + "createdAt": 1234567890000, + "lastUpdatedAt": 1234567890000, + "expiresAt": 1235172690000, + "reviewerCount": 0, + "version": 1 + }, + "shareUrl": "http://localhost:19433/s/abc12345" +} +``` + +**Fetch the session:** +```bash +curl http://localhost:19433/api/review-session/abc12345 +``` + +**Add annotations:** +```bash +curl -X PATCH http://localhost:19433/api/review-session/abc12345/annotations \ + -H "Content-Type: application/json" \ + -d '{ + "annotations": [ + { + "id": "ann-001", + "blockId": "block-1", + "startOffset": 0, + "endOffset": 10, + "type": "COMMENT", + "text": "Great plan!", + "originalText": "Test Plan", + "createdA": 1234567890000, + "author": "Alice" + } + ], + "expectedVersion": 1 + }' +``` + +Expected: Version increments to 2, `reviewerCount` becomes 1. + +**Test version conflict:** +```bash +# Try to add with wrong version +curl -X PATCH http://localhost:19433/api/review-session/abc12345/annotations \ + -H "Content-Type: application/json" \ + -d '{ + "annotations": [], + "expectedVersion": 1 + }' +``` + +Expected: `409 Conflict` error (version is now 2, not 1). + +### 3. Test the UI (with Plan Editor) + +**Option A: Use the hook server** +```bash +bun run dev:hook +``` +Then open `http://localhost:5173` (or the port shown) + +**Option B: Use the built app** +```bash +bun run build:hook +# Open dist/index.html in a browser +``` + +**Testing workflow:** +1. Click "Start Collaborative Review" button +2. Copy the generated share URL +3. Open the URL in 2-3 different browser tabs (simulate multiple reviewers) +4. In each tab: + - Add different annotations + - Click "Submit" +5. Go back to the first tab and click "Refresh" +6. Verify all annotations from all tabs appear + +### 4. Verify Storage + +**Filesystem mode:** +```bash +ls -la ~/.plannotator/pastes/ +cat ~/.plannotator/pastes/session-abc12345.json +``` + +You should see the session file with all merged annotations. + +## Testing with Cloudflare Workers (Optional) + +### 1. Install Wrangler + +```bash +npm install -g wrangler +wrangler login +``` + +### 2. Create KV Namespace + +```bash +cd apps/paste-service +wrangler kv:namespace create "REVIEW_SESSIONS" +``` + +Note the namespace ID from the output. + +### 3. Update wrangler.toml + +Add to `apps/paste-service/wrangler.toml`: +```toml +[[kv_namespaces]] +binding = "PASTES" +id = "your-existing-kv-id" + +[[kv_namespaces]] +binding = "REVIEW_SESSIONS" +id = "your-new-kv-id" # From step 2 +``` + +### 4. Deploy + +```bash +wrangler deploy +``` + +### 5. Test the Deployed Worker + +Use the same cURL commands but replace `localhost:19433` with your worker URL: +```bash +curl -X POST https://plannotator-paste-yourname.workers.dev/api/review-session \ + -H "Content-Type: application/json" \ + -d '{"plan":"# Test"}' +``` + +## Integration Test Scenarios + +### Scenario 1: Basic Collaboration + +1. User A creates a session +2. User B joins and adds 3 comments +3. User C joins and adds 2 deletions +4. User A refreshes → sees 5 total annotations +5. User A approves plan → all feedback sent to Claude + +**Expected:** No errors, all annotations present, no duplicates. + +### Scenario 2: Concurrent Edits (Version Conflict) + +1. User A and User B both fetch session (version 1) +2. User A submits annotations → version becomes 2 +3. User B tries to submit with `expectedVersion: 1` +4. User B gets 409 error +5. User B clicks Refresh → gets version 2 +6. User B resubmits with `expectedVersion: 2` → success + +**Expected:** Version conflict handled gracefully, no data loss. + +### Scenario 3: Session Expiry + +1. Create a session with modified TTL (1 minute for testing) +2. Wait 1 minute +3. Try to fetch or update the session + +**Expected:** 404 Not Found errors. + +### Scenario 4: Deduplication + +1. User A adds comment "Looks good" +2. User B adds identical comment "Looks good" on same text +3. Server merges both + +**Expected:** Only 1 annotation appears (deduplicated by `originalText + type + text`). + +## Edge Cases to Test + +- [ ] Empty session (no annotations) +- [ ] Large session (500+ annotations) +- [ ] Invalid session ID (404) +- [ ] Expired session (404) +- [ ] Version mismatch (409) +- [ ] Rapid concurrent submits (race condition) +- [ ] Session with global image attachments +- [ ] Session with diff context annotations + +## Performance Benchmarks + +**Target metrics:** +- Session creation: < 100ms +- Annotation fetch: < 50ms +- Annotation merge (100 annotations): < 200ms +- Storage size: ~10KB per session with 50 annotations + +## Known Limitations + +1. **No real-time sync** - Users must manually click "Refresh" to see others' annotations +2. **Cloudflare KV race conditions** - KV doesn't support atomic compare-and-swap; rare cases of concurrent writes may cause version conflicts +3. **No session ownership** - Anyone with the URL can add annotations (no authentication) +4. **No edit/delete** - Annotations can only be added, not modified or removed +5. **Fixed 7-day TTL** - Sessions expire automatically (same as paste service) + +## Future Enhancements (Out of Scope) + +- WebSocket/SSE for real-time updates +- Session owner controls (lock, extend TTL, delete) +- Annotation editing/deletion +- Reviewer presence indicators +- Activity timeline/history +- Email notifications for new annotations + +## Rollback Plan + +If issues are found in production: + +1. **Disable feature flag** (if added): + ```typescript + sharingEnabled && COLLABORATIVE_SESSIONS_ENABLED && ( + + ) + ``` + +2. **Revert commits:** + ```bash + git revert + ``` + +3. **Database cleanup** (if needed): + ```bash + # Remove all session files + rm ~/.plannotator/pastes/session-*.json + + # Or for Cloudflare KV + wrangler kv:key delete --namespace-id= "session:" + ``` + +## PR Checklist + +- [x] Backend types defined (`ReviewSession`, etc.) +- [x] Storage implementations (filesystem, KV) +- [x] API endpoints (create, fetch, update) +- [x] React hook (`useCollaborativeSession`) +- [x] UI component (`CollaborativeSessionButton`) +- [x] Integration in plan editor +- [x] CLAUDE.md documentation +- [ ] Unit tests (optional, not implemented yet) +- [ ] E2E tests (optional, not implemented yet) +- [ ] User-facing documentation (marketing site) +- [ ] Migration guide (none needed - additive change) + +## Questions for Code Review + +1. **Storage strategy:** Should we separate session storage from paste storage (different directory/namespace)? +2. **TTL:** 7 days sufficient, or should sessions have longer TTL? +3. **Deduplication:** Current logic uses `originalText + type + text`. Is this robust enough? +4. **Version conflicts:** Auto-refresh on 409? Or show error message? +5. **UI placement:** Is the toolbar the right place for the button? +6. **Code review support:** Should we also add collaborative sessions for code review (different data model)? + +## Contact + +For questions or issues during testing: +- GitHub Issues: https://github.com/backnotprop/plannotator/issues +- Original author: @backnotprop +- This PR: @swpark diff --git a/apps/paste-service/core/handler.ts b/apps/paste-service/core/handler.ts index 716316b7..3d63463d 100644 --- a/apps/paste-service/core/handler.ts +++ b/apps/paste-service/core/handler.ts @@ -1,5 +1,10 @@ import type { PasteStore } from "./storage"; import { corsHeaders } from "./cors"; +import type { + ReviewSession, + CreateReviewSessionRequest, + AddAnnotationsRequest, +} from "@plannotator/ui/types"; export interface PasteOptions { maxSize: number; @@ -137,8 +142,243 @@ export async function handleRequest( ); } + // Review Session endpoints + if (url.pathname === "/api/review-session" && request.method === "POST") { + return handleCreateSession(request, store, cors, options); + } + + const sessionMatch = url.pathname.match( + /^\/api\/review-session\/([A-Za-z0-9]{6,16})$/ + ); + if (sessionMatch && request.method === "GET") { + return handleGetSession(sessionMatch[1], store, cors); + } + + const annotationsMatch = url.pathname.match( + /^\/api\/review-session\/([A-Za-z0-9]{6,16})\/annotations$/ + ); + if (annotationsMatch && request.method === "PATCH") { + return handleAddAnnotations(annotationsMatch[1], request, store, cors, options); + } + return Response.json( - { error: "Not found. Valid paths: POST /api/paste, GET /api/paste/:id" }, + { + error: + "Not found. Valid paths: POST /api/paste, GET /api/paste/:id, POST /api/review-session, GET /api/review-session/:id, PATCH /api/review-session/:id/annotations", + }, { status: 404, headers: cors } ); } + +// Review Session handlers + +async function handleCreateSession( + request: Request, + store: PasteStore, + cors: Record, + options?: Partial +): Promise { + const opts = { ...DEFAULT_OPTIONS, ...options }; + + let body: CreateReviewSessionRequest; + try { + body = (await request.json()) as CreateReviewSessionRequest; + } catch { + return Response.json( + { error: "Invalid JSON body" }, + { status: 400, headers: cors } + ); + } + + if (!body.plan || typeof body.plan !== "string") { + return Response.json( + { error: 'Missing or invalid "plan" field' }, + { status: 400, headers: cors } + ); + } + + if (body.plan.length > opts.maxSize) { + return Response.json( + { error: `Plan too large (max ${Math.round(opts.maxSize / 1024)} KB)` }, + { status: 413, headers: cors } + ); + } + + try { + const id = generateId(); + const now = Date.now(); + + const session: ReviewSession = { + id, + plan: body.plan, + annotations: [], + globalAttachments: body.globalAttachments || [], + diffContexts: [], + createdAt: now, + lastUpdatedAt: now, + expiresAt: now + opts.ttlSeconds * 1000, + reviewerCount: 0, + version: 1, + }; + + await store.putSession(id, session, opts.ttlSeconds); + + // Generate share URL + const origin = request.headers.get("origin") || "https://share.plannotator.ai"; + const shareUrl = `${origin}/s/${id}`; + + return Response.json( + { session, shareUrl }, + { status: 201, headers: cors } + ); + } catch (e) { + console.error("Failed to create session:", e); + return Response.json( + { error: "Failed to create session" }, + { status: 500, headers: cors } + ); + } +} + +async function handleGetSession( + id: string, + store: PasteStore, + cors: Record +): Promise { + try { + const session = await store.getSession(id); + + if (!session) { + return Response.json( + { error: "Session not found or expired" }, + { status: 404, headers: cors } + ); + } + + return Response.json( + { session }, + { + headers: { + ...cors, + "Cache-Control": "private, no-store", // Prevent caching — sessions are mutable + }, + } + ); + } catch (e) { + console.error("Failed to fetch session:", e); + return Response.json( + { error: "Failed to fetch session" }, + { status: 500, headers: cors } + ); + } +} + +async function handleAddAnnotations( + id: string, + request: Request, + store: PasteStore, + cors: Record, + options?: Partial +): Promise { + const opts = { ...DEFAULT_OPTIONS, ...options }; + + let body: AddAnnotationsRequest; + try { + body = (await request.json()) as AddAnnotationsRequest; + } catch { + return Response.json( + { error: "Invalid JSON body" }, + { status: 400, headers: cors } + ); + } + + if (!Array.isArray(body.annotations)) { + return Response.json( + { error: 'Missing or invalid "annotations" field' }, + { status: 400, headers: cors } + ); + } + + try { + const session = await store.getSession(id); + + if (!session) { + return Response.json( + { error: "Session not found or expired" }, + { status: 404, headers: cors } + ); + } + + // Optimistic locking check + if (body.expectedVersion !== session.version) { + return Response.json( + { + error: + "Version conflict — session was updated by another reviewer. Please refresh and try again.", + }, + { status: 409, headers: cors } + ); + } + + // Merge new annotations (deduplicate by originalText + type + text) + const merged = [...session.annotations]; + const existingSet = new Set( + merged.map((a) => `${a.originalText}|${a.type}|${a.text || ""}`) + ); + + const newAnnotations = body.annotations.filter((ann) => { + const key = `${ann.originalText}|${ann.type}|${ann.text || ""}`; + return !existingSet.has(key); + }); + + merged.push(...newAnnotations); + + // Track unique reviewers + const reviewers = new Set( + merged.map((a) => a.author).filter((author): author is string => Boolean(author)) + ); + + // Merge global attachments (deduplicate by path) + const globalAttachments = [...(session.globalAttachments || [])]; + if (body.globalAttachments?.length) { + const existingPaths = new Set(globalAttachments.map((g) => g.path)); + const newAttachments = body.globalAttachments.filter( + (g) => !existingPaths.has(g.path) + ); + globalAttachments.push(...newAttachments); + } + + // Build updated diff contexts array + const diffContexts = merged.map((a) => a.diffContext || null); + + const updatedSession: ReviewSession = { + ...session, + annotations: merged, + globalAttachments, + diffContexts: diffContexts.some((d) => d !== null) ? diffContexts : [], + lastUpdatedAt: Date.now(), + reviewerCount: reviewers.size, + version: session.version + 1, + }; + + const success = await store.updateSession(id, updatedSession, opts.ttlSeconds); + + if (!success) { + return Response.json( + { + error: + "Update failed — session was modified by another reviewer. Please refresh.", + }, + { status: 409, headers: cors } + ); + } + + return Response.json({ session: updatedSession }, { status: 200, headers: cors }); + } catch (e) { + console.error("Failed to add annotations:", e); + return Response.json( + { error: "Failed to add annotations" }, + { status: 500, headers: cors } + ); + } +} diff --git a/apps/paste-service/core/storage.ts b/apps/paste-service/core/storage.ts index cc210560..573daf0c 100644 --- a/apps/paste-service/core/storage.ts +++ b/apps/paste-service/core/storage.ts @@ -1,9 +1,16 @@ +import type { ReviewSession } from '@plannotator/ui/types'; + /** * PasteStore interface — pluggable storage backend for paste data. * - * Implementations: FsPasteStore (filesystem), KvPasteStore (CF KV) + * Implementations: FsPasteStore (filesystem), KvPasteStore (CF KV), S3PasteStore (S3) */ export interface PasteStore { put(id: string, data: string, ttlSeconds: number): Promise; get(id: string): Promise; + + // Review Session methods + putSession(id: string, session: ReviewSession, ttlSeconds: number): Promise; + getSession(id: string): Promise; + updateSession(id: string, session: ReviewSession, ttlSeconds: number): Promise; } diff --git a/apps/paste-service/stores/fs.ts b/apps/paste-service/stores/fs.ts index 9e4a72a5..d1392bc8 100644 --- a/apps/paste-service/stores/fs.ts +++ b/apps/paste-service/stores/fs.ts @@ -1,6 +1,7 @@ import { mkdirSync, readdirSync, readFileSync, unlinkSync } from "fs"; import { join, resolve } from "path"; import type { PasteStore } from "../core/storage"; +import type { ReviewSession } from "@plannotator/ui/types"; interface PasteFile { data: string; @@ -67,4 +68,58 @@ export class FsPasteStore implements PasteStore { // dataDir might not exist yet } } + + // Review Session methods + + async putSession(id: string, session: ReviewSession, ttlSeconds: number): Promise { + const entry: PasteFile = { + data: JSON.stringify(session), + expiresAt: Date.now() + ttlSeconds * 1000, + }; + const path = this.safePath(`session-${id}`); + await Bun.write(path, JSON.stringify(entry)); + } + + async getSession(id: string): Promise { + const path = this.safePath(`session-${id}`); + try { + const entry: PasteFile = await Bun.file(path).json(); + if (Date.now() > entry.expiresAt) { + unlinkSync(path); + return null; + } + return JSON.parse(entry.data); + } catch { + return null; + } + } + + async updateSession(id: string, session: ReviewSession, ttlSeconds: number): Promise { + const path = this.safePath(`session-${id}`); + try { + // Read existing session + const entry: PasteFile = await Bun.file(path).json(); + if (Date.now() > entry.expiresAt) { + unlinkSync(path); + return false; + } + + const existing: ReviewSession = JSON.parse(entry.data); + + // Optimistic locking check + if (existing.version !== session.version - 1) { + return false; // Version mismatch — another client updated first + } + + // Write updated session + const newEntry: PasteFile = { + data: JSON.stringify(session), + expiresAt: Date.now() + ttlSeconds * 1000, + }; + await Bun.write(path, JSON.stringify(newEntry)); + return true; + } catch { + return false; + } + } } diff --git a/apps/paste-service/stores/kv.ts b/apps/paste-service/stores/kv.ts index 74ce189a..8515cf40 100644 --- a/apps/paste-service/stores/kv.ts +++ b/apps/paste-service/stores/kv.ts @@ -1,4 +1,5 @@ import type { PasteStore } from "../core/storage"; +import type { ReviewSession } from "@plannotator/ui/types"; /** * Cloudflare KV-backed paste store. @@ -14,4 +15,39 @@ export class KvPasteStore implements PasteStore { async get(id: string): Promise { return this.kv.get(`paste:${id}`); } + + // Review Session methods + + async putSession(id: string, session: ReviewSession, ttlSeconds: number): Promise { + await this.kv.put(`session:${id}`, JSON.stringify(session), { + expirationTtl: ttlSeconds, + }); + } + + async getSession(id: string): Promise { + const data = await this.kv.get(`session:${id}`); + return data ? JSON.parse(data) : null; + } + + async updateSession(id: string, session: ReviewSession, ttlSeconds: number): Promise { + try { + // KV doesn't support atomic compare-and-swap, so we use get-then-put + // (risk of race condition in high-concurrency scenarios, but acceptable for this use case) + const existing = await this.getSession(id); + + if (!existing) { + return false; // Session not found + } + + // Optimistic locking check + if (existing.version !== session.version - 1) { + return false; // Version mismatch + } + + await this.putSession(id, session, ttlSeconds); + return true; + } catch { + return false; + } + } } diff --git a/bun.lock b/bun.lock index e7c68807..8131a904 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "plannotator", diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index a0a9313b..e04713da 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -9,6 +9,7 @@ import { Annotation, Block, EditorMode, type InputMethod, type ImageAttachment } import { ThemeProvider } from '@plannotator/ui/components/ThemeProvider'; import { ModeToggle } from '@plannotator/ui/components/ModeToggle'; import { AnnotationToolstrip } from '@plannotator/ui/components/AnnotationToolstrip'; +import { CollaborativeSessionButton } from '@plannotator/ui/components/CollaborativeSessionButton'; import { TaterSpriteRunning } from '@plannotator/ui/components/TaterSpriteRunning'; import { TaterSpritePullup } from '@plannotator/ui/components/TaterSpritePullup'; import { Settings } from '@plannotator/ui/components/Settings'; @@ -1032,6 +1033,18 @@ const App: React.FC = () => { + {/* Collaborative Session Button */} + {!linkedDocHook.isActive && sharingEnabled && ( + + )} +
+ + + + {error &&
{error}
} +
+ ); + } + + return ( + <> + + + {showShareDialog && ( +
+
+

Share Review Session

+ +

+ Share this URL with your team. Everyone can add annotations, and you can import all feedback at once. +

+ +
+ + +
+ + +
+
+ )} + + {error &&
{error}
} + + ); +}; diff --git a/packages/ui/hooks/useCollaborativeSession.ts b/packages/ui/hooks/useCollaborativeSession.ts new file mode 100644 index 00000000..c5d8608a --- /dev/null +++ b/packages/ui/hooks/useCollaborativeSession.ts @@ -0,0 +1,260 @@ +import { useState, useCallback, useEffect } from 'react'; +import type { Annotation, ImageAttachment, ReviewSession } from '../types'; + +interface UseCollaborativeSessionResult { + /** Whether this is an active collaborative session */ + isCollaborativeSession: boolean; + + /** Session ID (empty if not collaborative) */ + sessionId: string; + + /** Current session version (for optimistic locking) */ + sessionVersion: number; + + /** Loading state */ + isLoading: boolean; + + /** Error message */ + error: string; + + /** Create a new collaborative session and get share URL */ + createSession: () => Promise; + + /** Join an existing session by ID */ + joinSession: (sessionId: string) => Promise; + + /** Submit annotations to the session (incremental) */ + submitAnnotations: ( + annotations: Annotation[], + globalAttachments?: ImageAttachment[] + ) => Promise; + + /** Refresh session to get latest annotations from other reviewers */ + refreshSession: () => Promise; + + /** Number of reviewers in session */ + reviewerCount: number; + + /** Last update timestamp */ + lastUpdatedAt: number; +} + +export function useCollaborativeSession( + markdown: string, + setAnnotations: React.Dispatch>, + setGlobalAttachments: React.Dispatch>, + pasteApiUrl?: string +): UseCollaborativeSessionResult { + const [isCollaborativeSession, setIsCollaborativeSession] = useState(false); + const [sessionId, setSessionId] = useState(''); + const [sessionVersion, setSessionVersion] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); + const [reviewerCount, setReviewerCount] = useState(0); + const [lastUpdatedAt, setLastUpdatedAt] = useState(0); + + const apiBase = pasteApiUrl || 'https://plannotator-paste.plannotator.workers.dev'; + + const createSession = useCallback(async (): Promise => { + setIsLoading(true); + setError(''); + + try { + const response = await fetch(`${apiBase}/api/review-session`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ plan: markdown }), + signal: AbortSignal.timeout(10_000), + }); + + if (!response.ok) { + const errData = await response.json().catch(() => ({ error: 'Failed to create session' })); + setError(errData.error || 'Failed to create session'); + return null; + } + + const { session, shareUrl } = await response.json(); + + setIsCollaborativeSession(true); + setSessionId(session.id); + setSessionVersion(session.version); + setReviewerCount(session.reviewerCount); + setLastUpdatedAt(session.lastUpdatedAt); + + return shareUrl; + } catch (e) { + setError('Network error while creating session'); + return null; + } finally { + setIsLoading(false); + } + }, [markdown, apiBase]); + + const joinSession = useCallback( + async (id: string): Promise => { + setIsLoading(true); + setError(''); + + try { + const response = await fetch(`${apiBase}/api/review-session/${id}`, { + signal: AbortSignal.timeout(10_000), + }); + + if (!response.ok) { + setError('Session not found or expired'); + return false; + } + + const { session } = await response.json(); + + setIsCollaborativeSession(true); + setSessionId(session.id); + setSessionVersion(session.version); + setReviewerCount(session.reviewerCount); + setLastUpdatedAt(session.lastUpdatedAt); + + // Load session state into UI + setAnnotations(session.annotations); + if (session.globalAttachments?.length) { + setGlobalAttachments(session.globalAttachments); + } + + return true; + } catch { + setError('Failed to join session'); + return false; + } finally { + setIsLoading(false); + } + }, + [apiBase, setAnnotations, setGlobalAttachments] + ); + + const submitAnnotations = useCallback( + async (annotations: Annotation[], globalAttachments?: ImageAttachment[]): Promise => { + if (!isCollaborativeSession) return false; + + setIsLoading(true); + setError(''); + + try { + const response = await fetch(`${apiBase}/api/review-session/${sessionId}/annotations`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + annotations, + globalAttachments, + expectedVersion: sessionVersion, + }), + signal: AbortSignal.timeout(10_000), + }); + + if (!response.ok) { + if (response.status === 409) { + setError('Session was updated by another reviewer — refreshing...'); + // Auto-refresh on conflict + await refreshSession(); + } else { + const errData = await response.json().catch(() => ({ error: 'Failed to submit' })); + setError(errData.error || 'Failed to submit annotations'); + } + return false; + } + + const { session } = await response.json(); + + setSessionVersion(session.version); + setReviewerCount(session.reviewerCount); + setLastUpdatedAt(session.lastUpdatedAt); + + return true; + } catch { + setError('Network error while submitting annotations'); + return false; + } finally { + setIsLoading(false); + } + }, + [isCollaborativeSession, sessionId, sessionVersion, apiBase] + ); + + const refreshSession = useCallback(async (): Promise => { + if (!isCollaborativeSession) return false; + + setIsLoading(true); + setError(''); + + try { + const response = await fetch(`${apiBase}/api/review-session/${sessionId}`, { + signal: AbortSignal.timeout(10_000), + }); + + if (!response.ok) { + setError('Failed to refresh session'); + return false; + } + + const { session } = await response.json(); + + setSessionVersion(session.version); + setReviewerCount(session.reviewerCount); + setLastUpdatedAt(session.lastUpdatedAt); + + // Merge annotations (keep local state, add new ones from server) + setAnnotations((prev) => { + const merged = [...prev]; + const existingSet = new Set(merged.map((a) => `${a.originalText}|${a.type}|${a.text || ''}`)); + + const newFromServer = session.annotations.filter((ann: Annotation) => { + const key = `${ann.originalText}|${ann.type}|${ann.text || ''}`; + return !existingSet.has(key); + }); + + return [...merged, ...newFromServer]; + }); + + if (session.globalAttachments?.length) { + setGlobalAttachments((prev) => { + const existingPaths = new Set(prev.map((g) => g.path)); + const newAttachments = session.globalAttachments.filter((g: ImageAttachment) => !existingPaths.has(g.path)); + return [...prev, ...newAttachments]; + }); + } + + return true; + } catch { + setError('Failed to refresh session'); + return false; + } finally { + setIsLoading(false); + } + }, [isCollaborativeSession, sessionId, apiBase, setAnnotations, setGlobalAttachments]); + + // Check URL for /s/ pattern on mount + useEffect(() => { + const pathMatch = window.location.pathname.match(/^\/s\/([A-Za-z0-9]{6,16})$/); + if (pathMatch) { + const id = pathMatch[1]; + joinSession(id).then((success) => { + if (success) { + // Clean up URL + window.history.replaceState({}, '', '/'); + } + }); + } + }, [joinSession]); + + return { + isCollaborativeSession, + sessionId, + sessionVersion, + isLoading, + error, + createSession, + joinSession, + submitAnnotations, + refreshSession, + reviewerCount, + lastUpdatedAt, + }; +} diff --git a/packages/ui/types.ts b/packages/ui/types.ts index b548ac72..f0f3a461 100644 --- a/packages/ui/types.ts +++ b/packages/ui/types.ts @@ -103,3 +103,53 @@ export interface VaultNode { } export type { EditorAnnotation } from '@plannotator/shared/types'; + +// Collaborative Review Session Types +export interface ReviewSession { + /** Unique session ID (8 chars, same format as paste IDs) */ + id: string; + + /** Base plan markdown that all reviewers see */ + plan: string; + + /** Accumulated annotations from all reviewers */ + annotations: Annotation[]; + + /** Global attachments (images uploaded by reviewers) */ + globalAttachments?: ImageAttachment[]; + + /** Optional diff contexts (parallel to annotations array) */ + diffContexts?: (string | null)[]; + + /** Timestamp when session was created */ + createdAt: number; + + /** Timestamp of last annotation added */ + lastUpdatedAt: number; + + /** Expiration timestamp (7 days from creation) */ + expiresAt: number; + + /** Number of unique reviewers (tracked via author field) */ + reviewerCount: number; + + /** Lock to prevent concurrent writes (simple optimistic locking) */ + version: number; +} + +export interface CreateReviewSessionRequest { + plan: string; + globalAttachments?: ImageAttachment[]; +} + +export interface AddAnnotationsRequest { + annotations: Annotation[]; + globalAttachments?: ImageAttachment[]; + /** Expected version for optimistic locking (prevents overwrite conflicts) */ + expectedVersion: number; +} + +export interface ReviewSessionResponse { + session: ReviewSession; + shareUrl: string; // Full URL for sharing with team +}