Skip to content
Open
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
47 changes: 47 additions & 0 deletions packages/backend/src/routes/missions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,53 @@ missionRoutes.delete('/:missionId', async (req, res, next) => {
}
});

// POST /api/missions/bulk-delete - Delete multiple missions
missionRoutes.post('/bulk-delete', async (req, res, next) => {
try {
const { ids, reason } = req.body;

// Validate input
if (!Array.isArray(ids) || ids.length === 0) {
return sendError(res, 'ids must be a non-empty array', 400);
}

if (ids.length > 10000) {
return sendError(res, 'Maximum 10000 missions can be deleted at once', 400);
}

const deleted: string[] = [];
const failed: string[] = [];
const errors: string[] = [];

// Delete each mission
for (const missionId of ids) {
try {
const meta = await missionStore.getMeta(missionId);
if (!meta) {
failed.push(missionId);
errors.push(`Mission not found: ${missionId}`);
continue;
}

await missionStore.deleteMission(missionId);
deleted.push(missionId);
} catch (err) {
failed.push(missionId);
errors.push(`Failed to delete ${missionId}: ${err instanceof Error ? err.message : String(err)}`);
}
}

sendSuccess(res, {
deleted: deleted.length,
failed: failed.length,
failedIds: failed.length > 0 ? failed : undefined,
message: `Deleted ${deleted.length} of ${ids.length} missions${reason ? ` (Reason: ${reason})` : ''}`,
});
} catch (err) {
next(err);
}
});

// GET /api/missions/:missionId/git-status - Get git status of cloned project
missionRoutes.get('/:missionId/git-status', async (req, res, next) => {
try {
Expand Down
155 changes: 155 additions & 0 deletions packages/backend/tests/integration/routes/missions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,4 +256,159 @@ describe('missions routes', () => {
expect(getRes.body.data.status).toBe('completed');
});
});

describe('DELETE /api/missions/:missionId', () => {
it('returns 404 for non-existent mission', async () => {
const res = await request(BASE_URL)
.delete('/api/missions/m-nonexist');

expect(res.status).toBe(404);
expect(res.body.success).toBe(false);
});

it('deletes mission and returns 200', async () => {
const createRes = await createTestMission('Delete Test', 'feature', 'Input');
const missionId = createRes.body.data.mission_id;
// Remove from createdMissionIds since we're manually deleting
createdMissionIds.splice(createdMissionIds.indexOf(missionId), 1);

const deleteRes = await request(BASE_URL)
.delete(`/api/missions/${missionId}`);

expect(deleteRes.status).toBe(200);
expect(deleteRes.body.success).toBe(true);
});

it('removes mission from list after deletion', async () => {
const createRes = await createTestMission('List Delete Test', 'feature', 'Input');
const missionId = createRes.body.data.mission_id;
createdMissionIds.splice(createdMissionIds.indexOf(missionId), 1);

// Verify it exists
let listRes = await request(BASE_URL).get('/api/missions');
let found = listRes.body.data.find((m: MissionListItem) => m.mission_id === missionId);
expect(found).toBeDefined();

// Delete it
await request(BASE_URL).delete(`/api/missions/${missionId}`);

// Verify it's gone
listRes = await request(BASE_URL).get('/api/missions');
found = listRes.body.data.find((m: MissionListItem) => m.mission_id === missionId);
expect(found).toBeUndefined();
});

it('makes mission unretrievable after deletion', async () => {
const createRes = await createTestMission('Get After Delete Test', 'feature', 'Input');
const missionId = createRes.body.data.mission_id;
createdMissionIds.splice(createdMissionIds.indexOf(missionId), 1);

// Delete it
await request(BASE_URL).delete(`/api/missions/${missionId}`);

// Try to get it
const getRes = await request(BASE_URL).get(`/api/missions/${missionId}`);
expect(getRes.status).toBe(404);
});
});

describe('POST /api/missions/bulk-delete', () => {
it('returns 400 for empty ids array', async () => {
const res = await request(BASE_URL)
.post('/api/missions/bulk-delete')
.send({ ids: [] });

expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
expect(res.body.error).toContain('non-empty array');
});

it('returns 400 for non-array ids', async () => {
const res = await request(BASE_URL)
.post('/api/missions/bulk-delete')
.send({ ids: 'not-an-array' });

expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});

it('deletes single mission via bulk endpoint', async () => {
const createRes = await createTestMission('Bulk Delete Single', 'feature', 'Input');
const missionId = createRes.body.data.mission_id;
createdMissionIds.splice(createdMissionIds.indexOf(missionId), 1);

const res = await request(BASE_URL)
.post('/api/missions/bulk-delete')
.send({ ids: [missionId] });

expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data.deleted).toBe(1);
expect(res.body.data.failed).toBe(0);
});

it('deletes multiple missions', async () => {
const m1 = await createTestMission('Bulk Delete 1', 'feature', 'Input');
const m2 = await createTestMission('Bulk Delete 2', 'feature', 'Input');
const m3 = await createTestMission('Bulk Delete 3', 'feature', 'Input');
const ids = [m1.body.data.mission_id, m2.body.data.mission_id, m3.body.data.mission_id];
ids.forEach(id => createdMissionIds.splice(createdMissionIds.indexOf(id), 1));

const res = await request(BASE_URL)
.post('/api/missions/bulk-delete')
.send({ ids });

expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data.deleted).toBe(3);
expect(res.body.data.failed).toBe(0);
});

it('handles mixed valid and invalid ids', async () => {
const m1 = await createTestMission('Bulk Delete Mixed 1', 'feature', 'Input');
const missionId = m1.body.data.mission_id;
createdMissionIds.splice(createdMissionIds.indexOf(missionId), 1);

const res = await request(BASE_URL)
.post('/api/missions/bulk-delete')
.send({ ids: [missionId, 'm-nonexist1', 'm-nonexist2'] });

expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data.deleted).toBe(1);
expect(res.body.data.failed).toBe(2);
expect(res.body.data.failedIds).toContain('m-nonexist1');
expect(res.body.data.failedIds).toContain('m-nonexist2');
});

it('includes reason in response when provided', async () => {
const m1 = await createTestMission('Bulk Delete Reason', 'feature', 'Input');
const missionId = m1.body.data.mission_id;
createdMissionIds.splice(createdMissionIds.indexOf(missionId), 1);

const res = await request(BASE_URL)
.post('/api/missions/bulk-delete')
.send({ ids: [missionId], reason: 'Cleanup old missions' });

expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data.message).toContain('Cleanup old missions');
});

it('removes all deleted missions from list', async () => {
const m1 = await createTestMission('Bulk Delete List 1', 'feature', 'Input');
const m2 = await createTestMission('Bulk Delete List 2', 'feature', 'Input');
const ids = [m1.body.data.mission_id, m2.body.data.mission_id];
ids.forEach(id => createdMissionIds.splice(createdMissionIds.indexOf(id), 1));

// Delete them
await request(BASE_URL)
.post('/api/missions/bulk-delete')
.send({ ids });

// Verify they're gone
const listRes = await request(BASE_URL).get('/api/missions');
expect(listRes.body.data.find((m: MissionListItem) => ids.includes(m.mission_id))).toBeUndefined();
});
});
});
57 changes: 57 additions & 0 deletions packages/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Sidebar } from '@/components/Sidebar'
import { MissionDetail as MissionDetailView } from '@/components/MissionDetail'
import { NewMissionModal } from '@/components/NewMissionModal'
import { ChatVoice } from '@/components/ChatVoice'
import { ConfirmDeletionDialog } from '@/components/ConfirmDeletionDialog'
import { api } from '@/api/client'
import { Button } from '@/components/ui/button'
import {
Expand Down Expand Up @@ -33,6 +34,9 @@ function AppContent() {
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
const [showVoiceChat, setShowVoiceChat] = useState(false)
const [isCleanupDialogOpen, setIsCleanupDialogOpen] = useState(false)
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const [missionToDelete, setMissionToDelete] = useState<string | null>(null)
const [deleteError, setDeleteError] = useState<string | null>(null)

// Query: Fetch missions list with polling
const { data: missions = [], isLoading: isLoadingMissions } = useQuery({
Expand Down Expand Up @@ -121,6 +125,25 @@ function AppContent() {
},
})

// Mutation: Delete mission
const deleteMissionMutation = useMutation({
mutationFn: async (missionId: string) => {
return api.deleteMission(missionId)
},
onSuccess: () => {
setIsDeleteDialogOpen(false)
setMissionToDelete(null)
setDeleteError(null)
queryClient.invalidateQueries({ queryKey: ['missions'] })
if (selectedMissionId === missionToDelete) {
setSelectedMissionId(null)
}
},
onError: (error) => {
setDeleteError(error instanceof Error ? error.message : 'Failed to delete mission')
},
})

// Handlers
const handleSelectMission = (id: string) => {
setSelectedMissionId(id)
Expand Down Expand Up @@ -153,6 +176,22 @@ function AppContent() {
await markCompletedMutation.mutateAsync()
}

const handleDeleteMission = (missionId: string) => {
setMissionToDelete(missionId)
setIsDeleteDialogOpen(true)
setDeleteError(null)
}

const handleConfirmDelete = async () => {
if (!missionToDelete) return
await deleteMissionMutation.mutateAsync(missionToDelete)
}

const getMissionTitle = (missionId: string): string => {
const mission = missions.find(m => m.mission_id === missionId)
return mission?.title || 'Unknown Mission'
}

if (isLoadingMissions) {
return (
<div className="h-screen flex items-center justify-center bg-background">
Expand Down Expand Up @@ -193,6 +232,7 @@ function AppContent() {
selectedMissionId={selectedMissionId}
onSelectMission={handleSelectMission}
onNewMission={handleNewMission}
onDeleteMission={handleDeleteMission}
isOpen={isSidebarOpen}
onClose={() => setIsSidebarOpen(false)}
/>
Expand Down Expand Up @@ -237,6 +277,7 @@ function AppContent() {
onSaveArtifact={handleSaveArtifact}
onContinue={handleContinue}
onMarkCompleted={handleMarkCompleted}
onDelete={() => selectedMissionId && handleDeleteMission(selectedMissionId)}
/>
) : (
<div className="h-full flex items-center justify-center">
Expand Down Expand Up @@ -282,6 +323,22 @@ function AppContent() {
</DialogFooter>
</DialogContent>
</Dialog>

{/* Delete Mission Confirm Dialog */}
<ConfirmDeletionDialog
isOpen={isDeleteDialogOpen}
title="Delete Mission"
description={`Delete "${getMissionTitle(missionToDelete || '')}"?`}
itemCount={1}
onConfirm={handleConfirmDelete}
onCancel={() => {
setIsDeleteDialogOpen(false)
setMissionToDelete(null)
setDeleteError(null)
}}
isPending={deleteMissionMutation.isPending}
error={deleteError}
/>
</div>
)
}
Expand Down
17 changes: 17 additions & 0 deletions packages/frontend/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,4 +152,21 @@ export const api = {
if (!res.data.success) throw new Error(res.data.error || 'Failed to get git status');
return res.data.data!;
},

deleteMission: async (missionId: string): Promise<void> => {
const res = await client.delete<ApiResponse<void>>(`/missions/${missionId}`);
if (!res.data.success) throw new Error(res.data.error || 'Failed to delete mission');
},

bulkDeleteMissions: async (
missionIds: string[],
reason?: string
): Promise<{ deleted: number; failed: number; failedIds?: string[] }> => {
const res = await client.post<ApiResponse<{ deleted: number; failed: number; failedIds?: string[] }>>(
'/missions/bulk-delete',
{ ids: missionIds, reason }
);
if (!res.data.success) throw new Error(res.data.error || 'Failed to delete missions');
return res.data.data!;
},
};
Loading
Loading