From 43f54a708c9386ab1afafb2cdbb4f7f1a7ec7154 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Mon, 15 Dec 2025 18:25:40 +0000 Subject: [PATCH 1/4] ai chooses projects --- orchestrator/src/client/api/client.ts | 2 + .../src/client/pages/SettingsPage.tsx | 168 +++++++++++++++++- orchestrator/src/server/api/routes.ts | 37 ++++ .../src/server/pipeline/orchestrator.ts | 1 + .../src/server/repositories/settings.ts | 1 + orchestrator/src/server/services/pdf.ts | 39 ++++ .../src/server/services/projectSelection.ts | 168 ++++++++++++++++++ .../src/server/services/resumeProjects.ts | 164 +++++++++++++++++ orchestrator/src/shared/types.ts | 18 ++ 9 files changed, 593 insertions(+), 5 deletions(-) create mode 100644 orchestrator/src/server/services/projectSelection.ts create mode 100644 orchestrator/src/server/services/resumeProjects.ts diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index b01f939..66d0a1c 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -10,6 +10,7 @@ import type { JobSource, PipelineRun, AppSettings, + ResumeProjectsSettings, } from '../../shared/types'; const API_BASE = '/api'; @@ -102,6 +103,7 @@ export async function updateSettings(update: { model?: string | null pipelineWebhookUrl?: string | null jobCompleteWebhookUrl?: string | null + resumeProjects?: ResumeProjectsSettings | null }): Promise { return fetchApi('/settings', { method: 'PATCH', diff --git a/orchestrator/src/client/pages/SettingsPage.tsx b/orchestrator/src/client/pages/SettingsPage.tsx index 92b34bc..41bc64e 100644 --- a/orchestrator/src/client/pages/SettingsPage.tsx +++ b/orchestrator/src/client/pages/SettingsPage.tsx @@ -7,16 +7,41 @@ import { toast } from "sonner" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Checkbox } from "@/components/ui/checkbox" import { Input } from "@/components/ui/input" import { Separator } from "@/components/ui/separator" -import type { AppSettings } from "../../shared/types" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import type { AppSettings, ResumeProjectsSettings } from "../../shared/types" import * as api from "../api" +function arraysEqual(a: string[], b: string[]) { + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false + } + return true +} + +function resumeProjectsEqual(a: ResumeProjectsSettings, b: ResumeProjectsSettings) { + return ( + a.maxProjects === b.maxProjects && + arraysEqual(a.lockedProjectIds, b.lockedProjectIds) && + arraysEqual(a.aiSelectableProjectIds, b.aiSelectableProjectIds) + ) +} + +function clampInt(value: number, min: number, max: number) { + const int = Math.floor(value) + if (Number.isNaN(int)) return min + return Math.min(max, Math.max(min, int)) +} + export const SettingsPage: React.FC = () => { const [settings, setSettings] = useState(null) const [modelDraft, setModelDraft] = useState("") const [pipelineWebhookUrlDraft, setPipelineWebhookUrlDraft] = useState("") const [jobCompleteWebhookUrlDraft, setJobCompleteWebhookUrlDraft] = useState("") + const [resumeProjectsDraft, setResumeProjectsDraft] = useState(null) const [isSaving, setIsSaving] = useState(false) const [isLoading, setIsLoading] = useState(true) @@ -31,6 +56,7 @@ export const SettingsPage: React.FC = () => { setModelDraft(data.overrideModel ?? "") setPipelineWebhookUrlDraft(data.overridePipelineWebhookUrl ?? "") setJobCompleteWebhookUrlDraft(data.overrideJobCompleteWebhookUrl ?? "") + setResumeProjectsDraft(data.resumeProjects) }) .catch((error) => { const message = error instanceof Error ? error.message : "Failed to load settings" @@ -55,9 +81,12 @@ export const SettingsPage: React.FC = () => { const effectiveJobCompleteWebhookUrl = settings?.jobCompleteWebhookUrl ?? "" const defaultJobCompleteWebhookUrl = settings?.defaultJobCompleteWebhookUrl ?? "" const overrideJobCompleteWebhookUrl = settings?.overrideJobCompleteWebhookUrl + const profileProjects = settings?.profileProjects ?? [] + const maxProjectsTotal = profileProjects.length + const lockedCount = resumeProjectsDraft?.lockedProjectIds.length ?? 0 const canSave = useMemo(() => { - if (!settings) return false + if (!settings || !resumeProjectsDraft) return false const next = modelDraft.trim() const current = (overrideModel ?? "").trim() const nextWebhook = pipelineWebhookUrlDraft.trim() @@ -67,7 +96,8 @@ export const SettingsPage: React.FC = () => { return ( next !== current || nextWebhook !== currentWebhook || - nextJobCompleteWebhook !== currentJobCompleteWebhook + nextJobCompleteWebhook !== currentJobCompleteWebhook || + !resumeProjectsEqual(resumeProjectsDraft, settings.resumeProjects) ) }, [ settings, @@ -77,24 +107,30 @@ export const SettingsPage: React.FC = () => { overrideModel, overridePipelineWebhookUrl, overrideJobCompleteWebhookUrl, + resumeProjectsDraft, ]) const handleSave = async () => { - if (!settings) return + if (!settings || !resumeProjectsDraft) return try { setIsSaving(true) const trimmed = modelDraft.trim() const webhookTrimmed = pipelineWebhookUrlDraft.trim() const jobCompleteTrimmed = jobCompleteWebhookUrlDraft.trim() + const resumeProjectsOverride = resumeProjectsEqual(resumeProjectsDraft, settings.defaultResumeProjects) + ? null + : resumeProjectsDraft const updated = await api.updateSettings({ model: trimmed.length > 0 ? trimmed : null, pipelineWebhookUrl: webhookTrimmed.length > 0 ? webhookTrimmed : null, jobCompleteWebhookUrl: jobCompleteTrimmed.length > 0 ? jobCompleteTrimmed : null, + resumeProjects: resumeProjectsOverride, }) setSettings(updated) setModelDraft(updated.overrideModel ?? "") setPipelineWebhookUrlDraft(updated.overridePipelineWebhookUrl ?? "") setJobCompleteWebhookUrlDraft(updated.overrideJobCompleteWebhookUrl ?? "") + setResumeProjectsDraft(updated.resumeProjects) toast.success("Settings saved") } catch (error) { const message = error instanceof Error ? error.message : "Failed to save settings" @@ -107,11 +143,17 @@ export const SettingsPage: React.FC = () => { const handleReset = async () => { try { setIsSaving(true) - const updated = await api.updateSettings({ model: null, pipelineWebhookUrl: null, jobCompleteWebhookUrl: null }) + const updated = await api.updateSettings({ + model: null, + pipelineWebhookUrl: null, + jobCompleteWebhookUrl: null, + resumeProjects: null, + }) setSettings(updated) setModelDraft("") setPipelineWebhookUrlDraft("") setJobCompleteWebhookUrlDraft("") + setResumeProjectsDraft(updated.resumeProjects) toast.success("Reset to default") } catch (error) { const message = error instanceof Error ? error.message : "Failed to reset settings" @@ -230,6 +272,122 @@ export const SettingsPage: React.FC = () => { + + + Resume Projects + + + +
+
Max projects included
+ { + if (!resumeProjectsDraft) return + const next = Number(event.target.value) + const clamped = clampInt(next, lockedCount, maxProjectsTotal) + setResumeProjectsDraft({ ...resumeProjectsDraft, maxProjects: clamped }) + }} + disabled={isLoading || isSaving || !resumeProjectsDraft} + /> +
+ Locked projects always count towards this cap. Locked: {lockedCount} · AI pool:{" "} + {resumeProjectsDraft?.aiSelectableProjectIds.length ?? 0} · Total projects: {maxProjectsTotal} +
+
+ + + + + + + Project + Base visible + Locked + AI selectable + + + + {profileProjects.map((project) => { + const locked = Boolean(resumeProjectsDraft?.lockedProjectIds.includes(project.id)) + const aiSelectable = Boolean(resumeProjectsDraft?.aiSelectableProjectIds.includes(project.id)) + const excluded = !locked && !aiSelectable + + return ( + + +
+
{project.name || project.id}
+
+ {[project.description, project.date].filter(Boolean).join(" · ")} + {excluded ? " · Excluded" : ""} +
+
+
+ {project.isVisibleInBase ? "Yes" : "No"} + + { + if (!resumeProjectsDraft) return + const isChecked = checked === true + const lockedIds = resumeProjectsDraft.lockedProjectIds.slice() + const selectableIds = resumeProjectsDraft.aiSelectableProjectIds.slice() + + if (isChecked) { + if (!lockedIds.includes(project.id)) lockedIds.push(project.id) + const nextSelectable = selectableIds.filter((id) => id !== project.id) + const minCap = lockedIds.length + setResumeProjectsDraft({ + ...resumeProjectsDraft, + lockedProjectIds: lockedIds, + aiSelectableProjectIds: nextSelectable, + maxProjects: Math.max(resumeProjectsDraft.maxProjects, minCap), + }) + return + } + + const nextLocked = lockedIds.filter((id) => id !== project.id) + if (!selectableIds.includes(project.id)) selectableIds.push(project.id) + setResumeProjectsDraft({ + ...resumeProjectsDraft, + lockedProjectIds: nextLocked, + aiSelectableProjectIds: selectableIds, + maxProjects: clampInt(resumeProjectsDraft.maxProjects, nextLocked.length, maxProjectsTotal), + }) + }} + /> + + + { + if (!resumeProjectsDraft) return + const isChecked = checked === true + const selectableIds = resumeProjectsDraft.aiSelectableProjectIds.slice() + const nextSelectable = isChecked + ? selectableIds.includes(project.id) + ? selectableIds + : [...selectableIds, project.id] + : selectableIds.filter((id) => id !== project.id) + setResumeProjectsDraft({ ...resumeProjectsDraft, aiSelectableProjectIds: nextSelectable }) + }} + /> + +
+ ) + })} +
+
+
+
+
+ {onHighlightChange && ( + + )} + {hasPdf && ( +
+ + + + + + Job description +
Press Esc or click outside to exit highlight.
+
+ +
+ + {highlightedJobDescription} + +
+
+
+ + + + )} + + setActiveTab(value as FilterTab)} + className="space-y-4" + >
@@ -500,6 +602,8 @@ export const JobList: React.FC = ({ onReject={onReject} onProcess={onProcess} processingJobId={processingJobId} + highlightedJobId={highlightedJobId} + onHighlightChange={setHighlightedJobId} /> @@ -514,6 +618,8 @@ export const JobList: React.FC = ({ onReject={onReject} onProcess={onProcess} isProcessing={processingJobId === job.id} + highlightedJobId={highlightedJobId} + onHighlightChange={setHighlightedJobId} /> ))}
@@ -523,6 +629,7 @@ export const JobList: React.FC = ({ ); })} - + + ); }; diff --git a/orchestrator/src/client/components/JobTable.tsx b/orchestrator/src/client/components/JobTable.tsx index 0817444..b9bdd52 100644 --- a/orchestrator/src/client/components/JobTable.tsx +++ b/orchestrator/src/client/components/JobTable.tsx @@ -59,6 +59,8 @@ export interface JobTableProps { onReject: (id: string) => void | Promise; onProcess: (id: string) => void | Promise; processingJobId: string | null; + highlightedJobId?: string | null; + onHighlightChange?: (jobId: string | null) => void; } const sourceLabel: Record = { @@ -135,6 +137,8 @@ export const JobTable: React.FC = ({ onReject, onProcess, processingJobId, + highlightedJobId, + onHighlightChange, }) => { const selectedCount = jobs.reduce((count, job) => count + (selectedJobIds.has(job.id) ? 1 : 0), 0); const allSelected = jobs.length > 0 && selectedCount === jobs.length; @@ -215,6 +219,7 @@ export const JobTable: React.FC = ({ const canReject = ["discovered", "ready"].includes(job.status); const isProcessing = processingJobId === job.id; const isSelected = selectedJobIds.has(job.id); + const isHighlighted = highlightedJobId === job.id; return ( @@ -290,6 +295,14 @@ export const JobTable: React.FC = ({ Copy info + {onHighlightChange && ( + onHighlightChange(isHighlighted ? null : job.id)} + > + {isHighlighted ? "Unhighlight" : "Highlight"} + + )} + {hasPdf && ( <> From c4fa1794ea5e5d5104df53fe7088871870fd803c Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Mon, 15 Dec 2025 19:12:43 +0000 Subject: [PATCH 4/4] multiple search term support in jobspy --- docker-compose.yml | 3 +- orchestrator/src/server/services/jobspy.ts | 268 +++++++++++++-------- 2 files changed, 176 insertions(+), 95 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 30c7abd..a4b3c2b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,7 +35,8 @@ services: # JobSpy (Indeed/LinkedIn scraping) - optional - JOBSPY_SITES=${JOBSPY_SITES:-indeed,linkedin} - - JOBSPY_SEARCH_TERM=${JOBSPY_SEARCH_TERM:-web developer} + # Preferred: pipe-separated list, e.g. "web developer|frontend developer|react developer" + - JOBSPY_SEARCH_TERMS=${JOBSPY_SEARCH_TERMS:-web developer|frontend developer|react developer} - JOBSPY_LOCATION=${JOBSPY_LOCATION:-UK} - JOBSPY_RESULTS_WANTED=${JOBSPY_RESULTS_WANTED:-200} - JOBSPY_HOURS_OLD=${JOBSPY_HOURS_OLD:-72} diff --git a/orchestrator/src/server/services/jobspy.ts b/orchestrator/src/server/services/jobspy.ts index 0846bad..687959b 100644 --- a/orchestrator/src/server/services/jobspy.ts +++ b/orchestrator/src/server/services/jobspy.ts @@ -5,7 +5,7 @@ */ import { spawn } from 'child_process'; -import { readFile, mkdir } from 'fs/promises'; +import { readFile, mkdir, unlink } from 'fs/promises'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import type { CreateJobInput, JobSource } from '../../shared/types.js'; @@ -110,7 +110,7 @@ function formatSalary(params: { export interface RunJobSpyOptions { sites?: Array; - searchTerm?: string; + searchTerms?: string[]; location?: string; resultsWanted?: number; hoursOld?: number; @@ -129,108 +129,71 @@ export async function runJobSpy(options: RunJobSpyOptions = {}): Promise s === 'indeed' || s === 'linkedin') .join(','); + const searchTerms = resolveSearchTerms(options); + if (searchTerms.length === 0) { + return { success: true, jobs: [] }; + } + try { - await new Promise((resolve, reject) => { - const pythonPath = getPythonPath(); - const child = spawn(pythonPath, [JOBSPY_SCRIPT], { - cwd: JOBSPY_DIR, - shell: false, - stdio: 'inherit', - env: { - ...process.env, - JOBSPY_SITES: sites || 'indeed,linkedin', - JOBSPY_SEARCH_TERM: options.searchTerm ?? process.env.JOBSPY_SEARCH_TERM ?? 'web developer', - JOBSPY_LOCATION: options.location ?? process.env.JOBSPY_LOCATION ?? 'UK', - JOBSPY_RESULTS_WANTED: String(options.resultsWanted ?? process.env.JOBSPY_RESULTS_WANTED ?? 200), - JOBSPY_HOURS_OLD: String(options.hoursOld ?? process.env.JOBSPY_HOURS_OLD ?? 72), - JOBSPY_COUNTRY_INDEED: options.countryIndeed ?? process.env.JOBSPY_COUNTRY_INDEED ?? 'UK', - JOBSPY_LINKEDIN_FETCH_DESCRIPTION: String( - options.linkedinFetchDescription ?? process.env.JOBSPY_LINKEDIN_FETCH_DESCRIPTION ?? '1' - ), - JOBSPY_OUTPUT_CSV: outputCsv, - JOBSPY_OUTPUT_JSON: outputJson, - }, - }); - - child.on('close', (code) => { - if (code === 0) resolve(); - else reject(new Error(`JobSpy exited with code ${code}`)); - }); - child.on('error', reject); - }); - - const raw = await readFile(outputJson, 'utf-8'); - const parsed = JSON.parse(raw) as Array>; - const jobs: CreateJobInput[] = []; + const seenJobUrls = new Set(); - for (const row of parsed) { - const source = toJobSource(row.site); - if (!source) continue; + for (let i = 0; i < searchTerms.length; i++) { + const searchTerm = searchTerms[i]; + const suffix = `${i + 1}_${slugForFilename(searchTerm)}`; + const outputCsv = join(outputDir, `jobspy_jobs_${suffix}.csv`); + const outputJson = join(outputDir, `jobspy_jobs_${suffix}.json`); - const jobUrl = toStringOrNull(row.job_url); - if (!jobUrl) continue; + await new Promise((resolve, reject) => { + const pythonPath = getPythonPath(); + const child = spawn(pythonPath, [JOBSPY_SCRIPT], { + cwd: JOBSPY_DIR, + shell: false, + stdio: 'inherit', + env: { + ...process.env, + JOBSPY_SITES: sites || 'indeed,linkedin', + JOBSPY_SEARCH_TERM: searchTerm, + JOBSPY_LOCATION: options.location ?? process.env.JOBSPY_LOCATION ?? 'UK', + JOBSPY_RESULTS_WANTED: String(options.resultsWanted ?? process.env.JOBSPY_RESULTS_WANTED ?? 200), + JOBSPY_HOURS_OLD: String(options.hoursOld ?? process.env.JOBSPY_HOURS_OLD ?? 72), + JOBSPY_COUNTRY_INDEED: options.countryIndeed ?? process.env.JOBSPY_COUNTRY_INDEED ?? 'UK', + JOBSPY_LINKEDIN_FETCH_DESCRIPTION: String( + options.linkedinFetchDescription ?? process.env.JOBSPY_LINKEDIN_FETCH_DESCRIPTION ?? '1' + ), + JOBSPY_OUTPUT_CSV: outputCsv, + JOBSPY_OUTPUT_JSON: outputJson, + }, + }); - const title = toStringOrNull(row.title) ?? 'Unknown Title'; - const employer = toStringOrNull(row.company) ?? 'Unknown Employer'; - - const jobUrlDirect = toStringOrNull(row.job_url_direct); - const applicationLink = jobUrlDirect ?? jobUrl; - - const minAmount = toNumberOrNull(row.min_amount); - const maxAmount = toNumberOrNull(row.max_amount); - const currency = toStringOrNull(row.currency); - const interval = toStringOrNull(row.interval); - - const salary = formatSalary({ minAmount, maxAmount, currency, interval }); - - jobs.push({ - source, - sourceJobId: toStringOrNull(row.id) ?? undefined, - jobUrlDirect: jobUrlDirect ?? undefined, - datePosted: toStringOrNull(row.date_posted) ?? undefined, - - title, - employer, - employerUrl: toStringOrNull(row.company_url) ?? undefined, - jobUrl, - applicationLink, - location: toStringOrNull(row.location) ?? undefined, - jobDescription: toStringOrNull(row.description) ?? undefined, - salary: salary ?? undefined, - - jobType: toStringOrNull(row.job_type) ?? undefined, - salarySource: toStringOrNull(row.salary_source) ?? undefined, - salaryInterval: interval ?? undefined, - salaryMinAmount: minAmount ?? undefined, - salaryMaxAmount: maxAmount ?? undefined, - salaryCurrency: currency ?? undefined, - isRemote: toBooleanOrNull(row.is_remote) ?? undefined, - jobLevel: toStringOrNull(row.job_level) ?? undefined, - jobFunction: toStringOrNull(row.job_function) ?? undefined, - listingType: toStringOrNull(row.listing_type) ?? undefined, - emails: toJsonStringOrNull(row.emails) ?? undefined, - companyIndustry: toStringOrNull(row.company_industry) ?? undefined, - companyLogo: toStringOrNull(row.company_logo) ?? undefined, - companyUrlDirect: toStringOrNull(row.company_url_direct) ?? undefined, - companyAddresses: toJsonStringOrNull(row.company_addresses) ?? undefined, - companyNumEmployees: toStringOrNull(row.company_num_employees) ?? undefined, - companyRevenue: toStringOrNull(row.company_revenue) ?? undefined, - companyDescription: toStringOrNull(row.company_description) ?? undefined, - skills: toJsonStringOrNull(row.skills) ?? undefined, - experienceRange: toJsonStringOrNull(row.experience_range) ?? undefined, - companyRating: toNumberOrNull(row.company_rating) ?? undefined, - companyReviewsCount: toNumberOrNull(row.company_reviews_count) ?? undefined, - vacancyCount: toNumberOrNull(row.vacancy_count) ?? undefined, - workFromHomeType: toStringOrNull(row.work_from_home_type) ?? undefined, + child.on('close', (code) => { + if (code === 0) resolve(); + else reject(new Error(`JobSpy exited with code ${code}`)); + }); + child.on('error', reject); }); + + const raw = await readFile(outputJson, 'utf-8'); + const parsed = JSON.parse(raw) as Array>; + const mapped = mapJobSpyRows(parsed); + + for (const job of mapped) { + const url = job.jobUrl; + if (seenJobUrls.has(url)) continue; + seenJobUrls.add(url); + jobs.push(job); + } + + try { + await unlink(outputJson); + await unlink(outputCsv); + } catch { + // Ignore cleanup errors + } } return { success: true, jobs }; @@ -239,3 +202,120 @@ export async function runJobSpy(options: RunJobSpyOptions = {}): Promise(); + + for (const term of raw) { + const normalized = term.trim(); + if (!normalized) continue; + const key = normalized.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + out.push(normalized); + } + + return out; +} + +function parseSearchTermsEnv(raw: string | undefined): string[] | null { + if (!raw) return null; + const trimmed = raw.trim(); + if (!trimmed) return null; + + if (trimmed.startsWith('[')) { + try { + const parsed = JSON.parse(trimmed) as unknown; + if (Array.isArray(parsed) && parsed.every((v) => typeof v === 'string')) { + return parsed; + } + } catch { + // fall through + } + } + + const delimiter = trimmed.includes('|') ? '|' : trimmed.includes('\n') ? '\n' : ','; + const split = trimmed.split(delimiter).map((t) => t.trim()).filter(Boolean); + return split.length > 0 ? split : null; +} + +function slugForFilename(input: string): string { + const slug = input + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 40); + return slug || 'term'; +} + +function mapJobSpyRows(parsed: Array>): CreateJobInput[] { + const jobs: CreateJobInput[] = []; + + for (const row of parsed) { + const source = toJobSource(row.site); + if (!source) continue; + + const jobUrl = toStringOrNull(row.job_url); + if (!jobUrl) continue; + + const title = toStringOrNull(row.title) ?? 'Unknown Title'; + const employer = toStringOrNull(row.company) ?? 'Unknown Employer'; + + const jobUrlDirect = toStringOrNull(row.job_url_direct); + const applicationLink = jobUrlDirect ?? jobUrl; + + const minAmount = toNumberOrNull(row.min_amount); + const maxAmount = toNumberOrNull(row.max_amount); + const currency = toStringOrNull(row.currency); + const interval = toStringOrNull(row.interval); + + const salary = formatSalary({ minAmount, maxAmount, currency, interval }); + + jobs.push({ + source, + sourceJobId: toStringOrNull(row.id) ?? undefined, + jobUrlDirect: jobUrlDirect ?? undefined, + datePosted: toStringOrNull(row.date_posted) ?? undefined, + + title, + employer, + employerUrl: toStringOrNull(row.company_url) ?? undefined, + jobUrl, + applicationLink, + location: toStringOrNull(row.location) ?? undefined, + jobDescription: toStringOrNull(row.description) ?? undefined, + salary: salary ?? undefined, + + jobType: toStringOrNull(row.job_type) ?? undefined, + salarySource: toStringOrNull(row.salary_source) ?? undefined, + salaryInterval: interval ?? undefined, + salaryMinAmount: minAmount ?? undefined, + salaryMaxAmount: maxAmount ?? undefined, + salaryCurrency: currency ?? undefined, + isRemote: toBooleanOrNull(row.is_remote) ?? undefined, + jobLevel: toStringOrNull(row.job_level) ?? undefined, + jobFunction: toStringOrNull(row.job_function) ?? undefined, + listingType: toStringOrNull(row.listing_type) ?? undefined, + emails: toJsonStringOrNull(row.emails) ?? undefined, + companyIndustry: toStringOrNull(row.company_industry) ?? undefined, + companyLogo: toStringOrNull(row.company_logo) ?? undefined, + companyUrlDirect: toStringOrNull(row.company_url_direct) ?? undefined, + companyAddresses: toJsonStringOrNull(row.company_addresses) ?? undefined, + companyNumEmployees: toStringOrNull(row.company_num_employees) ?? undefined, + companyRevenue: toStringOrNull(row.company_revenue) ?? undefined, + companyDescription: toStringOrNull(row.company_description) ?? undefined, + skills: toJsonStringOrNull(row.skills) ?? undefined, + experienceRange: toJsonStringOrNull(row.experience_range) ?? undefined, + companyRating: toNumberOrNull(row.company_rating) ?? undefined, + companyReviewsCount: toNumberOrNull(row.company_reviews_count) ?? undefined, + vacancyCount: toNumberOrNull(row.vacancy_count) ?? undefined, + workFromHomeType: toStringOrNull(row.work_from_home_type) ?? undefined, + }); + } + + return jobs; +}