diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c0f5b6f35..7d69a998a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -85,15 +85,28 @@ jobs: with: version: 2.116.0 - - name: Start isolated database and apply migrations - run: supabase db start + - name: Install dependencies + run: npm ci - - name: Database policy tests - run: npm run test:db + - name: Install Chromium + run: npx playwright install --with-deps chromium - - name: Stop isolated database - if: always() - run: supabase stop --no-backup + - name: Database and real workspace journey + env: + WORKSPACE_GATE_ARTIFACT_ROOT: ${{ runner.temp }}/workspace-gate + run: ./.superset/db-gate.sh + + - name: Upload workspace failure artifacts + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: workspace-gate-failure-${{ github.run_id }} + path: | + ${{ runner.temp }}/workspace-gate/ + test-results/ + playwright-report/ + if-no-files-found: ignore + retention-days: 7 browser-tests: if: github.event_name == 'pull_request' diff --git a/.superset/db-gate.sh b/.superset/db-gate.sh index aee389d45..f3e5b883f 100755 --- a/.superset/db-gate.sh +++ b/.superset/db-gate.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Runs the Docker-backed Supabase migration/pgTAP gate -# (`supabase db start` + `npm run test:db`) with exclusive access across +# Runs the Docker-backed Supabase, database, collaboration, and real-Auth browser gate +# with exclusive access across # Superset workspaces of this repo. `supabase/config.toml` sets no # project_id or custom port block, so `supabase db start` always binds the # CLI's default ports and container names - two workspaces running it at @@ -14,14 +14,37 @@ source "$DIR/lib/lock.sh" DB_LOCK="$HOME/.superset/debatelab-db.lock" stack_started=false +runtime_dir="" +owned_pids=() + +stop_owned_processes() { + local index + for ((index=${#owned_pids[@]}-1; index>=0; index--)); do + kill -- "-${owned_pids[$index]}" 2>/dev/null || true + done + for ((index=${#owned_pids[@]}-1; index>=0; index--)); do + wait "${owned_pids[$index]}" 2>/dev/null || true + done +} cleanup() { local status=$? trap - EXIT + stop_owned_processes + + if [ -n "$runtime_dir" ] && [ -f "$runtime_dir/function.env" ]; then + rm -- "$runtime_dir/function.env" + fi + if [ "$stack_started" = true ] && ! supabase stop --no-backup; then [ "$status" -ne 0 ] || status=1 fi + if [ "$status" -ne 0 ] && [ -n "$runtime_dir" ]; then + echo "Workspace gate failed; local service logs retained at $runtime_dir" + elif [ -n "$runtime_dir" ] && [ -d "$runtime_dir" ]; then + rm -r -- "$runtime_dir" + fi lock_release "$DB_LOCK" exit "$status" } @@ -30,6 +53,69 @@ echo "Waiting for exclusive access to the local Supabase stack..." lock_acquire "$DB_LOCK" 600 trap cleanup EXIT -supabase db start +if [ -n "${WORKSPACE_GATE_ARTIFACT_ROOT:-}" ]; then + mkdir -p -- "$WORKSPACE_GATE_ARTIFACT_ROOT" + runtime_dir="$(mktemp -d "$WORKSPACE_GATE_ARTIFACT_ROOT/workspace-gate.XXXXXX")" +else + runtime_dir="$(mktemp -d)" +fi +supabase start >"$runtime_dir/supabase.log" 2>&1 stack_started=true npm run test:db + +status_json="$(supabase status -o json 2>/dev/null)" +status_value() { + node -e 'const value=JSON.parse(process.argv[1])[process.argv[2]]; if (!value) process.exit(1); process.stdout.write(String(value))' "$status_json" "$1" +} +api_url="$(status_value API_URL)" +anon_key="$(status_value ANON_KEY)" +service_role_key="$(status_value SERVICE_ROLE_KEY)" +database_url="$(status_value DB_URL)" +app_origin="http://127.0.0.1:18081" +project_id="$(basename "$(pwd)")" +mailer_host="$(docker ps --filter "name=supabase_inbucket_$project_id" --format '{{.Names}}')" +if [ -z "$mailer_host" ]; then + echo "Local Mailpit container is unavailable" >&2 + exit 1 +fi +LOCAL_SERVICE_ROLE_KEY="$service_role_key" \ +LOCAL_MAILER_HOST="$mailer_host" \ + node scripts/workspace-test-env.mjs .superset/workspace-test.env "$runtime_dir/function.env" + +setsid supabase functions serve workspace-invites --no-verify-jwt \ + --env-file "$runtime_dir/function.env" >"$runtime_dir/functions.log" 2>&1 & +owned_pids+=("$!") + +VITE_LOCAL_PREVIEW=true \ +VITE_LOCAL_SUPABASE_URL="$api_url" \ +VITE_LOCAL_SUPABASE_ANON_KEY="$anon_key" \ +VITE_COLLABORATION_URL=ws://127.0.0.1:12345 \ + setsid npm run dev -- --host 127.0.0.1 --port 18081 --strictPort >"$runtime_dir/vite.log" 2>&1 & +owned_pids+=("$!") + +mailer_url="$(status_value MAILPIT_URL)" +for url in "$mailer_url/api/v1/messages" "$app_origin"; do + for _ in $(seq 1 200); do + if curl --fail --silent --output /dev/null "$url"; then break; fi + sleep 0.1 + done + curl --fail --silent --output /dev/null "$url" +done +function_url="$api_url/functions/v1/workspace-invites" +for _ in $(seq 1 200); do + if curl --fail --silent --output /dev/null --request OPTIONS --header "Origin: $app_origin" "$function_url"; then break; fi + sleep 0.1 +done +curl --fail --silent --output /dev/null --request OPTIONS --header "Origin: $app_origin" "$function_url" + +LOCAL_SUPABASE_URL="$api_url" \ +LOCAL_SUPABASE_ANON_KEY="$anon_key" \ +LOCAL_SUPABASE_SERVICE_ROLE_KEY="$service_role_key" \ +WORKSPACE_TEST_MAILER_URL="$mailer_url" \ +COLLABORATION_DATABASE_URL="$database_url" \ +COLLABORATION_PORT=12345 \ +COLLABORATION_ALLOWED_ORIGINS="$app_origin" \ +SUPABASE_URL="$api_url" \ +SUPABASE_ANON_KEY="$anon_key" \ +PLAYWRIGHT_BASE_URL="$app_origin" \ + npx playwright test --config=playwright.workspace.config.ts diff --git a/.superset/workspace-test.env b/.superset/workspace-test.env new file mode 100644 index 000000000..ee09e2f1b --- /dev/null +++ b/.superset/workspace-test.env @@ -0,0 +1,7 @@ +# Test-only values for the locked local workspace browser gate. +WORKSPACE_INVITE_SECRET=local-workspace-invite-secret-for-browser-gate +WORKSPACE_INVITE_MAILER_URL=http://workspace-mailer.invalid:8025/api/v1/send +WORKSPACE_INVITE_MAILER_TOKEN=local-workspace-mailer +WORKSPACE_INVITE_MAILER_FORMAT=mailpit +WORKSPACE_INVITE_BASE_URL=http://127.0.0.1:18081 +WORKSPACE_INVITE_ALLOWED_ORIGINS=http://127.0.0.1:18081 diff --git a/docs/development.md b/docs/development.md index f5b2997bb..39a378f8f 100644 --- a/docs/development.md +++ b/docs/development.md @@ -77,15 +77,15 @@ Follow existing functional React, Zustand, Zod, and shadcn/Radix patterns. Reuse Run the closest unit or component test while changing behavior. Run lint, type checking, and build before handing work off. -## Collaboration service spike +## Collaboration service -The Phase 0 service evidence lives under `services/collaboration/`. Run: +The local PostgreSQL-backed Hocuspocus service lives under `services/collaboration/`. Run its focused tests with: ```bash npm run test:collaboration ``` -The spike is not a deployed service. Its in-memory writer lock is single-process evidence; the database-backed service and advisory-lock lifecycle belong to P2.1. +Start it only with explicit local Supabase values for `COLLABORATION_DATABASE_URL`, `COLLABORATION_PORT`, `COLLABORATION_ALLOWED_ORIGINS`, `SUPABASE_URL`, and `SUPABASE_ANON_KEY`. The service binds loopback, takes one PostgreSQL session advisory lock per loaded document epoch, and closes sessions on access or restore notifications. It is not a production deployment. ## Edge Function workflow @@ -119,7 +119,7 @@ npm run test:db supabase stop --no-backup ``` -The default local stack begins at port `54321` and uses shared container names, so it is not parallel-safe across worktrees. The wrapper holds `~/.superset/debatelab-db.lock` and stops the stack on exit, including after a test failure. Outside Superset, coordinate ownership before starting it. The agent that starts the stack stops it before finishing, including after failures when safe. +The default local stack begins at port `54321` and uses shared container names, so it is not parallel-safe across worktrees. The wrapper holds `~/.superset/debatelab-db.lock` through database tests and the real-Auth collaboration browser journey, and stops the stack and owned processes on exit. Outside Superset, coordinate ownership before starting it. The agent that starts the stack stops it before finishing, including after failures when safe. Never point local tests at staging or production. Never apply a migration to an unverified project. diff --git a/docs/testing.md b/docs/testing.md index 3bdf38aa2..e0bef88d1 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -54,7 +54,7 @@ For a bug, watch the reproduction fail for the expected reason before implementi npm run test:collaboration ``` -This runs the isolated Node service spike with real Hocuspocus providers and Yjs documents. It covers durable acknowledgement ordering, reconnect sync, duplicate delivery, schema and size rejection, writer serialization, stale epochs, and process-kill recovery. It does not prove PostgreSQL advisory locks or a deployed collaboration service. +This runs the Node collaboration service with real Hocuspocus providers and Yjs documents. It covers durable acknowledgement ordering, reconnect sync, duplicate delivery, schema and size rejection, PostgreSQL adapter behavior, proposal decision rollback, writer serialization, stale epochs, and process-kill recovery. It does not prove a deployed service. ## Edge Function tests @@ -136,7 +136,7 @@ In Superset, run: ./.superset/db-gate.sh ``` -The wrapper acquires `~/.superset/debatelab-db.lock`, starts the stack, runs `npm run test:db`, and stops the stack on exit, including after a test failure. Outside Superset, run: +The wrapper acquires `~/.superset/debatelab-db.lock`, starts full local Supabase, runs `npm run test:db`, then holds the same lock while a controlled Mailpit relay, collaboration process, and Vite client run the real-Auth desktop/mobile workspace journey. Pull requests run this same gate in the `database-tests` job and retain its logs and browser traces on failure. It stops every owned process and the stack on exit, including after failure. Outside Superset, run only the database portion with: ```bash supabase db start @@ -144,7 +144,7 @@ npm run test:db supabase stop --no-backup ``` -The stack replays migrations and runs pgTAP tests. `CREATE INDEX CONCURRENTLY` requires the stated CLI floor so it runs outside the migration batch. +The stack replays migrations and runs pgTAP tests. The browser portion creates isolated local users and deletes them, captures mail only in local Mailpit, restarts the collaboration process, and proves recovery and revocation. `CREATE INDEX CONCURRENTLY` requires the stated CLI floor so it runs outside the migration batch. The local stack begins at port `54321` and is not parallel-safe across worktrees. The task that starts it must stop it before finishing or abandoning the run. Use `supabase stop --no-backup` unless another active task explicitly owns the same stack. diff --git a/e2e/fixtures/workspace-review-ime.html b/e2e/fixtures/workspace-review-ime.html index c16d77f29..1a5ed9b06 100644 --- a/e2e/fixtures/workspace-review-ime.html +++ b/e2e/fixtures/workspace-review-ime.html @@ -12,22 +12,33 @@ import { createWorkspaceEditor } from "/src/lib/workspace/editor-schema.ts"; import * as review from "/src/lib/workspace/review.ts"; - const canonicalDocument = new Y.Doc(); - const suggestionDocument = new Y.Doc(); - const canonical = createWorkspaceEditor(canonicalDocument); - const suggestion = createWorkspaceEditor(suggestionDocument); - canonical.commands.setContent("

Accepted:

"); - suggestion.commands.setContent("

"); - document.querySelector("#suggestion-editor").append(suggestion.view.dom); - let phase = "idle"; let proposal = null; + let proposalId = "ime"; let canonicalUpdates = 0; const events = []; - canonicalDocument.on("update", () => canonicalUpdates += 1); + let suggesting = false; + const yDocument = new Y.Doc(); + const editor = createWorkspaceEditor(yDocument, { + isSuggesting: () => suggesting, + onSuggestion(hunk) { + phase = "settled"; + proposal = review.createProposal({ + id: proposalId, + authorId: "ime-member", + documentEpoch: 1, + editor, + hunks: [hunk], + }); + }, + }); + editor.commands.setContent("

Accepted:

"); + yDocument.on("update", () => canonicalUpdates += 1); + suggesting = true; + document.querySelector("#suggestion-editor").append(editor.view.dom); for (const type of ["compositionstart", "beforeinput", "input", "compositionend"]) { - suggestion.view.dom.addEventListener(type, (event) => { + editor.view.dom.addEventListener(type, (event) => { if (type === "compositionstart") phase = "composing"; if (type === "compositionend") phase = "settled"; events.push({ @@ -40,10 +51,10 @@ } const snapshot = () => ({ - canonical: canonical.getText(), + canonical: editor.getText(), canonicalUpdates, - suggestion: suggestion.getText(), - editorComposing: suggestion.view.composing, + suggestion: editor.getText(), + editorComposing: editor.view.composing, phase, events: [...events], proposal: proposal && { @@ -55,31 +66,18 @@ window.workspaceIme = { snapshot, capture(id) { - const at = canonical.state.doc.content.size - 1; - proposal = review.createProposalAfterComposition({ - compositionState: phase, - proposal: { - id, - authorId: "ime-member", - documentEpoch: 1, - editor: canonical, - hunks: [{ - id: `${id}-insert`, - kind: "insert", - at, - content: [{ type: "text", text: suggestion.getText() }], - }], - }, - }); + proposalId = id; return snapshot(); }, accept() { + suggesting = false; proposal = review.acceptProposalHunks({ - editor: canonical, + editor, documentEpoch: 1, proposal, hunkIds: [proposal.hunks[0].id], }); + suggesting = true; return snapshot(); }, reject() { @@ -90,7 +88,6 @@ return snapshot(); }, resetSuggestion() { - suggestion.commands.clearContent(); phase = "idle"; proposal = null; events.length = 0; diff --git a/e2e/workspace-collaboration.spec.ts b/e2e/workspace-collaboration.spec.ts new file mode 100644 index 000000000..8671c2a17 --- /dev/null +++ b/e2e/workspace-collaboration.spec.ts @@ -0,0 +1,350 @@ +import { createClient, type Session } from "@supabase/supabase-js"; +import { expect, test, type Browser, type BrowserContext, type Page } from "@playwright/test"; +import { spawn, type ChildProcess } from "node:child_process"; +import { createConnection } from "node:net"; +import { kill as killProcess } from "node:process"; + +const supabaseUrl = process.env.LOCAL_SUPABASE_URL; +const anonKey = process.env.LOCAL_SUPABASE_ANON_KEY; +const serviceKey = process.env.LOCAL_SUPABASE_SERVICE_ROLE_KEY; +const mailerUrl = process.env.WORKSPACE_TEST_MAILER_URL; +const collaborationPort = Number(process.env.COLLABORATION_PORT ?? 12345); + +test.skip(!supabaseUrl || !anonKey || !serviceKey || !mailerUrl, "Local workspace services are required"); + +function waitForPort(port: number) { + return new Promise((resolve, reject) => { + const deadline = Date.now() + 20_000; + const attempt = () => { + const socket = createConnection({ host: "127.0.0.1", port }); + socket.once("connect", () => { socket.destroy(); resolve(); }); + socket.once("error", () => { + socket.destroy(); + if (Date.now() >= deadline) reject(new Error(`Port ${port} did not open`)); + else setTimeout(attempt, 100); + }); + }; + attempt(); + }); +} + +async function stop(child: ChildProcess | undefined) { + if (!child || child.exitCode !== null) return; + const exited = new Promise((resolve) => child.once("exit", () => resolve())); + killProcess(-child.pid!, "SIGTERM"); + await Promise.race([exited, new Promise((resolve) => setTimeout(resolve, 2_000))]); + try { + killProcess(-child.pid!, "SIGKILL"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; + } +} + +async function startCollaboration() { + const child = spawn("npm", ["run", "start:collaboration"], { + cwd: process.cwd(), + detached: true, + env: process.env, + stdio: ["ignore", "inherit", "inherit"], + }); + await waitForPort(collaborationPort); + return child; +} + +async function authenticatedPage(browser: Browser, session: Session) { + const context = await browser.newContext(); + await context.addInitScript((value) => { + localStorage.setItem("debate-auth-local", JSON.stringify(value)); + }, session); + const page = await context.newPage(); + await page.route("**/functions/v1/announcements*", (route) => route.fulfill({ + body: JSON.stringify({ announcements: [] }), + contentType: "application/json", + status: 200, + })); + return { context, page }; +} + +async function signedOutPage(browser: Browser) { + const context = await browser.newContext(); + const page = await context.newPage(); + await page.route("**/functions/v1/announcements*", (route) => route.fulfill({ + body: JSON.stringify({ announcements: [] }), + contentType: "application/json", + status: 200, + })); + return { context, page }; +} + +async function openPanel(page: Page, name: string) { + const summary = page.locator("summary").filter({ hasText: name }); + if (!await summary.evaluate((element) => (element.parentElement as HTMLDetailsElement).open)) { + await summary.click(); + } +} + +async function showPane(page: Page, name: "Files" | "Document" | "Working panel", mobile: boolean) { + if (!mobile) return; + const button = page.getByRole("button", { name, exact: true }); + await expect(button).toBeVisible(); + await button.click(); +} + +test("real local users collaborate, recover, review, restore, and revoke access", async ({ browser, request }, testInfo) => { + test.setTimeout(testInfo.project.name === "mobile-chromium" ? 120_000 : 240_000); + const mobile = testInfo.project.name === "mobile-chromium"; + const admin = createClient(supabaseUrl!, serviceKey!, { auth: { persistSession: false } }); + const suffix = `${testInfo.project.name}-${Date.now()}@workspace.test`; + const emails = [`owner-${suffix}`, `member-${suffix}`, `email-${suffix}`]; + const password = `Local-${crypto.randomUUID()}!`; + const ids: string[] = []; + const contexts: BrowserContext[] = []; + let collaboration: ChildProcess | undefined; + + try { + const sessions: Session[] = []; + for (const email of emails) { + const created = await admin.auth.admin.createUser({ email, email_confirm: true, password }); + if (created.error || !created.data.user) throw created.error ?? new Error("User creation failed"); + ids.push(created.data.user.id); + const client = createClient(supabaseUrl!, anonKey!, { auth: { persistSession: false } }); + const signedIn = await client.auth.signInWithPassword({ email, password }); + if (signedIn.error || !signedIn.data.session) throw signedIn.error ?? new Error("Sign in failed"); + sessions.push(signedIn.data.session); + } + + collaboration = await startCollaboration(); + const owner = await authenticatedPage(browser, sessions[0]); + const member = await authenticatedPage(browser, sessions[1]); + contexts.push(owner.context, member.context); + + await owner.page.goto("/app/workspaces"); + await owner.page.getByRole("textbox", { name: "Motion" }).fill("Schools should ban phones"); + await owner.page.getByRole("button", { name: "Create workspace" }).click(); + await expect(owner.page).toHaveURL(/\/app\/workspaces\/[0-9a-f-]+$/); + const workspaceUrl = owner.page.url(); + const workspaceId = workspaceUrl.split("/").at(-1)!; + + await showPane(owner.page, "Working panel", mobile); + await openPanel(owner.page, "Invite people"); + await owner.page.getByRole("button", { name: "Create join code" }).click(); + await expect(owner.page.getByRole("textbox", { name: "Join code" })).toBeVisible({ timeout: 10_000 }); + const code = await owner.page.getByRole("textbox", { name: "Join code" }).inputValue(); + await member.page.goto("/app/workspaces/invite"); + await member.page.getByLabel("Join code").fill(code); + await member.page.getByRole("button", { name: "Join", exact: true }).click(); + await expect(member.page).toHaveURL(workspaceUrl); + + await showPane(owner.page, "Files", mobile); + await owner.page.getByRole("button", { name: "R1" }).click(); + await expect(owner.page.getByLabel("Assignee").locator(`option[value="${ids[1]}"]`)).toHaveCount(1, { timeout: 10_000 }); + await owner.page.getByLabel("Assignee").selectOption(ids[1]); + await expect(owner.page.getByLabel("Assignee")).toHaveValue(ids[1]); + await showPane(member.page, "Files", mobile); + await member.page.getByRole("button", { name: "R1" }).click(); + const ownerEditor = owner.page.locator(".ProseMirror"); + const memberEditor = member.page.locator(".ProseMirror"); + await ownerEditor.fill("Phones interrupt learning"); + await expect(owner.page.getByText("Saved", { exact: true })).toBeVisible(); + await expect(memberEditor).toContainText("Phones interrupt learning"); + await memberEditor.click(); + await memberEditor.press("End"); + await memberEditor.pressSequentially(" and sleep"); + await expect(ownerEditor).toContainText("and sleep"); + + await stop(collaboration); + collaboration = await startCollaboration(); + await owner.page.reload(); + await showPane(owner.page, "Files", mobile); + await owner.page.getByRole("button", { name: "R1" }).click(); + await expect(owner.page.locator(".ProseMirror")).toContainText("Phones interrupt learning and sleep"); + await member.page.reload(); + await showPane(member.page, "Files", mobile); + await member.page.getByRole("button", { name: "R1" }).click(); + await expect(member.page.locator(".ProseMirror")).toContainText("Phones interrupt learning and sleep"); + + if (testInfo.project.name === "mobile-chromium") { + await owner.page.getByRole("button", { name: "Editing" }).click(); + await owner.page.locator(".ProseMirror").click(); + await owner.page.locator(".ProseMirror").press("ControlOrMeta+A"); + await owner.page.keyboard.insertText("Focused phone policy"); + await showPane(member.page, "Working panel", mobile); + await openPanel(member.page, "Review"); + await expect(member.page.getByRole("button", { name: "Accept" })).toHaveCount(1); + await member.page.getByRole("button", { name: "Accept" }).click(); + await showPane(member.page, "Document", mobile); + await expect(member.page.locator(".ProseMirror")).toContainText("Focused phone policy"); + return; + } + + await openPanel(owner.page, "Invite people"); + await owner.page.getByLabel("Email").fill(emails[2]); + await owner.page.getByRole("button", { name: "Send", exact: true }).click(); + await expect(owner.page.getByText("Email invitation sent.")).toBeVisible(); + let inviteUrl = ""; + await expect.poll(async () => { + const response = await request.get(`${mailerUrl}/api/v1/messages`); + const payload = await response.json() as { messages?: Array> } | Array>; + const messages = Array.isArray(payload) ? payload : payload.messages ?? []; + const message = [...messages].reverse().find((item) => JSON.stringify(item).includes(emails[2])); + const id = message?.ID ?? message?.Id ?? message?.id; + if (typeof id === "string") { + const detail = await (await request.get(`${mailerUrl}/api/v1/message/${id}`)).text(); + inviteUrl = detail.match(/https?:\/\/[^\s"<>]+token=[^\s"<>]+/)?.[0] ?? ""; + } + return inviteUrl; + }).toMatch(/token=/); + const emailMember = await signedOutPage(browser); + contexts.push(emailMember.context); + const invitation = new URL(inviteUrl); + await emailMember.page.goto(`${invitation.pathname}${invitation.search}`); + await expect(emailMember.page).toHaveURL(/\/login$/); + await expect(emailMember.page).not.toHaveURL(/token=/); + await emailMember.page.getByLabel("Email address").fill(emails[2]); + await emailMember.page.getByRole("button", { name: "Send code" }).click(); + let otp = ""; + await expect.poll(async () => { + const response = await request.get(`${mailerUrl}/api/v1/messages`); + const payload = await response.json() as { messages?: Array> } | Array>; + const messages = Array.isArray(payload) ? payload : payload.messages ?? []; + for (const message of [...messages].reverse()) { + if (!JSON.stringify(message).includes(emails[2])) continue; + const id = message.ID ?? message.Id ?? message.id; + if (typeof id !== "string") continue; + const detail = await (await request.get(`${mailerUrl}/api/v1/message/${id}`)).text(); + otp = detail.match(/\b(\d{6})\b/)?.[1] ?? ""; + if (otp) break; + } + return otp; + }).toMatch(/^\d{6}$/); + await emailMember.page.getByRole("textbox", { name: "Verification code" }).fill(otp); + await emailMember.page.getByRole("button", { name: "Verify code" }).click(); + await expect(emailMember.page).toHaveURL(workspaceUrl); + await expect(emailMember.page).not.toHaveURL(/token=/); + + await owner.page.getByLabel("New file name").fill("Rebuttal"); + await owner.page.getByRole("button", { name: "Add" }).click(); + await owner.page.getByRole("button", { name: "Rebuttal" }).click(); + await owner.page.getByLabel("File name", { exact: true }).fill("Rebuttal notes"); + await owner.page.getByRole("button", { name: "Rename" }).click(); + await owner.page.getByRole("button", { name: "Duplicate" }).click(); + await owner.page.getByRole("region", { name: "Rebuttal notes" }).getByRole("button", { name: "Trash" }).click(); + await owner.page.getByRole("complementary", { name: "Files" }).getByRole("button", { name: "Trash" }).click(); + await owner.page.getByRole("region", { name: "Rebuttal notes" }).getByRole("button", { name: "Restore" }).click(); + + const beforeScopedDraft = await admin.from("workspace_documents") + .select("durable_sequence,id") + .eq("workspace_id", workspaceId) + .eq("side", "pro") + .eq("title", "R1") + .single(); + if (beforeScopedDraft.error) throw beforeScopedDraft.error; + await member.context.setOffline(true); + await member.page.locator(".ProseMirror").click(); + await member.page.locator(".ProseMirror").press("End"); + await member.page.locator(".ProseMirror").pressSequentially(" scoped draft"); + await member.page.getByRole("button", { name: "Intro" }).click(); + await member.context.setOffline(false); + await expect(member.page.getByText("Saved", { exact: true })).toBeVisible({ timeout: 15_000 }); + await expect(member.page.locator(".ProseMirror")).not.toContainText("scoped draft"); + await member.page.getByRole("button", { name: "R1" }).click(); + await expect(member.page.locator(".ProseMirror")).toContainText("scoped draft"); + await expect.poll(async () => { + const current = await admin.from("workspace_documents") + .select("durable_sequence") + .eq("id", beforeScopedDraft.data.id) + .single(); + if (current.error) throw current.error; + return current.data.durable_sequence; + }).toBeGreaterThan(beforeScopedDraft.data.durable_sequence); + await expect(member.page.getByText("Saved", { exact: true })).toBeVisible({ timeout: 15_000 }); + + await owner.page.getByRole("button", { name: "R1" }).click(); + await owner.page.getByRole("button", { name: "Editing" }).click(); + await owner.page.locator(".ProseMirror").click(); + await owner.page.locator(".ProseMirror").press("ControlOrMeta+A"); + await owner.page.keyboard.insertText("Phones reduce attention"); + await openPanel(member.page, "Review"); + await expect(member.page.getByLabel("Proposed change preview")).toContainText("reduce attention"); + await expect(member.page.getByLabel("Proposed change preview")).toContainText("interrupt learning"); + await expect(member.page.getByText("Original", { exact: true })).toBeVisible(); + await expect(member.page.getByText("Proposed", { exact: true })).toBeVisible(); + await expect(member.page.getByRole("button", { name: "Accept" })).toHaveCount(1); + await member.page.getByRole("button", { name: "Accept" }).click(); + await expect(member.page.locator(".ProseMirror")).toContainText("Phones reduce attention"); + + await owner.page.locator(".ProseMirror").click(); + await owner.page.locator(".ProseMirror").press("End"); + const cdp = await owner.context.newCDPSession(owner.page); + await cdp.send("Input.imeSetComposition", { text: " t", selectionStart: 2, selectionEnd: 2 }); + await cdp.send("Input.imeSetComposition", { text: " tűrő", selectionStart: 5, selectionEnd: 5 }); + await expect(owner.page.locator(".ProseMirror")).not.toContainText("tűrő"); + await cdp.send("Input.imeSetComposition", { text: "", selectionStart: 0, selectionEnd: 0 }); + await owner.page.waitForTimeout(500); + await expect(member.page.getByRole("button", { name: "Reject" })).toHaveCount(0); + await expect(owner.page.locator(".ProseMirror")).not.toContainText("tűrő"); + + await owner.page.getByRole("button", { name: "Suggesting" }).click(); + await owner.page.locator(".ProseMirror").click(); + await owner.page.locator(".ProseMirror").press("ControlOrMeta+A"); + await owner.page.getByRole("button", { name: "Comment" }).click(); + await owner.page.getByLabel("Comment text").fill("Can you verify this?"); + await owner.page.getByLabel("Mention member").selectOption(ids[1]); + await owner.page.getByRole("button", { name: "Add comment" }).click(); + await expect(member.page.getByText("A teammate mentioned you in a comment.")).toBeVisible(); + await member.page.getByLabel("Reply").fill("Verified."); + await member.page.getByRole("button", { name: "Reply" }).click(); + await member.page.getByRole("button", { name: "Resolve" }).click(); + + await openPanel(owner.page, "Version history"); + await owner.page.getByLabel("Version name").fill("Ready for coach"); + await owner.page.getByRole("button", { name: "Save version" }).click(); + await expect(owner.page.getByText("Ready for coach")).toBeVisible(); + await member.context.setOffline(true); + await member.page.locator(".ProseMirror").click(); + await member.page.locator(".ProseMirror").press("End"); + await member.page.locator(".ProseMirror").pressSequentially(" offline draft"); + const beforeRewrite = await admin.from("workspace_documents") + .select("durable_sequence,id") + .eq("workspace_id", workspaceId) + .eq("side", "pro") + .eq("title", "R1") + .single(); + if (beforeRewrite.error) throw beforeRewrite.error; + await owner.page.locator(".ProseMirror").fill("Temporary rewrite"); + await expect.poll(async () => { + const current = await admin.from("workspace_documents") + .select("durable_sequence") + .eq("id", beforeRewrite.data.id) + .single(); + if (current.error) throw current.error; + return current.data.durable_sequence; + }).toBeGreaterThan(beforeRewrite.data.durable_sequence); + await owner.page.getByRole("button", { name: "Preview" }).first().click(); + await owner.page.getByRole("button", { name: "Restore this version" }).click(); + await member.context.setOffline(false); + await member.page.reload(); + await expect(member.page.getByRole("alert").filter({ hasText: "Recovery" })).toBeVisible(); + await expect(member.page.locator(".ProseMirror")).not.toContainText("offline draft"); + await expect(member.page.getByText("Saved", { exact: true })).toBeVisible({ timeout: 15_000 }); + + await openPanel(owner.page, "Invite people"); + const memberRow = owner.page.getByText(`Member ${ids[1].slice(0, 4)}`, { exact: false }).last().locator(".."); + await memberRow.getByRole("button", { name: "Remove" }).click(); + await member.page.reload(); + await expect(member.page.getByRole("alert")).toContainText("Workspace unavailable"); + + await owner.page.getByRole("button", { name: "Archive workspace" }).click(); + await owner.page.goto("/app/workspaces"); + await owner.page.getByRole("textbox", { name: "Motion" }).fill("Homework should be optional"); + await owner.page.getByRole("button", { name: "Create workspace" }).click(); + await owner.page.goto("/app/workspaces"); + await owner.page.getByRole("button", { name: "Archived" }).click(); + await owner.page.getByRole("link", { name: /Schools should ban phones/ }).click(); + await owner.page.getByRole("button", { name: "Reopen workspace" }).click(); + } finally { + await stop(collaboration); + await Promise.allSettled(contexts.map((context) => context.close())); + await Promise.allSettled(ids.map((id) => admin.auth.admin.deleteUser(id))); + } +}); diff --git a/e2e/workspace-review-ime.spec.ts b/e2e/workspace-review-ime.spec.ts index 2caac968e..4a83172ed 100644 --- a/e2e/workspace-review-ime.spec.ts +++ b/e2e/workspace-review-ime.spec.ts @@ -45,6 +45,7 @@ async function compose( acceptedUpdates: number, ) { await page.locator(".ProseMirror").focus(); + await page.locator(".ProseMirror").press("End"); for (const text of steps) { await cdp.send("Input.imeSetComposition", { text, @@ -61,7 +62,7 @@ async function compose( } await cdp.send("Input.insertText", { text: steps[steps.length - 1] }); await expect.poll(async () => (await snapshot(page)).phase).toBe("settled"); - await expect.poll(async () => (await snapshot(page)).editorComposing).toBe(false); + await expect.poll(async () => (await snapshot(page)).proposal).not.toBeNull(); } test("captures Spanish and Hungarian IME text only after composition settles", async ({ @@ -80,11 +81,12 @@ test("captures Spanish and Hungarian IME text only after composition settles", a "compositionstart", "beforeinput", "input", - "compositionend", ])); - expect(spanishSettled.events.filter((event) => event.type === "beforeinput").every((event) => - event.inputType === "insertCompositionText" && event.isComposing, - )).toBe(true); + const compositionInputs = spanishSettled.events.filter((event) => + event.type === "beforeinput" && event.inputType === "insertCompositionText", + ); + expect(compositionInputs.length).toBeGreaterThan(0); + expect(compositionInputs.every((event) => event.isComposing)).toBe(true); expect(spanishSettled.proposal).toEqual({ status: "pending", replacement: [{ type: "text", text: "niño" }], diff --git a/package-lock.json b/package-lock.json index d1b3c95e4..09f7c18ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,8 +41,10 @@ "@tanstack/react-query": "^5.83.0", "@tiptap/core": "3.31.3", "@tiptap/extension-collaboration": "3.31.3", + "@tiptap/extension-collaboration-caret": "3.31.3", "@tiptap/extension-table": "3.31.3", "@tiptap/pm": "3.31.3", + "@tiptap/react": "3.31.3", "@tiptap/starter-kit": "3.31.3", "@tiptap/y-tiptap": "3.0.9", "@vercel/analytics": "^2.0.1", @@ -58,10 +60,12 @@ "i18next": "^23.16.8", "jspdf": "^4.1.0", "jspdf-autotable": "^5.0.7", + "lib0": "0.2.117", "lucide-react": "^0.462.0", "mammoth": "^1.12.0", "next-themes": "^0.3.0", "pdfjs-dist": "^6.3.289", + "pg": "8.23.0", "posthog-js": "^1.399.2", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -89,6 +93,7 @@ "@testing-library/react": "^16.0.0", "@types/file-saver": "^2.0.7", "@types/node": "^22.16.5", + "@types/pg": "8.23.1", "@types/react": "^18.3.23", "@types/react-dom": "^18.3.7", "@vitejs/plugin-react-swc": "^3.11.0", @@ -3941,6 +3946,24 @@ "@tiptap/core": "3.31.3" } }, + "node_modules/@tiptap/extension-bubble-menu": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.31.3.tgz", + "integrity": "sha512-EV6ZnwKc++2OM/OcD54n8s1B7C9LP7GKAtdEPwu0t3BYJf3sG8Ueoinf7SgGPO9oMPg96GneOkDNm1urMV167g==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, "node_modules/@tiptap/extension-bullet-list": { "version": "3.31.3", "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.31.3.tgz", @@ -3997,6 +4020,21 @@ "yjs": "^13" } }, + "node_modules/@tiptap/extension-collaboration-caret": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration-caret/-/extension-collaboration-caret-3.31.3.tgz", + "integrity": "sha512-ub2SsYwmEcmTbqk4aC55M6AnU5/jJcr85lk0jQTqoo1h4ADGYcVXKaJY/CgDCxJgPxr6e/+IiNSYTLjW0nOEgg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3", + "@tiptap/y-tiptap": "^3.0.7" + } + }, "node_modules/@tiptap/extension-document": { "version": "3.31.3", "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.31.3.tgz", @@ -4023,6 +4061,22 @@ "@tiptap/extensions": "3.31.3" } }, + "node_modules/@tiptap/extension-floating-menu": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.31.3.tgz", + "integrity": "sha512-rd4VJ9PGSP9Eop8ZTEwaLZcMzMXLuJKe3hUNf58rq+zANpwM+9fI+vB7g9MAc3eXwUNDxNDVACFIL2oeBqpQ3A==", + "license": "MIT", + "optional": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@floating-ui/dom": "^1.0.0", + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, "node_modules/@tiptap/extension-gapcursor": { "version": "3.31.3", "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.31.3.tgz", @@ -4264,6 +4318,33 @@ "url": "https://github.com/sponsors/ueberdosis" } }, + "node_modules/@tiptap/react": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.31.3.tgz", + "integrity": "sha512-QiwQqvaLFLm5EMFu5tg7nAgXJxCUiUTLD8EsK+TqVV5P4bqOoMOCM39khbhXTJyahCuYpiFWx5YOSDtC/JiPtg==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "fast-equals": "^5.3.3", + "use-sync-external-store": "^1.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "optionalDependencies": { + "@tiptap/extension-bubble-menu": "^3.31.3", + "@tiptap/extension-floating-menu": "^3.31.3" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@tiptap/starter-kit": { "version": "3.31.3", "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.31.3.tgz", @@ -4701,6 +4782,18 @@ "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", "license": "MIT" }, + "node_modules/@types/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -4728,7 +4821,6 @@ "version": "18.3.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^18.0.0" @@ -4753,6 +4845,12 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.69.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.69.0.tgz", @@ -9453,6 +9551,95 @@ "license": "MIT", "optional": true }, + "node_modules/pg": { + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", + "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz", + "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -9743,6 +9930,45 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/posthog-js": { "version": "1.425.1", "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.425.1.tgz", @@ -12777,6 +13003,15 @@ "dev": true, "license": "MIT" }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y-protocols": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz", diff --git a/package.json b/package.json index 52097de90..583b4fa47 100644 --- a/package.json +++ b/package.json @@ -19,9 +19,10 @@ "smoke:jev": "node scripts/smoke-jev.mjs", "test": "vitest run", "test:collaboration": "vitest run --config services/collaboration/vitest.config.ts", + "start:collaboration": "tsx services/collaboration/src/index.ts", "test:watch": "vitest", - "test:edge": "deno test --allow-read --allow-env=DEMO_CODE_PEPPER,EXA_API_KEY,SUPABASE_URL,SUPABASE_ANON_KEY,SUPABASE_SERVICE_ROLE_KEY,SPARRING_INTEGRITY_SECRET,OPENROUTER_API_KEY,WORKSPACE_INVITE_SECRET,WORKSPACE_INVITE_MAILER_URL,WORKSPACE_INVITE_MAILER_TOKEN,WORKSPACE_INVITE_BASE_URL --allow-net=127.0.0.1 supabase/functions/", - "test:db": "supabase test db && bash scripts/test-daily-quota.sh && bash scripts/test-workspace-invite-concurrency.sh", + "test:edge": "deno test --allow-read --allow-env=DEMO_CODE_PEPPER,EXA_API_KEY,SUPABASE_URL,SUPABASE_ANON_KEY,SUPABASE_SERVICE_ROLE_KEY,SPARRING_INTEGRITY_SECRET,OPENROUTER_API_KEY,WORKSPACE_INVITE_SECRET,WORKSPACE_INVITE_MAILER_URL,WORKSPACE_INVITE_MAILER_TOKEN,WORKSPACE_INVITE_MAILER_FORMAT,WORKSPACE_INVITE_BASE_URL,WORKSPACE_INVITE_ALLOWED_ORIGINS,WORKSPACE_INVITE_SERVICE_ROLE_KEY --allow-net=127.0.0.1 supabase/functions/", + "test:db": "supabase test db && bash scripts/test-daily-quota.sh && bash scripts/test-workspace-invite-concurrency.sh && bash scripts/test-workspace-mutation-concurrency.sh", "test:e2e": "playwright test --project=desktop-chromium --project=mobile-chromium", "test:e2e:webkit": "playwright test --project=webkit", "test:e2e:staging": "playwright test --project=staging", @@ -61,8 +62,10 @@ "@tanstack/react-query": "^5.83.0", "@tiptap/core": "3.31.3", "@tiptap/extension-collaboration": "3.31.3", + "@tiptap/extension-collaboration-caret": "3.31.3", "@tiptap/extension-table": "3.31.3", "@tiptap/pm": "3.31.3", + "@tiptap/react": "3.31.3", "@tiptap/starter-kit": "3.31.3", "@tiptap/y-tiptap": "3.0.9", "@vercel/analytics": "^2.0.1", @@ -78,10 +81,12 @@ "i18next": "^23.16.8", "jspdf": "^4.1.0", "jspdf-autotable": "^5.0.7", + "lib0": "0.2.117", "lucide-react": "^0.462.0", "mammoth": "^1.12.0", "next-themes": "^0.3.0", "pdfjs-dist": "^6.3.289", + "pg": "8.23.0", "posthog-js": "^1.399.2", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -109,6 +114,7 @@ "@testing-library/react": "^16.0.0", "@types/file-saver": "^2.0.7", "@types/node": "^22.16.5", + "@types/pg": "8.23.1", "@types/react": "^18.3.23", "@types/react-dom": "^18.3.7", "@vitejs/plugin-react-swc": "^3.11.0", diff --git a/playwright.workspace.config.ts b/playwright.workspace.config.ts new file mode 100644 index 000000000..3f1c69452 --- /dev/null +++ b/playwright.workspace.config.ts @@ -0,0 +1,24 @@ +import { defineConfig, devices } from "@playwright/test"; + +const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:18081"; + +export default defineConfig({ + testDir: "./e2e", + testMatch: /workspace-collaboration\.spec\.ts/, + fullyParallel: false, + workers: 1, + reporter: "list", + use: { + actionTimeout: 10_000, + baseURL, + locale: "en-US", + screenshot: "only-on-failure", + trace: "retain-on-failure", + video: "retain-on-failure", + }, + expect: { timeout: 10_000 }, + projects: [ + { name: "desktop-chromium", use: { ...devices["Desktop Chrome"], viewport: { width: 1280, height: 800 } } }, + { name: "mobile-chromium", use: { ...devices["Pixel 5"] } }, + ], +}); diff --git a/scripts/test-workspace-mutation-concurrency.sh b/scripts/test-workspace-mutation-concurrency.sh new file mode 100755 index 000000000..04dc7b779 --- /dev/null +++ b/scripts/test-workspace-mutation-concurrency.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +set -euo pipefail + +database_url="${SUPABASE_DB_URL:-postgresql://postgres:postgres@127.0.0.1:54322/postgres}" +database_password="${SUPABASE_DB_PASSWORD:-postgres}" +owner_id="84444444-4444-4444-8444-444444444444" +member_id="85555555-5555-4555-8555-555555555555" +request_id="86666666-6666-4666-8666-666666666666" +result_dir="$(mktemp -d)" +first_pid="" +second_pid="" + +cleanup() { + touch "$result_dir/release-removal" + if [ -n "$first_pid" ]; then wait "$first_pid" 2>/dev/null || true; fi + if [ -n "$second_pid" ]; then wait "$second_pid" 2>/dev/null || true; fi + PGPASSWORD="$database_password" psql "$database_url" -qX --set ON_ERROR_STOP=1 </dev/null +DELETE FROM public.workspaces WHERE create_request_id = '$request_id'; +DELETE FROM auth.users WHERE id IN ('$owner_id', '$member_id'); +SQL + rm -rf "$result_dir" +} +trap cleanup EXIT + +PGPASSWORD="$database_password" psql "$database_url" -qX --set ON_ERROR_STOP=1 </dev/null +DELETE FROM public.workspaces WHERE create_request_id = '$request_id'; +DELETE FROM auth.users WHERE id IN ('$owner_id', '$member_id'); +INSERT INTO auth.users (id) VALUES ('$owner_id'), ('$member_id'); +BEGIN; +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', '$owner_id', true); +SELECT set_config('request.jwt.claim.role', 'authenticated', true); +SELECT public.create_workspace('Concurrent mutation', NULL, 'en', '$request_id'); +COMMIT; +INSERT INTO public.workspace_members (workspace_id, user_id) +SELECT id, '$member_id' FROM public.workspaces WHERE create_request_id = '$request_id'; +SQL + +workspace_id="$(PGPASSWORD="$database_password" psql "$database_url" -qAtX --set ON_ERROR_STOP=1 --command \ + "SELECT id FROM public.workspaces WHERE create_request_id = '$request_id'")" +document_id="$(PGPASSWORD="$database_password" psql "$database_url" -qAtX --set ON_ERROR_STOP=1 --command \ + "SELECT id FROM public.workspace_documents WHERE workspace_id = '$workspace_id' ORDER BY side, position LIMIT 1")" + +remove_member() { + PGPASSWORD="$database_password" psql "$database_url" -qAtX --set ON_ERROR_STOP=1 <"$result_dir/removal" 2>&1 & +first_pid=$! +for _ in {1..100}; do + if [ -e "$result_dir/removal-holds-lock" ]; then break; fi + if ! kill -0 "$first_pid" 2>/dev/null; then wait "$first_pid"; fi + sleep 0.05 +done +if [ ! -e "$result_dir/removal-holds-lock" ]; then + echo "member removal did not acquire the workspace lock" + exit 1 +fi + +write_after_removal >"$result_dir/write" 2>&1 & +second_pid=$! +second_is_waiting=false +for _ in {1..100}; do + if [ "$(PGPASSWORD="$database_password" psql "$database_url" -qAtX --set ON_ERROR_STOP=1 --command \ + "SELECT EXISTS (SELECT 1 FROM pg_stat_activity WHERE pid <> pg_backend_pid() AND wait_event_type = 'Lock' AND query LIKE '%workspace_mutation_concurrency_second%')")" = "t" ]; then + second_is_waiting=true + break + fi + if ! kill -0 "$second_pid" 2>/dev/null; then break; fi + sleep 0.05 +done +if [ "$second_is_waiting" != true ]; then + echo "workspace mutation did not wait for removal" + exit 1 +fi + +touch "$result_dir/release-removal" +wait "$first_pid" +if wait "$second_pid"; then + echo "removed member mutation unexpectedly committed" + exit 1 +fi +if ! grep -q "workspace unavailable" "$result_dir/write"; then + cat "$result_dir/write" + exit 1 +fi + +stored_comments="$(PGPASSWORD="$database_password" psql "$database_url" -qAtX --set ON_ERROR_STOP=1 --command \ + "SELECT count(*) FROM public.workspace_comments WHERE workspace_id = '$workspace_id'")" +if [ "$stored_comments" != "0" ]; then + echo "expected no post-removal comment, got: $stored_comments" + exit 1 +fi + +echo "Concurrent removal: later workspace mutation waited and was rejected" diff --git a/scripts/workspace-test-env.mjs b/scripts/workspace-test-env.mjs new file mode 100644 index 000000000..48b67bb6f --- /dev/null +++ b/scripts/workspace-test-env.mjs @@ -0,0 +1,8 @@ +import { readFile, writeFile } from "node:fs/promises"; + +const [source, target] = process.argv.slice(2); +const serviceRoleKey = process.env.LOCAL_SERVICE_ROLE_KEY; +const mailerHost = process.env.LOCAL_MAILER_HOST; +if (!source || !target || !serviceRoleKey || !mailerHost) throw new Error("Local Edge environment is incomplete"); +const fixture = (await readFile(source, "utf8")).replace("workspace-mailer.invalid", mailerHost); +await writeFile(target, `${fixture.trim()}\nWORKSPACE_INVITE_SERVICE_ROLE_KEY=${serviceRoleKey}\n`, { mode: 0o600 }); diff --git a/services/collaboration/README.md b/services/collaboration/README.md index 3a4d42e20..802aacd78 100644 --- a/services/collaboration/README.md +++ b/services/collaboration/README.md @@ -1,40 +1,32 @@ -# Collaboration service spike +# Collaboration service -**Status:** Phase 0 evidence only. No production route or deployment uses this service. +The Node 22 service is the durable workspace editor boundary. Hocuspocus carries Yjs updates; Supabase Auth and PostgreSQL remain authoritative for identity, current membership, metadata, snapshots, updates, versions, and review decisions. -## Selected stack +For every loaded document epoch, one dedicated PostgreSQL session holds an advisory lock. Incoming reconnect and live updates are schema-checked and persisted before broadcast or the custom `durable-ack`. Access is rechecked for reads, writes, and awareness. Database lock loss, member removal, and version restore close writable sessions. The 2 MiB update/document ceiling is intentional for the trial. -- Tiptap 3.31.3 -- Hocuspocus 4.7.0 -- Yjs 13.6.32 -- y-protocols 1.0.7 -- async-mutex 0.5.0 +The same loopback server exposes two authenticated HTTP operations: -These exact versions are MIT, support Node 22, and work with DebateLab's React 18 client. Tiptap 3 uses `@tiptap/y-tiptap`; the spike does not install redundant `y-prosemirror`, React provider bindings, or IndexedDB support. +- `/access/remove` waits for queued writes, changes membership, fences sockets, then acknowledges; +- `/review/decide` commits proposal status, accepted Yjs content, and the pre-decision version in one database transaction. -## Proven write order +Required configuration names: -Both reconnect `SyncStep2` and live `Update` messages use the same public Hocuspocus hook path: +- `COLLABORATION_DATABASE_URL` +- `COLLABORATION_PORT` +- `COLLABORATION_ALLOWED_ORIGINS` +- `SUPABASE_URL` +- `SUPABASE_ANON_KEY` -1. authenticate the room's document and epoch; -2. acquire the per-document lock; -3. apply the candidate to a bounded scratch Y.Doc and validate the editor schema; -4. append the update with authenticated author, epoch, SHA-256 mutation ID, and server sequence; -5. let Hocuspocus apply and broadcast synchronously; -6. verify the live document contains the update; -7. send the stateless durable acknowledgement and release the lock. +Run focused checks with: -The UI must use the custom acknowledgement for Saved. Hocuspocus `synced` and its boolean sync status do not prove database persistence. A reconnecting client queries pending mutation IDs and receives the original durable sequence. - -The tests reject append failures, invalid schema, stale epochs, and frames above 2 MiB before broadcast or acknowledgement. They also kill the service after commit before acknowledgement and after acknowledgement, then reload the stored update in a new process. - -## Remaining production boundary - -The in-memory mutex proves single-process serialization. P2.1 must replace it with the planned dedicated-session PostgreSQL advisory lock and terminate writable sessions when that database connection is lost. P2.2 owns account-scoped IndexedDB recovery and presence. +```sh +npm run test:collaboration +``` -Run: +Run the complete local database/Auth/browser lifecycle under the repository lock with: ```sh -npm run test:collaboration -npx vitest run src/test/workspace/collaboration.test.ts +./.superset/db-gate.sh ``` + +This service has no production deployment in Milestone 1. diff --git a/services/collaboration/src/auth.test.ts b/services/collaboration/src/auth.test.ts new file mode 100644 index 000000000..fc294e26d --- /dev/null +++ b/services/collaboration/src/auth.test.ts @@ -0,0 +1,46 @@ +// @vitest-environment node + +import { describe, expect, it, vi } from "vitest"; +import { createWorkspaceAuthenticator } from "./auth"; + +describe("collaboration authentication", () => { + it("binds the verified user to a current document membership", async () => { + const verifyToken = vi.fn().mockResolvedValue({ + email: "member@example.com", + id: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + name: "Ada Member", + }); + const isAuthorized = vi.fn().mockResolvedValue(true); + const authenticate = createWorkspaceAuthenticator({ isAuthorized, verifyToken }); + + await expect(authenticate({ + documentName: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa@3", + token: "signed-token", + })).resolves.toEqual({ + authorId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + displayName: "Ada Member", + documentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + epoch: 3, + }); + expect(isAuthorized).toHaveBeenCalledWith({ + authorId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + documentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + epoch: 3, + }); + }); + + it("rejects a valid but stale token after membership removal", async () => { + const authenticate = createWorkspaceAuthenticator({ + verifyToken: vi.fn().mockResolvedValue({ + email: "removed@example.com", + id: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + }), + isAuthorized: vi.fn().mockResolvedValue(false), + }); + + await expect(authenticate({ + documentName: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa@3", + token: "still-valid-token", + })).rejects.toThrow("Workspace unavailable"); + }); +}); diff --git a/services/collaboration/src/auth.ts b/services/collaboration/src/auth.ts new file mode 100644 index 000000000..a27082680 --- /dev/null +++ b/services/collaboration/src/auth.ts @@ -0,0 +1,44 @@ +import { z } from "zod"; + +const roomSchema = z.string().regex( + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}@(0|[1-9][0-9]*)$/, +); +const userSchema = z.object({ + email: z.string().email().nullable().optional(), + id: z.string().uuid(), + name: z.string().trim().min(1).max(120).optional(), +}); + +type AccessContext = { + authorId: string; + documentId: string; + epoch: number; +}; + +export function createWorkspaceAuthenticator({ + isAuthorized, + verifyToken, +}: { + isAuthorized: (context: AccessContext) => Promise; + verifyToken: (token: string) => Promise; +}) { + return async ({ documentName, token }: { documentName: string; token: string }) => { + const room = roomSchema.safeParse(documentName); + if (!room.success || !token) throw new Error("Workspace unavailable"); + const separator = room.data.lastIndexOf("@"); + const epoch = Number(room.data.slice(separator + 1)); + if (!Number.isSafeInteger(epoch)) throw new Error("Workspace unavailable"); + const user = userSchema.parse(await verifyToken(token)); + const context = { + authorId: user.id, + documentId: room.data.slice(0, separator), + epoch, + }; + if (!await isAuthorized(context)) throw new Error("Workspace unavailable"); + + return { + ...context, + displayName: user.name ?? user.email?.split("@")[0] ?? `Member ${user.id.slice(0, 4)}`, + }; + }; +} diff --git a/services/collaboration/src/index.ts b/services/collaboration/src/index.ts new file mode 100644 index 000000000..2dfa67312 --- /dev/null +++ b/services/collaboration/src/index.ts @@ -0,0 +1,84 @@ +import { createClient } from "@supabase/supabase-js"; +import { Pool } from "pg"; +import { z } from "zod"; +import { createWorkspaceAuthenticator } from "./auth"; +import { PostgresDurableUpdateStore } from "./postgres-store"; +import { parseRuntimeConfig } from "./runtime-config"; +import { createCollaborationServer } from "./server"; + +const config = parseRuntimeConfig(process.env); +const pool = new Pool({ connectionString: config.databaseUrl, max: 20 }); +const store = new PostgresDurableUpdateStore(pool); +const supabase = createClient(config.supabaseUrl, config.supabaseAnonKey, { + auth: { autoRefreshToken: false, detectSessionInUrl: false, persistSession: false }, +}); +const verifyToken = async (token: string) => { + const { data, error } = await supabase.auth.getUser(token); + if (error || !data.user) throw new Error("Workspace unavailable"); + const metadata = data.user.user_metadata; + const name = typeof metadata.full_name === "string" + ? metadata.full_name + : typeof metadata.name === "string" + ? metadata.name + : undefined; + return { email: data.user.email, id: data.user.id, name }; +}; +const authenticate = createWorkspaceAuthenticator({ + isAuthorized: (context) => store.isAuthorized(context), + verifyToken, +}); +const server = createCollaborationServer({ + allowedOrigins: new Set(config.allowedOrigins), + authenticate, + authorize: (context, operation) => store.isAuthorized(context, operation === "write"), + changeAccess: async ({ expectedRevision, requestId, targetUserId, token, workspaceId }) => { + await verifyToken(token); + const authenticated = createClient(config.supabaseUrl, config.supabaseAnonKey, { + auth: { autoRefreshToken: false, detectSessionInUrl: false, persistSession: false }, + global: { headers: { Authorization: `Bearer ${token}` } }, + }); + const { data, error } = await authenticated.rpc("change_workspace_access", { + p_action: "remove", + p_expected_revision: expectedRevision, + p_request_id: requestId, + p_target_user_id: targetUserId, + p_workspace_id: workspaceId, + }); + if (error) throw error; + return z.coerce.number().int().nonnegative().parse(data); + }, + decideProposal: async ({ decision, documentId, epoch, hunkId, proposalId, token }) => { + const user = await verifyToken(token); + return store.decideProposal({ + authorId: user.id, + decision, + documentId, + epoch, + hunkId, + proposalId, + }); + }, + store, +}); + +const accessListener = await pool.connect(); +await accessListener.query("LISTEN workspace_access_revoked; LISTEN workspace_document_restored"); +accessListener.on("notification", (message) => { + if (message.channel !== "workspace_access_revoked" && message.channel !== "workspace_document_restored") return; + // ponytail: global fence is safe for one trial process; target by workspace if disconnect churn matters. + server.hocuspocus.closeConnections(); +}); + +await server.listen(config.port); +console.info(`Collaboration service listening on 127.0.0.1:${config.port}`); + +let stopping = false; +const stop = async () => { + if (stopping) return; + stopping = true; + accessListener.release(); + await server.destroy(); + await pool.end(); +}; +process.once("SIGINT", () => void stop()); +process.once("SIGTERM", () => void stop()); diff --git a/services/collaboration/src/postgres-store.test.ts b/services/collaboration/src/postgres-store.test.ts new file mode 100644 index 000000000..8a40673b0 --- /dev/null +++ b/services/collaboration/src/postgres-store.test.ts @@ -0,0 +1,299 @@ +// @vitest-environment node + +import { EventEmitter } from "node:events"; +import { describe, expect, it, vi } from "vitest"; +import * as Y from "yjs"; +import { prosemirrorJSONToYDoc, yXmlFragmentToProsemirrorJSON } from "@tiptap/y-tiptap"; +import { PostgresDurableUpdateStore } from "./postgres-store"; +import { serializeProposal, type WorkspaceProposal } from "../../../src/lib/workspace/review"; +import { WORKSPACE_CONTENT_FIELD, workspaceSchema } from "../../../src/lib/workspace/schema"; + +class FakeClient extends EventEmitter { + readonly query = vi.fn().mockResolvedValue({ rows: [] }); + readonly release = vi.fn(); +} + +describe("Postgres collaboration writer locks", () => { + function proposalFixture() { + const document = prosemirrorJSONToYDoc(workspaceSchema, { + type: "doc", + content: [{ type: "paragraph", content: [{ type: "text", text: "Hello world" }] }], + }, WORKSPACE_CONTENT_FIELD); + const proposal: WorkspaceProposal = { + authorId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + documentEpoch: 3, + id: "dddddddd-dddd-4ddd-8ddd-dddddddddddd", + baseStateVector: new Uint8Array(), + hunks: [{ + absoluteFrom: 7, + absoluteTo: 12, + contextAfter: [{ type: "paragraph" }], + contextBefore: [{ type: "text", text: " " }], + from: new Uint8Array(), + id: "replace", + kind: "format", + original: [{ type: "text", text: "world" }], + replacement: [{ type: "text", text: "team" }], + status: "pending", + to: new Uint8Array(), + }], + }; + const snapshot = Y.encodeStateAsUpdate(document); + document.destroy(); + return { proposal, snapshot }; + } + it("holds one session advisory lock and fences writers when that session dies", async () => { + const client = new FakeClient(); + const pool = { connect: vi.fn().mockResolvedValue(client) }; + const store = new PostgresDurableUpdateStore(pool); + const onLost = vi.fn(); + + const release = await store.acquireWriter( + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + 3, + onLost, + ); + + expect(client.query).toHaveBeenCalledWith( + "SELECT pg_advisory_lock(hashtextextended($1, 0))", + ["workspace-document:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa@3"], + ); + + client.emit("error", new Error("lock connection lost")); + expect(onLost).toHaveBeenCalledTimes(1); + expect(client.release).toHaveBeenCalledTimes(1); + + await release(); + expect(client.release).toHaveBeenCalledTimes(1); + }); + + it("destroys the dedicated session when advisory lock acquisition fails", async () => { + const client = new FakeClient(); + client.query.mockRejectedValueOnce(new Error("database unavailable")); + const store = new PostgresDurableUpdateStore({ connect: vi.fn().mockResolvedValue(client) }); + + await expect(store.acquireWriter( + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + 3, + vi.fn(), + )).rejects.toThrow("database unavailable"); + expect(client.release).toHaveBeenCalledWith(true); + }); + + it("appends and snapshots only while author membership and epoch are current", async () => { + const client = new FakeClient(); + const pool = { connect: vi.fn().mockResolvedValue(client) }; + const store = new PostgresDurableUpdateStore(pool); + const release = await store.acquireWriter( + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + 3, + vi.fn(), + ); + client.query.mockReset(); + client.query.mockImplementation(async (text: string) => { + if (text.includes("FROM private.workspace_document_state")) { + return { rows: [{ durable_sequence: 7, workspace_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" }] }; + } + if (text.includes("FROM private.workspace_document_updates")) return { rows: [] }; + return { rows: [] }; + }); + + const durable = await store.append({ + authorId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + bytes: new Uint8Array([1, 2]), + documentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + epoch: 3, + mutationId: "a".repeat(43), + snapshot: new Uint8Array([3, 4]), + syncType: 2, + }); + + expect(durable.sequence).toBe(8); + const appendQueries = client.query.mock.calls.map(([text]) => String(text)); + expect(appendQueries.findIndex((text) => text.includes("FOR UPDATE OF workspace"))) + .toBeLessThan(appendQueries.findIndex((text) => text.includes("SELECT state.workspace_id, state.durable_sequence"))); + expect(client.query.mock.calls.some(([text]) => String(text).startsWith("INSERT INTO private.workspace_document_updates"))).toBe(true); + expect(client.query.mock.calls.some(([text]) => String(text).startsWith("UPDATE private.workspace_document_state"))).toBe(true); + expect(client.query).toHaveBeenCalledWith("COMMIT"); + await release(); + }); + + it("rejects an append when the database membership check fails", async () => { + const client = new FakeClient(); + const pool = { connect: vi.fn().mockResolvedValue(client) }; + const store = new PostgresDurableUpdateStore(pool); + const release = await store.acquireWriter( + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + 3, + vi.fn(), + ); + client.query.mockReset(); + client.query.mockResolvedValue({ rows: [] }); + + await expect(store.append({ + authorId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + bytes: new Uint8Array([1, 2]), + documentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + epoch: 3, + mutationId: "b".repeat(43), + snapshot: new Uint8Array([3, 4]), + syncType: 2, + })).rejects.toThrow("Workspace access revoked"); + expect(client.query).toHaveBeenCalledWith("ROLLBACK"); + expect(client.query.mock.calls.some(([text]) => String(text).startsWith("INSERT"))).toBe(false); + await release(); + }); + + it("loads the current snapshot and durable acknowledgement from the writer session", async () => { + const client = new FakeClient(); + const pool = { connect: vi.fn().mockResolvedValue(client) }; + const store = new PostgresDurableUpdateStore(pool); + const release = await store.acquireWriter( + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + 3, + vi.fn(), + ); + client.query.mockReset(); + client.query.mockImplementation(async (text: string) => { + if (text.includes("SELECT snapshot")) { + return { rows: [{ snapshot: new Uint8Array([9, 8]) }] }; + } + return { + rows: [{ + author_id: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + bytes: new Uint8Array([1, 2]), + sequence: "7", + sync_type: 2, + }], + }; + }); + + await expect(store.loadSnapshot( + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + 3, + )).resolves.toEqual(new Uint8Array([9, 8])); + await expect(store.find( + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + 3, + "a".repeat(43), + )).resolves.toMatchObject({ + authorId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + mutationId: "a".repeat(43), + sequence: 7, + }); + await release(); + }); + + it("stores one automatic version for the durable sequence after the idle window", async () => { + const client = new FakeClient(); + client.query.mockImplementation(async (text: string) => ({ + rows: text.includes("SELECT workspace.id AS workspace_id") + ? [{ workspace_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" }] + : [], + })); + const store = new PostgresDurableUpdateStore({ connect: vi.fn().mockResolvedValue(client) }); + const release = await store.acquireWriter( + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + 3, + vi.fn(), + ); + client.query.mockClear(); + + await store.snapshotVersion("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", 3); + + expect(client.query).toHaveBeenCalledWith( + expect.stringContaining("INSERT INTO public.workspace_versions"), + ["aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", 3], + ); + await release(); + }); + + it("checks current membership and epoch on a fresh database session", async () => { + const client = new FakeClient(); + client.query.mockResolvedValueOnce({ rows: [{ allowed: true }] }); + const pool = { connect: vi.fn().mockResolvedValue(client) }; + const store = new PostgresDurableUpdateStore(pool); + + await expect(store.isAuthorized({ + authorId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + documentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + epoch: 3, + })).resolves.toBe(true); + expect(client.release).toHaveBeenCalledTimes(1); + }); + + it("persists accepted content, its pre-decision version, and the decision in one transaction", async () => { + const { proposal, snapshot } = proposalFixture(); + const client = new FakeClient(); + client.query.mockImplementation(async (text: string) => { + if (text.includes("SELECT workspace.id AS workspace_id")) { + return { rows: [{ workspace_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" }] }; + } + if (text.includes("SELECT proposal.payload")) { + return { rows: [{ + durable_sequence: 4, + payload: serializeProposal(proposal), + proposal_sequence: 4, + snapshot, + workspace_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + }] }; + } + return { rows: [] }; + }); + const store = new PostgresDurableUpdateStore({ connect: vi.fn().mockResolvedValue(client) }); + + const result = await store.decideProposal({ + authorId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + decision: "accepted", + documentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + epoch: 3, + hunkId: "replace", + proposalId: proposal.id, + }); + + expect(result.proposal.hunks[0].status).toBe("accepted"); + expect(result.sequence).toBe(5); + const decisionQueries = client.query.mock.calls.map(([text]) => String(text)); + expect(decisionQueries.findIndex((text) => text.includes("FOR UPDATE OF workspace"))) + .toBeLessThan(decisionQueries.findIndex((text) => text.includes("FOR UPDATE OF proposal, state"))); + const accepted = new Y.Doc(); + Y.applyUpdate(accepted, snapshot); + Y.applyUpdate(accepted, result.update!); + expect(JSON.stringify(yXmlFragmentToProsemirrorJSON(accepted.getXmlFragment(WORKSPACE_CONTENT_FIELD)))).toContain("Hello team"); + accepted.destroy(); + expect(client.query.mock.calls.some(([text]) => String(text).includes("INSERT INTO public.workspace_versions"))).toBe(true); + expect(client.query.mock.calls.some(([text]) => String(text).includes("INSERT INTO private.workspace_document_updates"))).toBe(true); + expect(client.query.mock.calls.some(([text]) => String(text).includes("UPDATE public.workspace_proposals"))).toBe(true); + expect(client.query).toHaveBeenCalledWith( + expect.stringContaining("durable_sequence = $3"), + [proposal.id, expect.any(String), 5], + ); + expect(client.query).toHaveBeenCalledWith("COMMIT"); + }); + + it("rolls back accepted content when persisting the decision fails", async () => { + const { proposal, snapshot } = proposalFixture(); + const client = new FakeClient(); + client.query.mockImplementation(async (text: string) => { + if (text.includes("SELECT workspace.id AS workspace_id")) { + return { rows: [{ workspace_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" }] }; + } + if (text.includes("SELECT proposal.payload")) { + return { rows: [{ durable_sequence: 4, payload: serializeProposal(proposal), proposal_sequence: 4, snapshot, workspace_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" }] }; + } + if (text.includes("UPDATE public.workspace_proposals")) throw new Error("decision write failed"); + return { rows: [] }; + }); + const store = new PostgresDurableUpdateStore({ connect: vi.fn().mockResolvedValue(client) }); + + await expect(store.decideProposal({ + authorId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + decision: "accepted", + documentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + epoch: 3, + hunkId: "replace", + proposalId: proposal.id, + })).rejects.toThrow("decision write failed"); + expect(client.query).toHaveBeenCalledWith("ROLLBACK"); + }); +}); diff --git a/services/collaboration/src/postgres-store.ts b/services/collaboration/src/postgres-store.ts new file mode 100644 index 000000000..2ba4d6dfc --- /dev/null +++ b/services/collaboration/src/postgres-store.ts @@ -0,0 +1,479 @@ +import { createHash } from "node:crypto"; +import { z } from "zod"; +import * as Y from "yjs"; +import { + acceptProposalHunkInDocument, + deserializeProposal, + rejectProposalHunks, + serializeProposal, +} from "../../../src/lib/workspace/review"; +import { validateWorkspaceDocument } from "../../../src/lib/workspace/schema"; +import type { DurableAppend, DurableUpdate, DurableUpdateStore } from "./server"; + +export interface PgClient { + query(text: string, values?: unknown[]): Promise<{ rows: unknown[] }>; + release(destroy?: boolean): void; + once(event: "error", listener: (error: Error) => void): this; + removeListener(event: "error", listener: (error: Error) => void): this; +} + +export interface PgPool { + connect(): Promise; +} + +type Writer = { + client: PgClient; + lost: boolean; + refs: number; + onLost: Set<() => void>; +}; + +const bigintSchema = z.union([z.number(), z.string()]).transform(Number); +const stateRowSchema = z.object({ + durable_sequence: bigintSchema, + workspace_id: z.string().uuid(), +}); +const workspaceRowSchema = z.object({ workspace_id: z.string().uuid() }); +const durableRowSchema = z.object({ + author_id: z.string().uuid().nullable(), + bytes: z.instanceof(Uint8Array), + created_at: z.unknown().optional(), + sequence: bigintSchema, + sync_type: z.number(), +}); +const storedUpdateRowSchema = durableRowSchema.extend({ mutation_id: z.string() }); +const snapshotRowSchema = z.object({ snapshot: z.instanceof(Uint8Array) }); +const authorizationRowSchema = z.object({ allowed: z.boolean() }); +const proposalRowSchema = z.object({ + durable_sequence: bigintSchema, + payload: z.unknown(), + proposal_sequence: bigintSchema, + snapshot: z.instanceof(Uint8Array), + workspace_id: z.string().uuid(), +}); + +export class PostgresDurableUpdateStore implements DurableUpdateStore { + private readonly writers = new Map(); + + constructor(private readonly pool: PgPool) {} + + async isAuthorized( + context: { authorId: string; documentId: string; epoch: number }, + write = false, + ) { + const client = await this.pool.connect(); + try { + const result = await client.query( + `SELECT EXISTS ( + SELECT 1 + FROM private.workspace_document_state AS state + JOIN public.workspaces AS workspace ON workspace.id = state.workspace_id + JOIN public.workspace_members AS member ON member.workspace_id = state.workspace_id + WHERE state.document_id = $1 + AND state.epoch = $2 + AND member.user_id = $3 + AND workspace.status <> 'deleted' + AND (NOT $4 OR workspace.status = 'active') + ) AS allowed`, + [context.documentId, context.epoch, context.authorId, write], + ); + return authorizationRowSchema.parse(result.rows[0]).allowed; + } finally { + client.release(); + } + } + + async loadSnapshot(documentId: string, epoch: number) { + const result = await this.getWriter(documentId, epoch).query( + `SELECT snapshot + FROM private.workspace_document_state + WHERE document_id = $1 AND epoch = $2`, + [documentId, epoch], + ); + const input = result.rows[0]; + return input ? new Uint8Array(snapshotRowSchema.parse(input).snapshot) : undefined; + } + + async load(documentId: string, epoch: number): Promise { + const result = await this.getWriter(documentId, epoch).query( + `SELECT author_id, bytes, mutation_id, sequence, sync_type + FROM private.workspace_document_updates + WHERE document_id = $1 AND epoch = $2 + ORDER BY sequence`, + [documentId, epoch], + ); + return storedUpdateRowSchema.array().parse(result.rows).map((row) => ({ + authorId: row.author_id ?? "", + bytes: new Uint8Array(row.bytes), + documentId, + epoch, + mutationId: row.mutation_id, + sequence: row.sequence, + syncType: row.sync_type, + })); + } + + async find(documentId: string, epoch: number, mutationId: string) { + const result = await this.getWriter(documentId, epoch).query( + `SELECT author_id, bytes, sequence, sync_type + FROM private.workspace_document_updates + WHERE document_id = $1 AND epoch = $2 AND mutation_id = $3`, + [documentId, epoch, mutationId], + ); + const input = result.rows[0]; + if (!input) return undefined; + const row = durableRowSchema.parse(input); + return { + authorId: row.author_id ?? "", + bytes: new Uint8Array(row.bytes), + documentId, + epoch, + mutationId, + sequence: row.sequence, + syncType: row.sync_type, + }; + } + + async append(update: DurableAppend): Promise { + const client = this.getWriter(update.documentId, update.epoch); + + await client.query("BEGIN"); + try { + const workspaceResult = await client.query( + `SELECT workspace.id AS workspace_id + FROM private.workspace_document_state AS state + JOIN public.workspaces AS workspace ON workspace.id = state.workspace_id + WHERE state.document_id = $1 + AND state.epoch = $2 + AND workspace.status = 'active' + FOR UPDATE OF workspace`, + [update.documentId, update.epoch], + ); + const workspaceInput = workspaceResult.rows[0]; + if (!workspaceInput) throw new Error("Workspace access revoked"); + const workspace = workspaceRowSchema.parse(workspaceInput); + const stateResult = await client.query( + `SELECT state.workspace_id, state.durable_sequence + FROM private.workspace_document_state AS state + JOIN public.workspace_members AS member + ON member.workspace_id = state.workspace_id AND member.user_id = $4 + WHERE state.document_id = $1 AND state.epoch = $2 AND state.workspace_id = $3 + FOR UPDATE OF state`, + [update.documentId, update.epoch, workspace.workspace_id, update.authorId], + ); + const stateInput = stateResult.rows[0]; + if (!stateInput) throw new Error("Workspace access revoked"); + const state = stateRowSchema.parse(stateInput); + + const existingResult = await client.query( + `SELECT author_id, bytes, sequence, sync_type + FROM private.workspace_document_updates + WHERE document_id = $1 AND epoch = $2 AND mutation_id = $3`, + [update.documentId, update.epoch, update.mutationId], + ); + const existingInput = existingResult.rows[0]; + if (existingInput) { + const existing = durableRowSchema.parse(existingInput); + await client.query("COMMIT"); + return { + authorId: existing.author_id ?? "", + bytes: new Uint8Array(existing.bytes), + documentId: update.documentId, + epoch: update.epoch, + mutationId: update.mutationId, + sequence: existing.sequence, + syncType: existing.sync_type, + }; + } + + const sequence = state.durable_sequence + 1; + await client.query( + `INSERT INTO private.workspace_document_updates ( + workspace_id, document_id, epoch, sequence, mutation_id, author_id, sync_type, bytes + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [ + state.workspace_id, + update.documentId, + update.epoch, + sequence, + update.mutationId, + update.authorId, + update.syncType, + Buffer.from(update.bytes), + ], + ); + await client.query( + `UPDATE private.workspace_document_state + SET durable_sequence = $2, snapshot = $3, updated_at = now() + WHERE document_id = $1`, + [update.documentId, sequence, Buffer.from(update.snapshot)], + ); + await client.query( + `UPDATE public.workspace_documents + SET durable_sequence = $2, updated_at = now() + WHERE id = $1`, + [update.documentId, sequence], + ); + await client.query("COMMIT"); + return { ...update, sequence }; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } + } + + async snapshotVersion(documentId: string, epoch: number) { + const client = this.getWriter(documentId, epoch); + await client.query("BEGIN"); + try { + const workspaceResult = await client.query( + `SELECT workspace.id AS workspace_id + FROM private.workspace_document_state AS state + JOIN public.workspaces AS workspace ON workspace.id = state.workspace_id + WHERE state.document_id = $1 AND state.epoch = $2 + AND workspace.status = 'active' + FOR UPDATE OF workspace`, + [documentId, epoch], + ); + if (!workspaceResult.rows[0]) { + await client.query("COMMIT"); + return; + } + await client.query( + `WITH version AS ( + INSERT INTO public.workspace_versions ( + workspace_id, document_id, kind, document_epoch, durable_sequence + ) + SELECT state.workspace_id, state.document_id, 'automatic', state.epoch, + state.durable_sequence + FROM private.workspace_document_state AS state + WHERE state.document_id = $1 + AND state.epoch = $2 + AND state.durable_sequence > 0 + AND NOT EXISTS ( + SELECT 1 FROM public.workspace_versions AS existing + WHERE existing.document_id = state.document_id + AND existing.document_epoch = state.epoch + AND existing.durable_sequence = state.durable_sequence + ) + RETURNING id, document_id + ) + INSERT INTO private.workspace_version_data (version_id, snapshot) + SELECT version.id, state.snapshot + FROM version + JOIN private.workspace_document_state AS state + ON state.document_id = version.document_id`, + [documentId, epoch], + ); + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } + } + + async decideProposal(input: { + authorId: string; + decision: "accepted" | "rejected"; + documentId: string; + epoch: number; + hunkId: string; + proposalId: string; + }) { + const key = `${input.documentId}@${input.epoch}`; + const temporaryRelease = this.writers.has(key) + ? undefined + : await this.acquireWriter(input.documentId, input.epoch, () => undefined); + const client = this.getWriter(input.documentId, input.epoch); + await client.query("BEGIN"); + try { + const workspaceResult = await client.query( + `SELECT workspace.id AS workspace_id + FROM public.workspace_proposals AS proposal + JOIN public.workspaces AS workspace ON workspace.id = proposal.workspace_id + WHERE proposal.id = $1 AND proposal.document_id = $2 + AND proposal.document_epoch = $3 AND workspace.status = 'active' + FOR UPDATE OF workspace`, + [input.proposalId, input.documentId, input.epoch], + ); + const workspaceInput = workspaceResult.rows[0]; + if (!workspaceInput) throw new Error("Workspace unavailable"); + const workspace = workspaceRowSchema.parse(workspaceInput); + const result = await client.query( + `SELECT proposal.payload, proposal.durable_sequence AS proposal_sequence, + state.durable_sequence, state.snapshot, state.workspace_id + FROM public.workspace_proposals AS proposal + JOIN private.workspace_document_state AS state + ON state.workspace_id = proposal.workspace_id + AND state.document_id = proposal.document_id + AND state.epoch = proposal.document_epoch + JOIN public.workspace_members AS member + ON member.workspace_id = proposal.workspace_id AND member.user_id = $5 + WHERE proposal.id = $1 AND proposal.document_id = $2 + AND proposal.document_epoch = $3 AND proposal.workspace_id = $4 + FOR UPDATE OF proposal, state`, + [input.proposalId, input.documentId, input.epoch, workspace.workspace_id, input.authorId], + ); + if (!result.rows[0]) throw new Error("Workspace unavailable"); + const row = proposalRowSchema.parse(result.rows[0]); + let proposal = deserializeProposal(row.payload); + const hunk = proposal.hunks.find(({ id }) => id === input.hunkId); + if (!hunk) throw new Error("Proposal unavailable"); + if (hunk.status !== "pending") { + if (hunk.status !== input.decision) throw new Error("Proposal already decided"); + await client.query("COMMIT"); + return { proposal: serializeProposal(proposal), sequence: row.durable_sequence }; + } + + let update: Uint8Array | undefined; + if (input.decision === "rejected") { + proposal = rejectProposalHunks({ proposal, hunkIds: [input.hunkId] }); + } else { + const document = new Y.Doc(); + try { + if (row.snapshot.byteLength) Y.applyUpdate(document, row.snapshot); + const before = Y.encodeStateVector(document); + proposal = row.proposal_sequence === row.durable_sequence + ? acceptProposalHunkInDocument({ + document, + documentEpoch: input.epoch, + hunkId: input.hunkId, + proposal, + }) + : { + ...proposal, + hunks: proposal.hunks.map((candidate) => + candidate.id === input.hunkId ? { ...candidate, status: "stale" as const } : candidate, + ), + }; + if (proposal.hunks.find(({ id }) => id === input.hunkId)?.status === "accepted") { + update = Y.encodeStateAsUpdate(document, before); + const snapshot = Y.encodeStateAsUpdate(document); + validateWorkspaceDocument(document); + if (!update.byteLength || update.byteLength > 2 * 1024 * 1024 || snapshot.byteLength > 2 * 1024 * 1024) { + throw new Error("Collaboration document exceeds 2 MiB"); + } + const sequence = row.durable_sequence + 1; + const mutationId = createHash("sha256").update(update).digest("base64url"); + await client.query( + `WITH version AS ( + INSERT INTO public.workspace_versions ( + workspace_id, document_id, created_by, kind, document_epoch, durable_sequence + ) VALUES ($1, $2, $3, 'automatic', $4, $5) + RETURNING id + ) + INSERT INTO private.workspace_version_data (version_id, snapshot) + SELECT id, $6 FROM version`, + [row.workspace_id, input.documentId, input.authorId, input.epoch, row.durable_sequence, Buffer.from(row.snapshot)], + ); + await client.query( + `INSERT INTO private.workspace_document_updates ( + workspace_id, document_id, epoch, sequence, mutation_id, author_id, sync_type, bytes + ) VALUES ($1, $2, $3, $4, $5, $6, 2, $7)`, + [row.workspace_id, input.documentId, input.epoch, sequence, mutationId, input.authorId, Buffer.from(update)], + ); + await client.query( + `UPDATE private.workspace_document_state + SET durable_sequence = $2, snapshot = $3, updated_at = now() + WHERE document_id = $1`, + [input.documentId, sequence, Buffer.from(snapshot)], + ); + await client.query( + `UPDATE public.workspace_documents + SET durable_sequence = $2, updated_at = now() + WHERE id = $1`, + [input.documentId, sequence], + ); + } + } finally { + document.destroy(); + } + } + + const sequence = row.durable_sequence + (update ? 1 : 0); + await client.query( + `UPDATE public.workspace_proposals + SET payload = $2, durable_sequence = $3, updated_at = now() + WHERE id = $1`, + [input.proposalId, JSON.stringify(serializeProposal(proposal)), sequence], + ); + await client.query("COMMIT"); + return { + proposal: serializeProposal(proposal), + sequence, + update, + }; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + await temporaryRelease?.(); + } + } + + private getWriter(documentId: string, epoch: number) { + const writer = this.writers.get(`${documentId}@${epoch}`); + if (!writer || writer.lost) throw new Error("Collaboration writer unavailable"); + return writer.client; + } + + async acquireWriter(documentId: string, epoch: number, onLost: () => void) { + const key = `${documentId}@${epoch}`; + const existing = this.writers.get(key); + if (existing) { + existing.refs += 1; + existing.onLost.add(onLost); + return this.releaseWriter(key, existing, onLost); + } + + const client = await this.pool.connect(); + const writer: Writer = { client, lost: false, refs: 1, onLost: new Set([onLost]) }; + const handleLoss = () => { + if (writer.lost) return; + writer.lost = true; + this.writers.delete(key); + writer.onLost.forEach((notify) => notify()); + client.release(true); + }; + client.once("error", handleLoss); + + try { + await client.query("SELECT pg_advisory_lock(hashtextextended($1, 0))", [ + `workspace-document:${key}`, + ]); + } catch (error) { + writer.lost = true; + client.removeListener("error", handleLoss); + client.release(true); + throw error; + } + this.writers.set(key, writer); + return this.releaseWriter(key, writer, onLost, handleLoss); + } + + private releaseWriter( + key: string, + writer: Writer, + onLost: () => void, + handleLoss?: (error: Error) => void, + ) { + let released = false; + return async () => { + if (released) return; + released = true; + writer.refs -= 1; + writer.onLost.delete(onLost); + if (writer.lost || writer.refs > 0) return; + + this.writers.delete(key); + if (handleLoss) writer.client.removeListener("error", handleLoss); + try { + await writer.client.query("SELECT pg_advisory_unlock(hashtextextended($1, 0))", [ + `workspace-document:${key}`, + ]); + } finally { + writer.client.release(); + } + }; + } +} diff --git a/services/collaboration/src/runtime-config.test.ts b/services/collaboration/src/runtime-config.test.ts new file mode 100644 index 000000000..f42d85448 --- /dev/null +++ b/services/collaboration/src/runtime-config.test.ts @@ -0,0 +1,29 @@ +// @vitest-environment node + +import { describe, expect, it } from "vitest"; +import { parseRuntimeConfig } from "./runtime-config"; + +describe("collaboration runtime configuration", () => { + it("fails closed without an exact preview origin", () => { + expect(() => parseRuntimeConfig({ + COLLABORATION_DATABASE_URL: "postgresql://postgres:postgres@127.0.0.1:54322/postgres", + COLLABORATION_PORT: "1234", + SUPABASE_ANON_KEY: "public-key", + SUPABASE_URL: "http://127.0.0.1:54321", + })).toThrow("COLLABORATION_ALLOWED_ORIGINS"); + }); + + it("parses only explicit HTTP origins and a loopback bind", () => { + expect(parseRuntimeConfig({ + COLLABORATION_ALLOWED_ORIGINS: "https://trial.example.ts.net:18443", + COLLABORATION_DATABASE_URL: "postgresql://postgres:postgres@127.0.0.1:54322/postgres", + COLLABORATION_PORT: "1234", + SUPABASE_ANON_KEY: "public-key", + SUPABASE_URL: "http://127.0.0.1:54321", + })).toMatchObject({ + address: "127.0.0.1", + allowedOrigins: ["https://trial.example.ts.net:18443"], + port: 1234, + }); + }); +}); diff --git a/services/collaboration/src/runtime-config.ts b/services/collaboration/src/runtime-config.ts new file mode 100644 index 000000000..c08f22f91 --- /dev/null +++ b/services/collaboration/src/runtime-config.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; + +const runtimeEnvironmentSchema = z.object({ + COLLABORATION_ALLOWED_ORIGINS: z.string().min(1), + COLLABORATION_DATABASE_URL: z.string().url(), + COLLABORATION_PORT: z.coerce.number().int().min(1).max(65_535), + SUPABASE_ANON_KEY: z.string().min(1), + SUPABASE_URL: z.string().url(), +}); + +export function parseRuntimeConfig(environment: Record) { + const parsed = runtimeEnvironmentSchema.parse(environment); + const allowedOrigins = parsed.COLLABORATION_ALLOWED_ORIGINS + .split(",") + .map((value) => value.trim()) + .filter(Boolean) + .map((value) => { + const url = new URL(value); + if (url.origin !== value) throw new Error("COLLABORATION_ALLOWED_ORIGINS must contain exact origins"); + return url.origin; + }); + if (allowedOrigins.length === 0) throw new Error("COLLABORATION_ALLOWED_ORIGINS is required"); + + return { + address: "127.0.0.1", + allowedOrigins, + databaseUrl: parsed.COLLABORATION_DATABASE_URL, + port: parsed.COLLABORATION_PORT, + supabaseAnonKey: parsed.SUPABASE_ANON_KEY, + supabaseUrl: parsed.SUPABASE_URL, + }; +} diff --git a/services/collaboration/src/server.test.ts b/services/collaboration/src/server.test.ts index 5d87ae286..bfabd2e29 100644 --- a/services/collaboration/src/server.test.ts +++ b/services/collaboration/src/server.test.ts @@ -8,6 +8,7 @@ import { type DurableUpdate, type DurableUpdateStore, } from "./server"; +import { createWorkspaceAuthenticator } from "./auth"; import { appendText, documentText, insertParagraph } from "./test/document"; function deferred() { @@ -106,6 +107,42 @@ describe("collaboration durability", () => { await Promise.all(cleanup.splice(0).map((dispose) => dispose())); }); + it("rejects a second provider using a noncanonical alias for an open room", async () => { + const server = createCollaborationServer({ + authenticate: createWorkspaceAuthenticator({ + isAuthorized: async () => true, + verifyToken: async (token) => ({ + id: token === "author" + ? "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" + : "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + }), + }), + store: new MemoryStore(), + }); + await server.listen(); + cleanup.push(() => server.destroy()); + + const canonical = new HocuspocusProvider({ + document: new Y.Doc(), + name: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa@1", + token: "author", + url: `ws://127.0.0.1:${server.address.port}`, + }); + let aliasRejected = false; + const alias = new HocuspocusProvider({ + document: new Y.Doc(), + name: "AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA@01", + onAuthenticationFailed: () => { aliasRejected = true; }, + token: "peer", + url: `ws://127.0.0.1:${server.address.port}`, + }); + cleanup.push(() => canonical.destroy(), () => alias.destroy()); + + await vi.waitFor(() => expect(canonical.isSynced).toBe(true)); + await vi.waitFor(() => expect(aliasRejected).toBe(true)); + expect(alias.isSynced).toBe(false); + }); + it("does not broadcast or acknowledge an update before append commits", async () => { const store = new BlockingStore(); const server = createCollaborationServer({ @@ -164,6 +201,66 @@ describe("collaboration durability", () => { }); }); + it("acknowledges member removal only after queued writes are fenced and sockets close", async () => { + const store = new BlockingStore(); + const changeAccess = vi.fn().mockResolvedValue(4); + const server = createCollaborationServer({ + authenticate: async ({ documentName, token }) => ({ + authorId: token, + documentId: documentName.split("@")[0], + epoch: Number(documentName.split("@")[1]), + }), + changeAccess, + store, + }); + await server.listen(); + cleanup.push(() => server.destroy()); + + const document = new Y.Doc(); + let closed = 0; + const provider = new HocuspocusProvider({ + document, + name: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa@1", + onClose: () => { closed += 1; }, + token: "author", + url: `ws://127.0.0.1:${server.address.port}`, + }); + cleanup.push(() => provider.destroy(), () => document.destroy()); + await vi.waitFor(() => expect(provider.isSynced).toBe(true)); + + insertParagraph(document, "Queued claim"); + await store.appendStarted.promise; + const removal = fetch(`http://127.0.0.1:${server.address.port}/access/remove`, { + body: JSON.stringify({ + expectedRevision: 3, + requestId: "dddddddd-dddd-4ddd-8ddd-dddddddddddd", + targetUserId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + workspaceId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + }), + headers: { + Authorization: "Bearer owner-token", + "Content-Type": "application/json", + Origin: "https://trial.test", + }, + method: "POST", + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(changeAccess).not.toHaveBeenCalled(); + + store.releaseAppend.resolve(); + const response = await removal; + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ revision: 4 }); + expect(changeAccess).toHaveBeenCalledWith({ + expectedRevision: 3, + requestId: "dddddddd-dddd-4ddd-8ddd-dddddddddddd", + targetUserId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + token: "owner-token", + workspaceId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + }); + await vi.waitFor(() => expect(closed).toBeGreaterThan(0)); + }); + it("persists reconnect SyncStep2 and live Update messages through one gate", async () => { const store = new MemoryStore(); const server = createCollaborationServer({ @@ -198,6 +295,35 @@ describe("collaboration durability", () => { expect(store.updates[1].syncType).toBe(2); }); + it("allows an archived workspace to complete a read-only reconnect", async () => { + const store = new MemoryStore(); + const authorize = vi.fn(async (_context: unknown, operation: "awareness" | "read" | "write") => operation !== "write"); + const server = createCollaborationServer({ + authenticate: async ({ documentName, token }) => ({ + authorId: token, + documentId: documentName.split("@")[0], + epoch: Number(documentName.split("@")[1]), + }), + authorize, + store, + }); + await server.listen(); + cleanup.push(() => server.destroy()); + + const document = new Y.Doc(); + const provider = new HocuspocusProvider({ + document, + name: "archived-speech@1", + token: "archived-member", + url: `ws://127.0.0.1:${server.address.port}`, + }); + cleanup.push(() => provider.destroy(), () => document.destroy()); + + await vi.waitFor(() => expect(provider.isSynced).toBe(true)); + expect(store.updates).toHaveLength(0); + expect(authorize.mock.calls.some(([, operation]) => operation === "write")).toBe(false); + }); + it("deduplicates identical update bytes within each document and epoch scope", async () => { const store = new MemoryStore(); const server = createCollaborationServer({ @@ -549,4 +675,178 @@ describe("collaboration durability", () => { expect(store.updates).toEqual([]); expect(acknowledgements).toEqual([]); }); + + it("rechecks membership before accepting a document update", async () => { + const store = new MemoryStore(); + let currentMember = true; + const server = createCollaborationServer({ + authenticate: async ({ documentName, token }) => ({ + authorId: token, + documentId: documentName.split("@")[0], + epoch: Number(documentName.split("@")[1]), + }), + authorize: async () => currentMember, + store, + }); + await server.listen(); + cleanup.push(() => server.destroy()); + + const authorDocument = new Y.Doc(); + const peerDocument = new Y.Doc(); + const author = new HocuspocusProvider({ + document: authorDocument, + name: "speech-revoked@1", + token: "author", + url: `ws://127.0.0.1:${server.address.port}`, + }); + const peer = new HocuspocusProvider({ + document: peerDocument, + name: "speech-revoked@1", + token: "peer", + url: `ws://127.0.0.1:${server.address.port}`, + }); + const errorLog = vi.spyOn(console, "error").mockImplementation(() => undefined); + cleanup.push(() => author.destroy(), () => peer.destroy(), () => errorLog.mockRestore()); + + await vi.waitFor(() => { + expect(author.isSynced).toBe(true); + expect(peer.isSynced).toBe(true); + }); + currentMember = false; + insertParagraph(authorDocument, "Blocked after removal"); + + await vi.waitFor(() => expect(author.isSynced).toBe(false)); + expect(documentText(peerDocument)).toBe(""); + expect(store.updates).toEqual([]); + }); + + it("rechecks membership before broadcasting awareness", async () => { + const store = new MemoryStore(); + let currentMember = true; + const server = createCollaborationServer({ + authenticate: async ({ documentName, token }) => ({ + authorId: token, + documentId: documentName.split("@")[0], + epoch: Number(documentName.split("@")[1]), + }), + authorize: async () => currentMember, + store, + }); + await server.listen(); + cleanup.push(() => server.destroy()); + + const author = new HocuspocusProvider({ + document: new Y.Doc(), + name: "speech-awareness@1", + token: "author", + url: `ws://127.0.0.1:${server.address.port}`, + }); + const peer = new HocuspocusProvider({ + document: new Y.Doc(), + name: "speech-awareness@1", + token: "peer", + url: `ws://127.0.0.1:${server.address.port}`, + }); + const errorLog = vi.spyOn(console, "error").mockImplementation(() => undefined); + cleanup.push(() => author.destroy(), () => peer.destroy(), () => errorLog.mockRestore()); + + await vi.waitFor(() => { + expect(author.isSynced).toBe(true); + expect(peer.isSynced).toBe(true); + }); + currentMember = false; + author.setAwarenessField("user", { name: "Removed member" }); + + await vi.waitFor(() => expect(author.isSynced).toBe(false)); + expect( + [...(peer.awareness?.getStates().values() ?? [])] + .some((state) => state.user?.name === "Removed member"), + ).toBe(false); + }); + + it("closes every writer when the dedicated database lock session dies", async () => { + const memory = new MemoryStore(); + const released = vi.fn(); + let loseLock: (() => void) | undefined; + const store = { + append: memory.append.bind(memory), + find: memory.find.bind(memory), + load: memory.load.bind(memory), + acquireWriter: vi.fn(async (_documentId: string, _epoch: number, onLost: () => void) => { + loseLock = onLost; + return released; + }), + }; + const server = createCollaborationServer({ + authenticate: async ({ documentName, token }) => ({ + authorId: token, + documentId: documentName.split("@")[0], + epoch: Number(documentName.split("@")[1]), + }), + store, + }); + await server.listen(); + cleanup.push(() => server.destroy()); + + const provider = new HocuspocusProvider({ + document: new Y.Doc(), + name: "speech-lock-loss@4", + token: "author", + url: `ws://127.0.0.1:${server.address.port}`, + }); + cleanup.push(() => provider.destroy()); + + await vi.waitFor(() => expect(provider.isSynced).toBe(true)); + expect(store.acquireWriter).toHaveBeenCalledWith( + "speech-lock-loss", + 4, + expect.any(Function), + ); + loseLock?.(); + + await vi.waitFor(() => expect(provider.isSynced).toBe(false)); + }); + + it("replaces forged cursor identity with the authenticated label", async () => { + const store = new MemoryStore(); + const server = createCollaborationServer({ + authenticate: async ({ documentName, token }) => ({ + authorId: token, + displayName: "Authenticated author", + documentId: documentName.split("@")[0], + epoch: Number(documentName.split("@")[1]), + }), + store, + }); + await server.listen(); + cleanup.push(() => server.destroy()); + + const author = new HocuspocusProvider({ + document: new Y.Doc(), + name: "speech-cursor-label@1", + token: "author", + url: `ws://127.0.0.1:${server.address.port}`, + }); + const peer = new HocuspocusProvider({ + document: new Y.Doc(), + name: "speech-cursor-label@1", + token: "peer", + url: `ws://127.0.0.1:${server.address.port}`, + }); + cleanup.push(() => author.destroy(), () => peer.destroy()); + + await vi.waitFor(() => { + expect(author.isSynced).toBe(true); + expect(peer.isSynced).toBe(true); + }); + author.setAwarenessField("user", { id: "victim", name: "Forged name" }); + + await vi.waitFor(() => { + expect([...(peer.awareness?.getStates().values() ?? [])]).toContainEqual( + expect.objectContaining({ + user: expect.objectContaining({ id: "author", name: "Authenticated author" }), + }), + ); + }); + }); }); diff --git a/services/collaboration/src/server.ts b/services/collaboration/src/server.ts index b8ba57663..2f15dbe6f 100644 --- a/services/collaboration/src/server.ts +++ b/services/collaboration/src/server.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import type { IncomingMessage, ServerResponse } from "node:http"; import { Server, type Connection } from "@hocuspocus/server"; import { Mutex } from "async-mutex"; import { @@ -27,30 +28,98 @@ export interface DurableUpdate { syncType: number; } +export interface DurableAppend extends Omit { + snapshot: Uint8Array; +} + export interface DurableUpdateStore { - append(update: Omit): Promise; + append(update: DurableAppend): Promise; find( documentId: string, epoch: number, mutationId: string, ): Promise; load(documentId: string, epoch: number): Promise; + acquireWriter?( + documentId: string, + epoch: number, + onLost: () => void, + ): Promise<() => void | Promise>; + loadSnapshot?(documentId: string, epoch: number): Promise; + snapshotVersion?(documentId: string, epoch: number): Promise; } interface AuthenticatedDocument { authorId: string; + displayName?: string; documentId: string; epoch: number; } interface CollaborationServerOptions { + changeAccess?(input: { + expectedRevision: number; + requestId: string; + targetUserId: string; + token: string; + workspaceId: string; + }): Promise; + decideProposal?(input: { + decision: "accepted" | "rejected"; + documentId: string; + epoch: number; + hunkId: string; + proposalId: string; + token: string; + }): Promise<{ proposal: unknown; sequence: number; update?: Uint8Array }>; authenticate(input: { documentName: string; token: string; }): Promise; + allowedOrigins?: ReadonlySet; + authorize?( + context: AuthenticatedDocument, + operation: "awareness" | "read" | "write", + ): Promise; store: DurableUpdateStore; } +const accessChangeSchema = z.object({ + expectedRevision: z.number().int().nonnegative(), + requestId: z.string().uuid(), + targetUserId: z.string().uuid(), + workspaceId: z.string().uuid(), +}); +const proposalDecisionSchema = z.object({ + decision: z.enum(["accepted", "rejected"]), + documentId: z.string().uuid(), + epoch: z.number().int().nonnegative(), + hunkId: z.string().min(1).max(100), + proposalId: z.string().uuid(), +}); + +async function requestBody(request: IncomingMessage) { + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of request) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + size += bytes.byteLength; + if (size > 16 * 1024) throw new Error("Request too large"); + chunks.push(bytes); + } + return JSON.parse(Buffer.concat(chunks).toString("utf8")); +} + +function json(response: ServerResponse, status: number, body: unknown, origin?: string) { + response.writeHead(status, { + ...(origin ? { "Access-Control-Allow-Origin": origin } : {}), + "Access-Control-Allow-Headers": "authorization, content-type", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Content-Type": "application/json", + }); + response.end(JSON.stringify(body)); +} + function sendAcknowledgement( connection: Connection, update: DurableUpdate, @@ -73,26 +142,65 @@ export function createCollaborationServer( { durable: DurableUpdate; release: () => void } >(); const locks = new Map(); + const writerReleases = new Map void | Promise>(); + // ponytail: one process-wide fence keeps removal ordering obvious; scope per workspace if trial traffic warrants it. + const accessFence = new Mutex(); + const authorize = options.authorize ?? (async () => true); - return new Server({ + const server = new Server({ address: "127.0.0.1", + debounce: 3_000, flushDelay: false, + maxDebounce: 30_000, port: 0, quiet: true, stopOnSignals: false, + async onConnect({ requestHeaders }) { + if (options.allowedOrigins?.size) { + const origin = requestHeaders.get("origin"); + if (!origin || !options.allowedOrigins.has(origin)) { + throw new Error("Origin unavailable"); + } + } + }, onAuthenticate: ({ documentName, token }) => options.authenticate({ documentName, token }), - async onLoadDocument({ context }) { + async onLoadDocument({ context, documentName }) { + if (options.store.acquireWriter && !writerReleases.has(documentName)) { + const release = await options.store.acquireWriter( + context.documentId, + context.epoch, + () => { + writerReleases.delete(documentName); + server.hocuspocus.closeConnections(documentName); + }, + ); + writerReleases.set(documentName, release); + } + const document = new Y.Doc(); - const updates = await options.store.load(context.documentId, context.epoch); - for (const update of [...updates].sort( - (a, b) => a.sequence - b.sequence, - )) { - Y.applyUpdate(document, update.bytes); + try { + const snapshot = await options.store.loadSnapshot?.(context.documentId, context.epoch); + if (snapshot?.byteLength) { + Y.applyUpdate(document, snapshot); + } else { + const updates = await options.store.load(context.documentId, context.epoch); + for (const update of [...updates].sort( + (a, b) => a.sequence - b.sequence, + )) { + Y.applyUpdate(document, update.bytes); + } + } + return document; + } catch (error) { + const release = writerReleases.get(documentName); + writerReleases.delete(documentName); + await release?.(); + throw error; } - return document; }, async onStateless({ connection, payload }) { + if (!await authorize(connection.context, "read")) throw new Error("Workspace access revoked"); if (payload.length > MAX_ACK_QUERY_BYTES) return; let input: unknown; try { @@ -115,45 +223,83 @@ export function createCollaborationServer( } } }, + async beforeHandleAwareness({ connection, context, states }) { + if (connection && context && !await authorize(context, "awareness")) { + throw new Error("Workspace access revoked"); + } + if (context) { + states.forEach((state) => { + const color = typeof state.user?.color === "string" ? state.user.color : undefined; + state.user = { + ...(color ? { color } : {}), + id: context.authorId, + name: context.displayName ?? `Member ${context.authorId.slice(0, 4)}`, + }; + }); + } + }, async beforeSync({ connection, context, document, payload, type }) { - if (type !== messageYjsSyncStep2 && type !== messageYjsUpdate) return; + if (!await authorize(context, "read")) throw new Error("Workspace access revoked"); + const isUpdate = type === messageYjsSyncStep2 || type === messageYjsUpdate; + if (!isUpdate) return; if (payload.byteLength > MAX_UPDATE_BYTES) { throw new Error("Collaboration update exceeds 2 MiB"); } const decoded = Y.decodeUpdate(payload); if (decoded.structs.length === 0 && decoded.ds.clients.size === 0) return; + const mutationId = createHash("sha256").update(payload).digest("base64url"); + const probe = new Y.Doc(); + try { + Y.applyUpdate(probe, Y.encodeStateAsUpdate(document)); + const before = Y.encodeStateAsUpdate(probe); + Y.applyUpdate(probe, payload); + const after = Y.encodeStateAsUpdate(probe); + if (Buffer.from(before).equals(Buffer.from(after))) { + const duplicate = await options.store.find(context.documentId, context.epoch, mutationId); + if (duplicate?.authorId === context.authorId) sendAcknowledgement(connection, duplicate); + return; + } + } finally { + probe.destroy(); + } + if (!await authorize(context, "write")) throw new Error("Workspace access revoked"); + const releaseAccess = await accessFence.acquire(); const key = `${context.documentId}@${context.epoch}`; const mutex = locks.get(key) ?? new Mutex(); locks.set(key, mutex); const release = await mutex.acquire(); try { const candidate = new Y.Doc(); + let snapshot: Uint8Array; try { Y.applyUpdate(candidate, Y.encodeStateAsUpdate(document)); Y.applyUpdate(candidate, payload); validateWorkspaceDocument(candidate); - if (Y.encodeStateAsUpdate(candidate).byteLength > MAX_UPDATE_BYTES) { + snapshot = Y.encodeStateAsUpdate(candidate); + if (snapshot.byteLength > MAX_UPDATE_BYTES) { throw new Error("Collaboration document exceeds spike limit"); } } finally { candidate.destroy(); } - const mutationId = createHash("sha256") - .update(payload) - .digest("base64url"); const durable = await options.store.append({ authorId: context.authorId, bytes: payload, documentId: context.documentId, epoch: context.epoch, mutationId, + snapshot, syncType: type, }); - pending.set(connection, { durable, release }); + pending.set(connection, { durable, release: () => { + release(); + releaseAccess(); + } }); } catch (error) { release(); + releaseAccess(); if (!mutex.isLocked()) locks.delete(key); throw error; } @@ -178,5 +324,70 @@ export function createCollaborationServer( if (mutex && !mutex.isLocked()) locks.delete(key); } }, + async onStoreDocument({ documentName }) { + const [documentId, epoch] = documentName.split("@"); + await options.store.snapshotVersion?.(documentId, Number(epoch)); + }, + async onRequest({ instance, request, response }) { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + if (url.pathname !== "/access/remove" && url.pathname !== "/review/decide") return; + const origin = request.headers.origin; + if (options.allowedOrigins?.size && (!origin || !options.allowedOrigins.has(origin))) { + json(response, 403, { error: "Origin unavailable" }); + throw null; + } + if (request.method === "OPTIONS") { + json(response, 204, null, origin); + throw null; + } + if (request.method !== "POST") { + json(response, 405, { error: "Method unavailable" }, origin); + throw null; + } + const authorization = request.headers.authorization; + const token = authorization?.startsWith("Bearer ") ? authorization.slice(7) : ""; + try { + if (!token) throw new Error("Authentication required"); + const body = await requestBody(request); + const release = await accessFence.acquire(); + try { + if (url.pathname === "/access/remove") { + if (!options.changeAccess) throw new Error("Method unavailable"); + const revision = await options.changeAccess({ ...accessChangeSchema.parse(body), token }); + instance.closeConnections(); + json(response, 200, { revision }, origin); + } else { + if (!options.decideProposal) throw new Error("Method unavailable"); + const input = proposalDecisionSchema.parse(body); + const result = await options.decideProposal({ ...input, token }); + if (result.update) { + const documentName = `${input.documentId}@${input.epoch}`; + const direct = await instance.openDirectConnection(documentName); + try { + await direct.transact((document) => Y.applyUpdate(document, result.update!)); + } catch { + instance.closeConnections(documentName); + } finally { + await direct.disconnect(); + } + } + json(response, 200, { proposal: result.proposal, sequence: result.sequence }, origin); + } + } finally { + release(); + } + } catch { + json(response, 403, { error: "Workspace unavailable" }, origin); + } + throw null; + }, + async onDisconnect({ clientsCount, documentName }) { + if (clientsCount > 0) return; + const release = writerReleases.get(documentName); + writerReleases.delete(documentName); + await release?.(); + }, }); + + return server; } diff --git a/src/App.tsx b/src/App.tsx index 10237c792..ec3763dff 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ import { MotionConfig } from "framer-motion"; import { AppLayout } from "@/components/layout/AppLayout"; import { AuthProvider } from "@/lib/supabase/AuthProvider"; import { RequireAuth } from "@/components/auth/RequireAuth"; +import { AccountQueryCache } from "@/components/auth/AccountQueryCache"; import { ThemeProvider } from "@/components/ThemeProvider"; import { useSessionStorageSync } from "@/hooks/use-session-storage-sync"; import { useOnboardingStore } from "@/hooks/use-onboarding"; @@ -23,6 +24,9 @@ const LandingPage = lazy(routeLoaders["/"]); const TermsPage = lazy(routeLoaders["/terms"]); const PrivacyPage = lazy(routeLoaders["/privacy"]); const HomePage = lazy(routeLoaders["/app"]); +const WorkspacesPage = lazy(routeLoaders["/app/workspaces"]); +const WorkspaceInvitePage = lazy(routeLoaders["/app/workspaces/invite"]); +const WorkspacePage = lazy(routeLoaders["/app/workspaces/:workspaceId"]); const GeneratePage = lazy(routeLoaders["/app/generate"]); const AttackPage = lazy(routeLoaders["/app/attack"]); const BrainstormPage = lazy(routeLoaders["/app/brainstorm"]); @@ -93,6 +97,7 @@ const ROUTE_FALLBACKS: Record = { "/expocit/sources": { variant: "public", label: "Sources", labelKey: "crossfire.sources" }, "/login": { variant: "public", label: "Sign in", labelKey: "login.title" }, "/app": { variant: "home-grid", label: "Home", labelKey: "nav.home" }, + "/app/workspaces": { variant: "settings/list", label: "Workspaces", labelKey: "nav.workspaces" }, "/app/generate": { variant: "tool-form", label: "Generate", labelKey: "nav.generate" }, "/app/attack": { variant: "tool-form", label: "Attack", labelKey: "nav.attack" }, "/app/brainstorm": { variant: "tool-form", label: "Brainstorm", labelKey: "nav.brainstorm" }, @@ -235,6 +240,9 @@ function AppRoutes() { } > } /> + } /> + } /> + } /> } /> } /> } /> @@ -257,6 +265,7 @@ function AppRoutes() { const App = () => ( + diff --git a/src/components/auth/AccountQueryCache.tsx b/src/components/auth/AccountQueryCache.tsx new file mode 100644 index 000000000..2f54dfa09 --- /dev/null +++ b/src/components/auth/AccountQueryCache.tsx @@ -0,0 +1,17 @@ +import { useEffect, useRef } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useAuth } from "@/lib/supabase/AuthProvider"; + +export function AccountQueryCache(): null { + const { user } = useAuth(); + const queryClient = useQueryClient(); + const previousUserId = useRef(user?.id ?? null); + + useEffect(() => { + const nextUserId = user?.id ?? null; + if (previousUserId.current !== nextUserId) queryClient.clear(); + previousUserId.current = nextUserId; + }, [queryClient, user?.id]); + + return null; +} diff --git a/src/components/auth/RequireAuth.tsx b/src/components/auth/RequireAuth.tsx index 27c7efdaf..22e876317 100644 --- a/src/components/auth/RequireAuth.tsx +++ b/src/components/auth/RequireAuth.tsx @@ -1,6 +1,7 @@ import { Navigate, useLocation } from 'react-router-dom'; import { useAuth } from '@/lib/supabase/AuthProvider'; import { Loader2 } from 'lucide-react'; +import { consumeWorkspaceInvite, stashWorkspaceInvite } from '@/lib/workspace/invite-handoff'; export function RequireAuth({ children }: { children: React.ReactNode }) { const { user, loading } = useAuth(); @@ -15,8 +16,14 @@ export function RequireAuth({ children }: { children: React.ReactNode }) { } if (!user) { - return ; + const invite = stashWorkspaceInvite(location); + return ; } + const invite = location.pathname === "/app/workspaces/invite" + ? null + : consumeWorkspaceInvite(); + if (invite) return ; + return <>{children}; } diff --git a/src/components/features/workspace/WorkspaceAccessPanel.tsx b/src/components/features/workspace/WorkspaceAccessPanel.tsx new file mode 100644 index 000000000..56371f2f0 --- /dev/null +++ b/src/components/features/workspace/WorkspaceAccessPanel.tsx @@ -0,0 +1,167 @@ +import { useRef, useState, type FormEvent } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { createCodeInvite, revokeInvite, sendEmailInvite } from "@/lib/workspace/invite-client"; +import { removeWorkspaceMember } from "@/lib/workspace/client"; +import { changeWorkspaceAccess } from "@/lib/workspace/client"; +import { useOnlineStatus } from "@/hooks/use-online-status"; + +export function WorkspaceAccessPanel({ + members, + onChanged, + ownerId, + pendingOwnerId, + revision, + token, + userId, + workspaceId, +}: { + members: Array<{ user_id: string }>; + onChanged: () => Promise; + ownerId: string; + pendingOwnerId: string | null; + revision: number; + token: string; + userId: string; + workspaceId: string; +}) { + const { t } = useTranslation(); + const online = useOnlineStatus(); + const [busy, setBusy] = useState(false); + const [code, setCode] = useState(null); + const [inviteId, setInviteId] = useState(null); + const [email, setEmail] = useState(""); + const [message, setMessage] = useState(null); + const emailRequestId = useRef(null); + + const issueCode = async () => { + setBusy(true); + setMessage(null); + try { + const invite = await createCodeInvite(workspaceId, inviteId ?? undefined); + setCode(invite.code); + setInviteId(invite.inviteId); + } catch { + setMessage(t("workspaces.inviteFailed", "Invitation could not be created.")); + } finally { + setBusy(false); + } + }; + + const sendEmail = async (event: FormEvent) => { + event.preventDefault(); + if (!email.trim()) return; + setBusy(true); + setMessage(null); + emailRequestId.current ??= crypto.randomUUID(); + try { + await sendEmailInvite(workspaceId, email.trim(), emailRequestId.current); + emailRequestId.current = null; + setEmail(""); + setMessage(t("workspaces.emailInviteSent", "Email invitation sent.")); + } catch { + setMessage(t("workspaces.emailInviteRetry", "Delivery failed. Retry to continue the same invitation.")); + } finally { + setBusy(false); + } + }; + + return ( +
+ {t("workspaces.invitePeople", "Invite people")} +
+ + {code && ( +
+ + + +
+ )} +
+ +
+ { + setEmail(event.target.value); + emailRequestId.current = null; + }} /> + +
+
+
+

{t("workspaces.members", "Members")}

+
+ {members.map((member) => ( +
+ {t("workspaces.memberLabel", "Member {{id}}", { id: member.user_id.slice(0, 4) })}{member.user_id === ownerId ? ` · ${t("workspaces.owner", "Owner")}` : ""} + {member.user_id !== ownerId && ( +
+ {userId === ownerId && ( + + )} + +
+ )} +
+ ))} +
+ {pendingOwnerId === userId && ( + + )} +
+ {message &&

{message}

} +
+
+ ); +} diff --git a/src/components/features/workspace/WorkspaceEditor.tsx b/src/components/features/workspace/WorkspaceEditor.tsx new file mode 100644 index 000000000..353bd3e29 --- /dev/null +++ b/src/components/features/workspace/WorkspaceEditor.tsx @@ -0,0 +1,341 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { HocuspocusProvider } from "@hocuspocus/provider"; +import { EditorContent } from "@tiptap/react"; +import { z } from "zod"; +import * as Y from "yjs"; +import { useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { COLLABORATION_URL } from "@/lib/workspace/collaboration-config"; +import { + deleteWorkspaceDraft, + listWorkspaceDrafts, + loadWorkspaceDraft, + saveWorkspaceDraft, + type WorkspaceDraft, +} from "@/lib/workspace/draft-store"; +import { durableMutationFromMessage } from "@/lib/workspace/durable-ack"; +import { createWorkspaceEditor } from "@/lib/workspace/editor-schema"; +import { createProposal, createQuoteAnchor, type ProposalHunkInput } from "@/lib/workspace/review"; +import { createWorkspaceComment, saveWorkspaceProposal } from "@/lib/workspace/client"; +import { useOnlineStatus } from "@/hooks/use-online-status"; + +const acknowledgementSchema = z.object({ + epoch: z.number().int().nonnegative(), + kind: z.literal("durable-ack"), + mutationId: z.string(), + sequence: z.number().int().positive(), +}); + +export type SaveStatus = "Offline" | "Saved" | "Saving"; + +export function WorkspaceEditor({ + documentId, + editable, + epoch, + members, + token, + userId, + userName, + workspaceId, + onDurableSequence, + onSaveStatus, +}: { + documentId: string; + editable: boolean; + epoch: number; + members: Array<{ user_id: string }>; + token: string; + userId: string; + userName: string; + workspaceId: string; + onDurableSequence?: (sequence: number) => void; + onSaveStatus?: (status: SaveStatus) => void; +}) { + const { t } = useTranslation(); + const online = useOnlineStatus(); + const queryClient = useQueryClient(); + const identity = useMemo( + () => ({ documentId, epoch, userId, workspaceId }), + [documentId, epoch, userId, workspaceId], + ); + const [editor, setEditor] = useState | null>(null); + const [link, setLink] = useState(""); + const [status, setStatus] = useState(navigator.onLine ? "Saving" : "Offline"); + const [storageWarning, setStorageWarning] = useState(false); + const [suggesting, setSuggesting] = useState(false); + const [reviewError, setReviewError] = useState(null); + const [commentOpen, setCommentOpen] = useState(false); + const [commentBody, setCommentBody] = useState(""); + const [mentionId, setMentionId] = useState(""); + const [recoveryDrafts, setRecoveryDrafts] = useState([]); + const suggestingRef = useRef(false); + + useEffect(() => { + suggestingRef.current = suggesting; + }, [suggesting]); + + useEffect(() => { + onSaveStatus?.(status); + }, [onSaveStatus, status]); + + useEffect(() => { + let disposed = false; + setEditor(null); + setRecoveryDrafts([]); + setStatus(navigator.onLine ? "Saving" : "Offline"); + let provider: HocuspocusProvider | undefined; + let activeEditor: ReturnType | undefined; + const pending = new Set(); + const loadedPending = new Set(); + let bufferedHunk: ProposalHunkInput | undefined; + let suggestionTimer: ReturnType | undefined; + const flushSuggestion = () => { + if (!bufferedHunk || !activeEditor) return; + const hunk = bufferedHunk; + bufferedHunk = undefined; + if (suggestionTimer) clearTimeout(suggestionTimer); + suggestionTimer = undefined; + if (!online || pending.size > 0) { + if (!disposed) setReviewError(t("workspaces.saveBeforeSuggesting", "Wait for Saved before suggesting a change.")); + return; + } + const proposal = createProposal({ + authorId: userId, + documentEpoch: epoch, + editor: activeEditor, + hunks: [hunk], + id: crypto.randomUUID(), + }); + void saveWorkspaceProposal({ + documentId, + proposal, + workspaceId, + }).then(() => { + if (disposed) return; + setReviewError(null); + void queryClient.invalidateQueries({ queryKey: ["workspace-proposals", userId, documentId] }); + }).catch(() => { + if (!disposed) setReviewError(t("workspaces.suggestionFailed", "Suggestion could not be saved.")); + }); + }; + const queueSuggestion = (hunk: ProposalHunkInput) => { + const previous = bufferedHunk; + const sameRange = previous?.kind === "insert" && hunk.kind === "insert" + ? previous.at === hunk.at + : previous?.kind === "format" && hunk.kind === "format" + ? previous.from === hunk.from && previous.to === hunk.to + : false; + if ( + previous && previous.kind !== "delete" && hunk.kind === previous.kind && sameRange && + previous.content.length === 1 && hunk.content.length === 1 && + previous.content[0].type === "text" && hunk.content[0].type === "text" && + JSON.stringify(previous.content[0].marks ?? []) === JSON.stringify(hunk.content[0].marks ?? []) + ) { + bufferedHunk = { + ...previous, + content: [{ ...previous.content[0], text: `${previous.content[0].text ?? ""}${hunk.content[0].text ?? ""}` }], + } as ProposalHunkInput; + } else { + flushSuggestion(); + bufferedHunk = hunk; + } + if (suggestionTimer) clearTimeout(suggestionTimer); + suggestionTimer = setTimeout(flushSuggestion, 400); + }; + const document = new Y.Doc(); + const persist = async () => { + try { + await saveWorkspaceDraft({ + ...identity, + pendingMutationIds: [...pending], + snapshot: Y.encodeStateAsUpdate(document), + updatedAt: Date.now(), + }); + if (!disposed) setStorageWarning(false); + } catch { + if (!disposed) setStorageWarning(true); + } + }; + const start = async () => { + try { + const drafts = await listWorkspaceDrafts(identity); + if (!disposed) setRecoveryDrafts(drafts.filter((draft) => draft.epoch !== epoch)); + const draft = await loadWorkspaceDraft(identity); + if (draft?.snapshot.byteLength) Y.applyUpdate(document, draft.snapshot); + draft?.pendingMutationIds.forEach((mutationId) => { + loadedPending.add(mutationId); + pending.add(mutationId); + }); + } catch { + if (!disposed) setStorageWarning(true); + } + if (disposed || !COLLABORATION_URL) return; + + provider = new HocuspocusProvider({ + document, + name: `${documentId}@${epoch}`, + onOutgoingMessage: ({ message }) => { + void durableMutationFromMessage(message.toUint8Array()).then((mutation) => { + if (!mutation || disposed) return; + pending.add(mutation.mutationId); + setStatus("Saving"); + void persist(); + }); + }, + onStateless: ({ payload }) => { + if (disposed) return; + let input: unknown; + try { + input = JSON.parse(payload); + } catch { + return; + } + const acknowledgement = acknowledgementSchema.safeParse(input); + if (!acknowledgement.success || acknowledgement.data.epoch !== epoch) return; + if (loadedPending.size > 0 && !loadedPending.has(acknowledgement.data.mutationId)) { + loadedPending.forEach((mutationId) => pending.delete(mutationId)); + loadedPending.clear(); + } + loadedPending.delete(acknowledgement.data.mutationId); + pending.delete(acknowledgement.data.mutationId); + onDurableSequence?.(acknowledgement.data.sequence); + setStatus(pending.size === 0 ? "Saved" : "Saving"); + if (pending.size === 0) void deleteWorkspaceDraft(identity); + else void persist(); + }, + onStatus: ({ status: nextStatus }) => { + if (!disposed && nextStatus === "disconnected") setStatus("Offline"); + }, + onSynced: () => { + if (disposed) return; + if (pending.size > 0) { + provider?.sendStateless(JSON.stringify({ + epoch, + kind: "durable-ack-query", + mutationIds: [...pending].slice(0, 100), + })); + } else { + setStatus("Saved"); + } + }, + token, + url: COLLABORATION_URL, + }); + activeEditor = createWorkspaceEditor(document, { + ariaLabel: t("workspaces.documentEditor", "Document editor"), + editable, + isSuggesting: () => suggestingRef.current, + onSuggestion: queueSuggestion, + provider, + user: { color: `hsl(${Math.abs(userId.charCodeAt(0) * 47) % 360} 70% 45%)`, name: userName }, + }); + document.on("update", (_update, origin) => { + if (origin !== provider) setStatus("Saving"); + void persist(); + }); + if (!disposed) setEditor(activeEditor); + }; + const offline = () => setStatus("Offline"); + const online = () => provider?.connect(); + window.addEventListener("offline", offline); + window.addEventListener("online", online); + void start(); + + return () => { + disposed = true; + flushSuggestion(); + window.removeEventListener("offline", offline); + window.removeEventListener("online", online); + activeEditor?.destroy(); + provider?.destroy(); + document.destroy(); + }; + }, [documentId, editable, epoch, identity, onDurableSequence, online, queryClient, t, token, userId, userName, workspaceId]); + + if (!COLLABORATION_URL) { + return

{t("workspaces.collaborationUnavailable", "Collaboration service is not configured.")}

; + } + if (!editor) return

{t("workspaces.openingEditor", "Opening editor...")}

; + + return ( +
+
+ + + + + + + + setLink(event.target.value)} /> + + + + + {t(`workspaces.status${status}`, status)} +
+ {commentOpen && ( +
{ + event.preventDefault(); + const { from, to } = editor.state.selection; + if (from === to || !commentBody.trim()) { + setReviewError(t("workspaces.selectCommentText", "Select text before adding a comment.")); + return; + } + const id = crypto.randomUUID(); + const anchor = createQuoteAnchor({ + documentEpoch: epoch, + editor, + from, + id, + quote: editor.state.doc.textBetween(from, to), + to, + }); + void createWorkspaceComment({ + anchor, + body: commentBody, + documentId, + mentionIds: mentionId ? [mentionId] : [], + workspaceId, + }).then(() => { + setCommentBody(""); + setMentionId(""); + setCommentOpen(false); + setReviewError(null); + void queryClient.invalidateQueries({ queryKey: ["workspace-comments", userId, documentId] }); + }).catch(() => setReviewError(t("workspaces.commentFailed", "Comment could not be saved."))); + }}> + setCommentBody(event.target.value)} /> + + +
+ )} + {storageWarning &&

{t("workspaces.storageUnavailable", "Local recovery storage is unavailable.")}

} + {reviewError &&

{reviewError}

} + {recoveryDrafts.map((draft) => ( +
+ {t("workspaces.recovery", "Recovery")}: {t("workspaces.oldDraft", "An offline draft from before the restore is available.")} + + +
+ ))} + +
+ ); +} diff --git a/src/components/features/workspace/WorkspaceReviewPanel.tsx b/src/components/features/workspace/WorkspaceReviewPanel.tsx new file mode 100644 index 000000000..c4fc81efb --- /dev/null +++ b/src/components/features/workspace/WorkspaceReviewPanel.tsx @@ -0,0 +1,149 @@ +import { useState, type FormEvent } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + decideWorkspaceProposal, + listWorkspaceComments, + listWorkspaceNotifications, + listWorkspaceProposals, + replyWorkspaceComment, + resolveWorkspaceComment, +} from "@/lib/workspace/client"; +import { useOnlineStatus } from "@/hooks/use-online-status"; + +function summarizeContent(value: unknown): string { + if (Array.isArray(value)) return value.map(summarizeContent).filter(Boolean).join(" "); + if (!value || typeof value !== "object") return ""; + const node = value as { content?: unknown; marks?: Array<{ type?: unknown }>; text?: unknown; type?: unknown }; + const content = typeof node.text === "string" ? node.text : summarizeContent(node.content); + const marks = node.marks?.map(({ type }) => typeof type === "string" ? type : "").filter(Boolean) ?? []; + return marks.length ? `${content} [${marks.join(", ")}]` : content; +} + +export function WorkspaceReviewPanel({ + documentId, + editable, + epoch, + onDurableSequence, + token, + userId, + workspaceId, +}: { + documentId: string; + editable: boolean; + epoch: number; + onDurableSequence: (sequence: number) => void; + token: string; + userId: string; + workspaceId: string; +}) { + const { t } = useTranslation(); + const online = useOnlineStatus(); + const queryClient = useQueryClient(); + const [reply, setReply] = useState>({}); + const [error, setError] = useState(null); + const proposals = useQuery({ + queryKey: ["workspace-proposals", userId, documentId], + queryFn: () => listWorkspaceProposals(documentId), + refetchInterval: 2_000, + }); + const comments = useQuery({ + queryKey: ["workspace-comments", userId, documentId], + queryFn: () => listWorkspaceComments(documentId, workspaceId), + refetchInterval: 2_000, + }); + const notifications = useQuery({ + queryKey: ["workspace-notifications", userId, documentId], + queryFn: () => listWorkspaceNotifications(documentId), + refetchInterval: 2_000, + }); + const decision = useMutation({ + mutationFn: (input: { decision: "accepted" | "rejected"; hunkId: string; proposalId: string }) => + decideWorkspaceProposal({ ...input, documentId, epoch, token }), + onError: () => setError(t("workspaces.proposalDecisionFailed", "The change could not be decided.")), + onSuccess: (result) => { + setError(null); + onDurableSequence(result.sequence); + void queryClient.invalidateQueries({ queryKey: ["workspace-proposals", userId, documentId] }); + void queryClient.invalidateQueries({ queryKey: ["workspace-versions", userId, documentId] }); + }, + }); + + const submitReply = async (event: FormEvent, commentId: string) => { + event.preventDefault(); + const body = reply[commentId]?.trim(); + if (!body) return; + try { + await replyWorkspaceComment(workspaceId, commentId, body); + setReply((current) => ({ ...current, [commentId]: "" })); + await queryClient.invalidateQueries({ queryKey: ["workspace-comments", userId, documentId] }); + } catch { + setError(t("workspaces.commentFailed", "Comment could not be saved.")); + } + }; + + return ( +
+ {t("workspaces.review", "Review")} + {error &&

{error}

} +
+ {notifications.data?.map((notification) => ( +

+ {t("workspaces.mentionedYou", "A teammate mentioned you in a comment.")} +

+ ))} + {proposals.data?.flatMap((proposal) => proposal.hunks.map((hunk) => ( +
+

{t(`workspaces.proposal.${hunk.kind}`, hunk.kind)}

+

{t(`workspaces.proposal.${hunk.status}`, hunk.status)}

+
+

+ {t("workspaces.changePosition", "Characters {{from}}–{{to}}", { + from: hunk.absoluteFrom, + to: hunk.absoluteTo, + })} +

+

{t("workspaces.original", "Original")}

+

+ {summarizeContent(hunk.contextBefore)} + {summarizeContent(hunk.original) || t("workspaces.emptyChange", "Empty")} + {summarizeContent(hunk.contextAfter)} +

+

{t("workspaces.proposed", "Proposed")}

+

+ {summarizeContent(hunk.contextBefore)} + {summarizeContent(hunk.replacement) || t("workspaces.emptyChange", "Empty")} + {summarizeContent(hunk.contextAfter)} +

+
+ {hunk.status === "pending" && ( +
+ + +
+ )} +
+ )))} + {comments.data?.map((comment) => ( +
+
“{comment.anchor.quote}”
+

{comment.body}

+ {comment.replies.map((item) =>

{item.body}

)} +
void submitReply(event, comment.id)}> + setReply((current) => ({ ...current, [comment.id]: event.target.value }))} /> + +
+ +
+ ))} + {!proposals.data?.length && !comments.data?.length &&

{t("workspaces.noReviewItems", "No review items yet.")}

} +
+
+ ); +} diff --git a/src/components/features/workspace/WorkspaceVersions.tsx b/src/components/features/workspace/WorkspaceVersions.tsx new file mode 100644 index 000000000..937ccc2dd --- /dev/null +++ b/src/components/features/workspace/WorkspaceVersions.tsx @@ -0,0 +1,114 @@ +import { useEffect, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { EditorContent } from "@tiptap/react"; +import { useTranslation } from "react-i18next"; +import * as Y from "yjs"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + createWorkspaceVersion, + getWorkspaceVersionPreview, + listWorkspaceVersions, + restoreWorkspaceVersion, + type WorkspaceVersionPreview, +} from "@/lib/workspace/client"; +import { createWorkspaceEditor } from "@/lib/workspace/editor-schema"; + +function VersionPreview({ ariaLabel, preview }: { ariaLabel: string; preview: WorkspaceVersionPreview }) { + const [editor, setEditor] = useState | null>(null); + + useEffect(() => { + const document = new Y.Doc(); + Y.applyUpdate(document, preview.snapshot); + const nextEditor = createWorkspaceEditor(document, { editable: false, ariaLabel }); + setEditor(nextEditor); + return () => { + nextEditor.destroy(); + document.destroy(); + }; + }, [ariaLabel, preview]); + + return editor ? : null; +} + +export function WorkspaceVersions({ + canWrite, + documentId, + userId, + workspaceId, +}: { + canWrite: boolean; + documentId: string; + userId: string; + workspaceId: string; +}) { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const [name, setName] = useState(""); + const [error, setError] = useState(null); + const [selectedVersionId, setSelectedVersionId] = useState(null); + const versions = useQuery({ + queryKey: ["workspace-versions", userId, documentId], + queryFn: () => listWorkspaceVersions(documentId), + }); + const save = useMutation({ + mutationFn: () => createWorkspaceVersion({ documentId, name: name.trim() || null, workspaceId }), + onError: () => setError(t("workspaces.versionSaveFailed", "Version could not be saved.")), + onSuccess: () => { + setError(null); + setName(""); + void queryClient.invalidateQueries({ queryKey: ["workspace-versions", userId, documentId] }); + }, + }); + const preview = useQuery({ + queryKey: ["workspace-version-preview", userId, documentId, selectedVersionId], + queryFn: () => getWorkspaceVersionPreview({ + documentId, + versionId: selectedVersionId!, + workspaceId, + }), + enabled: selectedVersionId !== null, + }); + const restore = useMutation({ + mutationFn: () => restoreWorkspaceVersion({ + documentId, + previewedSequence: preview.data!.currentSequence, + versionId: selectedVersionId!, + workspaceId, + }), + onError: () => setError(t("workspaces.versionRestoreConflict", "The document changed. Preview the version again before restoring.")), + onSuccess: () => window.location.reload(), + }); + + return ( +
+ {t("workspaces.versionHistory", "Version history")} +
+ setName(event.target.value)} placeholder={t("workspaces.versionName", "Version name")} /> + +
+ {error &&

{error}

} +
+ {versions.data?.map((version) => ( +
+ {version.name ?? t(`workspaces.versionKind.${version.kind}`, version.kind)} + +
+ ))} +
+ {preview.isError &&

{t("workspaces.versionPreviewFailed", "Version preview could not be loaded.")}

} + {preview.data && ( +
+ +
+ +
+
+ )} +
+ ); +} diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx index 28567a438..c41ae5a53 100644 --- a/src/components/layout/AppLayout.tsx +++ b/src/components/layout/AppLayout.tsx @@ -18,7 +18,8 @@ import { Dumbbell, Database, GitBranch, - Crosshair + Crosshair, + Files, } from 'lucide-react'; import { NavLink } from '@/components/NavLink'; import { Button } from '@/components/ui/button'; @@ -36,6 +37,7 @@ import { const navItems = [ { path: '/app', key: 'home', icon: Home }, + { path: '/app/workspaces', key: 'workspaces', icon: Files }, { path: '/app/generate', key: 'generate', icon: Sparkles }, { path: '/app/attack', key: 'attack', icon: Swords }, { path: '/app/brainstorm', key: 'brainstorm', icon: Lightbulb }, @@ -50,7 +52,7 @@ const navItems = [ ]; const primaryNavItems = navItems.filter((item) => - ['home', 'generate', 'attack', 'ask', 'crossfire', 'sparring'].includes(item.key), + ['home', 'workspaces', 'generate', 'attack', 'ask', 'crossfire', 'sparring'].includes(item.key), ); const moreNavItems = navItems.filter((item) => !primaryNavItems.includes(item)); diff --git a/src/hooks/use-online-status.ts b/src/hooks/use-online-status.ts new file mode 100644 index 000000000..583aee870 --- /dev/null +++ b/src/hooks/use-online-status.ts @@ -0,0 +1,15 @@ +import { useEffect, useState } from "react"; + +export function useOnlineStatus() { + const [online, setOnline] = useState(() => navigator.onLine); + useEffect(() => { + const update = () => setOnline(navigator.onLine); + window.addEventListener("online", update); + window.addEventListener("offline", update); + return () => { + window.removeEventListener("online", update); + window.removeEventListener("offline", update); + }; + }, []); + return online; +} diff --git a/src/lib/route-modules.ts b/src/lib/route-modules.ts index 58d4620a0..d07253de3 100644 --- a/src/lib/route-modules.ts +++ b/src/lib/route-modules.ts @@ -20,6 +20,9 @@ export const routeLoaders = { "/login": cacheLoader(() => import("@/pages/LoginPage")), "/share/:code": cacheLoader(() => import("@/pages/SharedPage")), "/app": cacheLoader(() => import("@/pages/HomePage")), + "/app/workspaces": cacheLoader(() => import("@/pages/WorkspacesPage")), + "/app/workspaces/invite": cacheLoader(() => import("@/pages/WorkspaceInvitePage")), + "/app/workspaces/:workspaceId": cacheLoader(() => import("@/pages/WorkspacePage")), "/app/generate": cacheLoader(() => import("@/pages/GeneratePage")), "/app/attack": cacheLoader(() => import("@/pages/AttackPage")), "/app/brainstorm": cacheLoader(() => import("@/pages/BrainstormPage")), diff --git a/src/lib/supabase/env.ts b/src/lib/supabase/env.ts index 7213e5d31..a5061c540 100644 --- a/src/lib/supabase/env.ts +++ b/src/lib/supabase/env.ts @@ -1,41 +1,67 @@ -/** - * Runtime environment resolver for Supabase. - * Reads localStorage to determine whether to use prod or staging credentials. - * Only admin users should toggle this via the Settings → Developer panel. - */ +/** Runtime environment resolver for Supabase. */ -export type SupabaseEnv = 'production' | 'staging'; +export type SupabaseEnv = "local" | "production" | "staging"; -const STORAGE_KEY = 'debate-supabase-env'; +type EnvironmentInput = { + VITE_LOCAL_PREVIEW?: string; + VITE_LOCAL_SUPABASE_ANON_KEY?: string; + VITE_LOCAL_SUPABASE_URL?: string; +}; + +type StorageReader = { getItem(key: string): string | null }; +type SupabaseConfig = { anonKey: string; env: SupabaseEnv; url: string }; -const ENVS: Record = { +const STORAGE_KEY = "debate-supabase-env"; + +const ENVS: Record, { anonKey: string; url: string }> = { production: { - url: 'https://uziwdtkzofptihahfooo.supabase.co', - anonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InV6aXdkdGt6b2ZwdGloYWhmb29vIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njk3MDM0MzAsImV4cCI6MjA4NTI3OTQzMH0.caJvRcwlc5w_xGO_HpYxDobmibLbboa_kB2M1IjO3jw', + url: "https://uziwdtkzofptihahfooo.supabase.co", + anonKey: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InV6aXdkdGt6b2ZwdGloYWhmb29vIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njk3MDM0MzAsImV4cCI6MjA4NTI3OTQzMH0.caJvRcwlc5w_xGO_HpYxDobmibLbboa_kB2M1IjO3jw", }, staging: { - url: 'https://brifeyypjauyusmkptkx.supabase.co', - anonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImJyaWZleXlwamF1eXVzbWtwdGt4Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzkyNTI5NjEsImV4cCI6MjA5NDgyODk2MX0.xKYcvponjw5b_ouPhJDnSBBB1CUSw1Jpz35zGiMmH6A', + url: "https://brifeyypjauyusmkptkx.supabase.co", + anonKey: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImJyaWZleXlwamF1eXVzbWtwdGt4Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzkyNTI5NjEsImV4cCI6MjA5NDgyODk2MX0.xKYcvponjw5b_ouPhJDnSBBB1CUSw1Jpz35zGiMmH6A", }, }; -export function getActiveEnv(): SupabaseEnv { - try { - const stored = localStorage.getItem(STORAGE_KEY); - if (stored === 'staging') return 'staging'; - } catch { /* SSR or restricted storage */ } - return 'production'; -} +export function resolveSupabaseConfig(environment: EnvironmentInput, storage: StorageReader): SupabaseConfig { + if (environment.VITE_LOCAL_PREVIEW === "true") { + const url = environment.VITE_LOCAL_SUPABASE_URL?.trim(); + const anonKey = environment.VITE_LOCAL_SUPABASE_ANON_KEY?.trim(); + if (!url || !anonKey) throw new Error("Local preview configuration is incomplete"); -export function setActiveEnv(env: SupabaseEnv): void { - if (env === 'production') { - localStorage.removeItem(STORAGE_KEY); - } else { - localStorage.setItem(STORAGE_KEY, env); + const parsedUrl = new URL(url); + if (Object.values(ENVS).some((remote) => remote.url === parsedUrl.origin)) { + throw new Error("Local preview cannot use a remote Supabase project"); + } + return { anonKey, env: "local", url: parsedUrl.href.replace(/\/$/, "") }; } + + return storage.getItem(STORAGE_KEY) === "staging" + ? { ...ENVS.staging, env: "staging" } + : { ...ENVS.production, env: "production" }; } -const active = getActiveEnv(); +const storageReader: StorageReader = { + getItem(key) { + try { + return localStorage.getItem(key); + } catch { + return null; + } + }, +}; +const active = resolveSupabaseConfig(import.meta.env, storageReader); + +export function getActiveEnv(): SupabaseEnv { + return active.env; +} + +export function setActiveEnv(env: Exclude): void { + if (active.env === "local") return; + if (env === "production") localStorage.removeItem(STORAGE_KEY); + else localStorage.setItem(STORAGE_KEY, env); +} -export const SUPABASE_URL = ENVS[active].url; -export const SUPABASE_ANON_KEY = ENVS[active].anonKey; +export const SUPABASE_URL = active.url; +export const SUPABASE_ANON_KEY = active.anonKey; diff --git a/src/lib/workspace/client.ts b/src/lib/workspace/client.ts new file mode 100644 index 000000000..9b8b65ed4 --- /dev/null +++ b/src/lib/workspace/client.ts @@ -0,0 +1,453 @@ +import { z } from "zod"; +import { supabase } from "@/lib/supabase/client"; +import { COLLABORATION_URL } from "@/lib/workspace/collaboration-config"; +import { + deserializeProposal, + serializeProposal, + type QuoteAnchor, + type WorkspaceProposal, +} from "@/lib/workspace/review"; + +const workspaceSummarySchema = z.object({ + id: z.string().uuid(), + motion: z.string(), + status: z.enum(["active", "archived", "deleted"]), + title: z.string().nullable(), + updated_at: z.string(), +}); + +export type WorkspaceSummary = z.infer; + +const workspaceSchema = z.object({ + access_epoch: z.number().int().nonnegative(), + id: z.string().uuid(), + motion: z.string(), + owner_id: z.string().uuid(), + pending_owner_id: z.string().uuid().nullable(), + revision: z.number().int().nonnegative(), + status: z.enum(["active", "archived", "deleted"]), + title: z.string().nullable(), +}); + +const workspaceMemberSchema = z.object({ + joined_at: z.string(), + user_id: z.string().uuid(), +}); + +const workspaceDocumentSchema = z.object({ + assignee_id: z.string().uuid().nullable(), + document_epoch: z.number().int().nonnegative(), + durable_sequence: z.number().int().nonnegative(), + id: z.string().uuid(), + position: z.number().int().nonnegative(), + side: z.enum(["pro", "con"]), + state: z.enum(["active", "archived", "trashed"]), + title: z.string(), +}); + +export type WorkspaceDocument = z.infer; + +export type WorkspaceBundle = { + workspace: z.infer; + members: z.infer[]; + documents: z.infer[]; +}; + +const workspaceVersionSchema = z.object({ + created_at: z.string(), + durable_sequence: z.number().int().nonnegative(), + id: z.string().uuid(), + kind: z.enum(["automatic", "named", "pre_restore"]), + name: z.string().nullable(), +}); + +export type WorkspaceVersion = z.infer; + +const workspaceVersionPreviewSchema = z.object({ + current_sequence: z.number().int().nonnegative(), + snapshot_base64: z.string(), +}); + +export type WorkspaceVersionPreview = { + currentSequence: number; + snapshot: Uint8Array; +}; + +function decodeBase64(value: string) { + const binary = atob(value.replace(/\s/g, "")); + return Uint8Array.from(binary, (character) => character.charCodeAt(0)); +} + +export async function listWorkspaces(): Promise { + const { data, error } = await supabase + .from("workspaces") + .select("id,motion,status,title,updated_at") + .order("updated_at", { ascending: false }); + if (error) throw error; + return workspaceSummarySchema.array().parse(data ?? []); +} + +export async function getWorkspaceBundle(workspaceId: string): Promise { + const [workspaceResult, membersResult, documentsResult] = await Promise.all([ + supabase + .from("workspaces") + .select("access_epoch,id,motion,owner_id,pending_owner_id,revision,status,title") + .eq("id", workspaceId) + .single(), + supabase + .from("workspace_members") + .select("joined_at,user_id") + .eq("workspace_id", workspaceId), + supabase + .from("workspace_documents") + .select("assignee_id,document_epoch,durable_sequence,id,position,side,state,title") + .eq("workspace_id", workspaceId), + ]); + + if (workspaceResult.error) throw workspaceResult.error; + if (membersResult.error) throw membersResult.error; + if (documentsResult.error) throw documentsResult.error; + + return { + workspace: workspaceSchema.parse(workspaceResult.data), + members: workspaceMemberSchema.array().parse(membersResult.data ?? []), + documents: workspaceDocumentSchema.array().parse(documentsResult.data ?? []) + .sort((left, right) => left.position - right.position), + }; +} + +export async function listWorkspaceVersions(documentId: string) { + const { data, error } = await supabase + .from("workspace_versions") + .select("created_at,durable_sequence,id,kind,name") + .eq("document_id", documentId) + .order("created_at", { ascending: false }); + if (error) throw error; + return workspaceVersionSchema.array().parse(data ?? []); +} + +export async function createWorkspaceVersion(input: { + documentId: string; + name: string | null; + workspaceId: string; +}) { + const current = await supabase + .from("workspace_documents") + .select("durable_sequence") + .eq("id", input.documentId) + .single(); + if (current.error) throw current.error; + const sequence = z.object({ durable_sequence: z.number().int().nonnegative() }) + .parse(current.data).durable_sequence; + const { data, error } = await supabase.rpc("create_workspace_version", { + p_document_id: input.documentId, + p_expected_sequence: sequence, + p_name: input.name, + p_request_id: crypto.randomUUID(), + p_workspace_id: input.workspaceId, + }); + if (error) throw error; + return z.string().uuid().parse(data); +} + +export async function getWorkspaceVersionPreview(input: { + documentId: string; + versionId: string; + workspaceId: string; +}): Promise { + const { data, error } = await supabase.rpc("get_workspace_version_snapshot", { + p_document_id: input.documentId, + p_version_id: input.versionId, + p_workspace_id: input.workspaceId, + }); + if (error) throw error; + const preview = workspaceVersionPreviewSchema.parse(data?.[0]); + return { + currentSequence: preview.current_sequence, + snapshot: decodeBase64(preview.snapshot_base64), + }; +} + +export async function restoreWorkspaceVersion(input: { + documentId: string; + previewedSequence: number; + versionId: string; + workspaceId: string; +}) { + const { data, error } = await supabase.rpc("restore_workspace_version", { + p_document_id: input.documentId, + p_previewed_sequence: input.previewedSequence, + p_request_id: crypto.randomUUID(), + p_version_id: input.versionId, + p_workspace_id: input.workspaceId, + }); + if (error) throw error; + return z.number().int().nonnegative().parse(data); +} + +export async function createWorkspaceDocument(input: { + duplicateDocumentId?: string; + expectedRevision: number; + side: "pro" | "con"; + title: string; + workspaceId: string; +}) { + const { data, error } = await supabase.rpc("create_workspace_document", { + p_duplicate_document_id: input.duplicateDocumentId ?? null, + p_expected_revision: input.expectedRevision, + p_request_id: crypto.randomUUID(), + p_side: input.side, + p_title: input.title, + p_workspace_id: input.workspaceId, + }); + if (error) throw error; + return z.object({ document_id: z.string().uuid(), revision: z.number().int() }).parse(data?.[0]); +} + +export async function updateWorkspaceDocument(input: { + assigneeChanged?: boolean; + assigneeId?: string | null; + documentId: string; + expectedRevision: number; + orderedIds?: string[]; + side?: "pro" | "con"; + state?: "active" | "archived" | "trashed"; + title?: string; + workspaceId: string; +}) { + const { data, error } = await supabase.rpc("update_workspace_document", { + p_assignee_changed: input.assigneeChanged ?? false, + p_assignee_id: input.assigneeChanged ? input.assigneeId ?? null : null, + p_document_id: input.documentId, + p_expected_revision: input.expectedRevision, + p_ordered_ids: input.orderedIds ?? null, + p_request_id: crypto.randomUUID(), + p_side: input.side ?? null, + p_state: input.state ?? null, + p_title: input.title ?? null, + p_workspace_id: input.workspaceId, + }); + if (error) throw error; + return z.number().int().nonnegative().parse(data); +} + +export async function changeWorkspaceState(input: { + action: "archive" | "reopen" | "delete" | "restore"; + expectedRevision: number; + workspaceId: string; +}) { + const { data, error } = await supabase.rpc("change_workspace_state", { + p_action: input.action, + p_expected_revision: input.expectedRevision, + p_request_id: crypto.randomUUID(), + p_workspace_id: input.workspaceId, + }); + if (error) throw error; + return z.number().int().nonnegative().parse(data); +} + +export async function changeWorkspaceAccess(input: { + action: "remove" | "propose_transfer" | "accept_transfer"; + expectedRevision: number; + targetUserId: string; + workspaceId: string; +}) { + const { data, error } = await supabase.rpc("change_workspace_access", { + p_action: input.action, + p_expected_revision: input.expectedRevision, + p_request_id: crypto.randomUUID(), + p_target_user_id: input.targetUserId, + p_workspace_id: input.workspaceId, + }); + if (error) throw error; + return z.number().int().nonnegative().parse(data); +} + +export async function removeWorkspaceMember(input: { + expectedRevision: number; + requestId?: string; + targetUserId: string; + token: string; + workspaceId: string; +}) { + if (!COLLABORATION_URL) throw new Error("Collaboration service unavailable"); + const url = new URL(COLLABORATION_URL); + url.protocol = url.protocol === "wss:" ? "https:" : "http:"; + url.pathname = "/access/remove"; + url.search = ""; + const requestId = input.requestId ?? crypto.randomUUID(); + const request = () => fetch(url, { + body: JSON.stringify({ + expectedRevision: input.expectedRevision, + requestId, + targetUserId: input.targetUserId, + workspaceId: input.workspaceId, + }), + headers: { + Authorization: `Bearer ${input.token}`, + "Content-Type": "application/json", + }, + method: "POST", + }); + let response: Response; + try { + response = await request(); + } catch { + response = await request(); + } + if (!response.ok) throw new Error("Workspace unavailable"); + return z.object({ revision: z.number().int().nonnegative() }).parse(await response.json()).revision; +} + +const proposalRowSchema = z.object({ payload: z.unknown() }); + +export async function listWorkspaceProposals(documentId: string) { + const { data, error } = await supabase + .from("workspace_proposals") + .select("payload") + .eq("document_id", documentId) + .order("created_at", { ascending: false }); + if (error) throw error; + return proposalRowSchema.array().parse(data ?? []).map(({ payload }) => deserializeProposal(payload)); +} + +export async function saveWorkspaceProposal(input: { + documentId: string; + proposal: WorkspaceProposal; + workspaceId: string; +}) { + const current = await supabase + .from("workspace_documents") + .select("durable_sequence") + .eq("id", input.documentId) + .single(); + if (current.error) throw current.error; + const sequence = z.object({ durable_sequence: z.number().int().nonnegative() }) + .parse(current.data).durable_sequence; + const { data, error } = await supabase.rpc("create_workspace_proposal", { + p_document_epoch: input.proposal.documentEpoch, + p_document_id: input.documentId, + p_expected_sequence: sequence, + p_payload: serializeProposal(input.proposal), + p_proposal_id: input.proposal.id, + p_workspace_id: input.workspaceId, + }); + if (error) throw error; + return z.string().uuid().parse(data); +} + +export async function decideWorkspaceProposal(input: { + decision: "accepted" | "rejected"; + documentId: string; + epoch: number; + hunkId: string; + proposalId: string; + token: string; +}) { + if (!COLLABORATION_URL) throw new Error("Collaboration service unavailable"); + const url = new URL(COLLABORATION_URL); + url.protocol = url.protocol === "wss:" ? "https:" : "http:"; + url.pathname = "/review/decide"; + url.search = ""; + const response = await fetch(url, { + body: JSON.stringify({ + decision: input.decision, + documentId: input.documentId, + epoch: input.epoch, + hunkId: input.hunkId, + proposalId: input.proposalId, + }), + headers: { Authorization: `Bearer ${input.token}`, "Content-Type": "application/json" }, + method: "POST", + }); + if (!response.ok) throw new Error("Proposal unavailable"); + const result = z.object({ proposal: z.unknown(), sequence: z.number().int().nonnegative() }).parse(await response.json()); + return { proposal: deserializeProposal(result.proposal), sequence: result.sequence }; +} + +const commentSchema = z.object({ + anchor: z.object({ quote: z.string() }).passthrough(), + author_id: z.string().uuid(), + body: z.string(), + created_at: z.string(), + id: z.string().uuid(), + resolved_at: z.string().nullable(), +}); +const replySchema = z.object({ + author_id: z.string().uuid(), + body: z.string(), + comment_id: z.string().uuid(), + created_at: z.string(), + id: z.string().uuid(), +}); + +function serializeAnchor(anchor: QuoteAnchor) { + return { ...anchor, from: Array.from(anchor.from), to: Array.from(anchor.to) }; +} + +export async function listWorkspaceComments(documentId: string, workspaceId: string) { + const [comments, replies] = await Promise.all([ + supabase.from("workspace_comments").select("anchor,author_id,body,created_at,id,resolved_at").eq("document_id", documentId).order("created_at"), + supabase.from("workspace_comment_replies").select("author_id,body,comment_id,created_at,id").eq("workspace_id", workspaceId).order("created_at"), + ]); + if (comments.error) throw comments.error; + if (replies.error) throw replies.error; + const parsedReplies = replySchema.array().parse(replies.data ?? []); + return commentSchema.array().parse(comments.data ?? []).map((comment) => ({ + ...comment, + replies: parsedReplies.filter((reply) => reply.comment_id === comment.id), + })); +} + +export async function createWorkspaceComment(input: { + anchor: QuoteAnchor; + body: string; + documentId: string; + mentionIds: string[]; + workspaceId: string; +}) { + const { data, error } = await supabase.rpc("create_workspace_comment", { + p_anchor: serializeAnchor(input.anchor), + p_body: input.body, + p_comment_id: input.anchor.id, + p_document_id: input.documentId, + p_mention_ids: input.mentionIds, + p_workspace_id: input.workspaceId, + }); + if (error) throw error; + return z.string().uuid().parse(data); +} + +export async function replyWorkspaceComment(workspaceId: string, commentId: string, body: string) { + const { error } = await supabase.rpc("reply_workspace_comment", { + p_body: body, + p_comment_id: commentId, + p_reply_id: crypto.randomUUID(), + p_workspace_id: workspaceId, + }); + if (error) throw error; +} + +export async function resolveWorkspaceComment(workspaceId: string, commentId: string, resolved: boolean) { + const { error } = await supabase.rpc("resolve_workspace_comment", { + p_comment_id: commentId, + p_resolved: resolved, + p_workspace_id: workspaceId, + }); + if (error) throw error; +} + +export async function listWorkspaceNotifications(documentId: string) { + const { data, error } = await supabase + .from("workspace_notifications") + .select("comment_id,created_at,id,read_at") + .eq("document_id", documentId) + .order("created_at", { ascending: false }); + if (error) throw error; + return z.object({ + comment_id: z.string().uuid(), + created_at: z.string(), + id: z.string().uuid(), + read_at: z.string().nullable(), + }).array().parse(data ?? []); +} diff --git a/src/lib/workspace/collaboration-config.ts b/src/lib/workspace/collaboration-config.ts new file mode 100644 index 000000000..2ef9b4c40 --- /dev/null +++ b/src/lib/workspace/collaboration-config.ts @@ -0,0 +1,26 @@ +type CollaborationEnvironment = { + VITE_COLLABORATION_URL?: string; + VITE_LOCAL_PREVIEW?: string; +}; + +export function resolveCollaborationUrl(environment: CollaborationEnvironment) { + const value = environment.VITE_COLLABORATION_URL?.trim(); + if (!value) { + if (environment.VITE_LOCAL_PREVIEW === "true") { + throw new Error("Local collaboration configuration is incomplete"); + } + return null; + } + + const url = new URL(value); + const loopback = url.hostname === "127.0.0.1" || url.hostname === "localhost"; + if (environment.VITE_LOCAL_PREVIEW === "true" && url.protocol !== "wss:" && !(loopback && url.protocol === "ws:")) { + throw new Error("Local collaboration URL must use WSS outside loopback"); + } + if (url.protocol !== "ws:" && url.protocol !== "wss:") { + throw new Error("Collaboration URL must use WebSocket transport"); + } + return url.href.replace(/\/$/, ""); +} + +export const COLLABORATION_URL = resolveCollaborationUrl(import.meta.env); diff --git a/src/lib/workspace/draft-store.ts b/src/lib/workspace/draft-store.ts new file mode 100644 index 000000000..dbe558792 --- /dev/null +++ b/src/lib/workspace/draft-store.ts @@ -0,0 +1,93 @@ +export type WorkspaceDraft = { + documentId: string; + epoch: number; + pendingMutationIds: string[]; + snapshot: Uint8Array; + updatedAt: number; + userId: string; + workspaceId: string; +}; + +const DATABASE = "debatelab-workspace"; +const STORE = "drafts"; + +export function workspaceDraftKey(draft: Pick) { + return `${draft.userId}:${draft.workspaceId}:${draft.documentId}:${draft.epoch}`; +} + +function openDraftDatabase() { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DATABASE, 1); + request.onupgradeneeded = () => { + if (!request.result.objectStoreNames.contains(STORE)) request.result.createObjectStore(STORE); + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error("Draft storage unavailable")); + }); +} + +export async function loadWorkspaceDraft( + identity: Pick, +) { + const database = await openDraftDatabase(); + try { + return await new Promise((resolve, reject) => { + const request = database.transaction(STORE).objectStore(STORE).get(workspaceDraftKey(identity)); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error("Draft storage unavailable")); + }); + } finally { + database.close(); + } +} + +export async function saveWorkspaceDraft(draft: WorkspaceDraft) { + const database = await openDraftDatabase(); + try { + await new Promise((resolve, reject) => { + const transaction = database.transaction(STORE, "readwrite"); + transaction.objectStore(STORE).put(draft, workspaceDraftKey(draft)); + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error ?? new Error("Draft storage unavailable")); + transaction.onabort = () => reject(transaction.error ?? new Error("Draft storage unavailable")); + }); + } finally { + database.close(); + } +} + +export async function listWorkspaceDrafts( + identity: Pick, +) { + const database = await openDraftDatabase(); + try { + const drafts = await new Promise((resolve, reject) => { + const request = database.transaction(STORE).objectStore(STORE).getAll(); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error("Draft storage unavailable")); + }); + return drafts.filter((draft) => + draft.userId === identity.userId && + draft.workspaceId === identity.workspaceId && + draft.documentId === identity.documentId + ); + } finally { + database.close(); + } +} + +export async function deleteWorkspaceDraft( + identity: Pick, +) { + const database = await openDraftDatabase(); + try { + await new Promise((resolve, reject) => { + const transaction = database.transaction(STORE, "readwrite"); + transaction.objectStore(STORE).delete(workspaceDraftKey(identity)); + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error ?? new Error("Draft storage unavailable")); + }); + } finally { + database.close(); + } +} diff --git a/src/lib/workspace/durable-ack.ts b/src/lib/workspace/durable-ack.ts new file mode 100644 index 000000000..ca1450fbb --- /dev/null +++ b/src/lib/workspace/durable-ack.ts @@ -0,0 +1,19 @@ +import { createDecoder, readVarString, readVarUint, readVarUint8Array } from "lib0/decoding"; + +export async function durableMutationFromMessage(message: Uint8Array) { + const decoder = createDecoder(message); + readVarString(decoder); + if (readVarUint(decoder) !== 0) return null; + const syncType = readVarUint(decoder); + if (syncType !== 1 && syncType !== 2) return null; + + const payload = readVarUint8Array(decoder); + const digestInput = new Uint8Array(payload.byteLength); + digestInput.set(payload); + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", digestInput)); + const mutationId = btoa(String.fromCharCode(...digest)) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replace(/=+$/, ""); + return { mutationId, syncType }; +} diff --git a/src/lib/workspace/editor-schema.ts b/src/lib/workspace/editor-schema.ts index 13108c9af..e4f22be57 100644 --- a/src/lib/workspace/editor-schema.ts +++ b/src/lib/workspace/editor-schema.ts @@ -1,19 +1,129 @@ -import { Editor } from "@tiptap/core"; +import { Editor, Extension } from "@tiptap/core"; import Collaboration from "@tiptap/extension-collaboration"; +import CollaborationCaret from "@tiptap/extension-collaboration-caret"; +import type { HocuspocusProvider } from "@hocuspocus/provider"; import type * as Y from "yjs"; +import { Plugin } from "@tiptap/pm/state"; +import { ySyncPluginKey } from "@tiptap/y-tiptap"; +import type { ProposalHunkInput } from "./review"; import { WORKSPACE_CONTENT_FIELD, workspaceSchemaExtensions, } from "./schema"; -export function createWorkspaceEditor(document: Y.Doc): Editor { +export function createWorkspaceEditor( + document: Y.Doc, + options: { + editable?: boolean; + ariaLabel?: string; + provider?: HocuspocusProvider; + isSuggesting?: () => boolean; + onSuggestion?: (hunk: ProposalHunkInput) => void; + user?: { color: string; name: string }; + } = {}, +): Editor { + const collaborationCaret = options.provider && options.user + ? [CollaborationCaret.configure({ provider: options.provider, user: options.user })] + : []; + const suggestionInterceptor = options.isSuggesting && options.onSuggestion + ? [Extension.create({ + name: "workspaceSuggestionInterceptor", + addProseMirrorPlugins() { + let composing = false; + let compositionFrom = 0; + let compositionTo = 0; + let compositionText = ""; + const finishComposition = (eventText = "") => { + const wasComposing = composing; + composing = false; + const text = (compositionText || eventText).normalize("NFC"); + compositionText = ""; + if (!wasComposing || !options.isSuggesting?.()) return; + const hunk: ProposalHunkInput | undefined = text + ? compositionFrom === compositionTo + ? { at: compositionFrom, content: [{ type: "text", text }], id: crypto.randomUUID(), kind: "insert" } + : { content: [{ type: "text", text }], from: compositionFrom, id: crypto.randomUUID(), kind: "format", to: compositionTo } + : compositionFrom === compositionTo + ? undefined + : { from: compositionFrom, id: crypto.randomUUID(), kind: "delete", to: compositionTo }; + if (hunk) queueMicrotask(() => options.onSuggestion?.(hunk)); + }; + return [new Plugin({ + filterTransaction: (transaction, state) => { + if (!options.isSuggesting?.() || !transaction.docChanged || transaction.getMeta(ySyncPluginKey)) { + return true; + } + const from = state.doc.content.findDiffStart(transaction.doc.content); + const ends = state.doc.content.findDiffEnd(transaction.doc.content); + if (from === null || !ends) return true; + const to = ends.a; + const replacementTo = ends.b; + const kind = from === to ? "insert" : from === replacementTo ? "delete" : "format"; + const hunk: ProposalHunkInput = kind === "insert" + ? { at: from, content: transaction.doc.slice(from, replacementTo).content.toJSON(), id: crypto.randomUUID(), kind } + : kind === "delete" + ? { from, id: crypto.randomUUID(), kind, to } + : { content: transaction.doc.slice(from, replacementTo).content.toJSON(), from, id: crypto.randomUUID(), kind, to }; + if (composing || transaction.getMeta("composition") !== undefined) return false; + queueMicrotask(() => options.onSuggestion?.(hunk)); + return false; + }, + props: { + handleDOMEvents: { + compositionend: (_view, rawEvent) => { + finishComposition((rawEvent as CompositionEvent).data); + return false; + }, + compositionstart: (view) => { + composing = Boolean(options.isSuggesting?.()); + compositionFrom = view.state.selection.from; + compositionTo = view.state.selection.to; + compositionText = ""; + return false; + }, + compositionupdate: (_view, rawEvent) => { + compositionText = (rawEvent as CompositionEvent).data ?? compositionText; + return false; + }, + beforeinput: (_view, rawEvent) => { + const event = rawEvent as InputEvent; + if (!options.isSuggesting?.() || !composing) return false; + if (event.inputType === "insertCompositionText") { + compositionText = event.data ?? compositionText; + event.preventDefault(); + return true; + } + if (event.inputType === "insertText" && !event.isComposing) { + finishComposition(event.data ?? ""); + event.preventDefault(); + return true; + } + return false; + }, + }, + }, + })]; + }, + })] + : []; return new Editor({ + content: { type: "doc", content: [] }, + editable: options.editable ?? true, + editorProps: { + attributes: { + "aria-label": options.ariaLabel ?? "Document editor", + class: "min-h-64 px-4 py-3 focus:outline-none", + role: "textbox", + }, + }, extensions: [ ...workspaceSchemaExtensions(), Collaboration.configure({ document, field: WORKSPACE_CONTENT_FIELD, }), + ...collaborationCaret, + ...suggestionInterceptor, ], }); } diff --git a/src/lib/workspace/invite-client.ts b/src/lib/workspace/invite-client.ts new file mode 100644 index 000000000..c006140e2 --- /dev/null +++ b/src/lib/workspace/invite-client.ts @@ -0,0 +1,47 @@ +import { z } from "zod"; +import { supabase } from "@/lib/supabase/client"; + +const codeInviteSchema = z.object({ + code: z.string(), + expiresAt: z.string(), + inviteId: z.string().uuid(), + maxUses: z.number().int().nullable(), +}); + +const redemptionSchema = z.object({ + alreadyMember: z.boolean(), + member: z.literal(true), + workspaceId: z.string().uuid(), +}); + +async function invoke(body: Record, schema: z.ZodType) { + const { data, error } = await supabase.functions.invoke("workspace-invites", { body }); + if (error) throw error; + return schema.parse(data); +} + +export function createCodeInvite(workspaceId: string, inviteId?: string) { + return invoke({ + action: inviteId ? "rotate_code" : "create_code", + ...(inviteId ? { inviteId } : {}), + requestId: crypto.randomUUID(), + workspaceId, + }, codeInviteSchema); +} + +export async function revokeInvite(workspaceId: string, inviteId: string) { + await invoke({ + action: "revoke", + inviteId, + requestId: crypto.randomUUID(), + workspaceId, + }, z.object({ revoked: z.literal(true) })); +} + +export async function sendEmailInvite(workspaceId: string, email: string, requestId: string) { + await invoke({ action: "create_email", email, requestId, workspaceId }, z.object({ accepted: z.literal(true) })); +} + +export function redeemInvite(input: { code?: string; token?: string; requestId: string }) { + return invoke({ action: "redeem", ...input }, redemptionSchema); +} diff --git a/src/lib/workspace/invite-handoff.ts b/src/lib/workspace/invite-handoff.ts new file mode 100644 index 000000000..ce595631c --- /dev/null +++ b/src/lib/workspace/invite-handoff.ts @@ -0,0 +1,29 @@ +const STORAGE_KEY = "debate-workspace-invite-handoff"; +const INVITE_PATH = "/app/workspaces/invite"; +const TOKEN = /^wi_[A-Za-z0-9_-]{32}$/; + +type Storage = Pick & { + getItem(key: string): string | null; + removeItem(key: string): void; + setItem(key: string, value: string): void; +}; + +export function stashWorkspaceInvite( + location: { pathname: string; search: string }, + storage: Storage = sessionStorage, +) { + const token = location.pathname === INVITE_PATH + ? new URLSearchParams(location.search).get("token") + : null; + if (!token || !TOKEN.test(token)) return false; + storage.setItem(STORAGE_KEY, token); + return true; +} + +export function consumeWorkspaceInvite(storage: Storage = sessionStorage) { + const token = storage.getItem(STORAGE_KEY); + storage.removeItem(STORAGE_KEY); + return token && TOKEN.test(token) + ? `${INVITE_PATH}?${new URLSearchParams({ token })}` + : null; +} diff --git a/src/lib/workspace/preferences.ts b/src/lib/workspace/preferences.ts new file mode 100644 index 000000000..9d7239c19 --- /dev/null +++ b/src/lib/workspace/preferences.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; + +const preferenceSchema = z.object({ + documentId: z.string().uuid().nullable(), + side: z.enum(["pro", "con"]), +}); + +export type WorkspacePreference = z.infer; + +function key(userId: string, workspaceId: string) { + return `debatelab:workspace:${userId}:${workspaceId}`; +} + +export function loadWorkspacePreference(userId: string, workspaceId: string): WorkspacePreference | null { + try { + return preferenceSchema.parse(JSON.parse(localStorage.getItem(key(userId, workspaceId)) ?? "null")); + } catch { + return null; + } +} + +export function saveWorkspacePreference(userId: string, workspaceId: string, preference: WorkspacePreference) { + try { + localStorage.setItem(key(userId, workspaceId), JSON.stringify(preference)); + } catch { + // Non-critical preference; document recovery reports its own storage failures. + } +} diff --git a/src/lib/workspace/review.ts b/src/lib/workspace/review.ts index 666c709f7..c7b921df5 100644 --- a/src/lib/workspace/review.ts +++ b/src/lib/workspace/review.ts @@ -2,14 +2,19 @@ import type { Editor, JSONContent } from "@tiptap/core"; import { createMappablePosition } from "@tiptap/extension-collaboration"; import { Fragment, Slice } from "@tiptap/pm/model"; import { + prosemirrorJSONToYXmlFragment, relativePositionToAbsolutePosition, + yXmlFragmentToProsemirrorJSON, ySyncPluginKey, } from "@tiptap/y-tiptap"; import * as Y from "yjs"; +import { z } from "zod"; +import { EditorState } from "@tiptap/pm/state"; +import { WORKSPACE_CONTENT_FIELD, workspaceSchema } from "./schema"; export type ProposalHunkStatus = "pending" | "accepted" | "rejected" | "stale"; -type ProposalHunkInput = +export type ProposalHunkInput = | { id: string; kind: "insert"; at: number; content: JSONContent[] } | { id: string; kind: "delete"; from: number; to: number } | { @@ -21,6 +26,8 @@ type ProposalHunkInput = }; export interface ProposalHunk { + absoluteFrom: number; + absoluteTo: number; id: string; kind: ProposalHunkInput["kind"]; status: ProposalHunkStatus; @@ -40,6 +47,45 @@ export interface WorkspaceProposal { hunks: ProposalHunk[]; } +const bytesSchema = z.array(z.number().int().min(0).max(255)).transform((bytes) => Uint8Array.from(bytes)); +const serializedProposalSchema = z.object({ + authorId: z.string().uuid(), + baseStateVector: bytesSchema, + documentEpoch: z.number().int().nonnegative(), + hunks: z.array(z.object({ + contextAfter: z.array(z.unknown()), + contextBefore: z.array(z.unknown()), + absoluteFrom: z.number().int().nonnegative(), + absoluteTo: z.number().int().nonnegative(), + from: bytesSchema, + id: z.string().min(1).max(100), + kind: z.enum(["insert", "delete", "format"]), + original: z.array(z.unknown()), + replacement: z.array(z.unknown()), + status: z.enum(["pending", "accepted", "rejected", "stale"]), + to: bytesSchema, + })).min(1).max(100), + id: z.string().uuid(), +}); + +export type SerializedWorkspaceProposal = ReturnType; + +export function serializeProposal(proposal: WorkspaceProposal) { + return { + ...proposal, + baseStateVector: Array.from(proposal.baseStateVector), + hunks: proposal.hunks.map((hunk) => ({ + ...hunk, + from: Array.from(hunk.from), + to: Array.from(hunk.to), + })), + }; +} + +export function deserializeProposal(input: unknown): WorkspaceProposal { + return serializedProposalSchema.parse(input) as WorkspaceProposal; +} + export interface QuoteAnchor { id: string; documentEpoch: number; @@ -65,7 +111,12 @@ function documentFor(editor: Editor): Y.Doc { } function sameContent(left: JSONContent[], right: JSONContent[]) { - return JSON.stringify(left) === JSON.stringify(right); + const canonical = (value: unknown): unknown => Array.isArray(value) + ? value.map(canonical) + : value && typeof value === "object" + ? Object.fromEntries(Object.entries(value).sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)).map(([key, item]) => [key, canonical(item)])) + : value; + return JSON.stringify(canonical(left)) === JSON.stringify(canonical(right)); } function resolveAnchors(editor: Editor, fromAnchor: Uint8Array, toAnchor: Uint8Array) { @@ -162,6 +213,8 @@ export function createProposal({ input.kind === "delete" ? [] : normalizedContent(editor, input.content); return { + absoluteFrom: from, + absoluteTo: to, id: input.id, kind: input.kind, status: "pending", @@ -184,6 +237,62 @@ export function createProposal({ }; } +export function acceptProposalHunkInDocument({ + document, + documentEpoch, + hunkId, + proposal, +}: { + document: Y.Doc; + documentEpoch: number; + hunkId: string; + proposal: WorkspaceProposal; +}) { + const hunk = proposal.hunks.find(({ id }) => id === hunkId); + if (!hunk || hunk.status !== "pending") throw new Error("Proposal unavailable"); + const state = EditorState.create({ + doc: workspaceSchema.nodeFromJSON( + yXmlFragmentToProsemirrorJSON(document.getXmlFragment(WORKSPACE_CONTENT_FIELD)), + ), + schema: workspaceSchema, + }); + const { absoluteFrom: from, absoluteTo: to } = hunk; + const documentEnd = state.doc.content.size; + const original = state.doc.slice(from, to).content.toJSON(); + const contextBefore = state.doc.slice(Math.max(0, from - 1), from).content.toJSON(); + const contextAfter = state.doc.slice(to, Math.min(documentEnd, to + 1)).content.toJSON(); + if ( + documentEpoch !== proposal.documentEpoch || + !sameContent(original, hunk.original) || + !sameContent(contextBefore, hunk.contextBefore) || + !sameContent(contextAfter, hunk.contextAfter) + ) { + return withStatuses(proposal, new Set([hunkId]), "stale"); + } + const transaction = state.tr.replace( + from, + to, + new Slice(Fragment.fromJSON(workspaceSchema, hunk.replacement), 0, 0), + ); + prosemirrorJSONToYXmlFragment( + workspaceSchema, + transaction.doc.toJSON(), + document.getXmlFragment(WORKSPACE_CONTENT_FIELD), + ); + return { + ...proposal, + hunks: proposal.hunks.map((candidate) => candidate.id === hunkId + ? { ...candidate, status: "accepted" as const } + : candidate.status === "pending" + ? { + ...candidate, + absoluteFrom: transaction.mapping.map(candidate.absoluteFrom, -1), + absoluteTo: transaction.mapping.map(candidate.absoluteTo, 1), + } + : candidate), + }; +} + export function createProposalAfterComposition({ compositionState, proposal, diff --git a/src/locales/en.json b/src/locales/en.json index 3c826a26e..c07090379 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1,6 +1,7 @@ { "nav": { "home": "Home", + "workspaces": "Workspaces", "generate": "Generate", "attack": "Attack", "brainstorm": "Brainstorm", @@ -26,6 +27,7 @@ }, "navDesc": { "home": "Choose a mode", + "workspaces": "Write cases together", "generate": "Build arguments", "attack": "Find weaknesses", "brainstorm": "Explore ideas", @@ -38,6 +40,158 @@ "flowchart": "Map arguments", "history": "Saved items" }, + "workspaces": { + "title": "Workspaces", + "new": "New workspace", + "motion": "Motion", + "motionPlaceholder": "Schools should ban phones", + "create": "Create workspace", + "creating": "Creating...", + "createFailed": "Workspace creation failed. Try again.", + "yours": "Your workspaces", + "loading": "Loading workspaces...", + "loadFailed": "Workspaces could not be loaded.", + "empty": "Create your first workspace above.", + "active": "Active", + "archived": "Archived", + "deleted": "Deleted", + "all": "All", + "workspaceView": "Workspace view", + "loadingOne": "Opening workspace...", + "unavailable": "Workspace unavailable.", + "editorTitle": "Debate workspace", + "side": "Side", + "proSide": "For", + "conSide": "Against", + "files": "Files", + "assignedTo": "Assigned to {{member}}", + "assigned": "Assigned", + "document": "Document", + "documentEditor": "Document editor", + "workingPanel": "Working panel", + "versionHistory": "Version history", + "versionName": "Version name", + "saveVersion": "Save version", + "preview": "Preview", + "versionPreview": "Version preview", + "restoreVersion": "Restore this version", + "versionSaveFailed": "Version could not be saved.", + "versionPreviewFailed": "Version preview could not be loaded.", + "versionRestoreConflict": "The document changed. Preview the version again before restoring.", + "onlineRequired": "This action needs a connection.", + "updateFailed": "The workspace changed. Reload and try again.", + "archiveWorkspace": "Archive workspace", + "reopenWorkspace": "Reopen workspace", + "fileView": "File view", + "activeFiles": "Active", + "archivedFiles": "Archived", + "trashedFiles": "Trash", + "noFiles": "No files here.", + "newFileName": "New file name", + "addFile": "Add", + "fileName": "File name", + "rename": "Rename", + "moveUp": "Move up", + "moveDown": "Move down", + "duplicate": "Duplicate", + "copySuffix": "copy", + "archiveFile": "Archive", + "trashFile": "Trash", + "restoreFile": "Restore", + "assignee": "Assignee", + "unassigned": "Unassigned", + "invitePeople": "Invite people", + "createCode": "Create join code", + "rotateCode": "Rotate code", + "joinCode": "Join code", + "copy": "Copy", + "revoke": "Revoke", + "email": "Email", + "sendInvite": "Send", + "inviteFailed": "Invitation could not be created.", + "emailInviteSent": "Email invitation sent.", + "emailInviteRetry": "Delivery failed. Retry to continue the same invitation.", + "inviteUnavailable": "Invitation unavailable.", + "joinWorkspace": "Join workspace", + "joining": "Joining...", + "join": "Join", + "members": "Members", + "owner": "Owner", + "removeMember": "Remove", + "removeFailed": "Member could not be removed.", + "editing": "Editing", + "suggesting": "Suggesting", + "saveBeforeSuggesting": "Wait for Saved before suggesting a change.", + "suggestionFailed": "Suggestion could not be saved.", + "comment": "Comment", + "commentText": "Comment text", + "mentionMember": "Mention member", + "noMention": "No mention", + "addComment": "Add comment", + "selectCommentText": "Select text before adding a comment.", + "commentFailed": "Comment could not be saved.", + "review": "Review", + "accept": "Accept", + "reject": "Reject", + "reply": "Reply", + "resolveComment": "Resolve", + "reopenComment": "Reopen", + "noReviewItems": "No review items yet.", + "proposalDecisionFailed": "The change could not be decided.", + "mentionedYou": "A teammate mentioned you in a comment.", + "recovery": "Recovery", + "oldDraft": "An offline draft from before the restore is available.", + "exportDraft": "Export draft", + "discardDraft": "Discard", + "transferOwner": "Make owner", + "acceptOwnership": "Accept ownership", + "transferFailed": "Ownership transfer could not be started.", + "leave": "Leave", + "restoreWorkspace": "Restore workspace", + "deleteWorkspace": "Delete workspace", + "deleteWorkspaceConfirm": "Delete this workspace? It can be restored for 30 days.", + "collaborationUnavailable": "Collaboration service is not configured.", + "openingEditor": "Opening editor...", + "formatting": "Formatting", + "bold": "Bold", + "italic": "Italic", + "heading": "Heading", + "bullets": "Bullets", + "numberedList": "Numbered list", + "table": "Table", + "linkUrl": "Link URL", + "link": "Link", + "undo": "Undo", + "redo": "Redo", + "statusSaving": "Saving", + "statusSaved": "Saved", + "statusOffline": "Offline", + "storageUnavailable": "Local recovery storage is unavailable.", + "memberLabel": "Member {{id}}", + "mobilePane": "Workspace pane", + "filesPane": "Files", + "documentPane": "Document", + "workingPane": "Working panel", + "changePreview": "Proposed change preview", + "changePosition": "Characters {{from}}–{{to}}", + "original": "Original", + "proposed": "Proposed", + "emptyChange": "Empty", + "proposal": { + "insert": "Proposed insertion", + "delete": "Proposed deletion", + "format": "Proposed replacement or formatting", + "pending": "Pending", + "accepted": "Accepted", + "rejected": "Rejected", + "stale": "Detached from current text" + }, + "versionKind": { + "automatic": "Automatic", + "named": "Named", + "pre_restore": "Before restore" + } + }, "common": { "export": "Export", "pdf": "PDF", diff --git a/src/locales/es.json b/src/locales/es.json index edb11107a..8935ddc47 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -1,6 +1,7 @@ { "nav": { "home": "Inicio", + "workspaces": "Espacios", "generate": "Generar", "attack": "Atacar", "brainstorm": "Ideas", @@ -26,6 +27,7 @@ }, "navDesc": { "home": "Elegir un modo", + "workspaces": "Redactar casos en equipo", "generate": "Construir argumentos", "attack": "Encontrar debilidades", "brainstorm": "Explorar ideas", @@ -38,6 +40,158 @@ "flowchart": "Mapear argumentos", "history": "Elementos guardados" }, + "workspaces": { + "title": "Espacios de trabajo", + "new": "Nuevo espacio", + "motion": "Moción", + "motionPlaceholder": "Los colegios deberían prohibir los móviles", + "create": "Crear espacio", + "creating": "Creando...", + "createFailed": "No se pudo crear el espacio. Inténtalo de nuevo.", + "yours": "Tus espacios", + "loading": "Cargando espacios...", + "loadFailed": "No se pudieron cargar los espacios.", + "empty": "Crea tu primer espacio arriba.", + "active": "Activo", + "archived": "Archivado", + "deleted": "Eliminado", + "all": "Todos", + "workspaceView": "Vista de espacios", + "loadingOne": "Abriendo el espacio...", + "unavailable": "Espacio no disponible.", + "editorTitle": "Espacio de debate", + "side": "Lado", + "proSide": "A favor", + "conSide": "En contra", + "files": "Archivos", + "assignedTo": "Asignado a {{member}}", + "assigned": "Asignado", + "document": "Documento", + "documentEditor": "Editor del documento", + "workingPanel": "Panel de trabajo", + "versionHistory": "Historial de versiones", + "versionName": "Nombre de la versión", + "saveVersion": "Guardar versión", + "preview": "Vista previa", + "versionPreview": "Vista previa de la versión", + "restoreVersion": "Restaurar esta versión", + "versionSaveFailed": "No se pudo guardar la versión.", + "versionPreviewFailed": "No se pudo cargar la vista previa.", + "versionRestoreConflict": "El documento cambió. Vuelve a previsualizar la versión antes de restaurarla.", + "onlineRequired": "Esta acción necesita conexión.", + "updateFailed": "El espacio cambió. Recarga e inténtalo de nuevo.", + "archiveWorkspace": "Archivar espacio", + "reopenWorkspace": "Reabrir espacio", + "fileView": "Vista de archivos", + "activeFiles": "Activos", + "archivedFiles": "Archivados", + "trashedFiles": "Papelera", + "noFiles": "No hay archivos aquí.", + "newFileName": "Nombre del archivo nuevo", + "addFile": "Añadir", + "fileName": "Nombre del archivo", + "rename": "Renombrar", + "moveUp": "Subir", + "moveDown": "Bajar", + "duplicate": "Duplicar", + "copySuffix": "copia", + "archiveFile": "Archivar", + "trashFile": "Mover a la papelera", + "restoreFile": "Restaurar", + "assignee": "Responsable", + "unassigned": "Sin asignar", + "invitePeople": "Invitar personas", + "createCode": "Crear código de acceso", + "rotateCode": "Cambiar código", + "joinCode": "Código de acceso", + "copy": "Copiar", + "revoke": "Revocar", + "email": "Correo electrónico", + "sendInvite": "Enviar", + "inviteFailed": "No se pudo crear la invitación.", + "emailInviteSent": "Invitación enviada por correo.", + "emailInviteRetry": "La entrega falló. Reintenta para continuar la misma invitación.", + "inviteUnavailable": "Invitación no disponible.", + "joinWorkspace": "Unirse al espacio", + "joining": "Uniéndose...", + "join": "Unirse", + "members": "Miembros", + "owner": "Propietario", + "removeMember": "Eliminar", + "removeFailed": "No se pudo eliminar al miembro.", + "editing": "Editando", + "suggesting": "Sugiriendo", + "saveBeforeSuggesting": "Espera a que aparezca Guardado antes de sugerir un cambio.", + "suggestionFailed": "No se pudo guardar la sugerencia.", + "comment": "Comentar", + "commentText": "Texto del comentario", + "mentionMember": "Mencionar miembro", + "noMention": "Sin mención", + "addComment": "Añadir comentario", + "selectCommentText": "Selecciona texto antes de añadir un comentario.", + "commentFailed": "No se pudo guardar el comentario.", + "review": "Revisión", + "accept": "Aceptar", + "reject": "Rechazar", + "reply": "Responder", + "resolveComment": "Resolver", + "reopenComment": "Reabrir", + "noReviewItems": "Aún no hay elementos de revisión.", + "proposalDecisionFailed": "No se pudo decidir el cambio.", + "mentionedYou": "Un compañero te mencionó en un comentario.", + "recovery": "Recuperación", + "oldDraft": "Hay un borrador sin conexión anterior a la restauración.", + "exportDraft": "Exportar borrador", + "discardDraft": "Descartar", + "transferOwner": "Hacer propietario", + "acceptOwnership": "Aceptar propiedad", + "transferFailed": "No se pudo iniciar la transferencia de propiedad.", + "leave": "Salir", + "restoreWorkspace": "Restaurar espacio", + "deleteWorkspace": "Eliminar espacio", + "deleteWorkspaceConfirm": "¿Eliminar este espacio? Se puede restaurar durante 30 días.", + "collaborationUnavailable": "El servicio de colaboración no está configurado.", + "openingEditor": "Abriendo editor...", + "formatting": "Formato", + "bold": "Negrita", + "italic": "Cursiva", + "heading": "Encabezado", + "bullets": "Viñetas", + "numberedList": "Lista numerada", + "table": "Tabla", + "linkUrl": "URL del enlace", + "link": "Enlace", + "undo": "Deshacer", + "redo": "Rehacer", + "statusSaving": "Guardando", + "statusSaved": "Guardado", + "statusOffline": "Sin conexión", + "storageUnavailable": "El almacenamiento de recuperación local no está disponible.", + "memberLabel": "Miembro {{id}}", + "mobilePane": "Panel del espacio", + "filesPane": "Archivos", + "documentPane": "Documento", + "workingPane": "Panel", + "changePreview": "Vista previa del cambio propuesto", + "changePosition": "Caracteres {{from}}–{{to}}", + "original": "Original", + "proposed": "Propuesto", + "emptyChange": "Vacío", + "proposal": { + "insert": "Inserción propuesta", + "delete": "Eliminación propuesta", + "format": "Reemplazo o formato propuesto", + "pending": "Pendiente", + "accepted": "Aceptado", + "rejected": "Rechazado", + "stale": "Desvinculado del texto actual" + }, + "versionKind": { + "automatic": "Automática", + "named": "Nombrada", + "pre_restore": "Antes de restaurar" + } + }, "common": { "export": "Exportar", "pdf": "PDF", diff --git a/src/locales/hu.json b/src/locales/hu.json index 0e6cd0147..f4da98fc4 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -1,6 +1,7 @@ { "nav": { "home": "Kezdőlap", + "workspaces": "Munkaterek", "generate": "Generálás", "attack": "Támadás", "brainstorm": "Ötletelés", @@ -26,6 +27,7 @@ }, "navDesc": { "home": "Mód választása", + "workspaces": "Közös esetírás", "generate": "Érvek építése", "attack": "Gyengeségek keresése", "brainstorm": "Ötletek felfedezése", @@ -38,6 +40,158 @@ "flowchart": "Érvek térképezése", "history": "Mentett elemek" }, + "workspaces": { + "title": "Munkaterek", + "new": "Új munkatér", + "motion": "Tételmondat", + "motionPlaceholder": "Az iskoláknak be kellene tiltaniuk a mobiltelefonokat", + "create": "Munkatér létrehozása", + "creating": "Létrehozás...", + "createFailed": "A munkatér létrehozása sikertelen. Próbáld újra.", + "yours": "Saját munkaterek", + "loading": "Munkaterek betöltése...", + "loadFailed": "A munkaterek nem tölthetők be.", + "empty": "Hozd létre fent az első munkateredet.", + "active": "Aktív", + "archived": "Archivált", + "deleted": "Törölt", + "all": "Mind", + "workspaceView": "Munkatérnézet", + "loadingOne": "Munkatér megnyitása...", + "unavailable": "A munkatér nem érhető el.", + "editorTitle": "Vitamunkatér", + "side": "Oldal", + "proSide": "Mellette", + "conSide": "Ellene", + "files": "Fájlok", + "assignedTo": "Felelős: {{member}}", + "assigned": "Kiosztva", + "document": "Dokumentum", + "documentEditor": "Dokumentumszerkesztő", + "workingPanel": "Munkapanel", + "versionHistory": "Verzióelőzmények", + "versionName": "Verzió neve", + "saveVersion": "Verzió mentése", + "preview": "Előnézet", + "versionPreview": "Verzió előnézete", + "restoreVersion": "Verzió visszaállítása", + "versionSaveFailed": "A verzió nem menthető.", + "versionPreviewFailed": "A verzió előnézete nem tölthető be.", + "versionRestoreConflict": "A dokumentum megváltozott. Visszaállítás előtt nyisd meg újra az előnézetet.", + "onlineRequired": "Ehhez a művelethez kapcsolat szükséges.", + "updateFailed": "A munkatér megváltozott. Töltsd újra és próbáld meg ismét.", + "archiveWorkspace": "Munkatér archiválása", + "reopenWorkspace": "Munkatér újranyitása", + "fileView": "Fájlnézet", + "activeFiles": "Aktív", + "archivedFiles": "Archivált", + "trashedFiles": "Kuka", + "noFiles": "Itt nincs fájl.", + "newFileName": "Új fájl neve", + "addFile": "Hozzáadás", + "fileName": "Fájl neve", + "rename": "Átnevezés", + "moveUp": "Fel", + "moveDown": "Le", + "duplicate": "Másolat", + "copySuffix": "másolat", + "archiveFile": "Archiválás", + "trashFile": "Kukába", + "restoreFile": "Visszaállítás", + "assignee": "Felelős", + "unassigned": "Nincs kiosztva", + "invitePeople": "Tagok meghívása", + "createCode": "Csatlakozási kód létrehozása", + "rotateCode": "Kód cseréje", + "joinCode": "Csatlakozási kód", + "copy": "Másolás", + "revoke": "Visszavonás", + "email": "E-mail", + "sendInvite": "Küldés", + "inviteFailed": "A meghívó nem hozható létre.", + "emailInviteSent": "Az e-mailes meghívó elküldve.", + "emailInviteRetry": "A kézbesítés sikertelen. Próbáld újra ugyanazzal a meghívóval.", + "inviteUnavailable": "A meghívó nem érhető el.", + "joinWorkspace": "Csatlakozás munkatérhez", + "joining": "Csatlakozás...", + "join": "Csatlakozás", + "members": "Tagok", + "owner": "Tulajdonos", + "removeMember": "Eltávolítás", + "removeFailed": "A tag nem távolítható el.", + "editing": "Szerkesztés", + "suggesting": "Javaslattétel", + "saveBeforeSuggesting": "Várd meg a Mentve állapotot a javaslat előtt.", + "suggestionFailed": "A javaslat nem menthető.", + "comment": "Megjegyzés", + "commentText": "Megjegyzés szövege", + "mentionMember": "Tag megemlítése", + "noMention": "Nincs megemlítés", + "addComment": "Megjegyzés hozzáadása", + "selectCommentText": "Jelölj ki szöveget a megjegyzés előtt.", + "commentFailed": "A megjegyzés nem menthető.", + "review": "Véleményezés", + "accept": "Elfogadás", + "reject": "Elutasítás", + "reply": "Válasz", + "resolveComment": "Lezárás", + "reopenComment": "Újranyitás", + "noReviewItems": "Még nincs véleményezési elem.", + "proposalDecisionFailed": "A módosításról nem lehetett dönteni.", + "mentionedYou": "Egy csapattárs megemlített egy megjegyzésben.", + "recovery": "Helyreállítás", + "oldDraft": "Elérhető egy visszaállítás előtti offline piszkozat.", + "exportDraft": "Piszkozat exportálása", + "discardDraft": "Elvetés", + "transferOwner": "Tulajdonossá tétel", + "acceptOwnership": "Tulajdonjog elfogadása", + "transferFailed": "A tulajdonjog átadása nem indítható el.", + "leave": "Kilépés", + "restoreWorkspace": "Munkatér visszaállítása", + "deleteWorkspace": "Munkatér törlése", + "deleteWorkspaceConfirm": "Törlöd ezt a munkateret? 30 napig visszaállítható.", + "collaborationUnavailable": "Az együttműködési szolgáltatás nincs beállítva.", + "openingEditor": "Szerkesztő megnyitása...", + "formatting": "Formázás", + "bold": "Félkövér", + "italic": "Dőlt", + "heading": "Címsor", + "bullets": "Felsorolás", + "numberedList": "Számozott lista", + "table": "Táblázat", + "linkUrl": "Hivatkozás URL-je", + "link": "Hivatkozás", + "undo": "Visszavonás", + "redo": "Újra", + "statusSaving": "Mentés", + "statusSaved": "Mentve", + "statusOffline": "Kapcsolat nélkül", + "storageUnavailable": "A helyi helyreállítási tár nem érhető el.", + "memberLabel": "Tag {{id}}", + "mobilePane": "Munkatér panel", + "filesPane": "Fájlok", + "documentPane": "Dokumentum", + "workingPane": "Munkapanel", + "changePreview": "A javasolt módosítás előnézete", + "changePosition": "{{from}}–{{to}}. karakter", + "original": "Eredeti", + "proposed": "Javasolt", + "emptyChange": "Üres", + "proposal": { + "insert": "Javasolt beszúrás", + "delete": "Javasolt törlés", + "format": "Javasolt csere vagy formázás", + "pending": "Függőben", + "accepted": "Elfogadva", + "rejected": "Elutasítva", + "stale": "Leválasztva az aktuális szövegről" + }, + "versionKind": { + "automatic": "Automatikus", + "named": "Elnevezett", + "pre_restore": "Visszaállítás előtt" + } + }, "common": { "export": "Exportálás", "pdf": "PDF", diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 3af133898..3f1c02a6e 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -9,6 +9,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Sparkles } from 'lucide-react'; import { useAuth } from '@/lib/supabase/AuthProvider'; import { useToast } from '@/hooks/use-toast'; +import { consumeWorkspaceInvite } from '@/lib/workspace/invite-handoff'; type AuthMode = 'email' | 'demo'; type AuthStatus = 'idle' | 'submitting' | 'code-sent' | 'verifying' | 'success' | 'error'; @@ -38,7 +39,7 @@ export default function LoginPage() { const from = (location.state as { from?: { pathname?: string } } | null)?.from?.pathname || '/app'; useEffect(() => { - if (user) navigate(from, { replace: true }); + if (user) navigate(consumeWorkspaceInvite() ?? from, { replace: true }); }, [user, navigate, from]); useEffect(() => { diff --git a/src/pages/WorkspaceInvitePage.tsx b/src/pages/WorkspaceInvitePage.tsx new file mode 100644 index 000000000..30556904b --- /dev/null +++ b/src/pages/WorkspaceInvitePage.tsx @@ -0,0 +1,56 @@ +import { useCallback, useEffect, useRef, useState, type FormEvent } from "react"; +import { useTranslation } from "react-i18next"; +import { useLocation, useNavigate } from "react-router-dom"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { redeemInvite } from "@/lib/workspace/invite-client"; + +export default function WorkspaceInvitePage() { + const { t } = useTranslation(); + const location = useLocation(); + const navigate = useNavigate(); + const token = useRef(new URLSearchParams(location.search).get("token")); + const started = useRef(false); + const [code, setCode] = useState(""); + const [busy, setBusy] = useState(Boolean(token.current)); + const [error, setError] = useState(null); + + const redeem = useCallback(async (input: { code?: string; token?: string }) => { + setBusy(true); + setError(null); + try { + const result = await redeemInvite({ ...input, requestId: crypto.randomUUID() }); + navigate(`/app/workspaces/${result.workspaceId}`, { replace: true }); + } catch { + setError(t("workspaces.inviteUnavailable", "Invitation unavailable.")); + setBusy(false); + } + }, [navigate, t]); + + useEffect(() => { + if (!token.current || started.current) return; + started.current = true; + window.history.replaceState(window.history.state, "", "/app/workspaces/invite"); + void redeem({ token: token.current }); + }, [redeem]); + + const submit = (event: FormEvent) => { + event.preventDefault(); + if (code.trim()) void redeem({ code }); + }; + + return ( +
+
+

{t("workspaces.joinWorkspace", "Join workspace")}

+
+ + setCode(event.target.value)} /> + +
+ {error &&

{error}

} +
+
+ ); +} diff --git a/src/pages/WorkspacePage.tsx b/src/pages/WorkspacePage.tsx new file mode 100644 index 000000000..aa46276ec --- /dev/null +++ b/src/pages/WorkspacePage.tsx @@ -0,0 +1,410 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useParams } from "react-router-dom"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { WorkspaceEditor, type SaveStatus } from "@/components/features/workspace/WorkspaceEditor"; +import { WorkspaceAccessPanel } from "@/components/features/workspace/WorkspaceAccessPanel"; +import { WorkspaceReviewPanel } from "@/components/features/workspace/WorkspaceReviewPanel"; +import { WorkspaceVersions } from "@/components/features/workspace/WorkspaceVersions"; +import { useAuth } from "@/lib/supabase/AuthProvider"; +import { + changeWorkspaceState, + createWorkspaceDocument, + getWorkspaceBundle, + updateWorkspaceDocument, +} from "@/lib/workspace/client"; +import { loadWorkspacePreference, saveWorkspacePreference } from "@/lib/workspace/preferences"; + +type DocumentView = "active" | "archived" | "trashed"; + +export default function WorkspacePage() { + const { t } = useTranslation(); + const { session, user } = useAuth(); + const { workspaceId = "" } = useParams(); + const queryClient = useQueryClient(); + const [side, setSide] = useState<"pro" | "con">("pro"); + const [view, setView] = useState("active"); + const [mobilePane, setMobilePane] = useState<"files" | "document" | "working">("files"); + const [selectedDocumentId, setSelectedDocumentId] = useState(null); + const [durableSequence, setDurableSequence] = useState(0); + const [editorStatus, setEditorStatus] = useState("Saving"); + const [newTitle, setNewTitle] = useState(""); + const [renameTitle, setRenameTitle] = useState(""); + const [busy, setBusy] = useState(false); + const [operationError, setOperationError] = useState(null); + const [preferenceLoaded, setPreferenceLoaded] = useState(false); + const workspace = useQuery({ + queryKey: ["workspace", user?.id, workspaceId], + queryFn: () => getWorkspaceBundle(workspaceId), + enabled: Boolean(user && workspaceId), + refetchInterval: 5_000, + }); + const documents = useMemo( + () => workspace.data?.documents.filter((document) => document.side === side && document.state === view) ?? [], + [side, view, workspace.data?.documents], + ); + const selectedDocument = documents.find((document) => document.id === selectedDocumentId) ?? documents[0]; + + useEffect(() => { + if (user && workspaceId && !preferenceLoaded) { + const preference = loadWorkspacePreference(user.id, workspaceId); + if (preference) { + setSide(preference.side); + setSelectedDocumentId(preference.documentId); + } + setPreferenceLoaded(true); + } + }, [preferenceLoaded, user, workspaceId]); + + useEffect(() => { + if (!preferenceLoaded || workspace.isLoading || !workspace.data) return; + if (!selectedDocumentId || !documents.some(({ id }) => id === selectedDocumentId)) { + setSelectedDocumentId(documents[0]?.id ?? null); + } + }, [documents, preferenceLoaded, selectedDocumentId, workspace.data, workspace.isLoading]); + + useEffect(() => { + if (preferenceLoaded && user && workspaceId && view === "active") { + saveWorkspacePreference(user.id, workspaceId, { documentId: selectedDocument?.id ?? null, side }); + } + }, [preferenceLoaded, selectedDocument?.id, side, user, view, workspaceId]); + + useEffect(() => { + setDurableSequence(selectedDocument?.durable_sequence ?? 0); + }, [selectedDocument?.durable_sequence]); + + useEffect(() => { + setEditorStatus("Saving"); + }, [selectedDocument?.id]); + + useEffect(() => { + setRenameTitle(selectedDocument?.title ?? ""); + }, [selectedDocument?.id, selectedDocument?.title]); + + const refresh = async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["workspace", user?.id, workspaceId] }), + queryClient.invalidateQueries({ queryKey: ["workspaces", user?.id] }), + ]); + }; + + const run = async (operation: () => Promise) => { + if (!navigator.onLine || busy) { + setOperationError(t("workspaces.onlineRequired", "This action needs a connection.")); + return; + } + setBusy(true); + setOperationError(null); + try { + await operation(); + await refresh(); + } catch { + setOperationError(t("workspaces.updateFailed", "The workspace changed. Reload and try again.")); + } finally { + setBusy(false); + } + }; + + const reorder = (offset: -1 | 1) => { + if (!workspace.data || !selectedDocument) return; + const active = workspace.data.documents + .filter((document) => document.side === selectedDocument.side && document.state === "active") + .sort((left, right) => left.position - right.position); + const from = active.findIndex(({ id }) => id === selectedDocument.id); + const to = from + offset; + if (from < 0 || to < 0 || to >= active.length) return; + [active[from], active[to]] = [active[to], active[from]]; + void run(() => updateWorkspaceDocument({ + documentId: selectedDocument.id, + expectedRevision: workspace.data!.workspace.revision, + orderedIds: active.map(({ id }) => id), + workspaceId, + })); + }; + + if (workspace.isLoading) { + return

{t("workspaces.loadingOne", "Opening workspace...")}

; + } + if (workspace.isError || !workspace.data) { + return

{t("workspaces.unavailable", "Workspace unavailable.")}

; + } + + return ( +
+
+
+
+

{workspace.data.workspace.motion}

+

+ {workspace.data.workspace.title ?? t("workspaces.editorTitle", "Debate workspace")} +

+
+
+ {(workspace.data.workspace.status !== "deleted" || workspace.data.workspace.owner_id === user?.id) && } + {workspace.data.workspace.owner_id === user?.id && workspace.data.workspace.status !== "deleted" && ( + + )} +
+
+ {operationError &&

{operationError}

} + +
+ + +
+ +
+ {(["files", "document", "working"] as const).map((pane) => ( + + ))} +
+ +
+ + +
+
+

{selectedDocument?.title}

+ {selectedDocument?.assignee_id && {t("workspaces.assigned", "Assigned")}} +
+ {selectedDocument && workspace.data.workspace.status === "active" && ( +
+
+ setRenameTitle(event.target.value)} /> + +
+
+ {view === "active" ? ( + <> + + + + + + + ) : ( + + )} +
+ {view === "active" && ( +
+ + +
+ )} +
+ )} + {selectedDocument && session && user ? ( + + ) : ( +

+ {t("workspaces.unavailable", "Workspace unavailable.")} +

+ )} +
+ +
+
+
+ ); +} diff --git a/src/pages/WorkspacesPage.tsx b/src/pages/WorkspacesPage.tsx new file mode 100644 index 000000000..256de4584 --- /dev/null +++ b/src/pages/WorkspacesPage.tsx @@ -0,0 +1,142 @@ +import { useRef, useState, type FormEvent } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { Link } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; +import { supabase } from "@/lib/supabase/client"; +import { listWorkspaces } from "@/lib/workspace/client"; +import { useAuth } from "@/lib/supabase/AuthProvider"; + +export default function WorkspacesPage() { + const { t, i18n } = useTranslation(); + const { user } = useAuth(); + const navigate = useNavigate(); + const [motion, setMotion] = useState(""); + const [creating, setCreating] = useState(false); + const [error, setError] = useState(null); + const [view, setView] = useState<"active" | "archived" | "all">("active"); + const requestId = useRef(null); + const workspaces = useQuery({ queryKey: ["workspaces", user?.id], queryFn: listWorkspaces, enabled: Boolean(user) }); + const visibleWorkspaces = workspaces.data?.filter((workspace) => view === "all" || workspace.status === view); + + const createWorkspace = async (event: FormEvent) => { + event.preventDefault(); + const normalizedMotion = motion.trim(); + if (!normalizedMotion || creating) return; + + setCreating(true); + setError(null); + requestId.current ??= crypto.randomUUID(); + const language = ["en", "es", "hu"].includes(i18n.language) + ? i18n.language + : "en"; + const { data, error: createError } = await supabase.rpc("create_workspace", { + p_language: language, + p_motion: normalizedMotion, + p_request_id: requestId.current, + p_title: null, + }); + + if (createError || typeof data !== "string") { + setError(t("workspaces.createFailed", "Workspace creation failed. Try again.")); + setCreating(false); + return; + } + + requestId.current = null; + navigate(`/app/workspaces/${data}`); + }; + + return ( +
+
+

+ {t("workspaces.title", "Workspaces")} +

+ + + {t("workspaces.new", "New workspace")} + + +
+
+ +