feat: Phase 4 - Notes + Graph + Wikilinks

- Wikilink parser: [[Title]], [[Title|Display]], [[entity_type:Title]] patterns
- Notes REST API: CRUD under /api/domains/[domainId]/notes/ with wikilink sync
- Note link service: idempotent wikilink resolution, backlinks, outgoing links
- Notes list page: search, filter (all/pinned/archived), domain selector
- Note editor: TipTap with backlinks panel and outgoing links display
- Graph data API: /api/domains/[domainId]/graph and /api/graph
- Graph view page: D3 force-directed graph with entity type filters, search, zoom
- Keyboard shortcuts: g g → graph, c n → new note
- 18 passing wikilink parser tests
- All API routes follow AGENTS.md contract (activity + pg_notify)
This commit is contained in:
2026-07-29 06:56:23 -04:00
parent 064a46f97d
commit 40a26d2672
14 changed files with 1827 additions and 135 deletions
+352
View File
@@ -0,0 +1,352 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import dynamic from 'next/dynamic';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Search, ZoomIn, ZoomOut, RotateCcw, Filter } from 'lucide-react';
const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), {
ssr: false,
loading: () => (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">Loading graph...</p>
</div>
),
});
interface GraphNode {
id: string;
label: string;
type: string;
color: string;
}
interface GraphLink {
source: string;
target: string;
type: string;
}
interface GraphData {
nodes: GraphNode[];
links: GraphLink[];
}
const ENTITY_TYPE_COLORS: Record<string, string> = {
task: '#3b82f6',
habit: '#10b981',
project: '#8b5cf6',
note: '#f59e0b',
section: '#ec4899',
tag: '#6b7280',
domain: '#6366f1',
};
const ENTITY_TYPE_LABELS: Record<string, string> = {
task: 'Tasks',
habit: 'Habits',
project: 'Projects',
note: 'Notes',
section: 'Sections',
tag: 'Tags',
domain: 'Domains',
};
export default function GraphPage() {
const router = useRouter();
const [graphData, setGraphData] = useState<GraphData>({ nodes: [], links: [] });
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [domainId, setDomainId] = useState<string | null>(null);
const [domains, setDomains] = useState<{ id: string; name: string }[]>([]);
const [filterTypes, setFilterTypes] = useState<Set<string>>(new Set(['task', 'habit', 'project', 'note', 'section', 'tag', 'domain']));
const [hoveredNode, setHoveredNode] = useState<GraphNode | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const graphRef = useRef<any>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [dimensions, setDimensions] = useState({ width: 800, height: 600 });
// Fetch domains
useEffect(() => {
fetch('/api/domains?sort=sort_order')
.then((res) => res.json())
.then((data) => {
const items = data.items || [];
setDomains(items);
if (items.length > 0 && !domainId) {
setDomainId(items[0].id);
}
})
.catch(() => {});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Fetch graph data
useEffect(() => {
if (domainId) {
fetchGraphData();
}
}, [domainId]); // eslint-disable-line react-hooks/exhaustive-deps
// Update dimensions on resize
useEffect(() => {
function handleResize() {
if (containerRef.current) {
const { width, height } = containerRef.current.getBoundingClientRect();
setDimensions({ width: Math.floor(width) || 800, height: Math.floor(height) || 600 });
}
}
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
async function fetchGraphData() {
if (!domainId) return;
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/domains/${domainId}/graph`);
if (!response.ok) throw new Error('Unable to load graph data.');
const data = await response.json();
// Convert edges to links for react-force-graph-2d
setGraphData({
nodes: data.nodes || [],
links: (data.edges || []).map((e: any) => ({
source: e.source,
target: e.target,
type: e.type,
})),
});
} catch (error) {
console.error('Failed to fetch graph data:', error);
setError('Unable to load graph data.');
} finally {
setLoading(false);
}
}
const toggleFilterType = useCallback((type: string) => {
setFilterTypes((prev) => {
const next = new Set(prev);
if (next.has(type)) next.delete(type);
else next.add(type);
return next;
});
}, []);
// Filter nodes and links
const filteredData = {
nodes: graphData.nodes.filter(
(n) => filterTypes.has(n.type) && (!searchQuery || n.label.toLowerCase().includes(searchQuery.toLowerCase()))
),
links: graphData.links.filter(
(l) => {
const sourceNode = graphData.nodes.find((n) => n.id === l.source);
const targetNode = graphData.nodes.find((n) => n.id === l.target);
return sourceNode && targetNode && filterTypes.has(sourceNode.type) && filterTypes.has(targetNode.type);
}
),
};
function handleNodeClick(node: any) {
const n = node as GraphNode;
switch (n.type) {
case 'task':
router.push(`/tasks?taskId=${n.id}`);
break;
case 'habit':
router.push(`/habits?habitId=${n.id}`);
break;
case 'project':
router.push(`/projects/${n.id}`);
break;
case 'note':
router.push(`/notes?noteId=${n.id}`);
break;
case 'domain':
router.push(`/dashboard?domainId=${n.id}`);
break;
default:
break;
}
}
function handleZoomIn() {
if (graphRef.current) {
const current = graphRef.current.zoom();
graphRef.current.zoom(current * 1.3, 400);
}
}
function handleZoomOut() {
if (graphRef.current) {
const current = graphRef.current.zoom();
graphRef.current.zoom(current / 1.3, 400);
}
}
function handleReset() {
if (graphRef.current) {
graphRef.current.zoomToFit(400);
}
}
if (loading && graphData.nodes.length === 0) {
return <p className="text-muted-foreground" role="status">Loading graph...</p>;
}
if (error) {
return <div className="space-y-3"><p className="text-muted-foreground" role="alert">{error}</p><Button onClick={fetchGraphData}>Retry</Button></div>;
}
return (
<div className="flex h-[calc(100vh-120px)] flex-col">
<div className="mb-4 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Graph</h1>
<p className="mt-1 text-muted-foreground">
Visualize connections between all your entities
</p>
</div>
<div className="flex items-center gap-2">
{domains.length > 1 && (
<select
value={domainId || ''}
onChange={(e) => setDomainId(e.target.value)}
className="rounded-md border bg-background px-3 py-1.5 text-sm"
aria-label="Select domain"
>
{domains.map((d) => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</select>
)}
<Button variant="outline" size="icon" onClick={handleZoomIn} aria-label="Zoom in">
<ZoomIn className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" onClick={handleZoomOut} aria-label="Zoom out">
<ZoomOut className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" onClick={handleReset} aria-label="Reset view">
<RotateCcw className="h-4 w-4" />
</Button>
</div>
</div>
<div className="flex flex-1 gap-4">
{/* Filter sidebar */}
<Card className="w-56 shrink-0 p-4">
<div className="mb-3 flex items-center gap-2">
<Filter className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Filters</span>
</div>
<div className="mb-3">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search nodes..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-7 text-xs"
/>
</div>
</div>
<div className="space-y-1.5">
{Object.entries(ENTITY_TYPE_LABELS).map(([type, label]) => (
<button
key={type}
onClick={() => toggleFilterType(type)}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-xs transition-colors ${
filterTypes.has(type) ? 'bg-accent' : 'hover:bg-accent/50'
}`}
>
<span
className="h-2.5 w-2.5 rounded-full shrink-0"
style={{ backgroundColor: ENTITY_TYPE_COLORS[type] }}
/>
<span className="flex-1 text-left">{label}</span>
<Badge variant="outline" className="text-[10px] px-1">
{graphData.nodes.filter((n) => n.type === type).length}
</Badge>
</button>
))}
</div>
<div className="mt-4 border-t pt-3">
<p className="text-xs text-muted-foreground">
{filteredData.nodes.length} nodes · {filteredData.links.length} edges
</p>
</div>
</Card>
{/* Graph canvas */}
<Card className="flex-1 overflow-hidden" ref={containerRef}>
{filteredData.nodes.length === 0 ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">
{searchQuery ? 'No matching nodes found' : 'No graph data available'}
</p>
</div>
) : (
<div className="relative h-full w-full">
<ForceGraph2D
ref={graphRef}
graphData={filteredData}
nodeLabel="label"
nodeColor="color"
nodeVal={(node: any) => {
const edgeCount = filteredData.links.filter(
(e) => e.source === node.id || e.target === node.id
).length;
return Math.max(2, Math.min(edgeCount + 2, 20));
}}
linkColor={() => '#374151'}
linkWidth={0.5}
linkDirectionalArrowLength={4}
linkDirectionalArrowRelPos={0.99}
onNodeClick={(node: any) => handleNodeClick(node)}
onNodeHover={(node: any | null) => {
if (node) {
setHoveredNode(node as GraphNode);
} else {
setHoveredNode(null);
}
}}
width={dimensions.width}
height={dimensions.height}
d3AlphaDecay={0.02}
d3VelocityDecay={0.3}
cooldownTicks={100}
warmupTicks={40}
/>
{/* Tooltip */}
{hoveredNode && (
<div
className="pointer-events-none absolute left-4 top-4 z-10 rounded-lg border bg-background p-3 shadow-lg"
>
<div className="flex items-center gap-2">
<span
className="h-2.5 w-2.5 rounded-full shrink-0"
style={{ backgroundColor: hoveredNode.color }}
/>
<span className="text-sm font-medium">{hoveredNode.label}</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">
Type: {ENTITY_TYPE_LABELS[hoveredNode.type] || hoveredNode.type}
</p>
<p className="text-xs text-muted-foreground">
ID: {hoveredNode.id.slice(0, 8)}...
</p>
</div>
)}
</div>
)}
</Card>
</div>
</div>
);
}
+280 -134
View File
@@ -1,14 +1,14 @@
'use client';
import { useEffect, useRef, useState, Suspense } from 'react';
import { Plus, FileText, Link2, GitBranch, Trash2 } from 'lucide-react';
import { useState, useEffect, useRef, useCallback, Suspense } from 'react';
import { Plus, FileText, Link2, GitBranch, Trash2, Pin, Archive, Search, PinOff, ArchiveRestore } from 'lucide-react';
import dynamic from 'next/dynamic';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Badge } from '@/components/ui/badge';
import { DailyNoteButton } from '@/components/notes/daily-note-button';
import { Input } from '@/components/ui/input';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
AlertDialog,
@@ -21,8 +21,10 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
// Lazy load TipTap editor (~80KB TipTap + extensions)
// Lazy load TipTap editor
const NoteEditor = dynamic(
() => import('@/components/notes/note-editor').then((m) => m.NoteEditor),
{
@@ -35,93 +37,97 @@ const NoteEditor = dynamic(
}
);
// Lazy load react-force-graph-2d (~120KB + three.js)
const NoteGraph = dynamic(
() => import('@/components/notes/note-graph').then((m) => m.NoteGraph),
{
ssr: false,
loading: () => (
<div className="flex h-full items-center justify-center">
<div className="animate-pulse text-sm text-muted-foreground">Loading graph...</div>
</div>
),
}
);
interface Note {
id: string;
title: string;
content: string;
domain: string;
created: string;
updated: string;
content: string | null;
domainId: string;
isPinned: boolean;
isArchived: boolean;
createdAt: string;
updatedAt: string;
tags?: { id: string; name: string; color: string | null }[];
}
interface Backlink {
interface BacklinkItem {
id: string;
title: string;
excerpt: string;
}
interface OutgoingLink {
noteLinks: { id: string; title: string }[];
entityLinks: { entityType: string; entityId: string; title: string | null }[];
}
export default function NotesPage() {
const [notes, setNotes] = useState<Note[]>([]);
const [selectedNote, setSelectedNote] = useState<Note | null>(null);
const [domainForCreate, setDomainForCreate] = useState('');
const [domainOptions, setDomainOptions] = useState<{id: string; name: string}[]>([]);
const [backlinks, setBacklinks] = useState<Backlink[]>([]);
const [domainId, setDomainId] = useState<string | null>(null);
const [domains, setDomains] = useState<{ id: string; name: string; color: string | null }[]>([]);
const [backlinks, setBacklinks] = useState<BacklinkItem[]>([]);
const [outgoingLinks, setOutgoingLinks] = useState<OutgoingLink | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [backlinksError, setBacklinksError] = useState<string | null>(null);
const [backlinksLoading, setBacklinksLoading] = useState(false);
const [saveStatus, setSaveStatus] = useState<'Saved' | 'Saving' | 'Failed'>('Saved');
const [noteToDelete, setNoteToDelete] = useState<Note | null>(null);
const [deleting, setDeleting] = useState(false);
const createdFromQuery = useRef(false);
const pendingSave = useRef<{ id: string; updates: Partial<Note> } | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [filterMode, setFilterMode] = useState<'all' | 'pinned' | 'archived'>('all');
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const saveVersion = useRef(0);
const pendingSave = useRef<{ id: string; updates: Partial<Note> } | null>(null);
useEffect(() => { fetchDomains();
fetchNotes();
return () => {
if (saveTimer.current) clearTimeout(saveTimer.current);
};
}, []);
// Fetch domains on mount
useEffect(() => {
fetch('/api/domains?sort=sort_order')
.then((res) => res.json())
.then((data) => {
const items = data.items || [];
setDomains(items);
if (items.length > 0 && !domainId) {
setDomainId(items[0].id);
}
})
.catch(() => {});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { fetchDomains();
if (new URLSearchParams(window.location.search).get('new') !== 'true' || createdFromQuery.current) return;
createdFromQuery.current = true;
createNote().finally(() => window.history.replaceState(null, '', '/notes'));
// Route-triggered creation should only run once per page visit.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Fetch notes when domain or filter changes
useEffect(() => {
if (domainId) {
fetchNotes();
}
}, [domainId, filterMode]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { fetchDomains();
// Fetch backlinks when selected note changes
useEffect(() => {
if (selectedNote) {
fetchBacklinks(selectedNote.id);
fetchOutgoingLinks(selectedNote.id);
}
}, [selectedNote]);
async function fetchDomains() {
try {
const res = await fetch('/api/domains?sort=sort_order');
if (res.ok) {
const data = await res.json();
setDomainOptions(data.items || []);
if (data.items?.length > 0) setDomainForCreate(data.items[0].id);
}
} catch {}
}
}, [selectedNote]); // eslint-disable-line react-hooks/exhaustive-deps
async function fetchNotes() {
if (!domainId) return;
setLoading(true);
setError(null);
try {
const response = await fetch('/api/notes?sort=-updated');
const params = new URLSearchParams();
if (filterMode === 'pinned') params.set('pinned', 'true');
else if (filterMode === 'archived') params.set('archived', 'true');
else params.set('archived', 'false');
if (searchQuery) params.set('search', searchQuery);
params.set('sort', 'updated_at');
params.set('order', 'desc');
const response = await fetch(`/api/domains/${domainId}/notes?${params}`);
if (!response.ok) throw new Error('Unable to load notes.');
const data = await response.json();
const notesList = data.items || [];
setNotes(notesList);
setSelectedNote((current) => current || notesList[0] || null);
if (!selectedNote && notesList.length > 0) {
setSelectedNote(notesList[0]);
}
} catch (error) {
console.error('Failed to fetch notes:', error);
setError('Unable to load notes. Please try again.');
@@ -131,30 +137,40 @@ export default function NotesPage() {
}
async function fetchBacklinks(noteId: string) {
setBacklinksLoading(true);
setBacklinksError(null);
if (!domainId) return;
try {
const response = await fetch(`/api/notes/${noteId}/backlinks`);
const response = await fetch(`/api/domains/${domainId}/notes/${noteId}/backlinks`);
if (!response.ok) throw new Error('Unable to load backlinks.');
const data = await response.json();
setBacklinks(data.items || []);
} catch (error) {
console.error('Failed to fetch backlinks:', error);
setBacklinksError('Unable to load backlinks.');
} finally {
setBacklinksLoading(false);
setBacklinks([]);
}
}
async function fetchOutgoingLinks(noteId: string) {
if (!domainId) return;
try {
const response = await fetch(`/api/domains/${domainId}/notes/${noteId}`);
if (!response.ok) throw new Error('Unable to load note.');
const data = await response.json();
setOutgoingLinks(data.outgoingLinks || null);
} catch (error) {
console.error('Failed to fetch outgoing links:', error);
setOutgoingLinks(null);
}
}
async function createNote() {
if (!domainId) return;
try {
const response = await fetch('/api/notes', {
const response = await fetch(`/api/domains/${domainId}/notes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: 'Untitled note',
content: '',
domain: domainForCreate,
}),
});
if (!response.ok) throw new Error('Unable to create note.');
@@ -168,19 +184,6 @@ export default function NotesPage() {
}
}
function handleDailyNoteReady(raw: Record<string, unknown>) {
const note = raw as unknown as Note;
// If the note already appears in the list, just select it
const exists = notes.find((n) => n.id === note.id);
if (exists) {
setSelectedNote(exists);
return;
}
// Otherwise prepend it and select
setNotes((current) => [note, ...current]);
setSelectedNote(note);
}
function scheduleSave(noteId: string, updates: Partial<Note>) {
const version = ++saveVersion.current;
pendingSave.current = {
@@ -197,15 +200,16 @@ export default function NotesPage() {
}
async function updateNote(noteId: string, updates: Partial<Note>, version: number) {
if (!domainId) return;
try {
const response = await fetch(`/api/notes/${noteId}`, {
const response = await fetch(`/api/domains/${domainId}/notes/${noteId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
if (!response.ok) throw new Error('Unable to save note.');
const updated = await response.json();
setNotes((current) => current.map((note) => (note.id === noteId ? updated : note)));
setNotes((current) => current.map((note) => (note.id === noteId ? { ...note, ...updated } : note)));
if (saveVersion.current === version) setSaveStatus('Saved');
} catch (error) {
console.error('Failed to update note:', error);
@@ -215,7 +219,7 @@ export default function NotesPage() {
}
async function deleteNote() {
if (!noteToDelete) return;
if (!noteToDelete || !domainId) return;
if (pendingSave.current?.id === noteToDelete.id && saveTimer.current) {
clearTimeout(saveTimer.current);
pendingSave.current = null;
@@ -223,7 +227,7 @@ export default function NotesPage() {
++saveVersion.current;
setDeleting(true);
try {
const response = await fetch(`/api/notes/${noteToDelete.id}`, { method: 'DELETE' });
const response = await fetch(`/api/domains/${domainId}/notes/${noteToDelete.id}`, { method: 'DELETE' });
if (!response.ok) throw new Error('Unable to delete note.');
setNotes((current) => current.filter((note) => note.id !== noteToDelete.id));
setSelectedNote((selected) =>
@@ -239,11 +243,28 @@ export default function NotesPage() {
}
}
async function openBacklink(link: Backlink) {
async function togglePinned(note: Note) {
const newPinned = !note.isPinned;
setSelectedNote((prev) => prev?.id === note.id ? { ...prev, isPinned: newPinned } : prev);
setNotes((current) => current.map((n) => n.id === note.id ? { ...n, isPinned: newPinned } : n));
await updateNote(note.id, { isPinned: newPinned }, ++saveVersion.current);
toast.success(newPinned ? 'Note pinned' : 'Note unpinned');
}
async function toggleArchived(note: Note) {
const newArchived = !note.isArchived;
setSelectedNote((prev) => prev?.id === note.id ? { ...prev, isArchived: newArchived } : prev);
setNotes((current) => current.map((n) => n.id === note.id ? { ...n, isArchived: newArchived } : n));
await updateNote(note.id, { isArchived: newArchived }, ++saveVersion.current);
toast.success(newArchived ? 'Note archived' : 'Note restored');
}
async function openBacklink(link: BacklinkItem) {
const existing = notes.find((note) => note.id === link.id);
if (existing) return setSelectedNote(existing);
if (!domainId) return;
try {
const response = await fetch(`/api/notes/${link.id}`);
const response = await fetch(`/api/domains/${domainId}/notes/${link.id}`);
if (!response.ok) throw new Error('Unable to load linked note.');
const note = await response.json() as Note;
setNotes((current) => [note, ...current]);
@@ -254,11 +275,21 @@ export default function NotesPage() {
}
}
if (loading) {
const handleRefresh = useCallback(() => {
fetchNotes();
}, [domainId, filterMode, searchQuery]); // eslint-disable-line react-hooks/exhaustive-deps
// Filter notes locally for search
const filteredNotes = notes.filter((note) => {
if (searchQuery && !note.title.toLowerCase().includes(searchQuery.toLowerCase())) return false;
return true;
});
if (loading && notes.length === 0) {
return <p className="text-muted-foreground" role="status">Loading notes...</p>;
}
if (error) {
if (error && notes.length === 0) {
return <div className="space-y-3"><p className="text-muted-foreground" role="alert">{error}</p><Button onClick={fetchNotes}>Retry</Button></div>;
}
@@ -272,7 +303,18 @@ export default function NotesPage() {
</p>
</div>
<div className="flex items-center gap-3">
<DailyNoteButton onNoteReady={handleDailyNoteReady} />
{domains.length > 1 && (
<select
value={domainId || ''}
onChange={(e) => setDomainId(e.target.value)}
className="rounded-md border bg-background px-3 py-1.5 text-sm"
aria-label="Select domain"
>
{domains.map((d) => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</select>
)}
<Button onClick={createNote}>
<Plus className="mr-2 h-4 w-4" />
New note
@@ -280,14 +322,53 @@ export default function NotesPage() {
</div>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[250px_1fr_300px]">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[280px_1fr_320px]">
{/* Notes list */}
<Card className="max-h-80 lg:h-[calc(100vh-200px)] lg:max-h-none">
<ScrollArea className="max-h-80 lg:h-full lg:max-h-none">
<div className="border-b p-3">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search notes..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8"
/>
</div>
<div className="mt-2 flex gap-1">
<Button
variant={filterMode === 'all' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 text-xs"
onClick={() => setFilterMode('all')}
>
All
</Button>
<Button
variant={filterMode === 'pinned' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 text-xs"
onClick={() => setFilterMode('pinned')}
>
<Pin className="mr-1 h-3 w-3" />
Pinned
</Button>
<Button
variant={filterMode === 'archived' ? 'secondary' : 'ghost'}
size="sm"
className="h-7 text-xs"
onClick={() => setFilterMode('archived')}
>
<Archive className="mr-1 h-3 w-3" />
Archived
</Button>
</div>
</div>
<ScrollArea className="max-h-80 lg:h-[calc(100vh-260px)] lg:max-h-none">
<div className="p-2">
{notes.length === 0 ? (
{filteredNotes.length === 0 ? (
<div className="py-8 text-center">
<p className="text-sm text-muted-foreground">No notes yet</p>
<p className="text-sm text-muted-foreground">No notes found</p>
<Button className="mt-3" size="sm" onClick={createNote}>
<Plus className="mr-2 h-4 w-4" />
Create your first note
@@ -295,7 +376,7 @@ export default function NotesPage() {
</div>
) : (
<div className="space-y-1">
{notes.map((note) => (
{filteredNotes.map((note) => (
<button
key={note.id}
onClick={() => setSelectedNote(note)}
@@ -310,15 +391,25 @@ export default function NotesPage() {
<div className="flex items-start gap-2">
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{note.title}
</p>
<div className="flex items-center gap-1.5">
<p className="truncate text-sm font-medium">
{note.title}
</p>
{note.isPinned && <Pin className="h-3 w-3 shrink-0 text-amber-500" />}
{note.isArchived && <Archive className="h-3 w-3 shrink-0 text-muted-foreground" />}
</div>
<p className="mt-1 truncate text-xs text-muted-foreground">
{new Date(note.updated).toLocaleDateString()}
{new Date(note.updatedAt).toLocaleDateString()}
</p>
<Badge variant="outline" className="mt-1 text-xs">
{domainOptions.find(d => d.id === note.domain)?.name || note.domain}
</Badge>
{note.tags && note.tags.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{note.tags.slice(0, 3).map((tag) => (
<Badge key={tag.id} variant="outline" className="text-[10px] px-1.5 py-0">
{tag.name}
</Badge>
))}
</div>
)}
</div>
</div>
</button>
@@ -339,21 +430,36 @@ export default function NotesPage() {
</label>
<div className="flex items-center gap-3">
<input
id="note-title"
type="text"
value={selectedNote.title}
onChange={(e) => {
const title = e.target.value;
setSelectedNote({
...selectedNote,
title,
});
scheduleSave(selectedNote.id, { title });
}}
className="min-w-0 flex-1 text-xl font-semibold outline-none"
placeholder="Note title"
id="note-title"
type="text"
value={selectedNote.title}
onChange={(e) => {
const title = e.target.value;
setSelectedNote({ ...selectedNote, title });
scheduleSave(selectedNote.id, { title });
}}
className="min-w-0 flex-1 text-xl font-semibold outline-none"
placeholder="Note title"
/>
<span className="text-xs text-muted-foreground" role="status">{saveStatus}</span>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => togglePinned(selectedNote)}
aria-label={selectedNote.isPinned ? 'Unpin note' : 'Pin note'}
>
<Pin className={`h-4 w-4 ${selectedNote.isPinned ? 'fill-amber-500 text-amber-500' : ''}`} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => toggleArchived(selectedNote)}
aria-label={selectedNote.isArchived ? 'Restore note' : 'Archive note'}
>
{selectedNote.isArchived ? <ArchiveRestore className="h-4 w-4" /> : <Archive className="h-4 w-4" />}
</Button>
<AlertDialog open={noteToDelete?.id === selectedNote.id} onOpenChange={(open) => !open && setNoteToDelete(null)}>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" aria-label={`Delete ${selectedNote.title}`} onClick={() => setNoteToDelete(selectedNote)}>
@@ -378,8 +484,11 @@ export default function NotesPage() {
}
>
<NoteEditor
content={selectedNote.content}
onChange={(content) => { setSelectedNote({ ...selectedNote, content }); scheduleSave(selectedNote.id, { content }); }}
content={selectedNote.content || ''}
onChange={(content) => {
setSelectedNote({ ...selectedNote, content });
scheduleSave(selectedNote.id, { content });
}}
/>
</Suspense>
</div>
@@ -393,26 +502,26 @@ export default function NotesPage() {
)}
</Card>
{/* Backlinks and graph */}
{/* Backlinks and outgoing links panel */}
<Card className="min-h-[360px] lg:h-[calc(100vh-200px)]">
<Tabs defaultValue="backlinks" className="h-full">
<div className="border-b p-2">
<TabsList className="w-full">
<TabsTrigger value="backlinks" className="flex-1 gap-2">
<Link2 className="h-3 w-3" aria-hidden="true" />
Backlinks
Backlinks ({backlinks.length})
</TabsTrigger>
<TabsTrigger value="graph" className="flex-1 gap-2">
<TabsTrigger value="outgoing" className="flex-1 gap-2">
<GitBranch className="h-3 w-3" aria-hidden="true" />
Graph
Links
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="backlinks" className="h-full overflow-auto p-4">
{backlinksLoading ? <p className="py-8 text-center text-sm text-muted-foreground" role="status">Loading backlinks...</p> : backlinksError ? <div className="py-8 text-center"><p className="text-sm text-muted-foreground" role="alert">{backlinksError}</p><Button className="mt-3" size="sm" onClick={() => selectedNote && fetchBacklinks(selectedNote.id)}>Retry</Button></div> : backlinks.length === 0 ? (
{backlinks.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No backlinks
No backlinks no other notes link to this one
</p>
) : (
<div className="space-y-2">
@@ -425,28 +534,65 @@ export default function NotesPage() {
>
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
<span className="text-sm font-medium">
{link.title}
</span>
<span className="text-sm font-medium">{link.title}</span>
</div>
{link.excerpt && (
<p className="mt-1 text-xs text-muted-foreground line-clamp-2">{link.excerpt}</p>
)}
</button>
))}
</div>
)}
</TabsContent>
<TabsContent value="graph" className="h-full p-4">
<Suspense
fallback={
<div className="flex h-full items-center justify-center">
<div className="animate-pulse text-sm text-muted-foreground">
Loading graph...
<TabsContent value="outgoing" className="h-full overflow-auto p-4">
{!outgoingLinks ? (
<p className="py-8 text-center text-sm text-muted-foreground">Loading...</p>
) : outgoingLinks.noteLinks.length === 0 && outgoingLinks.entityLinks.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No outgoing links use [[Title]] to link to other notes
</p>
) : (
<div className="space-y-3">
{outgoingLinks.noteLinks.length > 0 && (
<div>
<p className="mb-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">Notes</p>
{outgoingLinks.noteLinks.map((link) => (
<button
key={link.id}
onClick={() => openBacklink({ id: link.id, title: link.title, excerpt: '' })}
className="w-full rounded-lg border p-2 text-left text-sm transition-colors hover:bg-accent"
>
<FileText className="mr-2 inline h-3 w-3 text-muted-foreground" />
{link.title}
</button>
))}
</div>
</div>
}
>
<NoteGraph notes={notes} />
</Suspense>
)}
{outgoingLinks.entityLinks.length > 0 && (
<div>
<p className="mb-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">Entities</p>
{outgoingLinks.entityLinks.map((link, i) => (
<div key={`${link.entityType}-${link.entityId}-${i}`} className="rounded-lg border p-2 text-sm">
<span
className="mr-1.5 inline-block h-2 w-2 rounded-full"
style={{
backgroundColor:
link.entityType === 'task' ? '#3b82f6' :
link.entityType === 'habit' ? '#10b981' :
link.entityType === 'project' ? '#8b5cf6' :
link.entityType === 'section' ? '#ec4899' :
link.entityType === 'tag' ? '#6b7280' : '#f59e0b',
}}
/>
<span className="text-xs text-muted-foreground">{link.entityType}:</span>{' '}
{link.title || link.entityId.slice(0, 8)}
</div>
))}
</div>
)}
</div>
)}
</TabsContent>
</Tabs>
</Card>
@@ -0,0 +1,24 @@
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
// 1. Insert activity feed entry
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
// See AGENTS.md for full rules.
import { NextRequest, NextResponse } from 'next/server';
import { withAuth, requireWorkspaceAccess } from '@/lib/auth';
import { getGraphData } from '@/lib/graph-service';
type RouteContext = { params: Promise<{ domainId: string }> };
// GET /api/domains/[domainId]/graph — Get graph data for one domain
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId } = await context!.params;
await requireWorkspaceAccess(domainId);
const graphData = await getGraphData(domainId);
return NextResponse.json(graphData, {
headers: {
'Cache-Control': 'private, max-age=30, stale-while-revalidate=120',
},
});
});
@@ -0,0 +1,23 @@
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
// 1. Insert activity feed entry
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
// See AGENTS.md for full rules.
import { NextRequest, NextResponse } from 'next/server';
import { withAuth, requireWorkspaceAccess, createErrorResponse } from '@/lib/auth';
import { getBacklinks } from '@/lib/note-link-service';
type RouteContext = { params: Promise<{ domainId: string; id: string }> };
// GET /api/domains/[domainId]/notes/[id]/backlinks — List notes that link to this one
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, id } = await context!.params;
await requireWorkspaceAccess(domainId);
const backlinks = await getBacklinks(id);
return NextResponse.json({
items: backlinks,
totalItems: backlinks.length,
});
});
@@ -0,0 +1,147 @@
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
// 1. Insert activity feed entry
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
// See AGENTS.md for full rules.
import { NextRequest, NextResponse } from 'next/server';
import { withAuth, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth';
import { recordActivity } from '@/lib/activity';
import { db, notes, noteTags, tags as tagsTable } from '@project-e/db';
import { and, eq, inArray, isNull } from 'drizzle-orm';
import { z } from 'zod';
import { syncNoteLinks, getBacklinks, getOutgoingLinks } from '@/lib/note-link-service';
const updateNoteSchema = z.object({
title: z.string().min(1).optional(),
content: z.string().optional().nullable(),
isPinned: z.boolean().optional(),
isArchived: z.boolean().optional(),
});
type RouteContext = { params: Promise<{ domainId: string; id: string }> };
// GET /api/domains/[domainId]/notes/[id] — Get a single note with computed links
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, id } = await context!.params;
await requireWorkspaceAccess(domainId);
const [note] = await db.select()
.from(notes)
.where(and(eq(notes.id, id), eq(notes.domainId, domainId), isNull(notes.deletedAt)))
.limit(1);
if (!note) {
return createErrorResponse('NOT_FOUND', 'Note not found', 404);
}
// Fetch tags
const tagRows = await db.select({
id: tagsTable.id,
name: tagsTable.name,
color: tagsTable.color,
})
.from(noteTags)
.innerJoin(tagsTable, eq(noteTags.tagId, tagsTable.id))
.where(eq(noteTags.noteId, id));
// Fetch backlinks and outgoing links
const [backlinks, outgoingLinks] = await Promise.all([
getBacklinks(id),
getOutgoingLinks(id),
]);
return NextResponse.json({
...note,
tags: tagRows,
backlinks,
outgoingLinks,
});
});
// PATCH /api/domains/[domainId]/notes/[id] — Update a note, re-parse wikilinks
export const PATCH = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, id } = await context!.params;
await requireWorkspaceAccess(domainId);
try {
const body = await request.json();
const data = updateNoteSchema.parse(body);
const [existing] = await db.select()
.from(notes)
.where(and(eq(notes.id, id), eq(notes.domainId, domainId), isNull(notes.deletedAt)))
.limit(1);
if (!existing) {
return createErrorResponse('NOT_FOUND', 'Note not found', 404);
}
const updateValues: Record<string, unknown> = {};
if (data.title !== undefined) updateValues.title = data.title;
if (data.content !== undefined) updateValues.content = data.content;
if (data.isPinned !== undefined) updateValues.isPinned = data.isPinned;
if (data.isArchived !== undefined) updateValues.isArchived = data.isArchived;
updateValues.updatedAt = new Date();
const [updated] = await db.update(notes)
.set(updateValues)
.where(eq(notes.id, id))
.returning();
// Re-sync wikilinks if content changed
const content = data.content ?? existing.content;
if (content) {
await syncNoteLinks(id, content);
}
await recordActivity({
actor: user.name,
action: 'updated',
entityType: 'note',
entityId: id,
changes: { ...data, previousTitle: existing.title },
workspaceId: domainId,
});
return NextResponse.json(updated);
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
if (error instanceof ApiError) {
return createErrorResponse(error.code, error.message, error.status);
}
console.error('[notes PATCH] error:', error);
return createErrorResponse('INTERNAL_ERROR', 'Failed to update note', 500);
}
});
// DELETE /api/domains/[domainId]/notes/[id] — Soft delete a note
export const DELETE = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, id } = await context!.params;
await requireWorkspaceAccess(domainId);
const [existing] = await db.select()
.from(notes)
.where(and(eq(notes.id, id), eq(notes.domainId, domainId), isNull(notes.deletedAt)))
.limit(1);
if (!existing) {
return createErrorResponse('NOT_FOUND', 'Note not found', 404);
}
await db.update(notes)
.set({ deletedAt: new Date(), updatedAt: new Date() })
.where(eq(notes.id, id));
await recordActivity({
actor: user.name,
action: 'deleted',
entityType: 'note',
entityId: id,
changes: { title: existing.title },
workspaceId: domainId,
});
return new NextResponse(null, { status: 204 });
});
@@ -0,0 +1,123 @@
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
// 1. Insert activity feed entry
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
// See AGENTS.md for full rules.
import { NextRequest, NextResponse } from 'next/server';
import { withAuth, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth';
import { recordActivity } from '@/lib/activity';
import { db, notes, noteTags, tags as tagsTable } from '@project-e/db';
import { and, eq, isNull } from 'drizzle-orm';
import { z } from 'zod';
const tagActionSchema = z.object({
tagId: z.string().uuid(),
});
type RouteContext = { params: Promise<{ domainId: string; id: string }> };
// POST /api/domains/[domainId]/notes/[id]/tags — Add a tag to a note
export const POST = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, id } = await context!.params;
await requireWorkspaceAccess(domainId);
try {
const body = await request.json();
const data = tagActionSchema.parse(body);
// Verify note exists
const [note] = await db.select()
.from(notes)
.where(and(eq(notes.id, id), eq(notes.domainId, domainId), isNull(notes.deletedAt)))
.limit(1);
if (!note) {
return createErrorResponse('NOT_FOUND', 'Note not found', 404);
}
// Verify tag exists
const [tag] = await db.select()
.from(tagsTable)
.where(eq(tagsTable.id, data.tagId))
.limit(1);
if (!tag) {
return createErrorResponse('NOT_FOUND', 'Tag not found', 404);
}
// Check if already tagged
const [existing] = await db.select()
.from(noteTags)
.where(and(eq(noteTags.noteId, id), eq(noteTags.tagId, data.tagId)))
.limit(1);
if (existing) {
return createErrorResponse('CONFLICT', 'Tag already added to this note', 409);
}
await db.insert(noteTags).values({ noteId: id, tagId: data.tagId });
await recordActivity({
actor: user.name,
action: 'tag_added',
entityType: 'note',
entityId: id,
changes: { tagId: data.tagId, tagName: tag.name },
workspaceId: domainId,
});
return NextResponse.json({ success: true }, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
if (error instanceof ApiError) {
return createErrorResponse(error.code, error.message, error.status);
}
console.error('[note tags POST] error:', error);
return createErrorResponse('INTERNAL_ERROR', 'Failed to add tag', 500);
}
});
// DELETE /api/domains/[domainId]/notes/[id]/tags — Remove a tag from a note
export const DELETE = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId, id } = await context!.params;
await requireWorkspaceAccess(domainId);
try {
const body = await request.json();
const data = tagActionSchema.parse(body);
const [existing] = await db.select()
.from(noteTags)
.where(and(eq(noteTags.noteId, id), eq(noteTags.tagId, data.tagId)))
.limit(1);
if (!existing) {
return createErrorResponse('NOT_FOUND', 'Tag not found on this note', 404);
}
await db.delete(noteTags)
.where(and(eq(noteTags.noteId, id), eq(noteTags.tagId, data.tagId)));
await recordActivity({
actor: user.name,
action: 'tag_removed',
entityType: 'note',
entityId: id,
changes: { tagId: data.tagId },
workspaceId: domainId,
});
return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
if (error instanceof ApiError) {
return createErrorResponse(error.code, error.message, error.status);
}
console.error('[note tags DELETE] error:', error);
return createErrorResponse('INTERNAL_ERROR', 'Failed to remove tag', 500);
}
});
@@ -0,0 +1,154 @@
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
// 1. Insert activity feed entry
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
// See AGENTS.md for full rules.
import { NextRequest, NextResponse } from 'next/server';
import { withAuth, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth';
import { recordActivity } from '@/lib/activity';
import { db, notes, noteTags, tags as tagsTable } from '@project-e/db';
import { and, asc, desc, eq, ilike, inArray, isNull, or, sql } from 'drizzle-orm';
import { z } from 'zod';
import { syncNoteLinks } from '@/lib/note-link-service';
const createNoteSchema = z.object({
title: z.string().min(1, 'Title is required'),
content: z.string().optional().nullable(),
isPinned: z.boolean().optional().default(false),
isArchived: z.boolean().optional().default(false),
tagIds: z.array(z.string().uuid()).optional(),
});
type RouteContext = { params: Promise<{ domainId: string }> };
// GET /api/domains/[domainId]/notes — List notes with filtering
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId } = await context!.params;
await requireWorkspaceAccess(domainId);
const { searchParams } = new URL(request.url);
const pinned = searchParams.get('pinned');
const archived = searchParams.get('archived');
const search = searchParams.get('search');
const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200);
const offset = parseInt(searchParams.get('offset') || '0');
const sort = searchParams.get('sort') || 'updated_at';
const order = searchParams.get('order') || 'desc';
const conditions: any[] = [
eq(notes.domainId, domainId),
isNull(notes.deletedAt),
];
if (pinned === 'true') conditions.push(eq(notes.isPinned, true));
if (archived === 'true') conditions.push(eq(notes.isArchived, true));
else if (archived !== 'all') conditions.push(eq(notes.isArchived, false));
if (search) conditions.push(ilike(notes.title, `%${search}%`));
const orderFn = order === 'desc' ? desc : asc;
let orderColumn;
switch (sort) {
case 'title': orderColumn = orderFn(notes.title); break;
case 'created_at': orderColumn = orderFn(notes.createdAt); break;
case 'is_pinned': orderColumn = orderFn(notes.isPinned); break;
default: orderColumn = orderFn(notes.updatedAt); break;
}
const [items, countResult] = await Promise.all([
db.select()
.from(notes)
.where(and(...conditions))
.orderBy(orderColumn)
.limit(limit)
.offset(offset),
db.select({ count: sql<number>`count(*)` })
.from(notes)
.where(and(...conditions)),
]);
const totalItems = Number(countResult[0]?.count || 0);
// Fetch tags for all notes
let noteTagMap = new Map<string, { id: string; name: string; color: string | null }[]>();
if (items.length > 0) {
const noteIds = items.map(n => n.id);
const tagRows = await db.select({
noteId: noteTags.noteId,
id: tagsTable.id,
name: tagsTable.name,
color: tagsTable.color,
})
.from(noteTags)
.innerJoin(tagsTable, eq(noteTags.tagId, tagsTable.id))
.where(inArray(noteTags.noteId, noteIds));
for (const row of tagRows) {
if (!noteTagMap.has(row.noteId)) noteTagMap.set(row.noteId, []);
noteTagMap.get(row.noteId)!.push({ id: row.id, name: row.name, color: row.color });
}
}
const itemsWithTags = items.map(n => ({
...n,
tags: noteTagMap.get(n.id) || [],
}));
return NextResponse.json({
items: itemsWithTags,
totalItems,
limit,
offset,
});
});
// POST /api/domains/[domainId]/notes — Create a note
export const POST = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
const { domainId } = await context!.params;
await requireWorkspaceAccess(domainId);
try {
const body = await request.json();
const data = createNoteSchema.parse(body);
const [note] = await db.insert(notes).values({
title: data.title,
content: data.content ?? null,
domainId,
isPinned: data.isPinned,
isArchived: data.isArchived,
}).returning();
// Insert tags if provided
if (data.tagIds && data.tagIds.length > 0) {
await db.insert(noteTags).values(
data.tagIds.map(tagId => ({ noteId: note.id, tagId }))
);
}
// Sync wikilinks from content
if (data.content) {
await syncNoteLinks(note.id, data.content);
}
// Record activity
await recordActivity({
actor: user.name,
action: 'created',
entityType: 'note',
entityId: note.id,
changes: { title: note.title },
workspaceId: domainId,
});
return NextResponse.json(note, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
}
if (error instanceof ApiError) {
return createErrorResponse(error.code, error.message, error.status);
}
console.error('[notes POST] error:', error);
return createErrorResponse('INTERNAL_ERROR', 'Failed to create note', 500);
}
});
+19
View File
@@ -0,0 +1,19 @@
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
// 1. Insert activity feed entry
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
// See AGENTS.md for full rules.
import { NextRequest, NextResponse } from 'next/server';
import { withAuth } from '@/lib/auth';
import { getGlobalGraphData } from '@/lib/graph-service';
// GET /api/graph — Get global graph data (all domains the user has access to)
export const GET = withAuth(async (request: NextRequest, user) => {
const graphData = await getGlobalGraphData();
return NextResponse.json(graphData, {
headers: {
'Cache-Control': 'private, max-age=30, stale-while-revalidate=120',
},
});
});