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 ? (
+
+ ) : 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) => (