Skip to content

[Feat]: Image level deletion , Soft Delete Images with Undo, Restore & Permanent Delete#801

Open
Isha-upadhyay wants to merge 3 commits intoAOSSIE-Org:mainfrom
Isha-upadhyay:Feat-delete-restore-undo-images-recentlydelteimages
Open

[Feat]: Image level deletion , Soft Delete Images with Undo, Restore & Permanent Delete#801
Isha-upadhyay wants to merge 3 commits intoAOSSIE-Org:mainfrom
Isha-upadhyay:Feat-delete-restore-undo-images-recentlydelteimages

Conversation

@Isha-upadhyay
Copy link

@Isha-upadhyay Isha-upadhyay commented Dec 19, 2025

📌 Overview

Fixes #795

This PR introduces a safe and reversible image deletion workflow by implementing
soft delete instead of irreversible removal. Deleted images are moved to a
Recently Deleted section where users can undo, restore, or permanently delete
images.

The implementation follows modern gallery UX patterns and improves data safety,
recoverability, and user trust.

⚠️ Note: Automatic scheduled cleanup is intentionally NOT included in this PR.
Backend cleanup capability exists, but scheduling is environment-dependent and out
of scope here.


🎥 Demo Video

demo.mp4

Demonstrated in the video:

  • Single image delete with Undo
  • Bulk image delete with Undo
  • Recently Deleted view
  • Restore images from Recently Deleted
  • Permanent delete flow

❗ Problem Statement

Previously:

  • Image deletion was irreversible
  • No undo or restore mechanism
  • Accidental deletions caused permanent data loss
  • No clear lifecycle for deleted images

This created a poor UX and high risk for users.


✅ Solution Implemented

🔹 1. Soft Delete (Non-destructive)

  • Images are not permanently removed
  • Database fields updated:
    • is_deleted = true
    • deleted_at = timestamp
  • Soft-deleted images are hidden from the main gallery

🔹 2. Recently Deleted Section

  • Soft-deleted images appear in a Recently Deleted page
  • Users can:
    • View deleted images
    • Select one or multiple images
    • Restore images back to gallery
    • Permanently delete images

🔹 3. Undo Support (Immediate Recovery)

  • After deleting images (single or bulk), an Undo notification is shown
  • Undo restores images instantly
  • Same undo flow works for:
    • Single image delete
    • Bulk delete
  • Undo state is handled centrally for consistent UX

🔹 4. Permanent Delete (Explicit & Safe)

  • Permanent deletion is only possible from Recently Deleted
  • Requires explicit user action
  • Prevents accidental irreversible deletions

🔹 5. Backend Cleanup Support (Manual)

  • Backend supports cleanup of old soft-deleted images via:
    • Cleanup function
    • Cleanup API endpoint
  • No automatic scheduler is added in this PR
  • Allows flexibility based on deployment environment

🏗️ Technical Details

Backend

  • Added soft delete fields: is_deleted, deleted_at
  • APIs implemented for:
    • Soft delete
    • Restore
    • Permanent delete
    • Fetch recently deleted images
  • Cleanup logic implemented (manual trigger only)

Frontend

  • Delete action added at image-card level
  • Recently Deleted page implemented
  • Undo notification integrated
  • Unified logic for:
    • Single delete
    • Bulk delete
  • UI consistency maintained with main gallery

🧠 Design Considerations

  • Non-destructive deletion by default
  • Explicit irreversible actions
  • Centralized undo state management
  • Scalable for future enhancements (auto cleanup, retention policies)

🧪 Testing & Validation

  • Soft delete moves images to Recently Deleted
  • Undo restores images correctly
  • Restore from Recently Deleted works
  • Permanent delete removes images permanently
  • Bulk operations behave consistently
  • Pre-commit checks (ruff, black) pass successfully

📈 Impact

  • Prevents accidental data loss
  • Improves user confidence and UX
  • Aligns with industry-standard gallery behavior
  • Prepares system for future automated cleanup jobs

📝 Notes for Reviewers

  • Automatic scheduled cleanup is intentionally excluded
  • Backend cleanup capability exists and can be scheduled later
  • This PR focuses on core UX, safety, and recoverability

✅ Checklist

  • Soft delete implemented
  • Recently Deleted view added
  • Undo support added
  • Restore functionality added
  • Permanent delete added
  • Demo video attached
  • Code formatted and linted
  • No breaking changes

@github-actions
Copy link
Contributor

⚠️ No issue was linked in the PR description.
Please make sure to link an issue (e.g., 'Fixes #issue_number')

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Dec 19, 2025

Walkthrough

This PR introduces comprehensive soft-delete functionality across the application. Backend adds deletion tracking fields (is_deleted, deleted_at), an audit log table (action_history), and new database operations for soft-delete, restore, and permanent-delete workflows. Frontend adds Redux state for image selection, new API endpoints, and UI components for managing the image lifecycle including bulk operations and an undo mechanism.

Changes

Cohort / File(s) Summary
Database Layer & Migrations
backend/app/database/images.py, backend/migrate_soft_delete.py
Adds soft-delete fields (is_deleted, deleted_at) and ActionHistoryRecord TypedDict; creates action_history table with indexes; implements db_soft_delete_images, db_restore_images, db_get_deleted_images, db_permanently_delete_images, db_permanently_delete_old_images; extends db_get_all_images with include_deleted parameter; migration script handles schema evolution.
API Routes & Endpoints
backend/app/routes/images.py, docs/backend/backend_python/openapi.json
Adds 5 new endpoints: /delete, /soft-delete, /restore, /deleted, /permanent-delete, /cleanup; extends ImageData, ImageInfoResponse with deletion fields; introduces request/response models; updates OpenAPI schema with new operations and schemas.
Cleanup Utility
backend/cleanup_old_images.py
Standalone script for automatic purging of old soft-deleted images.
Frontend API Layer
frontend/src/api/api-functions/images.ts, frontend/src/api/apiEndpoints.ts
Adds helper functions softDeleteImages, restoreImages, fetchDeletedImages, permanentDeleteImages, cleanupOldImages; extends fetchAllImages with includeDeleted flag; defines new endpoint constants.
Redux State & Selectors
frontend/src/features/imageSlice.ts, frontend/src/features/imageSelectors.ts
Adds selection state (selectedImages, isSelectionMode), undo state (lastDeletedImages, showUndo); implements reducers for selection toggling, soft-delete marking, restore, and undo; exports derived selectors.
UI Components
frontend/src/components/Gallery/GalleryToolbar.tsx, frontend/src/components/Gallery/HistoryToolbar.tsx, frontend/src/components/Gallery/UndoNotification.tsx, frontend/src/components/Media/ImageCard.tsx
New GalleryToolbar with bulk selection and delete with undo; new HistoryToolbar and UndoNotification components; updates ImageCard to support Redux-driven selection and per-image delete action.
Pages & Routes
frontend/src/pages/History/History.tsx, frontend/src/pages/Home/Home.tsx, frontend/src/routes/AppRoutes.tsx
New History page displaying deleted images with restore/permanent-delete; integrates GalleryToolbar in Home; adds HISTORY route.
Types & Navigation
frontend/src/types/Media.ts, frontend/src/constants/routes.ts, frontend/src/components/Navigation/Sidebar/AppSidebar.tsx
Extends Image interface with is_deleted and deleted_at; adds HISTORY route constant; adds "Recently Deleted" sidebar menu item.
Hooks
frontend/src/hooks/useDeleteImage.ts
New hook for single-image soft-delete mutation with undo state integration.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant Gallery as GalleryToolbar
    participant Redux as Redux State
    participant API as API Layer
    participant Backend as Backend API
    participant DB as Database

    User->>Gallery: Selects images
    Gallery->>Redux: dispatch toggleImageSelection
    Redux->>Redux: Add/remove from selectedImages
    
    User->>Gallery: Click Delete
    Gallery->>API: softDeleteImages(imageIds)
    API->>Backend: POST /soft-delete
    Backend->>DB: db_soft_delete_images()
    DB->>DB: UPDATE images SET is_deleted=1, deleted_at=NOW()
    DB->>DB: INSERT INTO action_history (delete, album_remove)
    DB-->>Backend: ✓
    Backend-->>API: SoftDeleteResponse
    API-->>Gallery: response.data
    
    Gallery->>Redux: dispatch markImagesAsDeleted(ids)
    Redux->>Redux: Update images, clear selectedImages
    Gallery->>Redux: dispatch setUndoState(lastDeletedIds)
    Redux->>Redux: Enable showUndo notification
    
    User->>Gallery: Click Undo (in notification)
    Gallery->>API: restoreImages(lastDeletedImages)
    API->>Backend: POST /restore
    Backend->>DB: db_restore_images()
    DB->>DB: UPDATE images SET is_deleted=0, deleted_at=NULL
    DB->>DB: INSERT INTO action_history (restore)
    DB-->>Backend: ✓
    Backend-->>API: RestoreResponse
    API-->>Gallery: response.data
    Gallery->>Redux: dispatch markImagesAsRestored(ids)
    Gallery->>Redux: dispatch clearUndoState()
    Redux->>Redux: Disable showUndo, update images
Loading
sequenceDiagram
    actor User
    participant History as History Page
    participant Redux as Redux State
    participant API as API Layer
    participant Backend as Backend API
    participant DB as Database

    User->>History: Navigate to History
    History->>API: fetchDeletedImages()
    API->>Backend: GET /deleted
    Backend->>DB: db_get_deleted_images()
    DB->>DB: SELECT * FROM images WHERE is_deleted=1
    DB-->>Backend: [deleted image records]
    Backend-->>API: GetDeletedImagesResponse
    API-->>History: response.data (deleted images)
    History->>Redux: Set deletedImages in state
    
    User->>History: Selects deleted images
    History->>Redux: dispatch selectImage / toggleImageSelection
    Redux->>Redux: Add to selectedImages
    
    alt Restore Selected
        User->>History: Click Restore
        History->>API: restoreImages(selectedIds)
        API->>Backend: POST /restore
        Backend->>DB: db_restore_images()
        DB-->>Backend: ✓
        Backend-->>API: RestoreResponse
        API-->>History: restored_count
        History->>API: Refetch fetchDeletedImages()
    else Permanently Delete
        User->>History: Click Delete Permanently
        History->>API: permanentDeleteImages(selectedIds)
        API->>Backend: POST /permanent-delete
        Backend->>DB: db_permanently_delete_images()
        DB->>DB: DELETE FROM images WHERE id IN (...)
        DB->>DB: INSERT INTO action_history (permanent_delete)
        DB-->>Backend: ✓
        Backend-->>API: PermanentDeleteResponse
        API-->>History: deleted_count
        History->>API: Refetch fetchDeletedImages()
    end
    
    History->>Redux: Refetch clears selectedImages
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Areas requiring extra attention:

  • Backend database operations (backend/app/database/images.py): Dense logic for soft-delete/restore/permanent-delete with audit logging; ensure all action_history log entries are consistent and cover all scenarios (delete, restore, album_remove, permanent_delete).
  • Redux state management (frontend/src/features/imageSlice.ts): Multiple interdependent reducers for selection, deletion marking, and undo—verify state consistency across all reducer transitions.
  • Image card selection refactoring (frontend/src/components/Media/ImageCard.tsx): Changed from prop-based to Redux-driven selection; ensure existing onClick handlers and favourite toggle still work correctly; confirm no regressions in interaction flows.
  • GalleryToolbar undo flow (frontend/src/components/Gallery/GalleryToolbar.tsx): Complex mutation coordination between delete and restore with state transitions; verify undo notification timing and state cleanup on success/failure.
  • Database migration script (backend/migrate_soft_delete.py): Ensure table creation and ALTER TABLE statements handle existing databases gracefully; verify index creation idempotency.

Possibly related PRs

Suggested labels

enhancement, backend, frontend, UI

Suggested reviewers

  • rahulharpal1603

Poem

🐰 A rabbit's delight in the code so bright,
Soft deletes dancing, restores alight!
With undo buttons to save the day,
And toolbars to manage each image's way,
Redux flows smoothly, from state to display! 🎯✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.69% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically summarizes the main feature: image-level soft deletion with undo, restore, and permanent delete capabilities.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Contributor

⚠️ No issue was linked in the PR description.
Please make sure to link an issue (e.g., 'Fixes #issue_number')

1 similar comment
@github-actions
Copy link
Contributor

⚠️ No issue was linked in the PR description.
Please make sure to link an issue (e.g., 'Fixes #issue_number')

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

🧹 Nitpick comments (10)
backend/migrate_soft_delete.py (1)

54-60: Consider a composite index for soft-delete queries.

The cleanup query in db_permanently_delete_old_images filters by both is_deleted = 1 AND deleted_at < ?. A composite index would serve this query better than a single-column index on is_deleted.

🔎 Suggested improvement
         # Create index on images for soft delete queries
         cursor.execute(
             """
-            CREATE INDEX IF NOT EXISTS idx_images_is_deleted
-            ON images(is_deleted)
+            CREATE INDEX IF NOT EXISTS idx_images_soft_delete
+            ON images(is_deleted, deleted_at)
         """
         )
frontend/src/components/Gallery/GalleryToolbar.tsx (1)

34-44: Use typed selectors or RootState instead of any.

These inline selectors bypass TypeScript's type checking. Consider using the existing typed selectors pattern or importing RootState for proper type safety.

🔎 Suggested improvement
+import { RootState } from '@/store'; // or wherever RootState is exported
+
-  const selectedImages = useSelector(
-    (state: any) => state.images.selectedImages
-  );
+  const selectedImages = useSelector(
+    (state: RootState) => state.images.selectedImages
+  );

-  const showUndo = useSelector(
-    (state: any) => state.images.showUndo
-  );
+  const showUndo = useSelector(
+    (state: RootState) => state.images.showUndo
+  );

-  const lastDeletedImages = useSelector(
-    (state: any) => state.images.lastDeletedImages
-  );
+  const lastDeletedImages = useSelector(
+    (state: RootState) => state.images.lastDeletedImages
+  );

Alternatively, add these selectors to imageSelectors.ts for consistency with the existing pattern.

frontend/src/api/api-functions/images.ts (1)

9-11: Consider using a typed params object instead of any.

Using any bypasses TypeScript's type checking. A simple inline type or interface would improve type safety.

🔎 Proposed fix
-  const params: any = {};
+  const params: { tagged?: boolean; include_deleted?: boolean } = {};
frontend/src/pages/History/History.tsx (2)

62-62: Type assertion on potentially empty/undefined data.

If the API returns an unexpected shape (e.g., data is undefined or not an array), the as Image[] assertion will silently pass invalid data. Consider adding a runtime check or using a more defensive approach.

🔎 Proposed fix
- const deletedImages = (deletedImagesData?.data ?? []) as Image[];
+ const deletedImages: Image[] = Array.isArray(deletedImagesData?.data) 
+   ? deletedImagesData.data 
+   : [];

15-16: Local selection state differs from Redux pattern used in ImageCard.

The History page manages selection with local useState, while ImageCard derives selection from Redux state (selectSelectedImages). This inconsistency could cause confusion and potential bugs if components are reused across contexts. Consider aligning on one approach.

Also applies to: 66-80

frontend/src/components/Media/ImageCard.tsx (1)

111-121: Inconsistent indentation in delete button.

The delete button uses different indentation compared to the favourite button above it. Consider aligning the formatting for consistency.

🔎 Proposed fix
              <Button
-   variant="ghost"
-   size="icon"
-   className="rounded-full bg-red-500/80 p-2.5 text-white hover:bg-red-600"
-   onClick={(e) => {
-     e.stopPropagation();
-     deleteSingleImage(image.id);
-   }}
- >
-   <Trash2 className="h-5 w-5" />
- </Button>
+               variant="ghost"
+               size="icon"
+               className="rounded-full bg-red-500/80 p-2.5 text-white hover:bg-red-600"
+               onClick={(e) => {
+                 e.stopPropagation();
+                 deleteSingleImage(image.id);
+               }}
+             >
+               <Trash2 className="h-5 w-5" />
+             </Button>
frontend/src/features/imageSlice.ts (1)

114-122: Inconsistent indentation in undo reducers.

These reducers have no leading indentation, breaking the consistent style used elsewhere in the file.

🔎 Proposed fix
-    setUndoState(state, action: PayloadAction<string[]>) {
-  state.lastDeletedImages = action.payload;
-  state.showUndo = true;
-},
-
-clearUndoState(state) {
-  state.lastDeletedImages = [];
-  state.showUndo = false;
-},
+    setUndoState(state, action: PayloadAction<string[]>) {
+      state.lastDeletedImages = action.payload;
+      state.showUndo = true;
+    },
+
+    clearUndoState(state) {
+      state.lastDeletedImages = [];
+      state.showUndo = false;
+    },
backend/app/routes/images.py (3)

186-201: deleted_count may not reflect actual deletions.

The response uses len(req.image_ids) as deleted_count regardless of how many images were actually soft-deleted. If some IDs don't exist or are already deleted, the count will be misleading.

Consider returning the actual count of affected rows from the database layer if accurate reporting is needed.


280-297: Consider restricting permanent deletion to soft-deleted images only.

The endpoint allows permanent deletion of any image, including those not in the soft-deleted state. Per the PR description, "Permanent deletion is restricted to the Recently Deleted view." Consider adding a check in db_permanently_delete_images to only delete images where is_deleted = 1 to enforce this constraint at the data layer.

🔎 Proposed database layer fix in backend/app/database/images.py
     try:
-        # Permanently delete the images (this will cascade to image_classes)
+        # Permanently delete only soft-deleted images (this will cascade to image_classes)
         placeholders = ",".join("?" for _ in image_ids)
         cursor.execute(
-            f"DELETE FROM images WHERE id IN ({placeholders})",
+            f"DELETE FROM images WHERE id IN ({placeholders}) AND is_deleted = 1",
             image_ids,
         )

306-321: Consider validating days parameter.

The days parameter accepts any integer, including negative values which could unintentionally delete all soft-deleted images (since all would be "older" than a future date). Consider adding validation with Query(30, ge=1, description=...).

🔎 Proposed fix
 @router.post("/cleanup", response_model=CleanupOldImagesResponse)
 def cleanup_old_images(
-    days: int = Query(30, description="Days after which to permanently delete")
+    days: int = Query(30, ge=1, description="Days after which to permanently delete")
 ):
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d07d817 and 84f05f7.

📒 Files selected for processing (20)
  • backend/app/database/images.py (11 hunks)
  • backend/app/routes/images.py (6 hunks)
  • backend/cleanup_old_images.py (1 hunks)
  • backend/migrate_soft_delete.py (1 hunks)
  • docs/backend/backend_python/openapi.json (9 hunks)
  • frontend/src/api/api-functions/images.ts (1 hunks)
  • frontend/src/api/apiEndpoints.ts (1 hunks)
  • frontend/src/components/Gallery/GalleryToolbar.tsx (1 hunks)
  • frontend/src/components/Gallery/HistoryToolbar.tsx (1 hunks)
  • frontend/src/components/Gallery/UndoNotification.tsx (1 hunks)
  • frontend/src/components/Media/ImageCard.tsx (3 hunks)
  • frontend/src/components/Navigation/Sidebar/AppSidebar.tsx (2 hunks)
  • frontend/src/constants/routes.ts (1 hunks)
  • frontend/src/features/imageSelectors.ts (1 hunks)
  • frontend/src/features/imageSlice.ts (2 hunks)
  • frontend/src/hooks/useDeleteImage.ts (1 hunks)
  • frontend/src/pages/History/History.tsx (1 hunks)
  • frontend/src/pages/Home/Home.tsx (2 hunks)
  • frontend/src/routes/AppRoutes.tsx (2 hunks)
  • frontend/src/types/Media.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (10)
frontend/src/hooks/useDeleteImage.ts (3)
frontend/src/api/api-functions/images.ts (1)
  • softDeleteImages (19-25)
frontend/src/features/imageSlice.ts (2)
  • markImagesAsDeleted (94-102)
  • setUndoState (114-117)
frontend/src/hooks/useMutationFeedback.tsx (1)
  • useMutationFeedback (60-135)
frontend/src/components/Gallery/GalleryToolbar.tsx (5)
frontend/src/features/imageSelectors.ts (5)
  • selectIsSelectionMode (15-16)
  • selectSelectedImagesCount (18-21)
  • selectAreAllImagesSelected (29-33)
  • selectIsAnyImageSelected (35-38)
  • selectImages (5-7)
frontend/src/api/api-functions/images.ts (2)
  • softDeleteImages (19-25)
  • restoreImages (27-33)
frontend/src/features/imageSlice.ts (7)
  • markImagesAsDeleted (94-102)
  • setUndoState (114-117)
  • toggleSelectionMode (57-62)
  • markImagesAsRestored (104-111)
  • clearUndoState (119-122)
  • deselectAllImages (89-91)
  • selectAllImages (85-87)
frontend/src/hooks/useMutationFeedback.tsx (1)
  • useMutationFeedback (60-135)
frontend/src/components/Gallery/UndoNotification.tsx (1)
  • UndoNotification (10-38)
frontend/src/routes/AppRoutes.tsx (2)
frontend/src/constants/routes.ts (1)
  • ROUTES (1-13)
frontend/src/pages/History/History.tsx (1)
  • History (15-174)
frontend/src/api/api-functions/images.ts (3)
frontend/src/types/API.ts (1)
  • APIResponse (1-8)
frontend/src/api/axiosConfig.ts (1)
  • apiClient (5-12)
frontend/src/api/apiEndpoints.ts (1)
  • imagesEndpoints (1-9)
backend/cleanup_old_images.py (1)
backend/app/database/images.py (1)
  • db_permanently_delete_old_images (651-707)
frontend/src/pages/Home/Home.tsx (1)
frontend/src/components/Gallery/GalleryToolbar.tsx (1)
  • GalleryToolbar (25-169)
frontend/src/components/Media/ImageCard.tsx (4)
frontend/src/features/imageSelectors.ts (2)
  • selectIsSelectionMode (15-16)
  • selectSelectedImages (12-13)
frontend/src/hooks/useDeleteImage.ts (1)
  • useDeleteImage (10-32)
frontend/src/features/imageSlice.ts (1)
  • toggleImageSelection (75-83)
frontend/src/lib/utils.ts (1)
  • cn (5-7)
frontend/src/features/imageSelectors.ts (1)
frontend/src/app/store.ts (1)
  • RootState (22-22)
backend/app/database/images.py (1)
backend/app/utils/images.py (1)
  • image_util_parse_metadata (496-513)
backend/app/routes/images.py (2)
backend/app/database/images.py (6)
  • db_get_all_images (167-274)
  • db_soft_delete_images (484-544)
  • db_restore_images (547-589)
  • db_get_deleted_images (592-648)
  • db_permanently_delete_images (710-752)
  • db_permanently_delete_old_images (651-707)
backend/app/utils/images.py (1)
  • image_util_parse_metadata (496-513)
🔇 Additional comments (27)
docs/backend/backend_python/openapi.json (1)

2454-2476: Ensure backend enforces consistency between is_deleted and deleted_at during soft-delete operations.

While the schema correctly shows both is_deleted and deleted_at as optional/nullable fields (standard for soft-delete patterns), these fields should be logically coupled at the application level: deleted_at should only contain a timestamp when is_deleted is true. Verify the backend code that handles soft-delete operations enforces this constraint—for example, when marking an image as deleted, both fields should be updated atomically.

backend/cleanup_old_images.py (1)

20-38: LGTM!

The main() function correctly invokes db_permanently_delete_old_images, logs outcomes at appropriate levels, and exits with status 1 on failure. The logic is sound.

frontend/src/api/apiEndpoints.ts (1)

4-8: LGTM!

The new endpoints follow the existing naming conventions and are logically grouped under imagesEndpoints.

backend/migrate_soft_delete.py (1)

14-70: LGTM!

The migration is idempotent, handles errors correctly with rollback, and properly closes the connection in the finally block. Good use of PRAGMA table_info to check for existing columns.

frontend/src/components/Gallery/GalleryToolbar.tsx (2)

46-56: LGTM on mutation logic.

The mutations correctly dispatch Redux actions on success. The markImagesAsDeleted and setUndoState are properly sequenced, and the restore flow clears undo state appropriately.

Also applies to: 65-71


109-127: LGTM on conditional rendering.

The three UI states (default, undo notification, selection mode) are cleanly separated and the logic flows correctly.

frontend/src/components/Gallery/HistoryToolbar.tsx (2)

4-10: LGTM!

The props interface is well-defined with clear types, making the component's contract explicit.


12-63: LGTM!

Clean implementation with proper disabled states and loading feedback. The conditional rendering for selection count and delete button is well-structured.

frontend/src/api/api-functions/images.ts (1)

19-59: LGTM!

The new API functions for soft-delete, restore, fetch deleted, permanent delete, and cleanup are well-structured and consistent with the existing patterns. They correctly use POST for mutation operations and GET for fetching, and properly pass parameters to the API client.

frontend/src/features/imageSelectors.ts (1)

12-38: LGTM!

The new selectors are well-implemented:

  • Basic selectors for direct state access are appropriately simple
  • Memoized selectors using createSelector prevent unnecessary re-renders
  • selectAreAllImagesSelected correctly handles the edge case of empty images array
frontend/src/components/Media/ImageCard.tsx (1)

33-52: LGTM on Redux integration for selection.

The component correctly reads selection state from Redux and dispatches toggleImageSelection when in selection mode, while preserving the original onClick behavior otherwise. The handleImageClick callback has proper dependencies.

frontend/src/features/imageSlice.ts (3)

94-102: Client-side timestamp may differ from server.

markImagesAsDeleted generates deleted_at using the client's clock (new Date().toISOString()). If the server also sets this timestamp, there could be a mismatch. This is fine for optimistic UI updates, but ensure the data is refreshed from the server after the API call succeeds to sync the authoritative timestamp.


85-91: selectAllImages only selects from non-deleted images in state.

This reducer selects IDs from state.images, which typically contains non-deleted images. If the History page (Recently Deleted) tries to use this action, it won't select the deleted images displayed there. This is likely intentional, but verify this aligns with the expected behavior for the History page's "Select All" functionality (which currently uses local state anyway).


57-83: LGTM on selection mode reducers.

The selection reducers are correctly implemented:

  • toggleSelectionMode properly clears selection when exiting selection mode
  • selectImage/deselectImage handle add/remove correctly with duplicate checks
  • toggleImageSelection efficiently uses splice for removal
backend/app/database/images.py (8)

3-4: LGTM!

The new imports for Optional, datetime, and timedelta are appropriately added to support the soft-delete functionality with timestamps.


31-32: LGTM!

The ImageRecord TypedDict is correctly extended with is_deleted and deleted_at fields to track soft-delete state.


48-57: LGTM!

The ActionHistoryRecord TypedDict is well-defined for audit logging purposes.


101-127: LGTM!

The action_history table schema and indexes are well-designed. The descending index on timestamp optimizes recent-first queries, and the is_deleted index supports filtering operations.


144-145: LGTM!

New image records are correctly initialized with is_deleted = 0 and deleted_at = NULL.


167-214: LGTM!

The db_get_all_images function is correctly extended with the include_deleted parameter. The WHERE clause construction properly handles both tagged and include_deleted filters with AND conditions.


592-648: LGTM!

The db_get_deleted_images function correctly retrieves soft-deleted images with proper metadata parsing and ordering by deletion timestamp.


651-707: LGTM!

The cleanup function correctly identifies and permanently deletes old soft-deleted images. The ISO 8601 date format from Python's isoformat() is compatible with SQLite's datetime comparison.

backend/app/routes/images.py (5)

3-10: LGTM!

The necessary database functions are properly imported for the new soft-delete functionality.


44-45: LGTM!

The ImageData model is correctly extended with is_deleted and deleted_at fields with appropriate defaults.


60-86: LGTM!

The get_all_images endpoint is correctly extended to support the include_deleted query parameter with proper default behavior.


214-229: LGTM with the same caveat.

The restore endpoint follows the same pattern as soft-delete. The restored_count similarly reflects the request size rather than actual restorations.


238-267: LGTM!

The /deleted endpoint correctly retrieves soft-deleted images with appropriate field handling.

Comment on lines +536 to +538
conn.commit()
logger.info(f"Soft deleted {cursor.rowcount} image(s)")
return True
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Logging statement uses incorrect rowcount.

At line 537, cursor.rowcount reflects the result of the last INSERT INTO action_history statement, not the number of soft-deleted images. Use len(image_ids) instead for accurate logging.

🔎 Proposed fix
         conn.commit()
-        logger.info(f"Soft deleted {cursor.rowcount} image(s)")
+        logger.info(f"Soft deleted {len(image_ids)} image(s)")
         return True
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
conn.commit()
logger.info(f"Soft deleted {cursor.rowcount} image(s)")
return True
conn.commit()
logger.info(f"Soft deleted {len(image_ids)} image(s)")
return True
🤖 Prompt for AI Agents
In backend/app/database/images.py around lines 536 to 538, the logger.info call
uses cursor.rowcount which reflects the last INSERT INTO action_history, not the
number of images soft-deleted; replace that logging to use len(image_ids) (or an
explicit count variable) so the message accurately reports the number of
soft-deleted images, e.g. change the logger.info line to log len(image_ids) and
ensure image_ids is defined as the list of IDs being soft-deleted.

Comment on lines +581 to +583
conn.commit()
logger.info(f"Restored {cursor.rowcount} image(s)")
return True
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Same rowcount logging issue as in db_soft_delete_images.

cursor.rowcount reflects the last INSERT, not the UPDATE. Use len(image_ids) for accurate logging.

🔎 Proposed fix
         conn.commit()
-        logger.info(f"Restored {cursor.rowcount} image(s)")
+        logger.info(f"Restored {len(image_ids)} image(s)")
         return True
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
conn.commit()
logger.info(f"Restored {cursor.rowcount} image(s)")
return True
conn.commit()
logger.info(f"Restored {len(image_ids)} image(s)")
return True
🤖 Prompt for AI Agents
In backend/app/database/images.py around lines 581 to 583, the logger uses
cursor.rowcount which reflects the last INSERT rather than the number of images
restored; replace the rowcount usage with len(image_ids) (or the appropriate
list/collection variable holding the restored image IDs) when logging, ensuring
image_ids is in scope and represents the intended restored-set so the log
reports the correct count; keep the commit and return True as-is.

Comment on lines +744 to +746
conn.commit()
logger.info(f"Manually permanently deleted {cursor.rowcount} image(s)")
return True
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Logging statement uses incorrect rowcount.

After the INSERT loop, cursor.rowcount is 1 (from the last INSERT), not the number of deleted images. Use len(image_ids) instead.

🔎 Proposed fix
         conn.commit()
-        logger.info(f"Manually permanently deleted {cursor.rowcount} image(s)")
+        logger.info(f"Manually permanently deleted {len(image_ids)} image(s)")
         return True
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
conn.commit()
logger.info(f"Manually permanently deleted {cursor.rowcount} image(s)")
return True
conn.commit()
logger.info(f"Manually permanently deleted {len(image_ids)} image(s)")
return True
🤖 Prompt for AI Agents
In backend/app/database/images.py around lines 744 to 746, the logger uses
cursor.rowcount which reflects only the last INSERT rather than the total
deleted images; change the log to use len(image_ids) (or 0 if image_ids may be
None) so the message shows the actual number deleted, e.g.
logger.info(f"Manually permanently deleted {len(image_ids)} image(s)") after the
commit.

Comment on lines +140 to +157
@router.post("/delete")
def delete_image(req: DeleteRequest):
image_id = req.image_id
try:
success = db_soft_delete_images([image_id])
if not success:
raise HTTPException(
status_code=404, detail="Image not found or failed to delete"
)
return {
"success": True,
"image_id": image_id,
"is_deleted": True,
}

except Exception as e:
logger.error(f"error in /delete route: {e}")
raise HTTPException(status_code=500, detail=f"Internal server error: {e}")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# Find the implementation of db_soft_delete_images
fd -t f -e py backend | xargs rg -l "def db_soft_delete_images"

Repository: AOSSIE-Org/PictoPy

Length of output: 92


🏁 Script executed:

# Search for db_soft_delete_images definition and usage
rg -n "db_soft_delete_images" backend/

Repository: AOSSIE-Org/PictoPy

Length of output: 388


🏁 Script executed:

# Find the DeleteRequest model definition
rg -n "class DeleteRequest" backend/

Repository: AOSSIE-Org/PictoPy

Length of output: 126


🏁 Script executed:

# Read the db_soft_delete_images implementation
sed -n '484,520p' backend/app/database/images.py

Repository: AOSSIE-Org/PictoPy

Length of output: 1156


🏁 Script executed:

# Also check the DeleteRequest model definition
sed -n '136,145p' backend/app/routes/images.py

Repository: AOSSIE-Org/PictoPy

Length of output: 288


🏁 Script executed:

# Read more of the db_soft_delete_images function to see the complete implementation
sed -n '484,550p' backend/app/database/images.py

Repository: AOSSIE-Org/PictoPy

Length of output: 2002


🏁 Script executed:

# Check the bulk delete endpoint and how it handles the return value
sed -n '180,200p' backend/app/routes/images.py

Repository: AOSSIE-Org/PictoPy

Length of output: 766


🏁 Script executed:

# Look for any comments or TODOs about idempotency or deletion behavior
rg -n "idempotent|idempotency" backend/

Repository: AOSSIE-Org/PictoPy

Length of output: 44


🏁 Script executed:

# Check if there are any related tests or specifications
fd -t f -e py backend | xargs rg -l "test.*delete.*image\|soft.*delete" | head -5

Repository: AOSSIE-Org/PictoPy

Length of output: 44


Soft delete of non-existent image returns success.

db_soft_delete_images returns True even when no rows are updated (e.g., when the image ID doesn't exist). The endpoint will return success: True for non-existent images, which may confuse clients. The function logs the affected row count but does not validate it before returning success.

Consider verifying the image exists before deletion or checking the affected row count in the database function to return accurate success status.

🤖 Prompt for AI Agents
In backend/app/routes/images.py around lines 140 to 157, the route treats
db_soft_delete_images() returning True as proof the image was deleted even when
no rows were affected; change the logic to verify deletion actually occurred by
checking the affected row count (either change db_soft_delete_images to return
the number of affected rows or have it return False when zero rows were updated)
and then raise HTTPException(404) when no rows were deleted; alternatively first
verify the image exists (e.g., db_get_image_by_id) before attempting soft-delete
and return 404 if not found, and keep the 500 path for real errors.

Comment on lines +8 to +17
import os
import sys

from app.database.images import db_permanently_delete_old_images
from app.logging.setup_logging import get_logger

# Add project root to PYTHONPATH
sys.path.append(os.path.dirname(os.path.abspath(__file__)))

logger = get_logger(__name__)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Path manipulation must precede imports from local modules.

The sys.path.append on line 15 executes after the imports from app.database and app.logging on lines 11–12. If this script is run from outside the backend/ directory, the imports will fail with ModuleNotFoundError.

🔎 Proposed fix
 #!/usr/bin/env python3
 """
 Script to clean up old soft-deleted images.
 Run this script periodically to permanently delete images that have been
 soft deleted for more than 30 days.
 """

 import os
 import sys

+# Add project root to PYTHONPATH before local imports
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
 from app.database.images import db_permanently_delete_old_images
 from app.logging.setup_logging import get_logger

-# Add project root to PYTHONPATH
-sys.path.append(os.path.dirname(os.path.abspath(__file__)))
-
 logger = get_logger(__name__)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import os
import sys
from app.database.images import db_permanently_delete_old_images
from app.logging.setup_logging import get_logger
# Add project root to PYTHONPATH
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
logger = get_logger(__name__)
import os
import sys
# Add project root to PYTHONPATH before local imports
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from app.database.images import db_permanently_delete_old_images
from app.logging.setup_logging import get_logger
logger = get_logger(__name__)
🤖 Prompt for AI Agents
In backend/cleanup_old_images.py around lines 8 to 17, the script appends the
project root to sys.path after importing local modules, which can cause
ModuleNotFoundError when run from outside backend; move the path manipulation to
the top of the file before any imports from app.*, compute the correct project
root (e.g., parent of this file) and insert it into sys.path (prefer
sys.path.insert(0, ...) to prioritize the project root), then keep the logger
and db imports as they are.

Comment on lines +58 to +62
useMutationFeedback(deleteMutation, {
loadingMessage: 'Deleting images...',
successMessage: `${selectedCount} images moved to recently deleted`,
showSuccess: true,
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Stale count in success message.

selectedCount is captured at render time. If the user deselects images before the mutation completes, the message may show an incorrect count. Consider using selectedImages.length directly or storing the count when initiating the mutation.

🔎 Suggested improvement
   useMutationFeedback(deleteMutation, {
     loadingMessage: 'Deleting images...',
-    successMessage: `${selectedCount} images moved to recently deleted`,
+    successMessage: `Images moved to recently deleted`,
     showSuccess: true,
   });

Or capture the count at mutation time and pass it through the mutation context.

🤖 Prompt for AI Agents
In frontend/src/components/Gallery/GalleryToolbar.tsx around lines 58 to 62, the
successMessage uses the render-time variable selectedCount which can be stale by
the time the mutation completes; change the implementation to capture the
selection count at mutation initiation and use that captured value for the
success message (or compute the count from selectedImages.length at the time you
call the mutation), e.g. create a local const countAtSubmit =
selectedImages.length when starting the delete mutation and pass that into
useMutationFeedback (or into the mutation context) so the success message uses
the captured count rather than the render-time selectedCount.

Comment on lines +97 to +101
onClick={(e) => {
console.log(image);
e.stopPropagation();
handleToggleFavourite();
}}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Remove debug console.log statement.

This appears to be a leftover debug statement that should be removed before merging.

🔎 Proposed fix
              onClick={(e) => {
-                 console.log(image);
                  e.stopPropagation();
                  handleToggleFavourite();
                }}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
onClick={(e) => {
console.log(image);
e.stopPropagation();
handleToggleFavourite();
}}
onClick={(e) => {
e.stopPropagation();
handleToggleFavourite();
}}
🤖 Prompt for AI Agents
In frontend/src/components/Media/ImageCard.tsx around lines 97 to 101, remove
the leftover debug console.log(image) from the onClick handler; keep
e.stopPropagation() and handleToggleFavourite() intact and ensure no other debug
console.log calls remain in this handler before committing.

{ name: 'Home', path: `/${ROUTES.HOME}`, icon: Home },
{ name: 'AI Tagging', path: `/${ROUTES.AI}`, icon: Sparkles },
{ name: 'Favourites', path: `/${ROUTES.FAVOURITES}`, icon: Heart },
{ name: 'Recently Deleted ', path: `/${ROUTES.HISTORY}`, icon: History },
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Remove trailing space in menu label.

The menu item name has a trailing space: "Recently Deleted ". This should be "Recently Deleted" for consistency.

🔎 Proposed fix
-    { name: 'Recently Deleted ', path: `/${ROUTES.HISTORY}`, icon: History },
+    { name: 'Recently Deleted', path: `/${ROUTES.HISTORY}`, icon: History },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{ name: 'Recently Deleted ', path: `/${ROUTES.HISTORY}`, icon: History },
{ name: 'Recently Deleted', path: `/${ROUTES.HISTORY}`, icon: History },
🤖 Prompt for AI Agents
In frontend/src/components/Navigation/Sidebar/AppSidebar.tsx around line 53, the
menu item label has an unintended trailing space ("Recently Deleted "); remove
the trailing space so the name is exactly "Recently Deleted" to ensure
consistent labels and avoid rendering/spacing issues.

Comment on lines +40 to +44
useMutationFeedback(permanentDeleteMutation, {
loadingMessage: 'Permanently deleting images...',
successMessage: `${selectedImages.length} images permanently deleted`,
showSuccess: true,
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Success message captures stale selectedImages.length.

The selectedImages.length is captured when useMutationFeedback is called (on render), not when the mutation succeeds. If multiple images are deleted, the message may show an incorrect count.

🔎 Proposed fix - use dynamic message in onSuccess

Consider moving the success feedback into the mutation's onSuccess callback where you have access to the actual count of deleted items, or use a ref to track the count.

+ const deletedCountRef = useRef(0);

  const permanentDeleteMutation = useMutation({
    mutationFn: permanentDeleteImages,
-   onSuccess: () => {
+   onSuccess: (_data, deletedIds) => {
+     deletedCountRef.current = deletedIds.length;
      setSelectedImages([]);
      refetchDeleted();
    },
  });

  useMutationFeedback(permanentDeleteMutation, {
    loadingMessage: 'Permanently deleting images...',
-   successMessage: `${selectedImages.length} images permanently deleted`,
+   successMessage: `Images permanently deleted`,
    showSuccess: true,
  });

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In frontend/src/pages/History/History.tsx around lines 40-44, the successMessage
passed to useMutationFeedback captures selectedImages.length at render time and
can be stale; remove the static successMessage and instead produce the success
feedback inside the mutation's onSuccess handler (or capture the selected count
in a ref/variable immediately before calling the mutation and pass it through),
then call the feedback success function (or set showSuccess) with `${count}
images permanently deleted` using that actual count so the displayed number
reflects what was deleted.

Comment on lines +151 to +159
<Button
size="icon"
variant="destructive"
onClick={() =>
permanentDeleteMutation.mutate([image.id])
}
>
<Trash2 className="h-4 w-4" />
</Button>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Per-image permanent delete uses same mutation as bulk delete.

When deleting a single image via the trash button, the success message from useMutationFeedback will still reference selectedImages.length, which may be 0 or unrelated to this single-item deletion. This creates a confusing UX.

Consider either creating a separate mutation for single-item deletion or adjusting the feedback mechanism to handle both cases.

🤖 Prompt for AI Agents
In frontend/src/pages/History/History.tsx around lines 151 to 159, the per-image
trash button calls the same permanentDeleteMutation used for bulk deletes which
causes the success feedback to reference selectedImages.length (possibly 0 or
unrelated); update the mutation handling so success feedback uses the actual
number of items deleted: either create a separate single-item delete mutation
for the trash button or change the existing mutation's onSuccess to derive the
deleted count from the mutation variables (e.g., the array passed to
mutate/mutateAsync) and use that count when calling useMutationFeedback so the
message accurately reflects single vs. bulk deletions.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feat: Image Deletion, Undo System & Trash(Recently deleted) and restore

1 participant