feat: allow updating existing CV branches

This commit is contained in:
2026-04-04 11:29:46 +02:00
parent 15d5ef6ac6
commit c9914191d8
6 changed files with 137 additions and 4 deletions

View File

@@ -4,8 +4,12 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user, get_db from app.api.deps import get_current_user, get_db
from app.schemas import BranchCreateRequest, VersionResponse from app.schemas import BranchCreateRequest, PatchApplyRequest, VersionResponse
from app.services.versions import create_branch, delete_version from app.services.versions import (
append_patches_to_version,
create_branch,
delete_version,
)
from dlib.auth import AuthenticatedUser from dlib.auth import AuthenticatedUser
from dlib.cv.ats_guard import PatchValidationError 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") raise HTTPException(status_code=400, detail="Cannot delete root version")
if result == "has_children": if result == "has_children":
raise HTTPException(status_code=409, detail="Delete child branches first") 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)

View File

@@ -4,6 +4,7 @@ from .cv import (
DocumentCreateResult, DocumentCreateResult,
DocumentListResponse, DocumentListResponse,
DocumentResponse, DocumentResponse,
PatchApplyRequest,
PublicAssetAnalyticsResponse, PublicAssetAnalyticsResponse,
PublicAssetLookupResponse, PublicAssetLookupResponse,
PublicAssetResponse, PublicAssetResponse,
@@ -21,6 +22,7 @@ __all__ = [
"DocumentCreateResult", "DocumentCreateResult",
"VersionResponse", "VersionResponse",
"BranchCreateRequest", "BranchCreateRequest",
"PatchApplyRequest",
"SubmissionCreateRequest", "SubmissionCreateRequest",
"SubmissionResponse", "SubmissionResponse",
"AiSuggestionRequest", "AiSuggestionRequest",

View File

@@ -63,6 +63,10 @@ class BranchCreateRequest(BaseModel):
patches: list[dict[str, Any]] = Field(default_factory=list) patches: list[dict[str, Any]] = Field(default_factory=list)
class PatchApplyRequest(BaseModel):
patches: list[dict[str, Any]] = Field(default_factory=list)
class SubmissionResponse(BaseModel): class SubmissionResponse(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)

View File

@@ -84,6 +84,66 @@ async def create_branch(
return result.scalars().one() 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( async def delete_version(
session: AsyncSession, owner_id: str, version_id: str session: AsyncSession, owner_id: str, version_id: str
) -> bool | str: ) -> bool | str:

View File

@@ -5,6 +5,7 @@ import CVTree from '@/components/cv/CVTree';
import DiffViewer from '@/components/cv/DiffViewer'; import DiffViewer from '@/components/cv/DiffViewer';
import Link from 'next/link'; import Link from 'next/link';
import { import {
appendPatches,
createBranch, createSubmission, deleteDocument, deleteVersion, createBranch, createSubmission, deleteDocument, deleteVersion,
Document, downloadVersionUrl, Document, downloadVersionUrl,
fetchDocuments, fetchSubmissions, fetchPublicAssetAnalytics, getPublicPdfUrl, fetchDocuments, fetchSubmissions, fetchPublicAssetAnalytics, getPublicPdfUrl,
@@ -488,6 +489,8 @@ export default function Dashboard() {
const [pendingEdits, setPendingEdits] = useState<Map<string, { old_value: string; new_value: string }>>(new Map()); const [pendingEdits, setPendingEdits] = useState<Map<string, { old_value: string; new_value: string }>>(new Map());
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const [docHovered, setDocHovered] = useState<string | null>(null); const [docHovered, setDocHovered] = useState<string | null>(null);
const [applyLoading, setApplyLoading] = useState(false);
const [applyError, setApplyError] = useState('');
useEffect(() => { useEffect(() => {
fetchDocuments() fetchDocuments()
@@ -501,6 +504,8 @@ export default function Dashboard() {
useEffect(() => { useEffect(() => {
setPendingEdits(new Map()); setPendingEdits(new Map());
setApplyError('');
setApplyLoading(false);
}, [selectedVersionId]); }, [selectedVersionId]);
useEffect(() => { useEffect(() => {
@@ -554,6 +559,21 @@ export default function Dashboard() {
const discardEdits = () => setPendingEdits(new Map()); const discardEdits = () => setPendingEdits(new Map());
const applyStagedEdits = async () => {
if (!selectedVersionId || !stagedPatches.length) return;
setApplyLoading(true);
setApplyError('');
try {
await appendPatches(selectedVersionId, stagedPatches as Record<string, unknown>[]);
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) => { const handleDeleteDoc = async (docId: string) => {
if (!confirm('Delete this CV and all its branches? This cannot be undone.')) return; if (!confirm('Delete this CV and all its branches? This cannot be undone.')) return;
try { try {
@@ -809,13 +829,22 @@ export default function Dashboard() {
<button <button
className="btn btn-primary" className="btn btn-primary"
style={{ fontSize: 12, padding: '3px 10px', background: '#92400e', borderColor: '#92400e' }} style={{ fontSize: 12, padding: '3px 10px', background: '#92400e', borderColor: '#92400e' }}
onClick={() => setModal('branch')} onClick={applyStagedEdits}
disabled={applyLoading}
> >
Save as branch {applyLoading ? 'Applying…' : 'Apply to branch'}
</button>
<button className="btn btn-ghost" style={{ fontSize: 12, padding: '3px 8px' }} onClick={() => setModal('branch')}>
Save as new branch
</button> </button>
<button className="btn btn-ghost" style={{ fontSize: 12, padding: '3px 8px' }} onClick={discardEdits}> <button className="btn btn-ghost" style={{ fontSize: 12, padding: '3px 8px' }} onClick={discardEdits}>
Discard Discard
</button> </button>
{applyError && (
<span style={{ color: '#b91c1c', fontSize: 12, flexBasis: '100%' }}>
{applyError}
</span>
)}
</div> </div>
)} )}

View File

@@ -133,6 +133,17 @@ export async function createBranch(
}); });
} }
export async function appendPatches(
versionId: string,
patches: Record<string, unknown>[],
): Promise<Version> {
return req<Version>(`/api/v1/versions/${versionId}/patches`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ patches }),
});
}
export async function createSubmission( export async function createSubmission(
versionId: string, versionId: string,
companyName: string, companyName: string,