"""Author RAG — GDPR / Data Rights API. Handles data subject rights under GDPR, CCPA, and similar regulations. Routes: POST /api/admin/{slug}/data/export → download all personal data as JSON POST /api/admin/{slug}/data/delete-request → schedule account + data deletion GET /api/admin/{slug}/data/delete-status → check deletion status POST /api/admin/{slug}/data/anonymize → anonymise conversation messages """ import json import io from datetime import datetime, timezone, timedelta import structlog from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import StreamingResponse from sqlalchemy import select, update, delete from sqlalchemy.ext.asyncio import AsyncSession from app.dependencies import get_admin_author_access, get_db from app.models.user import User from app.models.chat_session import ChatSession, ChatMessage logger = structlog.get_logger(__name__) router = APIRouter() # Track deletion requests (Phase 4: move to DB table) _deletion_requests: dict[str, dict] = {} @router.post("/{slug}/data/export") async def export_personal_data( slug: str, db: AsyncSession = Depends(get_db), access=Depends(get_admin_author_access), ): """Export all personal data for this author as a JSON file (GDPR Article 20).""" author = access.author # Gather all data sessions_r = await db.execute( select(ChatSession).where(ChatSession.author_id == slug) ) sessions = sessions_r.scalars().all() session_data = [] for s in sessions: msgs_r = await db.execute( select(ChatMessage).where(ChatMessage.session_id == s.session_id) ) session_data.append({ "session_id": s.session_id, "started_at": s.created_at.isoformat() if s.created_at else None, "messages": [ {"role": m.role, "content": m.content, "at": m.created_at.isoformat() if m.created_at else None} for m in msgs_r.scalars() ], }) export = { "export_date": datetime.now(timezone.utc).isoformat(), "author": { "id": author.id, "email": author.email, "full_name": author.full_name, "created_at": author.created_at.isoformat() if author.created_at else None, }, "conversations": session_data, "note": "This export contains all personal data we hold for your account.", } content = json.dumps(export, indent=2, ensure_ascii=False) filename = f"data_export_{slug}_{datetime.now().strftime('%Y%m%d')}.json" return StreamingResponse( iter([content]), media_type="application/json", headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) @router.post("/{slug}/data/delete-request", status_code=202) async def request_data_deletion( slug: str, db: AsyncSession = Depends(get_db), access=Depends(get_admin_author_access), ): """Schedule account + all associated data for deletion in 30 days (GDPR Article 17). 30-day grace period allows the author to cancel if requested in error. """ author = access.author if slug in _deletion_requests: existing = _deletion_requests[slug] return { "status": "already_requested", "scheduled_at": existing["scheduled_at"], "deletion_date": existing["deletion_date"], "message": "Deletion already scheduled. Contact support to cancel.", } scheduled_at = datetime.now(timezone.utc) deletion_date = scheduled_at + timedelta(days=30) _deletion_requests[slug] = { "author_id": slug, "scheduled_at": scheduled_at.isoformat(), "deletion_date": deletion_date.isoformat(), "status": "pending", } # Flag the user as pending deletion await db.execute( update(User).where(User.id == slug).values( notes=f"GDPR_DELETE_REQUESTED:{deletion_date.isoformat()}" ) ) await db.commit() logger.warning("Data deletion requested", author_id=slug, deletion_date=deletion_date.isoformat()) return { "status": "scheduled", "scheduled_at": scheduled_at.isoformat(), "deletion_date": deletion_date.isoformat(), "message": ( "Your account and all data will be permanently deleted on " f"{deletion_date.strftime('%B %d, %Y')}. " "To cancel, contact support@chatbotai.io within 30 days." ), } @router.get("/{slug}/data/delete-status") async def get_deletion_status(slug: str, access=Depends(get_admin_author_access)): """Check if a deletion request is pending.""" req = _deletion_requests.get(slug) if not req: return {"status": "none", "message": "No deletion request on file."} return req @router.post("/{slug}/data/anonymize") async def anonymize_conversations( slug: str, db: AsyncSession = Depends(get_db), access=Depends(get_admin_author_access), ): """Anonymise all visitor messages in conversation history. Replaces message content with [ANONYMISED] for analytics retention while removing any PII the visitor may have shared. """ author = access.author # Get all sessions for this author sessions_r = await db.execute( select(ChatSession.session_id).where(ChatSession.author_id == slug) ) session_ids = [row[0] for row in sessions_r] if not session_ids: return {"message": "No conversations to anonymise.", "affected": 0} result = await db.execute( update(ChatMessage) .where(ChatMessage.session_id.in_(session_ids), ChatMessage.role == "user") .values(content="[ANONYMISED]") ) await db.commit() affected = result.rowcount logger.info("Conversations anonymised", author_id=slug, rows=affected) return { "message": f"Anonymised {affected} visitor messages. Bot responses retained for quality analysis.", "affected": affected, }