feat: Implement node deletion with shared modal and fix SurrealDB RecordId handling

Implements complete node deletion functionality for both galaxy view and debug panel:

**Core Changes:**
- Created shared DeleteNodeModal component used by both ThoughtGalaxy and UserMenu
- Modal provides consistent UX with proper confirmation messaging
- Deletion follows write-through cache pattern: ATproto first, then SurrealDB

**SurrealDB RecordId Fixes:**
- Fixed SELECT query to use type::thing($table, $recordId) for UUID-based RecordIds
- Fixed DELETE query to use type::thing() instead of db.delete() to handle dashes in UUIDs
- Without type::thing(), SurrealDB interprets dashes as subtraction operators

**Testing & Documentation:**
- Added comprehensive Magnitude tests for delete functionality (galaxy view and debug panel)
- Updated CLAUDE.md with complete testing workflow documentation
- Added pre-commit checklist requiring database verification and test execution
- Documented PlaywrightMCP manual testing process before Magnitude test writing

**Database Setup:**
- Configured docker-compose.yml to use environment variables for credentials
- Updated namespace/database to match .env configuration (ponderants/main)

**File Changes:**
- app/api/nodes/[id]/route.ts: Fixed RecordId query patterns (SELECT and DELETE)
- components/DeleteNodeModal.tsx: New shared modal component
- components/ThoughtGalaxy.tsx: Uses shared DeleteNodeModal
- components/UserMenu.tsx: Replaced browser confirm() with shared DeleteNodeModal
- tests/magnitude/03-delete-node.mag.ts: Added debug panel delete test
- AGENTS.md: Added testing workflow and pre-commit checklist documentation
- docker-compose.yml: Environment variable configuration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-10 13:25:01 +00:00
parent d072b71eec
commit a520814771
7 changed files with 282 additions and 45 deletions

100
AGENTS.md
View File

@@ -17,6 +17,106 @@ EOF
These credentials should be used for all automated testing (Magnitude, Playwright) and manual testing when needed. Do not attempt to authenticate without using these credentials. These credentials should be used for all automated testing (Magnitude, Playwright) and manual testing when needed. Do not attempt to authenticate without using these credentials.
**Database Setup**: The application uses SurrealDB running in Docker Compose for the app cache layer:
1. Start the database services:
```bash
docker compose up -d
```
2. This starts two services:
- `surrealdb`: The main SurrealDB instance (port 8000)
- `surrealmcp`: SurrealMCP server for MCP access (port 8080)
3. Start the Next.js development server:
```bash
pnpm dev
```
4. To stop the services:
```bash
docker compose down
```
5. Configuration:
- SurrealDB runs in-memory mode (data is not persisted between restarts)
- Namespace: `ponderants`
- Database: `main`
- Credentials: `root/root`
**Note**: Always start docker compose services before starting the Next.js dev server to ensure the database is available.
**Testing Workflow**: All new features must follow a rigorous testing process before being committed:
1. **Manual Testing with Playwright MCP**:
- Use Playwright MCP tools to manually test all functionality interactively
- Test both happy paths (expected user flows) and unhappy paths (errors, edge cases)
- Document each step you verify during manual testing - these become test cases
- If you encounter issues during manual testing (e.g., 404 errors, unexpected behavior), investigate and fix them before proceeding
- Use the following pattern:
```
1. Navigate to the feature
2. Perform user actions (clicks, typing, etc.)
3. Verify expected outcomes
4. Test error scenarios
5. Verify cleanup/state updates
```
2. **Write Comprehensive Magnitude Tests**:
- After manually verifying functionality with Playwright MCP, write extensive Magnitude tests covering ALL verified behaviors
- Each manual test step should have a corresponding Magnitude test assertion
- Test files are located in `tests/magnitude/` with `.mag.ts` extension
- Use the test credentials from .env (TEST_BLUESKY_HANDLE, TEST_BLUESKY_PASSWORD)
- Include both happy path and unhappy path test cases
- Example test structure:
```typescript
import { test } from 'magnitude-test';
const TEST_HANDLE = process.env.TEST_BLUESKY_HANDLE;
const TEST_PASSWORD = process.env.TEST_BLUESKY_PASSWORD;
test('Feature description', async (agent) => {
await agent.act('Navigate to /page');
await agent.act('Perform user action');
await agent.check('Verify expected outcome');
});
```
3. **Reusable Playwright Scaffolding**:
- Abstract common patterns (auth, navigation, etc.) into helper files in `tests/playwright/helpers/`
- These helpers should be usable both during manual Playwright MCP testing AND by Magnitude tests
- Examples: `tests/playwright/helpers/chat.ts`, `tests/playwright/helpers/galaxy.ts`, `tests/playwright/helpers/node.ts`
- For auth setup, use Playwright's global setup pattern (see https://playwright.dev/docs/test-global-setup-teardown)
- Current auth setup: `tests/playwright/auth.setup.ts`
4. **Generating Playwright Code**:
- Use https://playwright.dev/docs/test-agents to generate Playwright test code when helpful
- This tool can convert natural language test descriptions into Playwright code
5. **Test Execution**:
- Run Magnitude tests: `pnpm test` or `npx magnitude`
- Ensure ALL tests pass before committing
- If tests fail, fix the implementation or update the tests to match the correct behavior
6. **Pre-Commit Checklist**:
- ✅ All manual testing with Playwright MCP completed and verified
- ✅ All Magnitude tests written and cover all verified functionality
- ✅ Database verified for expected state after operations (e.g., deletions actually removed records)
- ✅ Run ALL magnitude tests: `pnpm test`
- ✅ All tests passing
- ✅ No console errors or warnings in production code paths
- Only commit after ALL checklist items are complete
7. **Documentation**:
- Document test coverage in `tests/README.md`
- Add comments to complex test scenarios explaining the business logic being tested
**Testing Resources**:
- Playwright Global Setup/Teardown: https://playwright.dev/docs/test-global-setup-teardown
- Playwright Test Agents: https://playwright.dev/docs/test-agents
- Magnitude.run Documentation: https://magnitude.run/docs
- Project Test README: `tests/README.md`
You are an expert-level, full-stack AI coding agent. Your task is to implement You are an expert-level, full-stack AI coding agent. Your task is to implement
the "Ponderants" application. Product Vision: Ponderants is an AI-powered the "Ponderants" application. Product Vision: Ponderants is an AI-powered
thought partner that interviews a user to capture, structure, and visualize thought partner that interviews a user to capture, structure, and visualize

View File

@@ -50,9 +50,13 @@ export async function DELETE(
// 1. Fetch the node from SurrealDB to verify ownership and get atp_uri // 1. Fetch the node from SurrealDB to verify ownership and get atp_uri
const db = await connectToDB(); const db = await connectToDB();
// Parse the ID to extract table and record ID parts
// Format: "node:e9e38d09-c0f4-4834-a6ba-c92dfa4c0910" or "node:⟨e9e38d09-c0f4-4834-a6ba-c92dfa4c0910⟩"
const cleanId = id.replace(/^node:/, '').replace(/[⟨⟩]/g, '');
const nodeResult = await db.query<[Array<{ id: string; user_did: string; atp_uri: string }>]>( 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', 'SELECT id, user_did, atp_uri FROM node WHERE id = type::thing($table, $recordId)',
{ nodeId: id } { table: 'node', recordId: cleanId }
); );
const node = nodeResult[0]?.[0]; const node = nodeResult[0]?.[0];
@@ -114,7 +118,11 @@ export async function DELETE(
// 4. Delete from SurrealDB cache (only after successful ATproto deletion) // 4. Delete from SurrealDB cache (only after successful ATproto deletion)
try { try {
await db.delete(id); // Use type::thing() to properly construct the RecordId for deletion
await db.query('DELETE FROM type::thing($table, $recordId)', {
table: 'node',
recordId: cleanId,
});
console.log('[DELETE /api/nodes/[id]] ✓ Deleted node from SurrealDB cache'); console.log('[DELETE /api/nodes/[id]] ✓ Deleted node from SurrealDB cache');
} catch (error) { } catch (error) {
console.warn('[DELETE /api/nodes/[id]] ⚠ SurrealDB cache deletion failed (non-critical):', error); console.warn('[DELETE /api/nodes/[id]] ⚠ SurrealDB cache deletion failed (non-critical):', error);

View File

@@ -0,0 +1,58 @@
'use client';
import { Modal, Stack, Text, Group, Button } from '@mantine/core';
import { IconTrash } from '@tabler/icons-react';
interface DeleteNodeModalProps {
opened: boolean;
onClose: () => void;
onConfirm: () => void;
nodeTitle: string | null;
isDeleting: boolean;
}
export function DeleteNodeModal({
opened,
onClose,
onConfirm,
nodeTitle,
isDeleting,
}: DeleteNodeModalProps) {
return (
<Modal
opened={opened}
onClose={onClose}
title="Delete Node"
centered
zIndex={1001}
>
<Stack gap="md">
<Text>
Are you sure you want to delete &quot;{nodeTitle}&quot;? This will:
</Text>
<Stack gap="xs" ml="md">
<Text size="sm"> Remove the post from Bluesky</Text>
<Text size="sm"> Delete the node from your galaxy</Text>
<Text size="sm" fw={600} c="red">This action cannot be undone.</Text>
</Stack>
<Group justify="flex-end" gap="sm">
<Button
variant="subtle"
onClick={onClose}
disabled={isDeleting}
>
Cancel
</Button>
<Button
color="red"
onClick={onConfirm}
loading={isDeleting}
leftSection={<IconTrash size={16} />}
>
Delete Permanently
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -7,9 +7,10 @@ import {
Text, Text,
} from '@react-three/drei'; } from '@react-three/drei';
import { Suspense, useEffect, useRef, useState } from 'react'; import { Suspense, useEffect, useRef, useState } from 'react';
import { Stack, Text as MantineText, Paper, Title, Box, CloseButton, Group, Anchor, useComputedColorScheme, Button, Modal } from '@mantine/core'; import { Stack, Text as MantineText, Paper, Title, Box, CloseButton, Group, Anchor, useComputedColorScheme, Button } from '@mantine/core';
import { IconTrash } from '@tabler/icons-react'; import { IconTrash } from '@tabler/icons-react';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { DeleteNodeModal } from './DeleteNodeModal';
import { useRouter, usePathname, useSearchParams } from 'next/navigation'; import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import * as THREE from 'three'; import * as THREE from 'three';
@@ -482,41 +483,13 @@ export function ThoughtGalaxy() {
)} )}
{/* Delete confirmation modal */} {/* Delete confirmation modal */}
<Modal <DeleteNodeModal
opened={deleteConfirmOpen} opened={deleteConfirmOpen}
onClose={() => setDeleteConfirmOpen(false)} onClose={() => setDeleteConfirmOpen(false)}
title="Delete Node" onConfirm={handleDeleteNode}
centered nodeTitle={selectedNode?.title || null}
zIndex={1001} isDeleting={isDeleting}
> />
<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 <Canvas
camera={{ position: [0, 5, 10], fov: 60 }} camera={{ position: [0, 5, 10], fov: 60 }}

View File

@@ -6,6 +6,7 @@ import { useMantineColorScheme } from '@mantine/core';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { IconSun, IconMoon, IconDeviceDesktop } from '@tabler/icons-react'; import { IconSun, IconMoon, IconDeviceDesktop } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { DeleteNodeModal } from './DeleteNodeModal';
interface UserProfile { interface UserProfile {
did: string; did: string;
@@ -28,6 +29,8 @@ export function UserMenu({ showLabel = false }: { showLabel?: boolean } = {}) {
const [nodes, setNodes] = useState<Node[]>([]); const [nodes, setNodes] = useState<Node[]>([]);
const [nodesLoading, setNodesLoading] = useState(false); const [nodesLoading, setNodesLoading] = useState(false);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [nodeToDelete, setNodeToDelete] = useState<Node | null>(null);
useEffect(() => { useEffect(() => {
// Fetch user profile on mount // Fetch user profile on mount
@@ -65,12 +68,15 @@ export function UserMenu({ showLabel = false }: { showLabel?: boolean } = {}) {
}; };
// Delete a node (debug) - Matches ThoughtGalaxy delete pattern // Delete a node (debug) - Matches ThoughtGalaxy delete pattern
const handleDebugDelete = async (nodeId: string) => { const handleDebugDelete = async () => {
if (!nodeToDelete) return;
setIsDeleting(true); setIsDeleting(true);
setDeleteConfirmOpen(false);
try { try {
// Extract clean ID from SurrealDB RecordId format (removes angle brackets ⟨⟩) // Extract clean ID from SurrealDB RecordId format (removes angle brackets ⟨⟩)
const cleanId = String(nodeId).replace(/[⟨⟩]/g, ''); const cleanId = String(nodeToDelete.id).replace(/[⟨⟩]/g, '');
const response = await fetch(`/api/nodes/${cleanId}`, { const response = await fetch(`/api/nodes/${cleanId}`, {
method: 'DELETE', method: 'DELETE',
@@ -89,7 +95,8 @@ export function UserMenu({ showLabel = false }: { showLabel?: boolean } = {}) {
}); });
// Update local state to remove the deleted node // Update local state to remove the deleted node
setNodes((prevNodes) => prevNodes.filter((n) => n.id !== nodeId)); setNodes((prevNodes) => prevNodes.filter((n) => n.id !== nodeToDelete.id));
setNodeToDelete(null);
} catch (error) { } catch (error) {
console.error('[UserMenu Debug] Delete error:', error); console.error('[UserMenu Debug] Delete error:', error);
notifications.show({ notifications.show({
@@ -269,9 +276,8 @@ export function UserMenu({ showLabel = false }: { showLabel?: boolean } = {}) {
<Text size="xs" fw={600}>{node.title}</Text> <Text size="xs" fw={600}>{node.title}</Text>
<button <button
onClick={() => { onClick={() => {
if (confirm(`Delete "${node.title}"?`)) { setNodeToDelete(node);
handleDebugDelete(node.id); setDeleteConfirmOpen(true);
}
}} }}
style={{ style={{
background: '#fa5252', background: '#fa5252',
@@ -282,6 +288,7 @@ export function UserMenu({ showLabel = false }: { showLabel?: boolean } = {}) {
cursor: 'pointer', cursor: 'pointer',
fontSize: '0.65rem', fontSize: '0.65rem',
}} }}
disabled={isDeleting}
> >
Delete Delete
</button> </button>
@@ -306,6 +313,15 @@ export function UserMenu({ showLabel = false }: { showLabel?: boolean } = {}) {
Log out Log out
</Menu.Item> </Menu.Item>
</Menu.Dropdown> </Menu.Dropdown>
{/* Delete confirmation modal - shared with ThoughtGalaxy */}
<DeleteNodeModal
opened={deleteConfirmOpen}
onClose={() => setDeleteConfirmOpen(false)}
onConfirm={handleDebugDelete}
nodeTitle={nodeToDelete?.title || null}
isDeleting={isDeleting}
/>
</Menu> </Menu>
); );
} }

View File

@@ -8,12 +8,27 @@ services:
- --log - --log
- trace - trace
- --user - --user
- root - ${SURREALDB_USER}
- --pass - --pass
- root - ${SURREALDB_PASS}
- memory - memory
volumes:
- ./surreal/data:/data
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"] test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
surrealmcp:
image: surrealdb/surrealmcp:latest
command: >
start
--bind-address 0.0.0.0:8080
--server-url http://localhost:8080
-e ws://surrealdb:8000/rpc
--ns ${SURREALDB_NS} --db ${SURREALDB_DB} -u ${SURREALDB_USER} -p ${SURREALDB_PASS}
ports:
- "8080:8080"
depends_on:
- surrealdb

View File

@@ -135,3 +135,70 @@ test('Node deletion removes associated links', async (agent) => {
await agent.check('The link between the nodes is no longer visible'); await agent.check('The link between the nodes is no longer visible');
await agent.check('Only one node remains in the galaxy'); await agent.check('Only one node remains in the galaxy');
}); });
test('User can delete node from debug panel in Profile menu', 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 "Test node for debug panel 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 and publish
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');
await agent.act('Click the "Publish" button');
await agent.check('A success notification appears');
// Act: Open Profile menu
await agent.act('Click the "Profile" button in the navigation sidebar');
await agent.check('The Profile menu opens');
// Check: Verify debug panel is visible (development mode only)
await agent.check('A "Debug: SurrealDB Nodes" section is visible');
// Act: Fetch nodes from debug panel
await agent.act('Click the "Fetch Nodes" button');
await agent.check('The button shows a count greater than 0');
await agent.check('At least one node is listed in the debug panel');
// Check: Verify the test node appears
await agent.check('The node "Test node for debug panel deletion" is visible');
// Act: Click delete button in debug panel
await agent.act('Click the "Delete" button next to the test node');
// Check: Verify delete confirmation modal appears
await agent.check('A delete confirmation modal appears');
await agent.check('The modal is displayed above the profile menu');
await agent.check('The modal shows the node title "Test node for debug panel deletion"');
await agent.check('The modal explains this will remove the post from Bluesky');
await agent.check('The modal shows "This action cannot be undone"');
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 notification says "Node has been deleted from Bluesky and your galaxy"');
await agent.check('The modal closes');
// Check: Verify node is removed from debug panel
await agent.check('The "Fetch Nodes" button shows a count of 0 or the node is no longer in the list');
// Act: Verify node is deleted from Bluesky and database
await agent.act('Refresh the page');
await agent.act('Click the "Profile" button again');
await agent.act('Click the "Fetch Nodes" button');
await agent.check('The node "Test node for debug panel deletion" is not in the list');
});