|
| 1 | +import { NextRequest, NextResponse } from "next/server"; |
| 2 | +import { z } from "zod"; |
| 3 | +import { unstable_cache } from "next/cache"; |
| 4 | +import { formatEmploymentType, formatSalary, extractTags } from "@/lib/utils/careers"; |
| 5 | + |
| 6 | +// Validation schema for individual job response |
| 7 | +const joinJobSchema = z.object({ |
| 8 | + id: z.union([z.string(), z.number()]).transform(val => String(val)), |
| 9 | + title: z.string(), |
| 10 | + department: z.object({ |
| 11 | + name: z.string(), |
| 12 | + }).optional(), |
| 13 | + location: z.object({ |
| 14 | + name: z.string(), |
| 15 | + }).optional(), |
| 16 | + employmentType: z.string().optional(), |
| 17 | + publishedAt: z.string().optional(), |
| 18 | + applyUrl: z.string().optional(), |
| 19 | + seniority: z.object({ |
| 20 | + name: z.string(), |
| 21 | + }).optional(), |
| 22 | + remote: z.boolean().optional(), |
| 23 | + shortDescription: z.string().optional(), |
| 24 | + description: z.string().optional(), |
| 25 | + requirements: z.string().optional(), |
| 26 | + benefits: z.string().optional(), |
| 27 | + salaryMin: z.number().optional(), |
| 28 | + salaryMax: z.number().optional(), |
| 29 | + salaryCurrency: z.string().optional(), |
| 30 | + validThrough: z.string().optional(), |
| 31 | + skills: z.array(z.object({ |
| 32 | + name: z.string(), |
| 33 | + })).optional(), |
| 34 | +}); |
| 35 | + |
| 36 | +// Cached function to fetch individual job from join.com API |
| 37 | +const getCachedJob = unstable_cache( |
| 38 | + async (jobId: string) => { |
| 39 | + const apiKey = process.env.JOIN_API_KEY; |
| 40 | + if (!apiKey) { |
| 41 | + throw new Error("JOIN_API_KEY is not configured"); |
| 42 | + } |
| 43 | + |
| 44 | + const response = await fetch(`https://api.join.com/v2/jobs/${jobId}`, { |
| 45 | + headers: { |
| 46 | + Authorization: apiKey, |
| 47 | + Accept: "application/json", |
| 48 | + }, |
| 49 | + }); |
| 50 | + |
| 51 | + if (response.status === 404) { |
| 52 | + return null; // Job not found |
| 53 | + } |
| 54 | + |
| 55 | + if (!response.ok) { |
| 56 | + const errorText = await response.text(); |
| 57 | + console.error(`Join API error - Status: ${response.status}, Response: ${errorText}`); |
| 58 | + throw new Error(`Join API responded with status: ${response.status}`); |
| 59 | + } |
| 60 | + |
| 61 | + const jobData = await response.json(); |
| 62 | + |
| 63 | + // Validate the response data |
| 64 | + const validationResult = joinJobSchema.safeParse(jobData); |
| 65 | + if (!validationResult.success) { |
| 66 | + console.error("Invalid job response from join.com API:", validationResult.error); |
| 67 | + throw new Error("Invalid job data format from join.com API"); |
| 68 | + } |
| 69 | + |
| 70 | + const validatedJob = validationResult.data; |
| 71 | + return { |
| 72 | + id: validatedJob.id, |
| 73 | + title: validatedJob.title, |
| 74 | + department: validatedJob.department?.name || "Non spécifié", |
| 75 | + location: validatedJob.location?.name || "Remote", |
| 76 | + employmentType: |
| 77 | + formatEmploymentType(validatedJob.employmentType) || "Temps plein", |
| 78 | + datePosted: validatedJob.publishedAt || new Date().toISOString(), |
| 79 | + applyUrl: |
| 80 | + validatedJob.applyUrl || |
| 81 | + `https://join.com/companies/onruntime/jobs/${validatedJob.id}`, |
| 82 | + seniority: validatedJob.seniority?.name || null, |
| 83 | + remote: validatedJob.remote || false, |
| 84 | + shortDescription: validatedJob.shortDescription || "", |
| 85 | + description: validatedJob.description || "", |
| 86 | + requirements: validatedJob.requirements || "", |
| 87 | + benefits: validatedJob.benefits || "", |
| 88 | + salary: |
| 89 | + formatSalary( |
| 90 | + validatedJob.salaryMin, |
| 91 | + validatedJob.salaryMax, |
| 92 | + validatedJob.salaryCurrency, |
| 93 | + ) || null, |
| 94 | + validThrough: validatedJob.validThrough || null, |
| 95 | + tags: extractTags(validatedJob), |
| 96 | + }; |
| 97 | + }, |
| 98 | + ['join-job'], |
| 99 | + { |
| 100 | + revalidate: 300, // 5 minutes cache |
| 101 | + tags: ['careers'], |
| 102 | + } |
| 103 | +); |
| 104 | + |
| 105 | +export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { |
| 106 | + try { |
| 107 | + const { id } = await params; |
| 108 | + |
| 109 | + if (!id) { |
| 110 | + return NextResponse.json( |
| 111 | + { error: "Job ID is required" }, |
| 112 | + { status: 400 }, |
| 113 | + ); |
| 114 | + } |
| 115 | + |
| 116 | + const job = await getCachedJob(id); |
| 117 | + |
| 118 | + if (!job) { |
| 119 | + return NextResponse.json({ error: "Job not found" }, { status: 404 }); |
| 120 | + } |
| 121 | + |
| 122 | + return NextResponse.json({ job }); |
| 123 | + } catch (error) { |
| 124 | + console.error("Error fetching job from Join API:", error); |
| 125 | + |
| 126 | + // Check if this is a network error or API error |
| 127 | + if (error instanceof Error) { |
| 128 | + console.error("Error details:", error.message); |
| 129 | + } |
| 130 | + |
| 131 | + return NextResponse.json( |
| 132 | + { error: "Failed to fetch job details" }, |
| 133 | + { status: 500 }, |
| 134 | + ); |
| 135 | + } |
| 136 | +} |
| 137 | + |
| 138 | + |
0 commit comments