import { prisma } from '../config/database'; import { config } from '../config'; import { logger } from '../config/logger'; import { NotFoundError, ForbiddenError, ValidationError } from '../middleware/errorHandler'; import jwt from 'jsonwebtoken'; import { PDFDocument, rgb } from 'pdf-lib'; import fontkit from '@pdf-lib/fontkit'; import * as fs from 'fs'; import * as path from 'path'; import { uploadFile, getPresignedUrl } from '../config/minio'; import { GenerateCertificateInput, GenerateCertificateResponse, GetCertificateInput, GetCertificateResponse, ListMyCertificatesInput, ListMyCertificatesResponse, } from '../types/certificate.types'; export class CertificateService { private static TEMPLATE_PATH = path.join(__dirname, '../../assets/templates/Certificate.pdf'); private static FONT_REGULAR_PATH = path.join(__dirname, '../../assets/fonts/THSarabunNew.ttf'); private static FONT_BOLD_PATH = path.join(__dirname, '../../assets/fonts/THSarabunNew Bold.ttf'); /** * Generate certificate PDF for a completed course */ async generateCertificate(input: GenerateCertificateInput): Promise { try { const { token, course_id } = input; const decoded = jwt.verify(token, config.jwt.secret) as { id: number }; // Check enrollment and completion const enrollment = await prisma.enrollment.findUnique({ where: { unique_enrollment: { user_id: decoded.id, course_id, }, }, include: { course: { select: { id: true, title: true, have_certificate: true, }, }, user: { include: { profile: true, }, }, }, }); if (!enrollment) { throw new NotFoundError('Enrollment not found'); } if (enrollment.status !== 'COMPLETED') { throw new ForbiddenError('Course not completed yet'); } if (!enrollment.course.have_certificate) { throw new ValidationError('This course does not offer certificates'); } // Check if certificate already exists const existingCertificate = await prisma.certificate.findFirst({ where: { user_id: decoded.id, course_id, }, }); if (existingCertificate) { // Return existing certificate const downloadUrl = await getPresignedUrl(existingCertificate.file_path, 3600); return { code: 200, message: 'Certificate already exists', data: { certificate_id: existingCertificate.id, file_path: existingCertificate.file_path, download_url: downloadUrl, issued_at: existingCertificate.issued_at, }, }; } // Get user full name const firstName = enrollment.user.profile?.first_name || ''; const lastName = enrollment.user.profile?.last_name || ''; const fullName = `${firstName} ${lastName}`.trim() || enrollment.user.username; // Get course title const courseTitle = enrollment.course.title as { th: string; en: string }; // Generate PDF const pdfBytes = await this.createCertificatePDF({ studentName: fullName, courseTitleTh: courseTitle.th, courseTitleEn: courseTitle.en, completionDate: enrollment.completed_at || new Date(), }); // Upload to MinIO const timestamp = Date.now(); const filePath = `certificates/${course_id}/${decoded.id}/${timestamp}.pdf`; await uploadFile(filePath, Buffer.from(pdfBytes), 'application/pdf'); // Save to database const certificate = await prisma.certificate.create({ data: { user_id: decoded.id, course_id, enrollment_id: enrollment.id, file_path: filePath, issued_at: new Date(), }, }); const downloadUrl = await getPresignedUrl(filePath, 3600); return { code: 201, message: 'Certificate generated successfully', data: { certificate_id: certificate.id, file_path: certificate.file_path, download_url: downloadUrl, issued_at: certificate.issued_at, }, }; } catch (error) { logger.error('Failed to generate certificate', { error }); throw error; } } /** * Get certificate for a specific course */ async getCertificate(input: GetCertificateInput): Promise { try { const { token, course_id } = input; const decoded = jwt.verify(token, config.jwt.secret) as { id: number }; const certificate = await prisma.certificate.findFirst({ where: { user_id: decoded.id, course_id, }, include: { enrollment: { include: { course: { select: { title: true, }, }, }, }, }, }); if (!certificate) { throw new NotFoundError('Certificate not found'); } const downloadUrl = await getPresignedUrl(certificate.file_path, 3600); const courseTitle = certificate.enrollment.course.title as { th: string; en: string }; return { code: 200, message: 'Certificate retrieved successfully', data: { certificate_id: certificate.id, course_id: certificate.course_id, course_title: courseTitle, file_path: certificate.file_path, download_url: downloadUrl, issued_at: certificate.issued_at, }, }; } catch (error) { logger.error('Failed to get certificate', { error }); throw error; } } /** * List all certificates for the authenticated user */ async listMyCertificates(input: ListMyCertificatesInput): Promise { try { const { token } = input; const decoded = jwt.verify(token, config.jwt.secret) as { id: number }; const certificates = await prisma.certificate.findMany({ where: { user_id: decoded.id, }, include: { enrollment: { include: { course: { select: { title: true, }, }, }, }, }, orderBy: { issued_at: 'desc', }, }); const data = await Promise.all( certificates.map(async (cert) => { const downloadUrl = await getPresignedUrl(cert.file_path, 3600); const courseTitle = cert.enrollment.course.title as { th: string; en: string }; return { certificate_id: cert.id, course_id: cert.course_id, course_title: courseTitle, download_url: downloadUrl, issued_at: cert.issued_at, }; }) ); return { code: 200, message: 'Certificates retrieved successfully', data, }; } catch (error) { logger.error('Failed to list certificates', { error }); throw error; } } /** * Create certificate PDF from template */ private async createCertificatePDF(params: { studentName: string; courseTitleTh: string; courseTitleEn: string; completionDate: Date; }): Promise { const { studentName, courseTitleTh, courseTitleEn, completionDate } = params; // Load template PDF const templateBytes = fs.readFileSync(CertificateService.TEMPLATE_PATH); const pdfDoc = await PDFDocument.load(templateBytes); // Register fontkit for custom fonts pdfDoc.registerFontkit(fontkit); // Load Thai fonts const fontRegularBytes = fs.readFileSync(CertificateService.FONT_REGULAR_PATH); const fontBoldBytes = fs.readFileSync(CertificateService.FONT_BOLD_PATH); const fontRegular = await pdfDoc.embedFont(fontRegularBytes); const fontBold = await pdfDoc.embedFont(fontBoldBytes); // Get first page const pages = pdfDoc.getPages(); const page = pages[0]; const { width, height } = page.getSize(); // Format date in Thai const thaiMonths = [ 'มกราคม', 'กุมภาพันธ์', 'มีนาคม', 'เมษายน', 'พฤษภาคม', 'มิถุนายน', 'กรกฎาคม', 'สิงหาคม', 'กันยายน', 'ตุลาคม', 'พฤศจิกายน', 'ธันวาคม' ]; const day = completionDate.getDate(); const month = thaiMonths[completionDate.getMonth()]; const year = completionDate.getFullYear() + 543; // Buddhist Era const dateText = `${day} ${month} ${year}`; // Colors for dark blue background const whiteColor = rgb(1, 1, 1); // White text const lightGrayColor = rgb(0.85, 0.85, 0.85); // Light gray for secondary text // Draw student name (centered) - positioned below "HAS SUCCESSFULLY COMPLETED" const nameSize = 42; const nameWidth = fontBold.widthOfTextAtSize(studentName, nameSize); page.drawText(studentName, { x: (width - nameWidth) / 2, y: height * 0.59, size: nameSize, font: fontBold, color: whiteColor, }); // Draw course title (Thai + English combined, centered) const courseTitle = `${courseTitleTh} ${courseTitleEn}`; const courseTitleSize = 28; const courseTitleWidth = fontRegular.widthOfTextAtSize(courseTitle, courseTitleSize); page.drawText(courseTitle, { x: (width - courseTitleWidth) / 2, y: height * 0.43, size: courseTitleSize, font: fontRegular, color: whiteColor, }); // Draw completion date (centered) - at bottom const dateSize = 18; const dateWidth = fontRegular.widthOfTextAtSize(dateText, dateSize); page.drawText(dateText, { x: (width - dateWidth) / 2, y: height * 0.30, size: dateSize, font: fontRegular, color: lightGrayColor, }); // Save and return PDF bytes return await pdfDoc.save(); } }