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 0a579b2..f37c626 100644 Binary files a/prisma/dev.db and b/prisma/dev.db differ diff --git a/scripts/add-gov-jobs.ts b/scripts/add-gov-jobs.ts new file mode 100644 index 0000000..37fff24 --- /dev/null +++ b/scripts/add-gov-jobs.ts @@ -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()); 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)

+