Skip to main content

Frontend Migration: Structured CV Tables

Migration Complete

This migration is complete. The legacy ParsedCV, CVField, CVFieldValue, and CVGroupFields models have been removed from the Prisma schema and codebase. The 11 structured CV tables are now the sole source of truth. This document is preserved as a historical reference.

This guide covers migrating the frontend from the legacy flat parsedCV.schemaData JSON blob to the new typed GraphQL field resolvers on the CVType. The backend now exposes 11 structured CV tables as first-class GraphQL fields, giving the frontend type-safe access to CV data without parsing untyped JSON.

Overview of the Change

Before (Legacy)

CV data is stored in a single ParsedCV.schemaData column as an opaque JSON blob. The frontend fetches this blob and manually extracts fields using loosely typed interfaces (BackendCvSchema, BackendPersonalInfo, BackendExperience, etc. in lib/types/cv.ts). Field names are inconsistent between extraction pipelines (e.g., startDate vs start_date, institute vs institution, jobTitle vs title vs position).

CVType
└── parsedCV: ParsedCV
└── schemaData: JSON <-- untyped blob, shape varies by extraction pipeline

After (Structured)

CV data is written to 11 normalized relational tables, each exposed as a typed GraphQL field on CVType. The frontend queries exactly the fields it needs with full type safety and IDE autocomplete. Field names are consistent and well-defined.

CVType
├── profile: CvProfile (1:1, nullable)
├── jobPreference: CvJobPreference (1:1, nullable)
├── workExperiences: [CvWorkExperience!]!
├── educations: [CvEducation!]!
├── certifications: [CvCertification!]!
├── languages: [CvLanguage!]!
├── projects: [CvProject!]!
├── publications: [CvPublication!]!
├── awards: [CvAward!]!
├── volunteers: [CvVolunteer!]!
└── cvMetadata: CvMetadata (1:1, nullable)

Dual-Write Coexistence

Both formats coexist. When a CV is parsed, the backend writes to both the legacy ParsedCV.schemaData blob and the 11 structured tables (dual-write). This means:

  • You can migrate frontend components one at a time -- no big-bang switchover required.
  • The legacy parsedCV.schemaData field remains available and populated throughout the migration.
  • Once all frontend consumers have migrated, the legacy path can be deprecated and eventually removed.
Gradual Migration

Start by migrating read-only display components (talent profile views, candidate detail panels). Then move to write paths (CV builder, onboarding forms) using the 57 typed mutations (27 job seeker + 30 company-scoped) now available.

Complete Field Reference

CvProfile

Personal and professional profile data. Singular field on CVType (one profile per CV).

GraphQL FieldTypeNullableDescription
idID!NoRecord ID
cvIdID!NoParent CV ID
firstNameStringYesFirst name
middleNameStringYesMiddle name
lastNameStringYesLast name
fullNameStringYesFull name (display string)
titleStringYesProfessional title / headline
emailStringYesEmail address
phoneStringYesPhone number
addressStreetStringYesStreet address
addressCityStringYesCity
addressStateStringYesState / province
addressZipCodeStringYesZIP / postal code
addressCountryStringYesCountry
locationStringYesGeneral location string
linkedinUrlStringYesLinkedIn profile URL
portfolioUrlStringYesPortfolio / website URL
profileImageUrlStringYesProfile photo URL
dateOfBirthDateTimeYesDate of birth
genderStringYesGender
preferredLanguageStringYesPreferred language
biographyStringYesProfessional summary / bio
yearsOfExperienceIntYesTotal years of professional experience
currentRoleStringYesCurrent job title / role
industries[String!]!NoIndustry sectors
specializations[String!]!NoAreas of specialization
willingToRelocateBooleanYesRelocation willingness
preferredLocationStringYesPreferred work location
createdAtDateTime!NoCreation timestamp
updatedAtDateTime!NoLast update timestamp

CvJobPreference

Job search preferences and salary expectations. Singular field (one per CV).

GraphQL FieldTypeNullableDescription
idID!NoRecord ID
cvIdID!NoParent CV ID
desiredJobTitles[String!]!NoDesired job titles
preferredJobTypes[String!]!NoPreferred job types (e.g., FREELANCE_GIG, DIRECT_HIRE)
preferredWorkSiteStringYesWork site preference (REMOTE, ONSITE, HYBRID)
availableInStringYesAvailability timeline (e.g., IMMEDIATELY, ONE_MONTH)
expectedSalaryFloatYesExpected salary amount
salaryCurrencyStringYesCurrency code (default: USD)
salaryFrequencyStringYesPay frequency (default: MONTHLY)
createdAtDateTime!NoCreation timestamp
updatedAtDateTime!NoLast update timestamp

CvWorkExperience

Work history entries. Array field, ordered by the order field.

GraphQL FieldTypeNullableDescription
idID!NoRecord ID
cvIdID!NoParent CV ID
jobTitleString!NoJob title
companyNameString!NoCompany / employer name
startDateDateTimeYesStart date
endDateDateTimeYesEnd date (null = current position)
locationStringYesWork location
descriptionStringYesRole description
achievements[String!]!NoKey achievements
technologies[String!]!NoTechnologies used
isCurrentBoolean!NoWhether this is the current position (default: false)
durationStringYesComputed duration string
orderInt!NoDisplay order (0-indexed)
createdAtDateTime!NoCreation timestamp
updatedAtDateTime!NoLast update timestamp

CvEducation

Education history entries. Array field, ordered by order.

GraphQL FieldTypeNullableDescription
idID!NoRecord ID
cvIdID!NoParent CV ID
degreeString!NoDegree name
fieldOfStudyStringYesField of study / major
institutionString!NoInstitution name
graduationYearIntYesGraduation year (integer)
startDateDateTimeYesEducation start date
endDateDateTimeYesEducation end date
gpaFloatYesGPA
honors[String!]!NoHonors and distinctions
locationStringYesInstitution location
relevantCoursework[String!]!NoRelevant courses
orderInt!NoDisplay order (0-indexed)
createdAtDateTime!NoCreation timestamp
updatedAtDateTime!NoLast update timestamp

CvCertification

Professional certifications. Array field, ordered by order.

GraphQL FieldTypeNullableDescription
idID!NoRecord ID
cvIdID!NoParent CV ID
nameString!NoCertification name
issuingOrganizationStringYesIssuing organization
issueDateDateTimeYesDate issued
expirationDateDateTimeYesExpiration date
credentialIdStringYesCredential ID / number
verificationUrlStringYesVerification URL
locationStringYesLocation where certification was obtained
statusCvCertificationStatus!NoStatus: ACTIVE, EXPIRED, or PENDING (default: ACTIVE)
orderInt!NoDisplay order (0-indexed)
createdAtDateTime!NoCreation timestamp
updatedAtDateTime!NoLast update timestamp

CvLanguage

Language proficiencies. Array field, ordered by order.

GraphQL FieldTypeNullableDescription
idID!NoRecord ID
cvIdID!NoParent CV ID
languageString!NoLanguage name
proficiencyStringYesProficiency level
certificationStringYesLanguage certification (e.g., TOEFL, DELF)
orderInt!NoDisplay order (0-indexed)
createdAtDateTime!NoCreation timestamp
updatedAtDateTime!NoLast update timestamp

CvProject

Project portfolio entries. Array field, ordered by order.

GraphQL FieldTypeNullableDescription
idID!NoRecord ID
cvIdID!NoParent CV ID
nameString!NoProject name
descriptionStringYesProject description
roleStringYesRole in the project
technologies[String!]!NoTechnologies used
startDateDateTimeYesStart date
endDateDateTimeYesEnd date
urlStringYesProject URL
achievements[String!]!NoKey achievements
orderInt!NoDisplay order (0-indexed)
createdAtDateTime!NoCreation timestamp
updatedAtDateTime!NoLast update timestamp

CvPublication

Published works. Array field, ordered by order.

GraphQL FieldTypeNullableDescription
idID!NoRecord ID
cvIdID!NoParent CV ID
titleString!NoPublication title
typeStringYesPublication type (e.g., journal, conference, book)
publisherStringYesPublisher name
dateDateTimeYesPublication date
urlStringYesPublication URL
coAuthors[String!]!NoCo-author names
orderInt!NoDisplay order (0-indexed)
createdAtDateTime!NoCreation timestamp
updatedAtDateTime!NoLast update timestamp

CvAward

Awards and honors. Array field, ordered by order.

GraphQL FieldTypeNullableDescription
idID!NoRecord ID
cvIdID!NoParent CV ID
titleString!NoAward title
issuingOrganizationStringYesIssuing organization
dateDateTimeYesDate received
descriptionStringYesAward description
orderInt!NoDisplay order (0-indexed)
createdAtDateTime!NoCreation timestamp
updatedAtDateTime!NoLast update timestamp

CvVolunteer

Volunteer experience. Array field, ordered by order.

GraphQL FieldTypeNullableDescription
idID!NoRecord ID
cvIdID!NoParent CV ID
organizationString!NoOrganization name
roleStringYesRole / position
startDateDateTimeYesStart date
endDateDateTimeYesEnd date
descriptionStringYesDescription
skills[String!]!NoSkills applied
orderInt!NoDisplay order (0-indexed)
createdAtDateTime!NoCreation timestamp
updatedAtDateTime!NoLast update timestamp

CvMetadata

Parsing metadata and document quality information. Singular field (one per CV).

GraphQL FieldTypeNullableDescription
idID!NoRecord ID
cvIdID!NoParent CV ID
versionStringYesSchema/parser version
parsedAtDateTimeYesWhen the CV was parsed
confidenceFloatYesOverall parsing confidence (0.0 -- 1.0)
extractionMethodStringYesExtraction method used
qualityScoreFloatYesDocument quality score
documentFormatStringYesOriginal document format (PDF, DOCX, etc.)
pageCountIntYesNumber of pages
wordCountIntYesWord count
sections[String!]!NoDetected CV sections
sourceStringYesSource identifier
dataSourceStringYesData source descriptor
warnings[String!]!NoParsing warnings
suggestions[String!]!NoImprovement suggestions
createdAtDateTime!NoCreation timestamp
updatedAtDateTime!NoLast update timestamp

Before/After Examples

Fetching CV Profile Data

Before -- Parse schemaData JSON manually:

// graphql/operations/cv/queries.ts
const GET_CV_BY_ID = gql`
query GetCV($id: ID!) {
cv(id: $id) {
id
url
parsedCV {
schemaData
}
}
}
`;

// In a component
const { data } = useQuery(GET_CV_BY_ID, { variables: { id: cvId } });
const schema = data?.cv?.parsedCV?.schemaData as BackendCvSchema | null;

// Manually extract with fallbacks for inconsistent field names
const firstName = schema?.personalInfo?.firstName ?? schema?.contact?.first_name ?? '';
const email = schema?.personalInfo?.email ?? '';
const linkedin = schema?.links?.linkedin ?? schema?.personalInfo?.linkedinUrl
?? schema?.qualifications?.linkedinUrl ?? '';

After -- Query typed fields directly:

const GET_CV_BY_ID = gql`
query GetCV($id: ID!) {
cv(id: $id) {
id
url
profile {
firstName
lastName
fullName
email
phone
title
location
linkedinUrl
portfolioUrl
biography
yearsOfExperience
}
}
}
`;

// In a component -- no manual parsing, no fallbacks
const { data } = useQuery(GET_CV_BY_ID, { variables: { id: cvId } });
const profile = data?.cv?.profile;
const firstName = profile?.firstName ?? '';
const email = profile?.email ?? '';
const linkedin = profile?.linkedinUrl ?? '';

Fetching Work Experience

Before -- Multiple possible field names for the same concept:

const schema = data?.cv?.parsedCV?.schemaData as BackendCvSchema;

const experiences = (schema?.experience ?? []).map((e) => ({
title: e.jobTitle || e.title || e.position || e.role || '',
company: e.company || e.employer || '',
startDate: e.startDate || e.start_date || '',
endDate: e.endDate || e.end_date || '',
description: e.description || e.experience || '',
}));

After -- Consistent field names, no aliasing:

const GET_CV_EXPERIENCE = gql`
query GetCV($id: ID!) {
cv(id: $id) {
id
workExperiences {
id
jobTitle
companyName
startDate
endDate
description
location
achievements
technologies
isCurrent
order
}
}
}
`;

const experiences = data?.cv?.workExperiences ?? [];
// Already typed and sorted by order -- no mapping or normalization needed

Fetching a Complete CV

Before -- Fetch the entire JSON blob and destructure in code:

const GET_FULL_CV = gql`
query GetCV($id: ID!) {
cv(id: $id) {
id
url
parsedCV {
schemaData # Single JSON blob with everything
}
}
}
`;

After -- Query only what you need with typed fragments:

query GetFullCV($id: ID!) {
cv(id: $id) {
id
url

profile {
firstName
lastName
fullName
email
phone
title
location
linkedinUrl
portfolioUrl
biography
yearsOfExperience
currentRole
industries
specializations
}

jobPreference {
desiredJobTitles
preferredJobTypes
preferredWorkSite
availableIn
expectedSalary
salaryCurrency
salaryFrequency
}

workExperiences {
id
jobTitle
companyName
startDate
endDate
location
description
achievements
technologies
isCurrent
order
}

educations {
id
degree
fieldOfStudy
institution
graduationYear
startDate
endDate
gpa
honors
location
order
}

certifications {
id
name
issuingOrganization
issueDate
expirationDate
credentialId
verificationUrl
location
status
order
}

languages {
id
language
proficiency
certification
order
}

projects {
id
name
description
role
technologies
startDate
endDate
url
achievements
order
}

publications {
id
title
type
publisher
date
url
coAuthors
order
}

awards {
id
title
issuingOrganization
date
description
order
}

volunteers {
id
organization
role
startDate
endDate
description
skills
order
}

cvMetadata {
confidence
qualityScore
documentFormat
pageCount
wordCount
sections
warnings
suggestions
}
}
}
Performance

You do not need to query all 11 types at once. The structured fields are resolved lazily via DataLoaders -- the backend only hits the database for fields you actually include in your query. Query only the types your component needs.

GraphQL Fragment Definitions

To keep queries DRY, define reusable fragments. These can be added to graphql/operations/schema/cv/fragments.ts:

import { gql } from "@apollo/client";

export const CV_PROFILE_FIELDS = gql`
fragment CvProfileFields on CvProfile {
id
firstName
middleName
lastName
fullName
title
email
phone
addressCity
addressState
addressCountry
location
linkedinUrl
portfolioUrl
profileImageUrl
biography
yearsOfExperience
currentRole
industries
specializations
willingToRelocate
preferredLocation
}
`;

export const CV_JOB_PREFERENCE_FIELDS = gql`
fragment CvJobPreferenceFields on CvJobPreference {
id
desiredJobTitles
preferredJobTypes
preferredWorkSite
availableIn
expectedSalary
salaryCurrency
salaryFrequency
}
`;

export const CV_WORK_EXPERIENCE_FIELDS = gql`
fragment CvWorkExperienceFields on CvWorkExperience {
id
jobTitle
companyName
startDate
endDate
location
description
achievements
technologies
isCurrent
duration
order
}
`;

export const CV_EDUCATION_FIELDS = gql`
fragment CvEducationFields on CvEducation {
id
degree
fieldOfStudy
institution
graduationYear
startDate
endDate
gpa
honors
location
relevantCoursework
order
}
`;

export const CV_CERTIFICATION_FIELDS = gql`
fragment CvCertificationFields on CvCertification {
id
name
issuingOrganization
issueDate
expirationDate
credentialId
verificationUrl
location
status
order
}
`;

export const CV_LANGUAGE_FIELDS = gql`
fragment CvLanguageFields on CvLanguage {
id
language
proficiency
certification
order
}
`;

export const CV_PROJECT_FIELDS = gql`
fragment CvProjectFields on CvProject {
id
name
description
role
technologies
startDate
endDate
url
achievements
order
}
`;

export const CV_METADATA_FIELDS = gql`
fragment CvMetadataFields on CvMetadata {
id
confidence
qualityScore
documentFormat
pageCount
wordCount
sections
warnings
suggestions
}
`;

Use the fragments in queries:

import { CV_PROFILE_FIELDS, CV_WORK_EXPERIENCE_FIELDS } from "../schema/cv/fragments";

export const GET_CV_PROFILE = gql`
${CV_PROFILE_FIELDS}
${CV_WORK_EXPERIENCE_FIELDS}
query GetCvProfile($id: ID!) {
cv(id: $id) {
id
url
profile { ...CvProfileFields }
workExperiences { ...CvWorkExperienceFields }
}
}
`;

Step-by-Step Migration Guide

Step 1: Add Structured Fragments

Add the new fragment definitions to graphql/operations/schema/cv/fragments.ts alongside the existing CV_FIELDS and PARSED_CV_FIELDS fragments. Keep the legacy fragments -- they are still needed by unmigrated components.

Step 2: Create TypeScript Interfaces (Optional)

If you want type-safe access before codegen is set up, create interfaces matching the GraphQL types. However, since these types mirror the backend entities exactly, you can also rely on Apollo Client's generic typing or GraphQL codegen output.

// lib/types/structured-cv.ts
export interface CvProfile {
id: string;
firstName?: string | null;
lastName?: string | null;
fullName?: string | null;
email?: string | null;
phone?: string | null;
title?: string | null;
location?: string | null;
linkedinUrl?: string | null;
portfolioUrl?: string | null;
biography?: string | null;
yearsOfExperience?: number | null;
currentRole?: string | null;
industries: string[];
specializations: string[];
// ... remaining fields
}

export interface CvWorkExperience {
id: string;
jobTitle: string;
companyName: string;
startDate?: string | null;
endDate?: string | null;
location?: string | null;
description?: string | null;
achievements: string[];
technologies: string[];
isCurrent: boolean;
order: number;
}

// ... similar interfaces for other types

Step 3: Update Queries Incrementally

Migrate queries one at a time. Start with simple read-only pages that display CV data. For each query:

  1. Add the structured fields alongside parsedCV.schemaData (both will be returned).
  2. Update the component to read from the new typed fields.
  3. Add a fallback to schemaData for CVs that were parsed before dual-write was enabled (if any exist).
  4. Once confirmed working, remove the parsedCV.schemaData selection from the query.

Example incremental migration (talent profile view):

// Phase 1: Query both, prefer structured
const GET_TALENT_CV = gql`
query GetTalentCV($id: ID!) {
cv(id: $id) {
id
# New structured fields
profile {
firstName
lastName
email
phone
biography
}
workExperiences {
jobTitle
companyName
startDate
endDate
description
}
# Legacy fallback (remove after migration)
parsedCV {
schemaData
}
}
}
`;

// Phase 2: Use structured with fallback
const profile = data?.cv?.profile;
const legacyInfo = (data?.cv?.parsedCV?.schemaData as any)?.personalInfo;

const firstName = profile?.firstName ?? legacyInfo?.firstName ?? '';
const email = profile?.email ?? legacyInfo?.email ?? '';

Step 4: Migrate the cvMapper Utility

The lib/utils/cv/cvMapper.ts file contains schemaToProfile() which maps the legacy BackendCvSchema to form data. Create a parallel function that maps from the structured types:

// lib/utils/cv/structuredCvMapper.ts
import type { ProfileFormData } from "@lib/types/cvbuilder";

export function structuredCvToProfile(cv: {
profile?: CvProfile | null;
workExperiences?: CvWorkExperience[];
educations?: CvEducation[];
languages?: CvLanguage[];
certifications?: CvCertification[];
}): Partial<ProfileFormData> {
const p = cv.profile;
if (!p) return {};

return {
firstName: p.firstName ?? '',
lastName: p.lastName ?? '',
email: p.email ?? '',
phone: p.phone ?? '',
title: p.title ?? '',
location: p.location ?? '',
linkedin: p.linkedinUrl ?? '',
portfolio: p.portfolioUrl ?? '',
description: p.biography ?? '',

experiences: (cv.workExperiences ?? []).map((e) => ({
title: e.jobTitle,
company: e.companyName,
startDate: e.startDate ?? '',
endDate: e.endDate ?? '',
location: e.location ?? '',
description: e.description ?? '',
})),

education: (cv.educations ?? []).map((ed) => ({
title: ed.degree,
institute: ed.institution,
startDate: ed.startDate ?? '',
endDate: ed.endDate ?? (ed.graduationYear ? String(ed.graduationYear) : ''),
location: ed.location ?? '',
})),

skills: [], // Skills come from CVSkill, not structured CV tables

languages: (cv.languages ?? []).map((l) => ({
id: l.id,
name: l.language,
level: l.proficiency ?? 'Beginner',
})),

certificates: (cv.certifications ?? []).map((c) => ({
title: c.name,
institute: c.issuingOrganization ?? '',
year: c.issueDate ? new Date(c.issueDate).getFullYear().toString() : '',
location: '',
})),
};
}

Step 5: Migrate Candidate and Talent List Views

Several components display CV data in list/table format using schemaData:

FileComponentWhat it reads
lib/utils/candidates/candidateRowBuilder.tsCandidate table rowsName, email, skills from schemaData
lib/utils/candidates/talentRowBuilder.tsTalent table rowsName, title, location from schemaData
lib/export/profileFormToExportable.tsJSON/PDF/DOCX exportAll CV fields including education dates and certification location
lib/export/pdfDocument.tsxPDF exportRenders education dates, certification location
lib/export/docxDocument.tsDOCX exportRenders education dates, certification location
app/company/resume/candidates/page.tsxCandidates pageschemaData via candidate query
app/company/resume/talentpool/view/[id]/page.tsxTalent pool viewschemaData via talent query

For each, update the GraphQL query to include the structured fields and update the data access code.

Step 6: Migrate Write Paths

The backend now exposes 57 typed mutations (27 job seeker + 30 company-scoped) for writing to structured CV tables. Migrate the write paths to use these instead of the deprecated updateCvSchema / updateMyCvSchema raw JSON mutations.

Files to migrate:

  • app/onboarding/jobseeker/ -- Onboarding flow that writes schemaData
  • app/jobseeker/resume/edit/[cvId]/page.tsx -- CV editor
  • lib/hooks/useCreateCv.ts -- CV creation hook
  • graphql/operations/cv/mutations.ts -- UPDATE_CV_SCHEMA mutation

Available typed mutations:

CategoryMutations
Profile (1:1)updateMyCvProfile(cvId, input)
Job Preference (1:1)updateMyCvJobPreference(cvId, input), deleteMyCvJobPreference(cvId)
Work Experience (1:N)addMyCvWorkExperience(cvId, input), updateMyCvWorkExperience(id, input), removeMyCvWorkExperience(id)
Education (1:N)addMyCvEducation(cvId, input), updateMyCvEducation(id, input), removeMyCvEducation(id)
Certification (1:N)addMyCvCertification(cvId, input), updateMyCvCertification(id, input), removeMyCvCertification(id)
Language (1:N)addMyCvLanguage(cvId, input), updateMyCvLanguage(id, input), removeMyCvLanguage(id)
Project (1:N)addMyCvProject(cvId, input), updateMyCvProject(id, input), removeMyCvProject(id)
Publication (1:N)addMyCvPublication(cvId, input), updateMyCvPublication(id, input), removeMyCvPublication(id)
Award (1:N)addMyCvAward(cvId, input), updateMyCvAward(id, input), removeMyCvAward(id)
Volunteer (1:N)addMyCvVolunteer(cvId, input), updateMyCvVolunteer(id, input), removeMyCvVolunteer(id)

Company-scoped variants (same inputs, different auth — uses TalentAuthorizationService):

Drop the "My" prefix: addCvEducation, updateCvProfile, removeCvWorkExperience, etc. All 10 sections have company-scoped variants (30 mutations total). See Recruitment API for the full list.

Save orchestrator hook:

import { useStructuredCvMutations } from "@hooks/useStructuredCvMutations"

// Job seeker editing own CV (ownership check)
const { saveStructuredCv, saving } = useStructuredCvMutations()

// Employer editing candidate/talent CV (company write access check)
const { saveStructuredCv, saving } = useStructuredCvMutations({ companyScoped: true })

// Save — diffs current vs original, dispatches only changed sections in parallel
await saveStructuredCv(cvId, currentFormData, originalFormData)

The hook automatically selects "My" vs company-scoped mutations based on the companyScoped flag. It diffs form state against the original snapshot and only sends mutations for changed sections.

Example: Define mutation and call it:

// graphql/operations/cv/mutations.ts
export const UPDATE_MY_CV_PROFILE = gql`
mutation UpdateMyCvProfile($cvId: ID!, $input: UpdateCvProfileInput!) {
updateMyCvProfile(cvId: $cvId, input: $input) {
id
firstName
lastName
email
phone
linkedinUrl
biography
}
}
`;

export const ADD_MY_CV_WORK_EXPERIENCE = gql`
mutation AddMyCvWorkExperience($cvId: ID!, $input: CreateCvWorkExperienceInput!) {
addMyCvWorkExperience(cvId: $cvId, input: $input) {
id
jobTitle
companyName
startDate
endDate
isCurrent
technologies
order
}
}
`;

export const UPDATE_MY_CV_WORK_EXPERIENCE = gql`
mutation UpdateMyCvWorkExperience($id: ID!, $input: UpdateCvWorkExperienceInput!) {
updateMyCvWorkExperience(id: $id, input: $input) {
id
jobTitle
companyName
startDate
endDate
}
}
`;

export const REMOVE_MY_CV_WORK_EXPERIENCE = gql`
mutation RemoveMyCvWorkExperience($id: ID!) {
removeMyCvWorkExperience(id: $id) {
success
message
}
}
`;

Example: Use in a component:

const [updateProfile] = useMutation(UPDATE_MY_CV_PROFILE);
const [addExperience] = useMutation(ADD_MY_CV_WORK_EXPERIENCE);

// Update profile fields
await updateProfile({
variables: {
cvId,
input: {
firstName: formData.firstName,
lastName: formData.lastName,
email: formData.email,
linkedinUrl: formData.linkedin,
biography: formData.description,
},
},
});

// Add a new work experience
await addExperience({
variables: {
cvId,
input: {
jobTitle: 'Senior Engineer',
companyName: 'Acme Corp',
startDate: '2023-01-15',
isCurrent: true,
technologies: ['TypeScript', 'React', 'NestJS'],
},
},
});
tip

Each typed mutation automatically syncs ParsedCV.schemaData for backward compatibility. The deprecated updateCvSchema / updateMyCvSchema mutations still work but should not be used for new features. See the full Structured CV Mutations API reference for all input types.

Field Name Mapping

This table maps the old schemaData field paths to the new structured GraphQL fields, covering the inconsistencies that the structured types eliminate:

Legacy Path(s) in schemaDataStructured FieldNotes
personalInfo.firstName, contact.first_nameprofile.firstNameConsistent field name
personalInfo.lastName, contact.last_nameprofile.lastNameConsistent field name
personalInfo.emailprofile.emailSingle source of truth
personalInfo.phoneNumber, contact.phoneprofile.phoneRenamed from phoneNumber
personalInfo.titleprofile.titleSame
personalInfo.location, contact.locationprofile.locationConsistent
links.linkedin, personalInfo.linkedinUrl, qualifications.linkedinUrlprofile.linkedinUrlSingle field instead of 3
links.portfolio, personalInfo.portfolioUrl, qualifications.portfolioUrlprofile.portfolioUrlSingle field instead of 3
summary, personalInfo.biographyprofile.biographyConsolidated
meta.birthDate, contact.date_of_birthprofile.dateOfBirthTyped as Date
meta.gender, contact.genderprofile.genderSingle location
meta.profileImageUrlprofile.profileImageUrlMoved to profile
meta.preferredJobTypesjobPreference.preferredJobTypesSeparate type
meta.expectedSalaryjobPreference.expectedSalaryTyped as Float
meta.salaryCurrencyjobPreference.salaryCurrencySame
meta.salaryFrequencyjobPreference.salaryFrequencySame
meta.availableInjobPreference.availableInSame
meta.preferredWorkSitejobPreference.preferredWorkSiteSame
meta.willingToRelocateprofile.willingToRelocateMoved to profile
meta.desiredJobTitlesjobPreference.desiredJobTitlesSeparate type
meta.preferenceLocation, meta.preferredLocationprofile.preferredLocationConsistent field name
experience[].jobTitle, .title, .position, .roleworkExperiences[].jobTitleOne canonical name
experience[].company, .employerworkExperiences[].companyNameRenamed to companyName
experience[].startDate, .start_dateworkExperiences[].startDateTyped as Date
experience[].endDate, .end_dateworkExperiences[].endDateTyped as Date
experience[].description, .experienceworkExperiences[].descriptionConsistent
education[].title (degree name)educations[].degreeRenamed for clarity
education[].institute, .institutioneducations[].institutionOne canonical name
education[].startDate/endDateeducations[].startDate/endDate + graduationYearFull dates now supported; graduationYear kept for backward compat
languages[].namelanguages[].languageRenamed to language
languages[].levellanguages[].proficiencyRenamed to proficiency
certifications[].titlecertifications[].nameRenamed to name
certifications[].institutecertifications[].issuingOrganizationMore descriptive name
certifications[].yearcertifications[].issueDateTyped as Date

PII Masking for Public Access

When CV data is accessed through public or unauthenticated routes (external share tokens, public talent pool views), the backend automatically masks sensitive PII fields. This happens transparently -- the frontend does not need to implement masking logic.

Masked Fields on CvProfile

The following fields return null for public/unauthenticated access:

FieldReason
emailContact information
phoneContact information
dateOfBirthProtected characteristic (GDPR Article 9)
genderProtected characteristic (GDPR Article 9)
addressStreetDetailed address
addressCityDetailed address
addressStateDetailed address
addressZipCodeDetailed address
profileImageUrlBiometric data risk

Fields that remain visible for public access: firstName, lastName, fullName, title, location, addressCountry, biography, linkedinUrl, portfolioUrl, currentRole, industries, specializations, yearsOfExperience, willingToRelocate, preferredLocation.

Masked Fields on CvJobPreference

FieldReason
expectedSalaryFinancial data
salaryCurrencyFinancial data
salaryFrequencyFinancial data

Fields that remain visible: desiredJobTitles, preferredJobTypes, preferredWorkSite, availableIn.

How Public Access Is Detected

The backend considers a request "public" when any of these are true:

  1. The request has no authenticated user (req.user is null).
  2. The request is explicitly marked as public access by middleware (req.isPublicAccess === true).
  3. The GraphQL query uses an external share resolver (talentPoolByToken, sharedTalentList, publicCV).
Frontend Impact

If your component handles both authenticated and public views (e.g., a talent profile page that can be accessed via share link), the same GraphQL query works for both cases. The backend handles masking automatically. Your component should handle null values gracefully for the masked fields.

Key Differences from Legacy Format

AspectLegacy schemaDataStructured Fields
Type safetyRecord<string, unknown> (untyped JSON)Fully typed GraphQL objects
Field namesInconsistent across extraction pipelinesCanonical, consistent names
FetchingAll-or-nothing (entire blob)Selective (query only what you need)
PerformanceFull JSON deserialized on every queryDataLoader-batched, lazy resolution
ValidationNone (any JSON shape accepted)Database constraints enforce schema
PII maskingNot supportedAutomatic for public access
New data typesNot availableProjects, publications, awards, volunteers, metadata
Array orderingDepends on JSON array orderExplicit order field on each item
IDs on itemsClient-generated or absentServer-generated UUIDs

Frequently Asked Questions

Q: Do I need to migrate all at once? No. The dual-write ensures both formats stay in sync. Migrate component by component. There is no deadline to finish, but new features should prefer the structured fields.

Q: What about CVs parsed before dual-write was enabled? A backfill migration populated the structured tables from existing schemaData for all historical CVs. If you encounter a CV where structured fields are empty but schemaData has data, report it as a bug.

Q: Will parsedCV.schemaData be removed? Eventually, yes. The updateCvSchema mutation is already marked as deprecated. 57 typed mutations (27 job seeker + 30 company-scoped) are now available as replacements. Once all frontend consumers have migrated to structured fields and typed mutations, the legacy path will be removed in a future release.

Q: Are the structured fields available for CVs created via the AI extraction subscription? Yes. The background-tasks CV extraction pipeline dual-writes to both the legacy ParsedCV.schemaData and the structured tables. The structured data is available immediately after extraction completes.

Q: How do I handle the order field? Array-type structured fields (work experiences, educations, certifications, etc.) include an order field (0-indexed integer). The backend returns items sorted by order. When building UIs with drag-and-drop reordering, update the order field on each item. The client is responsible for managing order values -- the server does not auto-reorder remaining items on insert or delete.