Skip to content

Data Models

_david edited this page Sep 26, 2026 · 2 revisions

Data Models

All models use Mongoose with timestamps: true. Every CV section, Application, and Profile document carries a candidateId foreign key back to Candidate, plus a nullable deletedAt: Number (soft-delete, null = not deleted — see API Reference's restore endpoints). Visit has neither.

Model Key fields
Candidate email, password (bcrypt hash, never returned by the API), firstName, lastName, gender, marital, birthday, address, phone, introduction ({vi,en}), socialMedia, slug (vanity public-profile URL, unique/sparse), cvFile ({ originalName, uploadedAt } metadata for the uploaded PDF résumé), isPublic (default true — gates whether GET /api/me/:slug-or-email returns anything at all), emailVerified (default false, informational only)
generalInformation candidateId, positionDesired, career ({vi,en}), levelCurrent, levelDesired, salaryDesired, education, yearsOfExperience, workLocation, workForm, openToWork, careerGoal ({vi,en}), personalSkills[], professionalSkills[], professionalSkillsGroup[], foreignLanguages[]
Experience candidateId, company, position, startDate, endDate, isCurrent, description ({vi,en}), skills[]
Education candidateId, school, major, startDate, endDate, isCurrent, description ({vi,en})
Project candidateId, name, position, description ({vi,en}), technology[], startDate, endDate, isWorking, images[], link
Certificate candidateId, name, organization, startDate, endDate, isNoExpiration, link, images[], description ({vi,en})
Award candidateId, name, organization, issueDate, link, images[], description ({vi,en})
Reference candidateId, fullName, phone, company, position
Application candidateId, company, position, appliedDate, status (applied|interview|offer|rejected, default applied), note, jobLink — private job-application tracker, never exposed via the public profile
Profile candidateId, name, educationIds[], experienceIds[], projectIds[], certificateIds[], awardIds[], referenceIds[] — a named subset used to filter what a public share link shows
Visit candidateId, ip, location — one document per public-profile visit (analytics only)

Reusable sub-schemas (models/part/index.ts): foreignLanguageSchema, professionalSkillsSchema, personalSkills, socialMediaSchema, localizedTextSchema.

Multi-language resume content

Free-text fields that a candidate actually writes prose into are stored per-language:

{ vi: string, en: string }

Localized: Candidate.introduction, description on Education/Experience/Award/Certificate/Project, generalInformation.career / careerGoal.

Not localized (stays a plain string) — proper nouns and short labels: school, company, major, position, positionDesired, levelCurrent, levelDesired, education (degree level), workLocation, workForm.

Reading vs. writing

  • Authenticated CRUD (GET/POST/PUT /education, etc.) always returns/accepts the full { vi, en } object — the owner edits both languages directly.
  • Public-facing reads (GET /api/me/:slug-or-email, GET /download-pdf) resolve each localized field down to a single string based on ?lang=vi|en (default vi), falling back to whichever language actually has content if the requested one is empty.

Migrating existing data

npm run migrate:localize-text (src/scripts/migrate-localize-text-fields.ts) wraps any remaining plain-string values into { vi: <existing value>, en: '' }. It's a MongoDB aggregation-pipeline update matching only documents where the field is currently a string, so it's idempotent — safe to run repeatedly, a no-op once everything is migrated.

Soft delete (issue #121)

baseDeleteDocument (services/index.ts) sets deletedAt = Date.now() instead of actually removing the document. Every list/find path excludes deletedAt-set documents by default, so this is invisible to normal reads. baseRestoreDocument clears it back to null. baseUpdateDocument/basePatchDocument also refuse to touch an already-soft-deleted document (issue #136 fix — see Security); baseDeleteDocument/baseRestoreDocument themselves don't apply that exclusion, since restore specifically needs to find an already-deleted document. Account-level self-delete (DELETE /api/v1/candidate) is the one exception — it still hard-deletes and cascades across every collection.

Ownership model

Every write (create/update/delete/restore) on a CV section, application, or profile is scoped to req.user._id (the authenticated candidate), never a client-supplied id — see Security for the incident history behind why this is emphasized here.

Clone this wiki locally