diff --git a/packages/backend/src/routes/missions.ts b/packages/backend/src/routes/missions.ts index 500c156..1896ab0 100644 --- a/packages/backend/src/routes/missions.ts +++ b/packages/backend/src/routes/missions.ts @@ -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 { diff --git a/packages/backend/tests/integration/routes/missions.test.ts b/packages/backend/tests/integration/routes/missions.test.ts index b35efc4..12c8791 100644 --- a/packages/backend/tests/integration/routes/missions.test.ts +++ b/packages/backend/tests/integration/routes/missions.test.ts @@ -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(); + }); + }); }); diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index 44dee09..2a6bd8b 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -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 { @@ -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(null) + const [deleteError, setDeleteError] = useState(null) // Query: Fetch missions list with polling const { data: missions = [], isLoading: isLoadingMissions } = useQuery({ @@ -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) @@ -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 (
@@ -193,6 +232,7 @@ function AppContent() { selectedMissionId={selectedMissionId} onSelectMission={handleSelectMission} onNewMission={handleNewMission} + onDeleteMission={handleDeleteMission} isOpen={isSidebarOpen} onClose={() => setIsSidebarOpen(false)} /> @@ -237,6 +277,7 @@ function AppContent() { onSaveArtifact={handleSaveArtifact} onContinue={handleContinue} onMarkCompleted={handleMarkCompleted} + onDelete={() => selectedMissionId && handleDeleteMission(selectedMissionId)} /> ) : (
@@ -282,6 +323,22 @@ function AppContent() { + + {/* Delete Mission Confirm Dialog */} + { + setIsDeleteDialogOpen(false) + setMissionToDelete(null) + setDeleteError(null) + }} + isPending={deleteMissionMutation.isPending} + error={deleteError} + />
) } diff --git a/packages/frontend/src/api/client.ts b/packages/frontend/src/api/client.ts index 64e5592..684ceee 100644 --- a/packages/frontend/src/api/client.ts +++ b/packages/frontend/src/api/client.ts @@ -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 => { + const res = await client.delete>(`/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>( + '/missions/bulk-delete', + { ids: missionIds, reason } + ); + if (!res.data.success) throw new Error(res.data.error || 'Failed to delete missions'); + return res.data.data!; + }, }; diff --git a/packages/frontend/src/components/ConfirmDeletionDialog.tsx b/packages/frontend/src/components/ConfirmDeletionDialog.tsx new file mode 100644 index 0000000..200be26 --- /dev/null +++ b/packages/frontend/src/components/ConfirmDeletionDialog.tsx @@ -0,0 +1,128 @@ +import { useState } from 'react' +import { AlertTriangle } from 'lucide-react' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' + +interface ConfirmDeletionDialogProps { + isOpen: boolean + title?: string + description?: string + itemCount: number + reason?: string + onReason?: (reason: string) => void + onConfirm: () => Promise + onCancel: () => void + isPending?: boolean + error?: string + showReasonInput?: boolean +} + +export function ConfirmDeletionDialog({ + isOpen, + title = 'Delete Mission', + description = 'Are you sure you want to delete this mission?', + itemCount, + reason, + onReason, + onConfirm, + onCancel, + isPending = false, + error, + showReasonInput = false, +}: ConfirmDeletionDialogProps) { + const [understood, setUnderstood] = useState(false) + const [localReason, setLocalReason] = useState(reason || '') + + const handleConfirm = async () => { + if (onReason) { + onReason(localReason) + } + await onConfirm() + } + + const isDeleteDisabled = isPending || !understood + + return ( + !open && onCancel()}> + + + + + {title} + + + {description} + {itemCount > 1 && ( +
+ You are about to delete {itemCount} mission{itemCount !== 1 ? 's' : ''}. +
+ )} +
+ This action cannot be undone. +
+
+
+ + {showReasonInput && ( +
+ +