Skip to content

Async Agent Prompt: Implement Trash/Soft Delete Feature

Overview

Implement a complete "Trash" feature for the Kemma mindmap application. When users delete a mindmap, it should be soft-deleted (moved to trash) rather than permanently removed. Users can view, restore, or permanently delete items in trash. Items in trash are auto-purged after 30 days.

Architecture Context

  • Backend: FastAPI + SQLAlchemy (async) + SQLite
  • Frontend: React + TypeScript + Vite
  • Sync: IndexedDB + sync queue
  • Key Files:
    • Backend models: apps/server/kemma/db/models.py
    • Backend routes: apps/server/kemma/api/routes/mindmaps.py
    • Backend schemas: apps/server/kemma/api/schemas.py
    • Frontend persistence: apps/web-client/src/services/mindmapPersistence.ts
    • Frontend DB: apps/web-client/src/services/db.ts
    • User home page: apps/web-client/src/pages/UserHome.tsx
    • Delete dialog: apps/web-client/src/components/user-home/DeleteMindmapDialog.tsx

Design Decisions (Pre-Approved)

  1. Soft delete via deleted_at timestamp on MindMap model
  2. Nodes remain orphaned until cleanup job runs (not deleted with map)
  3. 30-day retention before auto-purge
  4. Users can "Empty Trash" immediately (sets purge_after timestamp)
  5. Background purge job using APScheduler (runs daily at 3 AM)
  6. Hard delete happens via background job, not in request/response cycle

Epic 1: Backend Schema & API for Soft Delete

1.1 Update Database Models

Modify apps/server/kemma/db/models.py:

python
class MindMap(Base):
    # ... existing fields ...

    # Soft delete support
    deleted_at = Column(String, nullable=True, index=True)  # ISO timestamp when moved to trash
    purge_after = Column(String, nullable=True, index=True)  # ISO timestamp when eligible for hard delete

1.2 Create Database Migration

Create Alembic migration or update the lifespan function to handle schema evolution.

1.3 Update API Schemas

Modify apps/server/kemma/api/schemas.py:

python
class MindMapResponse(MindMapBase):
    id: str
    user_id: str
    created_at: str | None = None
    updated_at: str | None = None
    deleted_at: str | None = None  # NEW: When moved to trash

1.4 Modify Existing Endpoints

Update apps/server/kemma/api/routes/mindmaps.py:

  • GET /api/v1/maps: Add filter WHERE deleted_at IS NULL
  • DELETE /api/v1/maps/{id}: Change from hard delete to soft delete:
    python
    m.deleted_at = datetime.now(UTC).isoformat().replace("+00:00", "Z")
    await db.commit()
    # Do NOT delete the map or its nodes

1.5 Create New Trash Endpoints

Add to apps/server/kemma/api/routes/mindmaps.py:

python
@router.get("/maps/trash", response_model=list[MindMapResponse])
async def list_trash(db, current_user):
    """List all soft-deleted maps for current user."""
    query = select(MindMap).where(
        MindMap.user_id == current_user.id,
        MindMap.deleted_at.isnot(None),
        MindMap.purge_after.is_(None)  # Not yet marked for purge
    ).order_by(MindMap.deleted_at.desc())
    # ...

@router.post("/maps/{map_id}/restore", response_model=MindMapResponse)
async def restore_map(map_id, db, current_user):
    """Restore a map from trash."""
    # Set deleted_at = None
    # ...

@router.delete("/maps/{map_id}/permanent", status_code=204)
async def permanent_delete(map_id, db, current_user):
    """Mark map for permanent deletion (sets purge_after)."""
    m.purge_after = datetime.now(UTC).isoformat().replace("+00:00", "Z")
    await db.commit()
    # Background job will handle actual deletion

1.6 Write Tests

Create apps/server/tests/test_mindmaps_trash.py with tests for:

  • Soft delete moves to trash
  • Trashed maps don't appear in list
  • Restore brings map back
  • Permanent delete sets purge_after

Epic 2: Frontend Trash View UX

2.1 Create Trash Page

Create apps/web-client/src/pages/Trash.tsx:

  • Fetch trashed items via api.getTrash()
  • Display with restore/delete permanently buttons
  • Show "deleted X days ago" metadata
  • Empty state: "Trash is empty" message

2.2 Add Route

Update router to add /trash route.

2.3 Update Sidebar

Modify apps/web-client/src/components/user-home/Sidebar.tsx:

  • Add "Trash" icon/link
  • Show badge with count of items in trash (optional)

2.4 Create Dialogs

  • RestoreConfirmDialog.tsx: "Restore 'Map Name' from trash?"
  • PermanentDeleteDialog.tsx: Strong warning about permanent deletion

2.5 Update DeleteMindmapDialog

Modify apps/web-client/src/components/user-home/DeleteMindmapDialog.tsx:

  • Change title: "Move to Trash"
  • Change message: "This mindmap will be moved to Trash. You can restore it within 30 days."
  • Change button: "Move to Trash" (not "Delete")

2.6 Add API Functions

Update apps/web-client/src/services/api.ts:

typescript
async getTrash(): Promise<MindMapResponse[]> {
  const response = await fetch('/api/v1/maps/trash', { headers: ... })
  return response.json()
}

async restoreMap(id: string): Promise<MindMapResponse> {
  const response = await fetch(`/api/v1/maps/${id}/restore`, { method: 'POST', ... })
  return response.json()
}

async permanentlyDeleteMap(id: string): Promise<void> {
  await fetch(`/api/v1/maps/${id}/permanent`, { method: 'DELETE', ... })
}

2.7 Write Tests

  • Vitest unit tests for Trash page components
  • Playwright E2E: Delete → appears in trash → restore → back in list

Epic 3: Update Sync Protocol

3.1 Update Frontend Persistence

Modify apps/web-client/src/services/mindmapPersistence.ts:

typescript
export async function deleteMindmap(mapId: string): Promise<void> {
    // Do NOT delete nodes from IndexedDB anymore
    // Just update the map's deletedAt field locally
    const map = await db.getMap(mapId)
    if (map) {
        map.deletedAt = new Date().toISOString()
        await db.saveMap(map)
    }

    // Queue MAP_SOFT_DELETE for backend sync
    await db.saveDelta({
        type: 'MAP_SOFT_DELETE',
        mapId,
        timestamp: Date.now(),
    })

    syncManager.triggerSync()
}

3.2 Add New Sync Record Types

Update schema types:

  • MAP_SOFT_DELETE - Move to trash
  • MAP_RESTORE - Restore from trash
  • MAP_PERMANENT_DELETE - Mark for purge

3.3 Update IndexedDB Schema

Modify apps/web-client/src/services/db.ts:

  • Increment DB_VERSION
  • Add deletedAt field to maps store
  • Add index on deletedAt for filtering

3.4 Update MindMapMeta Type

Modify apps/web-client/src/types/MindMapMeta.ts:

typescript
export interface MindMapMeta {
    // ... existing fields ...
    deletedAt?: string  // NEW: ISO timestamp when moved to trash
}

Epic 4: Background Job with APScheduler

4.1 Install APScheduler

Add to apps/server/pyproject.toml:

toml
dependencies = [
    # ... existing ...
    "APScheduler>=3.10.0",
]

4.2 Create Scheduler Module

Create apps/server/kemma/jobs/scheduler.py:

python
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger

scheduler = AsyncIOScheduler()

def setup_scheduler():
    """Configure and return the scheduler with all jobs."""
    from kemma.jobs.purge import purge_expired_maps, auto_expire_old_trash

    # Run purge at 3:00 AM daily
    scheduler.add_job(
        purge_expired_maps,
        CronTrigger(hour=3, minute=0),
        id="purge_expired_maps",
        replace_existing=True
    )

    # Check for items to auto-expire at 3:30 AM daily
    scheduler.add_job(
        auto_expire_old_trash,
        CronTrigger(hour=3, minute=30),
        id="auto_expire_old_trash",
        replace_existing=True
    )

    return scheduler

4.3 Create Purge Jobs

Create apps/server/kemma/jobs/purge.py:

python
from datetime import UTC, datetime, timedelta
from sqlalchemy import delete
from sqlalchemy.future import select

from kemma.core.logging import get_logger
from kemma.db.database import AsyncSessionLocal
from kemma.db.models import MindMap, MindMapNode

logger = get_logger(__name__)

async def purge_expired_maps():
    """Hard delete maps where purge_after has passed."""
    now = datetime.now(UTC).isoformat().replace("+00:00", "Z")

    async with AsyncSessionLocal() as db:
        # Find maps ready for purge
        result = await db.execute(
            select(MindMap).where(
                MindMap.purge_after.isnot(None),
                MindMap.purge_after < now
            )
        )
        maps_to_purge = result.scalars().all()

        for m in maps_to_purge:
            logger.info(f"Purging map {m.id} and its nodes")

            # Delete nodes first
            await db.execute(
                delete(MindMapNode).where(MindMapNode.map_id == m.id)
            )

            # Delete the map
            await db.delete(m)

        await db.commit()
        logger.info(f"Purged {len(maps_to_purge)} maps")

async def auto_expire_old_trash():
    """Set purge_after for items in trash > 30 days."""
    cutoff = (datetime.now(UTC) - timedelta(days=30)).isoformat().replace("+00:00", "Z")
    now = datetime.now(UTC).isoformat().replace("+00:00", "Z")

    async with AsyncSessionLocal() as db:
        result = await db.execute(
            select(MindMap).where(
                MindMap.deleted_at.isnot(None),
                MindMap.deleted_at < cutoff,
                MindMap.purge_after.is_(None)
            )
        )
        old_trash = result.scalars().all()

        for m in old_trash:
            logger.info(f"Auto-expiring map {m.id} (in trash since {m.deleted_at})")
            m.purge_after = now

        await db.commit()
        logger.info(f"Auto-expired {len(old_trash)} maps")

4.4 Integrate with FastAPI Lifespan

Update apps/server/kemma/main.py:

python
from kemma.jobs.scheduler import scheduler, setup_scheduler

@asynccontextmanager
async def lifespan(app: FastAPI):
    # ... existing database setup ...

    # Start scheduler
    setup_scheduler()
    scheduler.start()
    logger.info("Background scheduler started")

    yield

    # Shutdown
    scheduler.shutdown()
    logger.info("Background scheduler stopped")

4.5 Add Admin Endpoint (Optional)

python
@router.get("/admin/jobs")
async def list_scheduled_jobs():
    """List all scheduled background jobs and their next run times."""
    from kemma.jobs.scheduler import scheduler
    return [
        {
            "id": job.id,
            "next_run": job.next_run_time.isoformat() if job.next_run_time else None,
            "trigger": str(job.trigger)
        }
        for job in scheduler.get_jobs()
    ]

4.6 Write Tests

Create apps/server/tests/test_purge_jobs.py:

  • Test purge_expired_maps deletes correct records
  • Test auto_expire_old_trash sets purge_after correctly
  • Test nodes are deleted with their parent map

Verification Plan

Automated Tests

  1. Run make test in server directory
  2. Run npm test in web-client directory
  3. Run npm run test:e2e for Playwright tests

Linting

  1. Run uv run ruff check . --fix
  2. Run uv run ruff format .

Manual Verification

  1. Create a mindmap → Delete it → Verify appears in Trash
  2. Restore from Trash → Verify appears in main list
  3. Permanently delete from Trash → Verify removed
  4. Check /admin/jobs endpoint shows scheduled purge jobs

Implementation Order

Complete these epics in order:

  1. Epic 1 - Backend schema and API (foundation)
  2. Epic 3 - Sync protocol updates
  3. Epic 2 - Frontend Trash UX
  4. Epic 4 - Background job infrastructure

Each epic should be a separate PR with its own tests passing before merge.