diff --git a/apps/backend/fastapi/app/api/routes/versions.py b/apps/backend/fastapi/app/api/routes/versions.py index ed53426..e03ea9d 100644 --- a/apps/backend/fastapi/app/api/routes/versions.py +++ b/apps/backend/fastapi/app/api/routes/versions.py @@ -4,8 +4,12 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_user, get_db -from app.schemas import BranchCreateRequest, VersionResponse -from app.services.versions import create_branch, delete_version +from app.schemas import BranchCreateRequest, PatchApplyRequest, VersionResponse +from app.services.versions import ( + append_patches_to_version, + create_branch, + delete_version, +) from dlib.auth import AuthenticatedUser from dlib.cv.ats_guard import PatchValidationError @@ -48,3 +52,26 @@ async def delete_version_branch( raise HTTPException(status_code=400, detail="Cannot delete root version") if result == "has_children": raise HTTPException(status_code=409, detail="Delete child branches first") + + +@router.post("/{version_id}/patches", response_model=VersionResponse) +async def append_patches( + version_id: str, + payload: PatchApplyRequest, + session: AsyncSession = Depends(get_db), + user: AuthenticatedUser = Depends(get_current_user), +): + if not payload.patches: + raise HTTPException(status_code=400, detail="No patches provided") + try: + version = await append_patches_to_version( + session, + owner_id=user.sub, + version_id=version_id, + patches=payload.patches, + ) + except PatchValidationError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + if not version: + raise HTTPException(status_code=404, detail="Version not found") + return VersionResponse.model_validate(version) diff --git a/apps/backend/fastapi/app/schemas/__init__.py b/apps/backend/fastapi/app/schemas/__init__.py index 36def8e..ee67932 100644 --- a/apps/backend/fastapi/app/schemas/__init__.py +++ b/apps/backend/fastapi/app/schemas/__init__.py @@ -4,6 +4,7 @@ from .cv import ( DocumentCreateResult, DocumentListResponse, DocumentResponse, + PatchApplyRequest, PublicAssetAnalyticsResponse, PublicAssetLookupResponse, PublicAssetResponse, @@ -21,6 +22,7 @@ __all__ = [ "DocumentCreateResult", "VersionResponse", "BranchCreateRequest", + "PatchApplyRequest", "SubmissionCreateRequest", "SubmissionResponse", "AiSuggestionRequest", diff --git a/apps/backend/fastapi/app/schemas/cv.py b/apps/backend/fastapi/app/schemas/cv.py index 1262d53..5d797f5 100644 --- a/apps/backend/fastapi/app/schemas/cv.py +++ b/apps/backend/fastapi/app/schemas/cv.py @@ -63,6 +63,10 @@ class BranchCreateRequest(BaseModel): patches: list[dict[str, Any]] = Field(default_factory=list) +class PatchApplyRequest(BaseModel): + patches: list[dict[str, Any]] = Field(default_factory=list) + + class SubmissionResponse(BaseModel): model_config = ConfigDict(from_attributes=True) diff --git a/apps/backend/fastapi/app/services/versions.py b/apps/backend/fastapi/app/services/versions.py index e36e4ce..1a4402c 100644 --- a/apps/backend/fastapi/app/services/versions.py +++ b/apps/backend/fastapi/app/services/versions.py @@ -84,6 +84,66 @@ async def create_branch( return result.scalars().one() +async def append_patches_to_version( + session: AsyncSession, + *, + owner_id: str, + version_id: str, + patches: list[dict], +) -> CvVersion | None: + stmt = ( + select(CvVersion) + .join(CvVersion.document) + .where(CvVersion.id == version_id, CvDocument.owner_id == owner_id) + .options(selectinload(CvVersion.patches)) + ) + result = await session.execute(stmt) + version = result.scalars().one_or_none() + if not version: + return None + + patch_models = [PatchPayload.model_validate(item) for item in patches] + if not patch_models: + return version + + base_doc = StructuredDocument( + version_label=version.version_label, + blocks=[ + StructuredBlock.model_validate(block) + for block in version.structured_blocks or [] + ], + ) + validate_patchset(base_doc, patch_models) + updated_doc = apply_patchset(base_doc, patch_models) + + version.structured_blocks = [block.model_dump() for block in updated_doc.blocks] + metadata = version.metadata_json or {} + metadata["patch_count"] = int(metadata.get("patch_count") or 0) + len(patch_models) + version.metadata_json = metadata + + for patch in patch_models: + session.add( + CvPatch( + version_id=version.id, + target_path=patch.target_path, + operation=patch.operation.value, + old_value=patch.old_value, + new_value=patch.new_value, + metadata_json=patch.metadata, + ) + ) + + await session.commit() + + stmt_refresh = ( + select(CvVersion) + .where(CvVersion.id == version_id) + .options(selectinload(CvVersion.patches)) + ) + result = await session.execute(stmt_refresh) + return result.scalars().one() + + async def delete_version( session: AsyncSession, owner_id: str, version_id: str ) -> bool | str: diff --git a/apps/webapp/src/app/dashboard/page.tsx b/apps/webapp/src/app/dashboard/page.tsx index 892b7e8..cff2b1c 100644 --- a/apps/webapp/src/app/dashboard/page.tsx +++ b/apps/webapp/src/app/dashboard/page.tsx @@ -5,6 +5,7 @@ import CVTree from '@/components/cv/CVTree'; import DiffViewer from '@/components/cv/DiffViewer'; import Link from 'next/link'; import { + appendPatches, createBranch, createSubmission, deleteDocument, deleteVersion, Document, downloadVersionUrl, fetchDocuments, fetchSubmissions, fetchPublicAssetAnalytics, getPublicPdfUrl, @@ -488,6 +489,8 @@ export default function Dashboard() { const [pendingEdits, setPendingEdits] = useState>(new Map()); const [sidebarOpen, setSidebarOpen] = useState(false); const [docHovered, setDocHovered] = useState(null); + const [applyLoading, setApplyLoading] = useState(false); + const [applyError, setApplyError] = useState(''); useEffect(() => { fetchDocuments() @@ -501,6 +504,8 @@ export default function Dashboard() { useEffect(() => { setPendingEdits(new Map()); + setApplyError(''); + setApplyLoading(false); }, [selectedVersionId]); useEffect(() => { @@ -554,6 +559,21 @@ export default function Dashboard() { const discardEdits = () => setPendingEdits(new Map()); + const applyStagedEdits = async () => { + if (!selectedVersionId || !stagedPatches.length) return; + setApplyLoading(true); + setApplyError(''); + try { + await appendPatches(selectedVersionId, stagedPatches as Record[]); + await refreshDocs(); + setPendingEdits(new Map()); + } catch (e: unknown) { + setApplyError(e instanceof Error ? e.message : 'Failed to apply edits'); + } finally { + setApplyLoading(false); + } + }; + const handleDeleteDoc = async (docId: string) => { if (!confirm('Delete this CV and all its branches? This cannot be undone.')) return; try { @@ -809,13 +829,22 @@ export default function Dashboard() { + + {applyError && ( + + {applyError} + + )} )} diff --git a/apps/webapp/src/libs/api.ts b/apps/webapp/src/libs/api.ts index c50c201..c0108fd 100644 --- a/apps/webapp/src/libs/api.ts +++ b/apps/webapp/src/libs/api.ts @@ -133,6 +133,17 @@ export async function createBranch( }); } +export async function appendPatches( + versionId: string, + patches: Record[], +): Promise { + return req(`/api/v1/versions/${versionId}/patches`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ patches }), + }); +} + export async function createSubmission( versionId: string, companyName: string,