Frontend Migration: Structured CV Tables
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.schemaDatafield remains available and populated throughout the migration. - Once all frontend consumers have migrated, the legacy path can be deprecated and eventually removed.
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 Field | Type | Nullable | Description |
|---|---|---|---|
id | ID! | No | Record ID |
cvId | ID! | No | Parent CV ID |
firstName | String | Yes | First name |
middleName | String | Yes | Middle name |
lastName | String | Yes | Last name |
fullName | String | Yes | Full name (display string) |
title | String | Yes | Professional title / headline |
email | String | Yes | Email address |
phone | String | Yes | Phone number |
addressStreet | String | Yes | Street address |
addressCity | String | Yes | City |
addressState | String | Yes | State / province |
addressZipCode | String | Yes | ZIP / postal code |
addressCountry | String | Yes | Country |
location | String | Yes | General location string |
linkedinUrl | String | Yes | LinkedIn profile URL |
portfolioUrl | String | Yes | Portfolio / website URL |
profileImageUrl | String | Yes | Profile photo URL |
dateOfBirth | DateTime | Yes | Date of birth |
gender | String | Yes | Gender |
preferredLanguage | String | Yes | Preferred language |
biography | String | Yes | Professional summary / bio |
yearsOfExperience | Int | Yes | Total years of professional experience |
currentRole | String | Yes | Current job title / role |
industries | [String!]! | No | Industry sectors |
specializations | [String!]! | No | Areas of specialization |
willingToRelocate | Boolean | Yes | Relocation willingness |
preferredLocation | String | Yes | Preferred work location |
createdAt | DateTime! | No | Creation timestamp |
updatedAt | DateTime! | No | Last update timestamp |
CvJobPreference
Job search preferences and salary expectations. Singular field (one per CV).
| GraphQL Field | Type | Nullable | Description |
|---|---|---|---|
id | ID! | No | Record ID |
cvId | ID! | No | Parent CV ID |
desiredJobTitles | [String!]! | No | Desired job titles |
preferredJobTypes | [String!]! | No | Preferred job types (e.g., FREELANCE_GIG, DIRECT_HIRE) |
preferredWorkSite | String | Yes | Work site preference (REMOTE, ONSITE, HYBRID) |
availableIn | String | Yes | Availability timeline (e.g., IMMEDIATELY, ONE_MONTH) |
expectedSalary | Float | Yes | Expected salary amount |
salaryCurrency | String | Yes | Currency code (default: USD) |
salaryFrequency | String | Yes | Pay frequency (default: MONTHLY) |
createdAt | DateTime! | No | Creation timestamp |
updatedAt | DateTime! | No | Last update timestamp |
CvWorkExperience
Work history entries. Array field, ordered by the order field.
| GraphQL Field | Type | Nullable | Description |
|---|---|---|---|
id | ID! | No | Record ID |
cvId | ID! | No | Parent CV ID |
jobTitle | String! | No | Job title |
companyName | String! | No | Company / employer name |
startDate | DateTime | Yes | Start date |
endDate | DateTime | Yes | End date (null = current position) |
location | String | Yes | Work location |
description | String | Yes | Role description |
achievements | [String!]! | No | Key achievements |
technologies | [String!]! | No | Technologies used |
isCurrent | Boolean! | No | Whether this is the current position (default: false) |
duration | String | Yes | Computed duration string |
order | Int! | No | Display order (0-indexed) |
createdAt | DateTime! | No | Creation timestamp |
updatedAt | DateTime! | No | Last update timestamp |
CvEducation
Education history entries. Array field, ordered by order.
| GraphQL Field | Type | Nullable | Description |
|---|---|---|---|
id | ID! | No | Record ID |
cvId | ID! | No | Parent CV ID |
degree | String! | No | Degree name |
fieldOfStudy | String | Yes | Field of study / major |
institution | String! | No | Institution name |
graduationYear | Int | Yes | Graduation year (integer) |
startDate | DateTime | Yes | Education start date |
endDate | DateTime | Yes | Education end date |
gpa | Float | Yes | GPA |
honors | [String!]! | No | Honors and distinctions |
location | String | Yes | Institution location |
relevantCoursework | [String!]! | No | Relevant courses |
order | Int! | No | Display order (0-indexed) |
createdAt | DateTime! | No | Creation timestamp |
updatedAt | DateTime! | No | Last update timestamp |
CvCertification
Professional certifications. Array field, ordered by order.
| GraphQL Field | Type | Nullable | Description |
|---|---|---|---|
id | ID! | No | Record ID |
cvId | ID! | No | Parent CV ID |
name | String! | No | Certification name |
issuingOrganization | String | Yes | Issuing organization |
issueDate | DateTime | Yes | Date issued |
expirationDate | DateTime | Yes | Expiration date |
credentialId | String | Yes | Credential ID / number |
verificationUrl | String | Yes | Verification URL |
location | String | Yes | Location where certification was obtained |
status | CvCertificationStatus! | No | Status: ACTIVE, EXPIRED, or PENDING (default: ACTIVE) |
order | Int! | No | Display order (0-indexed) |
createdAt | DateTime! | No | Creation timestamp |
updatedAt | DateTime! | No | Last update timestamp |
CvLanguage
Language proficiencies. Array field, ordered by order.
| GraphQL Field | Type | Nullable | Description |
|---|---|---|---|
id | ID! | No | Record ID |
cvId | ID! | No | Parent CV ID |
language | String! | No | Language name |
proficiency | String | Yes | Proficiency level |
certification | String | Yes | Language certification (e.g., TOEFL, DELF) |
order | Int! | No | Display order (0-indexed) |
createdAt | DateTime! | No | Creation timestamp |
updatedAt | DateTime! | No | Last update timestamp |
CvProject
Project portfolio entries. Array field, ordered by order.
| GraphQL Field | Type | Nullable | Description |
|---|---|---|---|
id | ID! | No | Record ID |
cvId | ID! | No | Parent CV ID |
name | String! | No | Project name |
description | String | Yes | Project description |
role | String | Yes | Role in the project |
technologies | [String!]! | No | Technologies used |
startDate | DateTime | Yes | Start date |
endDate | DateTime | Yes | End date |
url | String | Yes | Project URL |
achievements | [String!]! | No | Key achievements |
order | Int! | No | Display order (0-indexed) |
createdAt | DateTime! | No | Creation timestamp |
updatedAt | DateTime! | No | Last update timestamp |
CvPublication
Published works. Array field, ordered by order.
| GraphQL Field | Type | Nullable | Description |
|---|---|---|---|
id | ID! | No | Record ID |
cvId | ID! | No | Parent CV ID |
title | String! | No | Publication title |
type | String | Yes | Publication type (e.g., journal, conference, book) |
publisher | String | Yes | Publisher name |
date | DateTime | Yes | Publication date |
url | String | Yes | Publication URL |
coAuthors | [String!]! | No | Co-author names |
order | Int! | No | Display order (0-indexed) |
createdAt | DateTime! | No | Creation timestamp |
updatedAt | DateTime! | No | Last update timestamp |
CvAward
Awards and honors. Array field, ordered by order.
| GraphQL Field | Type | Nullable | Description |
|---|---|---|---|
id | ID! | No | Record ID |
cvId | ID! | No | Parent CV ID |
title | String! | No | Award title |
issuingOrganization | String | Yes | Issuing organization |
date | DateTime | Yes | Date received |
description | String | Yes | Award description |
order | Int! | No | Display order (0-indexed) |
createdAt | DateTime! | No | Creation timestamp |
updatedAt | DateTime! | No | Last update timestamp |
CvVolunteer
Volunteer experience. Array field, ordered by order.
| GraphQL Field | Type | Nullable | Description |
|---|---|---|---|
id | ID! | No | Record ID |
cvId | ID! | No | Parent CV ID |
organization | String! | No | Organization name |
role | String | Yes | Role / position |
startDate | DateTime | Yes | Start date |
endDate | DateTime | Yes | End date |
description | String | Yes | Description |
skills | [String!]! | No | Skills applied |
order | Int! | No | Display order (0-indexed) |
createdAt | DateTime! | No | Creation timestamp |
updatedAt | DateTime! | No | Last update timestamp |
CvMetadata
Parsing metadata and document quality information. Singular field (one per CV).
| GraphQL Field | Type | Nullable | Description |
|---|---|---|---|
id | ID! | No | Record ID |
cvId | ID! | No | Parent CV ID |
version | String | Yes | Schema/parser version |
parsedAt | DateTime | Yes | When the CV was parsed |
confidence | Float | Yes | Overall parsing confidence (0.0 -- 1.0) |
extractionMethod | String | Yes | Extraction method used |
qualityScore | Float | Yes | Document quality score |
documentFormat | String | Yes | Original document format (PDF, DOCX, etc.) |
pageCount | Int | Yes | Number of pages |
wordCount | Int | Yes | Word count |
sections | [String!]! | No | Detected CV sections |
source | String | Yes | Source identifier |
dataSource | String | Yes | Data source descriptor |
warnings | [String!]! | No | Parsing warnings |
suggestions | [String!]! | No | Improvement suggestions |
createdAt | DateTime! | No | Creation timestamp |
updatedAt | DateTime! | No | Last 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
}
}
}
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:
- Add the structured fields alongside
parsedCV.schemaData(both will be returned). - Update the component to read from the new typed fields.
- Add a fallback to
schemaDatafor CVs that were parsed before dual-write was enabled (if any exist). - Once confirmed working, remove the
parsedCV.schemaDataselection 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:
| File | Component | What it reads |
|---|---|---|
lib/utils/candidates/candidateRowBuilder.ts | Candidate table rows | Name, email, skills from schemaData |
lib/utils/candidates/talentRowBuilder.ts | Talent table rows | Name, title, location from schemaData |
lib/export/profileFormToExportable.ts | JSON/PDF/DOCX export | All CV fields including education dates and certification location |
lib/export/pdfDocument.tsx | PDF export | Renders education dates, certification location |
lib/export/docxDocument.ts | DOCX export | Renders education dates, certification location |
app/company/resume/candidates/page.tsx | Candidates page | schemaData via candidate query |
app/company/resume/talentpool/view/[id]/page.tsx | Talent pool view | schemaData 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 writesschemaDataapp/jobseeker/resume/edit/[cvId]/page.tsx-- CV editorlib/hooks/useCreateCv.ts-- CV creation hookgraphql/operations/cv/mutations.ts--UPDATE_CV_SCHEMAmutation
Available typed mutations:
| Category | Mutations |
|---|---|
| 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'],
},
},
});
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 schemaData | Structured Field | Notes |
|---|---|---|
personalInfo.firstName, contact.first_name | profile.firstName | Consistent field name |
personalInfo.lastName, contact.last_name | profile.lastName | Consistent field name |
personalInfo.email | profile.email | Single source of truth |
personalInfo.phoneNumber, contact.phone | profile.phone | Renamed from phoneNumber |
personalInfo.title | profile.title | Same |
personalInfo.location, contact.location | profile.location | Consistent |
links.linkedin, personalInfo.linkedinUrl, qualifications.linkedinUrl | profile.linkedinUrl | Single field instead of 3 |
links.portfolio, personalInfo.portfolioUrl, qualifications.portfolioUrl | profile.portfolioUrl | Single field instead of 3 |
summary, personalInfo.biography | profile.biography | Consolidated |
meta.birthDate, contact.date_of_birth | profile.dateOfBirth | Typed as Date |
meta.gender, contact.gender | profile.gender | Single location |
meta.profileImageUrl | profile.profileImageUrl | Moved to profile |
meta.preferredJobTypes | jobPreference.preferredJobTypes | Separate type |
meta.expectedSalary | jobPreference.expectedSalary | Typed as Float |
meta.salaryCurrency | jobPreference.salaryCurrency | Same |
meta.salaryFrequency | jobPreference.salaryFrequency | Same |
meta.availableIn | jobPreference.availableIn | Same |
meta.preferredWorkSite | jobPreference.preferredWorkSite | Same |
meta.willingToRelocate | profile.willingToRelocate | Moved to profile |
meta.desiredJobTitles | jobPreference.desiredJobTitles | Separate type |
meta.preferenceLocation, meta.preferredLocation | profile.preferredLocation | Consistent field name |
experience[].jobTitle, .title, .position, .role | workExperiences[].jobTitle | One canonical name |
experience[].company, .employer | workExperiences[].companyName | Renamed to companyName |
experience[].startDate, .start_date | workExperiences[].startDate | Typed as Date |
experience[].endDate, .end_date | workExperiences[].endDate | Typed as Date |
experience[].description, .experience | workExperiences[].description | Consistent |
education[].title (degree name) | educations[].degree | Renamed for clarity |
education[].institute, .institution | educations[].institution | One canonical name |
education[].startDate/endDate | educations[].startDate/endDate + graduationYear | Full dates now supported; graduationYear kept for backward compat |
languages[].name | languages[].language | Renamed to language |
languages[].level | languages[].proficiency | Renamed to proficiency |
certifications[].title | certifications[].name | Renamed to name |
certifications[].institute | certifications[].issuingOrganization | More descriptive name |
certifications[].year | certifications[].issueDate | Typed 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:
| Field | Reason |
|---|---|
email | Contact information |
phone | Contact information |
dateOfBirth | Protected characteristic (GDPR Article 9) |
gender | Protected characteristic (GDPR Article 9) |
addressStreet | Detailed address |
addressCity | Detailed address |
addressState | Detailed address |
addressZipCode | Detailed address |
profileImageUrl | Biometric 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
| Field | Reason |
|---|---|
expectedSalary | Financial data |
salaryCurrency | Financial data |
salaryFrequency | Financial 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:
- The request has no authenticated user (
req.useris null). - The request is explicitly marked as public access by middleware (
req.isPublicAccess === true). - The GraphQL query uses an external share resolver (
talentPoolByToken,sharedTalentList,publicCV).
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
| Aspect | Legacy schemaData | Structured Fields |
|---|---|---|
| Type safety | Record<string, unknown> (untyped JSON) | Fully typed GraphQL objects |
| Field names | Inconsistent across extraction pipelines | Canonical, consistent names |
| Fetching | All-or-nothing (entire blob) | Selective (query only what you need) |
| Performance | Full JSON deserialized on every query | DataLoader-batched, lazy resolution |
| Validation | None (any JSON shape accepted) | Database constraints enforce schema |
| PII masking | Not supported | Automatic for public access |
| New data types | Not available | Projects, publications, awards, volunteers, metadata |
| Array ordering | Depends on JSON array order | Explicit order field on each item |
| IDs on items | Client-generated or absent | Server-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.