""" Projects API — list and fetch saved projects by authenticated user. """ import logging import uuid from datetime import datetime from typing import Any from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, field_validator from sqlalchemy import select from agent.scaffold import build_publish_repo, slugify from api.enhance import snapshot_version from auth.crypto import decrypt from auth.dependencies import get_current_user from db.database import SessionLocal from db.models import Project, User from integrations.github_client import GitHubError, publish_files logger = logging.getLogger(__name__) router = APIRouter(prefix="/projects", tags=["projects"]) class ProjectSummary(BaseModel): id: str title: str created_at: datetime updated_at: datetime class ProjectDetail(ProjectSummary): session_id: str brief: str files: dict chat: list oncall_log: list framework: str = "react" # default for old rows that predate the column @router.get("", response_model=list[ProjectSummary]) def list_projects(current_user: User = Depends(get_current_user)) -> list[ProjectSummary]: with SessionLocal() as db: rows = db.execute( select(Project) .where(Project.user_id == current_user.id) .order_by(Project.created_at.desc()) ).scalars().all() return [ ProjectSummary( id=str(r.id), title=r.title, created_at=r.created_at, updated_at=r.updated_at, ) for r in rows ] @router.get("/{project_id}", response_model=ProjectDetail) def get_project(project_id: str, current_user: User = Depends(get_current_user)) -> ProjectDetail: try: pid = uuid.UUID(project_id) except ValueError: raise HTTPException(status_code=404, detail="Project not found") with SessionLocal() as db: row = db.get(Project, pid) if not row or row.user_id != current_user.id: raise HTTPException(status_code=404, detail="Project not found") return ProjectDetail( id=str(row.id), title=row.title, created_at=row.created_at, updated_at=row.updated_at, session_id=row.session_id or "", brief=row.brief, files=row.files, chat=row.chat or [], oncall_log=row.oncall_log or [], framework=getattr(row, "framework", None) or "react", ) class PublishResponse(BaseModel): repo_url: str vercel_import_url: str @router.post("/{project_id}/publish", response_model=PublishResponse) def publish_project( project_id: str, current_user: User = Depends(get_current_user), ) -> PublishResponse: """Build a framework-aware deployable repo from a project's files and push it to GitHub.""" try: pid = uuid.UUID(project_id) except ValueError: raise HTTPException(status_code=404, detail="Project not found") with SessionLocal() as db: row = db.get(Project, pid) if not row or row.user_id != current_user.id: raise HTTPException(status_code=404, detail="Project not found") enc = getattr(current_user, "github_token_enc", None) if not (isinstance(enc, str) and enc): raise HTTPException(status_code=400, detail="Connect GitHub first") token = decrypt(enc) # Old rows that predate the framework column default to "react". framework = getattr(row, "framework", None) or "react" repo_files = build_publish_repo(row.files, framework, row.title) repo_name = f"appsmith-{slugify(row.title)}" try: repo_url = publish_files(token, repo_name, repo_files) except GitHubError as exc: logger.error("GitHub publish failed for project %s: %s", project_id, exc) raise HTTPException(status_code=502, detail=str(exc)) except ValueError: # ponytail: stored token no longer valid — never echo it. raise HTTPException(status_code=400, detail="GitHub token is no longer valid; reconnect GitHub") return PublishResponse( repo_url=repo_url, vercel_import_url=f"https://vercel.com/new/clone?repository-url={repo_url}", ) class HistoryBody(BaseModel): chat: list[Any] oncall_log: list[Any] @field_validator("chat", "oncall_log", mode="before") @classmethod def must_be_list(cls, v: Any) -> list: if not isinstance(v, list): raise ValueError("must be a list") return v @router.put("/{project_id}/history") def update_history( project_id: str, body: HistoryBody, current_user: User = Depends(get_current_user), ) -> dict: """Persist chat + oncall_log arrays for a project (owner-gated).""" try: pid = uuid.UUID(project_id) except ValueError: raise HTTPException(status_code=404, detail="Project not found") with SessionLocal() as db: row = db.get(Project, pid) if not row or row.user_id != current_user.id: raise HTTPException(status_code=404, detail="Project not found") row.chat = body.chat row.oncall_log = body.oncall_log db.commit() return {"ok": True} class VersionSummary(BaseModel): id: str at: str source: str label: str @router.get("/{project_id}/versions", response_model=list[VersionSummary]) def list_versions(project_id: str, current_user: User = Depends(get_current_user)) -> list[VersionSummary]: """List a project's saved versions, newest first (metadata only — no files).""" try: pid = uuid.UUID(project_id) except ValueError: raise HTTPException(status_code=404, detail="Project not found") with SessionLocal() as db: row = db.get(Project, pid) if not row or row.user_id != current_user.id: raise HTTPException(status_code=404, detail="Project not found") versions = list(row.versions or []) return [ VersionSummary( id=str(v.get("id", "")), at=str(v.get("at", "")), source=str(v.get("source", "edit")), label=str(v.get("label", "")), ) for v in reversed(versions) ] class RevertBody(BaseModel): version_id: str @router.post("/{project_id}/revert") def revert_project(project_id: str, body: RevertBody, current_user: User = Depends(get_current_user)) -> dict: """Revert a project's files to a saved version. Snapshots the CURRENT files first (as a "revert" version) so the revert is itself undoable. Returns the reverted file set.""" try: pid = uuid.UUID(project_id) except ValueError: raise HTTPException(status_code=404, detail="Project not found") with SessionLocal() as db: row = db.get(Project, pid) if not row or row.user_id != current_user.id: raise HTTPException(status_code=404, detail="Project not found") target = next((v for v in (row.versions or []) if v.get("id") == body.version_id), None) if not target: raise HTTPException(status_code=404, detail="Version not found") # Snapshot current state so the revert can be undone, then restore. snapshot_version(row, "revert", "before revert") row.files = target["files"] db.commit() files = row.files return {"project_id": project_id, "files": files} @router.delete("/{project_id}") def delete_project(project_id: str, current_user: User = Depends(get_current_user)) -> dict: """Delete a project the authed user owns. 404 (not 403) for missing or someone else's project — we don't reveal that another user's id exists. Idempotent from the client's view: the chat/oncall_log live inline on the row, so deleting the row removes the whole session.""" try: pid = uuid.UUID(project_id) except ValueError: raise HTTPException(status_code=404, detail="Project not found") with SessionLocal() as db: row = db.get(Project, pid) if not row or row.user_id != current_user.id: raise HTTPException(status_code=404, detail="Project not found") db.delete(row) db.commit() return {"ok": True}