Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/ralph-loop.local.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
active: true
iteration: 3
iteration: 4
max_iterations: 40
completion_promise: "PR_READY"
started_at: "2026-02-03T00:48:37Z"
Expand Down
229 changes: 229 additions & 0 deletions apps/web/src/app/api/pages/bulk-copy/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import { NextResponse } from 'next/server';
import { z } from 'zod/v4';
import { broadcastPageEvent, createPageEventPayload } from '@/lib/websocket';
import { loggers, pageTreeCache } from '@pagespace/lib/server';
import { pages, drives, driveMembers, db, and, eq, inArray, desc, isNull } from '@pagespace/db';
import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth';
import { canUserViewPage } from '@pagespace/lib/server';
import { createId } from '@paralleldrive/cuid2';

const AUTH_OPTIONS = { allow: ['session', 'mcp'] as const, requireCSRF: true };

const requestSchema = z.object({
pageIds: z.array(z.string()).min(1, 'At least one page ID is required'),
targetDriveId: z.string().min(1, 'Target drive ID is required'),
targetParentId: z.string().nullable(),
includeChildren: z.boolean().default(true),
});

export async function POST(request: Request) {
const auth = await authenticateRequestWithOptions(request, AUTH_OPTIONS);
if (isAuthError(auth)) {
return auth.error;
}
const userId = auth.userId;

try {
const body = await request.json();

const parseResult = requestSchema.safeParse(body);
if (!parseResult.success) {
return NextResponse.json(
{ error: parseResult.error.issues.map(i => i.message).join('. ') },
{ status: 400 }
);
}

const { pageIds, targetDriveId, targetParentId, includeChildren } = parseResult.data;

// Verify target drive exists
const targetDrive = await db.query.drives.findFirst({
where: eq(drives.id, targetDriveId),
});

if (!targetDrive) {
return NextResponse.json({ error: 'Target drive not found' }, { status: 404 });
}

// Check user has edit access to target drive
const isOwner = targetDrive.ownerId === userId;
let canEditDrive = isOwner;

if (!isOwner) {
const membership = await db.query.driveMembers.findFirst({
where: and(
eq(driveMembers.driveId, targetDriveId),
eq(driveMembers.userId, userId)
),
});
canEditDrive = membership?.role === 'OWNER' || membership?.role === 'ADMIN';
}

if (!canEditDrive) {
return NextResponse.json(
{ error: 'You do not have permission to copy pages to this drive' },
{ status: 403 }
);
}

// Verify target parent exists if specified
if (targetParentId) {
const targetParent = await db.query.pages.findFirst({
where: and(
eq(pages.id, targetParentId),
eq(pages.driveId, targetDriveId),
eq(pages.isTrashed, false)
),
});
if (!targetParent) {
return NextResponse.json({ error: 'Target folder not found' }, { status: 404 });
}
}

// Fetch source pages
const sourcePages = await db.query.pages.findMany({
where: inArray(pages.id, pageIds),
});

if (sourcePages.length !== pageIds.length) {
return NextResponse.json({ error: 'Some pages not found' }, { status: 404 });
}

// Verify view permissions for all pages
for (const page of sourcePages) {
const canView = await canUserViewPage(userId, page.id);
if (!canView) {
return NextResponse.json(
{ error: `You do not have permission to copy page: ${page.title}` },
{ status: 403 }
);
}
}

// Get the max position in target parent
const lastPage = await db.query.pages.findFirst({
where: and(
eq(pages.driveId, targetDriveId),
targetParentId ? eq(pages.parentId, targetParentId) : isNull(pages.parentId),
eq(pages.isTrashed, false)
),
orderBy: [desc(pages.position)],
});

let nextPosition = (lastPage?.position || 0) + 1;
let copiedCount = 0;

// Copy pages in transaction
await db.transaction(async (tx) => {
for (const page of sourcePages) {
const newPageId = createId();

// Copy the page
await tx.insert(pages).values({
id: newPageId,
title: page.title ? `${page.title} (Copy)` : 'Untitled (Copy)',
type: page.type,
content: page.content,
driveId: targetDriveId,
parentId: targetParentId,
position: nextPosition,
createdAt: new Date(),
updatedAt: new Date(),
revision: 0,
stateHash: null,
isTrashed: false,
aiProvider: page.aiProvider,
aiModel: page.aiModel,
systemPrompt: page.systemPrompt,
enabledTools: page.enabledTools,
isPaginated: page.isPaginated,
});

copiedCount += 1;
nextPosition += 1;

// Recursively copy children if requested
if (includeChildren) {
const childCount = await copyChildrenRecursively(
tx,
page.id,
newPageId,
targetDriveId
);
copiedCount += childCount;
}
}
});

// Invalidate cache and broadcast event
await pageTreeCache.invalidateDriveTree(targetDriveId);
await broadcastPageEvent(
createPageEventPayload(targetDriveId, '', 'created')
);

return NextResponse.json({
success: true,
copiedCount,
});
} catch (error) {
loggers.api.error('Error bulk copying pages:', error as Error);
return NextResponse.json(
{ error: 'Failed to copy pages' },
{ status: 500 }
);
}
}

// Recursively copy children
async function copyChildrenRecursively(
tx: Parameters<Parameters<typeof db.transaction>[0]>[0],
sourceParentId: string,
newParentId: string,
targetDriveId: string
): Promise<number> {
const children = await tx.query.pages.findMany({
where: and(
eq(pages.parentId, sourceParentId),
eq(pages.isTrashed, false)
),
});

let copiedCount = 0;

for (const child of children) {
const newChildId = createId();

await tx.insert(pages).values({
id: newChildId,
title: child.title,
type: child.type,
content: child.content,
driveId: targetDriveId,
parentId: newParentId,
position: child.position,
createdAt: new Date(),
updatedAt: new Date(),
revision: 0,
stateHash: null,
isTrashed: false,
aiProvider: child.aiProvider,
aiModel: child.aiModel,
systemPrompt: child.systemPrompt,
enabledTools: child.enabledTools,
isPaginated: child.isPaginated,
});

copiedCount += 1;

// Recursively copy grandchildren
const grandchildCount = await copyChildrenRecursively(
tx,
child.id,
newChildId,
targetDriveId
);
copiedCount += grandchildCount;
}

return copiedCount;
}
153 changes: 153 additions & 0 deletions apps/web/src/app/api/pages/bulk-delete/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { NextResponse } from 'next/server';
import { z } from 'zod/v4';
import { broadcastPageEvent, createPageEventPayload } from '@/lib/websocket';
import { loggers, pageTreeCache, agentAwarenessCache } from '@pagespace/lib/server';
import { pages, db, eq, inArray } from '@pagespace/db';
import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth';
import { canUserDeletePage } from '@pagespace/lib/server';

const AUTH_OPTIONS = { allow: ['session', 'mcp'] as const, requireCSRF: true };

const requestSchema = z.object({
pageIds: z.array(z.string()).min(1, 'At least one page ID is required'),
trashChildren: z.boolean().default(true),
});

export async function DELETE(request: Request) {
const auth = await authenticateRequestWithOptions(request, AUTH_OPTIONS);
if (isAuthError(auth)) {
return auth.error;
}
const userId = auth.userId;

try {
const body = await request.json();

const parseResult = requestSchema.safeParse(body);
if (!parseResult.success) {
return NextResponse.json(
{ error: parseResult.error.issues.map(i => i.message).join('. ') },
{ status: 400 }
);
}

const { pageIds, trashChildren } = parseResult.data;

// Fetch source pages
const sourcePages = await db.query.pages.findMany({
where: inArray(pages.id, pageIds),
});

if (sourcePages.length !== pageIds.length) {
return NextResponse.json({ error: 'Some pages not found' }, { status: 404 });
}

// Verify delete permissions for all pages
for (const page of sourcePages) {
const canDelete = await canUserDeletePage(userId, page.id);
if (!canDelete) {
return NextResponse.json(
{ error: `You do not have permission to delete page: ${page.title}` },
{ status: 403 }
);
}
}

// Track affected drives for cache invalidation
const affectedDriveIds = new Set<string>();
let hasAIChatPages = sourcePages.some(p => p.type === 'AI_CHAT');

// Trash pages in transaction
await db.transaction(async (tx) => {
const now = new Date();

for (const page of sourcePages) {
affectedDriveIds.add(page.driveId);

// Trash the page
await tx.update(pages)
.set({
isTrashed: true,
trashedAt: now,
updatedAt: now,
})
.where(eq(pages.id, page.id));

// Recursively trash children if requested
if (trashChildren) {
const trashedAIChat = await trashChildrenRecursively(tx, page.id, now);
if (trashedAIChat) {
hasAIChatPages = true;
}
} else {
// Move children to parent's parent
await tx.update(pages)
.set({
parentId: page.parentId,
updatedAt: now,
})
.where(eq(pages.parentId, page.id));
}
}
});

// Invalidate caches and broadcast events
for (const driveId of affectedDriveIds) {
await pageTreeCache.invalidateDriveTree(driveId);

if (hasAIChatPages) {
await agentAwarenessCache.invalidateDriveAgents(driveId);
}

await broadcastPageEvent(
createPageEventPayload(driveId, '', 'trashed')
);
}

return NextResponse.json({
success: true,
trashedCount: pageIds.length,
});
} catch (error) {
loggers.api.error('Error bulk deleting pages:', error as Error);
return NextResponse.json(
{ error: 'Failed to delete pages' },
{ status: 500 }
);
}
}

// Recursively trash children and return whether any AI_CHAT pages were trashed
async function trashChildrenRecursively(
tx: Parameters<Parameters<typeof db.transaction>[0]>[0],
parentId: string,
trashedAt: Date
): Promise<boolean> {
const children = await tx.query.pages.findMany({
where: eq(pages.parentId, parentId),
});

let hasAIChatChild = false;

for (const child of children) {
if (child.type === 'AI_CHAT') {
hasAIChatChild = true;
}

await tx.update(pages)
.set({
isTrashed: true,
trashedAt: trashedAt,
updatedAt: trashedAt,
})
.where(eq(pages.id, child.id));

// Recursively trash grandchildren
const grandchildHasAIChat = await trashChildrenRecursively(tx, child.id, trashedAt);
if (grandchildHasAIChat) {
hasAIChatChild = true;
}
}

return hasAIChatChild;
}
Loading