feat: Add delete functionality for user-authored nodes
- Add DELETE /api/nodes/[id] endpoint for deleting nodes - Verify user authentication and ownership before deletion - Delete from ATproto (source of truth) first, then SurrealDB cache - Add delete button in ThoughtGalaxy component for user's own nodes - Add confirmation modal before deletion - Fix Modal z-index to appear above node detail panel (zIndex: 1001) - Fix RecordId encoding issue (strip angle brackets ⟨⟩ from IDs) - Remove deleted node and associated links from local state - Add comprehensive Magnitude tests for delete functionality 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
131
app/api/nodes/[id]/route.ts
Normal file
131
app/api/nodes/[id]/route.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { Agent } from '@atproto/api';
|
||||
import { connectToDB } from '@/lib/db';
|
||||
import { verifySurrealJwt } from '@/lib/auth/jwt';
|
||||
import { getOAuthClient } from '@/lib/auth/oauth-client';
|
||||
|
||||
/**
|
||||
* DELETE /api/nodes/[id]
|
||||
*
|
||||
* Deletes a node from both ATproto (source of truth) and SurrealDB (cache).
|
||||
*
|
||||
* Process:
|
||||
* 1. Verify user authentication and ownership
|
||||
* 2. Fetch node from SurrealDB to get atp_uri
|
||||
* 3. Delete post(s) from ATproto/Bluesky
|
||||
* 4. Delete node from SurrealDB cache
|
||||
*
|
||||
* Note: ATproto is the source of truth. If ATproto deletion fails, we don't delete from cache.
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const cookieStore = await cookies();
|
||||
const surrealJwt = cookieStore.get('ponderants-auth')?.value;
|
||||
|
||||
console.log('[DELETE /api/nodes/[id]] Auth check:', {
|
||||
hasSurrealJwt: !!surrealJwt,
|
||||
});
|
||||
|
||||
if (!surrealJwt) {
|
||||
console.error('[DELETE /api/nodes/[id]] Missing auth cookie');
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Verify the JWT and extract user info
|
||||
const userSession = verifySurrealJwt(surrealJwt);
|
||||
if (!userSession) {
|
||||
console.error('[DELETE /api/nodes/[id]] Invalid JWT');
|
||||
return NextResponse.json({ error: 'Invalid auth token' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { did: userDid } = userSession;
|
||||
const { id } = await params;
|
||||
|
||||
console.log('[DELETE /api/nodes/[id]] Deleting node:', { nodeId: id, userDid });
|
||||
|
||||
try {
|
||||
// 1. Fetch the node from SurrealDB to verify ownership and get atp_uri
|
||||
const db = await connectToDB();
|
||||
const nodeResult = await db.query<[Array<{ id: string; user_did: string; atp_uri: string }>]>(
|
||||
'SELECT id, user_did, atp_uri FROM node WHERE id = $nodeId',
|
||||
{ nodeId: id }
|
||||
);
|
||||
|
||||
const node = nodeResult[0]?.[0];
|
||||
|
||||
if (!node) {
|
||||
console.error('[DELETE /api/nodes/[id]] Node not found:', id);
|
||||
return NextResponse.json({ error: 'Node not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// 2. Verify ownership
|
||||
if (node.user_did !== userDid) {
|
||||
console.error('[DELETE /api/nodes/[id]] Unauthorized: user does not own node');
|
||||
return NextResponse.json(
|
||||
{ error: 'You do not have permission to delete this node' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Delete from ATproto (source of truth)
|
||||
try {
|
||||
const client = await getOAuthClient();
|
||||
console.log('[DELETE /api/nodes/[id]] Got OAuth client, restoring session for DID:', userDid);
|
||||
|
||||
const session = await client.restore(userDid);
|
||||
const agent = new Agent(session);
|
||||
|
||||
console.log('[DELETE /api/nodes/[id]] Successfully restored OAuth session');
|
||||
|
||||
// Parse the atp_uri to get repo and rkey
|
||||
// Format: at://did:plc:xxx/app.bsky.feed.post/xxxxx
|
||||
const atUriMatch = node.atp_uri.match(/at:\/\/([^/]+)\/([^/]+)\/(.+)/);
|
||||
if (!atUriMatch) {
|
||||
throw new Error(`Invalid atp_uri format: ${node.atp_uri}`);
|
||||
}
|
||||
|
||||
const [, repo, collection, rkey] = atUriMatch;
|
||||
|
||||
console.log('[DELETE /api/nodes/[id]] Deleting ATproto record:', {
|
||||
repo,
|
||||
collection,
|
||||
rkey,
|
||||
});
|
||||
|
||||
// Delete the post from ATproto
|
||||
await agent.api.com.atproto.repo.deleteRecord({
|
||||
repo,
|
||||
collection,
|
||||
rkey,
|
||||
});
|
||||
|
||||
console.log('[DELETE /api/nodes/[id]] ✓ Deleted post from ATproto');
|
||||
} catch (error) {
|
||||
console.error('[DELETE /api/nodes/[id]] ATproto deletion error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete post from Bluesky. Node not deleted.' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Delete from SurrealDB cache (only after successful ATproto deletion)
|
||||
try {
|
||||
await db.delete(id);
|
||||
console.log('[DELETE /api/nodes/[id]] ✓ Deleted node from SurrealDB cache');
|
||||
} catch (error) {
|
||||
console.warn('[DELETE /api/nodes/[id]] ⚠ SurrealDB cache deletion failed (non-critical):', error);
|
||||
// This is non-critical since ATproto (source of truth) was successfully deleted
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, nodeId: id });
|
||||
} catch (error) {
|
||||
console.error('[DELETE /api/nodes/[id]] Error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete node' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
Text,
|
||||
} from '@react-three/drei';
|
||||
import { Suspense, useEffect, useRef, useState } from 'react';
|
||||
import { Stack, Text as MantineText, Paper, Title, Box, CloseButton, Group, Anchor, useComputedColorScheme } from '@mantine/core';
|
||||
import { Stack, Text as MantineText, Paper, Title, Box, CloseButton, Group, Anchor, useComputedColorScheme, Button, Modal } from '@mantine/core';
|
||||
import { IconTrash } from '@tabler/icons-react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
||||
import * as THREE from 'three';
|
||||
|
||||
@@ -96,6 +98,9 @@ export function ThoughtGalaxy() {
|
||||
const [links, setLinks] = useState<LinkData[]>([]);
|
||||
const [selectedNode, setSelectedNode] = useState<NodeData | null>(null);
|
||||
const [emptyMessage, setEmptyMessage] = useState<string | null>(null);
|
||||
const [currentUserDid, setCurrentUserDid] = useState<string | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const cameraControlsRef = useRef<CameraControls>(null);
|
||||
const hasFitCamera = useRef(false);
|
||||
const hasFocusedNode = useRef<string | null>(null);
|
||||
@@ -104,6 +109,28 @@ export function ThoughtGalaxy() {
|
||||
const selectedNodeId = searchParams.get('node');
|
||||
const targetUserDid = searchParams.get('user'); // For viewing someone else's galaxy
|
||||
|
||||
// Fetch current user's profile to get their DID
|
||||
useEffect(() => {
|
||||
async function fetchCurrentUser() {
|
||||
try {
|
||||
const response = await fetch('/api/user/profile', {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCurrentUserDid(data.did);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ThoughtGalaxy] Error fetching current user:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Only fetch current user if we're viewing our own galaxy
|
||||
if (!targetUserDid) {
|
||||
fetchCurrentUser();
|
||||
}
|
||||
}, [targetUserDid]);
|
||||
|
||||
// Fetch data from API on mount and poll for updates
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
@@ -290,6 +317,51 @@ export function ThoughtGalaxy() {
|
||||
router.replace(`${pathname}${newSearch ? `?${newSearch}` : ''}`, { scroll: false });
|
||||
};
|
||||
|
||||
// Handle deleting a node
|
||||
const handleDeleteNode = async () => {
|
||||
if (!selectedNode) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
setDeleteConfirmOpen(false);
|
||||
|
||||
try {
|
||||
// Extract clean ID from SurrealDB RecordId format (removes angle brackets ⟨⟩)
|
||||
const cleanId = String(selectedNode.id).replace(/[⟨⟩]/g, '');
|
||||
|
||||
const response = await fetch(`/api/nodes/${cleanId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to delete node');
|
||||
}
|
||||
|
||||
notifications.show({
|
||||
title: 'Node deleted',
|
||||
message: 'Node has been deleted from Bluesky and your galaxy',
|
||||
color: 'green',
|
||||
});
|
||||
|
||||
// Remove the node from local state
|
||||
setNodes((prevNodes) => prevNodes.filter((n) => n.id !== selectedNode.id));
|
||||
setLinks((prevLinks) => prevLinks.filter((l) => l.in !== selectedNode.id && l.out !== selectedNode.id));
|
||||
|
||||
// Close the modal
|
||||
handleCloseModal();
|
||||
} catch (error) {
|
||||
console.error('[ThoughtGalaxy] Delete error:', error);
|
||||
notifications.show({
|
||||
title: 'Delete failed',
|
||||
message: error instanceof Error ? error.message : 'Failed to delete node',
|
||||
color: 'red',
|
||||
});
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
console.log('[ThoughtGalaxy] Rendering with', nodes.length, 'nodes and', linkLines.length, 'link lines');
|
||||
|
||||
// Show message if no nodes are ready yet
|
||||
@@ -360,15 +432,30 @@ export function ThoughtGalaxy() {
|
||||
<Title order={2} style={{ margin: 0, marginBottom: '0.25rem' }}>
|
||||
{selectedNode.title}
|
||||
</Title>
|
||||
<Anchor
|
||||
href={`https://bsky.app/profile/${selectedNode.user_did}/post/${selectedNode.atp_uri.split('/').pop()}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
>
|
||||
View on Bluesky
|
||||
</Anchor>
|
||||
<Group gap="sm" mt="xs">
|
||||
<Anchor
|
||||
href={`https://bsky.app/profile/${selectedNode.user_did}/post/${selectedNode.atp_uri.split('/').pop()}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
>
|
||||
View on Bluesky
|
||||
</Anchor>
|
||||
{/* Show delete button only for user's own nodes */}
|
||||
{currentUserDid && selectedNode.user_did === currentUserDid && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
leftSection={<IconTrash size={14} />}
|
||||
onClick={() => setDeleteConfirmOpen(true)}
|
||||
loading={isDeleting}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Box>
|
||||
<CloseButton
|
||||
size="lg"
|
||||
@@ -394,6 +481,43 @@ export function ThoughtGalaxy() {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation modal */}
|
||||
<Modal
|
||||
opened={deleteConfirmOpen}
|
||||
onClose={() => setDeleteConfirmOpen(false)}
|
||||
title="Delete Node"
|
||||
centered
|
||||
zIndex={1001}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<MantineText>
|
||||
Are you sure you want to delete this node? This will:
|
||||
</MantineText>
|
||||
<Stack gap="xs" ml="md">
|
||||
<MantineText size="sm">• Remove the post from Bluesky</MantineText>
|
||||
<MantineText size="sm">• Delete the node from your galaxy</MantineText>
|
||||
<MantineText size="sm" fw={600} c="red">This action cannot be undone.</MantineText>
|
||||
</Stack>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => setDeleteConfirmOpen(false)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onClick={handleDeleteNode}
|
||||
loading={isDeleting}
|
||||
leftSection={<IconTrash size={16} />}
|
||||
>
|
||||
Delete Permanently
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Canvas
|
||||
camera={{ position: [0, 5, 10], fov: 60 }}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
|
||||
137
tests/magnitude/03-delete-node.mag.ts
Normal file
137
tests/magnitude/03-delete-node.mag.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { test } from 'magnitude-test';
|
||||
|
||||
const TEST_HANDLE = process.env.TEST_BLUESKY_HANDLE;
|
||||
const TEST_PASSWORD = process.env.TEST_BLUESKY_PASSWORD;
|
||||
|
||||
if (!TEST_HANDLE || !TEST_PASSWORD) {
|
||||
throw new Error('TEST_BLUESKY_HANDLE and TEST_BLUESKY_PASSWORD must be set in .env');
|
||||
}
|
||||
|
||||
test('User can delete their own node from galaxy view', async (agent) => {
|
||||
// Act: Log in
|
||||
await agent.act('Navigate to /login');
|
||||
await agent.act(`Type "${TEST_HANDLE}" into the "Your Handle" input field`);
|
||||
await agent.act('Click the "Log in with Bluesky" button');
|
||||
await agent.check('The page URL contains "bsky.social"');
|
||||
await agent.act(`Type "${TEST_HANDLE}" into the username/identifier field`);
|
||||
await agent.act(`Type "${TEST_PASSWORD}" into the password field`);
|
||||
await agent.act('Click the submit/authorize button');
|
||||
await agent.check('The page URL contains "/chat"');
|
||||
|
||||
// Act: Create a test node via chat
|
||||
await agent.act('Type "This is a test node for deletion" into the chat input');
|
||||
await agent.act('Press Enter or click send');
|
||||
await agent.check('AI responds with a message');
|
||||
|
||||
// Act: Trigger node creation
|
||||
await agent.act('Wait for the AI to suggest creating a node or manually trigger node creation');
|
||||
await agent.check('A node draft is created in the editor');
|
||||
|
||||
// Act: Publish the node
|
||||
await agent.act('Click the "Publish" button');
|
||||
await agent.check('A success notification appears');
|
||||
await agent.check('The node is published to Bluesky');
|
||||
|
||||
// Act: Navigate to Galaxy view
|
||||
await agent.act('Click the "Galaxy" navigation link');
|
||||
await agent.check('The galaxy visualization loads');
|
||||
await agent.check('At least one node is visible in the 3D galaxy view');
|
||||
|
||||
// Act: Click on the newly created node
|
||||
await agent.act('Click on the test node in the galaxy view');
|
||||
await agent.check('A node detail panel opens showing the node title and body');
|
||||
await agent.check('The node detail panel shows "This is a test node for deletion"');
|
||||
|
||||
// Check: Verify delete button is visible (only for user\'s own nodes)
|
||||
await agent.check('A "Delete" button is visible in the node detail panel');
|
||||
|
||||
// Act: Click the delete button
|
||||
await agent.act('Click the "Delete" button');
|
||||
|
||||
// Check: Verify delete confirmation modal appears
|
||||
await agent.check('A delete confirmation modal appears');
|
||||
await agent.check('The modal is displayed above the node detail panel');
|
||||
await agent.check('The modal shows "Are you sure you want to delete this node?"');
|
||||
await agent.check('The modal explains this will remove the post from Bluesky');
|
||||
await agent.check('The modal has a "Delete Permanently" button');
|
||||
await agent.check('The modal has a "Cancel" button');
|
||||
|
||||
// Act: Confirm deletion
|
||||
await agent.act('Click the "Delete Permanently" button');
|
||||
|
||||
// Check: Verify deletion succeeded
|
||||
await agent.check('A success notification appears saying "Node deleted"');
|
||||
await agent.check('The node detail panel closes');
|
||||
await agent.check('The node is no longer visible in the galaxy view');
|
||||
|
||||
// Act: Verify node is deleted from Bluesky
|
||||
await agent.act('Navigate to the user\'s Bluesky profile');
|
||||
await agent.check('The test node "This is a test node for deletion" is not visible on Bluesky');
|
||||
});
|
||||
|
||||
test('Delete button is not shown for other users\' nodes', async (agent) => {
|
||||
// This test would require viewing another user's public galaxy
|
||||
// Skipping for now as it requires a second test account
|
||||
await agent.act('Skip this test - requires second test account');
|
||||
});
|
||||
|
||||
test('Cancel button closes delete confirmation without deleting', async (agent) => {
|
||||
// Act: Log in
|
||||
await agent.act('Navigate to /login');
|
||||
await agent.act(`Type "${TEST_HANDLE}" into the "Your Handle" input field`);
|
||||
await agent.act('Click the "Log in with Bluesky" button');
|
||||
await agent.check('The page URL contains "bsky.social"');
|
||||
await agent.act(`Type "${TEST_HANDLE}" into the username/identifier field`);
|
||||
await agent.act(`Type "${TEST_PASSWORD}" into the password field`);
|
||||
await agent.act('Click the submit/authorize button');
|
||||
await agent.check('The page URL contains "/chat"');
|
||||
|
||||
// Act: Navigate to Galaxy view
|
||||
await agent.act('Click the "Galaxy" navigation link');
|
||||
await agent.check('The galaxy visualization loads');
|
||||
|
||||
// Act: Click on any existing node
|
||||
await agent.act('Click on any node in the galaxy view');
|
||||
await agent.check('A node detail panel opens');
|
||||
|
||||
// Act: Click the delete button
|
||||
await agent.act('Click the "Delete" button');
|
||||
await agent.check('A delete confirmation modal appears');
|
||||
|
||||
// Act: Click cancel
|
||||
await agent.act('Click the "Cancel" button');
|
||||
|
||||
// Check: Verify modal closes and node is still there
|
||||
await agent.check('The delete confirmation modal closes');
|
||||
await agent.check('The node detail panel is still open');
|
||||
await agent.check('The node is still visible in the galaxy view');
|
||||
});
|
||||
|
||||
test('Node deletion removes associated links', async (agent) => {
|
||||
// Act: Log in
|
||||
await agent.act('Navigate to /login');
|
||||
await agent.act(`Type "${TEST_HANDLE}" into the "Your Handle" input field`);
|
||||
await agent.act('Click the "Log in with Bluesky" button');
|
||||
await agent.check('The page URL contains "bsky.social"');
|
||||
await agent.act(`Type "${TEST_HANDLE}" into the username/identifier field`);
|
||||
await agent.act(`Type "${TEST_PASSWORD}" into the password field`);
|
||||
await agent.act('Click the submit/authorize button');
|
||||
await agent.check('The page URL contains "/chat"');
|
||||
|
||||
// Act: Create two linked nodes
|
||||
await agent.act('Create a first test node via chat');
|
||||
await agent.act('Create a second test node that links to the first');
|
||||
|
||||
// Act: Navigate to Galaxy view
|
||||
await agent.act('Click the "Galaxy" navigation link');
|
||||
await agent.check('The galaxy visualization shows two nodes with a link between them');
|
||||
|
||||
// Act: Delete one of the nodes
|
||||
await agent.act('Click on the first test node');
|
||||
await agent.act('Click the "Delete" button');
|
||||
await agent.act('Click "Delete Permanently"');
|
||||
|
||||
// Check: Verify the link is also removed
|
||||
await agent.check('The link between the nodes is no longer visible');
|
||||
await agent.check('Only one node remains in the galaxy');
|
||||
});
|
||||
Reference in New Issue
Block a user