From 28093fd4fc179255f630415c4f2c075c4ffefea8 Mon Sep 17 00:00:00 2001 From: Emp1500 Date: Tue, 14 Jul 2026 07:26:03 +0000 Subject: [PATCH 1/9] docs: add architecture-fixes implementation plan --- .../plans/2026-07-14-architecture-fixes.md | 1425 +++++++++++++++++ 1 file changed, 1425 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-14-architecture-fixes.md diff --git a/docs/superpowers/plans/2026-07-14-architecture-fixes.md b/docs/superpowers/plans/2026-07-14-architecture-fixes.md new file mode 100644 index 0000000..3e150d0 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-architecture-fixes.md @@ -0,0 +1,1425 @@ +# Architecture Fixes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close the 8 architecture gaps found in the 2026-07-14 audit (silent mutation failures, duplicated editor pages, missing ownership checks on mutation routes, zero mutation-route test coverage, no shared API client, unbounded list queries, save/pruning coupling, and dead schema/code) without changing any user-facing feature. + +**Architecture:** Introduce one shared client-side data-access layer (`lib/api-client.ts`) and one shared toast UI (`components/ui/toast.tsx`, Base UI primitive) that every mutation site (editor pages, `FlowchartCard`, `VersionDrawer`) is rewired through. Harden the API route layer with explicit per-request ownership checks (defense-in-depth alongside existing RLS) and decouple non-critical cleanup (version pruning) from the critical write path. Cap unbounded list queries. Remove dead code/fields. + +**Tech Stack:** Next.js 16 (App Router), TypeScript, Supabase (`@supabase/ssr`), Zod, `@base-ui/react` (Toast, Button, Dialog primitives already in use), Jest + ts-jest (existing `testEnvironment: 'node'`, no DOM testing library present — do not add one). + +## Global Constraints + +- Node's built-in `fetch`/`Response`/`Request` globals are available in the Jest `node` test environment (confirmed: Node 18+, no polyfill needed). +- No jsdom/testing-library exists in this repo — do not add component-rendering tests. UI-only changes (toast wiring, hook extraction) are verified manually via `npm run dev` in a browser, matching the existing convention that only pure functions and API routes have automated tests. +- Every new/changed API route behavior must have a Jest test using the existing mock-Supabase-client pattern (see `__tests__/flowcharts-get-route.test.ts`). +- Do not change any existing passing test's behavior or the `GET /api/flowcharts/[id]` test file. +- Match existing code style: no semicolons, `const`/arrow functions, 2-space indent, existing import grouping. +- Run `npm run lint` and `npm test` before each commit in this plan. + +--- + +## Task 1: Shared typed API client + +**Files:** +- Create: `lib/api-client.ts` +- Test: `__tests__/api-client.test.ts` + +**Interfaces:** +- Produces: `ApiError` (class, `.message: string`, `.status: number`), and functions `fetchFlowchart(id: string)`, `createFlowchart(input: { title: string; code: string; language: Language })`, `updateFlowchart(id: string, input: { code?: string; title?: string; language?: Language })`, `renameFlowchart(id: string, title: string)`, `deleteFlowchart(id: string)`, `toggleShare(id: string)`, `fetchVersions(flowchartId: string)`, `fetchVersion(flowchartId: string, versionNumber: number)` — all return `Promise` and reject with `ApiError` on non-2xx. +- Consumes: `Flowchart`, `FlowchartVersion`, `Language` types from `@/types` (already defined in `types/index.ts`). + +- [ ] **Step 1: Write the failing test** + +Create `__tests__/api-client.test.ts`: + +```ts +import { fetchFlowchart, ApiError } from '@/lib/api-client' + +describe('api-client error handling', () => { + afterEach(() => { + jest.restoreAllMocks() + }) + + it('throws an ApiError with the server message when the response is not ok', async () => { + jest.spyOn(global, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ error: 'Rate limit exceeded' }), { status: 429 }) + ) + + await expect(fetchFlowchart('fc-1')).rejects.toMatchObject({ + message: 'Rate limit exceeded', + status: 429, + }) + }) + + it('throws a fallback ApiError when the error response has no JSON body', async () => { + jest.spyOn(global, 'fetch').mockResolvedValue(new Response('not json', { status: 500 })) + + await expect(fetchFlowchart('fc-1')).rejects.toBeInstanceOf(ApiError) + await expect(fetchFlowchart('fc-1')).rejects.toMatchObject({ status: 500 }) + }) + + it('resolves with the parsed JSON when the response is ok', async () => { + jest.spyOn(global, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ id: 'fc-1', code: 'x' }), { status: 200 }) + ) + + await expect(fetchFlowchart('fc-1')).resolves.toMatchObject({ id: 'fc-1', code: 'x' }) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -- api-client` +Expected: FAIL with `Cannot find module '@/lib/api-client'` + +- [ ] **Step 3: Write the implementation** + +Create `lib/api-client.ts`: + +```ts +import type { Flowchart, FlowchartVersion, Language } from '@/types' + +export class ApiError extends Error { + status: number + + constructor(message: string, status: number) { + super(message) + this.name = 'ApiError' + this.status = status + } +} + +const jsonHeaders = { 'Content-Type': 'application/json' } + +async function apiFetch(input: string, init?: RequestInit): Promise { + const res = await fetch(input, init) + if (!res.ok) { + const body = await res.json().catch(() => null) + const message = body && typeof body.error === 'string' ? body.error : `Request failed with status ${res.status}` + throw new ApiError(message, res.status) + } + return res.json() as Promise +} + +export function fetchFlowchart(id: string) { + return apiFetch(`/api/flowcharts/${id}`) +} + +export function createFlowchart(input: { title: string; code: string; language: Language }) { + return apiFetch('/api/flowcharts', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify(input), + }) +} + +export function updateFlowchart(id: string, input: { code?: string; title?: string; language?: Language }) { + return apiFetch(`/api/flowcharts/${id}`, { + method: 'PUT', + headers: jsonHeaders, + body: JSON.stringify(input), + }) +} + +export function renameFlowchart(id: string, title: string) { + return apiFetch(`/api/flowcharts/${id}`, { + method: 'PATCH', + headers: jsonHeaders, + body: JSON.stringify({ title }), + }) +} + +export function deleteFlowchart(id: string) { + return apiFetch<{ success: true }>(`/api/flowcharts/${id}`, { method: 'DELETE' }) +} + +export function toggleShare(id: string) { + return apiFetch<{ is_public: boolean; share_id: string | null }>(`/api/flowcharts/${id}/share`, { method: 'POST' }) +} + +export function fetchVersions(flowchartId: string) { + return apiFetch[]>( + `/api/flowcharts/${flowchartId}/versions` + ) +} + +export function fetchVersion(flowchartId: string, versionNumber: number) { + return apiFetch>( + `/api/flowcharts/${flowchartId}/versions?v=${versionNumber}` + ) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test -- api-client` +Expected: PASS (3 tests) + +- [ ] **Step 5: Commit** + +```bash +git add lib/api-client.ts __tests__/api-client.test.ts +git commit -m "feat: add shared typed API client for flowchart endpoints" +``` + +--- + +## Task 2: Toast notification system + +**Files:** +- Create: `components/ui/toast.tsx` +- Modify: `app/layout.tsx` + +**Interfaces:** +- Produces: `ToastProvider` (wraps subtree, must be mounted once near the app root), `Toaster` (renders the portal/viewport, must be mounted *inside* `ToastProvider`), `useToastManager()` (re-exported hook; call `.add({ title, description })` from any client component inside `ToastProvider` to show a toast). +- Consumes: `@base-ui/react/toast` (already a transitive dependency of `@base-ui/react`, confirmed present at `node_modules/@base-ui/react/toast`), `cn` from `@/lib/utils`, `Button` from `@/components/ui/button`. + +- [ ] **Step 1: Create the toast primitive wrapper** + +Create `components/ui/toast.tsx` (same wrapping convention as `components/ui/dialog.tsx`): + +```tsx +"use client" + +import { Toast as ToastPrimitive } from "@base-ui/react/toast" +import { XIcon } from "lucide-react" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" + +function ToastProvider({ ...props }: ToastPrimitive.Provider.Props) { + return +} + +function ToastViewport({ className, ...props }: ToastPrimitive.Viewport.Props) { + return ( + + ) +} + +function ToastList() { + const { toasts } = ToastPrimitive.useToastManager() + return toasts.map(toast => ( + + +
+ + +
+ }> + + +
+
+ )) +} + +function Toaster() { + return ( + + + + + + ) +} + +const useToastManager = ToastPrimitive.useToastManager + +export { ToastProvider, Toaster, useToastManager } +``` + +- [ ] **Step 2: Mount the provider at the app root** + +Modify `app/layout.tsx`: + +```tsx +import type { Metadata } from 'next' +import { Inter, Fira_Code } from 'next/font/google' +import { Analytics } from '@vercel/analytics/next' +import { ToastProvider, Toaster } from '@/components/ui/toast' +import './globals.css' + +const inter = Inter({ subsets: ['latin'], variable: '--font-sans' }) +const firaCode = Fira_Code({ subsets: ['latin'], variable: '--font-mono' }) + +export const metadata: Metadata = { + title: 'Code2Flow', + description: 'Visualize code as flowcharts', +} + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + + {children} + + + + + + ) +} +``` + +- [ ] **Step 3: Verify manually** + +Run: `npm run dev`, open any page, open the browser console and run (as a smoke test) — temporarily add a test button, or skip to Task 3 where real toasts fire on an actual save error. Confirm at minimum: `npm run build` succeeds (catches any type errors from the Base UI `Toast` API surface). + +Run: `npm run build` +Expected: build succeeds with no new type errors. + +- [ ] **Step 4: Commit** + +```bash +git add components/ui/toast.tsx app/layout.tsx +git commit -m "feat: add toast notification system (Base UI Toast primitive)" +``` + +--- + +## Task 3: Unify editor pages and wire error toasts through every mutation site + +**Files:** +- Create: `components/editor/useFlowchartEditor.ts` +- Modify: `app/(protected)/editor/page.tsx` +- Modify: `app/(protected)/editor/[id]/page.tsx` +- Modify: `components/dashboard/FlowchartCard.tsx` +- Modify: `components/editor/VersionDrawer.tsx` + +**Interfaces:** +- Consumes: `lib/api-client.ts` (Task 1), `components/ui/toast.tsx`'s `useToastManager` (Task 2), `codeToMermaid`/`SupportedLanguage` from `@/lib/parser`. +- Produces: `useFlowchartEditor(initialId?: string)` returning `{ id, editorRef, panelRef, loaded, title, language, code, mermaidCode, isPublic, shareId, hasUnsavedChanges, showVersions, renameTrigger, setCode, setSavedCode, setShowVersions, setRenameTrigger, handleSave, handleSaveAs, handleRename, handleDelete, handleToggleShare, handleDownloadPng, handleLanguageChange }`. Both editor pages and nothing else consume this hook. + +- [ ] **Step 1: Create the shared editor hook** + +Create `components/editor/useFlowchartEditor.ts`: + +```ts +'use client' +import { useRef, useState, useCallback, useEffect } from 'react' +import { useRouter } from 'next/navigation' +import type { editor } from 'monaco-editor' +import { toPng } from 'html-to-image' +import { codeToMermaid } from '@/lib/parser' +import type { SupportedLanguage } from '@/lib/parser' +import * as api from '@/lib/api-client' +import { ApiError } from '@/lib/api-client' +import { useToastManager } from '@/components/ui/toast' + +const DEFAULT_CODE: Record = { + javascript: `function greet(name) {\n if (name) {\n return "Hello, " + name;\n } else {\n return "Hello, World!";\n }\n}`, + typescript: `function greet(name: string): string {\n if (name) {\n return \`Hello, \${name}\`;\n } else {\n return "Hello, World!";\n }\n}`, + python: `def greet(name):\n if name:\n return f"Hello, {name}"\n else:\n return "Hello, World!"`, +} + +function errorDescription(err: unknown) { + return err instanceof ApiError ? err.message : 'Please try again.' +} + +export function useFlowchartEditor(initialId?: string) { + const router = useRouter() + const toast = useToastManager() + const editorRef = useRef(null) + const panelRef = useRef(null) + + const [id, setId] = useState(initialId) + const [loaded, setLoaded] = useState(!initialId) + const [title, setTitle] = useState('Untitled') + const [language, setLanguage] = useState('javascript') + const [code, setCode] = useState(initialId ? '' : DEFAULT_CODE.javascript) + const [savedCode, setSavedCode] = useState('') + const [isPublic, setIsPublic] = useState(false) + const [shareId, setShareId] = useState(null) + const [mermaidCode, setMermaidCode] = useState('') + const [showVersions, setShowVersions] = useState(false) + const [renameTrigger, setRenameTrigger] = useState(0) + + useEffect(() => { + if (!initialId) return + api.fetchFlowchart(initialId) + .then(fc => { + setTitle(fc.title); setLanguage(fc.language); setCode(fc.code ?? '') + setSavedCode(fc.code ?? ''); setIsPublic(fc.is_public); setShareId(fc.share_id) + setLoaded(true) + }) + .catch(err => { + setLoaded(true) + toast.add({ title: 'Failed to load flowchart', description: errorDescription(err) }) + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialId]) + + useEffect(() => { + if (!loaded) return + try { setMermaidCode(codeToMermaid(code, language)) } catch {} + }, [code, language, loaded]) + + const hasUnsavedChanges = code !== savedCode + + const handleSave = useCallback(async () => { + try { + if (!id) { + const fc = await api.createFlowchart({ title, code, language }) + setId(fc.id) + setSavedCode(code) + router.replace(`/editor/${fc.id}`) + } else { + await api.updateFlowchart(id, { code, language }) + setSavedCode(code) + } + } catch (err) { + toast.add({ title: 'Save failed', description: errorDescription(err) }) + } + }, [id, title, code, language, router, toast]) + + const handleSaveAs = useCallback(async () => { + const newTitle = prompt('Title for new flowchart:', title + ' (copy)') + if (!newTitle) return + try { + const fc = await api.createFlowchart({ title: newTitle, code, language }) + router.push(`/editor/${fc.id}`) + } catch (err) { + toast.add({ title: 'Save As failed', description: errorDescription(err) }) + } + }, [title, code, language, router, toast]) + + const handleRename = useCallback(async (newTitle: string) => { + const previous = title + setTitle(newTitle) + if (!id) return + try { + await api.renameFlowchart(id, newTitle) + document.title = `${newTitle} — Code2Flow` + } catch (err) { + setTitle(previous) + toast.add({ title: 'Rename failed', description: errorDescription(err) }) + } + }, [id, title, toast]) + + const handleDelete = useCallback(async () => { + if (!id) { router.push('/dashboard'); return } + try { + await api.deleteFlowchart(id) + router.push('/dashboard') + } catch (err) { + toast.add({ title: 'Delete failed', description: errorDescription(err) }) + } + }, [id, router, toast]) + + const handleToggleShare = useCallback(async () => { + if (!id) return + try { + const data = await api.toggleShare(id) + setIsPublic(data.is_public); setShareId(data.share_id) + } catch (err) { + toast.add({ title: 'Share update failed', description: errorDescription(err) }) + } + }, [id, toast]) + + function handleDownloadPng() { + if (!panelRef.current) return + toPng(panelRef.current).then(url => { + const a = document.createElement('a'); a.href = url; a.download = `${title}-flowchart.png`; a.click() + }) + } + + function handleLanguageChange(lang: SupportedLanguage) { + setLanguage(lang) + if (!id) setCode(DEFAULT_CODE[lang]) + } + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); handleSave() } + if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'S') { e.preventDefault(); handleSaveAs() } + if ((e.ctrlKey || e.metaKey) && e.key === 'h') { e.preventDefault(); setShowVersions(v => !v) } + if ((e.ctrlKey || e.metaKey) && e.key === 'd') { e.preventDefault(); handleDownloadPng() } + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [handleSave, handleSaveAs]) + + return { + id, editorRef, panelRef, loaded, title, language, code, mermaidCode, + isPublic, shareId, hasUnsavedChanges, showVersions, renameTrigger, + setCode, setSavedCode, setShowVersions, setRenameTrigger, + handleSave, handleSaveAs, handleRename, handleDelete, handleToggleShare, + handleDownloadPng, handleLanguageChange, + } +} +``` + +- [ ] **Step 2: Rewrite the existing-flowchart editor page to use the hook** + +Replace the full contents of `app/(protected)/editor/[id]/page.tsx`: + +```tsx +'use client' +import { useParams } from 'next/navigation' +import { CodeEditor } from '@/components/editor/CodeEditor' +import { FlowchartPanel } from '@/components/editor/FlowchartPanel' +import { EditorLayout } from '@/components/editor/EditorLayout' +import { EditorToolbar } from '@/components/editor/EditorToolbar' +import { VersionDrawer } from '@/components/editor/VersionDrawer' +import { CommandPalette } from '@/components/command/CommandPalette' +import { useFlowchartEditor } from '@/components/editor/useFlowchartEditor' + +export default function EditFlowchartPage() { + const { id } = useParams<{ id: string }>() + const editor = useFlowchartEditor(id) + + if (!editor.loaded) return
Loading…
+ + return ( +
+ editor.setShowVersions(v => !v)} + renameTrigger={editor.renameTrigger} + /> +
+ { editor.editorRef.current = e }} />} + right={} + /> +
+ {editor.showVersions && ( + editor.setShowVersions(false)} + onRestore={c => { editor.setCode(c); editor.setSavedCode(c) }} /> + )} + editor.setShowVersions(v => !v)} + onLanguageChange={editor.handleLanguageChange} + onRenameStart={() => editor.setRenameTrigger(n => n + 1)} + /> +
+ ) +} +``` + +- [ ] **Step 3: Rewrite the new-flowchart editor page to use the same hook** + +Replace the full contents of `app/(protected)/editor/page.tsx`: + +```tsx +'use client' +import { CodeEditor } from '@/components/editor/CodeEditor' +import { FlowchartPanel } from '@/components/editor/FlowchartPanel' +import { EditorLayout } from '@/components/editor/EditorLayout' +import { EditorToolbar } from '@/components/editor/EditorToolbar' +import { VersionDrawer } from '@/components/editor/VersionDrawer' +import { CommandPalette } from '@/components/command/CommandPalette' +import { useFlowchartEditor } from '@/components/editor/useFlowchartEditor' + +export default function NewEditorPage() { + const editor = useFlowchartEditor() + + return ( +
+ editor.setShowVersions(v => !v)} + renameTrigger={editor.renameTrigger} + /> +
+ { editor.editorRef.current = e }} />} + right={} + /> +
+ {editor.showVersions && ( + editor.setShowVersions(false)} + onRestore={c => { editor.setCode(c); editor.setSavedCode(c) }} /> + )} + editor.setShowVersions(v => !v)} + onLanguageChange={editor.handleLanguageChange} + onRenameStart={() => editor.setRenameTrigger(n => n + 1)} + /> +
+ ) +} +``` + +- [ ] **Step 4: Wire error toasts into `VersionDrawer`'s two hand-rolled fetches** + +Modify `components/editor/VersionDrawer.tsx` — replace the `fetch` calls with the api-client and surface failures: + +```tsx +'use client' +import { useEffect, useState } from 'react' +import { Button } from '@/components/ui/button' +import { Separator } from '@/components/ui/separator' +import type { FlowchartVersion } from '@/types' +import { fetchVersions, fetchVersion, ApiError } from '@/lib/api-client' +import { useToastManager } from '@/components/ui/toast' + +interface Props { + flowchartId: string + onClose: () => void + onRestore: (code: string) => void +} + +export function VersionDrawer({ flowchartId, onClose, onRestore }: Props) { + const toast = useToastManager() + const [versions, setVersions] = useState[]>([]) + const [loading, setLoading] = useState(true) + const [selected, setSelected] = useState(null) + const [preview, setPreview] = useState(null) + const [fetching, setFetching] = useState(false) + + useEffect(() => { + if (!flowchartId) { setLoading(false); return } + fetchVersions(flowchartId) + .then(data => { setVersions(data); setLoading(false) }) + .catch(err => { + setLoading(false) + toast.add({ + title: 'Failed to load version history', + description: err instanceof ApiError ? err.message : 'Please try again.', + }) + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [flowchartId]) + + async function loadPreview(versionNumber: number) { + setFetching(true); setSelected(versionNumber) + try { + const d = await fetchVersion(flowchartId, versionNumber) + setPreview(d.code) + } catch (err) { + toast.add({ + title: 'Failed to load version', + description: err instanceof ApiError ? err.message : 'Please try again.', + }) + } finally { + setFetching(false) + } + } + + function handleRestore() { + if (preview !== null) { onRestore(preview); onClose() } + } + + return ( +
+
+

Version History

+ +
+ + {loading ? ( +

Loading…

+ ) : !versions.length ? ( +

No versions yet. Save to create one.

+ ) : ( +
+ {versions.map(v => ( + + ))} +
+ )} + + {preview !== null && ( +
+ +

+ Preview loaded. Restoring will load this code into the editor as unsaved changes — save to commit. +

+ +
+ )} +
+ ) +} +``` + +- [ ] **Step 5: Wire the dashboard card's delete into the api-client + toast** + +Modify `components/dashboard/FlowchartCard.tsx` — replace the imports and `handleDelete`: + +```tsx +'use client' +import Link from 'next/link' +import { useState, useEffect, useRef } from 'react' +import { useRouter } from 'next/navigation' +import type { Flowchart } from '@/types' +import { Badge } from '@/components/ui/badge' +import { MoreHorizontal } from 'lucide-react' +import { deleteFlowchart, ApiError } from '@/lib/api-client' +import { useToastManager } from '@/components/ui/toast' +``` + +(keep `timeAgo`, `langAccent`, `langBadge` unchanged), then replace `handleDelete`: + +```tsx +export function FlowchartCard({ flowchart }: { flowchart: Flowchart }) { + const router = useRouter() + const toast = useToastManager() + const [menuOpen, setMenuOpen] = useState(false) + const [deleting, setDeleting] = useState(false) + const menuRef = useRef(null) + + useEffect(() => { + if (!menuOpen) return + function handleOutside(e: MouseEvent) { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setMenuOpen(false) + } + } + document.addEventListener('mousedown', handleOutside) + return () => document.removeEventListener('mousedown', handleOutside) + }, [menuOpen]) + + async function handleDelete(e: React.MouseEvent) { + e.preventDefault() + setMenuOpen(false) + if (!confirm(`Delete "${flowchart.title}"?`)) return + setDeleting(true) + try { + await deleteFlowchart(flowchart.id) + router.refresh() + } catch (err) { + setDeleting(false) + toast.add({ + title: 'Delete failed', + description: err instanceof ApiError ? err.message : 'Please try again.', + }) + } + } +``` + +(the JSX return block is unchanged — only the imports, the `toast` hook call, and the body of `handleDelete` change). + +- [ ] **Step 6: Verify manually in the browser** + +Run: `npm run dev`, then in a browser: +1. Go to `/editor` (new flowchart) — confirm Ctrl+K opens the command palette and Ctrl+S saves (this previously didn't work on this route — confirms the drift bug is fixed). +2. Save a flowchart, then go to `/dashboard`, delete it — confirm success still works. +3. Trigger a save failure by opening DevTools → Network → set to "Offline", then press Ctrl+S in the editor — confirm a toast reading "Save failed" appears (previously this failed silently). +4. Re-enable network, confirm normal save/delete/rename/share still work end to end. + +Expected: all four checks pass with no console errors beyond the intentionally-triggered offline failure. + +- [ ] **Step 7: Run lint, type-check, and existing tests** + +Run: `npm run lint && npm test` +Expected: no new lint errors, all existing tests still pass. + +- [ ] **Step 8: Commit** + +```bash +git add components/editor/useFlowchartEditor.ts app/'(protected)'/editor/page.tsx "app/(protected)/editor/[id]/page.tsx" components/editor/VersionDrawer.tsx components/dashboard/FlowchartCard.tsx +git commit -m "refactor: unify editor pages into shared hook, surface mutation failures via toast" +``` + +--- + +## Task 4: Harden mutation-route ownership, decouple version pruning, remove dead service client + +**Files:** +- Modify: `app/api/flowcharts/[id]/route.ts` +- Modify: `app/api/flowcharts/[id]/share/route.ts` +- Modify: `lib/supabase/server.ts` +- Create: `__tests__/helpers/supabase-query-mock.ts` +- Create: `__tests__/flowcharts-put-route.test.ts` +- Create: `__tests__/flowcharts-patch-route.test.ts` +- Create: `__tests__/flowcharts-delete-route.test.ts` +- Create: `__tests__/flowcharts-share-route.test.ts` + +**Interfaces:** +- Produces: `createQueryMock()` in the test helper — returns a chainable **and thenable** mock Supabase query builder: every chain method (`select`, `eq`, `order`, `limit`, `in`, `update`, `insert`, `delete`, `single`) returns the same mock object, and `.queueResult({ data, error })` queues a value that the *next* `await` on the chain resolves to (FIFO), regardless of which method the chain happened to end on. This is necessary because the real PUT handler terminates different query chains on different methods (`.single()`, `.insert()`, `.order()`, `.in()`), unlike the existing `GET` test which only ever terminates on `.single()`. +- Consumes: nothing new — existing `checkRateLimit`/`saveLimit`/`shareLimit` from `@/lib/rate-limit`, `generateShareId` from `@/lib/share`. + +- [ ] **Step 1: Write the test helper (no test framework needed to "fail" first — this is shared infrastructure, not a behavior under test)** + +Create `__tests__/helpers/supabase-query-mock.ts`: + +```ts +type QueryResult = { data: any; error: any } + +export function createQueryMock() { + const queue: QueryResult[] = [] + const mock: any = { + select: jest.fn(() => mock), + eq: jest.fn(() => mock), + order: jest.fn(() => mock), + limit: jest.fn(() => mock), + in: jest.fn(() => mock), + update: jest.fn(() => mock), + insert: jest.fn(() => mock), + delete: jest.fn(() => mock), + single: jest.fn(() => mock), + queueResult(result: QueryResult) { + queue.push(result) + return mock + }, + then(resolve: (value: QueryResult) => void) { + resolve(queue.shift() ?? { data: null, error: null }) + }, + } + return mock +} +``` + +- [ ] **Step 2: Write the failing tests for PUT** + +Create `__tests__/flowcharts-put-route.test.ts`: + +```ts +import { PUT } from '@/app/api/flowcharts/[id]/route' +import { createQueryMock } from './helpers/supabase-query-mock' + +jest.mock('@/lib/rate-limit', () => ({ + saveLimit: {}, + checkRateLimit: jest.fn(async () => ({ success: true, retryAfter: 0 })), +})) + +let flowchartsQuery: ReturnType +let versionsQuery: ReturnType + +jest.mock('@/lib/supabase/server', () => ({ + createClient: jest.fn(async () => ({ + auth: { getUser: jest.fn(async () => ({ data: { user: { id: 'user-1' } }, error: null })) }, + from: (table: string) => (table === 'flowcharts' ? flowchartsQuery : versionsQuery), + })), +})) + +function putRequest(body: object) { + return new Request('http://localhost/api/flowcharts/fc-1', { + method: 'PUT', + body: JSON.stringify(body), + }) +} + +describe('PUT /api/flowcharts/[id]', () => { + beforeEach(() => { + flowchartsQuery = createQueryMock() + versionsQuery = createQueryMock() + }) + + it('returns 404 and skips all writes when the caller does not own the flowchart', async () => { + flowchartsQuery.queueResult({ data: null, error: { code: 'PGRST116' } }) + + const response = await PUT(putRequest({ code: 'x' }), { params: Promise.resolve({ id: 'fc-1' }) }) + + expect(response.status).toBe(404) + expect(versionsQuery.insert).not.toHaveBeenCalled() + }) + + it('still returns 200 with the updated flowchart when pruning old versions fails', async () => { + flowchartsQuery + .queueResult({ data: { id: 'fc-1' }, error: null }) // ownership check + .queueResult({ data: { id: 'fc-1', title: 'T' }, error: null }) // final reload + + versionsQuery + .queueResult({ data: { version_number: 3 }, error: null }) // latest version lookup + .queueResult({ data: null, error: null }) // version insert + .queueResult({ data: null, error: { message: 'boom' } }) // prune list lookup fails + + const response = await PUT(putRequest({ code: 'new code' }), { params: Promise.resolve({ id: 'fc-1' }) }) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body).toMatchObject({ id: 'fc-1', title: 'T' }) + }) +}) +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `npm test -- flowcharts-put-route` +Expected: FAIL — first test fails because PUT currently has no ownership check (never returns 404 for a non-owned id); second test fails because a pruning-list error currently causes a 500 instead of 200. + +- [ ] **Step 4: Implement the PUT handler changes** + +Modify `app/api/flowcharts/[id]/route.ts` — replace the `PUT` function: + +```ts +export async function PUT(request: Request, { params }: Params) { + const { id } = await params + const supabase = await createClient() + const { data: { user }, error: authErr } = await supabase.auth.getUser() + if (authErr || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const { data: owned, error: ownerError } = await supabase + .from('flowcharts').select('id').eq('id', id).eq('user_id', user.id).single() + if (ownerError || !owned) return NextResponse.json({ error: 'Not found' }, { status: 404 }) + + const { success, retryAfter } = await checkRateLimit(saveLimit, user.id) + if (!success) return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429, headers: { 'Retry-After': String(retryAfter) } }) + + const body = await request.json() + const parsed = UpdateFlowchartSchema.safeParse(body) + if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }) + + const { code, ...meta } = parsed.data + + if (Object.keys(meta).length) { + const { error: metaError } = await supabase.from('flowcharts').update(meta).eq('id', id) + if (metaError) return NextResponse.json({ error: 'Failed to update flowchart' }, { status: 500 }) + } + + if (code !== undefined) { + const latestRes = await supabase + .from('flowchart_versions') + .select('version_number') + .eq('flowchart_id', id) + .order('version_number', { ascending: false }) + .limit(1) + .single() + const latest = latestRes.data as { version_number: number } | null + + const nextVersion = (latest?.version_number ?? 0) + 1 + + const { error: versionError } = await supabase.from('flowchart_versions').insert({ + flowchart_id: id, code, version_number: nextVersion, + }) + if (versionError) return NextResponse.json({ error: 'Failed to save flowchart contents' }, { status: 500 }) + + const { data: all, error: listError } = await supabase + .from('flowchart_versions') + .select('id, version_number') + .eq('flowchart_id', id) + .order('version_number', { ascending: true }) + + if (listError) { + console.error('Failed to list versions for pruning (save already succeeded):', listError) + } else if (all && all.length > 50) { + const toDelete = all.slice(0, all.length - 50).map(v => v.id) + const { error: deleteError } = await supabase.from('flowchart_versions').delete().in('id', toDelete) + if (deleteError) console.error('Failed to prune old versions (save already succeeded):', deleteError) + } + } + + const { data: updated, error: reloadError } = await supabase.from('flowcharts').select('*').eq('id', id).single() + if (reloadError) return NextResponse.json({ error: 'Failed to reload flowchart' }, { status: 500 }) + return NextResponse.json(updated) +} +``` + +- [ ] **Step 5: Run tests to verify PUT tests pass** + +Run: `npm test -- flowcharts-put-route` +Expected: PASS (2 tests). Also run `npm test -- flowcharts-get-route` to confirm the existing GET test is untouched and still passes. + +- [ ] **Step 6: Write the failing tests for PATCH and DELETE** + +Create `__tests__/flowcharts-patch-route.test.ts`: + +```ts +import { PATCH } from '@/app/api/flowcharts/[id]/route' +import { createQueryMock } from './helpers/supabase-query-mock' + +let flowchartsQuery: ReturnType + +jest.mock('@/lib/supabase/server', () => ({ + createClient: jest.fn(async () => ({ + auth: { getUser: jest.fn(async () => ({ data: { user: { id: 'user-1' } }, error: null })) }, + from: () => flowchartsQuery, + })), +})) + +function patchRequest(body: object) { + return new Request('http://localhost/api/flowcharts/fc-1', { + method: 'PATCH', + body: JSON.stringify(body), + }) +} + +describe('PATCH /api/flowcharts/[id]', () => { + beforeEach(() => { + flowchartsQuery = createQueryMock() + }) + + it('returns 404 when the caller does not own the flowchart', async () => { + flowchartsQuery.queueResult({ data: null, error: { code: 'PGRST116' } }) + + const response = await PATCH(patchRequest({ title: 'New title' }), { params: Promise.resolve({ id: 'fc-1' }) }) + + expect(response.status).toBe(404) + }) + + it('scopes the update to the authenticated user', async () => { + flowchartsQuery.queueResult({ data: { id: 'fc-1', title: 'New title' }, error: null }) + + await PATCH(patchRequest({ title: 'New title' }), { params: Promise.resolve({ id: 'fc-1' }) }) + + expect(flowchartsQuery.eq).toHaveBeenCalledWith('user_id', 'user-1') + }) +}) +``` + +Create `__tests__/flowcharts-delete-route.test.ts`: + +```ts +import { DELETE } from '@/app/api/flowcharts/[id]/route' +import { createQueryMock } from './helpers/supabase-query-mock' + +let flowchartsQuery: ReturnType + +jest.mock('@/lib/supabase/server', () => ({ + createClient: jest.fn(async () => ({ + auth: { getUser: jest.fn(async () => ({ data: { user: { id: 'user-1' } }, error: null })) }, + from: () => flowchartsQuery, + })), +})) + +describe('DELETE /api/flowcharts/[id]', () => { + beforeEach(() => { + flowchartsQuery = createQueryMock() + }) + + it('returns 404 when the caller does not own the flowchart', async () => { + flowchartsQuery.queueResult({ data: null, error: { code: 'PGRST116' } }) + + const response = await DELETE( + new Request('http://localhost/api/flowcharts/fc-1', { method: 'DELETE' }), + { params: Promise.resolve({ id: 'fc-1' }) } + ) + + expect(response.status).toBe(404) + }) + + it('deletes and returns success when the caller owns the flowchart', async () => { + flowchartsQuery.queueResult({ data: { id: 'fc-1' }, error: null }) + + const response = await DELETE( + new Request('http://localhost/api/flowcharts/fc-1', { method: 'DELETE' }), + { params: Promise.resolve({ id: 'fc-1' }) } + ) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body).toEqual({ success: true }) + expect(flowchartsQuery.eq).toHaveBeenCalledWith('user_id', 'user-1') + }) +}) +``` + +- [ ] **Step 7: Run tests to verify they fail** + +Run: `npm test -- flowcharts-patch-route flowcharts-delete-route` +Expected: FAIL — neither route currently scopes its mutation to `user_id`, and DELETE currently has no way to distinguish "not found" from "success" (it never selects the deleted row back). + +- [ ] **Step 8: Implement the PATCH and DELETE handler changes** + +Modify `app/api/flowcharts/[id]/route.ts` — replace `PATCH` and `DELETE`: + +```ts +export async function PATCH(request: Request, { params }: Params) { + const { id } = await params + const supabase = await createClient() + const { data: { user }, error: authErr } = await supabase.auth.getUser() + if (authErr || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const body = await request.json() + const parsed = RenameSchema.safeParse(body) + if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }) + + const { data, error } = await supabase + .from('flowcharts').update({ title: parsed.data.title }).eq('id', id).eq('user_id', user.id).select().single() + if (error) { + if (error.code === 'PGRST116') return NextResponse.json({ error: 'Not found' }, { status: 404 }) + console.error('PATCH /api/flowcharts/[id] failed:', error) + return NextResponse.json({ error: 'Failed to rename flowchart' }, { status: 500 }) + } + return NextResponse.json(data) +} + +export async function DELETE(_req: Request, { params }: Params) { + const { id } = await params + const supabase = await createClient() + const { data: { user }, error: authErr } = await supabase.auth.getUser() + if (authErr || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const { data, error } = await supabase + .from('flowcharts').delete().eq('id', id).eq('user_id', user.id).select().single() + if (error) { + if (error.code === 'PGRST116') return NextResponse.json({ error: 'Not found' }, { status: 404 }) + console.error('DELETE /api/flowcharts/[id] failed:', error) + return NextResponse.json({ error: 'Failed to delete flowchart' }, { status: 500 }) + } + if (!data) return NextResponse.json({ error: 'Not found' }, { status: 404 }) + return NextResponse.json({ success: true }) +} +``` + +- [ ] **Step 9: Run tests to verify PATCH/DELETE tests pass** + +Run: `npm test -- flowcharts-patch-route flowcharts-delete-route` +Expected: PASS (4 tests) + +- [ ] **Step 10: Write the failing test for the share route** + +Create `__tests__/flowcharts-share-route.test.ts`: + +```ts +import { POST } from '@/app/api/flowcharts/[id]/share/route' +import { createQueryMock } from './helpers/supabase-query-mock' + +jest.mock('@/lib/rate-limit', () => ({ + shareLimit: {}, + checkRateLimit: jest.fn(async () => ({ success: true, retryAfter: 0 })), +})) +jest.mock('@/lib/share', () => ({ generateShareId: () => 'share-xyz' })) + +let flowchartsQuery: ReturnType + +jest.mock('@/lib/supabase/server', () => ({ + createClient: jest.fn(async () => ({ + auth: { getUser: jest.fn(async () => ({ data: { user: { id: 'user-1' } }, error: null })) }, + from: () => flowchartsQuery, + })), +})) + +function shareRequest() { + return new Request('http://localhost/api/flowcharts/fc-1/share', { method: 'POST' }) +} + +describe('POST /api/flowcharts/[id]/share', () => { + beforeEach(() => { + flowchartsQuery = createQueryMock() + }) + + it('returns 404 when the caller does not own the flowchart', async () => { + flowchartsQuery.queueResult({ data: null, error: { code: 'PGRST116' } }) + + const response = await POST(shareRequest(), { params: Promise.resolve({ id: 'fc-1' }) }) + + expect(response.status).toBe(404) + }) + + it('scopes both the read and the update to the authenticated user', async () => { + flowchartsQuery + .queueResult({ data: { is_public: false, share_id: null }, error: null }) + .queueResult({ data: { is_public: true, share_id: 'share-xyz' }, error: null }) + + await POST(shareRequest(), { params: Promise.resolve({ id: 'fc-1' }) }) + + expect(flowchartsQuery.eq).toHaveBeenCalledWith('user_id', 'user-1') + }) +}) +``` + +- [ ] **Step 11: Run test to verify it fails** + +Run: `npm test -- flowcharts-share-route` +Expected: FAIL — the share route currently doesn't filter by `user_id`, so `toHaveBeenCalledWith('user_id', 'user-1')` never matches. + +- [ ] **Step 12: Implement the share route change** + +Modify `app/api/flowcharts/[id]/share/route.ts`: + +```ts +import { createClient } from '@/lib/supabase/server' +import { generateShareId } from '@/lib/share' +import { shareLimit, checkRateLimit } from '@/lib/rate-limit' +import { NextResponse } from 'next/server' + +type Params = { params: Promise<{ id: string }> } + +export async function POST(_req: Request, { params }: Params) { + const { id } = await params + const supabase = await createClient() + const { data: { user }, error: authErr } = await supabase.auth.getUser() + if (authErr || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const { success, retryAfter } = await checkRateLimit(shareLimit, user.id) + if (!success) return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429, headers: { 'Retry-After': String(retryAfter) } }) + + const { data: fc, error: fetchError } = await supabase + .from('flowcharts').select('is_public, share_id').eq('id', id).eq('user_id', user.id).single() + if (fetchError || !fc) return NextResponse.json({ error: 'Not found' }, { status: 404 }) + + const toggled = !fc.is_public + const share_id = toggled ? generateShareId() : fc.share_id + const updateData = toggled ? { is_public: true, share_id } : { is_public: false } + + const { data: updated, error: updateError } = await supabase + .from('flowcharts').update(updateData).eq('id', id).eq('user_id', user.id).select('is_public, share_id').single() + + if (updateError) return NextResponse.json({ error: 'Failed to update share settings' }, { status: 500 }) + + return NextResponse.json(updated) +} +``` + +- [ ] **Step 13: Run test to verify it passes** + +Run: `npm test -- flowcharts-share-route` +Expected: PASS (2 tests) + +- [ ] **Step 14: Remove the dead, unused service-role client** + +Modify `lib/supabase/server.ts` — delete the `createServiceClient` export entirely, leaving only: + +```ts +import { createServerClient } from '@supabase/ssr' +import { cookies } from 'next/headers' + +export async function createClient() { + const cookieStore = await cookies() + return createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { return cookieStore.getAll() }, + setAll(cookiesToSet) { + try { + cookiesToSet.forEach(({ name, value, options }) => + cookieStore.set(name, value, options) + ) + } catch {} + }, + }, + } + ) +} +``` + +- [ ] **Step 15: Run the full test suite and lint** + +Run: `npm test && npm run lint` +Expected: all tests pass (existing + new), no lint errors. `grep -rn "createServiceClient" --include="*.ts" --include="*.tsx" .` (excluding `node_modules`) returns no results. + +- [ ] **Step 16: Commit** + +```bash +git add app/api/flowcharts/[id]/route.ts app/api/flowcharts/[id]/share/route.ts lib/supabase/server.ts __tests__/helpers/supabase-query-mock.ts __tests__/flowcharts-put-route.test.ts __tests__/flowcharts-patch-route.test.ts __tests__/flowcharts-delete-route.test.ts __tests__/flowcharts-share-route.test.ts +git commit -m "fix: enforce per-request ownership on mutation routes, decouple version pruning from save, remove dead service client" +``` + +--- + +## Task 5: Bound unbounded list queries + +**Files:** +- Modify: `app/api/flowcharts/route.ts` +- Modify: `app/(protected)/dashboard/page.tsx` +- Test: `__tests__/flowcharts-list-route.test.ts` + +**Interfaces:** +- No public interface changes — `GET /api/flowcharts` keeps returning a plain array; the dashboard page keeps its existing props to `FlowchartCard`. Only the query itself gains a cap. + +- [ ] **Step 1: Write the failing test** + +Create `__tests__/flowcharts-list-route.test.ts`: + +```ts +import { GET } from '@/app/api/flowcharts/route' +import { createQueryMock } from './helpers/supabase-query-mock' + +let flowchartsQuery: ReturnType + +jest.mock('@/lib/rate-limit', () => ({ + saveLimit: {}, + checkRateLimit: jest.fn(async () => ({ success: true, retryAfter: 0 })), +})) +jest.mock('@/lib/supabase/server', () => ({ + createClient: jest.fn(async () => ({ + auth: { getUser: jest.fn(async () => ({ data: { user: { id: 'user-1' } }, error: null })) }, + from: () => flowchartsQuery, + })), +})) + +describe('GET /api/flowcharts', () => { + beforeEach(() => { + flowchartsQuery = createQueryMock() + }) + + it('caps the list query so a single user cannot load an unbounded number of rows', async () => { + flowchartsQuery.queueResult({ data: [], error: null }) + + await GET() + + expect(flowchartsQuery.limit).toHaveBeenCalledWith(100) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -- flowcharts-list-route` +Expected: FAIL — `GET /api/flowcharts` currently never calls `.limit(...)`. + +- [ ] **Step 3: Implement the cap in the API route** + +Modify `app/api/flowcharts/route.ts` — in the `GET` function, add `.limit(100)` to the query chain: + +```ts +export async function GET() { + const supabase = await createClient() + const { data: { user }, error: authErr } = await supabase.auth.getUser() + if (authErr || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const { data, error } = await supabase + .from('flowcharts') + .select('id, title, language, is_public, share_id, created_at, updated_at') + .eq('user_id', user.id) + .order('updated_at', { ascending: false }) + .limit(100) + + if (error) { + console.error('GET /api/flowcharts failed:', error) + return NextResponse.json({ error: 'Failed to load flowcharts' }, { status: 500 }) + } + return NextResponse.json(data) +} +``` + +(the `POST` function in this file is unchanged) + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test -- flowcharts-list-route` +Expected: PASS + +- [ ] **Step 5: Apply the same cap to the dashboard's server-side query** + +Modify `app/(protected)/dashboard/page.tsx` — add `.limit(100)`: + +```tsx +export default async function DashboardPage() { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + const { data: flowcharts } = await supabase + .from('flowcharts') + .select('*') + .eq('user_id', user!.id) + .order('updated_at', { ascending: false }) + .limit(100) +``` + +(the rest of the file — the JSX render, empty state, `FlowchartCard` mapping — is unchanged) + +- [ ] **Step 6: Verify manually** + +Run: `npm run dev`, open `/dashboard` as the seeded demo user (`npm run seed:demo-user` if not already seeded), confirm the page still renders the existing flowcharts correctly. + +- [ ] **Step 7: Run the full test suite and lint** + +Run: `npm test && npm run lint` +Expected: all tests pass, no lint errors. + +- [ ] **Step 8: Commit** + +```bash +git add app/api/flowcharts/route.ts "app/(protected)/dashboard/page.tsx" __tests__/flowcharts-list-route.test.ts +git commit -m "fix: cap unbounded flowchart list queries at 100 rows" +``` + +--- + +## Task 6: Remove unused `thumbnail_url` schema drift + +**Files:** +- Modify: `types/index.ts` +- Modify: `docs/supabase-setup.sql` + +**Interfaces:** none — `thumbnail_url` is confirmed unread and unwritten anywhere in `app/`, `components/`, or `lib/` (verified via `grep -rn "thumbnail_url"` before writing this plan, which only matched `types/index.ts` and `docs/supabase-setup.sql`). + +- [ ] **Step 1: Remove the field from the `Flowchart` type** + +Modify `types/index.ts` — remove the `thumbnail_url` line from the `Flowchart` interface: + +```ts +export interface Flowchart { + id: string + user_id: string + title: string + language: Language + is_public: boolean + share_id: string | null + created_at: string + updated_at: string +} +``` + +(everything else in the file — `Profile`, `FlowchartVersion`, `Database`, which derives `Row`/`Insert`/`Update` from `Flowchart` automatically — is unchanged and needs no further edits) + +- [ ] **Step 2: Remove the column from the setup SQL doc** + +Modify `docs/supabase-setup.sql` — delete the `thumbnail_url TEXT,` line (line 17) from the `flowcharts` table definition. This only affects documentation for *new* Supabase project setups; it does not run any migration against the already-deployed production database, since that SQL file is not automatically re-applied. + +- [ ] **Step 3: Verify** + +Run: `npm run build` +Expected: build succeeds (confirms nothing else referenced the removed field). + +Run: `grep -rn "thumbnail_url" --include="*.ts" --include="*.tsx" --include="*.sql" . --exclude-dir=node_modules` +Expected: no output. + +- [ ] **Step 4: Commit** + +```bash +git add types/index.ts docs/supabase-setup.sql +git commit -m "chore: remove unused thumbnail_url field (dead schema/type drift)" +``` + +--- + +## Final check + +After all 6 tasks are committed: + +Run: `npm run lint && npm test && npm run build` +Expected: all green. + +Run: `git log --oneline -6` +Expected: 6 new commits, one per task, in the order above. From 15fe96f6518400cb7a278fe821863cfd0e17d38b Mon Sep 17 00:00:00 2001 From: Emp1500 Date: Tue, 14 Jul 2026 07:56:26 +0000 Subject: [PATCH 2/9] feat: add shared typed API client for flowchart endpoints --- __tests__/api-client.test.ts | 36 ++++++++++++++++++ lib/api-client.ts | 71 ++++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 __tests__/api-client.test.ts create mode 100644 lib/api-client.ts diff --git a/__tests__/api-client.test.ts b/__tests__/api-client.test.ts new file mode 100644 index 0000000..f8de11d --- /dev/null +++ b/__tests__/api-client.test.ts @@ -0,0 +1,36 @@ +import { fetchFlowchart, ApiError } from '@/lib/api-client' + +describe('api-client error handling', () => { + const originalFetch = global.fetch + + afterEach(() => { + jest.restoreAllMocks() + global.fetch = originalFetch + }) + + it('throws an ApiError with the server message when the response is not ok', async () => { + jest.spyOn(global, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ error: 'Rate limit exceeded' }), { status: 429 }) + ) + + await expect(fetchFlowchart('fc-1')).rejects.toMatchObject({ + message: 'Rate limit exceeded', + status: 429, + }) + }) + + it('throws a fallback ApiError when the error response has no JSON body', async () => { + jest.spyOn(global, 'fetch').mockResolvedValue(new Response('not json', { status: 500 })) + + await expect(fetchFlowchart('fc-1')).rejects.toBeInstanceOf(ApiError) + await expect(fetchFlowchart('fc-1')).rejects.toMatchObject({ status: 500 }) + }) + + it('resolves with the parsed JSON when the response is ok', async () => { + jest.spyOn(global, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ id: 'fc-1', code: 'x' }), { status: 200 }) + ) + + await expect(fetchFlowchart('fc-1')).resolves.toMatchObject({ id: 'fc-1', code: 'x' }) + }) +}) diff --git a/lib/api-client.ts b/lib/api-client.ts new file mode 100644 index 0000000..7f8dabb --- /dev/null +++ b/lib/api-client.ts @@ -0,0 +1,71 @@ +import type { Flowchart, FlowchartVersion, Language } from '@/types' + +export class ApiError extends Error { + status: number + + constructor(message: string, status: number) { + super(message) + this.name = 'ApiError' + this.status = status + } +} + +const jsonHeaders = { 'Content-Type': 'application/json' } + +async function apiFetch(input: string, init?: RequestInit): Promise { + const res = await fetch(input, init) + if (!res.ok) { + const body = await res.json().catch(() => null) + const message = body && typeof body.error === 'string' ? body.error : `Request failed with status ${res.status}` + throw new ApiError(message, res.status) + } + return res.json() as Promise +} + +export function fetchFlowchart(id: string) { + return apiFetch(`/api/flowcharts/${id}`) +} + +export function createFlowchart(input: { title: string; code: string; language: Language }) { + return apiFetch('/api/flowcharts', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify(input), + }) +} + +export function updateFlowchart(id: string, input: { code?: string; title?: string; language?: Language }) { + return apiFetch(`/api/flowcharts/${id}`, { + method: 'PUT', + headers: jsonHeaders, + body: JSON.stringify(input), + }) +} + +export function renameFlowchart(id: string, title: string) { + return apiFetch(`/api/flowcharts/${id}`, { + method: 'PATCH', + headers: jsonHeaders, + body: JSON.stringify({ title }), + }) +} + +export function deleteFlowchart(id: string) { + return apiFetch<{ success: true }>(`/api/flowcharts/${id}`, { method: 'DELETE' }) +} + +export function toggleShare(id: string) { + return apiFetch<{ is_public: boolean; share_id: string | null }>(`/api/flowcharts/${id}/share`, { method: 'POST' }) +} + +export function fetchVersions(flowchartId: string) { + return apiFetch[]>( + `/api/flowcharts/${flowchartId}/versions` + ) +} + +export function fetchVersion(flowchartId: string, versionNumber: number) { + return apiFetch>( + `/api/flowcharts/${flowchartId}/versions?v=${versionNumber}` + ) +} From dd9c65e919fb19020b9714b98dc8fa12529e822b Mon Sep 17 00:00:00 2001 From: Emp1500 Date: Tue, 14 Jul 2026 08:30:42 +0000 Subject: [PATCH 3/9] feat: add toast notification system (Base UI Toast primitive) --- app/layout.tsx | 6 ++++- components/ui/toast.tsx | 59 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 components/ui/toast.tsx diff --git a/app/layout.tsx b/app/layout.tsx index 4fdf9b0..3f60f65 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from 'next' import { Inter, Fira_Code } from 'next/font/google' import { Analytics } from '@vercel/analytics/next' +import { ToastProvider, Toaster } from '@/components/ui/toast' import './globals.css' const inter = Inter({ subsets: ['latin'], variable: '--font-sans' }) @@ -15,7 +16,10 @@ export default function RootLayout({ children }: { children: React.ReactNode }) return ( - {children} + + {children} + + diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx new file mode 100644 index 0000000..f7c0131 --- /dev/null +++ b/components/ui/toast.tsx @@ -0,0 +1,59 @@ +"use client" + +import { Toast as ToastPrimitive } from "@base-ui/react/toast" +import { XIcon } from "lucide-react" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" + +function ToastProvider({ ...props }: ToastPrimitive.Provider.Props) { + return +} + +function ToastViewport({ className, ...props }: ToastPrimitive.Viewport.Props) { + return ( + + ) +} + +function ToastList() { + const { toasts } = ToastPrimitive.useToastManager() + return toasts.map(toast => ( + + +
+ + +
+ }> + + +
+
+ )) +} + +function Toaster() { + return ( + + + + + + ) +} + +const useToastManager = ToastPrimitive.useToastManager + +export { ToastProvider, Toaster, useToastManager } From 4fe89a29d1c33849b37ba0f2f4202e84e49c00e5 Mon Sep 17 00:00:00 2001 From: Emp1500 Date: Tue, 14 Jul 2026 09:20:05 +0000 Subject: [PATCH 4/9] refactor: unify editor pages into shared hook, surface mutation failures via toast --- app/(protected)/editor/[id]/page.tsx | 154 +++++------------------- app/(protected)/editor/page.tsx | 98 +++++---------- components/dashboard/FlowchartCard.tsx | 15 ++- components/editor/VersionDrawer.tsx | 31 +++-- components/editor/useFlowchartEditor.ts | 153 +++++++++++++++++++++++ 5 files changed, 251 insertions(+), 200 deletions(-) create mode 100644 components/editor/useFlowchartEditor.ts diff --git a/app/(protected)/editor/[id]/page.tsx b/app/(protected)/editor/[id]/page.tsx index ca29b05..3dfc17b 100644 --- a/app/(protected)/editor/[id]/page.tsx +++ b/app/(protected)/editor/[id]/page.tsx @@ -1,148 +1,54 @@ 'use client' -import { useRef, useState, useCallback, useEffect } from 'react' -import { useRouter, useParams } from 'next/navigation' -import type { editor } from 'monaco-editor' +import { useParams } from 'next/navigation' import { CodeEditor } from '@/components/editor/CodeEditor' import { FlowchartPanel } from '@/components/editor/FlowchartPanel' import { EditorLayout } from '@/components/editor/EditorLayout' import { EditorToolbar } from '@/components/editor/EditorToolbar' import { VersionDrawer } from '@/components/editor/VersionDrawer' import { CommandPalette } from '@/components/command/CommandPalette' -import { codeToMermaid } from '@/lib/parser' -import type { SupportedLanguage } from '@/lib/parser' -import { toPng } from 'html-to-image' +import { useFlowchartEditor } from '@/components/editor/useFlowchartEditor' export default function EditFlowchartPage() { - const { id } = useParams<{ id: string }>() - const router = useRouter() - const editorRef = useRef(null) - const panelRef = useRef(null) + const { id } = useParams<{ id: string }>() + const editor = useFlowchartEditor(id) + const { editorRef } = editor - const [loaded, setLoaded] = useState(false) - const [title, setTitle] = useState('Untitled') - const [language, setLanguage] = useState('javascript') - const [code, setCode] = useState('') - const [savedCode, setSavedCode] = useState('') - const [isPublic, setIsPublic] = useState(false) - const [shareId, setShareId] = useState(null) - const [mermaidCode, setMermaidCode] = useState('') - const [showVersions, setShowVersions] = useState(false) - const [renameTrigger, setRenameTrigger] = useState(0) - - useEffect(() => { - fetch(`/api/flowcharts/${id}`) - .then(r => r.json()) - .then(fc => { - setTitle(fc.title); setLanguage(fc.language); setCode(fc.code ?? '') - setSavedCode(fc.code ?? ''); setIsPublic(fc.is_public); setShareId(fc.share_id) - setLoaded(true) - }) - }, [id]) - - useEffect(() => { - if (!loaded) return - try { setMermaidCode(codeToMermaid(code, language)) } catch {} - }, [code, language, loaded]) - - const hasUnsavedChanges = code !== savedCode - - const handleSave = useCallback(async () => { - await fetch(`/api/flowcharts/${id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ code, language }), - }) - setSavedCode(code) - }, [id, code, language]) - - const handleSaveAs = useCallback(async () => { - const newTitle = prompt('Title for new flowchart:', title + ' (copy)') - if (!newTitle) return - const res = await fetch('/api/flowcharts', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title: newTitle, code, language }), - }) - if (res.ok) { const fc = await res.json(); router.push(`/editor/${fc.id}`) } - }, [title, code, language, router]) - - const handleRename = useCallback(async (newTitle: string) => { - setTitle(newTitle) - await fetch(`/api/flowcharts/${id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title: newTitle }), - }) - document.title = `${newTitle} — Code2Flow` - }, [id]) - - const handleDelete = useCallback(async () => { - await fetch(`/api/flowcharts/${id}`, { method: 'DELETE' }) - router.push('/dashboard') - }, [id, router]) - - const handleToggleShare = useCallback(async () => { - const res = await fetch(`/api/flowcharts/${id}/share`, { method: 'POST' }) - const data = await res.json() - setIsPublic(data.is_public); setShareId(data.share_id) - }, [id]) - - function handleDownloadPng() { - if (!panelRef.current) return - toPng(panelRef.current).then(url => { - const a = document.createElement('a'); a.href = url; a.download = `${title}-flowchart.png`; a.click() - }) - } - - // Keyboard shortcuts - useEffect(() => { - const handler = (e: KeyboardEvent) => { - if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); handleSave() } - if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'S') { e.preventDefault(); handleSaveAs() } - if ((e.ctrlKey || e.metaKey) && e.key === 'h') { e.preventDefault(); setShowVersions(v => !v) } - if ((e.ctrlKey || e.metaKey) && e.key === 'd') { e.preventDefault(); handleDownloadPng() } - } - window.addEventListener('keydown', handler) - return () => window.removeEventListener('keydown', handler) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [handleSave, handleSaveAs]) - - if (!loaded) return
Loading…
+ if (!editor.loaded) return
Loading…
return (
setLanguage(lang)} - onVersionHistory={() => setShowVersions(v => !v)} - renameTrigger={renameTrigger} + flowchartId={editor.id} title={editor.title} language={editor.language} + isPublic={editor.isPublic} shareId={editor.shareId} + hasUnsavedChanges={editor.hasUnsavedChanges} editorRef={editor.editorRef} code={editor.code} + onSave={editor.handleSave} onSaveAs={editor.handleSaveAs} onRename={editor.handleRename} + onDelete={editor.handleDelete} onToggleShare={editor.handleToggleShare} + onDownloadPng={editor.handleDownloadPng} + onLanguageChange={editor.handleLanguageChange} + onVersionHistory={() => editor.setShowVersions(v => !v)} + renameTrigger={editor.renameTrigger} />
{ editorRef.current = e }} />} - right={} + left={ { editorRef.current = e }} />} + right={} />
- {showVersions && ( - setShowVersions(false)} - onRestore={c => { setCode(c); setSavedCode(c) }} /> + {editor.showVersions && ( + editor.setShowVersions(false)} + onRestore={c => { editor.setCode(c); editor.setSavedCode(c) }} /> )} setShowVersions(v => !v)} - onLanguageChange={lang => setLanguage(lang)} - onRenameStart={() => setRenameTrigger(n => n + 1)} + flowchartId={editor.id} + editorRef={editor.editorRef} + onSave={editor.handleSave} + onSaveAs={editor.handleSaveAs} + onDelete={editor.handleDelete} + onToggleShare={editor.handleToggleShare} + onDownloadPng={editor.handleDownloadPng} + onVersionHistory={() => editor.setShowVersions(v => !v)} + onLanguageChange={editor.handleLanguageChange} + onRenameStart={() => editor.setRenameTrigger(n => n + 1)} />
) diff --git a/app/(protected)/editor/page.tsx b/app/(protected)/editor/page.tsx index ba5fba2..e845e2a 100644 --- a/app/(protected)/editor/page.tsx +++ b/app/(protected)/editor/page.tsx @@ -1,87 +1,51 @@ 'use client' -import { useRef, useState, useCallback, useEffect } from 'react' -import { useRouter } from 'next/navigation' -import type { editor } from 'monaco-editor' import { CodeEditor } from '@/components/editor/CodeEditor' import { FlowchartPanel } from '@/components/editor/FlowchartPanel' import { EditorLayout } from '@/components/editor/EditorLayout' import { EditorToolbar } from '@/components/editor/EditorToolbar' import { VersionDrawer } from '@/components/editor/VersionDrawer' -import { codeToMermaid } from '@/lib/parser' -import type { SupportedLanguage } from '@/lib/parser' -import { toPng } from 'html-to-image' - -const DEFAULT_CODE: Record = { - javascript: `function greet(name) {\n if (name) {\n return "Hello, " + name;\n } else {\n return "Hello, World!";\n }\n}`, - typescript: `function greet(name: string): string {\n if (name) {\n return \`Hello, \${name}\`;\n } else {\n return "Hello, World!";\n }\n}`, - python: `def greet(name):\n if name:\n return f"Hello, {name}"\n else:\n return "Hello, World!"`, -} +import { CommandPalette } from '@/components/command/CommandPalette' +import { useFlowchartEditor } from '@/components/editor/useFlowchartEditor' export default function NewEditorPage() { - const router = useRouter() - const editorRef = useRef(null) - const panelRef = useRef(null) - - const [language, setLanguage] = useState('javascript') - const [code, setCode] = useState(DEFAULT_CODE.javascript) - const [mermaidCode, setMermaidCode] = useState('') - const [title, setTitle] = useState('Untitled') - const [showVersions, setShowVersions] = useState(false) - - useEffect(() => { - try { setMermaidCode(codeToMermaid(code, language)) } catch {} - }, [code, language]) - - const handleSave = useCallback(async () => { - const res = await fetch('/api/flowcharts', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title, code, language }), - }) - if (res.ok) { - const fc = await res.json() - router.replace(`/editor/${fc.id}`) - } - }, [title, code, language, router]) - - const handleSaveAs = useCallback(async () => { - const newTitle = prompt('Title for new flowchart:', title + ' (copy)') - if (!newTitle) return - const res = await fetch('/api/flowcharts', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title: newTitle, code, language }), - }) - if (res.ok) { const fc = await res.json(); router.push(`/editor/${fc.id}`) } - }, [title, code, language, router]) - - function handleDownloadPng() { - if (!panelRef.current) return - toPng(panelRef.current).then(url => { - const a = document.createElement('a'); a.href = url; a.download = `${title}-flowchart.png`; a.click() - }) - } + const editor = useFlowchartEditor() + const { editorRef } = editor return (
setTitle(t)} - onDelete={async () => router.push('/dashboard')} - onToggleShare={async () => {}} - onDownloadPng={handleDownloadPng} - onLanguageChange={lang => { setLanguage(lang); setCode(DEFAULT_CODE[lang]) }} - onVersionHistory={() => setShowVersions(true)} + flowchartId={editor.id} title={editor.title} language={editor.language} + isPublic={editor.isPublic} shareId={editor.shareId} + hasUnsavedChanges={editor.hasUnsavedChanges} editorRef={editor.editorRef} code={editor.code} + onSave={editor.handleSave} onSaveAs={editor.handleSaveAs} onRename={editor.handleRename} + onDelete={editor.handleDelete} onToggleShare={editor.handleToggleShare} + onDownloadPng={editor.handleDownloadPng} + onLanguageChange={editor.handleLanguageChange} + onVersionHistory={() => editor.setShowVersions(v => !v)} + renameTrigger={editor.renameTrigger} />
{ editorRef.current = e }} />} - right={} + left={ { editorRef.current = e }} />} + right={} />
- {showVersions && setShowVersions(false)} onRestore={setCode} />} + {editor.showVersions && ( + editor.setShowVersions(false)} + onRestore={c => { editor.setCode(c); editor.setSavedCode(c) }} /> + )} + editor.setShowVersions(v => !v)} + onLanguageChange={editor.handleLanguageChange} + onRenameStart={() => editor.setRenameTrigger(n => n + 1)} + />
) } diff --git a/components/dashboard/FlowchartCard.tsx b/components/dashboard/FlowchartCard.tsx index 1f90e58..dca3c1a 100644 --- a/components/dashboard/FlowchartCard.tsx +++ b/components/dashboard/FlowchartCard.tsx @@ -5,6 +5,8 @@ import { useRouter } from 'next/navigation' import type { Flowchart } from '@/types' import { Badge } from '@/components/ui/badge' import { MoreHorizontal } from 'lucide-react' +import { deleteFlowchart, ApiError } from '@/lib/api-client' +import { useToastManager } from '@/components/ui/toast' function timeAgo(dateStr: string): string { const now = new Date() @@ -31,6 +33,7 @@ const langBadge: Record = { export function FlowchartCard({ flowchart }: { flowchart: Flowchart }) { const router = useRouter() + const toast = useToastManager() const [menuOpen, setMenuOpen] = useState(false) const [deleting, setDeleting] = useState(false) const menuRef = useRef(null) @@ -51,8 +54,16 @@ export function FlowchartCard({ flowchart }: { flowchart: Flowchart }) { setMenuOpen(false) if (!confirm(`Delete "${flowchart.title}"?`)) return setDeleting(true) - await fetch(`/api/flowcharts/${flowchart.id}`, { method: 'DELETE' }) - router.refresh() + try { + await deleteFlowchart(flowchart.id) + router.refresh() + } catch (err) { + setDeleting(false) + toast.add({ + title: 'Delete failed', + description: err instanceof ApiError ? err.message : 'Please try again.', + }) + } } const accentClass = langAccent[flowchart.language] ?? '' diff --git a/components/editor/VersionDrawer.tsx b/components/editor/VersionDrawer.tsx index a66de56..e287735 100644 --- a/components/editor/VersionDrawer.tsx +++ b/components/editor/VersionDrawer.tsx @@ -3,6 +3,8 @@ import { useEffect, useState } from 'react' import { Button } from '@/components/ui/button' import { Separator } from '@/components/ui/separator' import type { FlowchartVersion } from '@/types' +import { fetchVersions, fetchVersion, ApiError } from '@/lib/api-client' +import { useToastManager } from '@/components/ui/toast' interface Props { flowchartId: string @@ -11,7 +13,8 @@ interface Props { } export function VersionDrawer({ flowchartId, onClose, onRestore }: Props) { - const [versions, setVersions] = useState[]>([]) + const toast = useToastManager() + const [versions, setVersions] = useState[]>([]) const [loading, setLoading] = useState(true) const [selected, setSelected] = useState(null) const [preview, setPreview] = useState(null) @@ -19,17 +22,31 @@ export function VersionDrawer({ flowchartId, onClose, onRestore }: Props) { useEffect(() => { if (!flowchartId) { setLoading(false); return } - fetch(`/api/flowcharts/${flowchartId}/versions`) - .then(r => r.json()) + fetchVersions(flowchartId) .then(data => { setVersions(data); setLoading(false) }) + .catch(err => { + setLoading(false) + toast.add({ + title: 'Failed to load version history', + description: err instanceof ApiError ? err.message : 'Please try again.', + }) + }) + // eslint-disable-next-line react-hooks/exhaustive-deps }, [flowchartId]) async function loadPreview(versionNumber: number) { setFetching(true); setSelected(versionNumber) - const r = await fetch(`/api/flowcharts/${flowchartId}/versions?v=${versionNumber}`) - const d = await r.json() - setPreview(d.code) - setFetching(false) + try { + const d = await fetchVersion(flowchartId, versionNumber) + setPreview(d.code) + } catch (err) { + toast.add({ + title: 'Failed to load version', + description: err instanceof ApiError ? err.message : 'Please try again.', + }) + } finally { + setFetching(false) + } } function handleRestore() { diff --git a/components/editor/useFlowchartEditor.ts b/components/editor/useFlowchartEditor.ts new file mode 100644 index 0000000..e20226a --- /dev/null +++ b/components/editor/useFlowchartEditor.ts @@ -0,0 +1,153 @@ +'use client' +import { useRef, useState, useCallback, useEffect } from 'react' +import { useRouter } from 'next/navigation' +import type { editor } from 'monaco-editor' +import { toPng } from 'html-to-image' +import { codeToMermaid } from '@/lib/parser' +import type { SupportedLanguage } from '@/lib/parser' +import * as api from '@/lib/api-client' +import { ApiError } from '@/lib/api-client' +import { useToastManager } from '@/components/ui/toast' + +const DEFAULT_CODE: Record = { + javascript: `function greet(name) {\n if (name) {\n return "Hello, " + name;\n } else {\n return "Hello, World!";\n }\n}`, + typescript: `function greet(name: string): string {\n if (name) {\n return \`Hello, \${name}\`;\n } else {\n return "Hello, World!";\n }\n}`, + python: `def greet(name):\n if name:\n return f"Hello, {name}"\n else:\n return "Hello, World!"`, +} + +function errorDescription(err: unknown) { + return err instanceof ApiError ? err.message : 'Please try again.' +} + +export function useFlowchartEditor(initialId?: string) { + const router = useRouter() + const toast = useToastManager() + const editorRef = useRef(null) + const panelRef = useRef(null) + + const [id, setId] = useState(initialId) + const [loaded, setLoaded] = useState(!initialId) + const [title, setTitle] = useState('Untitled') + const [language, setLanguage] = useState('javascript') + const [code, setCode] = useState(initialId ? '' : DEFAULT_CODE.javascript) + const [savedCode, setSavedCode] = useState('') + const [isPublic, setIsPublic] = useState(false) + const [shareId, setShareId] = useState(null) + const [mermaidCode, setMermaidCode] = useState('') + const [showVersions, setShowVersions] = useState(false) + const [renameTrigger, setRenameTrigger] = useState(0) + + useEffect(() => { + if (!initialId) return + api.fetchFlowchart(initialId) + .then(fc => { + setTitle(fc.title); setLanguage(fc.language); setCode(fc.code ?? '') + setSavedCode(fc.code ?? ''); setIsPublic(fc.is_public); setShareId(fc.share_id) + setLoaded(true) + }) + .catch(err => { + setLoaded(true) + toast.add({ title: 'Failed to load flowchart', description: errorDescription(err) }) + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialId]) + + useEffect(() => { + if (!loaded) return + try { setMermaidCode(codeToMermaid(code, language)) } catch {} + }, [code, language, loaded]) + + const hasUnsavedChanges = code !== savedCode + + const handleSave = useCallback(async () => { + try { + if (!id) { + const fc = await api.createFlowchart({ title, code, language }) + setId(fc.id) + setSavedCode(code) + router.replace(`/editor/${fc.id}`) + } else { + await api.updateFlowchart(id, { code, language }) + setSavedCode(code) + } + } catch (err) { + toast.add({ title: 'Save failed', description: errorDescription(err) }) + } + }, [id, title, code, language, router, toast]) + + const handleSaveAs = useCallback(async () => { + const newTitle = prompt('Title for new flowchart:', title + ' (copy)') + if (!newTitle) return + try { + const fc = await api.createFlowchart({ title: newTitle, code, language }) + router.push(`/editor/${fc.id}`) + } catch (err) { + toast.add({ title: 'Save As failed', description: errorDescription(err) }) + } + }, [title, code, language, router, toast]) + + const handleRename = useCallback(async (newTitle: string) => { + const previous = title + setTitle(newTitle) + if (!id) return + try { + await api.renameFlowchart(id, newTitle) + document.title = `${newTitle} — Code2Flow` + } catch (err) { + setTitle(previous) + toast.add({ title: 'Rename failed', description: errorDescription(err) }) + } + }, [id, title, toast]) + + const handleDelete = useCallback(async () => { + if (!id) { router.push('/dashboard'); return } + try { + await api.deleteFlowchart(id) + router.push('/dashboard') + } catch (err) { + toast.add({ title: 'Delete failed', description: errorDescription(err) }) + } + }, [id, router, toast]) + + const handleToggleShare = useCallback(async () => { + if (!id) return + try { + const data = await api.toggleShare(id) + setIsPublic(data.is_public); setShareId(data.share_id) + } catch (err) { + toast.add({ title: 'Share update failed', description: errorDescription(err) }) + } + }, [id, toast]) + + function handleDownloadPng() { + if (!panelRef.current) return + toPng(panelRef.current).then(url => { + const a = document.createElement('a'); a.href = url; a.download = `${title}-flowchart.png`; a.click() + }) + } + + function handleLanguageChange(lang: SupportedLanguage) { + setLanguage(lang) + if (!id) setCode(DEFAULT_CODE[lang]) + } + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); handleSave() } + if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'S') { e.preventDefault(); handleSaveAs() } + if ((e.ctrlKey || e.metaKey) && e.key === 'h') { e.preventDefault(); setShowVersions(v => !v) } + if ((e.ctrlKey || e.metaKey) && e.key === 'd') { e.preventDefault(); handleDownloadPng() } + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [handleSave, handleSaveAs]) + + return { + id, editorRef, panelRef, loaded, title, language, code, mermaidCode, + isPublic, shareId, hasUnsavedChanges, showVersions, renameTrigger, + setCode, setSavedCode, setShowVersions, setRenameTrigger, + handleSave, handleSaveAs, handleRename, handleDelete, handleToggleShare, + handleDownloadPng, handleLanguageChange, + } +} From d246890270fac3468ca720480f727a0036428c38 Mon Sep 17 00:00:00 2001 From: Emp1500 Date: Thu, 16 Jul 2026 05:50:17 +0000 Subject: [PATCH 5/9] fix: sync id state in useFlowchartEditor when initialId changes The id state was only ever initialized from initialId via useState and never re-synced when initialId changed after mount. The fetch effect (keyed on [initialId]) refreshed title/code/savedCode/isPublic/shareId on an id change but never called setId(initialId), so handleSave's update branch could PUT edited content onto the wrong flowchart id if the hook instance were ever reused across an id transition (including transitioning to/from undefined on the new-flowchart page). Not reachable via any current navigation path, but a real latent bug flagged in Task 3 review. --- components/editor/useFlowchartEditor.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/components/editor/useFlowchartEditor.ts b/components/editor/useFlowchartEditor.ts index e20226a..4be300d 100644 --- a/components/editor/useFlowchartEditor.ts +++ b/components/editor/useFlowchartEditor.ts @@ -38,6 +38,7 @@ export function useFlowchartEditor(initialId?: string) { const [renameTrigger, setRenameTrigger] = useState(0) useEffect(() => { + setId(initialId) if (!initialId) return api.fetchFlowchart(initialId) .then(fc => { From a453e82205af0dcdeb6b14c5106a19a445875959 Mon Sep 17 00:00:00 2001 From: Emp1500 Date: Thu, 16 Jul 2026 07:04:46 +0000 Subject: [PATCH 6/9] fix: enforce per-request ownership on mutation routes, decouple version pruning from save, remove dead service client --- __tests__/flowcharts-delete-route.test.ts | 42 +++++++++++++++++ __tests__/flowcharts-patch-route.test.ts | 40 ++++++++++++++++ __tests__/flowcharts-put-route.test.ts | 57 +++++++++++++++++++++++ __tests__/flowcharts-share-route.test.ts | 45 ++++++++++++++++++ __tests__/helpers/supabase-query-mock.ts | 25 ++++++++++ app/api/flowcharts/[id]/route.ts | 20 +++++--- app/api/flowcharts/[id]/share/route.ts | 5 +- jest.config.js | 1 + lib/supabase/server.ts | 20 -------- 9 files changed, 227 insertions(+), 28 deletions(-) create mode 100644 __tests__/flowcharts-delete-route.test.ts create mode 100644 __tests__/flowcharts-patch-route.test.ts create mode 100644 __tests__/flowcharts-put-route.test.ts create mode 100644 __tests__/flowcharts-share-route.test.ts create mode 100644 __tests__/helpers/supabase-query-mock.ts diff --git a/__tests__/flowcharts-delete-route.test.ts b/__tests__/flowcharts-delete-route.test.ts new file mode 100644 index 0000000..84783dc --- /dev/null +++ b/__tests__/flowcharts-delete-route.test.ts @@ -0,0 +1,42 @@ +import { DELETE } from '@/app/api/flowcharts/[id]/route' +import { createQueryMock } from './helpers/supabase-query-mock' + +let flowchartsQuery: ReturnType + +jest.mock('@/lib/supabase/server', () => ({ + createClient: jest.fn(async () => ({ + auth: { getUser: jest.fn(async () => ({ data: { user: { id: 'user-1' } }, error: null })) }, + from: () => flowchartsQuery, + })), +})) + +describe('DELETE /api/flowcharts/[id]', () => { + beforeEach(() => { + flowchartsQuery = createQueryMock() + }) + + it('returns 404 when the caller does not own the flowchart', async () => { + flowchartsQuery.queueResult({ data: null, error: { code: 'PGRST116' } }) + + const response = await DELETE( + new Request('http://localhost/api/flowcharts/fc-1', { method: 'DELETE' }), + { params: Promise.resolve({ id: 'fc-1' }) } + ) + + expect(response.status).toBe(404) + }) + + it('deletes and returns success when the caller owns the flowchart', async () => { + flowchartsQuery.queueResult({ data: { id: 'fc-1' }, error: null }) + + const response = await DELETE( + new Request('http://localhost/api/flowcharts/fc-1', { method: 'DELETE' }), + { params: Promise.resolve({ id: 'fc-1' }) } + ) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body).toEqual({ success: true }) + expect(flowchartsQuery.eq).toHaveBeenCalledWith('user_id', 'user-1') + }) +}) diff --git a/__tests__/flowcharts-patch-route.test.ts b/__tests__/flowcharts-patch-route.test.ts new file mode 100644 index 0000000..c11bc97 --- /dev/null +++ b/__tests__/flowcharts-patch-route.test.ts @@ -0,0 +1,40 @@ +import { PATCH } from '@/app/api/flowcharts/[id]/route' +import { createQueryMock } from './helpers/supabase-query-mock' + +let flowchartsQuery: ReturnType + +jest.mock('@/lib/supabase/server', () => ({ + createClient: jest.fn(async () => ({ + auth: { getUser: jest.fn(async () => ({ data: { user: { id: 'user-1' } }, error: null })) }, + from: () => flowchartsQuery, + })), +})) + +function patchRequest(body: object) { + return new Request('http://localhost/api/flowcharts/fc-1', { + method: 'PATCH', + body: JSON.stringify(body), + }) +} + +describe('PATCH /api/flowcharts/[id]', () => { + beforeEach(() => { + flowchartsQuery = createQueryMock() + }) + + it('returns 404 when the caller does not own the flowchart', async () => { + flowchartsQuery.queueResult({ data: null, error: { code: 'PGRST116' } }) + + const response = await PATCH(patchRequest({ title: 'New title' }), { params: Promise.resolve({ id: 'fc-1' }) }) + + expect(response.status).toBe(404) + }) + + it('scopes the update to the authenticated user', async () => { + flowchartsQuery.queueResult({ data: { id: 'fc-1', title: 'New title' }, error: null }) + + await PATCH(patchRequest({ title: 'New title' }), { params: Promise.resolve({ id: 'fc-1' }) }) + + expect(flowchartsQuery.eq).toHaveBeenCalledWith('user_id', 'user-1') + }) +}) diff --git a/__tests__/flowcharts-put-route.test.ts b/__tests__/flowcharts-put-route.test.ts new file mode 100644 index 0000000..027c4e8 --- /dev/null +++ b/__tests__/flowcharts-put-route.test.ts @@ -0,0 +1,57 @@ +import { PUT } from '@/app/api/flowcharts/[id]/route' +import { createQueryMock } from './helpers/supabase-query-mock' + +jest.mock('@/lib/rate-limit', () => ({ + saveLimit: {}, + checkRateLimit: jest.fn(async () => ({ success: true, retryAfter: 0 })), +})) + +let flowchartsQuery: ReturnType +let versionsQuery: ReturnType + +jest.mock('@/lib/supabase/server', () => ({ + createClient: jest.fn(async () => ({ + auth: { getUser: jest.fn(async () => ({ data: { user: { id: 'user-1' } }, error: null })) }, + from: (table: string) => (table === 'flowcharts' ? flowchartsQuery : versionsQuery), + })), +})) + +function putRequest(body: object) { + return new Request('http://localhost/api/flowcharts/fc-1', { + method: 'PUT', + body: JSON.stringify(body), + }) +} + +describe('PUT /api/flowcharts/[id]', () => { + beforeEach(() => { + flowchartsQuery = createQueryMock() + versionsQuery = createQueryMock() + }) + + it('returns 404 and skips all writes when the caller does not own the flowchart', async () => { + flowchartsQuery.queueResult({ data: null, error: { code: 'PGRST116' } }) + + const response = await PUT(putRequest({ code: 'x' }), { params: Promise.resolve({ id: 'fc-1' }) }) + + expect(response.status).toBe(404) + expect(versionsQuery.insert).not.toHaveBeenCalled() + }) + + it('still returns 200 with the updated flowchart when pruning old versions fails', async () => { + flowchartsQuery + .queueResult({ data: { id: 'fc-1' }, error: null }) // ownership check + .queueResult({ data: { id: 'fc-1', title: 'T' }, error: null }) // final reload + + versionsQuery + .queueResult({ data: { version_number: 3 }, error: null }) // latest version lookup + .queueResult({ data: null, error: null }) // version insert + .queueResult({ data: null, error: { message: 'boom' } }) // prune list lookup fails + + const response = await PUT(putRequest({ code: 'new code' }), { params: Promise.resolve({ id: 'fc-1' }) }) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body).toMatchObject({ id: 'fc-1', title: 'T' }) + }) +}) diff --git a/__tests__/flowcharts-share-route.test.ts b/__tests__/flowcharts-share-route.test.ts new file mode 100644 index 0000000..8236f1f --- /dev/null +++ b/__tests__/flowcharts-share-route.test.ts @@ -0,0 +1,45 @@ +import { POST } from '@/app/api/flowcharts/[id]/share/route' +import { createQueryMock } from './helpers/supabase-query-mock' + +jest.mock('@/lib/rate-limit', () => ({ + shareLimit: {}, + checkRateLimit: jest.fn(async () => ({ success: true, retryAfter: 0 })), +})) +jest.mock('@/lib/share', () => ({ generateShareId: () => 'share-xyz' })) + +let flowchartsQuery: ReturnType + +jest.mock('@/lib/supabase/server', () => ({ + createClient: jest.fn(async () => ({ + auth: { getUser: jest.fn(async () => ({ data: { user: { id: 'user-1' } }, error: null })) }, + from: () => flowchartsQuery, + })), +})) + +function shareRequest() { + return new Request('http://localhost/api/flowcharts/fc-1/share', { method: 'POST' }) +} + +describe('POST /api/flowcharts/[id]/share', () => { + beforeEach(() => { + flowchartsQuery = createQueryMock() + }) + + it('returns 404 when the caller does not own the flowchart', async () => { + flowchartsQuery.queueResult({ data: null, error: { code: 'PGRST116' } }) + + const response = await POST(shareRequest(), { params: Promise.resolve({ id: 'fc-1' }) }) + + expect(response.status).toBe(404) + }) + + it('scopes both the read and the update to the authenticated user', async () => { + flowchartsQuery + .queueResult({ data: { is_public: false, share_id: null }, error: null }) + .queueResult({ data: { is_public: true, share_id: 'share-xyz' }, error: null }) + + await POST(shareRequest(), { params: Promise.resolve({ id: 'fc-1' }) }) + + expect(flowchartsQuery.eq).toHaveBeenCalledWith('user_id', 'user-1') + }) +}) diff --git a/__tests__/helpers/supabase-query-mock.ts b/__tests__/helpers/supabase-query-mock.ts new file mode 100644 index 0000000..caadbf9 --- /dev/null +++ b/__tests__/helpers/supabase-query-mock.ts @@ -0,0 +1,25 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +type QueryResult = { data: any; error: any } + +export function createQueryMock() { + const queue: QueryResult[] = [] + const mock: any = { + select: jest.fn(() => mock), + eq: jest.fn(() => mock), + order: jest.fn(() => mock), + limit: jest.fn(() => mock), + in: jest.fn(() => mock), + update: jest.fn(() => mock), + insert: jest.fn(() => mock), + delete: jest.fn(() => mock), + single: jest.fn(() => mock), + queueResult(result: QueryResult) { + queue.push(result) + return mock + }, + then(resolve: (value: QueryResult) => void) { + resolve(queue.shift() ?? { data: null, error: null }) + }, + } + return mock +} diff --git a/app/api/flowcharts/[id]/route.ts b/app/api/flowcharts/[id]/route.ts index cef6c46..b04196a 100644 --- a/app/api/flowcharts/[id]/route.ts +++ b/app/api/flowcharts/[id]/route.ts @@ -34,6 +34,10 @@ export async function PUT(request: Request, { params }: Params) { const { data: { user }, error: authErr } = await supabase.auth.getUser() if (authErr || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const { data: owned, error: ownerError } = await supabase + .from('flowcharts').select('id').eq('id', id).eq('user_id', user.id).single() + if (ownerError || !owned) return NextResponse.json({ error: 'Not found' }, { status: 404 }) + const { success, retryAfter } = await checkRateLimit(saveLimit, user.id) if (!success) return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429, headers: { 'Retry-After': String(retryAfter) } }) @@ -71,12 +75,12 @@ export async function PUT(request: Request, { params }: Params) { .eq('flowchart_id', id) .order('version_number', { ascending: true }) - if (listError) return NextResponse.json({ error: 'Failed to prune old versions' }, { status: 500 }) - - if (all && all.length > 50) { + if (listError) { + console.error('Failed to list versions for pruning (save already succeeded):', listError) + } else if (all && all.length > 50) { const toDelete = all.slice(0, all.length - 50).map(v => v.id) const { error: deleteError } = await supabase.from('flowchart_versions').delete().in('id', toDelete) - if (deleteError) return NextResponse.json({ error: 'Failed to prune old versions' }, { status: 500 }) + if (deleteError) console.error('Failed to prune old versions (save already succeeded):', deleteError) } } @@ -96,8 +100,9 @@ export async function PATCH(request: Request, { params }: Params) { if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }) const { data, error } = await supabase - .from('flowcharts').update({ title: parsed.data.title }).eq('id', id).select().single() + .from('flowcharts').update({ title: parsed.data.title }).eq('id', id).eq('user_id', user.id).select().single() if (error) { + if (error.code === 'PGRST116') return NextResponse.json({ error: 'Not found' }, { status: 404 }) console.error('PATCH /api/flowcharts/[id] failed:', error) return NextResponse.json({ error: 'Failed to rename flowchart' }, { status: 500 }) } @@ -110,10 +115,13 @@ export async function DELETE(_req: Request, { params }: Params) { const { data: { user }, error: authErr } = await supabase.auth.getUser() if (authErr || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - const { error } = await supabase.from('flowcharts').delete().eq('id', id) + const { data, error } = await supabase + .from('flowcharts').delete().eq('id', id).eq('user_id', user.id).select().single() if (error) { + if (error.code === 'PGRST116') return NextResponse.json({ error: 'Not found' }, { status: 404 }) console.error('DELETE /api/flowcharts/[id] failed:', error) return NextResponse.json({ error: 'Failed to delete flowchart' }, { status: 500 }) } + if (!data) return NextResponse.json({ error: 'Not found' }, { status: 404 }) return NextResponse.json({ success: true }) } diff --git a/app/api/flowcharts/[id]/share/route.ts b/app/api/flowcharts/[id]/share/route.ts index 0b60044..369cb22 100644 --- a/app/api/flowcharts/[id]/share/route.ts +++ b/app/api/flowcharts/[id]/share/route.ts @@ -14,7 +14,8 @@ export async function POST(_req: Request, { params }: Params) { const { success, retryAfter } = await checkRateLimit(shareLimit, user.id) if (!success) return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429, headers: { 'Retry-After': String(retryAfter) } }) - const { data: fc, error: fetchError } = await supabase.from('flowcharts').select('is_public, share_id').eq('id', id).single() + const { data: fc, error: fetchError } = await supabase + .from('flowcharts').select('is_public, share_id').eq('id', id).eq('user_id', user.id).single() if (fetchError || !fc) return NextResponse.json({ error: 'Not found' }, { status: 404 }) const toggled = !fc.is_public @@ -22,7 +23,7 @@ export async function POST(_req: Request, { params }: Params) { const updateData = toggled ? { is_public: true, share_id } : { is_public: false } const { data: updated, error: updateError } = await supabase - .from('flowcharts').update(updateData).eq('id', id).select('is_public, share_id').single() + .from('flowcharts').update(updateData).eq('id', id).eq('user_id', user.id).select('is_public, share_id').single() if (updateError) return NextResponse.json({ error: 'Failed to update share settings' }, { status: 500 }) diff --git a/jest.config.js b/jest.config.js index a0f8045..06fd3bd 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,4 +2,5 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', moduleNameMapper: { '^@/(.*)$': '/$1' }, + testPathIgnorePatterns: ['/node_modules/', '/__tests__/helpers/'], } diff --git a/lib/supabase/server.ts b/lib/supabase/server.ts index 49139ce..dafcd82 100644 --- a/lib/supabase/server.ts +++ b/lib/supabase/server.ts @@ -20,23 +20,3 @@ export async function createClient() { } ) } - -export async function createServiceClient() { - const cookieStore = await cookies() - return createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.SUPABASE_SERVICE_ROLE_KEY!, - { - cookies: { - getAll() { return cookieStore.getAll() }, - setAll(cookiesToSet) { - try { - cookiesToSet.forEach(({ name, value, options }) => - cookieStore.set(name, value, options) - ) - } catch {} - }, - }, - } - ) -} From 3c4a2824a77df7120b3d4d2584c64582238db718 Mon Sep 17 00:00:00 2001 From: Emp1500 Date: Thu, 16 Jul 2026 07:49:14 +0000 Subject: [PATCH 7/9] fix: cap unbounded flowchart list queries at 100 rows --- __tests__/flowcharts-list-route.test.ts | 29 +++++++++++++++++++++++++ app/(protected)/dashboard/page.tsx | 1 + app/api/flowcharts/route.ts | 1 + 3 files changed, 31 insertions(+) create mode 100644 __tests__/flowcharts-list-route.test.ts diff --git a/__tests__/flowcharts-list-route.test.ts b/__tests__/flowcharts-list-route.test.ts new file mode 100644 index 0000000..c9bdc45 --- /dev/null +++ b/__tests__/flowcharts-list-route.test.ts @@ -0,0 +1,29 @@ +import { GET } from '@/app/api/flowcharts/route' +import { createQueryMock } from './helpers/supabase-query-mock' + +let flowchartsQuery: ReturnType + +jest.mock('@/lib/rate-limit', () => ({ + saveLimit: {}, + checkRateLimit: jest.fn(async () => ({ success: true, retryAfter: 0 })), +})) +jest.mock('@/lib/supabase/server', () => ({ + createClient: jest.fn(async () => ({ + auth: { getUser: jest.fn(async () => ({ data: { user: { id: 'user-1' } }, error: null })) }, + from: () => flowchartsQuery, + })), +})) + +describe('GET /api/flowcharts', () => { + beforeEach(() => { + flowchartsQuery = createQueryMock() + }) + + it('caps the list query so a single user cannot load an unbounded number of rows', async () => { + flowchartsQuery.queueResult({ data: [], error: null }) + + await GET() + + expect(flowchartsQuery.limit).toHaveBeenCalledWith(100) + }) +}) diff --git a/app/(protected)/dashboard/page.tsx b/app/(protected)/dashboard/page.tsx index 537c603..b9af141 100644 --- a/app/(protected)/dashboard/page.tsx +++ b/app/(protected)/dashboard/page.tsx @@ -14,6 +14,7 @@ export default async function DashboardPage() { .select('*') .eq('user_id', user!.id) .order('updated_at', { ascending: false }) + .limit(100) return (
diff --git a/app/api/flowcharts/route.ts b/app/api/flowcharts/route.ts index 6c04aae..b7346dc 100644 --- a/app/api/flowcharts/route.ts +++ b/app/api/flowcharts/route.ts @@ -13,6 +13,7 @@ export async function GET() { .select('id, title, language, is_public, share_id, created_at, updated_at') .eq('user_id', user.id) .order('updated_at', { ascending: false }) + .limit(100) if (error) { console.error('GET /api/flowcharts failed:', error) From 5c69b70fcdce0c15bf1e1cf18cf2c7b05196b491 Mon Sep 17 00:00:00 2001 From: Emp1500 Date: Thu, 16 Jul 2026 08:04:44 +0000 Subject: [PATCH 8/9] chore: remove unused thumbnail_url field (dead schema/type drift) --- docs/supabase-setup.sql | 1 - types/index.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/docs/supabase-setup.sql b/docs/supabase-setup.sql index e6bebdd..acfb963 100644 --- a/docs/supabase-setup.sql +++ b/docs/supabase-setup.sql @@ -14,7 +14,6 @@ CREATE TABLE IF NOT EXISTS flowcharts ( CHECK (language IN ('javascript', 'typescript', 'python')), is_public BOOLEAN NOT NULL DEFAULT false, share_id TEXT UNIQUE, - thumbnail_url TEXT, created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now() ); diff --git a/types/index.ts b/types/index.ts index 1f96244..38cb3a4 100644 --- a/types/index.ts +++ b/types/index.ts @@ -13,7 +13,6 @@ export interface Flowchart { language: Language is_public: boolean share_id: string | null - thumbnail_url: string | null created_at: string updated_at: string } From 17d67e9dad7834cc72d37645345bf806305fb522 Mon Sep 17 00:00:00 2001 From: Emp1500 Date: Thu, 16 Jul 2026 08:24:32 +0000 Subject: [PATCH 9/9] fix: scope PUT's meta-update and reload queries by user_id for defense-in-depth consistency --- app/api/flowcharts/[id]/route.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/api/flowcharts/[id]/route.ts b/app/api/flowcharts/[id]/route.ts index b04196a..ced80fe 100644 --- a/app/api/flowcharts/[id]/route.ts +++ b/app/api/flowcharts/[id]/route.ts @@ -48,7 +48,7 @@ export async function PUT(request: Request, { params }: Params) { const { code, ...meta } = parsed.data if (Object.keys(meta).length) { - const { error: metaError } = await supabase.from('flowcharts').update(meta).eq('id', id) + const { error: metaError } = await supabase.from('flowcharts').update(meta).eq('id', id).eq('user_id', user.id) if (metaError) return NextResponse.json({ error: 'Failed to update flowchart' }, { status: 500 }) } @@ -84,7 +84,7 @@ export async function PUT(request: Request, { params }: Params) { } } - const { data: updated, error: reloadError } = await supabase.from('flowcharts').select('*').eq('id', id).single() + const { data: updated, error: reloadError } = await supabase.from('flowcharts').select('*').eq('id', id).eq('user_id', user.id).single() if (reloadError) return NextResponse.json({ error: 'Failed to reload flowchart' }, { status: 500 }) return NextResponse.json(updated) }