Skip to content

Commit ef1c303

Browse files
committed
feat: implement automated certificate generation and email sending
1 parent 58a47d8 commit ef1c303

6 files changed

Lines changed: 252 additions & 1 deletion

File tree

backend/package-lock.json

Lines changed: 55 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"jsonwebtoken": "^9.0.2",
4444
"multer": "^1.4.5-lts.1",
4545
"nodemailer": "^6.9.7",
46+
"pdf-lib": "^1.17.1",
4647
"pdfkit": "^0.17.2",
4748
"qrcode": "^1.5.3",
4849
"razorpay": "^2.9.2",
@@ -54,4 +55,4 @@
5455
"nodemon": "^3.0.2",
5556
"prisma": "^6.19.0"
5657
}
57-
}
58+
}

backend/prisma/schema.prisma

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ model Event {
6666
6767
// Ticket styling options (JSON: template, primaryColor, accentColor, logoUrl)
6868
ticketStyle Json? @map("ticket_style")
69+
70+
// Certificate styling
71+
certificateEnabled Boolean @default(false) @map("certificate_enabled")
72+
certificateTemplateUrl String? @map("certificate_template_url")
73+
certificateMapping Json? @map("certificate_mapping")
6974
7075
createdAt DateTime @default(now()) @map("created_at")
7176
updatedAt DateTime @updatedAt @map("updated_at")

backend/src/routes/admin.routes.js

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,31 @@ const router = express.Router();
1212
router.use(authenticate);
1313
router.use(requireOrganizer);
1414

15+
// Upload generic file (e.g. certificate template)
16+
router.post('/upload', upload.single('file'), async (req, res) => {
17+
try {
18+
if (!req.file) {
19+
return res.status(400).json({ error: 'No file uploaded' });
20+
}
21+
22+
let fileUrl;
23+
24+
// Try Cloudinary first
25+
if (isCloudinaryConfigured()) {
26+
const result = await uploadToCloudinary(req.file.path, 'events/certificates');
27+
fileUrl = result.secure_url;
28+
} else {
29+
// Fallback to local
30+
fileUrl = `/uploads/${req.file.filename}`;
31+
}
32+
33+
res.json({ url: fileUrl });
34+
} catch (error) {
35+
console.error('Upload error:', error);
36+
res.status(500).json({ error: 'Failed to upload file' });
37+
}
38+
});
39+
1540
// Create event
1641
router.post('/events',
1742
[
@@ -1242,5 +1267,95 @@ router.delete('/events/:id/team/:memberId', async (req, res) => {
12421267
}
12431268
});
12441269

1270+
import { generateCertificate } from '../services/certificate.service.js';
1271+
import { sendCertificateEmail } from '../services/email.service.js';
1272+
1273+
// Send Certificates to checked-in users
1274+
router.post('/events/:id/certificates', async (req, res) => {
1275+
try {
1276+
const { id } = req.params;
1277+
const { dryRun } = req.body; // if true, just return count
1278+
1279+
const event = await prisma.event.findUnique({
1280+
where: { id }
1281+
});
1282+
1283+
if (!event) return res.status(404).json({ error: 'Event not found' });
1284+
if (!event.certificateEnabled || !event.certificateTemplateUrl) {
1285+
return res.status(400).json({ error: 'Certificates not configured for this event' });
1286+
}
1287+
1288+
// Find checked-in tickets
1289+
const tickets = await prisma.ticket.findMany({
1290+
where: {
1291+
checkedInAt: { not: null },
1292+
order: {
1293+
registration: {
1294+
eventId: id
1295+
}
1296+
}
1297+
},
1298+
include: {
1299+
order: {
1300+
include: {
1301+
registration: true
1302+
}
1303+
}
1304+
}
1305+
});
1306+
1307+
if (tickets.length === 0) {
1308+
return res.json({ message: 'No checked-in attendees found', count: 0 });
1309+
}
1310+
1311+
if (dryRun) {
1312+
return res.json({ message: 'Dry run complete', count: tickets.length });
1313+
}
1314+
1315+
let sentCount = 0;
1316+
1317+
// Process certificates
1318+
for (const ticket of tickets) {
1319+
const registration = ticket.order.registration;
1320+
// Use registration name if available, else fallback (schema says userEmail on registration, name might be in User model if we linked it, but registration has userEmail. Let's assume we need name.)
1321+
// Registration model doesn't have name field in the snippet I saw earlier?
1322+
// Wait, let me check schema again. Registration has `userEmail`. User model has `name`.
1323+
// But Registration doesn't link to User directly?
1324+
// "userEmail" map "user_email". "User" model has "email" @unique.
1325+
1326+
const user = await prisma.user.findUnique({
1327+
where: { email: registration.userEmail }
1328+
});
1329+
1330+
const userName = user ? user.name : registration.userEmail.split('@')[0];
1331+
const eventDate = event.startTime.toDateString();
1332+
1333+
try {
1334+
const pdfBytes = await generateCertificate(
1335+
event.certificateTemplateUrl,
1336+
event.certificateMapping,
1337+
{
1338+
userName,
1339+
eventName: event.title,
1340+
date: eventDate,
1341+
qrCode: ticket.id
1342+
}
1343+
);
1344+
1345+
await sendCertificateEmail(registration.userEmail, userName, event.title, Buffer.from(pdfBytes));
1346+
sentCount++;
1347+
} catch (err) {
1348+
console.error(`Failed to send cert to ${registration.userEmail}`, err);
1349+
}
1350+
}
1351+
1352+
res.json({ message: `Certificates sent to ${sentCount} attendees`, count: sentCount });
1353+
1354+
} catch (error) {
1355+
console.error('Certificate generation error:', error);
1356+
res.status(500).json({ error: 'Failed to generate certificates' });
1357+
}
1358+
});
1359+
12451360
export default router;
12461361

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
2+
import fetch from 'node-fetch';
3+
4+
export const generateCertificate = async (templateUrl, mapping, data) => {
5+
try {
6+
// 1. Fetch the template
7+
const existingPdfBytes = await fetch(templateUrl).then(res => res.arrayBuffer());
8+
9+
// 2. Load PDF
10+
const pdfDoc = await PDFDocument.load(existingPdfBytes);
11+
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
12+
const pages = pdfDoc.getPages();
13+
const firstPage = pages[0];
14+
const { width, height } = firstPage.getSize();
15+
16+
// 3. Draw fields
17+
for (const field of mapping) {
18+
const { fieldId, x, y, fontSize = 12, color = '#000000' } = field;
19+
20+
let text = '';
21+
if (fieldId === 'userName') text = data.userName || '';
22+
if (fieldId === 'eventName') text = data.eventName || '';
23+
if (fieldId === 'date') text = data.date || '';
24+
25+
// Convert hex color to rgb
26+
const r = parseInt(color.slice(1, 3), 16) / 255;
27+
const g = parseInt(color.slice(3, 5), 16) / 255;
28+
const b = parseInt(color.slice(5, 7), 16) / 255;
29+
30+
firstPage.drawText(text, {
31+
x: x * width,
32+
y: (1 - y) * height, // PDF y-axis starts from bottom, but DOM usually top
33+
size: fontSize,
34+
font: font,
35+
color: rgb(r, g, b),
36+
});
37+
}
38+
39+
// 4. Save
40+
const pdfBytes = await pdfDoc.save();
41+
return pdfBytes;
42+
} catch (error) {
43+
console.error('Certificate generation error:', error);
44+
throw error;
45+
}
46+
};

backend/src/services/email.service.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,3 +235,32 @@ export async function sendCustomEmail(to, subject, html) {
235235

236236
return transporter.sendMail(mailOptions);
237237
}
238+
239+
export async function sendCertificateEmail(toEmail, userName, eventName, pdfBuffer) {
240+
try {
241+
await transporter.sendMail({
242+
from: `"Occasio Events" <${process.env.SMTP_FROM_EMAIL || 'noreply@occasio.io'}>`,
243+
to: toEmail,
244+
subject: `Certificate of Participation - ${eventName}`,
245+
html: `
246+
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
247+
<h1>Hi ${userName},</h1>
248+
<p>Thank you for attending <strong>${eventName}</strong>.</p>
249+
<p>Please find your certificate of participation attached to this email.</p>
250+
<br>
251+
<p>Best regards,<br>The Occasio Team</p>
252+
</div>
253+
`,
254+
attachments: [
255+
{
256+
filename: `Certificate - ${eventName}.pdf`,
257+
content: pdfBuffer,
258+
contentType: 'application/pdf'
259+
}
260+
]
261+
});
262+
console.log(`Certificate sent to ${toEmail}`);
263+
} catch (error) {
264+
console.error('Email send error:', error);
265+
}
266+
}

0 commit comments

Comments
 (0)