From e69be785481dfc7d11107317cf8c6ad9b946857f Mon Sep 17 00:00:00 2001 From: Kai Date: Tue, 10 Feb 2026 01:41:33 -0800 Subject: [PATCH] feat: wire careers page to persistent data --- data/careers.json | 60 +++++---- src/app/api/careers/route.ts | 246 ++++++++++++++++++++++++----------- src/app/careers/page.tsx | 18 ++- src/lib/careers.ts | 220 +++++++++++++++++++++++++++++++ 4 files changed, 441 insertions(+), 103 deletions(-) create mode 100644 src/lib/careers.ts diff --git a/data/careers.json b/data/careers.json index a9418e7..a82221a 100644 --- a/data/careers.json +++ b/data/careers.json @@ -4,8 +4,8 @@ "id": "platform-engineer-core-runtime", "title": "Platform Engineer, Core Runtime", "department": "engineering", - "type": "full-time", "location": "remote", + "type": "full-time", "description": "Build the execution engine and APIs that power reliable multi-agent workflows across forAgents.dev.", "requirements": [ "4+ years building production systems with TypeScript or Go", @@ -13,21 +13,23 @@ "Strong debugging and performance profiling habits", "Comfort owning features from architecture to deployment" ], + "salary": "$165k-$210k + equity", + "status": "open", + "updatedAt": "2026-02-09T18:10:00.000Z", + "postedAt": "2026-02-01T10:00:00.000Z", "benefits": [ "Remote-first team with flexible working hours", "Annual learning and tooling stipend", "Health and wellness reimbursement", "Generous vacation and company recharge days" - ], - "postedAt": "2026-02-01", - "status": "open" + ] }, { "id": "agent-observability-engineer", "title": "Agent Observability Engineer", "department": "engineering", - "type": "contract", "location": "hybrid", + "type": "contract", "description": "Design telemetry pipelines and dashboards that make agent behavior understandable and trustworthy.", "requirements": [ "Hands-on experience with distributed tracing and metrics", @@ -35,21 +37,23 @@ "Strong communication with product and infrastructure teams", "Experience operating services in cloud environments" ], + "salary": "$110-$140/hour", + "status": "open", + "updatedAt": "2026-02-08T21:30:00.000Z", + "postedAt": "2026-02-03T10:00:00.000Z", "benefits": [ "Hybrid collaboration hub in North America time zones", "Competitive contract rates paid bi-weekly", "Optional equipment allowance", "Opportunity to extend to long-term role" - ], - "postedAt": "2026-02-03", - "status": "open" + ] }, { "id": "product-designer-workflows", "title": "Product Designer, Agent Workflows", "department": "design", - "type": "part-time", "location": "remote", + "type": "part-time", "description": "Craft intuitive interfaces for role setup, task routing, and agent collaboration experiences.", "requirements": [ "Portfolio demonstrating strong systems-thinking in UX", @@ -57,21 +61,23 @@ "Ability to run lightweight user research", "Experience partnering closely with engineers" ], + "salary": "$85k-$120k pro-rated", + "status": "open", + "updatedAt": "2026-02-07T12:05:00.000Z", + "postedAt": "2026-01-31T10:00:00.000Z", "benefits": [ "Flexible part-time schedule", "Design conference and training budget", "Async-first collaboration culture", "Mentorship across product and engineering" - ], - "postedAt": "2026-01-31", - "status": "open" + ] }, { "id": "community-programs-lead", "title": "Community Programs Lead", "department": "community", - "type": "full-time", "location": "remote", + "type": "full-time", "description": "Lead programs that help builders launch faster, share learnings, and contribute high-quality templates.", "requirements": [ "2+ years managing developer community initiatives", @@ -79,21 +85,23 @@ "Experience with events, AMAs, or cohort-based programs", "Ability to build and track program KPIs" ], + "salary": "$120k-$150k + bonus", + "status": "open", + "updatedAt": "2026-02-06T16:22:00.000Z", + "postedAt": "2026-02-04T10:00:00.000Z", "benefits": [ "Home office stipend", "Travel budget for partner events", "Extended health and dental coverage", "Paid volunteer days" - ], - "postedAt": "2026-02-04", - "status": "open" + ] }, { "id": "operations-coordinator-scale", "title": "Operations Coordinator", "department": "operations", - "type": "part-time", "location": "hybrid", + "type": "part-time", "description": "Keep hiring, release, and cross-team execution running smoothly as the organization scales.", "requirements": [ "Experience with project tracking and operations tooling", @@ -101,21 +109,23 @@ "Ability to coordinate across multiple stakeholders", "Comfort documenting and improving recurring processes" ], + "salary": "$70k-$90k pro-rated", + "status": "closed", + "updatedAt": "2026-02-05T09:45:00.000Z", + "postedAt": "2026-01-27T10:00:00.000Z", "benefits": [ "Flexible hybrid schedule", "Quarterly team offsites", "Professional development reimbursement", "Performance bonus program" - ], - "postedAt": "2026-01-27", - "status": "closed" + ] }, { "id": "docs-bounty-builder", "title": "Documentation Bounty Builder", "department": "community", - "type": "bounty", "location": "remote", + "type": "bounty", "description": "Ship practical onboarding guides, migration docs, and examples for new agent teams.", "requirements": [ "Strong technical writing with code examples", @@ -123,14 +133,16 @@ "Ability to work independently to a scoped brief", "Fast turnaround with clean editorial standards" ], + "salary": "$2,000-$4,500 per accepted package", + "status": "open", + "updatedAt": "2026-02-09T07:10:00.000Z", + "postedAt": "2026-02-06T10:00:00.000Z", "benefits": [ "Clear scoped payout per accepted deliverable", "Public contributor recognition", "Access to private builder channels", "Potential fast-track into recurring contract work" - ], - "postedAt": "2026-02-06", - "status": "open" + ] } ], "applications": [] diff --git a/src/app/api/careers/route.ts b/src/app/api/careers/route.ts index 9a15c64..b1ea579 100644 --- a/src/app/api/careers/route.ts +++ b/src/app/api/careers/route.ts @@ -1,27 +1,36 @@ -import { promises as fs } from "node:fs"; -import path from "node:path"; import { NextRequest, NextResponse } from "next/server"; +import { + createCareer, + getCareers, + readCareersFile, + writeCareersFile, + type CareerDepartment, + type CareerLocation, + type CareerStatus, + type CareerType, +} from "@/lib/careers"; export const runtime = "nodejs"; -const CAREERS_FILE_PATH = path.join(process.cwd(), "data", "careers.json"); -type JobType = "full-time" | "part-time" | "contract" | "bounty"; -type JobStatus = "open" | "closed"; +type CreateCareerBody = { + title?: unknown; + department?: unknown; + location?: unknown; + type?: unknown; + description?: unknown; + requirements?: unknown; + salary?: unknown; + status?: unknown; +}; -type JobListing = { - id: string; - title: string; - department: "engineering" | "design" | "community" | "operations"; - type: JobType; - location: "remote" | "hybrid"; - description: string; - requirements: string[]; - benefits: string[]; - postedAt: string; - status: JobStatus; +type SubmitApplicationBody = { + roleId: string; + name: string; + email: string; + message?: string; }; -type JobApplication = { +type CareerApplication = { id: string; roleId: string; roleTitle: string; @@ -31,67 +40,140 @@ type JobApplication = { submittedAt: string; }; -type CareersData = { - positions: JobListing[]; - applications: JobApplication[]; -}; +function asDepartment(value: unknown): CareerDepartment | null { + if ( + value === "engineering" || + value === "design" || + value === "community" || + value === "operations" || + value === "product" || + value === "growth" + ) { + return value; + } -async function readCareersData(): Promise { - const raw = await fs.readFile(CAREERS_FILE_PATH, "utf8"); - return JSON.parse(raw) as CareersData; + return null; } -async function writeCareersData(data: CareersData) { - await fs.writeFile(CAREERS_FILE_PATH, `${JSON.stringify(data, null, 2)}\n`, "utf8"); +function asLocation(value: unknown): CareerLocation | null { + if (value === "remote" || value === "hybrid" || value === "onsite") return value; + return null; } -export async function GET(request: NextRequest) { - try { - const data = await readCareersData(); - const { searchParams } = new URL(request.url); - - const department = searchParams.get("department")?.toLowerCase().trim(); - const type = searchParams.get("type")?.toLowerCase().trim(); - const status = searchParams.get("status")?.toLowerCase().trim(); - const search = searchParams.get("search")?.toLowerCase().trim(); - - const filtered = data.positions.filter((job) => { - const departmentMatches = department ? job.department.toLowerCase() === department : true; - const typeMatches = type ? job.type === type : true; - const statusMatches = status ? job.status === status : true; - const searchMatches = search - ? [job.title, job.description, job.department, ...job.requirements, ...job.benefits] - .join(" ") - .toLowerCase() - .includes(search) - : true; - - return departmentMatches && typeMatches && statusMatches && searchMatches; - }); +function asType(value: unknown): CareerType | null { + if (value === "full-time" || value === "part-time" || value === "contract" || value === "bounty") return value; + return null; +} - return NextResponse.json({ - positions: filtered, - total: filtered.length, - }); - } catch (error) { - console.error("Failed to read careers data", error); - return NextResponse.json({ error: "Failed to load careers data" }, { status: 500 }); +function asStatus(value: unknown): CareerStatus | null { + if (value === "open" || value === "closed") return value; + return null; +} + +function parseRequirements(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function validateCreateCareer( + body: CreateCareerBody, +): + | { + ok: true; + value: { + title: string; + department: CareerDepartment; + location: CareerLocation; + type: CareerType; + description: string; + requirements: string[]; + salary: string; + status: CareerStatus; + }; + } + | { ok: false; errors: string[] } { + const errors: string[] = []; + + const title = typeof body.title === "string" ? body.title.trim() : ""; + const description = typeof body.description === "string" ? body.description.trim() : ""; + const salary = typeof body.salary === "string" ? body.salary.trim() : ""; + + const department = asDepartment(body.department); + const location = asLocation(body.location); + const type = asType(body.type); + const status = asStatus(body.status ?? "open"); + const requirements = parseRequirements(body.requirements); + + if (title.length < 3) errors.push("title must be at least 3 characters"); + if (!department) errors.push("department must be one of: engineering, design, community, operations, product, growth"); + if (!location) errors.push("location must be one of: remote, hybrid, onsite"); + if (!type) errors.push("type must be one of: full-time, part-time, contract, bounty"); + if (description.length < 10) errors.push("description must be at least 10 characters"); + if (!salary) errors.push("salary is required"); + if (requirements.length === 0) errors.push("requirements must include at least one item"); + if (!status) errors.push("status must be one of: open, closed"); + + if (errors.length > 0 || !department || !location || !type || !status) { + return { ok: false, errors }; } + + return { + ok: true, + value: { + title, + department, + location, + type, + description, + requirements, + salary, + status, + }, + }; +} + +function isApplicationPayload(body: Record): body is SubmitApplicationBody { + return typeof body.roleId === "string" && typeof body.name === "string" && typeof body.email === "string"; +} + +export async function GET(request: NextRequest) { + const department = request.nextUrl.searchParams.get("department"); + const type = request.nextUrl.searchParams.get("type"); + const search = request.nextUrl.searchParams.get("search"); + const status = request.nextUrl.searchParams.get("status"); + + const positions = await getCareers({ department, type, search, status }); + + return NextResponse.json( + { + positions, + total: positions.length, + }, + { + headers: { + "Cache-Control": "no-store", + }, + }, + ); } export async function POST(request: NextRequest) { + let body: Record; + try { - const payload = (await request.json()) as { - roleId?: string; - name?: string; - email?: string; - message?: string; - }; + body = (await request.json()) as Record; + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } - const roleId = payload.roleId?.trim(); - const name = payload.name?.trim(); - const email = payload.email?.trim(); - const message = payload.message?.trim(); + if (isApplicationPayload(body)) { + const roleId = body.roleId.trim(); + const name = body.name.trim(); + const email = body.email.trim(); + const message = typeof body.message === "string" ? body.message.trim() : ""; if (!roleId || !name || !email || !message) { return NextResponse.json( @@ -100,14 +182,18 @@ export async function POST(request: NextRequest) { ); } - const data = await readCareersData(); + const data = await readCareersFile(); const matchedRole = data.positions.find((role) => role.id === roleId); if (!matchedRole) { return NextResponse.json({ error: "Role not found" }, { status: 404 }); } - const application: JobApplication = { + const existingApplications = Array.isArray((data as { applications?: unknown }).applications) + ? ((data as { applications: unknown[] }).applications as CareerApplication[]) + : []; + + const application: CareerApplication = { id: `app_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`, roleId, roleTitle: matchedRole.title, @@ -117,19 +203,31 @@ export async function POST(request: NextRequest) { submittedAt: new Date().toISOString(), }; - const updated: CareersData = { + const updated = { ...data, - applications: [...(data.applications ?? []), application], + applications: [...existingApplications, application], }; - await writeCareersData(updated); + await writeCareersFile(updated); return NextResponse.json({ message: "Application submitted successfully", application, }); - } catch (error) { - console.error("Failed to submit application", error); - return NextResponse.json({ error: "Failed to submit application" }, { status: 500 }); } + + const validation = validateCreateCareer(body as CreateCareerBody); + + if (!validation.ok) { + return NextResponse.json( + { + error: "Validation failed", + details: validation.errors, + }, + { status: 400 }, + ); + } + + const created = await createCareer(validation.value); + return NextResponse.json(created, { status: 201 }); } diff --git a/src/app/careers/page.tsx b/src/app/careers/page.tsx index f1495c3..9a776e9 100644 --- a/src/app/careers/page.tsx +++ b/src/app/careers/page.tsx @@ -23,8 +23,10 @@ type JobListing = { location: JobLocation; description: string; requirements: string[]; - benefits: string[]; - postedAt: string; + benefits?: string[]; + salary: string; + updatedAt: string; + postedAt?: string; status: JobStatus; }; @@ -204,7 +206,7 @@ export default function CareersPage() { "@type": "JobPosting", title: job.title, description: job.description, - datePosted: job.postedAt, + datePosted: job.postedAt ?? job.updatedAt, employmentType: job.type === "full-time" ? "FULL_TIME" @@ -362,9 +364,12 @@ export default function CareersPage() { {job.type} + + Salary: {job.salary} + - Posted {formatDate(job.postedAt)} + Updated {formatDate(job.updatedAt)} @@ -407,12 +412,15 @@ export default function CareersPage() { Benefits
    - {job.benefits.map((benefit) => ( + {(job.benefits ?? []).map((benefit) => (
  • {benefit}
  • ))} + {(job.benefits ?? []).length === 0 && ( +
  • Benefits shared during interview process.
  • + )}
diff --git a/src/lib/careers.ts b/src/lib/careers.ts new file mode 100644 index 0000000..3bd2309 --- /dev/null +++ b/src/lib/careers.ts @@ -0,0 +1,220 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; + +export type CareerDepartment = + | "engineering" + | "design" + | "community" + | "operations" + | "product" + | "growth"; + +export type CareerType = "full-time" | "part-time" | "contract" | "bounty"; +export type CareerStatus = "open" | "closed"; +export type CareerLocation = "remote" | "hybrid" | "onsite"; + +export type CareerPosition = { + id: string; + title: string; + department: CareerDepartment; + location: CareerLocation; + type: CareerType; + description: string; + requirements: string[]; + salary: string; + status: CareerStatus; + updatedAt: string; + benefits?: string[]; + postedAt?: string; +}; + +export type CareersFile = { + positions: CareerPosition[]; + applications?: unknown[]; + [key: string]: unknown; +}; + +type RawCareer = Partial & Record; + +export type CareerFilters = { + department?: string | null; + type?: string | null; + search?: string | null; + status?: string | null; +}; + +export type CreateCareerInput = { + title: string; + department: CareerDepartment; + location: CareerLocation; + type: CareerType; + description: string; + requirements: string[]; + salary: string; + status?: CareerStatus; +}; + +const CAREERS_PATH = path.join(process.cwd(), "data", "careers.json"); + +function toIso(value: unknown): string { + if (typeof value !== "string") return new Date().toISOString(); + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return new Date().toISOString(); + return parsed.toISOString(); +} + +function normalizeRequirements(raw: unknown): string[] { + if (!Array.isArray(raw)) return []; + return raw + .filter((item): item is string => typeof item === "string") + .map((item) => item.trim()) + .filter(Boolean); +} + +function normalizeCareer(raw: RawCareer): CareerPosition | null { + const id = typeof raw.id === "string" ? raw.id.trim() : ""; + const title = typeof raw.title === "string" ? raw.title.trim() : ""; + const description = typeof raw.description === "string" ? raw.description.trim() : ""; + const department = typeof raw.department === "string" ? raw.department.trim().toLowerCase() : ""; + const location = typeof raw.location === "string" ? raw.location.trim().toLowerCase() : ""; + const type = typeof raw.type === "string" ? raw.type.trim().toLowerCase() : ""; + const salary = typeof raw.salary === "string" ? raw.salary.trim() : ""; + const status = typeof raw.status === "string" ? raw.status.trim().toLowerCase() : ""; + + if (!id || !title || !description || !salary) return null; + if ( + department !== "engineering" && + department !== "design" && + department !== "community" && + department !== "operations" && + department !== "product" && + department !== "growth" + ) { + return null; + } + + if (location !== "remote" && location !== "hybrid" && location !== "onsite") return null; + if (type !== "full-time" && type !== "part-time" && type !== "contract" && type !== "bounty") return null; + if (status !== "open" && status !== "closed") return null; + + const requirements = normalizeRequirements(raw.requirements); + + const normalized: CareerPosition = { + id, + title, + department, + location, + type, + description, + requirements, + salary, + status, + updatedAt: toIso(raw.updatedAt), + }; + + if (Array.isArray(raw.benefits)) { + normalized.benefits = raw.benefits + .filter((item): item is string => typeof item === "string") + .map((item) => item.trim()) + .filter(Boolean); + } + + if (typeof raw.postedAt === "string" && raw.postedAt.trim()) { + normalized.postedAt = toIso(raw.postedAt); + } + + return normalized; +} + +function makeCareerId(title: string) { + const slug = title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, "") + .slice(0, 48); + + return `${slug || "career-role"}-${Date.now().toString(36)}`; +} + +export async function readCareersFile(): Promise { + try { + const raw = await fs.readFile(CAREERS_PATH, "utf-8"); + const parsed = JSON.parse(raw) as unknown; + + const rows = + Array.isArray(parsed) + ? parsed + : parsed && typeof parsed === "object" && Array.isArray((parsed as { positions?: unknown }).positions) + ? (parsed as { positions: unknown[] }).positions + : []; + + const positions = rows + .map((row) => normalizeCareer((row ?? {}) as RawCareer)) + .filter((row): row is CareerPosition => Boolean(row)); + + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + const base = parsed as Record; + return { + ...base, + positions, + applications: Array.isArray(base.applications) ? base.applications : [], + }; + } + + return { positions, applications: [] }; + } catch { + return { positions: [], applications: [] }; + } +} + +export async function writeCareersFile(data: CareersFile): Promise { + const dir = path.dirname(CAREERS_PATH); + await fs.mkdir(dir, { recursive: true }); + + const tmp = `${CAREERS_PATH}.tmp`; + await fs.writeFile(tmp, `${JSON.stringify(data, null, 2)}\n`, "utf-8"); + await fs.rename(tmp, CAREERS_PATH); +} + +export async function getCareers(filters: CareerFilters = {}): Promise { + const { positions } = await readCareersFile(); + const department = filters.department?.trim().toLowerCase(); + const type = filters.type?.trim().toLowerCase(); + const search = filters.search?.trim().toLowerCase(); + const status = filters.status?.trim().toLowerCase(); + + return positions.filter((job) => { + const departmentMatch = department ? job.department === department : true; + const typeMatch = type ? job.type === type : true; + const statusMatch = status ? job.status === status : true; + const searchMatch = search + ? [job.title, job.department, job.description, job.salary, ...job.requirements].join(" ").toLowerCase().includes(search) + : true; + + return departmentMatch && typeMatch && statusMatch && searchMatch; + }); +} + +export async function createCareer(input: CreateCareerInput): Promise { + const careers = await readCareersFile(); + const now = new Date().toISOString(); + + const position: CareerPosition = { + id: makeCareerId(input.title), + title: input.title, + department: input.department, + location: input.location, + type: input.type, + description: input.description, + requirements: input.requirements, + salary: input.salary, + status: input.status ?? "open", + updatedAt: now, + postedAt: now, + }; + + const next = [...careers.positions, position]; + await writeCareersFile({ ...careers, positions: next }); + + return position; +}