From 640cc825ade9cb700f5b63c8f0e01ba406a326d6 Mon Sep 17 00:00:00 2001 From: AyloRyd Date: Thu, 28 May 2026 23:25:21 +0200 Subject: [PATCH 1/6] feat: add optimizer smoke tests on Finish tab and request logging --- .gitignore | 2 + .../[resumeId]/_components/tabs/FinishTab.tsx | 124 ++++++- .../upload/_components/UploadResumeClient.tsx | 201 +++++++++++ .../resumes/upload/lib/parse-status.ts | 25 ++ src/app/dashboard/resumes/upload/page.tsx | 21 ++ .../actions/optimizer/test/constants.ts | 7 + .../optimizer/test/cover-letter-shared.ts | 65 ++++ .../actions/optimizer/test/cover-letter.ts | 125 +++++++ src/server/actions/optimizer/test/errors.ts | 80 +++++ src/server/actions/optimizer/test/fixtures.ts | 33 ++ src/server/actions/optimizer/test/optimize.ts | 280 ++++++++++++++++ src/server/actions/resume/actions.ts | 30 +- src/server/actions/resume/parse-upload.ts | 254 ++++++++++++++ src/server/api/mutator.ts | 51 ++- src/server/api/optimizer-mutator.ts | 9 +- src/server/lib/request-log.ts | 315 ++++++++++++++++++ 16 files changed, 1593 insertions(+), 29 deletions(-) create mode 100644 src/app/dashboard/resumes/upload/_components/UploadResumeClient.tsx create mode 100644 src/app/dashboard/resumes/upload/lib/parse-status.ts create mode 100644 src/app/dashboard/resumes/upload/page.tsx create mode 100644 src/server/actions/optimizer/test/constants.ts create mode 100644 src/server/actions/optimizer/test/cover-letter-shared.ts create mode 100644 src/server/actions/optimizer/test/cover-letter.ts create mode 100644 src/server/actions/optimizer/test/errors.ts create mode 100644 src/server/actions/optimizer/test/fixtures.ts create mode 100644 src/server/actions/optimizer/test/optimize.ts create mode 100644 src/server/actions/resume/parse-upload.ts create mode 100644 src/server/lib/request-log.ts diff --git a/.gitignore b/.gitignore index edade79..6eb6785 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,5 @@ skills-lock.json .docs .scripts + +.logs \ No newline at end of file diff --git a/src/app/dashboard/resumes/[resumeId]/_components/tabs/FinishTab.tsx b/src/app/dashboard/resumes/[resumeId]/_components/tabs/FinishTab.tsx index d2877b7..cf7cc89 100644 --- a/src/app/dashboard/resumes/[resumeId]/_components/tabs/FinishTab.tsx +++ b/src/app/dashboard/resumes/[resumeId]/_components/tabs/FinishTab.tsx @@ -1,18 +1,77 @@ "use client"; +import { useState, useTransition } from "react"; import { Download, Sparkles, FileText, Target, ArrowRight } from "lucide-react"; + +import { + testCoverLetterAction, + type TestCoverLetterResult, +} from "~/server/actions/optimizer/test/cover-letter"; +import { + testOptimizerAction, + type TestOptimizerResult, +} from "~/server/actions/optimizer/test/optimize"; import type { ResumeContent } from "../resume-content-types"; import { buildPdfPayload } from "../resume-content-types"; -import { useState } from "react"; interface FinishTabProps { content: ResumeContent; resumeId: string; } -export function FinishTab({ content, resumeId: _resumeId }: FinishTabProps) { +function appendLogPath(parts: string[], logPath?: string | null): string { + if (logPath) { + parts.push(`Log: ${logPath}`); + } + return parts.join("\n\n"); +} + +function formatOptimizerResult(result: TestOptimizerResult): string { + if (result.ok) { + const parts = [`OK: optimisation ${result.optimisationId}`]; + if (result.atsScoreSeedSkipped) { + parts.push( + "Note: ATS score seed skipped (RLS); row came from existing pipeline.", + ); + } + if (result.coverLetterPreview) { + parts.push(result.coverLetterPreview); + } + return appendLogPath(parts, result.logPath); + } + const parts = [`[${result.kind}] ${result.error}`]; + if (result.atsScoreSeedSkipped) { + parts.push("ATS score seed was skipped (RLS on ats_scores)."); + } + return appendLogPath(parts, result.logPath); +} + +function formatCoverLetterResult(result: TestCoverLetterResult): string { + if (result.ok && result.outcome === "no_optimisation_in_db") { + return appendLogPath( + [`OK (expected): ${result.message}`], + result.logPath, + ); + } + if (result.ok) { + return appendLogPath( + [ + `OK: cover letter for optimisation ${result.optimisationId}`, + result.coverLetterPreview, + ], + result.logPath, + ); + } + return appendLogPath([`[${result.kind}] ${result.error}`], result.logPath); +} + +export function FinishTab({ content, resumeId }: FinishTabProps) { const [isDownloading, setIsDownloading] = useState(false); const [downloadError, setDownloadError] = useState(null); + const [testStatus, setTestStatus] = useState(null); + const [isOptimizePending, startOptimize] = useTransition(); + const [isCoverLetterPending, startCoverLetter] = useTransition(); + const isTestPending = isOptimizePending || isCoverLetterPending; const handleDownload = async () => { setIsDownloading(true); @@ -42,23 +101,46 @@ export function FinishTab({ content, resumeId: _resumeId }: FinishTabProps) { } }; - const comingSoon = [ + const handleOptimizeTest = () => { + setTestStatus(null); + startOptimize(async () => { + const result = await testOptimizerAction(resumeId); + setTestStatus(formatOptimizerResult(result)); + }); + }; + + const handleCoverLetterTest = () => { + setTestStatus(null); + startCoverLetter(async () => { + const result = await testCoverLetterAction(resumeId); + setTestStatus(formatCoverLetterResult(result)); + }); + }; + + const actions = [ { icon: , label: "Tailor to a specific role", - description: "Optimise your resume for a target job", + description: isOptimizePending + ? "Running pipeline test (up to ~30s)…" + : "Optimise your resume for a target job", + onClick: handleOptimizeTest, + pending: isOptimizePending, }, { icon: , label: "Write cover letter", description: "Generate a cover letter with this resume linked", + onClick: handleCoverLetterTest, + pending: isCoverLetterPending, }, { icon: , label: "Refine with AI", description: "Chat with an AI assistant to improve your resume", + disabled: true, }, - ]; + ] as const; return (
@@ -90,30 +172,42 @@ export function FinishTab({ content, resumeId: _resumeId }: FinishTabProps) { Continue editing

- {comingSoon.map((item) => ( + {actions.map((item) => ( ))} + + {testStatus ? ( +
+          {testStatus}
+        
+ ) : null}
); } diff --git a/src/app/dashboard/resumes/upload/_components/UploadResumeClient.tsx b/src/app/dashboard/resumes/upload/_components/UploadResumeClient.tsx new file mode 100644 index 0000000..0abc5bf --- /dev/null +++ b/src/app/dashboard/resumes/upload/_components/UploadResumeClient.tsx @@ -0,0 +1,201 @@ +"use client"; + +import { useCallback, useRef, useState, useTransition } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { AlertCircle, FileUp, Loader2, Upload } from "lucide-react"; + +import { Button } from "~/components/ui/button"; +import { cn } from "~/lib/utils"; +import { parseUploadedResumeAction } from "~/server/actions/resume/parse-upload"; + +const ACCEPT = ".pdf,.docx,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + +type UploadPhase = "idle" | "parsing" | "error"; + +export function UploadResumeClient() { + const router = useRouter(); + const inputRef = useRef(null); + const [phase, setPhase] = useState("idle"); + const [isDragging, setIsDragging] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [isPending, startTransition] = useTransition(); + + const isBusy = phase === "parsing" || isPending; + + const pickFile = useCallback((file: File | null) => { + if (!file) return; + setSelectedFile(file); + setError(null); + setNotice(null); + }, []); + + const handleUpload = useCallback(() => { + if (!selectedFile || isBusy) return; + + setError(null); + setNotice(null); + setPhase("parsing"); + + const formData = new FormData(); + formData.append("file", selectedFile); + + startTransition(async () => { + const result = await parseUploadedResumeAction(formData); + + if (!result.ok) { + setPhase("error"); + setError(result.error); + return; + } + + if (result.partialParse || (result.warnings?.length ?? 0) > 0) { + const warningText = result.warnings?.join(" ") ?? "Some sections may be incomplete."; + setNotice( + result.partialParse + ? `Partial parse: ${warningText}` + : warningText, + ); + } + + router.push(`/dashboard/resumes/${result.resumeId}/choose-template`); + }); + }, [isBusy, router, selectedFile]); + + const onDrop = useCallback( + (event: React.DragEvent) => { + event.preventDefault(); + setIsDragging(false); + if (isBusy) return; + const file = event.dataTransfer.files.item(0); + pickFile(file); + }, + [isBusy, pickFile], + ); + + return ( +
+
+

+ Import resume +

+

+ Upload your resume +

+

+ Upload a PDF or DOCX file. We'll parse it automatically, then you + can choose a template and preview the result. +

+
+ +
+
{ + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + if (!isBusy) inputRef.current?.click(); + } + }} + onDragEnter={(event) => { + event.preventDefault(); + if (!isBusy) setIsDragging(true); + }} + onDragLeave={(event) => { + event.preventDefault(); + setIsDragging(false); + }} + onDragOver={(event) => event.preventDefault()} + onDrop={onDrop} + onClick={() => { + if (!isBusy) inputRef.current?.click(); + }} + className={cn( + "flex cursor-pointer flex-col items-center justify-center gap-3 rounded-2xl border border-dashed px-6 py-12 text-center transition-colors", + isDragging + ? "border-violet-500/50 bg-violet-500/10" + : "border-white/12 bg-white/2 hover:border-white/20 hover:bg-white/4", + isBusy && "pointer-events-none opacity-70", + )} + > + + {isBusy ? ( + + ) : ( + + )} + +
+

+ {isBusy + ? "Parsing your resume…" + : selectedFile + ? selectedFile.name + : "Drop your file here or click to browse"} +

+

+ {isBusy + ? "This may take up to a minute for complex documents." + : "PDF or DOCX · max 10 MB"} +

+
+ pickFile(event.target.files?.item(0) ?? null)} + /> +
+ + {error ? ( +
+ +

{error}

+
+ ) : null} + + {notice ? ( +
+ {notice} +
+ ) : null} + +
+ + + +
+
+
+ ); +} diff --git a/src/app/dashboard/resumes/upload/lib/parse-status.ts b/src/app/dashboard/resumes/upload/lib/parse-status.ts new file mode 100644 index 0000000..e88ef23 --- /dev/null +++ b/src/app/dashboard/resumes/upload/lib/parse-status.ts @@ -0,0 +1,25 @@ +export function isResumeContentReady(content: unknown): boolean { + if (!content || typeof content !== "object" || Array.isArray(content)) { + return false; + } + + const record = content as Record; + + const contact = record.contact; + if (contact && typeof contact === "object" && !Array.isArray(contact)) { + const name = (contact as Record).name; + if (typeof name === "string" && name.trim().length > 0) { + return true; + } + } + + if (Array.isArray(record.experience) && record.experience.length > 0) { + return true; + } + + if (typeof record.summary === "string" && record.summary.trim().length > 0) { + return true; + } + + return false; +} diff --git a/src/app/dashboard/resumes/upload/page.tsx b/src/app/dashboard/resumes/upload/page.tsx new file mode 100644 index 0000000..7748c00 --- /dev/null +++ b/src/app/dashboard/resumes/upload/page.tsx @@ -0,0 +1,21 @@ +import { type Metadata } from "next"; +import { redirect } from "next/navigation"; + +import { DashboardBackground } from "~/app/dashboard/_components/DashboardBackground"; +import { DashboardPageFill } from "~/app/dashboard/_components/DashboardPageFill"; +import { getUserId } from "~/lib/auth"; +import { UploadResumeClient } from "./_components/UploadResumeClient"; + +export const metadata: Metadata = { title: "Upload Resume" }; + +export default async function UploadResumePage() { + const userId = await getUserId().catch(() => null); + if (!userId) redirect("/sign-in"); + + return ( + + + + + ); +} diff --git a/src/server/actions/optimizer/test/constants.ts b/src/server/actions/optimizer/test/constants.ts new file mode 100644 index 0000000..41babb5 --- /dev/null +++ b/src/server/actions/optimizer/test/constants.ts @@ -0,0 +1,7 @@ +/** Per-request timeout so smoke tests cannot hang on a dead service. */ +export const TEST_HTTP_TIMEOUT_MS = 20_000; + +/** Poll for async pipeline only when ATS seed succeeded. */ +export const PIPELINE_POLL_TIMEOUT_MS = 30_000; + +export const testHttpOptions = { timeout: TEST_HTTP_TIMEOUT_MS }; diff --git a/src/server/actions/optimizer/test/cover-letter-shared.ts b/src/server/actions/optimizer/test/cover-letter-shared.ts new file mode 100644 index 0000000..26c2a05 --- /dev/null +++ b/src/server/actions/optimizer/test/cover-letter-shared.ts @@ -0,0 +1,65 @@ +import { + getApiOptimisationsOptimisationIdCoverLetter, + postApiOptimisationsOptimisationIdCoverLetterGenerate, +} from "~/server/api/generated/optimizer/optimizer"; +import { getOptimizations } from "~/server/api/generated/optimizations/optimizations"; +import type { Optimizations } from "~/server/api/generated/schemas/optimizations"; + +import { testHttpOptions } from "./constants"; + +export function extractLatestOptimization(rows: unknown): Optimizations | null { + if (!Array.isArray(rows) || rows.length === 0) { + return null; + } + const first: unknown = rows[0]; + if (!first || typeof first !== "object" || !("id" in first)) { + return null; + } + const id = (first as Optimizations).id; + return typeof id === "string" && id.length > 0 ? (first as Optimizations) : null; +} + +export async function fetchLatestOptimizationForResume( + resumeId: string, +): Promise { + const rows = await getOptimizations( + { + resume_id: `eq.${resumeId}`, + order: "created_at.desc", + limit: "1", + }, + testHttpOptions, + ); + return extractLatestOptimization(rows); +} + +export async function runCoverLetterGenerate( + optimisationId: string, + addStep: (name: string, data?: unknown) => void, +): Promise<{ coverLetterPreview: string }> { + const generated = + await postApiOptimisationsOptimisationIdCoverLetterGenerate( + optimisationId, + {}, + testHttpOptions, + ); + addStep("optimizer.cover_letter.generate", { + wordCount: generated.wordCount, + salutationUsed: generated.salutationUsed, + }); + + const saved = await getApiOptimisationsOptimisationIdCoverLetter( + optimisationId, + testHttpOptions, + ); + addStep("optimizer.cover_letter.get", { + wordCount: saved.wordCount, + }); + + const preview = + saved.coverLetter.length > 200 + ? `${saved.coverLetter.slice(0, 200)}…` + : saved.coverLetter; + + return { coverLetterPreview: preview }; +} diff --git a/src/server/actions/optimizer/test/cover-letter.ts b/src/server/actions/optimizer/test/cover-letter.ts new file mode 100644 index 0000000..ac495a7 --- /dev/null +++ b/src/server/actions/optimizer/test/cover-letter.ts @@ -0,0 +1,125 @@ +"use server"; + +import { getUserId } from "~/lib/auth"; +import { getResumeAction } from "~/server/actions/resume/actions"; +import { + createRequestLog, + serializeAxiosError, +} from "~/server/lib/request-log"; +import { + fetchLatestOptimizationForResume, + runCoverLetterGenerate, +} from "./cover-letter-shared"; +import { classifyRequestError } from "./errors"; +import type { OptimizerFailureKind } from "./errors"; + +export type TestCoverLetterResult = + | { + ok: true; + outcome: "cover_letter_generated"; + optimisationId: string; + coverLetterPreview: string; + logPath?: string | null; + } + | { + ok: true; + outcome: "no_optimisation_in_db"; + message: string; + logPath?: string | null; + } + | { + ok: false; + kind: OptimizerFailureKind; + error: string; + logPath?: string | null; + }; + +/** Cover-letter API only: verifies DB row exists, then calls optimizer (no env overrides). */ +export async function testCoverLetterAction( + resumeId: string, +): Promise { + const { addStep, finish } = createRequestLog("cover-letter-test"); + let result: TestCoverLetterResult = { + ok: false, + kind: "unknown", + error: "Cover letter test did not run.", + }; + + try { + const userId = await getUserId(); + addStep("action.start", { userId, resumeId }); + + const resume = await getResumeAction(resumeId); + if (!resume) { + result = { + ok: false, + kind: "resume_not_found", + error: "Resume not found for current user.", + }; + addStep("resume.missing", result); + } else { + addStep("resume.found", { resumeId: resume.id }); + + const optimization = await fetchLatestOptimizationForResume(resumeId); + addStep("optimisation.lookup", { + found: Boolean(optimization), + optimizationId: optimization?.id ?? null, + }); + + if (!optimization) { + result = { + ok: true, + outcome: "no_optimisation_in_db", + message: + "No row in optimizations for this resume — optimizer API was not called (expected for cover-letter-only test without pipeline).", + }; + addStep("optimisation.missing_expected", result); + } else { + try { + const { coverLetterPreview } = await runCoverLetterGenerate( + optimization.id, + addStep, + ); + result = { + ok: true, + outcome: "cover_letter_generated", + optimisationId: optimization.id, + coverLetterPreview, + }; + addStep("action.success", result); + } catch (error) { + const classified = classifyRequestError(error); + result = { + ok: false, + kind: classified.kind, + error: classified.message, + }; + addStep("optimizer.cover_letter.error", { + ...classified, + axios: serializeAxiosError(error), + }); + } + } + } + } catch (e) { + const classified = classifyRequestError(e); + console.error("[testCoverLetterAction]", e); + result = { + ok: false, + kind: classified.kind, + error: classified.message, + }; + addStep("action.error", { + ...classified, + axios: serializeAxiosError(e), + }); + } finally { + const logPath = await finish(result); + if (logPath) { + result = { ...result, logPath }; + console.info(`[cover-letter-test] log written: ${logPath}`); + } + } + + return result; +} diff --git a/src/server/actions/optimizer/test/errors.ts b/src/server/actions/optimizer/test/errors.ts new file mode 100644 index 0000000..a43d1ea --- /dev/null +++ b/src/server/actions/optimizer/test/errors.ts @@ -0,0 +1,80 @@ +import axios from "axios"; + +export type OptimizerFailureKind = + | "resume_not_found" + | "no_optimisation_in_db" + | "pipeline_timeout" + | "auth" + | "optimizer_api" + | "core_api" + | "unknown"; + +export function classifyRequestError(error: unknown): { + kind: OptimizerFailureKind; + message: string; + status?: number; +} { + if ((error as Error).message === "Unauthorized") { + return { kind: "auth", message: "Unauthorized (Clerk session)." }; + } + + if (!axios.isAxiosError(error)) { + return { + kind: "unknown", + message: error instanceof Error ? error.message : String(error), + }; + } + + const status = error.response?.status; + const responseData: unknown = error.response?.data; + let detail: string | null = null; + if ( + responseData && + typeof responseData === "object" && + "message" in responseData + ) { + const message = (responseData as Record).message; + if (typeof message === "string") { + detail = message; + } + } + + if (status === 401 || status === 403) { + const isRls = + typeof detail === "string" && + detail.toLowerCase().includes("row-level security"); + return { + kind: "auth", + status, + message: isRls + ? `Forbidden (${status}): ${detail}` + : `Auth or access denied (${status})${detail ? `: ${detail}` : ""}.`, + }; + } + + if (status === 404) { + return { + kind: "optimizer_api", + status, + message: `Optimizer returned 404${detail ? `: ${detail}` : " (optimisation not found in service)."}`, + }; + } + + const url = error.config?.url ?? ""; + const isOptimizer = url.includes("/optimisations"); + const isCore = !isOptimizer && url.length > 0; + + if (status !== undefined && status >= 400) { + return { + kind: isOptimizer ? "optimizer_api" : isCore ? "core_api" : "unknown", + status, + message: detail ?? error.message, + }; + } + + return { + kind: "unknown", + status, + message: error.message, + }; +} diff --git a/src/server/actions/optimizer/test/fixtures.ts b/src/server/actions/optimizer/test/fixtures.ts new file mode 100644 index 0000000..66e705d --- /dev/null +++ b/src/server/actions/optimizer/test/fixtures.ts @@ -0,0 +1,33 @@ +import type { JobPostingContent } from "~/server/api/generated/parser/schemas"; + +/** Artificial job posting for optimizer pipeline smoke tests (no UI input). */ +export const ARTIFICIAL_JOB_POSTING_CONTENT: JobPostingContent = { + title: "Senior Backend Engineer", + company: "Acme GmbH", + location: "Berlin, Germany", + work_mode: "hybrid", + employment_type: "full-time", + seniority: "senior", + summary: + "We are looking for a senior backend engineer to design distributed systems in Rust and TypeScript.", + responsibilities: [ + "Design and operate event-driven microservices", + "Improve ingestion pipeline throughput and reliability", + "Mentor engineers on async patterns and observability", + ], + requirements: [ + "5+ years backend experience", + "Rust or Go and PostgreSQL", + "Kafka or similar message brokers", + "CI/CD and production operations", + ], + preferred_requirements: ["Kubernetes", "Prometheus", "Open source contributions"], + skills: ["Rust", "TypeScript", "PostgreSQL", "Kafka", "Docker", "Kubernetes"], + raw_text: `Senior Backend Engineer — Acme GmbH (Berlin, hybrid) + +We are looking for a senior backend engineer to design distributed systems in Rust and TypeScript. + +Requirements: 5+ years backend experience, Rust or Go, PostgreSQL, Kafka, CI/CD. + +Nice to have: Kubernetes, Prometheus.`, +}; diff --git a/src/server/actions/optimizer/test/optimize.ts b/src/server/actions/optimizer/test/optimize.ts new file mode 100644 index 0000000..80878c1 --- /dev/null +++ b/src/server/actions/optimizer/test/optimize.ts @@ -0,0 +1,280 @@ +"use server"; + +import axios from "axios"; + +import { getUserId } from "~/lib/auth"; +import { postAtsScores } from "~/server/api/generated/ats-scores/ats-scores"; +import { postJobPostings } from "~/server/api/generated/job-postings/job-postings"; +import { getOptimizations } from "~/server/api/generated/optimizations/optimizations"; +import type { JobPostings } from "~/server/api/generated/schemas"; +import type { Optimizations } from "~/server/api/generated/schemas/optimizations"; +import { getResumeAction } from "~/server/actions/resume/actions"; +import { + createRequestLog, + serializeAxiosError, +} from "~/server/lib/request-log"; +import { + extractLatestOptimization, + fetchLatestOptimizationForResume, + runCoverLetterGenerate, +} from "./cover-letter-shared"; +import { ARTIFICIAL_JOB_POSTING_CONTENT } from "./fixtures"; +import { + PIPELINE_POLL_TIMEOUT_MS, + testHttpOptions, +} from "./constants"; +import { classifyRequestError } from "./errors"; +import type { OptimizerFailureKind } from "./errors"; + +const POLL_INITIAL_DELAY_MS = 500; +const POLL_MAX_DELAY_MS = 1_500; + +export type TestOptimizerResult = + | { + ok: true; + optimisationId: string; + coverLetterPreview?: string; + atsScoreSeedSkipped?: boolean; + logPath?: string | null; + } + | { + ok: false; + kind: OptimizerFailureKind; + error: string; + atsScoreSeedSkipped?: boolean; + logPath?: string | null; + }; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function extractJobPostingId(data: unknown): string | null { + if (Array.isArray(data) && data.length > 0) { + const first: unknown = data[0]; + if (first && typeof first === "object" && "id" in first) { + const id = (first as JobPostings).id; + return typeof id === "string" && id.length > 0 ? id : null; + } + } + if (data && typeof data === "object" && "id" in data) { + const id = (data as JobPostings).id; + return typeof id === "string" && id.length > 0 ? id : null; + } + return null; +} + +async function pollForOptimization( + resumeId: string, + addStep: (name: string, data?: unknown) => void, +): Promise { + const deadline = Date.now() + PIPELINE_POLL_TIMEOUT_MS; + let delayMs = POLL_INITIAL_DELAY_MS; + let attempt = 0; + + while (Date.now() < deadline) { + attempt += 1; + const rows = await getOptimizations( + { + resume_id: `eq.${resumeId}`, + order: "created_at.desc", + limit: "1", + }, + testHttpOptions, + ); + const row = extractLatestOptimization(rows); + addStep("poll.optimizations", { + attempt, + found: Boolean(row), + optimizationId: row?.id ?? null, + jobPostingId: row?.job_posting_id ?? null, + atsScoreId: row?.ats_score_id ?? null, + }); + if (row) { + return row; + } + await sleep(delayMs); + delayMs = Math.min(Math.round(delayMs * 1.25), POLL_MAX_DELAY_MS); + } + + return null; +} + +async function trySeedAtsScore( + resumeId: string, + jobPostingId: string, + addStep: (name: string, data?: unknown) => void, +): Promise<{ skipped: boolean }> { + try { + const atsResponse = await postAtsScores( + undefined, + { + data: { + resume_id: resumeId, + job_posting_id: jobPostingId, + score: 72, + analysis: { + source: "optimizer-smoke-test", + note: "Artificial ATS row to exercise event pipeline", + }, + }, + headers: { + Prefer: "return=representation", + }, + validateStatus: (status) => status === 201 || status === 200, + ...testHttpOptions, + }, + ); + addStep("ats_score.created", { atsResponse }); + return { skipped: false }; + } catch (error) { + const classified = classifyRequestError(error); + addStep("ats_score.seed_failed", { + ...classified, + axios: serializeAxiosError(error), + }); + if ( + axios.isAxiosError(error) && + error.response?.status === 403 && + classified.kind === "auth" + ) { + addStep("ats_score.seed_skipped", { + reason: "RLS blocks client INSERT on ats_scores; continuing poll only", + }); + return { skipped: true }; + } + throw error; + } +} + +export async function testOptimizerAction( + resumeId: string, +): Promise { + const { addStep, finish } = createRequestLog("optimizer-test"); + let result: TestOptimizerResult = { + ok: false, + kind: "unknown", + error: "Optimizer test did not run.", + }; + let atsScoreSeedSkipped = false; + + try { + const userId = await getUserId(); + addStep("action.start", { userId, resumeId }); + + const resume = await getResumeAction(resumeId); + if (!resume) { + result = { + ok: false, + kind: "resume_not_found", + error: "Resume not found for current user.", + }; + addStep("resume.missing", result); + } else { + addStep("resume.found", { resumeId: resume.id }); + + const jobResponse = await postJobPostings( + undefined, + { + data: { + user_id: userId, + content: ARTIFICIAL_JOB_POSTING_CONTENT, + }, + headers: { + Prefer: "return=representation", + }, + validateStatus: (status) => status === 201 || status === 200, + ...testHttpOptions, + }, + ); + const jobPostingId = extractJobPostingId(jobResponse); + if (!jobPostingId) { + result = { + ok: false, + kind: "core_api", + error: "Core API did not return a job posting id.", + }; + addStep("job_posting.missing_id", { jobResponse }); + } else { + addStep("job_posting.created", { jobPostingId }); + + addStep("ats_score.trigger", { method: "postAtsScores" }); + const seed = await trySeedAtsScore(resumeId, jobPostingId, addStep); + atsScoreSeedSkipped = seed.skipped; + + let optimization = await fetchLatestOptimizationForResume(resumeId); + if (optimization) { + addStep("optimisation.already_present", { + optimizationId: optimization.id, + }); + } else if (atsScoreSeedSkipped) { + addStep("poll.skipped", { + reason: + "ATS seed failed (RLS); async pipeline will not run — not polling.", + }); + } else { + optimization = await pollForOptimization(resumeId, addStep); + } + + if (optimization) { + try { + const { coverLetterPreview } = await runCoverLetterGenerate( + optimization.id, + addStep, + ); + result = { + ok: true, + optimisationId: optimization.id, + coverLetterPreview, + atsScoreSeedSkipped, + }; + addStep("action.success", result); + } catch (error) { + const classified = classifyRequestError(error); + result = { + ok: false, + kind: classified.kind, + error: classified.message, + atsScoreSeedSkipped, + }; + addStep("optimizer.cover_letter.error", { + ...classified, + axios: serializeAxiosError(error), + }); + } + } else { + result = { + ok: false, + kind: "pipeline_timeout", + error: atsScoreSeedSkipped + ? "Pipeline cannot start: ATS score seed blocked by RLS and no existing optimisation row. Use backend ats_score_requests or fix RLS." + : `No optimization row within ${PIPELINE_POLL_TIMEOUT_MS / 1000}s after seeding job and ATS score.`, + atsScoreSeedSkipped, + }; + addStep("poll.timeout", result); + } + } + } + } catch (e) { + const classified = classifyRequestError(e); + console.error("[testOptimizerAction]", e); + result = { + ok: false, + kind: classified.kind, + error: classified.message, + atsScoreSeedSkipped, + }; + addStep("action.error", { + ...classified, + axios: serializeAxiosError(e), + }); + } finally { + const logPath = await finish(result); + if (logPath) { + result = { ...result, logPath }; + console.info(`[optimizer-test] log written: ${logPath}`); + } + } + + return result; +} diff --git a/src/server/actions/resume/actions.ts b/src/server/actions/resume/actions.ts index 6ac3459..1f8f9f3 100644 --- a/src/server/actions/resume/actions.ts +++ b/src/server/actions/resume/actions.ts @@ -8,15 +8,41 @@ import { getResumes, patchResumes, } from "~/server/api/generated/resumes/resumes"; +import { WIZARD_SESSION_CONTENT_KEY } from "~/app/dashboard/resumes/wizard/lib/resume-constants"; import type { Resumes } from "~/server/api/generated/schemas"; import type { ResumeListItem } from "./types"; +type ResumeRow = Resumes & { + input_method?: string | null; + parse_status?: string | null; +}; + +function inferInputMethod(row: ResumeRow): string | null { + if (row.input_method) { + return row.input_method; + } + + const content = row.content; + if (!content || typeof content !== "object" || Array.isArray(content)) { + return null; + } + + const record = content as Record; + if (typeof record[WIZARD_SESSION_CONTENT_KEY] === "string") { + return "wizard"; + } + + return "upload"; +} + function mapRowToListItem(row: Resumes): ResumeListItem { + const extended = row as ResumeRow; + return { id: row.id, content: row.content, - inputMethod: "wizard", - parseStatus: "completed", + inputMethod: inferInputMethod(extended), + parseStatus: extended.parse_status ?? null, createdAt: new Date(row.created_at), updatedAt: new Date(row.updated_at), }; diff --git a/src/server/actions/resume/parse-upload.ts b/src/server/actions/resume/parse-upload.ts new file mode 100644 index 0000000..3e9b5f8 --- /dev/null +++ b/src/server/actions/resume/parse-upload.ts @@ -0,0 +1,254 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { getUserId } from "~/lib/auth"; +import { isResumeContentReady } from "~/app/dashboard/resumes/upload/lib/parse-status"; +import { env } from "~/env"; +import type { ParseResumeResponse } from "~/server/api/generated/parser/schemas"; +import { parseResumeResumesParsePost } from "~/server/api/generated/parser/parser"; +import { + createRequestLog, + serializeAxiosError, +} from "~/server/lib/request-log"; +import { getResumeAction } from "./actions"; + +const MAX_FILE_BYTES = 10 * 1024 * 1024; +const POLL_TIMEOUT_MS = 90_000; +const POLL_INITIAL_DELAY_MS = 500; +const POLL_MAX_DELAY_MS = 1_500; + +export type ParseUploadResult = + | { + ok: true; + resumeId: string; + warnings?: string[]; + partialParse?: boolean; + } + | { ok: false; error: string }; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function getFileExtension(name: string): string { + const dot = name.lastIndexOf("."); + return dot >= 0 ? name.slice(dot).toLowerCase() : ""; +} + +function validateUploadFile(file: unknown): File | { error: string } { + if (!(file instanceof File)) { + return { error: "Please select a PDF or DOCX file." }; + } + + if (file.size === 0) { + return { error: "The selected file is empty." }; + } + + if (file.size > MAX_FILE_BYTES) { + return { error: "File must be 10 MB or smaller." }; + } + + const ext = getFileExtension(file.name); + const allowedExtensions = new Set([".pdf", ".docx"]); + const allowedMime = new Set([ + "application/pdf", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ]); + + if (!allowedExtensions.has(ext) && !allowedMime.has(file.type)) { + return { error: "Only PDF and DOCX files are supported." }; + } + + return file; +} + +function getParserErrorMessage(error: unknown): string { + const responseData = + error && typeof error === "object" && "response" in error + ? (error as { response?: { data?: unknown } }).response?.data + : undefined; + + if ( + responseData && + typeof responseData === "object" && + !Array.isArray(responseData) + ) { + const detail = (responseData as Record).detail; + if (typeof detail === "string") { + return detail; + } + if (Array.isArray(detail) && detail.length > 0) { + const first: unknown = detail[0]; + if ( + first && + typeof first === "object" && + "msg" in first && + typeof (first as Record).msg === "string" + ) { + return (first as Record).msg as string; + } + } + } + + return error instanceof Error + ? error.message + : "Failed to parse resume. Try again."; +} + +async function callParseResumeApi( + file: File, + addStep: (name: string, data?: unknown) => void, +): Promise { + try { + const response = await parseResumeResumesParsePost({ file }); + addStep("parser.response.success", response); + return response; + } catch (error) { + addStep("parser.response.error", serializeAxiosError(error)); + throw error; + } +} + +async function waitForParsedResume( + resumeId: string, + initialParseStatus: string, + addStep: (name: string, data?: unknown) => void, +): Promise<{ ready: boolean; error?: string }> { + const failed = initialParseStatus.toLowerCase(); + if (failed === "failed" || failed === "error") { + return { ready: false, error: "Resume parsing failed. Try another file." }; + } + + const deadline = Date.now() + POLL_TIMEOUT_MS; + let delayMs = POLL_INITIAL_DELAY_MS; + let attempt = 0; + + while (Date.now() < deadline) { + attempt += 1; + const resume = await getResumeAction(resumeId); + const ready = Boolean(resume && isResumeContentReady(resume.content)); + + addStep("poll.resume", { + attempt, + resumeId, + ready, + parseStatus: resume?.parseStatus ?? null, + contentReady: ready, + contentPreview: + resume?.content && typeof resume.content === "object" + ? { + hasContact: Boolean( + (resume.content as Record).contact, + ), + experienceCount: Array.isArray( + (resume.content as Record).experience, + ) + ? ( + (resume.content as Record) + .experience as unknown[] + ).length + : 0, + } + : null, + }); + + if (ready) { + return { ready: true }; + } + + await sleep(delayMs); + delayMs = Math.min(Math.round(delayMs * 1.25), POLL_MAX_DELAY_MS); + } + + return { + ready: false, + error: "Parsing is taking longer than expected. Please try again.", + }; +} + +export async function parseUploadedResumeAction( + formData: FormData, +): Promise { + const { addStep, finish } = createRequestLog("parse-upload"); + let result: ParseUploadResult | undefined; + + try { + const userId = await getUserId(); + addStep("action.start", { + userId, + formDataKeys: [...formData.keys()], + backendApiBaseUrl: env.BACKEND_API_BASE_URL, + }); + + const validated = validateUploadFile(formData.get("file")); + if (!(validated instanceof File)) { + result = { ok: false, error: validated.error }; + addStep("validation.failed", { error: validated.error }); + return result; + } + + addStep("validation.passed", { + name: validated.name, + size: validated.size, + type: validated.type, + }); + + const parseResponse = await callParseResumeApi(validated, addStep); + const resumeId = parseResponse.resume_id?.trim(); + if (!resumeId) { + result = { ok: false, error: "Parser did not return a resume id." }; + addStep("parser.missing_resume_id", { parseResponse }); + return result; + } + + addStep("parser.resume_id", { resumeId }); + + const poll = await waitForParsedResume( + resumeId, + parseResponse.parse_status ?? "", + addStep, + ); + if (!poll.ready) { + result = { + ok: false, + error: poll.error ?? "Failed to load parsed resume.", + }; + addStep("poll.failed", result); + return result; + } + + revalidatePath("/dashboard/resumes"); + revalidatePath(`/dashboard/resumes/${resumeId}`); + + result = { + ok: true, + resumeId, + warnings: parseResponse.warnings, + partialParse: parseResponse.partial_parse, + }; + addStep("action.success", result); + return result; + } catch (e) { + if ((e as Error).message === "Unauthorized") { + result = { ok: false, error: "Unauthorized" }; + addStep("action.unauthorized", result); + return result; + } + + console.error("[parseUploadedResumeAction]", e); + result = { + ok: false, + error: getParserErrorMessage(e), + }; + addStep("action.error", { + message: getParserErrorMessage(e), + axios: serializeAxiosError(e), + }); + return result; + } finally { + const logPath = await finish(result); + if (logPath) { + console.info(`[parse-upload] log written: ${logPath}`); + } + } +} diff --git a/src/server/api/mutator.ts b/src/server/api/mutator.ts index f35d2a5..385a9f0 100644 --- a/src/server/api/mutator.ts +++ b/src/server/api/mutator.ts @@ -2,6 +2,7 @@ import axios, { type AxiosInstance, type AxiosRequestConfig } from "axios"; import { auth } from "@clerk/nextjs/server"; import { env } from "~/env"; +import { logApiRequestOutcome } from "~/server/lib/request-log"; type MutatorOptions = { basePath: string; @@ -27,13 +28,12 @@ function getAxios(options: MutatorOptions): AxiosInstance { const instance = axios.create({ baseURL, headers: { - "Content-Type": "application/json", Accept: "application/json", }, }); instance.interceptors.request.use(async (config) => { - const { getToken } = await auth(); + const { userId, getToken } = await auth(); const token = await getToken(); if (token) { config.headers.Authorization = `Bearer ${token}`; @@ -43,6 +43,9 @@ function getAxios(options: MutatorOptions): AxiosInstance { url: config.url, }); } + if (userId) { + config.headers["X-User-Id"] = userId; + } return config; }); @@ -51,7 +54,7 @@ function getAxios(options: MutatorOptions): AxiosInstance { (error: unknown) => { if (axios.isAxiosError(error)) { const responseData: unknown = error.response?.data as unknown; - console.error(`[${options.logPrefix}] Request failed`, { + console.warn(`[${options.logPrefix}] Request failed`, { method: error.config?.method?.toUpperCase(), url: error.config?.url, status: error.response?.status, @@ -76,14 +79,42 @@ export function createMutator(options: MutatorOptions) { extraOptions?: AxiosRequestConfig, ): Promise { const instance = getAxios(options); - const response = await instance({ + const headers: AxiosRequestConfig["headers"] = { + ...config.headers, + ...extraOptions?.headers, + }; + + if (config.data instanceof FormData && headers) { + delete (headers as Record)["Content-Type"]; + delete (headers as Record)["content-type"]; + } + + const requestConfig: AxiosRequestConfig = { ...config, ...extraOptions, - headers: { - ...config.headers, - ...extraOptions?.headers, - }, - }); - return response.data as T; + headers, + }; + + const startedAtMs = Date.now(); + + try { + const response = await instance(requestConfig); + await logApiRequestOutcome({ + scope: options.logPrefix, + config: requestConfig, + mergedHeaders: response.config.headers, + startedAtMs, + response, + }); + return response.data as T; + } catch (error) { + await logApiRequestOutcome({ + scope: options.logPrefix, + config: requestConfig, + startedAtMs, + error, + }); + throw error; + } }; } diff --git a/src/server/api/optimizer-mutator.ts b/src/server/api/optimizer-mutator.ts index 0d71c92..d5d006c 100644 --- a/src/server/api/optimizer-mutator.ts +++ b/src/server/api/optimizer-mutator.ts @@ -2,7 +2,7 @@ import { createMutator } from "./mutator"; import type { AxiosRequestConfig } from "axios"; const optimizerRequestMutator = createMutator({ - basePath: "/api/v1/optimizer", + basePath: "/api/v1", logPrefix: "optimizer-api", fallbackErrorMessage: "Optimizer API request failed", }); @@ -11,5 +11,10 @@ export async function optimizerMutator( config: AxiosRequestConfig, extraOptions?: AxiosRequestConfig, ): Promise { - return optimizerRequestMutator(config, extraOptions); + const url = + typeof config.url === "string" + ? config.url.replace(/^\/api/, "") + : config.url; + + return optimizerRequestMutator({ ...config, url }, extraOptions); } diff --git a/src/server/lib/request-log.ts b/src/server/lib/request-log.ts new file mode 100644 index 0000000..2a37b82 --- /dev/null +++ b/src/server/lib/request-log.ts @@ -0,0 +1,315 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import axios, { type AxiosRequestConfig, type AxiosResponse } from "axios"; + +const LOG_DIR = path.join(process.cwd(), ".logs"); + +export type LogStep = { + at: string; + name: string; + data?: unknown; +}; + +export type RequestLog = { + logId: string; + scope: string; + startedAt: string; + finishedAt?: string; + durationMs?: number; + result?: unknown; + error?: unknown; + steps: LogStep[]; +}; + +export type ApiRequestLog = { + logId: string; + scope: string; + startedAt: string; + finishedAt: string; + durationMs: number; + request: { + method?: string; + baseURL?: string; + url?: string; + headers?: Record | null; + params?: unknown; + data?: unknown; + }; + response?: { + status?: number; + statusText?: string; + headers?: Record | null; + data?: unknown; + }; + error?: Record; +}; + +function isProductionRuntime(): boolean { + return ( + process.env.NODE_ENV === "production" || + process.env.VERCEL_ENV === "production" + ); +} + +export function isRequestLoggingEnabled(): boolean { + if (isProductionRuntime()) { + return false; + } + if (process.env.API_REQUEST_LOGS === "true") { + return true; + } + return process.env.NODE_ENV === "development"; +} + +export function createRequestLog(scope: string): { + log: RequestLog; + addStep: (name: string, data?: unknown) => void; + finish: (result?: unknown, error?: unknown) => Promise; +} { + const enabled = isRequestLoggingEnabled(); + const startedAt = new Date().toISOString(); + const logId = `${scope}-${Date.now()}`; + + const log: RequestLog = { + logId, + scope, + startedAt, + steps: [], + }; + + const addStep = (name: string, data?: unknown) => { + if (!enabled) return; + log.steps.push({ + at: new Date().toISOString(), + name, + data: data === undefined ? undefined : sanitizeForLog(data), + }); + }; + + const finish = async (result?: unknown, error?: unknown) => { + if (!enabled) return null; + + const finishedAt = new Date().toISOString(); + log.finishedAt = finishedAt; + log.durationMs = + new Date(finishedAt).getTime() - new Date(startedAt).getTime(); + if (result !== undefined) { + log.result = sanitizeForLog(result); + } + if (error !== undefined) { + log.error = sanitizeForLog(error); + } + + return writeLogFile(logId, log); + }; + + return { log, addStep, finish }; +} + +export async function writeApiRequestLog( + entry: Omit & { + startedAtMs: number; + }, +): Promise { + const finishedAt = new Date().toISOString(); + const payload: ApiRequestLog = { + ...entry, + finishedAt, + durationMs: Date.now() - entry.startedAtMs, + }; + + return writeLogFile(entry.logId, payload); +} + +export function buildApiRequestLogId(scope: string): string { + return `${scope}-${Date.now()}`; +} + +export function snapshotAxiosRequest( + config: AxiosRequestConfig, + mergedHeaders?: AxiosRequestConfig["headers"], +): ApiRequestLog["request"] { + const headers = mergedHeaders ?? config.headers; + + return { + method: config.method?.toUpperCase(), + baseURL: config.baseURL, + url: config.url, + headers: sanitizeHeaders(headers), + params: sanitizeForLog(config.params), + data: sanitizeForLog(config.data), + }; +} + +export function snapshotAxiosResponse( + response: AxiosResponse, +): ApiRequestLog["response"] { + return { + status: response.status, + statusText: response.statusText, + headers: sanitizeHeaders(response.headers), + data: sanitizeForLog(response.data), + }; +} + +export async function logApiRequestOutcome(params: { + scope: string; + config: AxiosRequestConfig; + mergedHeaders?: AxiosRequestConfig["headers"]; + startedAtMs: number; + response?: AxiosResponse; + error?: unknown; +}): Promise { + if (!isRequestLoggingEnabled()) { + return null; + } + + const logId = buildApiRequestLogId(params.scope); + const startedAt = new Date(params.startedAtMs).toISOString(); + + try { + const filePath = await writeApiRequestLog({ + logId, + scope: params.scope, + startedAt, + startedAtMs: params.startedAtMs, + request: snapshotAxiosRequest(params.config, params.mergedHeaders), + response: params.response + ? snapshotAxiosResponse(params.response) + : undefined, + error: params.error ? serializeAxiosError(params.error) : undefined, + }); + if (filePath) { + console.info(`[${params.scope}] request log written: ${filePath}`); + } + return filePath; + } catch (logError) { + console.warn(`[${params.scope}] failed to write request log`, logError); + return null; + } +} + +export function redactSecret(value: string | null | undefined): string | null { + if (!value) return null; + if (value.length <= 12) return "[redacted]"; + return `${value.slice(0, 6)}…${value.slice(-4)} [redacted]`; +} + +export function sanitizeHeaders( + headers: unknown, +): Record | null { + if (!headers || typeof headers !== "object") { + return null; + } + + const record = headers as Record; + const out: Record = {}; + + for (const [key, value] of Object.entries(record)) { + const lower = key.toLowerCase(); + if (lower === "authorization" && typeof value === "string") { + out[key] = redactSecret(value); + continue; + } + out[key] = value; + } + + return out; +} + +export function serializeAxiosError(error: unknown): Record { + if (!axios.isAxiosError(error)) { + return { + type: error instanceof Error ? error.name : typeof error, + message: error instanceof Error ? error.message : String(error), + }; + } + + return { + type: "AxiosError", + message: error.message, + code: error.code, + method: error.config?.method?.toUpperCase(), + baseURL: error.config?.baseURL, + url: error.config?.url, + status: error.response?.status, + statusText: error.response?.statusText, + requestHeaders: sanitizeHeaders(error.config?.headers), + responseHeaders: sanitizeHeaders(error.response?.headers), + responseData: error.response?.data, + }; +} + +async function writeLogFile( + logId: string, + payload: RequestLog | ApiRequestLog, +): Promise { + if (!isRequestLoggingEnabled()) { + return null; + } + + await fs.mkdir(LOG_DIR, { recursive: true }); + const filePath = path.join(LOG_DIR, `${logId}.json`); + await fs.writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); + return filePath; +} + +function sanitizeForLog(value: unknown): unknown { + if (value === null || value === undefined) { + return value; + } + + if (value instanceof File) { + return { + name: value.name, + size: value.size, + type: value.type, + lastModified: value.lastModified, + }; + } + + if (value instanceof FormData) { + const entries: Record = {}; + for (const [key, entry] of value.entries()) { + entries[key] = + entry instanceof File + ? sanitizeForLog(entry) + : typeof entry === "string" + ? entry + : String(entry); + } + return { formData: entries }; + } + + if (axios.isAxiosError(value)) { + return serializeAxiosError(value); + } + + if (value instanceof Error) { + return { + name: value.name, + message: value.message, + stack: value.stack, + }; + } + + if (Array.isArray(value)) { + return value.map((item) => sanitizeForLog(item)); + } + + if (typeof value === "object") { + const record = value as Record; + const out: Record = {}; + for (const [key, entry] of Object.entries(record)) { + if (key.toLowerCase() === "authorization" && typeof entry === "string") { + out[key] = redactSecret(entry); + continue; + } + out[key] = sanitizeForLog(entry); + } + return out; + } + + return value; +} From 272c0e6d5659dd5a7fca241d3edade628ec770da Mon Sep 17 00:00:00 2001 From: pencelheimer Date: Fri, 29 May 2026 16:32:06 +0300 Subject: [PATCH 2/6] fix!: use proper rpc call --- .../[resumeId]/_components/tabs/FinishTab.tsx | 11 +--- src/server/actions/optimizer/test/optimize.ts | 65 ++++--------------- 2 files changed, 13 insertions(+), 63 deletions(-) diff --git a/src/app/dashboard/resumes/[resumeId]/_components/tabs/FinishTab.tsx b/src/app/dashboard/resumes/[resumeId]/_components/tabs/FinishTab.tsx index cf7cc89..a883a4c 100644 --- a/src/app/dashboard/resumes/[resumeId]/_components/tabs/FinishTab.tsx +++ b/src/app/dashboard/resumes/[resumeId]/_components/tabs/FinishTab.tsx @@ -29,21 +29,12 @@ function appendLogPath(parts: string[], logPath?: string | null): string { function formatOptimizerResult(result: TestOptimizerResult): string { if (result.ok) { const parts = [`OK: optimisation ${result.optimisationId}`]; - if (result.atsScoreSeedSkipped) { - parts.push( - "Note: ATS score seed skipped (RLS); row came from existing pipeline.", - ); - } if (result.coverLetterPreview) { parts.push(result.coverLetterPreview); } return appendLogPath(parts, result.logPath); } - const parts = [`[${result.kind}] ${result.error}`]; - if (result.atsScoreSeedSkipped) { - parts.push("ATS score seed was skipped (RLS on ats_scores)."); - } - return appendLogPath(parts, result.logPath); + return appendLogPath([`[${result.kind}] ${result.error}`], result.logPath); } function formatCoverLetterResult(result: TestCoverLetterResult): string { diff --git a/src/server/actions/optimizer/test/optimize.ts b/src/server/actions/optimizer/test/optimize.ts index 80878c1..66515f0 100644 --- a/src/server/actions/optimizer/test/optimize.ts +++ b/src/server/actions/optimizer/test/optimize.ts @@ -1,9 +1,7 @@ "use server"; -import axios from "axios"; - import { getUserId } from "~/lib/auth"; -import { postAtsScores } from "~/server/api/generated/ats-scores/ats-scores"; +import { coreMutator } from "~/server/api/core-mutator"; import { postJobPostings } from "~/server/api/generated/job-postings/job-postings"; import { getOptimizations } from "~/server/api/generated/optimizations/optimizations"; import type { JobPostings } from "~/server/api/generated/schemas"; @@ -34,14 +32,12 @@ export type TestOptimizerResult = ok: true; optimisationId: string; coverLetterPreview?: string; - atsScoreSeedSkipped?: boolean; logPath?: string | null; } | { ok: false; kind: OptimizerFailureKind; error: string; - atsScoreSeedSkipped?: boolean; logPath?: string | null; }; @@ -100,49 +96,27 @@ async function pollForOptimization( return null; } -async function trySeedAtsScore( +async function requestAtsScore( resumeId: string, jobPostingId: string, addStep: (name: string, data?: unknown) => void, -): Promise<{ skipped: boolean }> { +): Promise { try { - const atsResponse = await postAtsScores( - undefined, + await coreMutator( { - data: { - resume_id: resumeId, - job_posting_id: jobPostingId, - score: 72, - analysis: { - source: "optimizer-smoke-test", - note: "Artificial ATS row to exercise event pipeline", - }, - }, - headers: { - Prefer: "return=representation", - }, - validateStatus: (status) => status === 201 || status === 200, - ...testHttpOptions, + url: `/rpc/request_ats_score`, + method: "POST", + data: { resume_id: resumeId, job_id: jobPostingId }, }, + { validateStatus: (s) => s === 204, ...testHttpOptions }, ); - addStep("ats_score.created", { atsResponse }); - return { skipped: false }; + addStep("ats_score.requested", { resumeId, jobPostingId }); } catch (error) { const classified = classifyRequestError(error); - addStep("ats_score.seed_failed", { + addStep("ats_score.request_failed", { ...classified, axios: serializeAxiosError(error), }); - if ( - axios.isAxiosError(error) && - error.response?.status === 403 && - classified.kind === "auth" - ) { - addStep("ats_score.seed_skipped", { - reason: "RLS blocks client INSERT on ats_scores; continuing poll only", - }); - return { skipped: true }; - } throw error; } } @@ -156,8 +130,6 @@ export async function testOptimizerAction( kind: "unknown", error: "Optimizer test did not run.", }; - let atsScoreSeedSkipped = false; - try { const userId = await getUserId(); addStep("action.start", { userId, resumeId }); @@ -198,20 +170,13 @@ export async function testOptimizerAction( } else { addStep("job_posting.created", { jobPostingId }); - addStep("ats_score.trigger", { method: "postAtsScores" }); - const seed = await trySeedAtsScore(resumeId, jobPostingId, addStep); - atsScoreSeedSkipped = seed.skipped; + await requestAtsScore(resumeId, jobPostingId, addStep); let optimization = await fetchLatestOptimizationForResume(resumeId); if (optimization) { addStep("optimisation.already_present", { optimizationId: optimization.id, }); - } else if (atsScoreSeedSkipped) { - addStep("poll.skipped", { - reason: - "ATS seed failed (RLS); async pipeline will not run — not polling.", - }); } else { optimization = await pollForOptimization(resumeId, addStep); } @@ -226,7 +191,6 @@ export async function testOptimizerAction( ok: true, optimisationId: optimization.id, coverLetterPreview, - atsScoreSeedSkipped, }; addStep("action.success", result); } catch (error) { @@ -235,7 +199,6 @@ export async function testOptimizerAction( ok: false, kind: classified.kind, error: classified.message, - atsScoreSeedSkipped, }; addStep("optimizer.cover_letter.error", { ...classified, @@ -246,10 +209,7 @@ export async function testOptimizerAction( result = { ok: false, kind: "pipeline_timeout", - error: atsScoreSeedSkipped - ? "Pipeline cannot start: ATS score seed blocked by RLS and no existing optimisation row. Use backend ats_score_requests or fix RLS." - : `No optimization row within ${PIPELINE_POLL_TIMEOUT_MS / 1000}s after seeding job and ATS score.`, - atsScoreSeedSkipped, + error: `No optimization row within ${PIPELINE_POLL_TIMEOUT_MS / 1000}s after requesting ATS score.`, }; addStep("poll.timeout", result); } @@ -262,7 +222,6 @@ export async function testOptimizerAction( ok: false, kind: classified.kind, error: classified.message, - atsScoreSeedSkipped, }; addStep("action.error", { ...classified, From 1b5f2b5ee32a361131c645ed4b64d3f109423fcc Mon Sep 17 00:00:00 2001 From: pencelheimer Date: Fri, 29 May 2026 23:15:30 +0300 Subject: [PATCH 3/6] fix: larger upload size limit --- next.config.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/next.config.js b/next.config.js index e541ade..8442082 100644 --- a/next.config.js +++ b/next.config.js @@ -3,6 +3,11 @@ import "./src/env.js"; /** @type {import("next").NextConfig} */ const config = { output: "standalone", + experimental: { + serverActions: { + bodySizeLimit: "25mb", + }, + }, images: { remotePatterns: [ { From 4960f62ca841b80ea0eb1f7dd760cc4297e54e9e Mon Sep 17 00:00:00 2001 From: AyloRyd Date: Sat, 30 May 2026 23:14:49 +0200 Subject: [PATCH 4/6] feat: update Orval clients --- .../-rpc-request-ats-score.ts | 43 +++++++++++++++++++ .../api/generated/optimizer/optimizer.ts | 7 --- src/server/api/generated/schemas/index.ts | 4 ++ .../schemas/postRpcRequestAtsScoreBodyFour.ts | 17 ++++++++ .../schemas/postRpcRequestAtsScoreBodyOne.ts | 17 ++++++++ .../postRpcRequestAtsScoreBodyThree.ts | 17 ++++++++ .../schemas/postRpcRequestAtsScoreBodyTwo.ts | 17 ++++++++ 7 files changed, 115 insertions(+), 7 deletions(-) create mode 100644 src/server/api/generated/-rpc-request-ats-score/-rpc-request-ats-score.ts create mode 100644 src/server/api/generated/schemas/postRpcRequestAtsScoreBodyFour.ts create mode 100644 src/server/api/generated/schemas/postRpcRequestAtsScoreBodyOne.ts create mode 100644 src/server/api/generated/schemas/postRpcRequestAtsScoreBodyThree.ts create mode 100644 src/server/api/generated/schemas/postRpcRequestAtsScoreBodyTwo.ts diff --git a/src/server/api/generated/-rpc-request-ats-score/-rpc-request-ats-score.ts b/src/server/api/generated/-rpc-request-ats-score/-rpc-request-ats-score.ts new file mode 100644 index 0000000..93431b6 --- /dev/null +++ b/src/server/api/generated/-rpc-request-ats-score/-rpc-request-ats-score.ts @@ -0,0 +1,43 @@ +/** + * Generated by orval v8.10.0 🍺 + * Do not edit manually. + * # GetJob AI + +Welcome to the **GetJob AI** API documentation. + + * OpenAPI spec version: 14.12 + */ +import type { + PostRpcRequestAtsScoreBodyFour, + PostRpcRequestAtsScoreBodyOne, + PostRpcRequestAtsScoreBodyThree, + PostRpcRequestAtsScoreBodyTwo, +} from "../schemas"; + +import { coreMutator } from "../../core-mutator"; + +type SecondParameter unknown> = Parameters[1]; + +/** + * @summary Publishes an ATS scoring request via pg_notify → event-relay → RabbitMQ → ats-scorer. + */ +export const postRpcRequestAtsScore = ( + postRpcRequestAtsScoreBody: + | PostRpcRequestAtsScoreBodyOne + | PostRpcRequestAtsScoreBodyTwo + | PostRpcRequestAtsScoreBodyThree + | PostRpcRequestAtsScoreBodyFour, + options?: SecondParameter>, +) => { + return coreMutator( + { + url: `/rpc/request_ats_score`, + method: "POST", + data: postRpcRequestAtsScoreBody, + }, + options, + ); +}; +export type PostRpcRequestAtsScoreResult = NonNullable< + Awaited> +>; diff --git a/src/server/api/generated/optimizer/optimizer.ts b/src/server/api/generated/optimizer/optimizer.ts index 43225cc..2ad3db4 100644 --- a/src/server/api/generated/optimizer/optimizer.ts +++ b/src/server/api/generated/optimizer/optimizer.ts @@ -18,7 +18,6 @@ import { optimizerMutator } from "../../optimizer-mutator"; type SecondParameter unknown> = Parameters[1]; /** - * Accept or reject an AI-generated work experience suggestion. A rejection hint is stored and passed to the AI on the next rewrite. * @summary Review a work experience suggestion */ export const postApiOptimisationsOptimisationIdWorkExperiencesSuggestionIdReview = @@ -40,7 +39,6 @@ export const postApiOptimisationsOptimisationIdWorkExperiencesSuggestionIdReview }; /** - * Triggers an AI rewrite of the work experience entry. Replaces all existing bullets with the new output. An optional hint guides the AI. * @summary Rewrite a work experience suggestion */ export const postApiOptimisationsOptimisationIdWorkExperiencesSuggestionIdRewrite = @@ -64,7 +62,6 @@ export const postApiOptimisationsOptimisationIdWorkExperiencesSuggestionIdRewrit }; /** - * Accept or reject an individual AI-rewritten bullet point within a work experience suggestion. * @summary Review a bullet point */ export const postApiOptimisationsOptimisationIdBulletsBulletIdReview = ( @@ -85,7 +82,6 @@ export const postApiOptimisationsOptimisationIdBulletsBulletIdReview = ( }; /** - * Accept or reject an AI-generated activity suggestion. A rejection hint is stored and passed to the AI on the next rewrite. * @summary Review an activity suggestion */ export const postApiOptimisationsOptimisationIdActivitiesSuggestionIdReview = ( @@ -106,7 +102,6 @@ export const postApiOptimisationsOptimisationIdActivitiesSuggestionIdReview = ( }; /** - * Triggers an AI rewrite of the activity entry. An optional hint guides the AI. * @summary Rewrite an activity suggestion */ export const postApiOptimisationsOptimisationIdActivitiesSuggestionIdRewrite = ( @@ -127,7 +122,6 @@ export const postApiOptimisationsOptimisationIdActivitiesSuggestionIdRewrite = ( }; /** - * Generates or regenerates a cover letter using Gemini AI. Uses the session's accepted summary, skills, and top bullets automatically. If a cover letter already exists it is overwritten. * @summary Generate a cover letter */ export const postApiOptimisationsOptimisationIdCoverLetterGenerate = ( @@ -147,7 +141,6 @@ export const postApiOptimisationsOptimisationIdCoverLetterGenerate = ( }; /** - * Returns the saved cover letter for the optimisation session. * @summary Get the cover letter */ export const getApiOptimisationsOptimisationIdCoverLetter = ( diff --git a/src/server/api/generated/schemas/index.ts b/src/server/api/generated/schemas/index.ts index 0a6fc65..7b9dcff 100644 --- a/src/server/api/generated/schemas/index.ts +++ b/src/server/api/generated/schemas/index.ts @@ -39,6 +39,10 @@ export * from "./postAtsScoresParams"; export * from "./postJobPostingsParams"; export * from "./postOptimizationsParams"; export * from "./postResumesParams"; +export * from "./postRpcRequestAtsScoreBodyFour"; +export * from "./postRpcRequestAtsScoreBodyOne"; +export * from "./postRpcRequestAtsScoreBodyThree"; +export * from "./postRpcRequestAtsScoreBodyTwo"; export * from "./postSchemaMigrationsParams"; export * from "./preferCountParameter"; export * from "./preferParamsParameter"; diff --git a/src/server/api/generated/schemas/postRpcRequestAtsScoreBodyFour.ts b/src/server/api/generated/schemas/postRpcRequestAtsScoreBodyFour.ts new file mode 100644 index 0000000..b085e2b --- /dev/null +++ b/src/server/api/generated/schemas/postRpcRequestAtsScoreBodyFour.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v8.10.0 🍺 + * Do not edit manually. + * # GetJob AI + +Welcome to the **GetJob AI** API documentation. + + * OpenAPI spec version: 14.12 + */ + +/** + * Publishes an ATS scoring request via pg_notify → event-relay → RabbitMQ → ats-scorer. + */ +export type PostRpcRequestAtsScoreBodyFour = { + job_id: string; + resume_id: string; +}; diff --git a/src/server/api/generated/schemas/postRpcRequestAtsScoreBodyOne.ts b/src/server/api/generated/schemas/postRpcRequestAtsScoreBodyOne.ts new file mode 100644 index 0000000..fe31f19 --- /dev/null +++ b/src/server/api/generated/schemas/postRpcRequestAtsScoreBodyOne.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v8.10.0 🍺 + * Do not edit manually. + * # GetJob AI + +Welcome to the **GetJob AI** API documentation. + + * OpenAPI spec version: 14.12 + */ + +/** + * Publishes an ATS scoring request via pg_notify → event-relay → RabbitMQ → ats-scorer. + */ +export type PostRpcRequestAtsScoreBodyOne = { + job_id: string; + resume_id: string; +}; diff --git a/src/server/api/generated/schemas/postRpcRequestAtsScoreBodyThree.ts b/src/server/api/generated/schemas/postRpcRequestAtsScoreBodyThree.ts new file mode 100644 index 0000000..5242193 --- /dev/null +++ b/src/server/api/generated/schemas/postRpcRequestAtsScoreBodyThree.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v8.10.0 🍺 + * Do not edit manually. + * # GetJob AI + +Welcome to the **GetJob AI** API documentation. + + * OpenAPI spec version: 14.12 + */ + +/** + * Publishes an ATS scoring request via pg_notify → event-relay → RabbitMQ → ats-scorer. + */ +export type PostRpcRequestAtsScoreBodyThree = { + job_id: string; + resume_id: string; +}; diff --git a/src/server/api/generated/schemas/postRpcRequestAtsScoreBodyTwo.ts b/src/server/api/generated/schemas/postRpcRequestAtsScoreBodyTwo.ts new file mode 100644 index 0000000..d2213ec --- /dev/null +++ b/src/server/api/generated/schemas/postRpcRequestAtsScoreBodyTwo.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v8.10.0 🍺 + * Do not edit manually. + * # GetJob AI + +Welcome to the **GetJob AI** API documentation. + + * OpenAPI spec version: 14.12 + */ + +/** + * Publishes an ATS scoring request via pg_notify → event-relay → RabbitMQ → ats-scorer. + */ +export type PostRpcRequestAtsScoreBodyTwo = { + job_id: string; + resume_id: string; +}; From a7b9f55220c800e1716902fd982a7d387a74fd4a Mon Sep 17 00:00:00 2001 From: AyloRyd Date: Tue, 2 Jun 2026 18:25:36 +0200 Subject: [PATCH 5/6] feat: mapping optimized resume payload, auto-download files on success, and silencing API polling logs --- .../[resumeId]/_components/tabs/FinishTab.tsx | 54 ++++- .../actions/optimizer/test/cover-letter.ts | 16 +- src/server/actions/optimizer/test/errors.ts | 14 +- src/server/actions/optimizer/test/fixtures.ts | 30 ++- src/server/actions/optimizer/test/optimize.ts | 211 +++++++++++++++--- .../actions/optimizer/test/parse-job.ts | 57 +++++ src/server/api/mutator.ts | 35 +-- 7 files changed, 366 insertions(+), 51 deletions(-) create mode 100644 src/server/actions/optimizer/test/parse-job.ts diff --git a/src/app/dashboard/resumes/[resumeId]/_components/tabs/FinishTab.tsx b/src/app/dashboard/resumes/[resumeId]/_components/tabs/FinishTab.tsx index a883a4c..ac995b3 100644 --- a/src/app/dashboard/resumes/[resumeId]/_components/tabs/FinishTab.tsx +++ b/src/app/dashboard/resumes/[resumeId]/_components/tabs/FinishTab.tsx @@ -97,6 +97,44 @@ export function FinishTab({ content, resumeId }: FinishTabProps) { startOptimize(async () => { const result = await testOptimizerAction(resumeId); setTestStatus(formatOptimizerResult(result)); + + if (result.ok) { + if (result.optimizedResumePayload) { + try { + const payload = buildPdfPayload(result.optimizedResumePayload); + const res = await fetch("/api/pdf-preview", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (res.ok) { + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "optimized-resume.pdf"; + a.click(); + URL.revokeObjectURL(url); + } + } catch (e) { + console.error("Failed to generate and download optimized PDF:", e); + } + } + + if (result.coverLetterText) { + try { + const blob = new Blob([result.coverLetterText], { type: "text/plain;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "cover-letter.txt"; + a.click(); + URL.revokeObjectURL(url); + } catch (e) { + console.error("Failed to download cover letter:", e); + } + } + } }); }; @@ -105,6 +143,20 @@ export function FinishTab({ content, resumeId }: FinishTabProps) { startCoverLetter(async () => { const result = await testCoverLetterAction(resumeId); setTestStatus(formatCoverLetterResult(result)); + + if (result.ok && result.outcome === "cover_letter_generated" && result.coverLetterText) { + try { + const blob = new Blob([result.coverLetterText], { type: "text/plain;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "cover-letter.txt"; + a.click(); + URL.revokeObjectURL(url); + } catch (e) { + console.error("Failed to download cover letter:", e); + } + } }); }; @@ -173,7 +225,7 @@ export function FinishTab({ content, resumeId }: FinishTabProps) { : isTestPending } onClick={"onClick" in item ? item.onClick : undefined} - className="flex w-full items-center justify-between rounded-xl border border-white/6 bg-white/2 px-4 py-3.5 text-left transition-all hover:border-white/12 hover:bg-white/4 disabled:opacity-50" + className="flex w-full cursor-pointer items-center justify-between rounded-xl border border-white/6 bg-white/2 px-4 py-3.5 text-left transition-all hover:border-white/12 hover:bg-white/4 disabled:cursor-default disabled:opacity-50 disabled:hover:border-white/6 disabled:hover:bg-white/2" >
diff --git a/src/server/actions/optimizer/test/cover-letter.ts b/src/server/actions/optimizer/test/cover-letter.ts index ac495a7..8812643 100644 --- a/src/server/actions/optimizer/test/cover-letter.ts +++ b/src/server/actions/optimizer/test/cover-letter.ts @@ -10,6 +10,8 @@ import { fetchLatestOptimizationForResume, runCoverLetterGenerate, } from "./cover-letter-shared"; +import { getApiOptimisationsOptimisationIdCoverLetter } from "~/server/api/generated/optimizer/optimizer"; +import { testHttpOptions } from "./constants"; import { classifyRequestError } from "./errors"; import type { OptimizerFailureKind } from "./errors"; @@ -19,6 +21,7 @@ export type TestCoverLetterResult = outcome: "cover_letter_generated"; optimisationId: string; coverLetterPreview: string; + coverLetterText?: string; logPath?: string | null; } | { @@ -80,13 +83,24 @@ export async function testCoverLetterAction( optimization.id, addStep, ); + const savedCoverLetter = await getApiOptimisationsOptimisationIdCoverLetter( + optimization.id, + testHttpOptions, + ).catch(() => null); + result = { ok: true, outcome: "cover_letter_generated", optimisationId: optimization.id, coverLetterPreview, + coverLetterText: savedCoverLetter?.coverLetter, }; - addStep("action.success", result); + addStep("action.success", { + ok: true, + outcome: "cover_letter_generated", + optimisationId: optimization.id, + coverLetterPreview, + }); } catch (error) { const classified = classifyRequestError(error); result = { diff --git a/src/server/actions/optimizer/test/errors.ts b/src/server/actions/optimizer/test/errors.ts index a43d1ea..1578895 100644 --- a/src/server/actions/optimizer/test/errors.ts +++ b/src/server/actions/optimizer/test/errors.ts @@ -5,6 +5,7 @@ export type OptimizerFailureKind = | "no_optimisation_in_db" | "pipeline_timeout" | "auth" + | "parser_api" | "optimizer_api" | "core_api" | "unknown"; @@ -62,11 +63,20 @@ export function classifyRequestError(error: unknown): { const url = error.config?.url ?? ""; const isOptimizer = url.includes("/optimisations"); - const isCore = !isOptimizer && url.length > 0; + const isParser = + url.includes("/parser/") || url.includes("/job-postings/parse"); + const isCore = + !isOptimizer && !isParser && url.length > 0; if (status !== undefined && status >= 400) { return { - kind: isOptimizer ? "optimizer_api" : isCore ? "core_api" : "unknown", + kind: isOptimizer + ? "optimizer_api" + : isParser + ? "parser_api" + : isCore + ? "core_api" + : "unknown", status, message: detail ?? error.message, }; diff --git a/src/server/actions/optimizer/test/fixtures.ts b/src/server/actions/optimizer/test/fixtures.ts index 66e705d..d6efa3a 100644 --- a/src/server/actions/optimizer/test/fixtures.ts +++ b/src/server/actions/optimizer/test/fixtures.ts @@ -1,6 +1,34 @@ import type { JobPostingContent } from "~/server/api/generated/parser/schemas"; -/** Artificial job posting for optimizer pipeline smoke tests (no UI input). */ +/** + * Plain-text job ad for pipeline smoke tests — sent to the parser service. + * Keep in sync with {@link ARTIFICIAL_JOB_POSTING_CONTENT} (expected structured shape). + */ +export const ARTIFICIAL_JOB_POSTING_RAW_TEXT = `Senior Backend Engineer — Acme GmbH +Berlin, Germany · Hybrid · Full-time · Senior + +About the role +We are looking for a senior backend engineer to design distributed systems in Rust and TypeScript. + +Responsibilities +- Design and operate event-driven microservices +- Improve ingestion pipeline throughput and reliability +- Mentor engineers on async patterns and observability + +Requirements +- 5+ years backend experience +- Rust or Go and PostgreSQL +- Kafka or similar message brokers +- CI/CD and production operations + +Nice to have +- Kubernetes +- Prometheus +- Open source contributions + +Skills: Rust, TypeScript, PostgreSQL, Kafka, Docker, Kubernetes`; + +/** Structured fallback reference (not used when parser path is enabled). */ export const ARTIFICIAL_JOB_POSTING_CONTENT: JobPostingContent = { title: "Senior Backend Engineer", company: "Acme GmbH", diff --git a/src/server/actions/optimizer/test/optimize.ts b/src/server/actions/optimizer/test/optimize.ts index 66515f0..06973d2 100644 --- a/src/server/actions/optimizer/test/optimize.ts +++ b/src/server/actions/optimizer/test/optimize.ts @@ -13,10 +13,10 @@ import { } from "~/server/lib/request-log"; import { extractLatestOptimization, - fetchLatestOptimizationForResume, runCoverLetterGenerate, } from "./cover-letter-shared"; -import { ARTIFICIAL_JOB_POSTING_CONTENT } from "./fixtures"; +import { getApiOptimisationsOptimisationIdCoverLetter } from "~/server/api/generated/optimizer/optimizer"; +import { parseJobPostingForTest } from "./parse-job"; import { PIPELINE_POLL_TIMEOUT_MS, testHttpOptions, @@ -27,11 +27,69 @@ import type { OptimizerFailureKind } from "./errors"; const POLL_INITIAL_DELAY_MS = 500; const POLL_MAX_DELAY_MS = 1_500; +interface OptimizeExperience { + company?: string; + title?: string; + dates?: string; + location?: string; + bullets?: string[]; + hide?: boolean; +} + +interface OptimizeSkillGroup { + category: string; + items: string[]; +} + +interface SuggestionExperience { + company_name?: string | null; + job_title?: string | null; + start_date?: string | null; + end_date?: string | null; + bullets?: string[] | null; + entry_id?: string | null; +} + +interface SuggestionBullet { + id?: string; + original?: string; + rewritten?: string; + xyz_applied?: boolean; + keywords_added?: string[]; +} + +interface SuggestionWorkExperience { + id?: string; + reason?: string; + bullets?: SuggestionBullet[]; + include?: boolean; + entry_id?: string; + rewrite_count?: number; +} + +interface SuggestionSummaryObject { + original?: string; + rewritten?: string; + rewrite_count?: number; + keywords_incorporated?: string[]; +} + +interface SuggestionsJson { + resume_experiences?: SuggestionExperience[]; + work_experiences?: SuggestionWorkExperience[]; + resume_skills?: string[]; + existing_summary?: string; + summary?: string | SuggestionSummaryObject; +} + + export type TestOptimizerResult = | { ok: true; optimisationId: string; coverLetterPreview?: string; + coverLetterText?: string; + optimizedResumePayload?: Record | null; logPath?: string | null; } | { @@ -62,6 +120,7 @@ function extractJobPostingId(data: unknown): string | null { async function pollForOptimization( resumeId: string, + jobPostingId: string, addStep: (name: string, data?: unknown) => void, ): Promise { const deadline = Date.now() + PIPELINE_POLL_TIMEOUT_MS; @@ -73,20 +132,31 @@ async function pollForOptimization( const rows = await getOptimizations( { resume_id: `eq.${resumeId}`, - order: "created_at.desc", + job_posting_id: `eq.${jobPostingId}`, limit: "1", }, - testHttpOptions, + { + ...testHttpOptions, + headers: { + "X-Disable-Logging": "true", + }, + }, ); const row = extractLatestOptimization(rows); + + let status: string | null = null; + if (row?.ai_suggestions && typeof row.ai_suggestions === "object") { + status = (row.ai_suggestions as Record).status as string | null ?? null; + } + addStep("poll.optimizations", { attempt, found: Boolean(row), optimizationId: row?.id ?? null, - jobPostingId: row?.job_posting_id ?? null, - atsScoreId: row?.ats_score_id ?? null, + status, }); - if (row) { + + if (row && (status === "completed" || status === "failed")) { return row; } await sleep(delayMs); @@ -145,12 +215,14 @@ export async function testOptimizerAction( } else { addStep("resume.found", { resumeId: resume.id }); + const parsedJobContent = await parseJobPostingForTest(addStep); + const jobResponse = await postJobPostings( undefined, { data: { user_id: userId, - content: ARTIFICIAL_JOB_POSTING_CONTENT, + content: parsedJobContent, }, headers: { Prefer: "return=representation", @@ -172,27 +244,106 @@ export async function testOptimizerAction( await requestAtsScore(resumeId, jobPostingId, addStep); - let optimization = await fetchLatestOptimizationForResume(resumeId); - if (optimization) { - addStep("optimisation.already_present", { - optimizationId: optimization.id, - }); - } else { - optimization = await pollForOptimization(resumeId, addStep); - } + const optimization = await pollForOptimization(resumeId, jobPostingId, addStep); - if (optimization) { - try { - const { coverLetterPreview } = await runCoverLetterGenerate( - optimization.id, - addStep, - ); + if (!optimization) { + result = { + ok: false, + kind: "pipeline_timeout", + error: "Optimization pipeline timed out.", + }; + addStep("optimisation.timeout", { resumeId, jobPostingId }); + } else { + const suggestions = optimization.ai_suggestions as Record | null | undefined; + if (suggestions?.status === "failed") { + const errMsg = (suggestions.error_message as string | undefined) ?? "Optimization pipeline failed on backend"; result = { - ok: true, - optimisationId: optimization.id, - coverLetterPreview, + ok: false, + kind: "optimizer_api", + error: errMsg, }; - addStep("action.success", result); + addStep("optimizer.failed_status", { error: errMsg }); + } else { + try { + const { coverLetterPreview } = await runCoverLetterGenerate( + optimization.id, + addStep, + ); + + const savedCoverLetter = await getApiOptimisationsOptimisationIdCoverLetter( + optimization.id, + testHttpOptions, + ).catch(() => null); + + let optimizedResumePayload: Record | null = null; + if (optimization.ai_suggestions) { + const suggestionsJson = optimization.ai_suggestions as unknown as SuggestionsJson; + const originalContent = resume.content as Record | null; + + const originalExperiences = (originalContent?.experience as OptimizeExperience[] | undefined) ?? []; + const optExperiences = originalExperiences.map((exp) => { + const sugResumeExp = suggestionsJson.resume_experiences?.find( + (sugExp) => (sugExp.company_name ?? "") === (exp.company ?? "") && (sugExp.job_title ?? "") === (exp.title ?? "") + ); + + let optBullets = exp.bullets; + if (sugResumeExp?.entry_id) { + const sugWorkExp = suggestionsJson.work_experiences?.find( + (workExp) => workExp.entry_id === sugResumeExp.entry_id + ); + if (sugWorkExp?.bullets) { + optBullets = sugWorkExp.bullets + .map((b) => b.rewritten) + .filter((b): b is string => typeof b === "string"); + } + } + + return Object.assign({}, exp, { + bullets: optBullets, + }); + }); + + const optSkills = suggestionsJson.resume_skills ? [ + { + category: "Skills", + items: suggestionsJson.resume_skills + } + ] : (originalContent?.skills as OptimizeSkillGroup[] | undefined); + + let optSummary = originalContent?.summary; + const sugSummary = suggestionsJson.summary; + if (sugSummary) { + if (typeof sugSummary === "object") { + optSummary = sugSummary.rewritten ?? originalContent?.summary; + } else if (typeof sugSummary === "string") { + optSummary = sugSummary; + } + } + + optimizedResumePayload = Object.assign({}, originalContent, { + summary: optSummary, + experience: optExperiences, + skills: optSkills + }); + + addStep("optimizer.payload_comparison", { + original: originalContent, + optimized: optimizedResumePayload, + }); + } + + result = { + ok: true, + optimisationId: optimization.id, + coverLetterPreview, + coverLetterText: savedCoverLetter?.coverLetter, + optimizedResumePayload, + }; + addStep("action.success", { + ok: true, + optimisationId: optimization.id, + coverLetterPreview, + }); } catch (error) { const classified = classifyRequestError(error); result = { @@ -205,16 +356,10 @@ export async function testOptimizerAction( axios: serializeAxiosError(error), }); } - } else { - result = { - ok: false, - kind: "pipeline_timeout", - error: `No optimization row within ${PIPELINE_POLL_TIMEOUT_MS / 1000}s after requesting ATS score.`, - }; - addStep("poll.timeout", result); } } } + } } catch (e) { const classified = classifyRequestError(e); console.error("[testOptimizerAction]", e); diff --git a/src/server/actions/optimizer/test/parse-job.ts b/src/server/actions/optimizer/test/parse-job.ts new file mode 100644 index 0000000..ff1d466 --- /dev/null +++ b/src/server/actions/optimizer/test/parse-job.ts @@ -0,0 +1,57 @@ +import { parseJobPostingJobPostingsParsePost } from "~/server/api/generated/parser/parser"; +import type { JobPostingContent } from "~/server/api/generated/parser/schemas"; +import { serializeAxiosError } from "~/server/lib/request-log"; + +import { testHttpOptions } from "./constants"; +import { ARTIFICIAL_JOB_POSTING_RAW_TEXT } from "./fixtures"; + +function isFailedParseStatus(status: string): boolean { + const normalized = status.trim().toLowerCase(); + return normalized === "failed" || normalized === "error"; +} + +function hasMinimalJobContent(content: JobPostingContent): boolean { + const title = content.title?.trim(); + const company = content.company?.trim(); + return Boolean(title ?? company); +} + +/** Calls the parser service with fixture plain text; returns structured job content for core API. */ +export async function parseJobPostingForTest( + addStep: (name: string, data?: unknown) => void, +): Promise { + addStep("parser.job.request", { + textLength: ARTIFICIAL_JOB_POSTING_RAW_TEXT.length, + }); + + try { + const response = await parseJobPostingJobPostingsParsePost( + { text: ARTIFICIAL_JOB_POSTING_RAW_TEXT }, + testHttpOptions, + ); + + addStep("parser.job.response", { + parse_status: response.parse_status, + partial_parse: response.partial_parse, + warnings: response.warnings, + extraction_method: response.extraction_method, + title: response.content?.title ?? null, + company: response.content?.company ?? null, + }); + + if (isFailedParseStatus(response.parse_status ?? "")) { + throw new Error( + `Job posting parser returned status "${response.parse_status}".`, + ); + } + + if (!hasMinimalJobContent(response.content)) { + throw new Error("Job posting parser returned empty structured content."); + } + + return response.content; + } catch (error) { + addStep("parser.job.error", serializeAxiosError(error)); + throw error; + } +} diff --git a/src/server/api/mutator.ts b/src/server/api/mutator.ts index 385a9f0..8914e1a 100644 --- a/src/server/api/mutator.ts +++ b/src/server/api/mutator.ts @@ -89,6 +89,11 @@ export function createMutator(options: MutatorOptions) { delete (headers as Record)["content-type"]; } + const disableLogging = headers && (headers as Record)["X-Disable-Logging"] === "true"; + if (disableLogging && headers) { + delete (headers as Record)["X-Disable-Logging"]; + } + const requestConfig: AxiosRequestConfig = { ...config, ...extraOptions, @@ -99,21 +104,25 @@ export function createMutator(options: MutatorOptions) { try { const response = await instance(requestConfig); - await logApiRequestOutcome({ - scope: options.logPrefix, - config: requestConfig, - mergedHeaders: response.config.headers, - startedAtMs, - response, - }); + if (!disableLogging) { + await logApiRequestOutcome({ + scope: options.logPrefix, + config: requestConfig, + mergedHeaders: response.config.headers, + startedAtMs, + response, + }); + } return response.data as T; } catch (error) { - await logApiRequestOutcome({ - scope: options.logPrefix, - config: requestConfig, - startedAtMs, - error, - }); + if (!disableLogging) { + await logApiRequestOutcome({ + scope: options.logPrefix, + config: requestConfig, + startedAtMs, + error, + }); + } throw error; } }; From fbcabe16c6fc10b047708d3e2faf6613cee7693c Mon Sep 17 00:00:00 2001 From: AyloRyd Date: Tue, 2 Jun 2026 18:55:44 +0200 Subject: [PATCH 6/6] feat: move AI tailoring to JobTailoring tab and update Finish tab actions --- .../[resumeId]/_components/EditorSidebar.tsx | 15 +- .../[resumeId]/_components/PdfPreview.tsx | 16 ++- .../_components/ResumeEditorClient.tsx | 10 +- .../_components/TemplatesPopover.tsx | 8 +- .../[resumeId]/_components/tabs/FinishTab.tsx | 132 +++++++----------- .../_components/tabs/JobTailoringTab.tsx | 119 ++++++++++------ src/server/actions/optimizer/test/optimize.ts | 42 ++---- .../actions/optimizer/test/parse-job.ts | 8 +- 8 files changed, 188 insertions(+), 162 deletions(-) diff --git a/src/app/dashboard/resumes/[resumeId]/_components/EditorSidebar.tsx b/src/app/dashboard/resumes/[resumeId]/_components/EditorSidebar.tsx index b4f71c3..9b67626 100644 --- a/src/app/dashboard/resumes/[resumeId]/_components/EditorSidebar.tsx +++ b/src/app/dashboard/resumes/[resumeId]/_components/EditorSidebar.tsx @@ -20,6 +20,8 @@ interface EditorSidebarProps { content: ResumeContent; onSave: (patch: Partial) => void; isSaving: boolean; + onTabChange?: (tabId: EditorTabId) => void; + onTemplatesOpenChange?: (open: boolean) => void; } export function EditorSidebar({ @@ -28,6 +30,8 @@ export function EditorSidebar({ content, onSave, isSaving, + onTabChange, + onTemplatesOpenChange, }: EditorSidebarProps) { const meta = EDITOR_TAB_META[activeTab]; @@ -52,7 +56,9 @@ export function EditorSidebar({
- {activeTab === "job-tailoring" && } + {activeTab === "job-tailoring" && ( + + )} {activeTab === "sections" && ( )} @@ -81,7 +87,12 @@ export function EditorSidebar({ )} {activeTab === "finish" && ( - + onTemplatesOpenChange?.(true)} + /> )}
diff --git a/src/app/dashboard/resumes/[resumeId]/_components/PdfPreview.tsx b/src/app/dashboard/resumes/[resumeId]/_components/PdfPreview.tsx index 6ed951e..8dcbe8a 100644 --- a/src/app/dashboard/resumes/[resumeId]/_components/PdfPreview.tsx +++ b/src/app/dashboard/resumes/[resumeId]/_components/PdfPreview.tsx @@ -10,9 +10,16 @@ import { TemplatesPopover } from "./TemplatesPopover"; interface PdfPreviewProps { content: ResumeContent; onStyleChange: (style: "professional" | "technical" | "minimal") => void; + isTemplatesOpen?: boolean; + onTemplatesOpenChange?: (open: boolean) => void; } -export function PdfPreview({ content, onStyleChange }: PdfPreviewProps) { +export function PdfPreview({ + content, + onStyleChange, + isTemplatesOpen, + onTemplatesOpenChange, +}: PdfPreviewProps) { const [blobUrl, setBlobUrl] = useState(null); const [isCompiling, setIsCompiling] = useState(false); const [error, setError] = useState(null); @@ -110,7 +117,12 @@ export function PdfPreview({ content, onStyleChange }: PdfPreviewProps) {
- +
- +
diff --git a/src/app/dashboard/resumes/[resumeId]/_components/TemplatesPopover.tsx b/src/app/dashboard/resumes/[resumeId]/_components/TemplatesPopover.tsx index 5485877..a33b37d 100644 --- a/src/app/dashboard/resumes/[resumeId]/_components/TemplatesPopover.tsx +++ b/src/app/dashboard/resumes/[resumeId]/_components/TemplatesPopover.tsx @@ -20,13 +20,19 @@ const TEMPLATES: Array<{ value: StyleValue; label: string }> = [ interface TemplatesPopoverProps { content: ResumeContent; onStyleChange: (style: StyleValue) => void; + open?: boolean; + onOpenChange?: (open: boolean) => void; } export function TemplatesPopover({ content, onStyleChange, + open: controlledOpen, + onOpenChange: controlledOnOpenChange, }: TemplatesPopoverProps) { - const [open, setOpen] = useState(false); + const [localOpen, setLocalOpen] = useState(false); + const open = controlledOpen ?? localOpen; + const setOpen = controlledOnOpenChange ?? setLocalOpen; const current = content.style ?? "professional"; return ( diff --git a/src/app/dashboard/resumes/[resumeId]/_components/tabs/FinishTab.tsx b/src/app/dashboard/resumes/[resumeId]/_components/tabs/FinishTab.tsx index ac995b3..9061e03 100644 --- a/src/app/dashboard/resumes/[resumeId]/_components/tabs/FinishTab.tsx +++ b/src/app/dashboard/resumes/[resumeId]/_components/tabs/FinishTab.tsx @@ -1,22 +1,22 @@ "use client"; import { useState, useTransition } from "react"; -import { Download, Sparkles, FileText, Target, ArrowRight } from "lucide-react"; +import { Download, FileText, Target, ArrowRight, Layers, Sliders } from "lucide-react"; import { testCoverLetterAction, type TestCoverLetterResult, } from "~/server/actions/optimizer/test/cover-letter"; -import { - testOptimizerAction, - type TestOptimizerResult, -} from "~/server/actions/optimizer/test/optimize"; import type { ResumeContent } from "../resume-content-types"; import { buildPdfPayload } from "../resume-content-types"; +import type { EditorTabId } from "../editor-tabs"; +import { cn } from "~/lib/utils"; interface FinishTabProps { content: ResumeContent; resumeId: string; + onTabChange?: (tab: EditorTabId) => void; + onBrowseTemplates?: () => void; } function appendLogPath(parts: string[], logPath?: string | null): string { @@ -26,17 +26,6 @@ function appendLogPath(parts: string[], logPath?: string | null): string { return parts.join("\n\n"); } -function formatOptimizerResult(result: TestOptimizerResult): string { - if (result.ok) { - const parts = [`OK: optimisation ${result.optimisationId}`]; - if (result.coverLetterPreview) { - parts.push(result.coverLetterPreview); - } - return appendLogPath(parts, result.logPath); - } - return appendLogPath([`[${result.kind}] ${result.error}`], result.logPath); -} - function formatCoverLetterResult(result: TestCoverLetterResult): string { if (result.ok && result.outcome === "no_optimisation_in_db") { return appendLogPath( @@ -56,13 +45,16 @@ function formatCoverLetterResult(result: TestCoverLetterResult): string { return appendLogPath([`[${result.kind}] ${result.error}`], result.logPath); } -export function FinishTab({ content, resumeId }: FinishTabProps) { +export function FinishTab({ + content, + resumeId, + onTabChange, + onBrowseTemplates, +}: FinishTabProps) { const [isDownloading, setIsDownloading] = useState(false); const [downloadError, setDownloadError] = useState(null); const [testStatus, setTestStatus] = useState(null); - const [isOptimizePending, startOptimize] = useTransition(); const [isCoverLetterPending, startCoverLetter] = useTransition(); - const isTestPending = isOptimizePending || isCoverLetterPending; const handleDownload = async () => { setIsDownloading(true); @@ -92,52 +84,7 @@ export function FinishTab({ content, resumeId }: FinishTabProps) { } }; - const handleOptimizeTest = () => { - setTestStatus(null); - startOptimize(async () => { - const result = await testOptimizerAction(resumeId); - setTestStatus(formatOptimizerResult(result)); - - if (result.ok) { - if (result.optimizedResumePayload) { - try { - const payload = buildPdfPayload(result.optimizedResumePayload); - const res = await fetch("/api/pdf-preview", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - if (res.ok) { - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = "optimized-resume.pdf"; - a.click(); - URL.revokeObjectURL(url); - } - } catch (e) { - console.error("Failed to generate and download optimized PDF:", e); - } - } - - if (result.coverLetterText) { - try { - const blob = new Blob([result.coverLetterText], { type: "text/plain;charset=utf-8" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = "cover-letter.txt"; - a.click(); - URL.revokeObjectURL(url); - } catch (e) { - console.error("Failed to download cover letter:", e); - } - } - } - }); - }; - + // Dedicated cover letter generation and download (kept intact in code, but button is disabled in UI) const handleCoverLetterTest = () => { setTestStatus(null); startCoverLetter(async () => { @@ -164,24 +111,38 @@ export function FinishTab({ content, resumeId }: FinishTabProps) { { icon: , label: "Tailor to a specific role", - description: isOptimizePending - ? "Running pipeline test (up to ~30s)…" - : "Optimise your resume for a target job", - onClick: handleOptimizeTest, - pending: isOptimizePending, + description: "Optimise your resume for a target job", + onClick: () => onTabChange?.("job-tailoring"), + disabled: false, }, { icon: , - label: "Write cover letter", + label: ( + + Write cover letter + + soon + + + ), description: "Generate a cover letter with this resume linked", onClick: handleCoverLetterTest, + disabled: true, pending: isCoverLetterPending, }, { - icon: , - label: "Refine with AI", - description: "Chat with an AI assistant to improve your resume", - disabled: true, + icon: , + label: "Browse our templates", + description: "Choose a different design or layout template", + onClick: onBrowseTemplates, + disabled: false, + }, + { + icon: , + label: "Adjust sections", + description: "Show or hide sections and customize PDF section headings", + onClick: () => onTabChange?.("sections"), + disabled: false, }, ] as const; @@ -215,26 +176,27 @@ export function FinishTab({ content, resumeId }: FinishTabProps) { Continue editing

- {actions.map((item) => ( + {actions.map((item, idx) => (