Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions apps/backend/app/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ def improvements(self) -> Table:
"""Improvement results table."""
return self.db.table("improvements")

@property
def resume_json_backups(self) -> Table:
"""Backups created before browser JSON imports overwrite resumes."""
return self.db.table("resume_json_backups")

def close(self) -> None:
"""Close database connection."""
if self._db is not None:
Expand Down Expand Up @@ -169,6 +174,29 @@ def update_resume(self, resume_id: str, updates: dict[str, Any]) -> dict[str, An

return result

def create_resume_json_backup(
self,
resume: dict[str, Any],
source: str = "json_upload",
) -> dict[str, Any]:
"""Create a point-in-time backup before replacing resume JSON."""
now = datetime.now(timezone.utc).isoformat()
backup = {
"backup_id": str(uuid4()),
"resume_id": resume["resume_id"],
"source": source,
"created_at": now,
"previous_content": resume.get("content"),
"previous_content_type": resume.get("content_type"),
"previous_processed_data": resume.get("processed_data"),
"previous_processing_status": resume.get("processing_status"),
"previous_title": resume.get("title"),
"previous_filename": resume.get("filename"),
"previous_updated_at": resume.get("updated_at"),
}
self.resume_json_backups.insert(backup)
return backup

def delete_resume(self, resume_id: str) -> bool:
"""Delete resume by ID."""
Resume = Query()
Expand Down
130 changes: 130 additions & 0 deletions apps/backend/app/routers/resumes.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@
import logging
import unicodedata
from collections.abc import Awaitable
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, NoReturn
from uuid import uuid4

from fastapi import APIRouter, File, HTTPException, Query, UploadFile
from fastapi.responses import Response
from pydantic import ValidationError

from app.config_cache import get_content_language, load_config as _load_config
from app.database import db
Expand Down Expand Up @@ -137,6 +139,70 @@ def _get_original_resume_data(resume: dict[str, Any]) -> dict[str, Any] | None:
return original_data


def _get_resume_json_payload(resume: dict[str, Any]) -> dict[str, Any]:
"""Return the structured resume JSON for export or replacement."""
resume_data = resume.get("processed_data")
if not resume_data and resume.get("content_type") == "json":
try:
resume_data = json.loads(resume["content"])
except json.JSONDecodeError as e:
raise HTTPException(
status_code=422,
detail="Stored resume content is not valid JSON",
) from e

if not isinstance(resume_data, dict):
raise HTTPException(status_code=404, detail="Resume JSON not found")

normalized_data = normalize_resume_data(copy.deepcopy(resume_data))
try:
return ResumeData.model_validate(normalized_data).model_dump(mode="json")
except ValidationError as e:
raise HTTPException(status_code=422, detail="Stored resume JSON is invalid") from e


def _extract_uploaded_resume_json(payload: Any, resume_id: str) -> dict[str, Any]:
"""Accept either a raw ResumeData object or exported {metadata, resume} JSON."""
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="Uploaded JSON must be an object")

metadata = payload.get("metadata")
uploaded_resume_id = None
if isinstance(metadata, dict):
uploaded_resume_id = metadata.get("resume_id")
if uploaded_resume_id and uploaded_resume_id != resume_id:
raise HTTPException(
status_code=400,
detail="Uploaded JSON belongs to a different resume_id",
)

resume_payload = payload.get("resume", payload)
if not isinstance(resume_payload, dict):
raise HTTPException(status_code=400, detail="Uploaded resume JSON must be an object")

normalized_data = normalize_resume_data(copy.deepcopy(resume_payload))
try:
return ResumeData.model_validate(normalized_data).model_dump(mode="json")
except ValidationError as e:
raise HTTPException(status_code=422, detail="Uploaded resume JSON is invalid") from e


def _build_json_export_metadata(resume: dict[str, Any]) -> dict[str, Any]:
return {
"schema": "resume-matcher-json-export/v1",
"resume_id": resume["resume_id"],
"title": resume.get("title"),
"filename": resume.get("filename"),
"is_master": resume.get("is_master", False),
"parent_id": resume.get("parent_id"),
"content_type": resume.get("content_type"),
"processing_status": resume.get("processing_status", "pending"),
"created_at": resume.get("created_at"),
"updated_at": resume.get("updated_at"),
"exported_at": datetime.now(timezone.utc).isoformat(),
}


def _get_original_markdown(resume: dict[str, Any]) -> str | None:
"""Get the original markdown content from a resume.

Expand Down Expand Up @@ -1386,6 +1452,70 @@ async def update_resume_endpoint(
)


@router.get("/{resume_id}/json")
async def export_resume_json(resume_id: str) -> dict[str, Any]:
"""Export a resume as JSON with metadata for browser downloads."""
resume = db.get_resume(resume_id)
if not resume:
raise HTTPException(status_code=404, detail="Resume not found")

return {
"metadata": _build_json_export_metadata(resume),
"resume": _get_resume_json_payload(resume),
}


@router.put("/{resume_id}/json", response_model=ResumeFetchResponse)
async def replace_resume_json(
resume_id: str, payload: dict[str, Any]
) -> ResumeFetchResponse:
"""Replace a resume's structured JSON, preserving the same resume_id.

The uploaded body can be either the export wrapper ({metadata, resume}) or
a raw ResumeData object. A backup of the previous JSON/content is stored
before the overwrite.
"""
existing = db.get_resume(resume_id)
if not existing:
raise HTTPException(status_code=404, detail="Resume not found")

updated_data = _extract_uploaded_resume_json(payload, resume_id)
updated_content = json.dumps(updated_data, indent=2, ensure_ascii=False)

backup = db.create_resume_json_backup(existing, source="browser_json_upload")
updated = db.update_resume(
resume_id,
{
"content": updated_content,
"content_type": "json",
"processed_data": updated_data,
"processing_status": "ready",
"last_json_backup_id": backup["backup_id"],
},
)

raw_resume = RawResume(
id=None,
content=updated["content"],
content_type=updated["content_type"],
created_at=updated["created_at"],
processing_status=updated.get("processing_status", "pending"),
)

return ResumeFetchResponse(
request_id=str(uuid4()),
data=ResumeFetchData(
resume_id=resume_id,
raw_resume=raw_resume,
processed_resume=ResumeData.model_validate(updated.get("processed_data")),
cover_letter=updated.get("cover_letter"),
outreach_message=updated.get("outreach_message"),
parent_id=updated.get("parent_id"),
title=updated.get("title"),
),
)


@router.get("/{resume_id}/pdf")
async def download_resume_pdf(
resume_id: str,
Expand Down
73 changes: 73 additions & 0 deletions apps/backend/tests/integration/test_resume_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,79 @@ async def test_update_outreach(self, mock_db, client, mock_resume_record):
assert resp.status_code == 200


class TestResumeJsonImportExport:
"""GET/PUT /api/v1/resumes/{resume_id}/json"""

@patch("app.routers.resumes.db")
async def test_export_resume_json_includes_metadata(
self, mock_db, client, mock_resume_record
):
mock_db.get_resume.return_value = {
**mock_resume_record,
"title": "Google TPM",
"content_type": "json",
}
async with client:
resp = await client.get("/api/v1/resumes/res-123/json")

assert resp.status_code == 200
payload = resp.json()
assert payload["metadata"]["schema"] == "resume-matcher-json-export/v1"
assert payload["metadata"]["resume_id"] == "res-123"
assert payload["metadata"]["title"] == "Google TPM"
assert payload["resume"]["personalInfo"]["name"] == "Jane Doe"

@patch("app.routers.resumes.db")
async def test_replace_resume_json_creates_backup(
self, mock_db, client, mock_resume_record, sample_resume
):
mock_db.get_resume.return_value = mock_resume_record
mock_db.create_resume_json_backup.return_value = {"backup_id": "backup-123"}
mock_db.update_resume.return_value = {
**mock_resume_record,
"content": "{}",
"content_type": "json",
"processed_data": sample_resume,
"last_json_backup_id": "backup-123",
}

async with client:
resp = await client.put(
"/api/v1/resumes/res-123/json",
json={
"metadata": {"resume_id": "res-123"},
"resume": sample_resume,
},
)

assert resp.status_code == 200
mock_db.create_resume_json_backup.assert_called_once_with(
mock_resume_record, source="browser_json_upload"
)
update_payload = mock_db.update_resume.call_args.args[1]
assert update_payload["content_type"] == "json"
assert update_payload["processing_status"] == "ready"
assert update_payload["last_json_backup_id"] == "backup-123"

@patch("app.routers.resumes.db")
async def test_replace_resume_json_rejects_other_resume_id(
self, mock_db, client, mock_resume_record, sample_resume
):
mock_db.get_resume.return_value = mock_resume_record

async with client:
resp = await client.put(
"/api/v1/resumes/res-123/json",
json={
"metadata": {"resume_id": "other-resume"},
"resume": sample_resume,
},
)

assert resp.status_code == 400
mock_db.update_resume.assert_not_called()


class TestRetryProcessing:
"""POST /api/v1/resumes/{resume_id}/retry-processing"""

Expand Down
Loading