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 resumeResume 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 noteAI 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:
- Frontend renders the resume JSON as a styled HTML component (using a
ResumeRendererReact component) - For download: use
@react-pdf/rendererorhtml2canvas+jsPDFin the browser - For sharing with recruiters (bulk download): server-side puppeteer job enqueued in
ai-jobsqueue as a separate job typegenerate-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.