Job search tracker: CRUD, AI parse/tailor/cover via OmniRoute, legacy import
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -39,3 +39,4 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
.env
|
||||
|
||||
1062
package-lock.json
generated
1062
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,12 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.19.3",
|
||||
"gray-matter": "^4.0.3",
|
||||
"marked": "^18.0.9",
|
||||
"next": "16.3.1",
|
||||
"openai": "^7.4.0",
|
||||
"prisma": "^6.19.3",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
},
|
||||
@@ -21,6 +26,7 @@
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.3.1",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.23.12",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
prisma/dev.db
Normal file
BIN
prisma/dev.db
Normal file
Binary file not shown.
91
prisma/migrations/20260816110117_init/migration.sql
Normal file
91
prisma/migrations/20260816110117_init/migration.sql
Normal file
@@ -0,0 +1,91 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Job" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"title" TEXT NOT NULL,
|
||||
"company" TEXT NOT NULL,
|
||||
"source" TEXT,
|
||||
"url" TEXT,
|
||||
"applyUrl" TEXT,
|
||||
"payMin" REAL,
|
||||
"payMax" REAL,
|
||||
"payCurrency" TEXT,
|
||||
"payPeriod" TEXT,
|
||||
"location" TEXT,
|
||||
"remoteType" TEXT,
|
||||
"phEligibility" TEXT NOT NULL DEFAULT 'unclear',
|
||||
"description" TEXT,
|
||||
"requirements" TEXT,
|
||||
"tags" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'prospect',
|
||||
"notes" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Resume" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"kind" TEXT NOT NULL DEFAULT 'tailored',
|
||||
"markdown" TEXT NOT NULL,
|
||||
"htmlRendered" TEXT,
|
||||
"version" INTEGER NOT NULL DEFAULT 1,
|
||||
"jobId" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "Resume_jobId_fkey" FOREIGN KEY ("jobId") REFERENCES "Job" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CoverLetter" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"markdown" TEXT NOT NULL,
|
||||
"version" INTEGER NOT NULL DEFAULT 1,
|
||||
"jobId" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "CoverLetter_jobId_fkey" FOREIGN KEY ("jobId") REFERENCES "Job" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Application" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"jobId" TEXT NOT NULL,
|
||||
"method" TEXT NOT NULL DEFAULT 'manual',
|
||||
"submittedAt" DATETIME,
|
||||
"confirmation" TEXT,
|
||||
"answers" TEXT,
|
||||
"log" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "Application_jobId_fkey" FOREIGN KEY ("jobId") REFERENCES "Job" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Profile" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY DEFAULT 'main',
|
||||
"fullName" TEXT NOT NULL,
|
||||
"email" TEXT,
|
||||
"phone" TEXT,
|
||||
"location" TEXT,
|
||||
"linkedin" TEXT,
|
||||
"github" TEXT,
|
||||
"portfolio" TEXT,
|
||||
"headline" TEXT,
|
||||
"summary" TEXT,
|
||||
"skills" TEXT,
|
||||
"workHistory" TEXT,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AiRun" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"type" TEXT NOT NULL,
|
||||
"model" TEXT,
|
||||
"inputSummary" TEXT,
|
||||
"resultRef" TEXT,
|
||||
"ok" BOOLEAN NOT NULL DEFAULT true,
|
||||
"error" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "sqlite"
|
||||
97
prisma/schema.prisma
Normal file
97
prisma/schema.prisma
Normal file
@@ -0,0 +1,97 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Job {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
company String
|
||||
source String?
|
||||
url String?
|
||||
applyUrl String?
|
||||
payMin Float?
|
||||
payMax Float?
|
||||
payCurrency String? // USD, PHP
|
||||
payPeriod String? // hour, month, year
|
||||
location String?
|
||||
remoteType String? // remote, hybrid, onsite
|
||||
phEligibility String @default("unclear") // yes, unclear, no
|
||||
description String?
|
||||
requirements String?
|
||||
tags String? // comma-separated
|
||||
status String @default("prospect") // prospect, reviewing, tailoring, ready, applied, interviewing, offer, rejected, dead
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
resumes Resume[]
|
||||
coverLetters CoverLetter[]
|
||||
applications Application[]
|
||||
}
|
||||
|
||||
model Resume {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
kind String @default("tailored") // base, tailored
|
||||
markdown String
|
||||
htmlRendered String?
|
||||
version Int @default(1)
|
||||
jobId String?
|
||||
job Job? @relation(fields: [jobId], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model CoverLetter {
|
||||
id String @id @default(cuid())
|
||||
markdown String
|
||||
version Int @default(1)
|
||||
jobId String
|
||||
job Job @relation(fields: [jobId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Application {
|
||||
id String @id @default(cuid())
|
||||
jobId String
|
||||
job Job @relation(fields: [jobId], references: [id], onDelete: Cascade)
|
||||
method String @default("manual") // manual, semi, auto
|
||||
submittedAt DateTime?
|
||||
confirmation String?
|
||||
answers String? // JSON: [{question, answer}]
|
||||
log String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Profile {
|
||||
id String @id @default("main")
|
||||
fullName String
|
||||
email String?
|
||||
phone String?
|
||||
location String?
|
||||
linkedin String?
|
||||
github String?
|
||||
portfolio String?
|
||||
headline String?
|
||||
summary String?
|
||||
skills String? // JSON array
|
||||
workHistory String? // JSON array of {position, company, unit, start, end, supervisor, bullets[]}
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model AiRun {
|
||||
id String @id @default(cuid())
|
||||
type String // parse, tailor, cover, answers
|
||||
model String?
|
||||
inputSummary String?
|
||||
resultRef String? // id of created entity
|
||||
ok Boolean @default(true)
|
||||
error String?
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
169
scripts/import-legacy.ts
Normal file
169
scripts/import-legacy.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* One-time import of the legacy markdown job files + resumes into the DB.
|
||||
* Usage: npx tsx scripts/import-legacy.ts
|
||||
*/
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import matter from "gray-matter";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const LEGACY = path.join(os.homedir(), "development/personal/ai-job-search");
|
||||
|
||||
function section(body: string, name: string): string | undefined {
|
||||
const re = new RegExp(`##\\s*${name}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, "i");
|
||||
return body.match(re)?.[1]?.trim();
|
||||
}
|
||||
|
||||
function firstMatch(body: string, re: RegExp): string | undefined {
|
||||
return body.match(re)?.[1]?.trim();
|
||||
}
|
||||
|
||||
async function importJobs() {
|
||||
const dir = path.join(LEGACY, "jobs");
|
||||
const files = fs.readdirSync(dir).filter((f) => f.endsWith(".md"));
|
||||
for (const file of files) {
|
||||
const raw = fs.readFileSync(path.join(dir, file), "utf8");
|
||||
const { data, content } = matter(raw);
|
||||
const title = (data.title as string) ?? file.replace(/\.md$/, "");
|
||||
const company = (data.company as string) ?? "Unknown";
|
||||
const existing = await prisma.job.findFirst({ where: { title, company } });
|
||||
if (existing) {
|
||||
console.log(`skip (exists): ${file}`);
|
||||
continue;
|
||||
}
|
||||
const url = firstMatch(content, /\*\*Link:\*\*\s*(\S+)/);
|
||||
const applyUrl = firstMatch(content, /\*\*Apply(?: link)?:\*\*\s*(\S+)/);
|
||||
const details = section(content, "Details");
|
||||
const payLine = details ? firstMatch(details, /\*\*Pay(?:\s*Range)?:\*\*\s*(.+)/) : undefined;
|
||||
const location = details ? firstMatch(details, /\*\*Location:\*\*\s*(.+)/) : undefined;
|
||||
|
||||
// crude pay parse: "$15-20/hr", "₱150-170k/mo", "$65-120/hr"
|
||||
let payMin: number | undefined, payMax: number | undefined, payCurrency: string | undefined, payPeriod: string | undefined;
|
||||
if (payLine) {
|
||||
const m = payLine.match(/([$₱P])\s*([\d,.]+)\s*[-–]\s*([\d,.]+)(k?)\s*\/?\s*(hr|hour|mo|month|yr|year)?/i);
|
||||
if (m) {
|
||||
const mult = m[4] === "k" ? 1000 : 1;
|
||||
payMin = parseFloat(m[2].replace(/,/g, "")) * mult;
|
||||
payMax = parseFloat(m[3].replace(/,/g, "")) * mult;
|
||||
payCurrency = m[1] === "$" ? "USD" : "PHP";
|
||||
payPeriod = /hr|hour/i.test(m[5] ?? "") ? "hour" : /mo/i.test(m[5] ?? "") ? "month" : /yr|year/i.test(m[5] ?? "") ? "year" : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const phEligibility = /philippines|ph eligible|170\+ countries|worldwide/i.test(content) ? "yes" : "unclear";
|
||||
const status = (data.status as string)?.replace(/\s+/g, "-") === "not-applied" ? "prospect" : "prospect";
|
||||
|
||||
await prisma.job.create({
|
||||
data: {
|
||||
title,
|
||||
company,
|
||||
source: file.replace(/\.md$/, ""),
|
||||
url,
|
||||
applyUrl,
|
||||
payMin,
|
||||
payMax,
|
||||
payCurrency,
|
||||
payPeriod,
|
||||
location,
|
||||
remoteType: "remote",
|
||||
phEligibility,
|
||||
description: section(content, "Description"),
|
||||
requirements: section(content, "Requirements"),
|
||||
notes: section(content, "Notes") ?? section(content, "Tailoring notes"),
|
||||
status,
|
||||
},
|
||||
});
|
||||
console.log(`imported: ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function importResumes() {
|
||||
const base = path.join(LEGACY, "resume/resume.md");
|
||||
if (fs.existsSync(base)) {
|
||||
const md = fs.readFileSync(base, "utf8");
|
||||
const exists = await prisma.resume.findFirst({ where: { kind: "base" } });
|
||||
if (!exists) {
|
||||
await prisma.resume.create({ data: { name: "Base Resume", kind: "base", markdown: md } });
|
||||
console.log("imported: base resume");
|
||||
}
|
||||
}
|
||||
const tailoredDir = path.join(LEGACY, "resume/tailored");
|
||||
if (fs.existsSync(tailoredDir)) {
|
||||
for (const f of fs.readdirSync(tailoredDir).filter((f) => f.endsWith(".md"))) {
|
||||
const name = `Tailored: ${f.replace(/\.md$/, "")}`;
|
||||
const exists = await prisma.resume.findFirst({ where: { name } });
|
||||
if (exists) continue;
|
||||
await prisma.resume.create({
|
||||
data: { name, kind: "tailored", markdown: fs.readFileSync(path.join(tailoredDir, f), "utf8") },
|
||||
});
|
||||
console.log(`imported: tailored ${f}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function seedProfile() {
|
||||
const exists = await prisma.profile.findUnique({ where: { id: "main" } });
|
||||
if (exists) return;
|
||||
await prisma.profile.create({
|
||||
data: {
|
||||
id: "main",
|
||||
fullName: "Joshua C. Sarmiento",
|
||||
email: "joshuacsarmiento@gmail.com",
|
||||
location: "Philippines",
|
||||
headline: "Software Developer / AI Engineer — Prompt Engineering, Conversational AI, Backend Systems",
|
||||
summary:
|
||||
"Software developer with 8+ years of experience spanning enterprise IT, full-stack web development, and applied AI: building LLM-powered APIs and RAG systems with FastAPI and Amazon Bedrock.",
|
||||
skills: JSON.stringify([
|
||||
"Prompt engineering", "Amazon Bedrock", "RAG", "Conversational AI", "Python", "FastAPI",
|
||||
"REST API design", "Active Directory", "Full-stack web development", "Bash/PowerShell automation",
|
||||
]),
|
||||
workHistory: JSON.stringify([
|
||||
{
|
||||
position: "Project Technical Specialist III",
|
||||
company: "Philippine Institute of Volcanology and Seismology (PHIVOLCS)",
|
||||
unit: "Geomatics and Hazard Assessment Services Section",
|
||||
start: "2025-03", end: null, supervisor: "Eric Santos",
|
||||
bullets: [
|
||||
"Contributed to CLUP and CDRA systems",
|
||||
"Gates Handa: AI APIs with FastAPI + Amazon Bedrock, chatbot, RAG document/report analyzer",
|
||||
"Design system diagrams and plans; support project management",
|
||||
],
|
||||
},
|
||||
{
|
||||
position: "Computer Programmer II",
|
||||
company: "Presidential Commission for the Urban Poor (PCUP)",
|
||||
unit: "IT Unit",
|
||||
start: "2019-08", end: "2024-02", supervisor: "Jason Ronidel",
|
||||
bullets: [
|
||||
"Developed major web apps: Attendance Monitoring, Service Workflow Management, UPO Accreditation, Reservation, Directory",
|
||||
"Managed commission network infrastructure and devices",
|
||||
"MS Access + VBA database applications",
|
||||
],
|
||||
},
|
||||
{
|
||||
position: "IT Associate",
|
||||
company: "Manulife Business Process Services",
|
||||
unit: "Global Access Management (GAM) Division",
|
||||
start: "2016-11", end: "2017-10", supervisor: "Emory Lopez",
|
||||
bullets: [
|
||||
"Automated Active Directory user management with scripts (.sh, .ps1, .bat)",
|
||||
"Managed AD, mainframe, Oracle, FTP, and email accounts (Exchange, Lotus Notes, O365)",
|
||||
"ServiceNow request handling; VPN token creation",
|
||||
],
|
||||
},
|
||||
]),
|
||||
},
|
||||
});
|
||||
console.log("seeded: profile");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await importJobs();
|
||||
await importResumes();
|
||||
await seedProfile();
|
||||
console.log("done");
|
||||
}
|
||||
|
||||
main().finally(() => prisma.$disconnect());
|
||||
26
src/app/api/ai/cover/route.ts
Normal file
26
src/app/api/ai/cover/route.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { aiComplete } from "@/lib/ai";
|
||||
|
||||
const SYSTEM = `You write concise, specific cover letters (250-350 words, markdown).
|
||||
Truthful only — never invent experience. Reference the company and role by name.
|
||||
Output ONLY the letter markdown.`;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { jobId } = await req.json();
|
||||
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 markdown = await aiComplete({
|
||||
type: "cover",
|
||||
system: SYSTEM,
|
||||
user: `CANDIDATE PROFILE:\n${JSON.stringify(profile)}\n\nRESUME:\n${base?.markdown ?? ""}\n\nJOB: ${job.title} at ${job.company}\n${job.description ?? ""}\n${job.requirements ?? ""}`,
|
||||
inputSummary: `${job.company} — ${job.title}`,
|
||||
});
|
||||
|
||||
const version = (await prisma.coverLetter.count({ where: { jobId } })) + 1;
|
||||
const letter = await prisma.coverLetter.create({ data: { jobId, markdown, version } });
|
||||
return NextResponse.json(letter, { status: 201 });
|
||||
}
|
||||
44
src/app/api/ai/parse/route.ts
Normal file
44
src/app/api/ai/parse/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { aiComplete, extractJson } from "@/lib/ai";
|
||||
|
||||
const SYSTEM = `You are a job-posting parser. Extract structured data from the job posting text.
|
||||
Reply with ONLY a JSON object with these keys (null when unknown):
|
||||
{"title","company","url","applyUrl","payMin","payMax","payCurrency","payPeriod","location","remoteType","phEligibility","description","requirements","tags"}
|
||||
- payCurrency: "USD" or "PHP". payPeriod: "hour"|"month"|"year".
|
||||
- remoteType: "remote"|"hybrid"|"onsite".
|
||||
- phEligibility: "yes" if Philippines applicants are explicitly allowed or the role is worldwide; "no" if excluded; otherwise "unclear".
|
||||
- tags: comma-separated keywords.
|
||||
- description: 2-4 sentence summary. requirements: bullet list as plain text.`;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { text, url } = await req.json();
|
||||
let input = text as string | undefined;
|
||||
|
||||
if (!input && url) {
|
||||
// Server-side fetch; many boards (JobStreet) will block — caller falls back to pasting text.
|
||||
const res = await fetch(url, { headers: { "User-Agent": "Mozilla/5.0" } });
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Fetch failed (${res.status}). Paste the job text instead.` },
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
const html = await res.text();
|
||||
input = html.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").slice(0, 20000);
|
||||
}
|
||||
if (!input) return NextResponse.json({ error: "Provide text or url" }, { status: 400 });
|
||||
|
||||
const reply = await aiComplete({
|
||||
type: "parse",
|
||||
system: SYSTEM,
|
||||
user: input,
|
||||
inputSummary: url ?? input.slice(0, 120),
|
||||
});
|
||||
try {
|
||||
const parsed = extractJson<Record<string, unknown>>(reply);
|
||||
if (url && !parsed.url) parsed.url = url;
|
||||
return NextResponse.json(parsed);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "AI reply was not valid JSON", raw: reply }, { status: 502 });
|
||||
}
|
||||
}
|
||||
36
src/app/api/ai/tailor/route.ts
Normal file
36
src/app/api/ai/tailor/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { aiComplete } from "@/lib/ai";
|
||||
|
||||
const SYSTEM = `You are a resume-tailoring assistant. Given a base resume (markdown) and a job posting, produce a tailored resume in markdown.
|
||||
Rules:
|
||||
- Keep all facts truthful — never invent experience, employers, or dates.
|
||||
- Reorder and reword to emphasize what the job asks for.
|
||||
- Keep it one page worth of content.
|
||||
- Keep the candidate's real contact lines exactly as in the base resume.
|
||||
- Output ONLY the resume markdown, no commentary.`;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { jobId } = await req.json();
|
||||
const job = await prisma.job.findUnique({ where: { id: jobId } });
|
||||
if (!job) return NextResponse.json({ error: "job not found" }, { status: 404 });
|
||||
const base = await prisma.resume.findFirst({ where: { kind: "base" }, orderBy: { updatedAt: "desc" } });
|
||||
if (!base) return NextResponse.json({ error: "no base resume" }, { status: 400 });
|
||||
|
||||
const jobText = `# ${job.title} — ${job.company}\n${job.description ?? ""}\nRequirements:\n${job.requirements ?? ""}\nNotes:\n${job.notes ?? ""}`;
|
||||
const markdown = await aiComplete({
|
||||
type: "tailor",
|
||||
system: SYSTEM,
|
||||
user: `BASE RESUME:\n${base.markdown}\n\nJOB POSTING:\n${jobText}`,
|
||||
inputSummary: `${job.company} — ${job.title}`,
|
||||
});
|
||||
|
||||
const version = (await prisma.resume.count({ where: { jobId } })) + 1;
|
||||
const resume = await prisma.resume.create({
|
||||
data: { name: `Tailored: ${job.company} — ${job.title} v${version}`, kind: "tailored", markdown, jobId, version },
|
||||
});
|
||||
if (job.status === "prospect" || job.status === "reviewing") {
|
||||
await prisma.job.update({ where: { id: jobId }, data: { status: "tailoring" } });
|
||||
}
|
||||
return NextResponse.json(resume, { status: 201 });
|
||||
}
|
||||
19
src/app/api/applications/route.ts
Normal file
19
src/app/api/applications/route.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export async function GET() {
|
||||
const apps = await prisma.application.findMany({
|
||||
orderBy: { updatedAt: "desc" },
|
||||
include: { job: { select: { title: true, company: true } } },
|
||||
});
|
||||
return NextResponse.json(apps);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const data = await req.json();
|
||||
const app = await prisma.application.create({ data });
|
||||
if (data.submittedAt) {
|
||||
await prisma.job.update({ where: { id: data.jobId }, data: { status: "applied" } });
|
||||
}
|
||||
return NextResponse.json(app, { status: 201 });
|
||||
}
|
||||
27
src/app/api/jobs/[id]/route.ts
Normal file
27
src/app/api/jobs/[id]/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function GET(_req: NextRequest, { params }: Params) {
|
||||
const { id } = await params;
|
||||
const job = await prisma.job.findUnique({
|
||||
where: { id },
|
||||
include: { resumes: true, coverLetters: true, applications: true },
|
||||
});
|
||||
if (!job) return NextResponse.json({ error: "not found" }, { status: 404 });
|
||||
return NextResponse.json(job);
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest, { params }: Params) {
|
||||
const { id } = await params;
|
||||
const data = await req.json();
|
||||
const job = await prisma.job.update({ where: { id }, data });
|
||||
return NextResponse.json(job);
|
||||
}
|
||||
|
||||
export async function DELETE(_req: NextRequest, { params }: Params) {
|
||||
const { id } = await params;
|
||||
await prisma.job.delete({ where: { id } });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
22
src/app/api/jobs/route.ts
Normal file
22
src/app/api/jobs/route.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const status = req.nextUrl.searchParams.get("status") ?? undefined;
|
||||
const q = req.nextUrl.searchParams.get("q") ?? undefined;
|
||||
const jobs = await prisma.job.findMany({
|
||||
where: {
|
||||
...(status ? { status } : {}),
|
||||
...(q ? { OR: [{ title: { contains: q } }, { company: { contains: q } }] } : {}),
|
||||
},
|
||||
orderBy: { updatedAt: "desc" },
|
||||
include: { applications: true, resumes: { select: { id: true, name: true } } },
|
||||
});
|
||||
return NextResponse.json(jobs);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const data = await req.json();
|
||||
const job = await prisma.job.create({ data });
|
||||
return NextResponse.json(job, { status: 201 });
|
||||
}
|
||||
17
src/app/api/profile/route.ts
Normal file
17
src/app/api/profile/route.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export async function GET() {
|
||||
const profile = await prisma.profile.findUnique({ where: { id: "main" } });
|
||||
return NextResponse.json(profile);
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const data = await req.json();
|
||||
const profile = await prisma.profile.upsert({
|
||||
where: { id: "main" },
|
||||
update: data,
|
||||
create: { id: "main", fullName: data.fullName ?? "Me", ...data },
|
||||
});
|
||||
return NextResponse.json(profile);
|
||||
}
|
||||
24
src/app/api/resumes/[id]/route.ts
Normal file
24
src/app/api/resumes/[id]/route.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function GET(_req: NextRequest, { params }: Params) {
|
||||
const { id } = await params;
|
||||
const resume = await prisma.resume.findUnique({ where: { id } });
|
||||
if (!resume) return NextResponse.json({ error: "not found" }, { status: 404 });
|
||||
return NextResponse.json(resume);
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest, { params }: Params) {
|
||||
const { id } = await params;
|
||||
const data = await req.json();
|
||||
const resume = await prisma.resume.update({ where: { id }, data });
|
||||
return NextResponse.json(resume);
|
||||
}
|
||||
|
||||
export async function DELETE(_req: NextRequest, { params }: Params) {
|
||||
const { id } = await params;
|
||||
await prisma.resume.delete({ where: { id } });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
16
src/app/api/resumes/route.ts
Normal file
16
src/app/api/resumes/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export async function GET() {
|
||||
const resumes = await prisma.resume.findMany({
|
||||
orderBy: { updatedAt: "desc" },
|
||||
include: { job: { select: { title: true, company: true } } },
|
||||
});
|
||||
return NextResponse.json(resumes);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const data = await req.json();
|
||||
const resume = await prisma.resume.create({ data });
|
||||
return NextResponse.json(resume, { status: 201 });
|
||||
}
|
||||
27
src/app/applications/page.tsx
Normal file
27
src/app/applications/page.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import Link from "next/link";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ApplicationsPage() {
|
||||
const apps = await prisma.application.findMany({
|
||||
orderBy: { updatedAt: "desc" },
|
||||
include: { job: { select: { id: true, title: true, company: true, status: true } } },
|
||||
});
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold">Applications ({apps.length})</h1>
|
||||
{apps.length === 0 && <p className="text-zinc-500 text-sm">No applications yet. Open a job and click “Mark Applied”.</p>}
|
||||
<ul className="space-y-2">
|
||||
{apps.map((a) => (
|
||||
<li key={a.id} className="rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 p-4 text-sm">
|
||||
<Link href={`/jobs/${a.job.id}`} className="font-medium hover:text-blue-600">{a.job.title} — {a.job.company}</Link>
|
||||
<div className="text-zinc-500">
|
||||
{a.method} · {a.submittedAt ? `submitted ${a.submittedAt.toISOString().slice(0, 10)}` : "not submitted"} · job status: {a.job.status}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
src/app/jobs/[id]/job-actions.tsx
Normal file
79
src/app/jobs/[id]/job-actions.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const STATUSES = ["prospect", "reviewing", "tailoring", "ready", "applied", "interviewing", "offer", "rejected", "dead"];
|
||||
|
||||
export default function JobActions({ job }: { job: { id: string; status: string } }) {
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState("");
|
||||
const [msg, setMsg] = useState("");
|
||||
|
||||
async function setStatus(status: string) {
|
||||
await fetch(`/api/jobs/${job.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function run(kind: "tailor" | "cover") {
|
||||
setBusy(kind); setMsg("");
|
||||
try {
|
||||
const res = await fetch(`/api/ai/${kind}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ jobId: job.id }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error ?? "failed");
|
||||
setMsg(kind === "tailor" ? "Tailored resume created ✅" : "Cover letter created ✅");
|
||||
router.refresh();
|
||||
} catch (e) { setMsg(String(e)); } finally { setBusy(""); }
|
||||
}
|
||||
|
||||
async function markApplied() {
|
||||
await fetch("/api/applications", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ jobId: job.id, method: "manual", submittedAt: new Date().toISOString() }),
|
||||
});
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function del() {
|
||||
if (!confirm("Delete this job?")) return;
|
||||
await fetch(`/api/jobs/${job.id}`, { method: "DELETE" });
|
||||
router.push("/jobs");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 p-4 space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-zinc-500">Status:</span>
|
||||
<select value={job.status} onChange={(e) => setStatus(e.target.value)}
|
||||
className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-1">
|
||||
{STATUSES.map((s) => <option key={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button onClick={() => run("tailor")} disabled={!!busy}
|
||||
className="rounded bg-purple-600 text-white px-3 py-1.5 text-sm disabled:opacity-50 hover:bg-purple-700">
|
||||
{busy === "tailor" ? "Generating…" : "🪄 AI Tailor Resume"}
|
||||
</button>
|
||||
<button onClick={() => run("cover")} disabled={!!busy}
|
||||
className="rounded bg-indigo-600 text-white px-3 py-1.5 text-sm disabled:opacity-50 hover:bg-indigo-700">
|
||||
{busy === "cover" ? "Generating…" : "✉️ AI Cover Letter"}
|
||||
</button>
|
||||
<button onClick={markApplied} className="rounded bg-green-600 text-white px-3 py-1.5 text-sm hover:bg-green-700">
|
||||
✅ Mark Applied
|
||||
</button>
|
||||
<button onClick={del} className="rounded border border-red-300 text-red-600 px-3 py-1.5 text-sm hover:bg-red-50 dark:hover:bg-red-950">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
{msg && <div className="text-sm">{msg}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
src/app/jobs/[id]/page.tsx
Normal file
71
src/app/jobs/[id]/page.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import JobActions from "./job-actions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function JobDetail({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const job = await prisma.job.findUnique({
|
||||
where: { id },
|
||||
include: { resumes: true, coverLetters: true, applications: true },
|
||||
});
|
||||
if (!job) notFound();
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-6">
|
||||
<div>
|
||||
<Link href="/jobs" className="text-sm text-zinc-500 hover:text-blue-600">← Jobs</Link>
|
||||
<h1 className="text-2xl font-bold mt-1">{job.title}</h1>
|
||||
<div className="text-zinc-500">{job.company} · {job.location ?? "Remote"} · PH: {job.phEligibility}</div>
|
||||
<div className="mt-2 flex gap-3 text-sm">
|
||||
{job.url && <a href={job.url} target="_blank" className="text-blue-600 hover:underline">Listing ↗</a>}
|
||||
{job.applyUrl && <a href={job.applyUrl} target="_blank" className="text-blue-600 hover:underline">Apply page ↗</a>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<JobActions job={JSON.parse(JSON.stringify(job))} />
|
||||
|
||||
{job.description && (
|
||||
<section>
|
||||
<h2 className="font-semibold mb-1">Description</h2>
|
||||
<p className="text-sm whitespace-pre-wrap">{job.description}</p>
|
||||
</section>
|
||||
)}
|
||||
{job.requirements && (
|
||||
<section>
|
||||
<h2 className="font-semibold mb-1">Requirements</h2>
|
||||
<p className="text-sm whitespace-pre-wrap">{job.requirements}</p>
|
||||
</section>
|
||||
)}
|
||||
{job.notes && (
|
||||
<section>
|
||||
<h2 className="font-semibold mb-1">Notes</h2>
|
||||
<p className="text-sm whitespace-pre-wrap">{job.notes}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{job.resumes.length > 0 && (
|
||||
<section>
|
||||
<h2 className="font-semibold mb-1">Tailored resumes</h2>
|
||||
<ul className="text-sm space-y-1">
|
||||
{job.resumes.map((r) => (
|
||||
<li key={r.id}><Link href={`/resumes/${r.id}`} className="text-blue-600 hover:underline">{r.name}</Link></li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
{job.coverLetters.length > 0 && (
|
||||
<section>
|
||||
<h2 className="font-semibold mb-1">Cover letters</h2>
|
||||
<ul className="text-sm space-y-2">
|
||||
{job.coverLetters.map((c) => (
|
||||
<li key={c.id} className="rounded border border-zinc-200 dark:border-zinc-800 p-3 whitespace-pre-wrap bg-white dark:bg-zinc-900">{c.markdown}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
src/app/jobs/new/page.tsx
Normal file
85
src/app/jobs/new/page.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
type Parsed = Record<string, string | number | null>;
|
||||
|
||||
export default function NewJobPage() {
|
||||
const router = useRouter();
|
||||
const [url, setUrl] = useState("");
|
||||
const [text, setText] = useState("");
|
||||
const [parsed, setParsed] = useState<Parsed | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
async function parse() {
|
||||
setBusy(true); setErr("");
|
||||
try {
|
||||
const res = await fetch("/api/ai/parse", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url: url || undefined, text: text || undefined }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error ?? "parse failed");
|
||||
setParsed(data);
|
||||
} catch (e) { setErr(String(e)); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!parsed) return;
|
||||
setBusy(true);
|
||||
const res = await fetch("/api/jobs", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(parsed),
|
||||
});
|
||||
const job = await res.json();
|
||||
router.push(`/jobs/${job.id}`);
|
||||
}
|
||||
|
||||
function set(k: string, v: string) { setParsed((p) => ({ ...p!, [k]: v || null })); }
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-4">
|
||||
<h1 className="text-2xl font-bold">Add Job</h1>
|
||||
{!parsed && (
|
||||
<>
|
||||
<input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="Job URL (may fail on Cloudflare sites)"
|
||||
className="w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-2 text-sm" />
|
||||
<div className="text-center text-zinc-400 text-sm">— or paste job text (more reliable) —</div>
|
||||
<textarea value={text} onChange={(e) => setText(e.target.value)} rows={10} placeholder="Paste the full job posting text here"
|
||||
className="w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-2 text-sm" />
|
||||
<button onClick={parse} disabled={busy || (!url && !text)}
|
||||
className="rounded bg-blue-600 text-white px-4 py-2 text-sm disabled:opacity-50 hover:bg-blue-700">
|
||||
{busy ? "Parsing with AI…" : "Parse with AI"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{err && <div className="rounded bg-red-100 dark:bg-red-900/40 text-red-800 dark:text-red-200 p-3 text-sm">{err}</div>}
|
||||
{parsed && (
|
||||
<div className="space-y-3">
|
||||
<h2 className="font-semibold">Review parsed fields</h2>
|
||||
{["title", "company", "url", "applyUrl", "location", "remoteType", "phEligibility", "payMin", "payMax", "payCurrency", "payPeriod", "tags"].map((k) => (
|
||||
<label key={k} className="block text-sm">
|
||||
<span className="text-zinc-500">{k}</span>
|
||||
<input value={String(parsed[k] ?? "")} onChange={(e) => set(k, e.target.value)}
|
||||
className="mt-1 w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-2" />
|
||||
</label>
|
||||
))}
|
||||
{["description", "requirements"].map((k) => (
|
||||
<label key={k} className="block text-sm">
|
||||
<span className="text-zinc-500">{k}</span>
|
||||
<textarea value={String(parsed[k] ?? "")} onChange={(e) => set(k, e.target.value)} rows={4}
|
||||
className="mt-1 w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-2" />
|
||||
</label>
|
||||
))}
|
||||
<div className="flex gap-2">
|
||||
<button onClick={save} disabled={busy} className="rounded bg-green-600 text-white px-4 py-2 text-sm hover:bg-green-700">Save Job</button>
|
||||
<button onClick={() => setParsed(null)} className="rounded border border-zinc-300 dark:border-zinc-700 px-4 py-2 text-sm">Back</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
src/app/jobs/page.tsx
Normal file
68
src/app/jobs/page.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import Link from "next/link";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const badge: Record<string, string> = {
|
||||
prospect: "bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300",
|
||||
reviewing: "bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200",
|
||||
tailoring: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200",
|
||||
ready: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
|
||||
applied: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
|
||||
interviewing: "bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-200",
|
||||
offer: "bg-emerald-200 text-emerald-900 dark:bg-emerald-800 dark:text-emerald-100",
|
||||
rejected: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
|
||||
dead: "bg-zinc-300 text-zinc-600 dark:bg-zinc-700 dark:text-zinc-400",
|
||||
};
|
||||
|
||||
function pay(j: { payMin: number | null; payMax: number | null; payCurrency: string | null; payPeriod: string | null }) {
|
||||
if (!j.payMin && !j.payMax) return "—";
|
||||
const cur = j.payCurrency === "PHP" ? "₱" : "$";
|
||||
const per = j.payPeriod === "hour" ? "/hr" : j.payPeriod === "month" ? "/mo" : j.payPeriod === "year" ? "/yr" : "";
|
||||
const fmt = (n: number) => (n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`);
|
||||
return `${cur}${fmt(j.payMin ?? 0)}${j.payMax ? `–${fmt(j.payMax)}` : ""}${per}`;
|
||||
}
|
||||
|
||||
export default async function JobsPage({ searchParams }: { searchParams: Promise<{ status?: string }> }) {
|
||||
const { status } = await searchParams;
|
||||
const jobs = await prisma.job.findMany({
|
||||
where: status ? { status } : undefined,
|
||||
orderBy: { updatedAt: "desc" },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Jobs {status ? `— ${status}` : ""} ({jobs.length})</h1>
|
||||
<Link href="/jobs/new" className="rounded bg-blue-600 text-white px-4 py-2 text-sm hover:bg-blue-700">+ Add Job</Link>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-lg 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-3">Job</th>
|
||||
<th className="p-3">Pay</th>
|
||||
<th className="p-3">PH?</th>
|
||||
<th className="p-3">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{jobs.map((j) => (
|
||||
<tr key={j.id} className="border-b border-zinc-100 dark:border-zinc-800 hover:bg-zinc-50 dark:hover:bg-zinc-800/50">
|
||||
<td className="p-3">
|
||||
<Link href={`/jobs/${j.id}`} className="font-medium hover:text-blue-600">{j.title}</Link>
|
||||
<div className="text-zinc-500">{j.company}</div>
|
||||
</td>
|
||||
<td className="p-3 whitespace-nowrap">{pay(j)}</td>
|
||||
<td className="p-3">{j.phEligibility === "yes" ? "✅" : j.phEligibility === "no" ? "❌" : "❓"}</td>
|
||||
<td className="p-3">
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs ${badge[j.status] ?? ""}`}>{j.status}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +1,38 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import Link from "next/link";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "AI Job Search",
|
||||
description: "Track jobs, tailor resumes, apply with AI",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||
const nav = [
|
||||
{ href: "/", label: "Dashboard" },
|
||||
{ href: "/jobs", label: "Jobs" },
|
||||
{ href: "/resumes", label: "Resumes" },
|
||||
{ href: "/applications", label: "Applications" },
|
||||
{ href: "/profile", label: "Profile" },
|
||||
];
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
<html lang="en">
|
||||
<body className="min-h-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100 antialiased">
|
||||
<header className="border-b border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900">
|
||||
<div className="mx-auto max-w-6xl flex items-center gap-6 px-4 h-14">
|
||||
<span className="font-bold">🎯 AI Job Search</span>
|
||||
<nav className="flex gap-4 text-sm">
|
||||
{nav.map((n) => (
|
||||
<Link key={n.href} href={n.href} className="hover:text-blue-600 dark:hover:text-blue-400">
|
||||
{n.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto max-w-6xl px-4 py-6">{children}</main>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
102
src/app/page.tsx
102
src/app/page.tsx
@@ -1,69 +1,47 @@
|
||||
import Image from "next/image";
|
||||
import { prisma } from "@/lib/db";
|
||||
import Link from "next/link";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const STATUSES = ["prospect", "reviewing", "tailoring", "ready", "applied", "interviewing", "offer", "rejected", "dead"];
|
||||
|
||||
export default async function Dashboard() {
|
||||
const jobs = await prisma.job.findMany();
|
||||
const apps = await prisma.application.count();
|
||||
const resumes = await prisma.resume.count();
|
||||
const byStatus = Object.fromEntries(STATUSES.map((s) => [s, jobs.filter((j) => j.status === s).length]));
|
||||
const phYes = jobs.filter((j) => j.phEligibility === "yes").length;
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert h-5 w-[100px]"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the{" "}
|
||||
<code className="rounded bg-black/[.06] px-1.5 py-0.5 font-mono text-[0.9em] dark:bg-white/[.08]">
|
||||
page.tsx
|
||||
</code>{" "}
|
||||
file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Dashboard</h1>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{[
|
||||
["Total jobs", jobs.length],
|
||||
["PH-eligible", phYes],
|
||||
["Resumes", resumes],
|
||||
["Applications", apps],
|
||||
].map(([label, n]) => (
|
||||
<div key={label as string} className="rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 p-4">
|
||||
<div className="text-3xl font-bold">{n as number}</div>
|
||||
<div className="text-sm text-zinc-500">{label as string}</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert h-[14px] w-4"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={14}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold mb-2">Pipeline</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{STATUSES.map((s) => (
|
||||
<Link key={s} href={`/jobs?status=${s}`} className="rounded-full border border-zinc-300 dark:border-zinc-700 px-3 py-1 text-sm hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
||||
{s} <span className="font-bold">{byStatus[s]}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Link href="/jobs/new" className="rounded bg-blue-600 text-white px-4 py-2 text-sm hover:bg-blue-700">+ Add Job (AI parse)</Link>
|
||||
<Link href="/jobs" className="rounded border border-zinc-300 dark:border-zinc-700 px-4 py-2 text-sm hover:bg-zinc-100 dark:hover:bg-zinc-800">View all jobs</Link>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
9
src/app/profile/page.tsx
Normal file
9
src/app/profile/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import ProfileEditor from "./profile-editor";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ProfilePage() {
|
||||
const profile = await prisma.profile.findUnique({ where: { id: "main" } });
|
||||
return <ProfileEditor profile={JSON.parse(JSON.stringify(profile ?? {}))} />;
|
||||
}
|
||||
62
src/app/profile/profile-editor.tsx
Normal file
62
src/app/profile/profile-editor.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
|
||||
type P = Record<string, string | null>;
|
||||
const FIELDS: [string, string][] = [
|
||||
["fullName", "Full name"], ["email", "Email"], ["phone", "Phone"], ["location", "Location"],
|
||||
["linkedin", "LinkedIn"], ["github", "GitHub"], ["portfolio", "Portfolio URL"], ["headline", "Headline"],
|
||||
];
|
||||
|
||||
export default function ProfileEditor({ profile }: { profile: P }) {
|
||||
const [p, setP] = useState<P>(profile);
|
||||
const [saved, setSaved] = useState(true);
|
||||
|
||||
async function save() {
|
||||
const { ...data } = p;
|
||||
delete (data as Record<string, unknown>).id;
|
||||
delete (data as Record<string, unknown>).updatedAt;
|
||||
await fetch("/api/profile", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
setSaved(true);
|
||||
}
|
||||
|
||||
function set(k: string, v: string) { setP((x) => ({ ...x, [k]: v })); setSaved(false); }
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Profile</h1>
|
||||
<button onClick={save} disabled={saved} className="rounded bg-blue-600 text-white px-4 py-2 text-sm disabled:opacity-50 hover:bg-blue-700">
|
||||
{saved ? "Saved" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 gap-3">
|
||||
{FIELDS.map(([k, label]) => (
|
||||
<label key={k} className="block text-sm">
|
||||
<span className="text-zinc-500">{label}</span>
|
||||
<input value={p[k] ?? ""} onChange={(e) => set(k, e.target.value)}
|
||||
className="mt-1 w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-2" />
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<label className="block text-sm">
|
||||
<span className="text-zinc-500">Summary</span>
|
||||
<textarea value={p.summary ?? ""} onChange={(e) => set("summary", e.target.value)} rows={4}
|
||||
className="mt-1 w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-2" />
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="text-zinc-500">Skills (JSON array)</span>
|
||||
<textarea value={p.skills ?? ""} onChange={(e) => set("skills", e.target.value)} rows={3}
|
||||
className="mt-1 w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-2 font-mono" />
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="text-zinc-500">Work history (JSON)</span>
|
||||
<textarea value={p.workHistory ?? ""} onChange={(e) => set("workHistory", e.target.value)} rows={12}
|
||||
className="mt-1 w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-2 font-mono" />
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
src/app/resumes/[id]/page.tsx
Normal file
12
src/app/resumes/[id]/page.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { notFound } from "next/navigation";
|
||||
import ResumeEditor from "./resume-editor";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ResumePage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const resume = await prisma.resume.findUnique({ where: { id } });
|
||||
if (!resume) notFound();
|
||||
return <ResumeEditor resume={JSON.parse(JSON.stringify(resume))} />;
|
||||
}
|
||||
59
src/app/resumes/[id]/resume-editor.tsx
Normal file
59
src/app/resumes/[id]/resume-editor.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { marked } from "marked";
|
||||
|
||||
export default function ResumeEditor({ resume }: { resume: { id: string; name: string; markdown: string } }) {
|
||||
const [md, setMd] = useState(resume.markdown);
|
||||
const [saved, setSaved] = useState(true);
|
||||
const [preview, setPreview] = useState(true);
|
||||
|
||||
async function save() {
|
||||
await fetch(`/api/resumes/${resume.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ markdown: md }),
|
||||
});
|
||||
setSaved(true);
|
||||
}
|
||||
|
||||
function printResume() {
|
||||
const w = window.open("", "_blank");
|
||||
if (!w) return;
|
||||
w.document.write(`<!DOCTYPE html><html><head><title>${resume.name}</title><style>
|
||||
body{font-family:Georgia,serif;max-width:800px;margin:2rem auto;padding:0 1rem;line-height:1.5;color:#222}
|
||||
h1{font-size:1.6rem;margin-bottom:0} h2{font-size:1.1rem;border-bottom:1px solid #ccc;padding-bottom:2px;margin-top:1.2rem}
|
||||
h3{font-size:1rem;margin-bottom:0} ul{margin-top:.3rem} li{margin:.15rem 0} @media print{body{margin:0}}
|
||||
</style></head><body>${marked.parse(md)}</body></html>`);
|
||||
w.document.close();
|
||||
setTimeout(() => w.print(), 300);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div>
|
||||
<Link href="/resumes" className="text-sm text-zinc-500 hover:text-blue-600">← Resumes</Link>
|
||||
<h1 className="text-xl font-bold">{resume.name}</h1>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setPreview(!preview)} className="rounded border border-zinc-300 dark:border-zinc-700 px-3 py-1.5 text-sm">
|
||||
{preview ? "Hide" : "Show"} preview
|
||||
</button>
|
||||
<button onClick={printResume} className="rounded border border-zinc-300 dark:border-zinc-700 px-3 py-1.5 text-sm">🖨 Print / PDF</button>
|
||||
<button onClick={save} disabled={saved} className="rounded bg-blue-600 text-white px-4 py-1.5 text-sm disabled:opacity-50 hover:bg-blue-700">
|
||||
{saved ? "Saved" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`grid gap-4 ${preview ? "md:grid-cols-2" : ""}`}>
|
||||
<textarea value={md} onChange={(e) => { setMd(e.target.value); setSaved(false); }} rows={30}
|
||||
className="w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-3 font-mono text-sm" />
|
||||
{preview && (
|
||||
<div className="rounded border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 p-4 prose prose-sm dark:prose-invert max-w-none overflow-auto"
|
||||
dangerouslySetInnerHTML={{ __html: marked.parse(md) as string }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
src/app/resumes/page.tsx
Normal file
26
src/app/resumes/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import Link from "next/link";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ResumesPage() {
|
||||
const resumes = await prisma.resume.findMany({
|
||||
orderBy: { updatedAt: "desc" },
|
||||
include: { job: { select: { title: true, company: true } } },
|
||||
});
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold">Resumes ({resumes.length})</h1>
|
||||
<ul className="space-y-2">
|
||||
{resumes.map((r) => (
|
||||
<li key={r.id} className="rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 p-4">
|
||||
<Link href={`/resumes/${r.id}`} className="font-medium hover:text-blue-600">{r.name}</Link>
|
||||
<div className="text-sm text-zinc-500">
|
||||
{r.kind}{r.job ? ` · for ${r.job.company} — ${r.job.title}` : ""} · updated {r.updatedAt.toISOString().slice(0, 10)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
src/lib/ai.ts
Normal file
63
src/lib/ai.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import OpenAI from "openai";
|
||||
import { prisma } from "./db";
|
||||
|
||||
// OmniRoute gateway is OpenAI-compatible. IMPORTANT: the ClinePass node is
|
||||
// streaming-only — non-stream requests come back with empty choices. Always
|
||||
// stream and accumulate.
|
||||
let _client: OpenAI | undefined;
|
||||
function client(): OpenAI {
|
||||
_client ??= new OpenAI({
|
||||
baseURL: process.env.OMNIROUTE_BASE_URL,
|
||||
apiKey: process.env.OMNIROUTE_API_KEY || "unset",
|
||||
});
|
||||
return _client;
|
||||
}
|
||||
|
||||
const MODEL = process.env.OMNIROUTE_MODEL ?? "deepseek-v4-flash";
|
||||
|
||||
export async function aiComplete(opts: {
|
||||
type: string; // parse | tailor | cover | answers
|
||||
system: string;
|
||||
user: string;
|
||||
inputSummary?: string;
|
||||
}): Promise<string> {
|
||||
try {
|
||||
const stream = await client().chat.completions.create({
|
||||
model: MODEL,
|
||||
stream: true,
|
||||
messages: [
|
||||
{ role: "system", content: opts.system },
|
||||
{ role: "user", content: opts.user },
|
||||
],
|
||||
});
|
||||
let out = "";
|
||||
for await (const chunk of stream) {
|
||||
out += chunk.choices[0]?.delta?.content ?? "";
|
||||
}
|
||||
await prisma.aiRun.create({
|
||||
data: { type: opts.type, model: MODEL, inputSummary: opts.inputSummary, ok: true },
|
||||
});
|
||||
return out;
|
||||
} catch (e) {
|
||||
await prisma.aiRun.create({
|
||||
data: {
|
||||
type: opts.type,
|
||||
model: MODEL,
|
||||
inputSummary: opts.inputSummary,
|
||||
ok: false,
|
||||
error: String(e),
|
||||
},
|
||||
});
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract the first JSON object from a model reply (handles ```json fences). */
|
||||
export function extractJson<T>(text: string): T {
|
||||
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
|
||||
const raw = fenced ? fenced[1] : text;
|
||||
const start = raw.indexOf("{");
|
||||
const end = raw.lastIndexOf("}");
|
||||
if (start === -1 || end === -1) throw new Error("No JSON object in AI reply");
|
||||
return JSON.parse(raw.slice(start, end + 1)) as T;
|
||||
}
|
||||
7
src/lib/db.ts
Normal file
7
src/lib/db.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
|
||||
|
||||
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||
Reference in New Issue
Block a user