Skip to content

Commit f880e6b

Browse files
committed
feat: Implement CV PDF generation and download functionality
1 parent a0ddb60 commit f880e6b

File tree

5 files changed

+221
-38
lines changed

5 files changed

+221
-38
lines changed

backend/src/controllers/cv.controller.js

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { CV } from "../models/cv.model.js";
22
import { asyncHandler } from '../utils/asyncHandler.js';
33
import { ApiError } from "../utils/ApiError.js";
44
import { ApiResponse } from "../utils/ApiResponse.js";
5+
import { generateCVPDF } from "../utils/generatepdf.js";
56

67

78
const createOrUpdateCV = asyncHandler(async (req, res) => {
@@ -102,8 +103,33 @@ const uploadGovCV = asyncHandler(async (req, res) => {
102103
}
103104
}
104105
});
106+
107+
const downloadCVPDF = async (req, res) => {
108+
try {
109+
const userId = req.params.userId || req.user._id;
110+
111+
const pdfBuffer = await generateCVPDF(userId);
112+
113+
// Get user name for filename
114+
const cvData = await CV.findOne({ userId });
115+
const filename = `${cvData.basicDetails.fullName.replace(/\s+/g, '_')}_CV.pdf`;
116+
117+
res.setHeader('Content-Type', 'application/pdf');
118+
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
119+
res.setHeader('Content-Length', pdfBuffer.length);
120+
121+
res.send(pdfBuffer);
122+
123+
} catch (error) {
124+
res.status(500).json({
125+
success: false,
126+
message: error.message
127+
});
128+
}
129+
};
105130
export {
106131
createOrUpdateCV,
107132
getCV,
108-
uploadGovCV
133+
uploadGovCV,
134+
downloadCVPDF
109135
}

backend/src/routes/cv.routes.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { Router } from 'express';
2-
import { createOrUpdateCV, getCV, uploadGovCV } from '../controllers/cv.controller.js';
2+
import { createOrUpdateCV, getCV, uploadGovCV,downloadCVPDF} from '../controllers/cv.controller.js';
33
import { verifyJWT } from '../middlewares/auth.middleware.js';
44
import { uploadPDF, saveToGridFS } from '../middlewares/multer.middleware.js';
55
const router = Router();
66

77
router.route('/save').post(verifyJWT, createOrUpdateCV);
88
router.route('/:userId').get(verifyJWT, getCV);
9+
router.route('/download/:userId').get(verifyJWT, downloadCVPDF);
910
router.route('/upload-gov-csv').post(
1011
verifyJWT,
1112
uploadPDF.single('govCV'),

backend/src/utils/generatepdf.js

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import PDFDocument from 'pdfkit';
2+
import { CV } from '../models/cv.model.js';
3+
4+
export const generateCVPDF = async (userId) => {
5+
try {
6+
const cvData = await CV.findOne({ userId }).populate('userId');
7+
if (!cvData) {
8+
throw new Error('CV not found');
9+
}
10+
11+
const doc = new PDFDocument({ margin: 50 });
12+
const chunks = [];
13+
14+
doc.on('data', chunk => chunks.push(chunk));
15+
16+
const pdfPromise = new Promise((resolve) => {
17+
doc.on('end', () => resolve(Buffer.concat(chunks)));
18+
});
19+
20+
// Generate PDF content
21+
generateCVContent(doc, cvData);
22+
doc.end();
23+
24+
return await pdfPromise;
25+
} catch (error) {
26+
throw new Error(`PDF generation failed: ${error.message}`);
27+
}
28+
};
29+
30+
const generateCVContent = (doc, cvData) => {
31+
const primaryColor = '#04445E';
32+
const secondaryColor = '#169AB4';
33+
34+
// Header Section
35+
doc.fontSize(24)
36+
.fillColor(primaryColor)
37+
.text(cvData.basicDetails.fullName, { align: 'center' });
38+
39+
doc.fontSize(12)
40+
.fillColor('black')
41+
.text(`${cvData.basicDetails.email} | ${cvData.basicDetails.phone}`, { align: 'center' })
42+
.text(`${cvData.basicDetails.city}`, { align: 'center' })
43+
.moveDown(2);
44+
45+
// Education Section
46+
addSection(doc, 'Education', primaryColor);
47+
doc.fontSize(12)
48+
.text(`${cvData.education.medicalSchoolName}`, { continued: false })
49+
.fontSize(10)
50+
.fillColor('gray')
51+
.text(`${cvData.education.country} | ${cvData.education.joiningDate} - ${cvData.education.completionDate}`)
52+
.fillColor('black')
53+
.moveDown();
54+
55+
// USMLE Scores
56+
if (cvData.usmleScores.step1Status !== 'not-taken' || cvData.usmleScores.step2ckScore) {
57+
addSection(doc, 'USMLE Scores', primaryColor);
58+
if (cvData.usmleScores.step1Status !== 'not-taken') {
59+
doc.text(`USMLE Step 1: ${cvData.usmleScores.step1Status.toUpperCase()}`);
60+
}
61+
if (cvData.usmleScores.step2ckScore) {
62+
doc.text(`USMLE Step 2 CK: ${cvData.usmleScores.step2ckScore}`);
63+
}
64+
if (cvData.usmleScores.ecfmgCertified) {
65+
doc.text(`ECFMG Certified: Yes`);
66+
}
67+
doc.moveDown();
68+
}
69+
70+
// Skills
71+
if (cvData.skills) {
72+
addSection(doc, 'Skills', primaryColor);
73+
doc.text(cvData.skills);
74+
doc.moveDown();
75+
}
76+
77+
// Clinical Experiences
78+
if (cvData.clinicalExperiences.length > 0) {
79+
addSection(doc, 'Clinical Experience', primaryColor);
80+
cvData.clinicalExperiences.forEach(exp => {
81+
doc.fontSize(11)
82+
.fillColor('black')
83+
.text(exp.title, { continued: true })
84+
.fontSize(10)
85+
.fillColor('gray')
86+
.text(` - ${exp.hospital}`)
87+
.text(exp.duration)
88+
.fontSize(10)
89+
.fillColor('black')
90+
.text(exp.description)
91+
.moveDown(0.5);
92+
});
93+
doc.moveDown();
94+
}
95+
96+
// Publications
97+
if (cvData.publications.length > 0) {
98+
addSection(doc, 'Publications', primaryColor);
99+
cvData.publications.forEach((pub, index) => {
100+
doc.fontSize(10)
101+
.text(`${index + 1}. ${pub.title}`)
102+
.fillColor('gray')
103+
.text(`${pub.journal}, ${pub.year}`)
104+
.fillColor('black')
105+
.moveDown(0.5);
106+
});
107+
doc.moveDown();
108+
}
109+
110+
// Conferences
111+
if (cvData.conferences.length > 0) {
112+
addSection(doc, 'Conferences', primaryColor);
113+
cvData.conferences.forEach(conf => {
114+
doc.fontSize(10)
115+
.text(`${conf.name} (${conf.year})`)
116+
.fillColor('gray')
117+
.text(`Role: ${conf.role}`)
118+
.fillColor('black');
119+
if (conf.description) {
120+
doc.text(conf.description);
121+
}
122+
doc.moveDown(0.5);
123+
});
124+
doc.moveDown();
125+
}
126+
127+
// Workshops
128+
if (cvData.workshops.length > 0) {
129+
addSection(doc, 'Workshops', primaryColor);
130+
cvData.workshops.forEach(workshop => {
131+
doc.fontSize(10)
132+
.text(`${workshop.name}`)
133+
.fillColor('gray')
134+
.text(`${workshop.organizer} | ${workshop.year}`)
135+
.fillColor('black');
136+
if (workshop.description) {
137+
doc.text(workshop.description);
138+
}
139+
doc.moveDown(0.5);
140+
});
141+
doc.moveDown();
142+
}
143+
144+
// Achievements
145+
if (cvData.significantAchievements) {
146+
addSection(doc, 'Achievements', primaryColor);
147+
doc.text(cvData.significantAchievements);
148+
doc.moveDown();
149+
}
150+
};
151+
152+
const addSection = (doc, title, color) => {
153+
doc.fontSize(14)
154+
.fillColor(color)
155+
.text(title)
156+
.strokeColor(color)
157+
.lineWidth(1)
158+
.moveTo(50, doc.y)
159+
.lineTo(550, doc.y)
160+
.stroke()
161+
.fillColor('black')
162+
.moveDown(0.5);
163+
};

frontend/src/components/Dashboard/ViewCV/ViewCV.jsx

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,20 @@ const ViewCV = ({ onEdit }) => {
1313
try {
1414
setLoading(true);
1515
setError(null);
16-
16+
1717
const userResponse = await api.get('/users/current-user');
1818
if (!userResponse.data.success) {
1919
throw new Error('Failed to get user information');
2020
}
21-
21+
2222
const userId = userResponse.data.data._id;
2323
console.log('User ID:', userId);
2424

2525
const cvEndpoint = `/cv/${userId}`;
2626
console.log('Calling CV endpoint:', cvEndpoint);
27-
27+
2828
const response = await api.get(cvEndpoint);
29-
29+
3030
if (response.data.success) {
3131
setCvData(response.data.data);
3232
console.log('CV data fetched successfully');
@@ -44,29 +44,27 @@ const ViewCV = ({ onEdit }) => {
4444
const handleDownload = async () => {
4545
try {
4646
setDownloading(true);
47-
48-
const response = await api.get('/cv/download', {
47+
48+
const userResponse = await api.get('/users/current-user');
49+
const userId = userResponse.data.data._id;
50+
51+
const response = await api.get(`/cv/download/${userId}`, {
4952
responseType: 'blob',
5053
});
51-
54+
5255
const url = window.URL.createObjectURL(new Blob([response.data]));
5356
const link = document.createElement('a');
5457
link.href = url;
55-
56-
const fileName = getFileName();
57-
link.setAttribute('download', fileName);
58-
58+
link.setAttribute('download', getFileName());
5959
document.body.appendChild(link);
60-
6160
link.click();
62-
6361
link.parentNode.removeChild(link);
6462
window.URL.revokeObjectURL(url);
65-
63+
6664
toast.success('CV downloaded successfully!');
6765
} catch (error) {
6866
console.error('Error downloading CV:', error);
69-
toast.error(error.response?.data?.message || 'Failed to download CV');
67+
toast.error('Failed to download CV');
7068
} finally {
7169
setDownloading(false);
7270
}
@@ -199,7 +197,7 @@ const ViewCV = ({ onEdit }) => {
199197
<Edit className="h-4 w-4" />
200198
Edit CV
201199
</button>
202-
200+
203201
<button
204202
onClick={handleDownload}
205203
disabled={downloading}

frontend/src/components/Login/Login.jsx

Lines changed: 15 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React, { useState } from 'react';
22
import { User, Lock, Eye, EyeOff } from 'lucide-react';
3-
import {toast} from 'react-toastify';
3+
import { toast } from 'react-toastify';
44

55
const Login = ({ onLogin, onNavigateToRegister }) => {
66
const [formData, setFormData] = useState({
@@ -13,7 +13,7 @@ const Login = ({ onLogin, onNavigateToRegister }) => {
1313

1414
const handleSubmit = async (e) => {
1515
e.preventDefault();
16-
16+
1717
if (!formData.email.trim() || !formData.password.trim()) {
1818
setError('Please fill in all fields');
1919
return;
@@ -23,36 +23,31 @@ const Login = ({ onLogin, onNavigateToRegister }) => {
2323
setError('');
2424

2525
try {
26-
const response = await fetch('http://localhost:5000/api/users/login', {
27-
method: 'POST',
28-
headers: { 'Content-Type': 'application/json' },
29-
credentials: 'include',
30-
body: JSON.stringify(formData)
31-
});
32-
33-
if (response.ok) {
34-
const result = await response.json();
35-
console.log(result);
36-
toast.success('Login successful! Welcome back.');
37-
onLogin(result.data);
26+
const response = await api.post('/users/login', formData, { withCredentials: true });
27+
console.log(response.data);
28+
toast.success('Login successful! Welcome back.');
29+
onLogin(response.data.data);
30+
} catch (err) {
31+
console.error(err);
32+
if (err.response) {
33+
toast.error(err.response.data?.message || 'Login failed');
3834
} else {
39-
toast.error(result.message || 'Login failed');
35+
toast.error('Network error. Please try again.');
4036
}
41-
} catch (err) {
42-
toast.error('Wrong credentials. Please try again.');
4337
} finally {
4438
setLoading(false);
4539
}
40+
4641
};
4742

4843
return (
4944
<div className="min-h-screen bg-gradient-to-r from-[#04445E] to-[#169AB4] flex items-center justify-center p-4">
5045
<div className="bg-white rounded-2xl shadow-2xl p-8 w-full max-w-md">
5146
<div className="text-center mb-8">
5247
<div className="mb-6">
53-
<img
54-
src="/NEXT-STEPS-LOGO.png"
55-
alt="Next Steps Logo"
48+
<img
49+
src="/NEXT-STEPS-LOGO.png"
50+
alt="Next Steps Logo"
5651
className="h-16 mx-auto mb-4"
5752
/>
5853
</div>

0 commit comments

Comments
 (0)