Structured CV Migration Documentation
Migration Complete. The legacy
ParsedCV,CVField,CVFieldValue, andCVGroupFieldsmodels have been removed from the Prisma schema and the backend. TheparsedCVfield no longer exists onCVType. The 11 structured CV tables (+ CVSkill join table) are the sole source of truth. This document is preserved as a historical reference and ongoing implementation guide.
Overview
This document covers the migration from the legacy schemaData JSON blob approach to typed structured GraphQL fields and mutations for CV data management. The migration affects both the read path (how CV data is fetched and displayed) and the write path (how CV edits are saved).
Background
Legacy Approach (schemaData)
Previously, all CV data was stored as a single JSON blob called schemaData inside ParsedCV. The frontend would:
- Read: Query
parsedCV.schemaData, parse the JSON, and extract fields likepersonalInfo,experience,education,skills,meta, etc. - Write: Build a
BackendCvSchemaobject from form data viaprofileToSchema(), then send it as a JSON blob via theUPDATE_CV_SCHEMAmutation.
New Approach (Structured Fields)
The backend now exposes 11 normalized GraphQL field resolvers on CVType, each backed by its own database table:
| Field | Type | Description |
|---|---|---|
profile | 1:1 | Personal info (name, email, phone, title, location, etc.) |
jobPreference | 1:1 | Job preferences (desired titles, salary, work site, availability) |
workExperiences | Array | Work experience entries |
educations | Array | Education entries |
certifications | Array | Certification entries |
languages | Array | Language entries |
projects | Array | Project entries |
publications | Array | Publication entries |
awards | Array | Award entries |
volunteers | Array | Volunteer entries |
cvMetadata | 1:1 | Parsing quality info (confidence, extraction method, etc.) |
cvSkills | Array | CV-skill join table (separate from structured tables) |
The backend also exposes 30 typed write mutations (jobseeker-only) for granular CV updates, including 3 skill mutations (addMyCvSkill, updateMyCvSkill, removeMyCvSkill).
Architecture
File Structure
graphql/operations/
schema/cv/fragments.ts # GraphQL fragments for structured fields
cv/structuredMutations.ts # 30 typed mutation definitions (27 jobseeker + 3 company-scoped skill)
cv/mutations.ts # CREATE_MY_CV, SET_MY_ACTIVE_CV
lib/types/
structuredCv.ts # TypeScript interfaces for structured CV types
structuredCvInputs.ts # Input types for mutation variables
cvbuilder.ts # Form types (Experience, Education, etc.) with _structuredId
cv.ts # Legacy types + TMyCvsQuery, TSetMyActiveCvData (with StructuredCvFields)
lib/utils/cv/
structuredCvToProfileForm.ts # Structured fields -> ProfileFormData (read)
profileFormToInputs.ts # ProfileFormData -> mutation inputs (write)
structuredCvMapper.ts # Resolvers for structured CV data
lib/utils/
resolveAvatarUrl.ts # Avatar URL resolver with initials fallback
lib/hooks/
useStructuredCvMutations.ts # Central save orchestrator hook (diff + dispatch)
Data Flow
READ PATH (All Pages):
GraphQL query (with ...CvStructuredFields fragment)
-> structured fields on CVType (profile, workExperiences, etc.)
-> structuredCvToProfileForm() maps to ProfileFormData
-> Form/preview components render the data
WRITE PATH (Jobseeker):
ProfileFormData (from form)
-> profileFormToInputs.ts maps to mutation inputs
-> useStructuredCvMutations diff engine compares current vs original
-> Typed mutations dispatched in parallel (add/update/remove)
WRITE PATH (Company):
ProfileFormData (from form)
-> profileFormToInputs.ts maps to mutation inputs
-> Company-scoped mutations for skills (addCvSkill, updateCvSkill, removeCvSkill)
-> Jobseeker "My" mutations NOT usable (ownership check fails)
-> TODO: Backend needs company-scoped variants for all other tables
Flowcharts
Read Path — How CV Data Reaches the UI
Write Path — How Edits Are Saved
CV Lifecycle — Create, View, Edit, Switch
Avatar Resolution Flow
Diff Algorithm — _structuredId Tracking
GraphQL Fragments
CV_STRUCTURED_FIELDS (Full)
Used for detail/edit views. Includes all 11 structured field types plus cvSkills.
CV_STRUCTURED_LIST_FIELDS (Lightweight)
Used for list/table views. Includes only profile, jobPreference, and cvSkills.
Location: graphql/operations/schema/cv/fragments.ts
Individual fragments:
CV_PROFILE_FIELDS,CV_JOB_PREFERENCE_FIELDSCV_WORK_EXPERIENCE_FIELDS,CV_EDUCATION_FIELDS,CV_CERTIFICATION_FIELDSCV_LANGUAGE_FIELDS,CV_PROJECT_FIELDS,CV_PUBLICATION_FIELDSCV_AWARD_FIELDS,CV_VOLUNTEER_FIELDS,CV_METADATA_FIELDS
Typed Mutations
Location: graphql/operations/cv/structuredMutations.ts
All mutations use the "My" prefix (jobseeker-only, checks CV ownership against logged-in user):
| Section | Add | Update | Remove |
|---|---|---|---|
| Profile | — | UPDATE_MY_CV_PROFILE | — |
| Job Preference | — | UPDATE_MY_CV_JOB_PREFERENCE | DELETE_MY_CV_JOB_PREFERENCE |
| Work Experience | ADD_MY_CV_WORK_EXPERIENCE | UPDATE_MY_CV_WORK_EXPERIENCE | REMOVE_MY_CV_WORK_EXPERIENCE |
| Education | ADD_MY_CV_EDUCATION | UPDATE_MY_CV_EDUCATION | REMOVE_MY_CV_EDUCATION |
| Certification | ADD_MY_CV_CERTIFICATION | UPDATE_MY_CV_CERTIFICATION | REMOVE_MY_CV_CERTIFICATION |
| Language | ADD_MY_CV_LANGUAGE | UPDATE_MY_CV_LANGUAGE | REMOVE_MY_CV_LANGUAGE |
| Project | ADD_MY_CV_PROJECT | UPDATE_MY_CV_PROJECT | REMOVE_MY_CV_PROJECT |
| Publication | ADD_MY_CV_PUBLICATION | UPDATE_MY_CV_PUBLICATION | REMOVE_MY_CV_PUBLICATION |
| Award | ADD_MY_CV_AWARD | UPDATE_MY_CV_AWARD | REMOVE_MY_CV_AWARD |
| Volunteer | ADD_MY_CV_VOLUNTEER | UPDATE_MY_CV_VOLUNTEER | REMOVE_MY_CV_VOLUNTEER |
| Skill | ADD_MY_CV_SKILL | UPDATE_MY_CV_SKILL | REMOVE_MY_CV_SKILL |
Important: Input types use Create prefix (e.g., CreateCvWorkExperienceInput) not Add.
Skill mutations: addMyCvSkill, updateMyCvSkill, removeMyCvSkill are implemented on both backend and frontend. The useStructuredCvMutations hook handles skill saves via diff (add/update/remove). Company-scoped variants (addCvSkill, updateCvSkill, removeCvSkill) are not yet available — see BACKEND_CV_SKILL_MUTATIONS.md.
Key Components
useStructuredCvMutations Hook
Location: lib/hooks/useStructuredCvMutations.ts
Central save orchestrator that:
- Registers mutations for Profile, JobPreference, WorkExperience, Education, Certification, Language, and Skill via
useMutation - Exposes
saveStructuredCv(cvId, currentForm, originalSnapshot) - Computes diffs and dispatches mutations in parallel
Diff algorithm (diffArraySection):
- Items in current WITH
_structuredIdmatching original -> UPDATE - Items in current WITHOUT
_structuredId(or new) -> ADD - Items in original's
_structuredIdset but NOT in current -> REMOVE
Execution: Profile + JobPreference always overwrite. Array sections use computed diffs. All dispatched via Promise.allSettled().
Note: No schemaData sync — the hook purely uses typed structured mutations. The backend handles any backward-compatibility sync internally.
structuredCvToProfileForm
Location: lib/utils/cv/structuredCvToProfileForm.ts
Maps StructuredCvFields -> Partial<ProfileFormData> with _structuredId threading. Key mappings:
profile-> personal fields (firstName, email, phone, etc.)profile.profileImageUrl->profileImageUrl(raw value, no avatar fallback — form components handle empty state)jobPreference-> preference fields (preferredTitles, expectedSalary, etc.)workExperiences[].id->experiences[]._structuredIdeducations[].id->education[]._structuredIdcertifications[].id->certificates[]._structuredIdlanguages[].id->languages[]._structuredIdcvSkills->skills[](name, proficiency mapped to numeric level)
profileFormToInputs
Location: lib/utils/cv/profileFormToInputs.ts
Mapper functions:
profileFormToProfileInput(form)->UpdateCvProfileInputprofileFormToJobPrefInput(form)->UpdateCvJobPreferenceInputexperienceToInput(exp)->CreateCvWorkExperienceInput(mapstitle->jobTitle,company->companyName,technicalSkills->technologies)educationToInput(edu)->CreateCvEducationInput(mapstitle->degree,institute->institution)certificateToInput(cert)->CreateCvCertificationInput(mapstitle->name,institute->issuingOrganization,year->issueDate)languageToInput(lang)->CreateCvLanguageInput(mapsname->language, numericlevel-> stringproficiency)
Includes toISODateOrNull() helper for date normalization (YYYY-MM-DD format required by backend).
Important: technologies must be [] not null — Prisma array fields reject null values.
_structuredId Convention
Array item types (Experience, Education, Certificate, LanguageItem) have an optional _structuredId?: string field that stores the backend database ID. The _ prefix:
- Distinguishes it from frontend-generated IDs
- Prevents accidental inclusion in legacy mutations
- Enables the diff engine to track add/update/remove operations
resolveAvatarUrl Utility
Location: lib/utils/resolveAvatarUrl.ts
Resolves avatar image URLs with initials fallback:
- Empty/null URL → generates
ui-avatars.comURL from thenameparameter - Relative paths → prepends
NEXT_PUBLIC_IMAGE_BASE_URL - S3 URLs on localhost → proxied through
/s3-proxy/to avoid CORS
Usage: Always pass name as the second argument for meaningful initials fallback:
resolveAvatarUrl(user.profileImageUrl, fullName) // ✓ shows "JD" initials
resolveAvatarUrl(user.profileImageUrl) // ✗ shows "?" character
Migration Status by Page
Jobseeker CV Edit Page
File: app/jobseeker/resume/edit/[cvId]/page.tsx
| Path | Status | Method |
|---|---|---|
| Read | Structured fields | structuredCvToProfileForm() |
| Write | Typed mutations | useStructuredCvMutations.saveStructuredCv() |
Fully migrated. No schemaData usage.
Jobseeker Resume Preview Page
File: app/jobseeker/resume/page.tsx
| Path | Status | Method |
|---|---|---|
| Read | Structured fields | structuredCvToProfileForm() + profileFormToPreview() |
Fully migrated. Uses MY_CVS query (which includes ...CvStructuredFields) to find the active CV, then maps structured data through structuredCvToProfileForm() → profileFormToPreview() → JobseekerCVPreview component.
Previously: Used schemaToPreviewForm() which read from activeCV.parsedCV.schemaData (JSON blob). This 230-line function and all its helpers have been removed.
Company-Side Talent Edit (UpdateCandidateForm)
File: app/company/resume/candidates/_components/UpdateCandidateForm.tsx
| Path | Status | Method |
|---|---|---|
| Read | Structured fields | structuredCvToProfileForm(structured) |
| Write (skills) | Company-scoped structured mutations | useStructuredCvMutations({ companyScoped: true }) → addCvSkill, updateCvSkill, removeCvSkill |
| Write (other) | Legacy UPDATE_CV_SCHEMA | profileToSchema() → updateCvSchema() (backend needs company-scoped variants for other tables) |
Partially migrated: Skills use company-scoped structured mutations with diff logic. Other fields (profile, experiences, education, etc.) still use UPDATE_CV_SCHEMA because company-scoped "My" variants don't exist yet.
Company-Side Talent View (TalentDetails)
File: app/company/resume/talents/_components/TalentDetails.tsx
| Path | Status | Method |
|---|---|---|
| Read | Structured fields | structured.profile, structured.jobPreference |
Migrated from reading schemaData.personalInfo/schemaData.meta to reading structured profile and jobPreference fields. The personalData (avatar, name, title, email, location) and salary/preferences now come from structured fields with user fallback.
Company-Side Talent List Components
| File | Change |
|---|---|
TalentSidebar.tsx | Uses resolveAvatarUrl(avatarUrl, fullName) for avatar display |
TalentQuickView.tsx | Uses resolveAvatarUrl(avatarUrl, fullName) for avatar display |
TalentsTable.tsx | Uses resolveAvatarUrl(avatarUrl, talentName) with name prop on TWAvatar |
Avatar Components (Profile Image Fix)
| File | Change |
|---|---|
app/onboarding/jobseeker/steps/components/AvatarSection.tsx | Uses resolveAvatarUrl(user.profileImageUrl, fullName) + name prop |
app/(shared)/userprofile/components/AvatarSection.tsx | Uses resolveAvatarUrl(user.profileImageUrl, fullName) + name prop |
Paths NOT Changed
| File | Reason |
|---|---|
app/onboarding/jobseeker/steps/SummaryStep.tsx | CREATE path — uses CREATE_MY_CV |
lib/hooks/useCreateCv.ts | CREATE path — uses CREATE_MY_CV |
app/company/resume/talents/_components/ImportTalentModal.tsx | Bulk import — low ROI, backend sync keeps data consistent |
lib/hooks/useAddCandidate.ts | CREATE path — could be migrated to structured mutations |
Mutation Response Changes
SET_MY_ACTIVE_CV (graphql/operations/cv/mutations.ts)
- Now returns
...CvStructuredFieldsfragment (profile, workExperiences, cvSkills, etc.) - Removed
schemaDatafromparsedCVblock - Ensures Apollo cache is populated with structured data after switching active CV
CREATE_MY_CV (graphql/operations/cv/mutations.ts)
- Returns
...CvStructuredFieldsfragment - Ensures structured data is available in cache immediately after CV creation
MY_CVS Query (graphql/operations/cv/queries.ts)
- Includes
...CvStructuredFieldsfragment on each CV
GET_CV_BY_ID Query (graphql/operations/cv/queries.ts)
- Includes
...CvStructuredFieldsfragment
Type Changes
TMyCvsQuery (lib/types/cv.ts)
- CV items now include
Partial<StructuredCvFields>(profile, workExperiences, cvSkills, etc.)
TSetMyActiveCvData (lib/types/cv.ts)
- Added
Partial<StructuredCvFields>for structured field access - No
parsedCV— backend removed it from schema
Query Changes
GET_TALENT (graphql/operations/talents/queries.ts)
- Added
sourcefield on talent (needed forneedsRegistrationcheck) - Added
isActivefield on user (needed forneedsRegistrationcheck) - Uses
CV_STRUCTURED_FIELDSfragment onactiveCV
GET_COMPANY_TALENTS (graphql/operations/talents/queries.ts)
- Upgraded from
CV_STRUCTURED_LIST_FIELDStoCV_STRUCTURED_FIELDS
GET_CANDIDATE_DETAILED (graphql/operations/candidate/queries.ts)
- Uses
CV_STRUCTURED_FIELDSand...CvStructuredFieldson both CVs
Data Source Priority
When prefilling the edit form, data is resolved in this priority order:
- Structured CV fields (primary) — from
activeCV.profile,.workExperiences,.cvSkills, etc. user.qualifications(fallback for arrays) — JSON field containing skills, experience, education, certifications, languagesuser.*fields (fallback for scalars) — firstName, lastName, email, title, location, etc.
This ensures data is shown even when structured tables are empty (e.g., newly created CVs before parsing completes).
Bug Fixes Applied
1. Input Type Naming
- Issue: Frontend used
AddCvWorkExperienceInputbut backend expectsCreateCvWorkExperienceInput - Fix: Updated all input types to use
Createprefix
2. Date Format Validation
- Issue: Backend requires ISO 8601 dates (YYYY-MM-DD) but form data had various formats ("May 2024", "2018-01-01T00:00:00.000Z", etc.)
- Fix: Added
toISODateOrNull()helper inprofileFormToInputs.tsthat normalizes all date formats
3. "My" Mutations Are Jobseeker-Only
- Issue:
updateMyCvProfile,addMyCvWorkExperience, etc. check CV ownership — company users get "CV not found" - Fix: Reverted company-side write path to
UPDATE_CV_SCHEMA. Jobseeker edit page uses typed mutations successfully.
4. schemaData Sent as String Instead of Object
- Issue:
JSON.stringify(schema)was passed toschemaDatafield which expectsJSONObjecttype - Fix: Pass the raw object directly:
{ schemaData: schema }not{ schemaData: JSON.stringify(schema) }
5. Profile Image Not Showing on View Page
- Issue:
TalentDetails.tsxwas readingschemaData.meta.profileImageUrlwhich was removed from the query - Fix: Updated to read from
structured.profile.profileImageUrlwithuser.profileImageUrlfallback
6. Hardcoded "Active" Status
- Issue:
talentRowBuilder.tsandcandidateRowBuilder.tsdefaulted to"Active"whenuser.statuswas null - Fix: Changed fallback to empty string
"", andTalentsTable.tsxnow shows "—" for empty status
7. Profile Image Question Mark on Multiple Pages
- Issue: When a user had no profile image, a "?" character was displayed instead of proper initials or upload UI
- Root causes:
resolveAvatarUrl(url)called withoutnameparam → generatedui-avatars.com/?name=?URLstructuredCvToProfileFormusedresolveAvatarUrl(profile.profileImageUrl, null)for form data → injected a ui-avatars URL intoprofileImageUrl, which madeProfileForm.tsxshow an<img>with "?" instead of theImageUploadCropcomponent
- Fixes:
- All avatar display components now pass
fullNametoresolveAvatarUrl(url, fullName) structuredCvToProfileFormnow usesprofile.profileImageUrl ?? ""(raw value, no avatar fallback)- Components fixed: onboarding
AvatarSection, sharedAvatarSection,TalentSidebar,TalentQuickView,TalentsTable
- All avatar display components now pass
8. SET_MY_ACTIVE_CV and CREATE_MY_CV Missing Structured Fields
- Issue: Mutation responses didn't include
...CvStructuredFields, so Apollo cache wasn't populated with structured data (profile, skills, etc.) after switching/creating CVs - Fix: Added
CV_STRUCTURED_FIELDSfragment to both mutations. Verified response now includes all structured fields (e.g., 36 cvSkills returned correctly)
9. Jobseeker Resume Preview Page Using Legacy schemaData
- Issue:
/jobseeker/resumepage read fromactiveCV.parsedCV.schemaDatavia a 230-lineschemaToPreviewForm()function. This showed incomplete/outdated data since writes now go through structured mutations. - Fix: Replaced with
structuredCvToProfileForm()+ compactprofileFormToPreview()mapper. Page now finds active CV frommyCvsquery (which has structured fields) instead ofWHO_AM_I.activeCV.schemaData.
10. technologies Null Error in Work Experience Updates
- Issue:
experienceToInput()senttechnologies: nullwhen no technical skills existed. Prisma rejectsnullfor array fields: "Argumenttechnologiesmust not be null" - Fix: Changed to
technologies: exp.technicalSkills ?? [](empty array instead of null)
Known Limitations & Backend Issues
1. Company-Side Write Path — Partial Migration
Company-scoped skill mutations (addCvSkill, updateCvSkill, removeCvSkill) are fully integrated (backend + frontend). UpdateCandidateForm uses useStructuredCvMutations({ companyScoped: true }) with saveSkillsOnly() for diff-based skill saves. However, company-scoped variants for all other tables (updateCvProfile, addCvWorkExperience, etc.) do not exist yet — these still go through UPDATE_CV_SCHEMA.
3. cv(id:) Resolver Returns Empty cvSkills
Backend bug: The cv(id:) GraphQL resolver returns cvSkills: [] while myCvs and setMyActiveCv correctly return all skills for the same CV. The frontend query is correct (uses ...CvStructuredFields which includes cvSkills { ...CVSkillFields }). This affects the jobseeker CV edit page which uses GET_CV_BY_ID.
4. updateMyCvJobPreference Upsert Missing CV Relation
Backend Prisma bug: When upserting a cvJobPreference record, the create block passes cvId as a scalar field but Prisma requires the CV relation to be connected via connect: { id: cvId }. Error: "Argument CV is missing."
5. CREATE Paths
CREATE_MY_CV (onboarding, useCreateCv) creates CVs via the CREATE mutation. The backend populates structured tables during CV parsing. Typed mutations apply to existing CVs only.
6. Qualifications Fallback
When structured tables are empty for a CV, the form falls back to user.qualifications JSON for array fields. This ensures data continuity for newly created CVs before parsing completes.
Verification Checklist
npm run buildpasses with no type errors- Jobseeker CV Editor: Edit CV -> save -> verify typed mutation calls in Network tab (not
updateCvSchema) - Jobseeker Resume Preview: View
/jobseeker/resume-> verify all fields display correctly from structured data - Company Talent View: Open talent details -> verify profile image, name, salary display correctly from structured fields
- Company Talent Edit: Edit talent skills -> save -> verify
addCvSkill/updateCvSkill/removeCvSkillcalls in Network tab - Talents List: Verify avatar shows initials (not "?") when no profile image exists
- Profile Image: Verify CV edit page shows upload component (not question mark) when no image exists
- Add Candidate: Add new candidate with CV -> verify structured fields are populated via backend auto-sync
- Active CV Switch: Switch active CV -> verify all structured fields populate correctly in preview
Files Changed (Complete List)
Core Migration Files (Created/Heavily Modified)
| File | Purpose |
|---|---|
graphql/operations/cv/structuredMutations.ts | 30 typed mutation definitions (27 jobseeker + 3 company-scoped skill) |
graphql/operations/schema/cv/fragments.ts | CV_STRUCTURED_FIELDS and sub-fragments |
lib/types/structuredCv.ts | TypeScript interfaces for 11 structured types |
lib/types/structuredCvInputs.ts | Input types for mutation variables |
lib/utils/cv/structuredCvToProfileForm.ts | Structured → ProfileFormData mapper |
lib/utils/cv/profileFormToInputs.ts | ProfileFormData → mutation inputs mapper |
lib/utils/cv/structuredCvMapper.ts | resolveStructuredCv() for structured CV data |
lib/hooks/useStructuredCvMutations.ts | Central save orchestrator with diff engine |
GraphQL Queries/Mutations Modified
| File | Changes |
|---|---|
graphql/operations/cv/mutations.ts | Added CvStructuredFields to CREATE_MY_CV and SET_MY_ACTIVE_CV |
graphql/operations/cv/queries.ts | Both queries include CvStructuredFields |
graphql/operations/talents/queries.ts | Upgraded to CV_STRUCTURED_FIELDS, added source/isActive fields |
graphql/operations/candidate/queries.ts | Uses CvStructuredFields (no parsedCV) |
Type Definitions Modified
| File | Changes |
|---|---|
lib/types/cv.ts | TMyCvsQuery, TSetMyActiveCvData, TWhoAmIQuery updated with StructuredCvFields |
lib/types/cvbuilder.ts | Added _structuredId to Experience, Education, Certificate |
Page Components Modified
| File | Changes |
|---|---|
app/jobseeker/resume/edit/[cvId]/page.tsx | Full structured read/write migration |
app/jobseeker/resume/page.tsx | Migrated from schemaData to structured fields for preview |
app/company/resume/candidates/_components/UpdateCandidateForm.tsx | Structured read + schemaData fallback |
app/company/resume/talents/_components/TalentDetails.tsx | Structured field reads for profile/preferences |
app/company/resume/talents/_components/TalentSidebar.tsx | resolveAvatarUrl with fullName |
app/company/resume/talents/_components/TalentQuickView.tsx | resolveAvatarUrl with fullName |
app/company/resume/talents/_components/TalentsTable.tsx | resolveAvatarUrl with talentName |
app/onboarding/jobseeker/steps/components/AvatarSection.tsx | resolveAvatarUrl with fullName |
app/(shared)/userprofile/components/AvatarSection.tsx | resolveAvatarUrl with fullName |
app/onboarding/jobseeker/steps/SummaryStep.tsx | Made schemaData optional in local WhoAmIQuery type |
components/cv/ProfileForm.tsx | Profile image uses raw URL (no resolveAvatarUrl injection) |
Utility Files Modified
| File | Changes |
|---|---|
lib/utils/resolveAvatarUrl.ts | Enhanced with name-based initials, S3 proxy, URL encoding |
lib/utils/candidates/talentRowBuilder.ts | Changed status fallback from "Active" to "" |
lib/utils/candidates/candidateRowBuilder.ts | Changed status fallback from "Active" to "" |