From 05cba437a922be24de0517ea1e6bb2d36475efc3 Mon Sep 17 00:00:00 2001 From: Akshay Chikhalkar <84782757+AkshayChikhalkar@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:33:46 +0200 Subject: [PATCH] fix(job-form): show resumes in dropdown and normalize resumeId Closes #91 --- __tests__/job.actions.spec.ts | 76 +++++++++++++++++++++++++++++++- src/actions/job.actions.ts | 29 ++++++++++-- src/actions/profile.actions.ts | 10 +---- src/components/myjobs/AddJob.tsx | 21 +++++---- src/lib/resumeOwnership.ts | 13 ++++++ src/models/addJobForm.schema.ts | 11 ++++- 6 files changed, 134 insertions(+), 26 deletions(-) create mode 100644 src/lib/resumeOwnership.ts diff --git a/__tests__/job.actions.spec.ts b/__tests__/job.actions.spec.ts index 1912ba02..717f9236 100644 --- a/__tests__/job.actions.spec.ts +++ b/__tests__/job.actions.spec.ts @@ -44,6 +44,9 @@ vi.mock("@prisma/client", () => { create: vi.fn(), update: vi.fn(), }, + resume: { + findUnique: vi.fn(), + }, }; return { PrismaClient: vi.fn(function () { @@ -929,6 +932,7 @@ describe("jobActions", () => { expect(result).toStrictEqual({ data: jobData, success: true }); expect(prisma.job.create).toHaveBeenCalledTimes(1); + expect(prisma.resume.findUnique).not.toHaveBeenCalled(); expect(prisma.job.create).toHaveBeenCalledWith({ data: { jobTitleId: jobData.title, @@ -942,10 +946,12 @@ describe("jobActions", () => { appliedDate: jobData.dateApplied, description: jobData.jobDescription, jobType: jobData.type, + workplaceType: undefined, userId: mockUser.id, jobUrl: jobData.jobUrl, applied: jobData.applied, - resumeId: jobData.resume, + resumeId: null, + coverLetterId: null, }, }); }); @@ -970,15 +976,61 @@ describe("jobActions", () => { salaryRange: jobData.salaryRange, createdAt: jobData.createdAt, dueDate: jobData.dueDate, + appliedDate: undefined, description: jobData.jobDescription, jobType: jobData.type, + workplaceType: undefined, userId: mockUser.id, + jobUrl: undefined, applied: jobData.applied, - resumeId: jobData.resume, + resumeId: null, + coverLetterId: null, }, }); expect(result).toEqual({ data: jobData, success: true }); }); + it("should normalize empty resume to null and skip ownership check", async () => { + (getCurrentUser as any).mockResolvedValue(mockUser); + (prisma.job.create as any).mockResolvedValue(jobData); + + await addJob({ ...jobData, resume: " " }); + + expect(prisma.resume.findUnique).not.toHaveBeenCalled(); + expect(prisma.job.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ resumeId: null }), + }), + ); + }); + it("should validate resume ownership when resumeId is provided", async () => { + (getCurrentUser as any).mockResolvedValue(mockUser); + (prisma.resume.findUnique as any).mockResolvedValue({ id: "resume-1" }); + (prisma.job.create as any).mockResolvedValue(jobData); + + await addJob({ ...jobData, resume: "resume-1" }); + + expect(prisma.resume.findUnique).toHaveBeenCalledWith({ + where: { id: "resume-1", profile: { userId: mockUser.id } }, + select: { id: true }, + }); + expect(prisma.job.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ resumeId: "resume-1" }), + }), + ); + }); + it("should reject create when resume is not owned by the user", async () => { + (getCurrentUser as any).mockResolvedValue(mockUser); + (prisma.resume.findUnique as any).mockResolvedValue(null); + + await expect( + addJob({ ...jobData, resume: "someone-elses-resume" }), + ).resolves.toStrictEqual({ + success: false, + message: "Resume not found or access denied", + }); + expect(prisma.job.create).not.toHaveBeenCalled(); + }); it("should handle unexpected errors", async () => { (getCurrentUser as any).mockResolvedValue(mockUser); @@ -1009,6 +1061,26 @@ describe("jobActions", () => { expect(result).toStrictEqual({ data: jobData, success: true }); expect(prisma.job.update).toHaveBeenCalledTimes(1); + expect(prisma.job.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + resumeId: null, + coverLetterId: null, + }), + }), + ); + }); + it("should reject update when resume is not owned by the user", async () => { + (getCurrentUser as any).mockResolvedValue(mockUser); + (prisma.resume.findUnique as any).mockResolvedValue(null); + + await expect( + updateJob({ ...jobData, resume: "someone-elses-resume" }), + ).resolves.toStrictEqual({ + success: false, + message: "Resume not found or access denied", + }); + expect(prisma.job.update).not.toHaveBeenCalled(); }); it("should handle unexpected errors", async () => { (getCurrentUser as any).mockResolvedValue(mockUser); diff --git a/src/actions/job.actions.ts b/src/actions/job.actions.ts index 2df86303..d49040cd 100644 --- a/src/actions/job.actions.ts +++ b/src/actions/job.actions.ts @@ -6,9 +6,17 @@ import { AddJobFormSchema } from "@/models/addJobForm.schema"; import { JOB_TYPES, JobStatus } from "@/models/job.model"; import { getCurrentUser } from "@/utils/user.utils"; import { APP_CONSTANTS } from "@/lib/constants"; +import { assertResumeOwnership } from "@/lib/resumeOwnership"; import { revalidatePath } from "next/cache"; import { z } from "zod"; +/** Coerce empty/whitespace optional FK strings to null (avoids Prisma P2003). */ +const optionalFkId = (value?: string | null): string | null => { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +}; + export const getStatusList = async (): Promise => { try { const statuses = await prisma.jobStatus.findMany(); @@ -355,6 +363,13 @@ export const addJob = async ( tags, } = data; + const resumeId = optionalFkId(resume); + const coverLetterId = optionalFkId(coverLetter); + + if (resumeId) { + await assertResumeOwnership(resumeId, user.id); + } + const job = await createJobRecord({ jobTitleId: title, companyId: company, @@ -370,8 +385,8 @@ export const addJob = async ( userId: user.id, jobUrl, applied, - resumeId: resume, - coverLetterId: coverLetter, + resumeId, + coverLetterId, tagIds: tags ?? [], }); revalidatePath("/dashboard"); @@ -415,8 +430,14 @@ export const updateJob = async ( tags, } = data; + const resumeId = optionalFkId(resume); + const coverLetterId = optionalFkId(coverLetter); const tagIds = tags ?? []; + if (resumeId) { + await assertResumeOwnership(resumeId, user.id); + } + const job = await prisma.job.update({ where: { id, @@ -437,8 +458,8 @@ export const updateJob = async ( workplaceType, jobUrl, applied, - resumeId: resume, - coverLetterId: coverLetter, + resumeId, + coverLetterId, tags: { set: tagIds.map((id) => ({ id })) }, }, }); diff --git a/src/actions/profile.actions.ts b/src/actions/profile.actions.ts index ff175d77..47413199 100644 --- a/src/actions/profile.actions.ts +++ b/src/actions/profile.actions.ts @@ -20,21 +20,13 @@ import { hasMinResumeSections, } from "@/lib/resumeSections"; import { buildCopyTitle, ensureUniqueTitle } from "@/lib/resumeCopyTitle"; +import { assertResumeOwnership } from "@/lib/resumeOwnership"; import { revalidatePath } from "next/cache"; import { z } from "zod"; import path from "path"; import fs from "fs"; import { writeFile } from "fs/promises"; -// Canonical IDOR guard for actions that only need to confirm resume ownership -const assertResumeOwnership = async (resumeId: string, userId: string) => { - const owned = await prisma.resume.findUnique({ - where: { id: resumeId, profile: { userId } }, - select: { id: true }, - }); - if (!owned) throw new Error("Resume not found or access denied"); -}; - const resumeListSelect = { id: true, profileId: true, diff --git a/src/components/myjobs/AddJob.tsx b/src/components/myjobs/AddJob.tsx index ca6ab834..8b58246f 100644 --- a/src/components/myjobs/AddJob.tsx +++ b/src/components/myjobs/AddJob.tsx @@ -118,25 +118,28 @@ export function AddJob({ const loadResumes = useCallback(async () => { try { - const resumes = await getResumeList( - 1, - APP_CONSTANTS.RECORDS_PER_PAGE, - APP_CONSTANTS.MIN_RESUME_SECTIONS_FOR_SELECTION, - ); - setResumes(resumes.data); + // Load all of the user's resumes (minSections=0). The section-count + // filter is for AI/automation pickers only; job assignment should list + // every resume the user can see on Profile. + const result = await getResumeList(1, APP_CONSTANTS.RECORDS_PER_PAGE); + if (result?.success) { + setResumes(result.data ?? []); + } } catch (error) { console.error("Failed to load resumes:", error); } - }, [setResumes]); + }, []); const loadCoverLetters = useCallback(async () => { try { const result = await getCoverLetterList(1, 100); - setCoverLetters(result.data); + if (result?.success) { + setCoverLetters(result.data ?? []); + } } catch (error) { console.error("Failed to load cover letters:", error); } - }, [setCoverLetters]); + }, []); useEffect(() => { if (editJob) { diff --git a/src/lib/resumeOwnership.ts b/src/lib/resumeOwnership.ts new file mode 100644 index 00000000..7d986ddf --- /dev/null +++ b/src/lib/resumeOwnership.ts @@ -0,0 +1,13 @@ +import prisma from "@/lib/db"; + +/** Canonical IDOR guard: resume must exist and belong to the given user. */ +export const assertResumeOwnership = async ( + resumeId: string, + userId: string, +) => { + const owned = await prisma.resume.findUnique({ + where: { id: resumeId, profile: { userId } }, + select: { id: true }, + }); + if (!owned) throw new Error("Resume not found or access denied"); +}; diff --git a/src/models/addJobForm.schema.ts b/src/models/addJobForm.schema.ts index 89535fb1..000e0d48 100644 --- a/src/models/addJobForm.schema.ts +++ b/src/models/addJobForm.schema.ts @@ -59,7 +59,14 @@ export const AddJobFormSchema = z.object({ }), jobUrl: z.string().optional(), applied: z.boolean().default(false), - resume: z.string().optional(), - coverLetter: z.string().optional(), + // Empty select values must not become FK strings (Prisma P2003). + resume: z + .string() + .optional() + .transform((val) => (val && val.trim() !== "" ? val : undefined)), + coverLetter: z + .string() + .optional() + .transform((val) => (val && val.trim() !== "" ? val : undefined)), tags: z.array(z.string()).max(APP_CONSTANTS.MAX_JOB_TAGS).optional().default([]), });