feat(settings): Resume Profile tab — store, view, API endpoints, identity sync

This commit is contained in:
pyr0ball 2026-03-21 02:57:49 -07:00
parent c4a58c7e27
commit 4b0db182b8
4 changed files with 524 additions and 1 deletions

View file

@ -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}

View file

@ -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)
})
})

View file

@ -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<string | null>(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<WorkEntry[]>([])
// 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<string[]>([]); const domains = ref<string[]>([]); const keywords = ref<string[]>([])
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<Record<string, unknown>>('/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,
}
})

View file

@ -0,0 +1,315 @@
<template>
<div class="resume-profile">
<h2>Resume Profile</h2>
<!-- Empty state -->
<div v-if="!store.hasResume && !store.loading" class="empty-state">
<p>No resume found. Choose how to get started:</p>
<div class="empty-actions">
<!-- Upload -->
<div class="empty-card">
<h3>Upload & Parse</h3>
<p>Upload a PDF, DOCX, or ODT and we'll extract your info automatically.</p>
<input type="file" accept=".pdf,.docx,.odt" @change="handleUpload" ref="fileInput" />
<p v-if="uploadError" class="error">{{ uploadError }}</p>
</div>
<!-- Blank -->
<div class="empty-card">
<h3>Fill in Manually</h3>
<p>Start with a blank form and fill in your details.</p>
<button @click="store.createBlank()" :disabled="store.loading">Start from Scratch</button>
</div>
<!-- Wizard -->
<div class="empty-card">
<h3>Run Setup Wizard</h3>
<p>Walk through the onboarding wizard to set up your profile step by step.</p>
<RouterLink to="/setup">Open Setup Wizard </RouterLink>
</div>
</div>
</div>
<!-- Full form (when resume exists) -->
<template v-else-if="store.hasResume">
<!-- Personal Information -->
<section class="form-section">
<h3>Personal Information</h3>
<div class="field-row">
<label>First Name <span class="sync-label"> from My Profile</span></label>
<input v-model="store.name" />
</div>
<div class="field-row">
<label>Last Name</label>
<input v-model="store.surname" />
</div>
<div class="field-row">
<label>Email <span class="sync-label"> from My Profile</span></label>
<input v-model="store.email" type="email" />
</div>
<div class="field-row">
<label>Phone <span class="sync-label"> from My Profile</span></label>
<input v-model="store.phone" type="tel" />
</div>
<div class="field-row">
<label>LinkedIn URL <span class="sync-label"> from My Profile</span></label>
<input v-model="store.linkedin_url" type="url" />
</div>
<div class="field-row">
<label>Address</label>
<input v-model="store.address" />
</div>
<div class="field-row">
<label>City</label>
<input v-model="store.city" />
</div>
<div class="field-row">
<label>ZIP Code</label>
<input v-model="store.zip_code" />
</div>
</section>
<!-- Work Experience -->
<section class="form-section">
<h3>Work Experience</h3>
<div v-for="(entry, idx) in store.experience" :key="idx" class="experience-card">
<div class="field-row">
<label>Job Title</label>
<input v-model="entry.title" />
</div>
<div class="field-row">
<label>Company</label>
<input v-model="entry.company" />
</div>
<div class="field-row">
<label>Period</label>
<input v-model="entry.period" placeholder="e.g. Jan 2022 Present" />
</div>
<div class="field-row">
<label>Location</label>
<input v-model="entry.location" />
</div>
<div class="field-row">
<label>Industry</label>
<input v-model="entry.industry" />
</div>
<div class="field-row">
<label>Responsibilities</label>
<textarea v-model="entry.responsibilities" rows="4" />
</div>
<button class="remove-btn" @click="removeExperience(idx)">Remove</button>
</div>
<button @click="addExperience">+ Add Position</button>
</section>
<!-- Preferences -->
<section class="form-section">
<h3>Preferences & Availability</h3>
<div class="field-row">
<label>Salary Min</label>
<input v-model.number="store.salary_min" type="number" />
</div>
<div class="field-row">
<label>Salary Max</label>
<input v-model.number="store.salary_max" type="number" />
</div>
<div class="field-row">
<label>Notice Period</label>
<input v-model="store.notice_period" />
</div>
<label class="checkbox-row">
<input type="checkbox" v-model="store.remote" /> Open to remote
</label>
<label class="checkbox-row">
<input type="checkbox" v-model="store.relocation" /> Open to relocation
</label>
<label class="checkbox-row">
<input type="checkbox" v-model="store.assessment" /> Willing to complete assessments
</label>
<label class="checkbox-row">
<input type="checkbox" v-model="store.background_check" /> Willing to undergo background check
</label>
</section>
<!-- Self-ID (collapsible) -->
<section class="form-section">
<h3>
Self-Identification
<button class="toggle-btn" @click="showSelfId = !showSelfId">
{{ showSelfId ? '▲ Hide' : '▼ Show' }}
</button>
</h3>
<p class="section-note">Optional. Used only for your personal tracking.</p>
<template v-if="showSelfId">
<div class="field-row">
<label>Gender</label>
<input v-model="store.gender" />
</div>
<div class="field-row">
<label>Pronouns</label>
<input v-model="store.pronouns" />
</div>
<div class="field-row">
<label>Ethnicity</label>
<input v-model="store.ethnicity" />
</div>
<div class="field-row">
<label>Veteran Status</label>
<input v-model="store.veteran_status" />
</div>
<div class="field-row">
<label>Disability</label>
<input v-model="store.disability" />
</div>
</template>
</section>
<!-- Skills & Keywords -->
<section class="form-section">
<h3>Skills & Keywords</h3>
<div class="tag-section">
<label>Skills</label>
<div class="tags">
<span v-for="skill in store.skills" :key="skill" class="tag">
{{ skill }} <button @click="removeTag('skills', skill)">×</button>
</span>
</div>
<input v-model="skillInput" @keydown.enter.prevent="addTag('skills', skillInput); skillInput = ''" placeholder="Add skill, press Enter" />
</div>
<div class="tag-section">
<label>Domains</label>
<div class="tags">
<span v-for="domain in store.domains" :key="domain" class="tag">
{{ domain }} <button @click="removeTag('domains', domain)">×</button>
</span>
</div>
<input v-model="domainInput" @keydown.enter.prevent="addTag('domains', domainInput); domainInput = ''" placeholder="Add domain, press Enter" />
</div>
<div class="tag-section">
<label>Keywords</label>
<div class="tags">
<span v-for="kw in store.keywords" :key="kw" class="tag">
{{ kw }} <button @click="removeTag('keywords', kw)">×</button>
</span>
</div>
<input v-model="kwInput" @keydown.enter.prevent="addTag('keywords', kwInput); kwInput = ''" placeholder="Add keyword, press Enter" />
</div>
</section>
<!-- Save -->
<div class="form-actions">
<button @click="store.save()" :disabled="store.saving" class="btn-primary">
{{ store.saving ? 'Saving…' : 'Save Resume' }}
</button>
<p v-if="store.saveError" class="error">{{ store.saveError }}</p>
</div>
</template>
<div v-else class="loading">Loading</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useResumeStore } from '../../stores/settings/resume'
import { useProfileStore } from '../../stores/settings/profile'
import { useApiFetch } from '../../composables/useApi'
const store = useResumeStore()
const profileStore = useProfileStore()
const showSelfId = ref(false)
const skillInput = ref('')
const domainInput = ref('')
const kwInput = ref('')
const uploadError = ref<string | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
onMounted(async () => {
await store.load()
store.syncFromProfile({
name: profileStore.name,
email: profileStore.email,
phone: profileStore.phone,
linkedin_url: profileStore.linkedin_url,
})
})
function addExperience() {
store.experience.push({ title: '', company: '', period: '', location: '', industry: '', responsibilities: '', skills: [] })
}
function removeExperience(idx: number) {
store.experience.splice(idx, 1)
}
function addTag(field: 'skills' | 'domains' | 'keywords', value: string) {
const trimmed = value.trim()
if (!trimmed || store[field].includes(trimmed)) return
store[field].push(trimmed)
}
function removeTag(field: 'skills' | 'domains' | 'keywords', value: string) {
const arr = store[field] as string[]
const idx = arr.indexOf(value)
if (idx !== -1) arr.splice(idx, 1)
}
async function handleUpload(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0]
if (!file) return
uploadError.value = null
const formData = new FormData()
formData.append('file', file)
const { data, error } = await useApiFetch<{ ok: boolean; data?: Record<string, unknown>; error?: string }>(
'/api/settings/resume/upload',
{ method: 'POST', body: formData }
)
if (error || !data?.ok) {
uploadError.value = data?.error ?? error ?? 'Upload failed'
return
}
if (data.data) {
await store.load()
}
}
</script>
<style scoped>
.resume-profile { max-width: 720px; margin: 0 auto; padding: var(--space-4, 24px); }
h2 { font-size: 1.4rem; font-weight: 600; margin-bottom: var(--space-6, 32px); color: var(--color-text-primary, #e2e8f0); }
h3 { font-size: 1rem; font-weight: 600; margin-bottom: var(--space-3, 16px); color: var(--color-text-primary, #e2e8f0); }
.form-section { margin-bottom: var(--space-8, 48px); padding-bottom: var(--space-6, 32px); border-bottom: 1px solid var(--color-border, rgba(255,255,255,0.08)); }
.field-row { display: flex; flex-direction: column; gap: 4px; margin-bottom: var(--space-3, 16px); }
.field-row label { font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); }
.field-row input, .field-row textarea, .field-row select {
background: var(--color-surface-2, rgba(255,255,255,0.05));
border: 1px solid var(--color-border, rgba(255,255,255,0.12));
border-radius: 6px;
color: var(--color-text-primary, #e2e8f0);
padding: 7px 10px;
font-size: 0.88rem;
width: 100%;
box-sizing: border-box;
}
.sync-label { font-size: 0.72rem; color: var(--color-accent, #7c3aed); margin-left: 6px; }
.checkbox-row { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; font-size: 0.88rem; color: var(--color-text-primary, #e2e8f0); cursor: pointer; }
.experience-card { border: 1px solid var(--color-border, rgba(255,255,255,0.08)); border-radius: 8px; padding: var(--space-4, 24px); margin-bottom: var(--space-4, 24px); }
.remove-btn { margin-top: 8px; padding: 4px 12px; border-radius: 4px; background: rgba(239,68,68,0.15); color: #ef4444; border: 1px solid rgba(239,68,68,0.3); cursor: pointer; font-size: 0.82rem; }
.empty-state { text-align: center; padding: var(--space-8, 48px) 0; }
.empty-actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--space-4, 24px); margin-top: var(--space-6, 32px); }
.empty-card { background: var(--color-surface-2, rgba(255,255,255,0.04)); border: 1px solid var(--color-border, rgba(255,255,255,0.08)); border-radius: 10px; padding: var(--space-4, 24px); text-align: left; }
.empty-card h3 { margin-bottom: 8px; }
.empty-card p { font-size: 0.85rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: 16px; }
.empty-card button, .empty-card a { padding: 8px 16px; border-radius: 6px; font-size: 0.85rem; cursor: pointer; text-decoration: none; display: inline-block; background: var(--color-accent, #7c3aed); color: #fff; border: none; }
.tag-section { margin-bottom: var(--space-4, 24px); }
.tag-section label { font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); display: block; margin-bottom: 6px; }
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.tag { padding: 3px 10px; background: rgba(124,58,237,0.15); border: 1px solid rgba(124,58,237,0.3); border-radius: 12px; font-size: 0.78rem; color: var(--color-accent, #a78bfa); display: flex; align-items: center; gap: 5px; }
.tag button { background: none; border: none; color: inherit; cursor: pointer; padding: 0; line-height: 1; }
.tag-section input { background: var(--color-surface-2, rgba(255,255,255,0.05)); border: 1px solid var(--color-border, rgba(255,255,255,0.12)); border-radius: 6px; color: var(--color-text-primary, #e2e8f0); padding: 6px 10px; font-size: 0.85rem; width: 100%; box-sizing: border-box; }
.form-actions { margin-top: var(--space-6, 32px); display: flex; align-items: center; gap: var(--space-4, 24px); }
.btn-primary { padding: 9px 24px; background: var(--color-accent, #7c3aed); color: #fff; border: none; border-radius: 7px; font-size: 0.9rem; cursor: pointer; font-weight: 600; }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.error { color: #ef4444; font-size: 0.82rem; }
.section-note { font-size: 0.8rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: 16px; }
.toggle-btn { margin-left: 10px; padding: 2px 10px; background: transparent; border: 1px solid var(--color-border, rgba(255,255,255,0.15)); border-radius: 4px; color: var(--color-text-secondary, #94a3b8); cursor: pointer; font-size: 0.78rem; }
.loading { text-align: center; padding: var(--space-8, 48px); color: var(--color-text-secondary, #94a3b8); }
</style>