Phase 3-4: apply wizard with AI answers, Playwright auto-apply worker, report page, Docker deploy files; add PH government job leads
This commit is contained in:
25
Dockerfile
Normal file
25
Dockerfile
Normal file
@@ -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
|
||||||
16
docker-compose.yml
Normal file
16
docker-compose.yml
Normal file
@@ -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:
|
||||||
48
package-lock.json
generated
48
package-lock.json
generated
@@ -24,6 +24,7 @@
|
|||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.3.1",
|
"eslint-config-next": "16.3.1",
|
||||||
|
"playwright": "^1.62.1",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"tsx": "^4.23.12",
|
"tsx": "^4.23.12",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
@@ -6567,6 +6568,53 @@
|
|||||||
"pathe": "^2.0.3"
|
"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": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.3.1",
|
"eslint-config-next": "16.3.1",
|
||||||
|
"playwright": "^1.62.1",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"tsx": "^4.23.12",
|
"tsx": "^4.23.12",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
|
|||||||
BIN
prisma/dev.db
BIN
prisma/dev.db
Binary file not shown.
65
scripts/add-gov-jobs.ts
Normal file
65
scripts/add-gov-jobs.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const jobs = [
|
||||||
|
{
|
||||||
|
title: "Information Systems Analyst (SG-12) — Nationwide",
|
||||||
|
company: "Philippine Statistics Authority (PSA)",
|
||||||
|
source: "governmentph.com", url: "https://governmentph.com/psa-infosys-analysts/",
|
||||||
|
payMin: 22149, payMax: 22149, payCurrency: "PHP", payPeriod: "month",
|
||||||
|
location: "All Regional & Field Offices, Philippines", remoteType: "onsite", phEligibility: "yes",
|
||||||
|
description: "PSA hiring Information Systems Analysts nationwide. Salary Grade 12. Open to fresh graduates.",
|
||||||
|
requirements: "Bachelor's in Computer Science, Information Systems, or IT-related course. No experience or training required. Civil Service eligibility per CSC rules.",
|
||||||
|
tags: "government,onsite,IT,fresh-grad-ok", status: "prospect",
|
||||||
|
notes: "Gov job — apply via PSA regional office postings; check CSC portal for exact deadlines.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "IT/Programmer Vacancies (portal — filter 'Computer Programmer')",
|
||||||
|
company: "Civil Service Commission Job Portal",
|
||||||
|
source: "csc.gov.ph", url: "https://csc.gov.ph/career/",
|
||||||
|
location: "Philippines (all agencies/regions)", remoteType: "onsite", phEligibility: "yes",
|
||||||
|
description: "Official CSC job portal aggregating all PH government vacancies. Filter by position title (Computer Programmer, Information Systems Analyst, IT Officer), agency, and region. Each posting shows salary grade, qualification standards, documents, and application instructions.",
|
||||||
|
requirements: "Varies per posting; most require CS/IT degree + civil service eligibility for permanent items.",
|
||||||
|
tags: "government,portal,aggregator", status: "prospect",
|
||||||
|
notes: "Portal blocks bots (403) — browse manually. CSC NCR posts its own vacancies at csc.gov.ph/regional-offices/ncr.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "IT Positions (multiple, NCR + Caraga)",
|
||||||
|
company: "Department of Information and Communications Technology (DICT)",
|
||||||
|
source: "govjobsph.com", url: "https://govjobsph.com/jobs/agency/dict",
|
||||||
|
location: "NCR / Region 13 Caraga", remoteType: "onsite", phEligibility: "yes",
|
||||||
|
description: "DICT job openings across NCR and Caraga. The country's lead ICT agency — roles closest to Josh's dev/infra background.",
|
||||||
|
requirements: "Varies; typically IT degree + relevant eligibility.",
|
||||||
|
tags: "government,ICT,DICT", status: "prospect",
|
||||||
|
notes: "Listings on GovJobsPH; verify deadlines on DICT's official careers page.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Business/Systems Analyst + ICT roles",
|
||||||
|
company: "Department of Science and Technology (DOST)",
|
||||||
|
source: "jobstreet", url: "https://ph.jobstreet.com/dost-jobs",
|
||||||
|
location: "Philippines", remoteType: "onsite", phEligibility: "yes",
|
||||||
|
description: "DOST openings including Business/Systems Analyst under Information & Communication Technology, plus research roles.",
|
||||||
|
requirements: "IT/CS degree; per-posting qualification standards.",
|
||||||
|
tags: "government,DOST,analyst", status: "prospect",
|
||||||
|
notes: "Also check dost.gov.ph careers page directly.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Government IT Jobs (aggregator)",
|
||||||
|
company: "GovJobs.PH",
|
||||||
|
source: "govjobs.ph", url: "https://govjobs.ph/",
|
||||||
|
location: "Philippines", remoteType: "onsite", phEligibility: "yes",
|
||||||
|
description: "Aggregator for Philippine government vacancies incl. Computer Programmer I/II items across agencies.",
|
||||||
|
tags: "government,portal,aggregator", status: "prospect",
|
||||||
|
notes: "Individual listings expire fast (404s) — check regularly.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
for (const j of jobs) {
|
||||||
|
const exists = await prisma.job.findFirst({ where: { title: j.title, company: j.company } });
|
||||||
|
if (exists) { console.log("skip:", j.title); continue; }
|
||||||
|
await prisma.job.create({ data: j });
|
||||||
|
console.log("added:", j.title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
main().finally(() => prisma.$disconnect());
|
||||||
33
src/app/api/ai/answers/route.ts
Normal file
33
src/app/api/ai/answers/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
163
src/app/jobs/[id]/apply/apply-wizard.tsx
Normal file
163
src/app/jobs/[id]/apply/apply-wizard.tsx
Normal file
@@ -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<string, string | null>;
|
||||||
|
type QA = { question: string; answer: string };
|
||||||
|
|
||||||
|
function CopyField({ label, value }: { label: string; value: string }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
if (!value) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="text-zinc-500 w-28 shrink-0">{label}</span>
|
||||||
|
<span className="truncate flex-1">{value}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => { navigator.clipboard.writeText(value); setCopied(true); setTimeout(() => setCopied(false), 1500); }}
|
||||||
|
className="rounded border border-zinc-300 dark:border-zinc-700 px-2 py-0.5 text-xs hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
||||||
|
{copied ? "✓" : "Copy"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<QA[]>([]);
|
||||||
|
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 (
|
||||||
|
<div className="max-w-3xl mx-auto space-y-6">
|
||||||
|
<div>
|
||||||
|
<Link href={`/jobs/${job.id}`} className="text-sm text-zinc-500 hover:text-indigo-600">← Back to job</Link>
|
||||||
|
<h1 className="text-2xl font-bold mt-1">Apply: {job.title}</h1>
|
||||||
|
<div className="text-zinc-500">{job.company}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step 1: open the application page */}
|
||||||
|
<section className="rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 p-5 space-y-3">
|
||||||
|
<h2 className="font-semibold">1 · Open the application page</h2>
|
||||||
|
{job.applyUrl || job.url ? (
|
||||||
|
<a href={job.applyUrl ?? job.url ?? "#"} target="_blank"
|
||||||
|
className="inline-block rounded-xl bg-gradient-to-r from-indigo-600 to-violet-600 text-white px-4 py-2 text-sm font-semibold hover:from-indigo-500 hover:to-violet-500">
|
||||||
|
Open application page ↗
|
||||||
|
</a>
|
||||||
|
) : <p className="text-sm text-zinc-500">No apply URL saved — add one on the job page.</p>}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Step 2: copy-ready profile fields */}
|
||||||
|
<section className="rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 p-5 space-y-2">
|
||||||
|
<h2 className="font-semibold mb-2">2 · Copy your details into the form</h2>
|
||||||
|
<CopyField label="Full name" value={profile.fullName ?? ""} />
|
||||||
|
<CopyField label="Email" value={profile.email ?? ""} />
|
||||||
|
<CopyField label="Phone" value={profile.phone ?? ""} />
|
||||||
|
<CopyField label="Location" value={profile.location ?? ""} />
|
||||||
|
<CopyField label="LinkedIn" value={profile.linkedin ?? ""} />
|
||||||
|
<CopyField label="GitHub" value={profile.github ?? ""} />
|
||||||
|
<CopyField label="Portfolio" value={profile.portfolio ?? ""} />
|
||||||
|
<CopyField label="Headline" value={profile.headline ?? ""} />
|
||||||
|
<CopyField label="Summary" value={profile.summary ?? ""} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Step 3: resume + cover letter */}
|
||||||
|
<section className="rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 p-5 space-y-3">
|
||||||
|
<h2 className="font-semibold">3 · Attach documents</h2>
|
||||||
|
{job.resumes.length ? (
|
||||||
|
<div className="text-sm space-y-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-zinc-500">Resume to use</span>
|
||||||
|
<select value={resumeId} onChange={(e) => setResumeId(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded-xl border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-2">
|
||||||
|
{job.resumes.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{resumeId && <Link href={`/resumes/${resumeId}`} className="text-indigo-600 hover:underline text-sm">Open resume → Print/PDF ↗</Link>}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-zinc-500">No tailored resume yet — generate one from the job page (“AI Tailor Resume”).</p>
|
||||||
|
)}
|
||||||
|
{job.coverLetters.length > 0 && (
|
||||||
|
<details className="text-sm">
|
||||||
|
<summary className="cursor-pointer text-zinc-600 dark:text-zinc-300">Cover letter v{job.coverLetters[job.coverLetters.length - 1].version} (click to view/copy)</summary>
|
||||||
|
<div className="mt-2 rounded-xl border border-zinc-200 dark:border-zinc-800 p-3 whitespace-pre-wrap">{job.coverLetters[job.coverLetters.length - 1].markdown}</div>
|
||||||
|
<button onClick={() => navigator.clipboard.writeText(job.coverLetters[job.coverLetters.length - 1].markdown)}
|
||||||
|
className="mt-2 rounded border border-zinc-300 dark:border-zinc-700 px-3 py-1 text-xs hover:bg-zinc-100 dark:hover:bg-zinc-800">Copy letter</button>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Step 4: AI answers for form questions */}
|
||||||
|
<section className="rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 p-5 space-y-3">
|
||||||
|
<h2 className="font-semibold">4 · Application questions (optional, AI-drafted)</h2>
|
||||||
|
<textarea value={questions} onChange={(e) => setQuestions(e.target.value)} rows={4}
|
||||||
|
placeholder={"Paste the form's questions, one per line:\nWhy do you want to work here?\nDescribe your experience with LLMs."}
|
||||||
|
className="w-full rounded-xl border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-950/60 p-3 text-sm" />
|
||||||
|
<button onClick={genAnswers} disabled={busy || !questions.trim()}
|
||||||
|
className="rounded-xl bg-indigo-600 text-white px-4 py-2 text-sm font-semibold disabled:opacity-50 hover:bg-indigo-500">
|
||||||
|
{busy ? "Drafting…" : "🪄 Draft answers with AI"}
|
||||||
|
</button>
|
||||||
|
{err && <div className="text-sm text-rose-600">{err}</div>}
|
||||||
|
{answers.map((qa, i) => (
|
||||||
|
<div key={i} className="rounded-xl border border-zinc-200 dark:border-zinc-800 p-3 text-sm space-y-1">
|
||||||
|
<div className="font-medium">{qa.question}</div>
|
||||||
|
<div className="text-zinc-600 dark:text-zinc-300 whitespace-pre-wrap">{qa.answer}</div>
|
||||||
|
<button onClick={() => navigator.clipboard.writeText(qa.answer)}
|
||||||
|
className="rounded border border-zinc-300 dark:border-zinc-700 px-2 py-0.5 text-xs hover:bg-zinc-100 dark:hover:bg-zinc-800">Copy</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Step 5: confirm */}
|
||||||
|
<section className="rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 p-5 space-y-3">
|
||||||
|
<h2 className="font-semibold">5 · Done?</h2>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" checked={confirmSubmit} onChange={(e) => setConfirmSubmit(e.target.checked)} />
|
||||||
|
I submitted the application on the employer's site
|
||||||
|
</label>
|
||||||
|
<button onClick={markSubmitted} disabled={!confirmSubmit || busy}
|
||||||
|
className="rounded-xl bg-emerald-600 text-white px-5 py-2 text-sm font-semibold disabled:opacity-50 hover:bg-emerald-500">
|
||||||
|
✅ Record application
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
src/app/jobs/[id]/apply/page.tsx
Normal file
16
src/app/jobs/[id]/apply/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import ApplyWizard from "./apply-wizard";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function ApplyPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const job = await prisma.job.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { resumes: true, coverLetters: true },
|
||||||
|
});
|
||||||
|
if (!job) notFound();
|
||||||
|
const profile = await prisma.profile.findUnique({ where: { id: "main" } });
|
||||||
|
return <ApplyWizard job={JSON.parse(JSON.stringify(job))} profile={JSON.parse(JSON.stringify(profile ?? {}))} />;
|
||||||
|
}
|
||||||
@@ -163,6 +163,17 @@ export default function JobActions({ job }: { job: { id: string; status: string
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Apply Wizard */}
|
||||||
|
<a
|
||||||
|
href={`/jobs/${job.id}/apply`}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-500 hover:to-cyan-500 shadow-sm shadow-blue-500/20 active:scale-95 transition-all"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
||||||
|
</svg>
|
||||||
|
<span>Apply Wizard</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
{/* Mark Applied */}
|
{/* Mark Applied */}
|
||||||
<button
|
<button
|
||||||
onClick={markApplied}
|
onClick={markApplied}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const nav = [
|
|||||||
{ href: "/resumes", label: "Resumes" },
|
{ href: "/resumes", label: "Resumes" },
|
||||||
{ href: "/applications", label: "Applications" },
|
{ href: "/applications", label: "Applications" },
|
||||||
{ href: "/profile", label: "Profile" },
|
{ href: "/profile", label: "Profile" },
|
||||||
|
{ href: "/report", label: "Report" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
|||||||
61
src/app/report/page.tsx
Normal file
61
src/app/report/page.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function ReportPage() {
|
||||||
|
const jobs = await prisma.job.findMany({ orderBy: [{ status: "asc" }, { company: "asc" }] });
|
||||||
|
const apps = await prisma.application.findMany({ include: { job: true }, orderBy: { createdAt: "desc" } });
|
||||||
|
const now = new Date().toISOString().slice(0, 10);
|
||||||
|
const applied = jobs.filter((j) => ["applied", "interviewing", "offer"].includes(j.status)).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto space-y-6 print:text-black">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Job Search Report</h1>
|
||||||
|
<p className="text-sm text-zinc-500">Generated {now} · {jobs.length} tracked · {applied} in application pipeline</p>
|
||||||
|
</div>
|
||||||
|
<button className="print:hidden rounded-xl border border-zinc-300 dark:border-zinc-700 px-4 py-2 text-sm">
|
||||||
|
Use browser Print (Ctrl+P) to save as PDF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="font-semibold mb-2">All jobs</h2>
|
||||||
|
<div className="overflow-x-auto rounded-xl border border-zinc-200 dark:border-zinc-800">
|
||||||
|
<table className="w-full text-sm bg-white dark:bg-zinc-900">
|
||||||
|
<thead className="text-left text-zinc-500 border-b border-zinc-200 dark:border-zinc-800">
|
||||||
|
<tr><th className="p-2">Company</th><th className="p-2">Title</th><th className="p-2">Pay</th><th className="p-2">PH?</th><th className="p-2">Status</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{jobs.map((j) => (
|
||||||
|
<tr key={j.id} className="border-b border-zinc-100 dark:border-zinc-800">
|
||||||
|
<td className="p-2">{j.company}</td>
|
||||||
|
<td className="p-2">{j.title}</td>
|
||||||
|
<td className="p-2 whitespace-nowrap">
|
||||||
|
{j.payMin ? `${j.payCurrency === "PHP" ? "₱" : "$"}${j.payMin}${j.payMax && j.payMax !== j.payMin ? `–${j.payMax}` : ""}/${j.payPeriod === "hour" ? "hr" : j.payPeriod === "month" ? "mo" : "yr"}` : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="p-2">{j.phEligibility}</td>
|
||||||
|
<td className="p-2">{j.status}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{apps.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h2 className="font-semibold mb-2">Applications</h2>
|
||||||
|
<ul className="text-sm space-y-1">
|
||||||
|
{apps.map((a) => (
|
||||||
|
<li key={a.id}>
|
||||||
|
{a.job.company} — {a.job.title} · {a.method} · {a.submittedAt ? a.submittedAt.toISOString().slice(0, 10) : "not submitted"}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
worker/adapters/dataannotation.ts
Normal file
48
worker/adapters/dataannotation.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import type { Adapter } from "./index";
|
||||||
|
import { launchPage, fillIfPresent } from "./index";
|
||||||
|
|
||||||
|
/** DataAnnotation worker signup (app.dataannotation.tech/worker_signup). */
|
||||||
|
export const dataAnnotationAdapter: Adapter = {
|
||||||
|
name: "dataannotation",
|
||||||
|
matches: (url) => url.includes("dataannotation.tech"),
|
||||||
|
async run({ job, profile, submit }) {
|
||||||
|
const lines: string[] = [];
|
||||||
|
fs.mkdirSync("worker/out", { recursive: true });
|
||||||
|
const { browser, page } = await launchPage();
|
||||||
|
try {
|
||||||
|
await page.goto(job.applyUrl ?? job.url ?? "", { waitUntil: "domcontentloaded" });
|
||||||
|
lines.push(`opened ${page.url()}`);
|
||||||
|
await page.screenshot({ path: "worker/out/da-1-landing.png", fullPage: true });
|
||||||
|
|
||||||
|
const [first, ...rest] = (profile.fullName ?? "").split(" ");
|
||||||
|
await fillIfPresent(page, ['input[name="first_name"]', "#first_name"], first, lines);
|
||||||
|
await fillIfPresent(page, ['input[name="last_name"]', "#last_name"], rest.join(" "), lines);
|
||||||
|
await fillIfPresent(page, ['input[type="email"]', 'input[name="email"]'], profile.email, lines);
|
||||||
|
await page.screenshot({ path: "worker/out/da-2-filled.png", fullPage: true });
|
||||||
|
|
||||||
|
// CAPTCHA / verification: give the human 90s in the headed browser.
|
||||||
|
if (await page.locator('iframe[src*="captcha"], iframe[src*="turnstile"]').count()) {
|
||||||
|
lines.push("CAPTCHA detected — waiting 90s for manual solve");
|
||||||
|
await page.waitForTimeout(90_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!submit) {
|
||||||
|
lines.push("dry run: stopping before submit");
|
||||||
|
return { submitted: false, lines };
|
||||||
|
}
|
||||||
|
const btn = page.locator('button[type="submit"]').first();
|
||||||
|
if (!(await btn.count())) {
|
||||||
|
lines.push("no submit button found — aborting");
|
||||||
|
return { submitted: false, lines };
|
||||||
|
}
|
||||||
|
await btn.click();
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
await page.screenshot({ path: "worker/out/da-3-submitted.png", fullPage: true });
|
||||||
|
lines.push(`submitted; landed on ${page.url()}`);
|
||||||
|
return { submitted: true, lines };
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
45
worker/adapters/greenhouse.ts
Normal file
45
worker/adapters/greenhouse.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import type { Adapter } from "./index";
|
||||||
|
import { launchPage, fillIfPresent } from "./index";
|
||||||
|
|
||||||
|
/** Generic Greenhouse job-board application form (boards.greenhouse.io). */
|
||||||
|
export const genericGreenhouseAdapter: Adapter = {
|
||||||
|
name: "greenhouse",
|
||||||
|
matches: (url) => url.includes("greenhouse.io"),
|
||||||
|
async run({ job, profile, submit }) {
|
||||||
|
const lines: string[] = [];
|
||||||
|
fs.mkdirSync("worker/out", { recursive: true });
|
||||||
|
const { browser, page } = await launchPage();
|
||||||
|
try {
|
||||||
|
await page.goto(job.applyUrl ?? job.url ?? "", { waitUntil: "domcontentloaded" });
|
||||||
|
lines.push(`opened ${page.url()}`);
|
||||||
|
|
||||||
|
const [first, ...rest] = (profile.fullName ?? "").split(" ");
|
||||||
|
await fillIfPresent(page, ["#first_name"], first, lines);
|
||||||
|
await fillIfPresent(page, ["#last_name"], rest.join(" "), lines);
|
||||||
|
await fillIfPresent(page, ["#email"], profile.email, lines);
|
||||||
|
await fillIfPresent(page, ["#phone"], profile.phone, lines);
|
||||||
|
await fillIfPresent(page, ['input[name*="linkedin" i]', 'input[autocomplete="url"]'], profile.linkedin, lines);
|
||||||
|
await page.screenshot({ path: "worker/out/gh-filled.png", fullPage: true });
|
||||||
|
lines.push("NOTE: resume file upload must be done manually in the headed browser (60s pause)");
|
||||||
|
await page.waitForTimeout(60_000);
|
||||||
|
|
||||||
|
if (!submit) {
|
||||||
|
lines.push("dry run: stopping before submit");
|
||||||
|
return { submitted: false, lines };
|
||||||
|
}
|
||||||
|
const btn = page.locator('button[type="submit"], input[type="submit"]').first();
|
||||||
|
if (!(await btn.count())) {
|
||||||
|
lines.push("no submit button found — aborting");
|
||||||
|
return { submitted: false, lines };
|
||||||
|
}
|
||||||
|
await btn.click();
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
await page.screenshot({ path: "worker/out/gh-submitted.png", fullPage: true });
|
||||||
|
lines.push(`submitted; landed on ${page.url()}`);
|
||||||
|
return { submitted: true, lines };
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
40
worker/adapters/index.ts
Normal file
40
worker/adapters/index.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { Job, Profile } from "@prisma/client";
|
||||||
|
|
||||||
|
export type RunResult = { submitted: boolean; lines: string[] };
|
||||||
|
export type AdapterCtx = { job: Job; profile: Profile; submit: boolean };
|
||||||
|
export type Adapter = {
|
||||||
|
name: string;
|
||||||
|
matches: (url: string) => boolean;
|
||||||
|
run: (ctx: AdapterCtx) => Promise<RunResult>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Shared helper: launch a headed chromium page (human can intervene on CAPTCHAs). */
|
||||||
|
export async function launchPage() {
|
||||||
|
// Playwright is an optional dependency — installed only where the worker runs.
|
||||||
|
const { chromium } = await import("playwright");
|
||||||
|
const browser = await chromium.launch({ headless: false });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
return { browser, page };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fillIfPresent(
|
||||||
|
page: import("playwright").Page,
|
||||||
|
selectors: string[],
|
||||||
|
value: string | null | undefined,
|
||||||
|
log: string[],
|
||||||
|
) {
|
||||||
|
if (!value) return;
|
||||||
|
for (const sel of selectors) {
|
||||||
|
const el = page.locator(sel).first();
|
||||||
|
if (await el.count()) {
|
||||||
|
await el.fill(value);
|
||||||
|
log.push(`filled ${sel}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
import { dataAnnotationAdapter } from "./dataannotation";
|
||||||
|
import { genericGreenhouseAdapter } from "./greenhouse";
|
||||||
|
|
||||||
|
export const adapters: Adapter[] = [dataAnnotationAdapter, genericGreenhouseAdapter];
|
||||||
58
worker/apply.ts
Normal file
58
worker/apply.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Auto-apply worker (Playwright).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* npm i -D playwright && npx playwright install chromium # one-time
|
||||||
|
* npx tsx worker/apply.ts <jobId> [--submit]
|
||||||
|
*
|
||||||
|
* Safety model:
|
||||||
|
* - WITHOUT --submit it fills the form, screenshots every step, and STOPS
|
||||||
|
* before the final submit (dry run). You review screenshots in worker/out/.
|
||||||
|
* - WITH --submit it still pauses on CAPTCHAs/Cloudflare (headed mode) and
|
||||||
|
* waits for you to solve them; it only clicks submit on adapter-known forms.
|
||||||
|
*/
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { adapters } from "./adapters";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const [jobId, ...flags] = process.argv.slice(2);
|
||||||
|
const submit = flags.includes("--submit");
|
||||||
|
if (!jobId) {
|
||||||
|
console.error("usage: npx tsx worker/apply.ts <jobId> [--submit]");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const job = await prisma.job.findUnique({ where: { id: jobId } });
|
||||||
|
if (!job) throw new Error("job not found");
|
||||||
|
const profile = await prisma.profile.findUnique({ where: { id: "main" } });
|
||||||
|
if (!profile) throw new Error("no profile — fill in /profile first");
|
||||||
|
const url = job.applyUrl ?? job.url;
|
||||||
|
if (!url) throw new Error("job has no applyUrl/url");
|
||||||
|
|
||||||
|
const adapter = adapters.find((a) => a.matches(url));
|
||||||
|
if (!adapter) {
|
||||||
|
console.error(`No adapter matches ${url}.\nKnown adapters: ${adapters.map((a) => a.name).join(", ")}\nUse the in-app Apply Wizard (semi-auto) instead.`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Adapter: ${adapter.name} · submit=${submit}`);
|
||||||
|
const log = await adapter.run({ job, profile, submit });
|
||||||
|
|
||||||
|
await prisma.application.create({
|
||||||
|
data: {
|
||||||
|
jobId,
|
||||||
|
method: "auto",
|
||||||
|
submittedAt: submit && log.submitted ? new Date() : null,
|
||||||
|
log: log.lines.join("\n"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (submit && log.submitted) {
|
||||||
|
await prisma.job.update({ where: { id: jobId }, data: { status: "applied" } });
|
||||||
|
}
|
||||||
|
console.log(log.submitted ? "SUBMITTED ✅" : "DRY RUN complete — review worker/out/ screenshots, re-run with --submit to finish.");
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => { console.error(e); process.exit(1); })
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
Reference in New Issue
Block a user