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
- Backend models:
Design Decisions (Pre-Approved)
- Soft delete via
deleted_attimestamp onMindMapmodel - Nodes remain orphaned until cleanup job runs (not deleted with map)
- 30-day retention before auto-purge
- Users can "Empty Trash" immediately (sets
purge_aftertimestamp) - Background purge job using APScheduler (runs daily at 3 AM)
- 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:
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 delete1.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:
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 trash1.4 Modify Existing Endpoints
Update apps/server/kemma/api/routes/mindmaps.py:
GET /api/v1/maps: Add filterWHERE deleted_at IS NULLDELETE /api/v1/maps/{id}: Change from hard delete to soft delete:pythonm.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:
@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 deletion1.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:
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:
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 trashMAP_RESTORE- Restore from trashMAP_PERMANENT_DELETE- Mark for purge
3.3 Update IndexedDB Schema
Modify apps/web-client/src/services/db.ts:
- Increment
DB_VERSION - Add
deletedAtfield to maps store - Add index on
deletedAtfor filtering
3.4 Update MindMapMeta Type
Modify apps/web-client/src/types/MindMapMeta.ts:
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:
dependencies = [
# ... existing ...
"APScheduler>=3.10.0",
]4.2 Create Scheduler Module
Create apps/server/kemma/jobs/scheduler.py:
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 scheduler4.3 Create Purge Jobs
Create apps/server/kemma/jobs/purge.py:
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:
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)
@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_mapsdeletes correct records - Test
auto_expire_old_trashsets purge_after correctly - Test nodes are deleted with their parent map
Verification Plan
Automated Tests
- Run
make testin server directory - Run
npm testin web-client directory - Run
npm run test:e2efor Playwright tests
Linting
- Run
uv run ruff check . --fix - Run
uv run ruff format .
Manual Verification
- Create a mindmap → Delete it → Verify appears in Trash
- Restore from Trash → Verify appears in main list
- Permanently delete from Trash → Verify removed
- Check
/admin/jobsendpoint shows scheduled purge jobs
Implementation Order
Complete these epics in order:
- Epic 1 - Backend schema and API (foundation)
- Epic 3 - Sync protocol updates
- Epic 2 - Frontend Trash UX
- Epic 4 - Background job infrastructure
Each epic should be a separate PR with its own tests passing before merge.