Skip to content

Commit c7ef879

Browse files
committed
feat: Add certificate upload functionality in OET Score and confirmation modals
1 parent 8b7bb4e commit c7ef879

File tree

11 files changed

+2375
-76
lines changed

11 files changed

+2375
-76
lines changed

backend/package-lock.json

Lines changed: 1636 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"author": "",
1313
"license": "ISC",
1414
"dependencies": {
15+
"@aws-sdk/client-s3": "^3.921.0",
1516
"aws-sdk": "^2.1692.0",
1617
"axios": "^1.12.2",
1718
"bcrypt": "^6.0.0",

backend/src/controllers/workshop.controller.js

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { s3 } from '../middlewares/s3.upload.middleware.js';
12
import { Workshop } from '../models/workshop.model.js';
23
import { WorkshopRegistration } from '../models/workshopRegistration.model.js';
34
import { User } from '../models/users.model.js';
@@ -350,6 +351,93 @@ const getPendingRegistrations = asyncHandler(async (req, res) => {
350351
);
351352
});
352353

354+
const uploadCertificate = asyncHandler(async (req, res) => {
355+
const { registrationId } = req.params;
356+
const adminId = req.user._id;
357+
358+
if (!req.file) {
359+
throw new ApiError(400, 'Certificate file is required');
360+
}
361+
362+
const registration = await WorkshopRegistration.findById(registrationId);
363+
364+
if (!registration) {
365+
throw new ApiError(404, 'Registration not found');
366+
}
367+
368+
if (registration.certificate && registration.certificate.key) {
369+
try {
370+
await s3.deleteObject({
371+
Bucket: process.env.S3_BUCKET_NAME,
372+
Key: registration.certificate.key
373+
}).promise();
374+
} catch (error) {
375+
console.error('Error deleting old certificate from S3:', error);
376+
}
377+
}
378+
379+
registration.certificate = {
380+
url: req.file.location,
381+
key: req.file.key,
382+
uploadedAt: new Date(),
383+
uploadedBy: adminId
384+
};
385+
386+
await registration.save();
387+
388+
await User.updateOne(
389+
{ _id: registration.user, 'workshopRegistrations.workshop': registration.workshop },
390+
{
391+
$set: {
392+
'workshopRegistrations.$.certificate': req.file.location
393+
}
394+
}
395+
);
396+
397+
return res.status(200).json(
398+
new ApiResponse(200, registration, 'Certificate uploaded successfully to AWS S3')
399+
);
400+
});
401+
402+
const deleteCertificate = asyncHandler(async (req, res) => {
403+
const { registrationId } = req.params;
404+
405+
const registration = await WorkshopRegistration.findById(registrationId);
406+
407+
if (!registration) {
408+
throw new ApiError(404, 'Registration not found');
409+
}
410+
411+
if (registration.certificate && registration.certificate.key) {
412+
try {
413+
await s3.deleteObject({
414+
Bucket: process.env.S3_BUCKET_NAME,
415+
Key: registration.certificate.key
416+
}).promise();
417+
} catch (error) {
418+
console.error('Error deleting certificate from S3:', error);
419+
throw new ApiError(500, 'Failed to delete certificate from AWS S3');
420+
}
421+
}
422+
423+
registration.certificate = undefined;
424+
await registration.save();
425+
426+
await User.updateOne(
427+
{ _id: registration.user, 'workshopRegistrations.workshop': registration.workshop },
428+
{
429+
$set: {
430+
'workshopRegistrations.$.certificate': null
431+
}
432+
}
433+
);
434+
435+
return res.status(200).json(
436+
new ApiResponse(200, registration, 'Certificate deleted successfully from AWS S3')
437+
);
438+
});
439+
440+
353441
export {
354442
createWorkshop,
355443
getAllWorkshops,
@@ -362,5 +450,7 @@ export {
362450
cancelRegistration,
363451
confirmRegistration,
364452
rejectRegistration,
365-
getPendingRegistrations
453+
getPendingRegistrations,
454+
uploadCertificate,
455+
deleteCertificate
366456
};

backend/src/middlewares/s3.upload.middleware.js

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import multer from 'multer';
33
import multerS3 from 'multer-s3';
44
import { v4 as uuidv4 } from 'uuid';
55
import dotenv from 'dotenv';
6+
67
dotenv.config();
78

89
AWS.config.update({
@@ -66,17 +67,46 @@ const uploadDocument = multer({
6667
'image/png',
6768
'image/jpg',
6869
'application/pdf',
69-
'application/msword', // .doc
70-
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // .docx
70+
'application/msword',
71+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
7172
];
7273

7374
if (allowedTypes.includes(file.mimetype)) cb(null, true);
7475
else cb(new Error('Only image or document files are allowed'), false);
7576
},
7677
});
7778

78-
export { uploadPhoto, uploadDocument, s3 };
79-
80-
79+
const uploadCertificate = multer({
80+
storage: multerS3({
81+
s3,
82+
bucket: process.env.S3_BUCKET_NAME,
83+
key: (req, file, cb) => {
84+
const fileName = `workshop-certificates/${uuidv4()}-${file.originalname}`;
85+
cb(null, fileName);
86+
},
87+
contentType: multerS3.AUTO_CONTENT_TYPE,
88+
acl: 'public-read',
89+
metadata: (req, file, cb) => {
90+
cb(null, {
91+
fieldName: file.fieldname,
92+
uploadedBy: req.user._id.toString(),
93+
uploadDate: new Date().toISOString(),
94+
registrationId: req.params.registrationId
95+
});
96+
},
97+
}),
98+
limits: { fileSize: 10 * 1024 * 1024 },
99+
fileFilter: (req, file, cb) => {
100+
const allowedTypes = [
101+
'image/jpeg',
102+
'image/png',
103+
'image/jpg',
104+
'application/pdf'
105+
];
81106

107+
if (allowedTypes.includes(file.mimetype)) cb(null, true);
108+
else cb(new Error('Only PDF, JPG, and PNG files are allowed'), false);
109+
},
110+
});
82111

112+
export { uploadPhoto, uploadDocument, uploadCertificate, s3 };

backend/src/models/users.model.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,8 @@ const userSchema = new mongoose.Schema({
6464
default: 'pending'
6565
},
6666
confirmedAt: Date,
67-
rejectedAt: Date
67+
rejectedAt: Date,
68+
certificate: String
6869
}],
6970
conferenceRegistrations: [{
7071
type: mongoose.Schema.Types.ObjectId,

backend/src/routes/workshop.routes.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,12 @@ import {
1111
cancelRegistration,
1212
confirmRegistration,
1313
rejectRegistration,
14-
getPendingRegistrations
14+
getPendingRegistrations,
15+
uploadCertificate,
16+
deleteCertificate
1517
} from '../controllers/workshop.controller.js';
1618
import { verifyJWT } from '../middlewares/auth.middleware.js';
19+
import { uploadCertificate as uploadCertificateMiddleware } from '../middlewares/s3.upload.middleware.js';
1720

1821
const router = Router();
1922

@@ -29,5 +32,7 @@ router.route('/:id/register').post(verifyJWT, registerForWorkshop);
2932
router.route('/registrations/:registrationId').delete(verifyJWT, cancelRegistration);
3033
router.route('/registrations/:registrationId/confirm').put(verifyJWT, confirmRegistration);
3134
router.route('/registrations/:registrationId/reject').put(verifyJWT, rejectRegistration);
35+
router.route('/registrations/:registrationId/certificate').post(verifyJWT, uploadCertificateMiddleware.single('certificate'), uploadCertificate);
36+
router.route('/registrations/:registrationId/certificate').delete(verifyJWT, deleteCertificate);
3237

3338
export default router;
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { X, AlertTriangle, Info, CheckCircle } from 'lucide-react';
2+
3+
const ConfirmationModal = ({
4+
isOpen,
5+
onClose,
6+
onConfirm,
7+
title,
8+
message,
9+
confirmText = "Confirm",
10+
cancelText = "Cancel",
11+
type = "warning",
12+
children
13+
}) => {
14+
if (!isOpen) return null;
15+
16+
const getIconAndColors = () => {
17+
switch(type) {
18+
case 'warning':
19+
return {
20+
icon: AlertTriangle,
21+
iconBg: 'bg-yellow-100',
22+
iconColor: 'text-yellow-600',
23+
buttonBg: 'bg-yellow-600 hover:bg-yellow-700',
24+
borderColor: 'border-yellow-200'
25+
};
26+
case 'danger':
27+
return {
28+
icon: AlertTriangle,
29+
iconBg: 'bg-red-100',
30+
iconColor: 'text-red-600',
31+
buttonBg: 'bg-red-600 hover:bg-red-700',
32+
borderColor: 'border-red-200'
33+
};
34+
case 'info':
35+
return {
36+
icon: Info,
37+
iconBg: 'bg-blue-100',
38+
iconColor: 'text-blue-600',
39+
buttonBg: 'bg-blue-600 hover:bg-blue-700',
40+
borderColor: 'border-blue-200'
41+
};
42+
case 'success':
43+
return {
44+
icon: CheckCircle,
45+
iconBg: 'bg-green-100',
46+
iconColor: 'text-green-600',
47+
buttonBg: 'bg-green-600 hover:bg-green-700',
48+
borderColor: 'border-green-200'
49+
};
50+
default:
51+
return {
52+
icon: Info,
53+
iconBg: 'bg-gray-100',
54+
iconColor: 'text-gray-600',
55+
buttonBg: 'bg-gray-600 hover:bg-gray-700',
56+
borderColor: 'border-gray-200'
57+
};
58+
}
59+
};
60+
61+
const { icon: Icon, iconBg, iconColor, buttonBg, borderColor } = getIconAndColors();
62+
63+
return (
64+
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
65+
<div className="bg-white rounded-lg shadow-xl max-w-md w-full animate-fade-in">
66+
{/* Header */}
67+
<div className="flex items-center justify-between p-6 border-b border-gray-200">
68+
<h3 className="text-xl font-bold text-gray-900">{title}</h3>
69+
<button
70+
onClick={onClose}
71+
className="text-gray-400 hover:text-gray-600 transition-colors"
72+
>
73+
<X className="h-5 w-5" />
74+
</button>
75+
</div>
76+
77+
{/* Content */}
78+
<div className="p-6">
79+
<div className="flex items-start gap-4">
80+
<div className={`flex-shrink-0 ${iconBg} rounded-full p-3`}>
81+
<Icon className={`h-6 w-6 ${iconColor}`} />
82+
</div>
83+
<div className="flex-1">
84+
{message && (
85+
<p className="text-gray-700 leading-relaxed mb-4">{message}</p>
86+
)}
87+
{children}
88+
</div>
89+
</div>
90+
91+
</div>
92+
93+
{/* Footer */}
94+
<div className="flex items-center justify-end gap-3 p-6 bg-gray-50 rounded-b-lg">
95+
<button
96+
onClick={onClose}
97+
className="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-100 transition-colors font-medium"
98+
>
99+
{cancelText}
100+
</button>
101+
<button
102+
onClick={onConfirm}
103+
className={`px-6 py-2 text-white rounded-lg transition-colors font-medium ${buttonBg}`}
104+
>
105+
{confirmText}
106+
</button>
107+
</div>
108+
</div>
109+
</div>
110+
);
111+
};
112+
113+
export default ConfirmationModal;

0 commit comments

Comments
 (0)