diff --git a/dev-api.py b/dev-api.py index 052adb6..331d694 100644 --- a/dev-api.py +++ b/dev-api.py @@ -1093,3 +1093,49 @@ async def upload_resume(file: UploadFile): return {"ok": True, "data": result} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + + +# ── Settings: Search Preferences endpoints ──────────────────────────────────── + +class SearchPrefsPayload(BaseModel): + remote_preference: str = "both" + job_titles: List[str] = [] + locations: List[str] = [] + exclude_keywords: List[str] = [] + job_boards: List[dict] = [] + custom_board_urls: List[str] = [] + blocklist_companies: List[str] = [] + blocklist_industries: List[str] = [] + blocklist_locations: List[str] = [] + +SEARCH_PREFS_PATH = Path("config/search_profiles.yaml") + +@app.get("/api/settings/search") +def get_search_prefs(): + try: + if not SEARCH_PREFS_PATH.exists(): + return {} + with open(SEARCH_PREFS_PATH) as f: + data = yaml.safe_load(f) or {} + return data.get("default", {}) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@app.put("/api/settings/search") +def save_search_prefs(payload: SearchPrefsPayload): + try: + data = {} + if SEARCH_PREFS_PATH.exists(): + with open(SEARCH_PREFS_PATH) as f: + data = yaml.safe_load(f) or {} + data["default"] = payload.model_dump() + with open(SEARCH_PREFS_PATH, "w") as f: + yaml.dump(data, 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/search/suggest") +def suggest_search(body: dict): + # Stub — LLM suggest for paid tier + return {"suggestions": []} diff --git a/scripts/discover.py b/scripts/discover.py index bd7530a..77f8f9d 100644 --- a/scripts/discover.py +++ b/scripts/discover.py @@ -196,13 +196,20 @@ def run_discovery(db_path: Path = DEFAULT_DB, notion_push: bool = False) -> None exclude_kw = [kw.lower() for kw in profile.get("exclude_keywords", [])] results_per_board = profile.get("results_per_board", 25) + # Map remote_preference → JobSpy is_remote param: + # 'remote' → True (remote-only listings) + # 'onsite' → False (on-site-only listings) + # 'both' → None (no filter — JobSpy default) + _rp = profile.get("remote_preference", "both") + _is_remote: bool | None = True if _rp == "remote" else (False if _rp == "onsite" else None) + for location in profile["locations"]: # ── JobSpy boards ────────────────────────────────────────────────── if boards: print(f" [jobspy] {location} — boards: {', '.join(boards)}") try: - jobs: pd.DataFrame = scrape_jobs( + jobspy_kwargs: dict = dict( site_name=boards, search_term=" OR ".join(f'"{t}"' for t in profile["titles"]), location=location, @@ -210,6 +217,9 @@ def run_discovery(db_path: Path = DEFAULT_DB, notion_push: bool = False) -> None hours_old=profile.get("hours_old", 72), linkedin_fetch_description=True, ) + if _is_remote is not None: + jobspy_kwargs["is_remote"] = _is_remote + jobs: pd.DataFrame = scrape_jobs(**jobspy_kwargs) print(f" [jobspy] {len(jobs)} raw results") except Exception as exc: print(f" [jobspy] ERROR: {exc}") diff --git a/web/src/stores/settings/search.test.ts b/web/src/stores/settings/search.test.ts new file mode 100644 index 0000000..f525cbb --- /dev/null +++ b/web/src/stores/settings/search.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useSearchStore } from './search' + +vi.mock('../../composables/useApi', () => ({ useApiFetch: vi.fn() })) +import { useApiFetch } from '../../composables/useApi' +const mockFetch = vi.mocked(useApiFetch) + +describe('useSearchStore', () => { + beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks() }) + + it('defaults remote_preference to both', () => { + expect(useSearchStore().remote_preference).toBe('both') + }) + + it('load() sets fields from API', async () => { + mockFetch.mockResolvedValue({ data: { + remote_preference: 'remote', job_titles: ['Engineer'], locations: ['NYC'], + exclude_keywords: [], job_boards: [], custom_board_urls: [], + blocklist_companies: [], blocklist_industries: [], blocklist_locations: [], + }, error: null }) + const store = useSearchStore() + await store.load() + expect(store.remote_preference).toBe('remote') + expect(store.job_titles).toContain('Engineer') + }) + + it('suggest() adds to titleSuggestions without persisting', async () => { + mockFetch.mockResolvedValue({ data: { suggestions: ['Staff Engineer'] }, error: null }) + const store = useSearchStore() + await store.suggestTitles() + expect(store.titleSuggestions).toContain('Staff Engineer') + expect(store.job_titles).not.toContain('Staff Engineer') + }) + + it('save() calls PUT endpoint', async () => { + mockFetch.mockResolvedValue({ data: { ok: true }, error: null }) + const store = useSearchStore() + await store.save() + expect(mockFetch).toHaveBeenCalledWith('/api/settings/search', expect.objectContaining({ method: 'PUT' })) + }) +}) diff --git a/web/src/stores/settings/search.ts b/web/src/stores/settings/search.ts new file mode 100644 index 0000000..09b2d47 --- /dev/null +++ b/web/src/stores/settings/search.ts @@ -0,0 +1,120 @@ +import { ref } from 'vue' +import { defineStore } from 'pinia' +import { useApiFetch } from '../../composables/useApi' + +export type RemotePreference = 'remote' | 'onsite' | 'both' +export interface JobBoard { name: string; enabled: boolean } + +export const useSearchStore = defineStore('settings/search', () => { + const remote_preference = ref('both') + const job_titles = ref([]) + const locations = ref([]) + const exclude_keywords = ref([]) + const job_boards = ref([]) + const custom_board_urls = ref([]) + const blocklist_companies = ref([]) + const blocklist_industries = ref([]) + const blocklist_locations = ref([]) + + const titleSuggestions = ref([]) + const locationSuggestions = ref([]) + + const loading = ref(false) + const saving = ref(false) + const saveError = ref(null) + const loadError = ref(null) + + async function load() { + loading.value = true + loadError.value = null + const { data, error } = await useApiFetch>('/api/settings/search') + loading.value = false + if (error) { loadError.value = 'Failed to load search preferences'; return } + if (!data) return + remote_preference.value = (data.remote_preference as RemotePreference) ?? 'both' + job_titles.value = (data.job_titles as string[]) ?? [] + locations.value = (data.locations as string[]) ?? [] + exclude_keywords.value = (data.exclude_keywords as string[]) ?? [] + job_boards.value = (data.job_boards as JobBoard[]) ?? [] + custom_board_urls.value = (data.custom_board_urls as string[]) ?? [] + blocklist_companies.value = (data.blocklist_companies as string[]) ?? [] + blocklist_industries.value = (data.blocklist_industries as string[]) ?? [] + blocklist_locations.value = (data.blocklist_locations as string[]) ?? [] + } + + async function save() { + saving.value = true + saveError.value = null + const body = { + remote_preference: remote_preference.value, + job_titles: job_titles.value, + locations: locations.value, + exclude_keywords: exclude_keywords.value, + job_boards: job_boards.value, + custom_board_urls: custom_board_urls.value, + blocklist_companies: blocklist_companies.value, + blocklist_industries: blocklist_industries.value, + blocklist_locations: blocklist_locations.value, + } + const { error } = await useApiFetch('/api/settings/search', { + 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 suggestTitles() { + const { data } = await useApiFetch<{ suggestions: string[] }>('/api/settings/search/suggest', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'titles', current: job_titles.value }), + }) + if (data?.suggestions) { + titleSuggestions.value = data.suggestions.filter(s => !job_titles.value.includes(s)) + } + } + + async function suggestLocations() { + const { data } = await useApiFetch<{ suggestions: string[] }>('/api/settings/search/suggest', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'locations', current: locations.value }), + }) + if (data?.suggestions) { + locationSuggestions.value = data.suggestions.filter(s => !locations.value.includes(s)) + } + } + + function addTag(field: 'job_titles' | 'locations' | 'exclude_keywords' | 'custom_board_urls' | 'blocklist_companies' | 'blocklist_industries' | 'blocklist_locations', value: string) { + const arr = { job_titles, locations, exclude_keywords, custom_board_urls, blocklist_companies, blocklist_industries, blocklist_locations }[field] + const trimmed = value.trim() + if (!trimmed || arr.value.includes(trimmed)) return + arr.value.push(trimmed) + } + + function removeTag(field: 'job_titles' | 'locations' | 'exclude_keywords' | 'custom_board_urls' | 'blocklist_companies' | 'blocklist_industries' | 'blocklist_locations', value: string) { + const arr = { job_titles, locations, exclude_keywords, custom_board_urls, blocklist_companies, blocklist_industries, blocklist_locations }[field] + const idx = arr.value.indexOf(value) + if (idx !== -1) arr.value.splice(idx, 1) + } + + function acceptSuggestion(type: 'title' | 'location', value: string) { + if (type === 'title') { + if (!job_titles.value.includes(value)) job_titles.value.push(value) + titleSuggestions.value = titleSuggestions.value.filter(s => s !== value) + } else { + if (!locations.value.includes(value)) locations.value.push(value) + locationSuggestions.value = locationSuggestions.value.filter(s => s !== value) + } + } + + return { + remote_preference, job_titles, locations, exclude_keywords, job_boards, + custom_board_urls, blocklist_companies, blocklist_industries, blocklist_locations, + titleSuggestions, locationSuggestions, + loading, saving, saveError, loadError, + load, save, suggestTitles, suggestLocations, addTag, removeTag, acceptSuggestion, + } +}) diff --git a/web/src/views/settings/SearchPrefsView.vue b/web/src/views/settings/SearchPrefsView.vue new file mode 100644 index 0000000..03ee58e --- /dev/null +++ b/web/src/views/settings/SearchPrefsView.vue @@ -0,0 +1,204 @@ + + + + +