diff --git a/backend/src/modules/notes/routes.integration.test.ts b/backend/src/modules/notes/routes.integration.test.ts index 3646105..8eb5719 100644 --- a/backend/src/modules/notes/routes.integration.test.ts +++ b/backend/src/modules/notes/routes.integration.test.ts @@ -94,6 +94,18 @@ describe('notes routes — integration', () => { expect(res.json().status).toBe('archived'); }); + it('POST /notes/:id/restore sets status to active', async () => { + await app.inject({ method: 'POST', url: '/api/notes', payload: validNote }); + await app.inject({ method: 'POST', url: '/api/notes/note-1/archive', payload: { workspaceId: 'ws-1' } }); + const res = await app.inject({ + method: 'POST', + url: '/api/notes/note-1/restore', + payload: { workspaceId: 'ws-1' }, + }); + expect(res.statusCode).toBe(200); + expect(res.json().status).toBe('active'); + }); + it('POST /notes rejects invalid body', async () => { const res = await app.inject({ method: 'POST', url: '/api/notes', payload: { id: 'x' } }); expect(res.statusCode).toBe(400); diff --git a/backend/src/modules/notes/routes.test.ts b/backend/src/modules/notes/routes.test.ts index 98c2a57..d9ef6ad 100644 --- a/backend/src/modules/notes/routes.test.ts +++ b/backend/src/modules/notes/routes.test.ts @@ -39,7 +39,7 @@ describe('noteRoutes', () => { await noteRoutes(app as never); expect(app.get).toHaveBeenCalledTimes(3); - expect(app.post).toHaveBeenCalledTimes(2); + expect(app.post).toHaveBeenCalledTimes(3); expect(app.patch).toHaveBeenCalledTimes(1); }); }); diff --git a/backend/src/modules/notes/routes.ts b/backend/src/modules/notes/routes.ts index 53f88ab..bdc63bf 100644 --- a/backend/src/modules/notes/routes.ts +++ b/backend/src/modules/notes/routes.ts @@ -120,6 +120,33 @@ export async function noteRoutes(app: RouteApp) { return updated; }); + app.post('/notes/:id/restore', async req => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + const workspaceId = (req.body as { workspaceId?: string } | undefined)?.workspaceId; + + if (!workspaceId) { + throw new BadRequestError('workspaceId is required'); + } + + const existing = await repo.getNote(id, workspaceId); + if (!existing || existing.userId !== auth.sub || existing.productId !== PRODUCT_ID) { + throw new NotFoundError('Note not found'); + } + + const updated = await repo.updateNote(id, workspaceId, { + status: 'active', + updatedAt: new Date().toISOString(), + updatedBy: auth.sub, + }); + + if (!updated) { + throw new NotFoundError('Note not found'); + } + + return updated; + }); + app.post('/notes/:id/archive', async req => { const auth = await extractAuth(req); const { id } = req.params as { id: string }; diff --git a/web/src/app/(app)/dashboard/page.tsx b/web/src/app/(app)/dashboard/page.tsx index 0622711..1e9f7e7 100644 --- a/web/src/app/(app)/dashboard/page.tsx +++ b/web/src/app/(app)/dashboard/page.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { useEffect, useState } from "react"; import { AppShell } from "@/components/AppShell"; +import { CreateNoteModal } from "@/components/CreateNoteModal"; import { listNoteSummaries, listWorkspaceSummaries } from "@/lib/notes-client"; import { listApprovalQueue } from "@/lib/review-client"; import type { NoteSummary, WorkspaceSummary } from "@/lib/types"; @@ -12,6 +13,7 @@ export default function DashboardPage() { const [workspaces, setWorkspaces] = useState([]); const [pendingReviewCount, setPendingReviewCount] = useState(0); const [error, setError] = useState(null); + const [showCreateNote, setShowCreateNote] = useState(false); useEffect(() => { void (async () => { @@ -123,7 +125,11 @@ export default function DashboardPage() { Operational shell} + actions={ + + } >
{summaryCards.map((card) => ( @@ -210,6 +216,16 @@ export default function DashboardPage() { ))}
+ {showCreateNote && ( + { + setShowCreateNote(false); + window.location.reload(); + }} + onClose={() => setShowCreateNote(false)} + /> + )}
); } diff --git a/web/src/app/(app)/notes/[noteId]/page.tsx b/web/src/app/(app)/notes/[noteId]/page.tsx index bca2e6e..d7bcdb2 100644 --- a/web/src/app/(app)/notes/[noteId]/page.tsx +++ b/web/src/app/(app)/notes/[noteId]/page.tsx @@ -10,7 +10,8 @@ import { LinkedNotesPanel } from "@/components/LinkedNotesPanel"; import { TaskReviewPanel } from "@/components/TaskReviewPanel"; import { ArtifactPanel } from "@/components/ArtifactPanel"; import { AgentTimeline } from "@/components/AgentTimeline"; -import { createNoteArtifact, createNoteTask, getNoteDetail, updateNoteDetail } from "@/lib/notes-client"; +import { LinkNoteModal } from "@/components/LinkNoteModal"; +import { archiveNote, createNoteArtifact, createNoteTask, getNoteDetail, restoreNote, updateNoteDetail } from "@/lib/notes-client"; import type { NoteDetail } from "@/lib/types"; export default function NoteDetailPage() { @@ -21,6 +22,7 @@ export default function NoteDetailPage() { const [isCreatingTask, setIsCreatingTask] = useState(false); const [isCreatingArtifact, setIsCreatingArtifact] = useState(false); const [error, setError] = useState(null); + const [showLinkNote, setShowLinkNote] = useState(false); useEffect(() => { void (async () => { @@ -109,6 +111,26 @@ export default function NoteDetailPage() { } } + async function handleArchive() { + if (!note) return; + try { + await archiveNote(note.id, note.workspaceId); + setNote(await getNoteDetail(note.id, note.workspaceId)); + } catch (err) { + setError(err instanceof Error ? err.message : "Unable to archive note"); + } + } + + async function handleRestore() { + if (!note) return; + try { + await restoreNote(note.id, note.workspaceId); + setNote(await getNoteDetail(note.id, note.workspaceId)); + } catch (err) { + setError(err instanceof Error ? err.message : "Unable to restore note"); + } + } + if (!note) { return ( Saving - ) : ( - - {`Review: ${note.metadata.reviewState}`} - - ) +
+ {isSaving ? ( +
Saving
+ ) : ( + + {`Review: ${note.metadata.reviewState}`} + + )} + + {note.status === "archived" ? ( + + ) : ( + + )} +
} >
@@ -149,6 +181,18 @@ export default function NoteDetailPage() {
+ {showLinkNote && ( + ln.id)} + onLinked={() => { + setShowLinkNote(false); + void getNoteDetail(note.id, note.workspaceId).then(setNote); + }} + onClose={() => setShowLinkNote(false)} + /> + )}
); } diff --git a/web/src/components/CreateNoteModal.test.tsx b/web/src/components/CreateNoteModal.test.tsx new file mode 100644 index 0000000..4d88152 --- /dev/null +++ b/web/src/components/CreateNoteModal.test.tsx @@ -0,0 +1,57 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; + +const createNoteMock = vi.fn(); + +vi.mock("@/lib/notes-client", () => ({ + createNote: (...args: unknown[]) => createNoteMock(...args), +})); + +import { CreateNoteModal } from "./CreateNoteModal"; + +const workspaces = [ + { id: "ws-1", name: "Product", description: "", owner: "u1", noteCount: 5, visibility: "private" as const, updatedAt: "", tags: [] }, + { id: "ws-2", name: "Design", description: "", owner: "u1", noteCount: 2, visibility: "private" as const, updatedAt: "", tags: [] }, +]; + +describe("CreateNoteModal", () => { + beforeEach(() => { + createNoteMock.mockReset(); + }); + + it("renders form fields", () => { + render(); + expect(screen.getByText("Create Note")).toBeDefined(); + expect(screen.getByPlaceholderText("Note title")).toBeDefined(); + expect(screen.getByPlaceholderText("Note content...")).toBeDefined(); + }); + + it("disables submit when title or body is empty", () => { + render(); + const btn = screen.getByText("Create"); + expect((btn as HTMLButtonElement).disabled).toBe(true); + }); + + it("calls createNote and onCreated on submit", async () => { + createNoteMock.mockResolvedValueOnce({}); + const onCreated = vi.fn(); + render(); + + fireEvent.change(screen.getByPlaceholderText("Note title"), { target: { value: "My Note" } }); + fireEvent.change(screen.getByPlaceholderText("Note content..."), { target: { value: "Some body" } }); + fireEvent.click(screen.getByText("Create")); + + await waitFor(() => expect(createNoteMock).toHaveBeenCalledTimes(1)); + expect(createNoteMock.mock.calls[0][0].title).toBe("My Note"); + expect(createNoteMock.mock.calls[0][0].body).toBe("Some body"); + expect(createNoteMock.mock.calls[0][0].workspaceId).toBe("ws-1"); + await waitFor(() => expect(onCreated).toHaveBeenCalled()); + }); + + it("calls onClose when Cancel is clicked", () => { + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByText("Cancel")); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/web/src/components/CreateNoteModal.tsx b/web/src/components/CreateNoteModal.tsx new file mode 100644 index 0000000..5d1956c --- /dev/null +++ b/web/src/components/CreateNoteModal.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { useState } from "react"; +import { createNote } from "@/lib/notes-client"; +import type { WorkspaceSummary } from "@/lib/types"; + +interface CreateNoteModalProps { + workspaces: WorkspaceSummary[]; + defaultWorkspaceId?: string; + onCreated: () => void; + onClose: () => void; +} + +export function CreateNoteModal({ workspaces, defaultWorkspaceId, onCreated, onClose }: CreateNoteModalProps) { + const [title, setTitle] = useState(""); + const [body, setBody] = useState(""); + const [workspaceId, setWorkspaceId] = useState(defaultWorkspaceId ?? workspaces[0]?.id ?? ""); + const [tags, setTags] = useState(""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const canSubmit = title.trim().length > 0 && body.trim().length > 0 && workspaceId.length > 0; + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!canSubmit || saving) return; + + setSaving(true); + setError(null); + + try { + await createNote({ + id: crypto.randomUUID(), + workspaceId, + title: title.trim(), + body: body.trim(), + tags: tags + .split(",") + .map((t) => t.trim()) + .filter(Boolean), + sourceType: "manual", + }); + onCreated(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to create note"); + } finally { + setSaving(false); + } + } + + return ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+
Create Note
+ + {error &&
{error}
} + + + + + +