diff --git a/dev-api.py b/dev-api.py index 29d178f..d2486dc 100644 --- a/dev-api.py +++ b/dev-api.py @@ -14,7 +14,8 @@ from urllib.parse import urlparse from bs4 import BeautifulSoup from datetime import datetime from pathlib import Path -from fastapi import FastAPI, HTTPException, Response +import yaml +from fastapi import FastAPI, HTTPException, Response, UploadFile from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel from typing import Optional, List @@ -1017,3 +1018,73 @@ def save_profile(payload: UserProfilePayload): return {"ok": True} except Exception as e: raise HTTPException(500, f"Could not save profile: {e}") + + +# ── Settings: Resume Profile endpoints ─────────────────────────────────────── + +class WorkEntry(BaseModel): + title: str = ""; company: str = ""; period: str = ""; location: str = "" + industry: str = ""; responsibilities: str = ""; skills: List[str] = [] + +class ResumePayload(BaseModel): + name: str = ""; email: str = ""; phone: str = ""; linkedin_url: str = "" + surname: str = ""; address: str = ""; city: str = ""; zip_code: str = ""; date_of_birth: str = "" + experience: List[WorkEntry] = [] + salary_min: int = 0; salary_max: int = 0; notice_period: str = "" + remote: bool = False; relocation: bool = False + assessment: bool = False; background_check: bool = False + gender: str = ""; pronouns: str = ""; ethnicity: str = "" + veteran_status: str = ""; disability: str = "" + skills: List[str] = []; domains: List[str] = []; keywords: List[str] = [] + +RESUME_PATH = Path("config/plain_text_resume.yaml") + +@app.get("/api/settings/resume") +def get_resume(): + try: + if not RESUME_PATH.exists(): + return {"exists": False} + with open(RESUME_PATH) as f: + data = yaml.safe_load(f) or {} + data["exists"] = True + return data + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@app.put("/api/settings/resume") +def save_resume(payload: ResumePayload): + try: + RESUME_PATH.parent.mkdir(parents=True, exist_ok=True) + with open(RESUME_PATH, "w") as f: + yaml.dump(payload.model_dump(), f, allow_unicode=True, default_flow_style=False) + return {"ok": True} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@app.post("/api/settings/resume/blank") +def create_blank_resume(): + try: + RESUME_PATH.parent.mkdir(parents=True, exist_ok=True) + if not RESUME_PATH.exists(): + with open(RESUME_PATH, "w") as f: + yaml.dump({}, f) + return {"ok": True} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@app.post("/api/settings/resume/upload") +async def upload_resume(file: UploadFile): + from scripts.resume_parser import structure_resume + import tempfile, os + suffix = Path(file.filename).suffix.lower() + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp: + tmp.write(await file.read()) + tmp_path = tmp.name + try: + result, error = structure_resume(tmp_path) + finally: + os.unlink(tmp_path) + if error: + return {"ok": False, "error": error, "data": result} + result["exists"] = True + return {"ok": True, "data": result} diff --git a/web/src/stores/settings/resume.test.ts b/web/src/stores/settings/resume.test.ts new file mode 100644 index 0000000..4415513 --- /dev/null +++ b/web/src/stores/settings/resume.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useResumeStore } from './resume' + +vi.mock('../../composables/useApi', () => ({ useApiFetch: vi.fn() })) +import { useApiFetch } from '../../composables/useApi' +const mockFetch = vi.mocked(useApiFetch) + +describe('useResumeStore', () => { + beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks() }) + + it('hasResume is false before load', () => { + expect(useResumeStore().hasResume).toBe(false) + }) + + it('load() sets hasResume from API exists flag', async () => { + mockFetch.mockResolvedValue({ data: { exists: true, name: 'Meg', email: '', phone: '', + linkedin_url: '', surname: '', address: '', city: '', zip_code: '', date_of_birth: '', + experience: [], salary_min: 0, salary_max: 0, notice_period: '', remote: false, + relocation: false, assessment: false, background_check: false, + gender: '', pronouns: '', ethnicity: '', veteran_status: '', disability: '', + skills: [], domains: [], keywords: [], + }, error: null }) + const store = useResumeStore() + await store.load() + expect(store.hasResume).toBe(true) + }) + + it('syncFromProfile() copies identity fields', () => { + const store = useResumeStore() + store.syncFromProfile({ name: 'Test', email: 'a@b.com', phone: '555', linkedin_url: 'li.com/test' }) + expect(store.name).toBe('Test') + expect(store.email).toBe('a@b.com') + }) + + it('load() empty-state when exists=false', async () => { + mockFetch.mockResolvedValue({ data: { exists: false }, error: null }) + const store = useResumeStore() + await store.load() + expect(store.hasResume).toBe(false) + }) +}) diff --git a/web/src/stores/settings/resume.ts b/web/src/stores/settings/resume.ts new file mode 100644 index 0000000..8ecbba9 --- /dev/null +++ b/web/src/stores/settings/resume.ts @@ -0,0 +1,95 @@ +import { ref } from 'vue' +import { defineStore } from 'pinia' +import { useApiFetch } from '../../composables/useApi' + +export interface WorkEntry { + title: string; company: string; period: string; location: string + industry: string; responsibilities: string; skills: string[] +} + +export const useResumeStore = defineStore('settings/resume', () => { + const hasResume = ref(false) + const loading = ref(false) + const saving = ref(false) + const saveError = ref(null) + + // Identity (synced from profile store) + const name = ref(''); const email = ref(''); const phone = ref(''); const linkedin_url = ref('') + // Resume-only contact + const surname = ref(''); const address = ref(''); const city = ref('') + const zip_code = ref(''); const date_of_birth = ref('') + // Experience + const experience = ref([]) + // Prefs + const salary_min = ref(0); const salary_max = ref(0); const notice_period = ref('') + const remote = ref(false); const relocation = ref(false) + const assessment = ref(false); const background_check = ref(false) + // Self-ID + const gender = ref(''); const pronouns = ref(''); const ethnicity = ref('') + const veteran_status = ref(''); const disability = ref('') + // Keywords + const skills = ref([]); const domains = ref([]); const keywords = ref([]) + + function syncFromProfile(p: { name: string; email: string; phone: string; linkedin_url: string }) { + name.value = p.name; email.value = p.email + phone.value = p.phone; linkedin_url.value = p.linkedin_url + } + + async function load() { + loading.value = true + const { data, error } = await useApiFetch>('/api/settings/resume') + loading.value = false + if (error || !data || !data.exists) { hasResume.value = false; return } + hasResume.value = true + name.value = String(data.name ?? ''); email.value = String(data.email ?? '') + phone.value = String(data.phone ?? ''); linkedin_url.value = String(data.linkedin_url ?? '') + surname.value = String(data.surname ?? ''); address.value = String(data.address ?? '') + city.value = String(data.city ?? ''); zip_code.value = String(data.zip_code ?? '') + date_of_birth.value = String(data.date_of_birth ?? '') + experience.value = (data.experience as WorkEntry[]) ?? [] + salary_min.value = Number(data.salary_min ?? 0); salary_max.value = Number(data.salary_max ?? 0) + notice_period.value = String(data.notice_period ?? '') + remote.value = Boolean(data.remote); relocation.value = Boolean(data.relocation) + assessment.value = Boolean(data.assessment); background_check.value = Boolean(data.background_check) + gender.value = String(data.gender ?? ''); pronouns.value = String(data.pronouns ?? '') + ethnicity.value = String(data.ethnicity ?? ''); veteran_status.value = String(data.veteran_status ?? '') + disability.value = String(data.disability ?? '') + skills.value = (data.skills as string[]) ?? [] + domains.value = (data.domains as string[]) ?? [] + keywords.value = (data.keywords as string[]) ?? [] + } + + async function save() { + saving.value = true; saveError.value = null + const body = { + name: name.value, email: email.value, phone: phone.value, linkedin_url: linkedin_url.value, + surname: surname.value, address: address.value, city: city.value, zip_code: zip_code.value, + date_of_birth: date_of_birth.value, experience: experience.value, + salary_min: salary_min.value, salary_max: salary_max.value, notice_period: notice_period.value, + remote: remote.value, relocation: relocation.value, + assessment: assessment.value, background_check: background_check.value, + gender: gender.value, pronouns: pronouns.value, ethnicity: ethnicity.value, + veteran_status: veteran_status.value, disability: disability.value, + skills: skills.value, domains: domains.value, keywords: keywords.value, + } + const { error } = await useApiFetch('/api/settings/resume', { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), + }) + saving.value = false + if (error) saveError.value = 'Save failed — please try again.' + } + + async function createBlank() { + const { error } = await useApiFetch('/api/settings/resume/blank', { method: 'POST' }) + if (!error) { hasResume.value = true; await load() } + } + + return { + hasResume, loading, saving, saveError, + name, email, phone, linkedin_url, surname, address, city, zip_code, date_of_birth, + experience, salary_min, salary_max, notice_period, remote, relocation, assessment, background_check, + gender, pronouns, ethnicity, veteran_status, disability, + skills, domains, keywords, + syncFromProfile, load, save, createBlank, + } +}) diff --git a/web/src/views/settings/ResumeProfileView.vue b/web/src/views/settings/ResumeProfileView.vue new file mode 100644 index 0000000..d32915d --- /dev/null +++ b/web/src/views/settings/ResumeProfileView.vue @@ -0,0 +1,315 @@ +