diff --git a/dev-api.py b/dev-api.py index 60f98e3..86c40ca 100644 --- a/dev-api.py +++ b/dev-api.py @@ -17,7 +17,7 @@ from pathlib import Path from fastapi import FastAPI, HTTPException, Response from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel -from typing import Optional +from typing import Optional, List import requests # Allow importing peregrine scripts for cover letter generation @@ -901,3 +901,110 @@ def config_user(): return {"name": cfg.get("name", "")} except Exception: return {"name": ""} + + +# ── Settings: My Profile endpoints ─────────────────────────────────────────── + +def _user_yaml_path() -> str: + """Resolve user.yaml path, falling back to legacy location.""" + cfg_path = os.path.join(os.path.dirname(DB_PATH), "config", "user.yaml") + if not os.path.exists(cfg_path): + cfg_path = "/devl/job-seeker/config/user.yaml" + return cfg_path + + +def _read_user_yaml() -> dict: + import yaml + path = _user_yaml_path() + if not os.path.exists(path): + return {} + with open(path) as f: + return yaml.safe_load(f) or {} + + +def _write_user_yaml(data: dict) -> None: + import yaml + path = _user_yaml_path() + with open(path, "w") as f: + yaml.dump(data, f, default_flow_style=False, allow_unicode=True) + + +def _mission_dict_to_list(prefs: object) -> list: + """Convert {industry: note} dict to [{industry, note}] list for the SPA.""" + if isinstance(prefs, list): + return prefs + if isinstance(prefs, dict): + return [{"industry": k, "note": v or ""} for k, v in prefs.items()] + return [] + + +def _mission_list_to_dict(prefs: list) -> dict: + """Convert [{industry, note}] list from the SPA back to {industry: note} dict.""" + result = {} + for item in prefs: + if isinstance(item, dict): + result[item.get("industry", "")] = item.get("note", "") + return result + + +@app.get("/api/settings/profile") +def get_profile(): + try: + cfg = _read_user_yaml() + return { + "name": cfg.get("name", ""), + "email": cfg.get("email", ""), + "phone": cfg.get("phone", ""), + "linkedin_url": cfg.get("linkedin", ""), + "career_summary": cfg.get("career_summary", ""), + "candidate_voice": cfg.get("candidate_voice", ""), + "inference_profile": cfg.get("inference_profile", "cpu"), + "mission_preferences": _mission_dict_to_list(cfg.get("mission_preferences", {})), + "nda_companies": cfg.get("nda_companies", []), + "accessibility_focus": cfg.get("candidate_accessibility_focus", False), + "lgbtq_focus": cfg.get("candidate_lgbtq_focus", False), + } + except Exception as e: + raise HTTPException(500, f"Could not read profile: {e}") + + +class MissionPrefModel(BaseModel): + industry: str + note: str = "" + + +class UserProfilePayload(BaseModel): + name: str = "" + email: str = "" + phone: str = "" + linkedin_url: str = "" + career_summary: str = "" + candidate_voice: str = "" + inference_profile: str = "cpu" + mission_preferences: List[MissionPrefModel] = [] + nda_companies: List[str] = [] + accessibility_focus: bool = False + lgbtq_focus: bool = False + + +@app.put("/api/settings/profile") +def save_profile(payload: UserProfilePayload): + try: + cfg = _read_user_yaml() + cfg["name"] = payload.name + cfg["email"] = payload.email + cfg["phone"] = payload.phone + cfg["linkedin"] = payload.linkedin_url + cfg["career_summary"] = payload.career_summary + cfg["candidate_voice"] = payload.candidate_voice + cfg["inference_profile"] = payload.inference_profile + cfg["mission_preferences"] = _mission_list_to_dict( + [m.model_dump() for m in payload.mission_preferences] + ) + cfg["nda_companies"] = payload.nda_companies + cfg["candidate_accessibility_focus"] = payload.accessibility_focus + cfg["candidate_lgbtq_focus"] = payload.lgbtq_focus + _write_user_yaml(cfg) + return {"ok": True} + except Exception as e: + raise HTTPException(500, f"Could not save profile: {e}") diff --git a/web/src/stores/settings/profile.test.ts b/web/src/stores/settings/profile.test.ts new file mode 100644 index 0000000..fdcb819 --- /dev/null +++ b/web/src/stores/settings/profile.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useProfileStore } from './profile' + +vi.mock('../../composables/useApi', () => ({ useApiFetch: vi.fn() })) +import { useApiFetch } from '../../composables/useApi' +const mockFetch = vi.mocked(useApiFetch) + +describe('useProfileStore', () => { + beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks() }) + + it('load() populates fields from API', async () => { + mockFetch.mockResolvedValue({ + data: { name: 'Meg', email: 'meg@example.com', phone: '555-0100', + linkedin_url: '', career_summary: '', candidate_voice: '', + inference_profile: 'cpu', mission_preferences: [], + nda_companies: [], accessibility_focus: false, lgbtq_focus: false }, + error: null, + }) + const store = useProfileStore() + await store.load() + expect(store.name).toBe('Meg') + expect(store.email).toBe('meg@example.com') + }) + + it('save() calls PUT /api/settings/profile', async () => { + mockFetch.mockResolvedValue({ data: { ok: true }, error: null }) + const store = useProfileStore() + store.name = 'Meg' + await store.save() + expect(mockFetch).toHaveBeenCalledWith('/api/settings/profile', expect.objectContaining({ method: 'PUT' })) + }) + + it('save() error sets error state', async () => { + mockFetch.mockResolvedValue({ data: null, error: { kind: 'network', message: 'fail' } }) + const store = useProfileStore() + await store.save() + expect(store.saveError).toBeTruthy() + }) +}) diff --git a/web/src/stores/settings/profile.ts b/web/src/stores/settings/profile.ts new file mode 100644 index 0000000..933d9aa --- /dev/null +++ b/web/src/stores/settings/profile.ts @@ -0,0 +1,87 @@ +import { ref } from 'vue' +import { defineStore } from 'pinia' +import { useApiFetch } from '../../composables/useApi' + +export interface MissionPref { industry: string; note: string } + +export const useProfileStore = defineStore('settings/profile', () => { + const name = ref('') + const email = ref('') + const phone = ref('') + const linkedin_url = ref('') + const career_summary = ref('') + const candidate_voice = ref('') + const inference_profile = ref('cpu') + const mission_preferences = ref([]) + const nda_companies = ref([]) + const accessibility_focus = ref(false) + const lgbtq_focus = ref(false) + + const loading = ref(false) + const saving = ref(false) + const saveError = ref(null) + + async function load() { + loading.value = true + const { data } = await useApiFetch>('/api/settings/profile') + loading.value = false + if (!data) return + name.value = String(data.name ?? '') + email.value = String(data.email ?? '') + phone.value = String(data.phone ?? '') + linkedin_url.value = String(data.linkedin_url ?? '') + career_summary.value = String(data.career_summary ?? '') + candidate_voice.value = String(data.candidate_voice ?? '') + inference_profile.value = String(data.inference_profile ?? 'cpu') + mission_preferences.value = (data.mission_preferences as MissionPref[]) ?? [] + nda_companies.value = (data.nda_companies as string[]) ?? [] + accessibility_focus.value = Boolean(data.accessibility_focus) + lgbtq_focus.value = Boolean(data.lgbtq_focus) + } + + 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, + career_summary: career_summary.value, + candidate_voice: candidate_voice.value, + inference_profile: inference_profile.value, + mission_preferences: mission_preferences.value, + nda_companies: nda_companies.value, + accessibility_focus: accessibility_focus.value, + lgbtq_focus: lgbtq_focus.value, + } + const { error } = await useApiFetch('/api/settings/profile', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + saving.value = false + if (error) { + saveError.value = 'Save failed — please try again.' + return + } + // Push identity fields to resume YAML — graceful; endpoint may not exist yet (Task 3) + await useApiFetch('/api/settings/resume/sync-identity', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: name.value, + email: email.value, + phone: phone.value, + linkedin_url: linkedin_url.value, + }), + }) + } + + return { + name, email, phone, linkedin_url, career_summary, candidate_voice, inference_profile, + mission_preferences, nda_companies, accessibility_focus, lgbtq_focus, + loading, saving, saveError, + load, save, + } +}) diff --git a/web/src/views/settings/MyProfileView.vue b/web/src/views/settings/MyProfileView.vue new file mode 100644 index 0000000..4efe128 --- /dev/null +++ b/web/src/views/settings/MyProfileView.vue @@ -0,0 +1,565 @@ +