Product: Resume

Resume JSON schema

Resumes are stored as JSONB in Postgres. The schema is versioned. All resume rendering (PDF, preview) is done by transforming this JSON.

interface ResumeData {
  version: 1;
  sections: {
    education: EducationEntry[];
    experience: ExperienceEntry[];
    projects: ProjectEntry[];
    skills: SkillGroup[];
    achievements: AchievementEntry[];
    certifications: CertificationEntry[];
    custom?: CustomSection[];    // for edge cases
  };
  sectionOrder: (keyof ResumeData['sections'])[];  // controls display order
  hiddenSections: (keyof ResumeData['sections'])[];  // sections toggled off for this resume
}
 
interface EducationEntry {
  id: string;                // UUID, stable across edits
  institution: string;
  degree?: string;
  fieldOfStudy?: string;
  startDate: string;         // "YYYY-MM"
  endDate?: string;          // "YYYY-MM" or null (current)
  grade?: string;            // "8.5/10" or "3.8/4.0"
  activities?: string;
  verified: boolean;
  proofDocumentId?: string;
}
 
interface ExperienceEntry {
  id: string;
  company: string;
  companyId?: string;        // FK to companies table if matched
  role: string;
  location?: string;
  startDate: string;         // "YYYY-MM"
  endDate?: string;          // "YYYY-MM" or null (current)
  bullets: BulletPoint[];
  verified: boolean;
  proofDocumentId?: string;
}
 
interface BulletPoint {
  id: string;
  text: string;
  verified: boolean;         // individual bullet can be unverified even if parent entry is verified
  isAiGenerated: boolean;    // true if written or rewritten by AI
  originalText?: string;     // if AI rewrote it, the original text is preserved here
}
 
interface ProjectEntry {
  id: string;
  title: string;
  description?: string;
  link?: string;
  techStack?: string[];
  startDate?: string;
  endDate?: string;
  bullets: BulletPoint[];
  verified: boolean;
}
 
interface SkillGroup {
  id: string;
  category: string;         // "Programming languages", "Tools", "Languages"
  skills: string[];
  verified: boolean;
}
 
interface AchievementEntry {
  id: string;
  title: string;
  description?: string;
  date?: string;
  verified: boolean;
}
 
interface CertificationEntry {
  id: string;
  name: string;
  issuer: string;
  date?: string;
  url?: string;
  verified: boolean;
}

Resume creation flow

1. Student clicks "Create from profile"
2. core-api:
   a. Fetches student's current ProfileSections from DB
   b. Transforms each verified ProfileSection into the Resume JSON shape
   c. For unverified sections: includes them but marks verified: false
   d. Creates Resume record with:
      - name: "Resume " + (count + 1)
      - data: transformed JSON
      - isAiVariant: false
      - unverifiedChangeKeys: []     (empty — inherits verification from profile)
3. Frontend opens the resume editor with the new resume

Resume editing and verification tracking

When a student edits a bullet point on a resume:

// Frontend: student changes bullet text
function handleBulletEdit(resumeId: string, path: string, newText: string) {
  // path: "experience.0.bullets.2"
  
  // Optimistic update in UI
  updateBulletLocally(path, newText);
  
  // Send to API
  api.put(`/me/resumes/${resumeId}`, {
    changes: [{ path, value: newText }]
  });
}
 
// Backend: core-api PUT /me/resumes/:id
async function updateResume(resumeId: string, changes: Change[], studentId: string) {
  const resume = await prisma.resume.findUnique({ where: { id: resumeId } });
  
  const updatedData = applyChanges(resume.data, changes);
  const newUnverifiedKeys = [...resume.unverifiedChangeKeys];
  
  for (const change of changes) {
    // Mark the changed item as unverified
    setNestedVerified(updatedData, change.path, false);
    if (!newUnverifiedKeys.includes(change.path)) {
      newUnverifiedKeys.push(change.path);
    }
  }
  
  await prisma.resume.update({
    where: { id: resumeId },
    data: {
      data: updatedData,
      unverifiedChangeKeys: newUnverifiedKeys,
    }
  });
}

Verification request flow

1. Student clicks "Request verification" on a resume
2. API creates VerificationRequest:
   {
     resumeId,
     studentId,
     changeKeys: resume.unverifiedChangeKeys,  // only the changed paths
     status: "pending"
   }
3. System assigns to a verifier (based on verifier_assignments or round-robin)
4. Verifier sees only the specific bullets/entries in unverifiedChangeKeys
5. Verifier approves:
   → VerificationRequest.status = "approved"
   → For each changeKey: set verified: true in resume JSON
   → Remove changeKeys from resume.unverifiedChangeKeys
   → Notify student
6. Verifier rejects:
   → VerificationRequest.status = "rejected"
   → VerificationRequest.verifierNote = "..." (shown to student)
   → changeKeys remain in unverifiedChangeKeys
   → Notify student with note

AI variant flow

1. Student clicks "AI rewrite" on a resume
2. Opens modal: select target job, select tone (concise/detailed/professional)
3. POST /me/resumes/:id/ai-variant { jobId, tone }
4. core-api:
   a. Validates feature flag: tenant.featureFlags.aiResume === true
   b. Creates a placeholder Resume record: { name: "AI variant (processing...)", data: null, isAiVariant: true }
   c. Enqueues: ai-jobs queue, job "rewrite-resume" with resumeId, jobId, tone
   d. Returns { resumeId: newResumeId, status: "processing" }
5. Frontend shows spinner next to the new resume card
6. ai-worker processes job (see ai-worker.md)
7. ai-worker writes result to DB, calls core-api internal endpoint
8. core-api enqueues in-app notification for student
9. Frontend React Query poll (every 3s) detects resume is now populated → shows it
 
AI variant resume properties:
- isAiVariant: true
- baseResumeId: original resume ID
- All bullets: verified: true, isAiGenerated: true
- unverifiedChangeKeys: []  (AI variants start fully "verified")

Selective re-verification after editing an AI variant

When a student edits a bullet on an AI variant:

- That specific bullet's verified flag → false
- That bullet's path added to unverifiedChangeKeys
- All other bullets remain verified: true
- Student can submit a verification request for just those bullets
- Verifier sees only the modified bullets in a diff view:
  [Original AI text] → [Student's edit]

This is the "minimum verification surface" principle — only changed content needs re-verification.


Resume PDF generation

Not generated server-side on every view. Instead:

  1. Frontend renders the resume JSON as a styled HTML component (using a ResumeRenderer React component)
  2. For download: use @react-pdf/renderer or html2canvas + jsPDF in the browser
  3. For sharing with recruiters (bulk download): server-side puppeteer job enqueued in ai-jobs queue as a separate job type generate-resume-pdf

Resume PDF style

A clean single-column or two-column layout. Rendering is purely a function of the resume JSON — no custom styles per student. The frontend ResumeRenderer component:

interface ResumeRendererProps {
  data: ResumeData;
  studentName: string;
  studentEmail: string;
  studentPhone?: string;
  template?: 'standard' | 'compact';  // compact fits more on one page
}

Sections are rendered in the order specified in data.sectionOrder. Hidden sections (data.hiddenSections) are not rendered.