From bcbecbb49aa9db64c37b081cd7451e05c2638d85 Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Sun, 16 Aug 2026 20:42:27 +0800 Subject: [PATCH] Phase 3-4: apply wizard with AI answers, Playwright auto-apply worker, report page, Docker deploy files; add PH government job leads --- Dockerfile | 25 ++++ docker-compose.yml | 16 +++ package-lock.json | 48 +++++++ package.json | 1 + prisma/dev.db | Bin 77824 -> 81920 bytes scripts/add-gov-jobs.ts | 65 +++++++++ src/app/api/ai/answers/route.ts | 33 +++++ src/app/jobs/[id]/apply/apply-wizard.tsx | 163 +++++++++++++++++++++++ src/app/jobs/[id]/apply/page.tsx | 16 +++ src/app/jobs/[id]/job-actions.tsx | 11 ++ src/app/layout.tsx | 1 + src/app/report/page.tsx | 61 +++++++++ worker/adapters/dataannotation.ts | 48 +++++++ worker/adapters/greenhouse.ts | 45 +++++++ worker/adapters/index.ts | 40 ++++++ worker/apply.ts | 58 ++++++++ 16 files changed, 631 insertions(+) create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 scripts/add-gov-jobs.ts create mode 100644 src/app/api/ai/answers/route.ts create mode 100644 src/app/jobs/[id]/apply/apply-wizard.tsx create mode 100644 src/app/jobs/[id]/apply/page.tsx create mode 100644 src/app/report/page.tsx create mode 100644 worker/adapters/dataannotation.ts create mode 100644 worker/adapters/greenhouse.ts create mode 100644 worker/adapters/index.ts create mode 100644 worker/apply.ts diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..287d4eb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM node:22-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +COPY prisma ./prisma +RUN npm ci + +FROM node:22-alpine AS build +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +ENV DATABASE_URL="file:/data/app.db" +RUN npx prisma generate && npm run build + +FROM node:22-alpine +WORKDIR /app +ENV NODE_ENV=production +ENV DATABASE_URL="file:/data/app.db" +COPY --from=build /app/.next ./.next +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/package.json ./ +COPY --from=build /app/prisma ./prisma +COPY --from=build /app/public ./public +EXPOSE 3000 +# Run migrations against the volume-mounted DB, then start. +CMD npx prisma migrate deploy && npm start diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b63d21e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +services: + jobsearch: + build: . + restart: unless-stopped + environment: + DATABASE_URL: "file:/data/app.db" + OMNIROUTE_BASE_URL: "https://omni.hesed.sbs/v1" + OMNIROUTE_API_KEY: "${OMNIROUTE_API_KEY}" + OMNIROUTE_MODEL: "${OMNIROUTE_MODEL:-deepseek-v4-flash}" + volumes: + - jobsearch_data:/data + ports: + - "3790:3000" + +volumes: + jobsearch_data: diff --git a/package-lock.json b/package-lock.json index bb41e39..51da324 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.3.1", + "playwright": "^1.62.1", "tailwindcss": "^4", "tsx": "^4.23.12", "typescript": "^5" @@ -6567,6 +6568,53 @@ "pathe": "^2.0.3" } }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/package.json b/package.json index 632a7d0..ab57b10 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.3.1", + "playwright": "^1.62.1", "tailwindcss": "^4", "tsx": "^4.23.12", "typescript": "^5" diff --git a/prisma/dev.db b/prisma/dev.db index 0a579b202961c89fe677d7cd0472fe5977a36764..f37c62607c893b461a6a256cc5d123fc849840a5 100644 GIT binary patch delta 3016 zcmaJ@&2JM&6n6*|h>`{ff&>)xAp}W;z3Y7NQE~_&&|q4?U<2aRj(5lQHoH5UnOz4X zp-uR>lTuXmQdLzi94ijJRz3C)D7UJrQ17T}FQro7oAufb0g%{O^Yz}m-^ZKP-?y%= zZhLfa+mTErvv(cldXD_?+4ttY-ockRkl8!<@8Ca!FSibCdC+q!v%J23^VxEKb#Na} z?HR_;?%nv=wX+Yu2m7Bt-hL+6H*jTOa$u|o|jrE#EZ_JqRODVm+~?o3h&-T)M?Y~jF=XaoJ+G2jOLujOzGvK4-MwKRR#X~9E&XD)VhdZoqkF)hUq9OV!1S z0>xY=n}Btt)^APWLFUFG;Hl#xX_DdPr{PK<>M z5S&(^S@19-=CB+&5^*V+n5fOMKpc*NrBPE{YS6gr)SL>|KWqZtl#cP3Pc_4Wdy24Y z6)~I8BAZrP$Uq7ea4Rgv31KR(u$B~5M2I-fhrvtGZ1?Lw9-Mn$A{X z7zJX*??41Bgf5OGm1J&C5cFJ`z9o&GZ~LD7vYLD!Zr>jd&n@4~Ihie6hW39tGPJgM zZ}#fw+FYCaB&UsPdIzj9URumeP9@1-*0`)VHt&!WN+FISq!v;k3hP3~RK4+HX?~(p zuApvM!b?g2`nVY=mUEC>s;P1;;gd3NJiPI+c%kSCKdjFqiLYt;BioN6ZMkbFY~x6Y zPBscjfbmHjm;lrrNfLO+{S^4^OBql~*tke+gvlubt^^zvA)qR`s%w@;Eab{Ki%jOO zkYfm$Pg+m>poEs;#}+dA4I#6Dj4G6KlDl|@4VH+c;s#t6z;7eda4Y%-t_dY`e9F7U zSsPiBn#(4d64OckGIB95s%)IdJe$}=wouOM>~o~CoLEYEx+ogjGsxITPfchz45(@; z$si$FDkL=vcxh`%hikbaiG<%~R)~sXwo9gE^lK*8lkbmr_x0Q#`Qp*mzTUnUnSuZM z&-A^(rt)iFeDBu5{c|55Iy9|K`Y;mpdImkZx6RYd?FLg$Jq$IBwn`4!vN|CraGJ)9 z;sq=N+M(+5+0IDIU}NpDZ7ZE`AESleVbmLq6G69JL?y+&(+8kn77B@50JG2%RNKEd zx<^c1;TD?$%NFVPkMDf!+%ePG|* zb?bwx?dq!K+0k0NxF?-W?fPt_2tQ@M=IrQ_g@rOmfdo6p113Y=MMM=e-I23o$z^WU zec_54YX97oQh9;^2+?H2$G_!AVjDd;M?;;;l2#L`(s~dmHGNyEZDbSw-()WOtKoA=%5v&mN(RkAVd0k?TV?52qNNS--OsE@9qqm z^wZQ2l%{DrR!HH^mQ%yzbSn|z$0^dpB;3?NyCo9ZsB_Yp8&IxuGDts^P4rk7%|(+i z38g!!h7l}RD2}o~kG?2W9oO2tVPY?l3@Xf8s!2S=Hez}haCES+86ZWl^U`yr{R%)! z<_(5zzUu+djW=eUJ%^tZ>rYZ;;yY+Dah>9YSwMGks?ClS0#4JmF?BfcQhKW;gh=?1 LNcM@Q!IS?114S(I delta 99 zcmZo@U~PE7GC`VEfq{WRc%p(mqr%37CHg!}{NI2A3_|?B`M+%z`0$Kh5Xch*5dxfC xKvIl1cQU(!?q)#&OWw`7zS9r!v9i@N@P+V9;9A5nkF6Fck prisma.$disconnect()); diff --git a/src/app/api/ai/answers/route.ts b/src/app/api/ai/answers/route.ts new file mode 100644 index 0000000..1c84521 --- /dev/null +++ b/src/app/api/ai/answers/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { aiComplete, extractJson } from "@/lib/ai"; + +const SYSTEM = `You draft application-form answers for a job candidate. +Given the candidate profile/resume, the job, and a list of application questions, +reply with ONLY JSON: {"answers":[{"question":"...","answer":"..."}]} +Rules: truthful only (never invent experience); concise (1-4 sentences unless the question demands more); first person; specific to this job.`; + +export async function POST(req: NextRequest) { + const { jobId, questions } = await req.json(); + if (!jobId || !questions?.length) { + return NextResponse.json({ error: "jobId and questions[] required" }, { status: 400 }); + } + const job = await prisma.job.findUnique({ where: { id: jobId } }); + if (!job) return NextResponse.json({ error: "job not found" }, { status: 404 }); + const profile = await prisma.profile.findUnique({ where: { id: "main" } }); + const base = await prisma.resume.findFirst({ where: { kind: "base" } }); + + const reply = await aiComplete({ + type: "answers", + system: SYSTEM, + user: `PROFILE:\n${JSON.stringify(profile)}\n\nRESUME:\n${base?.markdown ?? ""}\n\nJOB: ${job.title} at ${job.company}\n${job.description ?? ""}\n\nQUESTIONS:\n${(questions as string[]).map((q, i) => `${i + 1}. ${q}`).join("\n")}`, + inputSummary: `${job.company} — ${questions.length} questions`, + }); + + try { + const parsed = extractJson<{ answers: { question: string; answer: string }[] }>(reply); + return NextResponse.json(parsed); + } catch { + return NextResponse.json({ error: "AI reply was not valid JSON", raw: reply }, { status: 502 }); + } +} diff --git a/src/app/jobs/[id]/apply/apply-wizard.tsx b/src/app/jobs/[id]/apply/apply-wizard.tsx new file mode 100644 index 0000000..3ed3350 --- /dev/null +++ b/src/app/jobs/[id]/apply/apply-wizard.tsx @@ -0,0 +1,163 @@ +"use client"; +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; + +type Resume = { id: string; name: string }; +type Cover = { id: string; markdown: string; version: number }; +type Job = { id: string; title: string; company: string; applyUrl: string | null; url: string | null; resumes: Resume[]; coverLetters: Cover[] }; +type Profile = Record; +type QA = { question: string; answer: string }; + +function CopyField({ label, value }: { label: string; value: string }) { + const [copied, setCopied] = useState(false); + if (!value) return null; + return ( +
+ {label} + {value} + +
+ ); +} + +export default function ApplyWizard({ job, profile }: { job: Job; profile: Profile }) { + const router = useRouter(); + const [resumeId, setResumeId] = useState(job.resumes[0]?.id ?? ""); + const [questions, setQuestions] = useState(""); + const [answers, setAnswers] = useState([]); + const [busy, setBusy] = useState(false); + const [confirmSubmit, setConfirmSubmit] = useState(false); + const [err, setErr] = useState(""); + + async function genAnswers() { + setBusy(true); setErr(""); + try { + const qs = questions.split("\n").map((q) => q.trim()).filter(Boolean); + const res = await fetch("/api/ai/answers", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ jobId: job.id, questions: qs }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error ?? "failed"); + setAnswers(data.answers); + } catch (e) { setErr(String(e)); } finally { setBusy(false); } + } + + async function markSubmitted() { + setBusy(true); + await fetch("/api/applications", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jobId: job.id, + method: "semi", + submittedAt: new Date().toISOString(), + answers: answers.length ? JSON.stringify(answers) : undefined, + log: `Applied via apply wizard; resume ${resumeId || "none"}`, + }), + }); + router.push(`/jobs/${job.id}`); + } + + return ( +
+
+ ← Back to job +

Apply: {job.title}

+
{job.company}
+
+ + {/* Step 1: open the application page */} +
+

1 · Open the application page

+ {job.applyUrl || job.url ? ( + + Open application page ↗ + + ) :

No apply URL saved — add one on the job page.

} +
+ + {/* Step 2: copy-ready profile fields */} +
+

2 · Copy your details into the form

+ + + + + + + + + +
+ + {/* Step 3: resume + cover letter */} +
+

3 · Attach documents

+ {job.resumes.length ? ( +
+ + {resumeId && Open resume → Print/PDF ↗} +
+ ) : ( +

No tailored resume yet — generate one from the job page (“AI Tailor Resume”).

+ )} + {job.coverLetters.length > 0 && ( +
+ Cover letter v{job.coverLetters[job.coverLetters.length - 1].version} (click to view/copy) +
{job.coverLetters[job.coverLetters.length - 1].markdown}
+ +
+ )} +
+ + {/* Step 4: AI answers for form questions */} +
+

4 · Application questions (optional, AI-drafted)

+