Skip to main content

Backend Requirement: CV Skill CRUD Mutations

Status

ScopeStatusNotes
Jobseeker mutations (addMyCvSkill, updateMyCvSkill, removeMyCvSkill)DONEBackend implemented, frontend wired up
Company-scoped mutations (addCvSkill, updateCvSkill, removeCvSkill)DONEBackend + frontend fully integrated

Part 1: Jobseeker Mutations (DONE)

The My mutations are fully implemented on both backend and frontend. They verify cv.userId === currentUser.id (job seeker editing their own CV).

Mutations

OperationReturnsDescription
addMyCvSkill(cvId, input)CVSkill!Add a skill to a CV (find-or-create Skill record by name)
updateMyCvSkill(id, input)CVSkill!Update proficiency/yearsExperience on an existing CVSkill
removeMyCvSkill(id)SuccessResponse!Remove a CVSkill record

Input Types

input CreateCvSkillInput {
skillName: String! # Backend finds or creates the Skill record (case-insensitive)
proficiency: String # NONE | BASIC | INTERMEDIATE | ADVANCED | EXPERT
yearsExperience: Int
}

input UpdateCvSkillInput {
proficiency: String # NONE | BASIC | INTERMEDIATE | ADVANCED | EXPERT
yearsExperience: Int
}

Frontend Files

FileWhat was added
graphql/operations/cv/structuredMutations.tsADD_MY_CV_SKILL, UPDATE_MY_CV_SKILL, REMOVE_MY_CV_SKILL
lib/types/structuredCvInputs.tsCreateCvSkillInput, UpdateCvSkillInput interfaces
lib/types/skills.tsAdded _structuredId to SkillRequirementInput for diff tracking
lib/utils/cv/profileFormToInputs.tsskillToCreateInput(), skillToUpdateInput() mappers
lib/utils/cv/structuredCvToProfileForm.tsSkills carry _structuredId from CVSkill record ID
lib/hooks/useStructuredCvMutations.tsSkills diff + add/update/remove wired into save flow

Part 2: Company-Scoped Mutations (DONE)

Context

The My mutations enforce cv.userId === currentUser.id, so they only work for job seekers editing their own CV. Company users editing a candidate's or talent's CV need mutations that verify company access instead of CV ownership.

The backend has company-scoped skill mutations using TalentAuthorizationService.checkWriteAccessToCV() for access control. The frontend is fully wired up via useStructuredCvMutations({ companyScoped: true }).

Available Mutations (Backend Implemented)

Same pattern as the My variants, but with company access checks via TalentAuthorizationService.checkWriteAccessToCV().

OperationReturnsDescription
addCvSkill(cvId, input)CVSkill!Add a skill to any CV the company has access to
updateCvSkill(id, input)CVSkill!Update proficiency/yearsExperience on a CVSkill
removeCvSkill(id)SuccessResponse!Remove a CVSkill record

Input Types

Same as the My variants:

input CreateCvSkillInput {
skillName: String! # Find-or-create Skill record (case-insensitive)
proficiency: String # NONE | BASIC | INTERMEDIATE | ADVANCED | EXPERT
yearsExperience: Int
}

input UpdateCvSkillInput {
proficiency: String # NONE | BASIC | INTERMEDIATE | ADVANCED | EXPERT
yearsExperience: Int
}

Access Control

Instead of cv.userId === currentUser.id, verify that:

  1. The CV belongs to a talent in a talent pool owned by the user's company, OR
  2. The CV belongs to a candidate on a job owned by the user's company

This matches how other company-scoped CV operations (like updateCvSchema) verify access.

async addCvSkill(userId: string, cvId: string, input: CreateCvSkillInput) {
return this.prisma.$transaction(async (tx) => {
// 1. Verify company access to this CV (not ownership)
const cv = await tx.cV.findUniqueOrThrow({
where: { id: cvId },
include: { user: { include: { talents: true } } },
});
await this.verifyCompanyAccess(tx, userId, cv);

// 2. Find or create the Skill record
let skill = await tx.skill.findFirst({
where: { name: { equals: input.skillName, mode: 'insensitive' } },
});
if (!skill) {
skill = await tx.skill.create({ data: { name: input.skillName } });
}

// 3. Upsert CVSkill
const cvSkill = await tx.cVSkill.upsert({
where: { cvId_skillId: { cvId, skillId: skill.id } },
create: {
cvId,
skillId: skill.id,
proficiency: input.proficiency ?? 'INTERMEDIATE',
yearsExperience: input.yearsExperience,
},
update: {
proficiency: input.proficiency ?? undefined,
yearsExperience: input.yearsExperience ?? undefined,
},
include: { skill: true },
});

// 4. Sync schemaData
await this.serializeStructuredToSchemaData(tx, cvId);

return cvSkill;
});
}

Backend Files (DONE)

FileChange
dto/cv-structured-inputs.tsReuses CreateCvSkillInput, UpdateCvSkillInput
cv-structured-mutation.resolver.tsaddCvSkill, updateCvSkill, removeCvSkill resolvers added
cv-structured-mutation.service.tsService methods with TalentAuthorizationService.checkWriteAccessToCV() + transaction

Frontend Files (DONE)

FileChange
graphql/operations/cv/structuredMutations.tsAdded ADD_CV_SKILL, UPDATE_CV_SKILL, REMOVE_CV_SKILL mutation definitions
lib/hooks/useStructuredCvMutations.tsAdded companyScoped option + saveSkillsOnly() method
app/company/resume/candidates/_components/UpdateCandidateForm.tsxUses useStructuredCvMutations({ companyScoped: true }) with diff-based skill saves

Broader Context: Company-Scoped Structured Mutations

The same My-only limitation applies to all structured mutations, not just skills. Skills are now fully covered (both jobseeker and company-scoped). The remaining tables still need company-scoped variants:

Jobseeker (exists)Company (needed)
updateMyCvProfileupdateCvProfile
updateMyCvJobPreferenceupdateCvJobPreference
addMyCvWorkExperienceaddCvWorkExperience
addMyCvSkilladdCvSkill
......

Skills are done. The remaining tables are a larger effort but needed since parsedCV has been removed from the backend schema entirely.


Data Flow

Jobseeker (Working)

User edits skills in CV builder
|
v
Form state: { _structuredId, id, name, level, proficiency, mustHave, weight }
|
v
Diff algorithm compares original vs current skills (by _structuredId)
|
+--> Added skills --> addMyCvSkill(cvId, { skillName, proficiency })
+--> Changed skills --> updateMyCvSkill(id, { proficiency })
+--> Removed skills --> removeMyCvSkill(id)
|
v
Backend: CVSkill write + Skill find-or-create + serializeStructuredToSchemaData()

Company (DONE)

Company user edits candidate/talent skills
|
v
Same form state and diff logic
|
+--> Added skills --> addCvSkill(cvId, { skillName, proficiency })
+--> Changed skills --> updateCvSkill(id, { proficiency })
+--> Removed skills --> removeCvSkill(id)
|
v
Backend: Same logic but with company access check instead of ownership check