Account export / restore-into-new-tree / delete

New account_service + endpoints under /users/me:
- GET /me/export — zip of every owned tree (account.json + media blobs).
- POST /me/import — restore a backup into NEW trees (ids remapped, media
  re-uploaded); non-destructive, never touches existing data.
- DELETE /me — soft-delete the user, their owned trees, and revoke sessions;
  guarded by retyping the account email.

Settings page wires all three (export download, restore upload, delete with
typed-email confirmation). No migration — uses existing tables + soft-delete.

52 backend tests pass (export→restore round-trip + delete guards); frontend builds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 11:26:04 -04:00
parent d27cc5dddc
commit e9b2436ce0
6 changed files with 853 additions and 7 deletions
+37 -3
View File
@@ -1,8 +1,8 @@
from fastapi import APIRouter
from fastapi import APIRouter, File, Form, Response, UploadFile
from app.api.deps import CurrentUser, SessionDep
from app.api.deps import CurrentUser, ObjectStoreDep, SessionDep
from app.schemas.user import UserRead, UserSelfPersonUpdate
from app.services import user_service
from app.services import account_service, user_service
router = APIRouter(prefix="/users", tags=["users"])
@@ -21,3 +21,37 @@ async def set_self_person(
session, user=current, person_id=data.self_person_id
)
return UserRead.model_validate(user)
@router.get("/me/export")
async def export_account(
session: SessionDep, current: CurrentUser, store: ObjectStoreDep
) -> Response:
"""Download a full backup (JSON + media) of every tree the user owns."""
data = await account_service.export_account(session, store, user=current)
return Response(
content=data,
media_type="application/zip",
headers={"Content-Disposition": 'attachment; filename="provenance-export.zip"'},
)
@router.post("/me/import")
async def import_account(
session: SessionDep,
current: CurrentUser,
store: ObjectStoreDep,
file: UploadFile = File(...),
) -> dict:
"""Restore a previously-exported backup into new trees (non-destructive)."""
raw = await file.read()
return await account_service.import_account(session, store, user=current, raw_zip=raw)
@router.delete("/me", status_code=204)
async def delete_account(
session: SessionDep, current: CurrentUser, confirm_email: str = Form(...)
) -> None:
"""Delete the account: the user, their owned trees, and their sessions.
Requires retyping the account email as a guard."""
await account_service.delete_account(session, user=current, confirm_email=confirm_email)