diff --git a/Backend/assets/fonts/THSarabunNew Bold.ttf b/Backend/assets/fonts/THSarabunNew Bold.ttf new file mode 100755 index 00000000..a059996b Binary files /dev/null and b/Backend/assets/fonts/THSarabunNew Bold.ttf differ diff --git a/Backend/assets/fonts/THSarabunNew BoldItalic.ttf b/Backend/assets/fonts/THSarabunNew BoldItalic.ttf new file mode 100755 index 00000000..e38f6d2f Binary files /dev/null and b/Backend/assets/fonts/THSarabunNew BoldItalic.ttf differ diff --git a/Backend/assets/fonts/THSarabunNew Italic.ttf b/Backend/assets/fonts/THSarabunNew Italic.ttf new file mode 100755 index 00000000..15312bc0 Binary files /dev/null and b/Backend/assets/fonts/THSarabunNew Italic.ttf differ diff --git a/Backend/assets/fonts/THSarabunNew.ttf b/Backend/assets/fonts/THSarabunNew.ttf new file mode 100755 index 00000000..13c55aee Binary files /dev/null and b/Backend/assets/fonts/THSarabunNew.ttf differ diff --git a/Backend/assets/templates/Certificate.pdf b/Backend/assets/templates/Certificate.pdf new file mode 100644 index 00000000..85995b90 Binary files /dev/null and b/Backend/assets/templates/Certificate.pdf differ diff --git a/Backend/src/controllers/CertificateController.ts b/Backend/src/controllers/CertificateController.ts new file mode 100644 index 00000000..13ec2075 --- /dev/null +++ b/Backend/src/controllers/CertificateController.ts @@ -0,0 +1,61 @@ +import { Get, Post, Route, Tags, SuccessResponse, Response, Security, Path, Request } from 'tsoa'; +import { ValidationError } from '../middleware/errorHandler'; +import { CertificateService } from '../services/certificate.service'; +import { + GenerateCertificateResponse, + GetCertificateResponse, + ListMyCertificatesResponse, +} from '../types/certificate.types'; + +@Route('api/certificates') +@Tags('Certificates') +export class CertificateController { + private certificateService = new CertificateService(); + + /** + * ดึงรายการใบ Certificate ทั้งหมดของผู้ใช้ + * Get all certificates for the authenticated user + */ + @Get('') + @Security('jwt') + @SuccessResponse('200', 'Certificates retrieved successfully') + @Response('401', 'Invalid or expired token') + public async listMyCertificates(@Request() request: any): Promise { + const token = request.headers.authorization?.replace('Bearer ', ''); + if (!token) throw new ValidationError('No token provided'); + return await this.certificateService.listMyCertificates({ token }); + } + + /** + * ดึงใบ Certificate ของคอร์สที่ระบุ + * Get certificate for a specific course + * @param courseId - รหัสคอร์ส / Course ID + */ + @Get('{courseId}') + @Security('jwt') + @SuccessResponse('200', 'Certificate retrieved successfully') + @Response('401', 'Invalid or expired token') + @Response('404', 'Certificate not found') + public async getCertificate(@Request() request: any, @Path() courseId: number): Promise { + const token = request.headers.authorization?.replace('Bearer ', ''); + if (!token) throw new ValidationError('No token provided'); + return await this.certificateService.getCertificate({ token, course_id: courseId }); + } + + /** + * สร้างใบ Certificate สำหรับคอร์สที่เรียนจบแล้ว + * Generate certificate for a completed course + * @param courseId - รหัสคอร์ส / Course ID + */ + @Post('{courseId}/generate') + @Security('jwt') + @SuccessResponse('201', 'Certificate generated successfully') + @Response('400', 'Course not completed or does not offer certificates') + @Response('401', 'Invalid or expired token') + @Response('404', 'Enrollment not found') + public async generateCertificate(@Request() request: any, @Path() courseId: number): Promise { + const token = request.headers.authorization?.replace('Bearer ', ''); + if (!token) throw new ValidationError('No token provided'); + return await this.certificateService.generateCertificate({ token, course_id: courseId }); + } +} diff --git a/Backend/src/services/certificate.service.ts b/Backend/src/services/certificate.service.ts new file mode 100644 index 00000000..ab1749ae --- /dev/null +++ b/Backend/src/services/certificate.service.ts @@ -0,0 +1,326 @@ +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(); + } +} diff --git a/Backend/src/types/certificate.types.ts b/Backend/src/types/certificate.types.ts new file mode 100644 index 00000000..a35caa19 --- /dev/null +++ b/Backend/src/types/certificate.types.ts @@ -0,0 +1,53 @@ +// ============================================ +// Certificate Types +// ============================================ + +export interface GenerateCertificateInput { + token: string; + course_id: number; +} + +export interface GenerateCertificateResponse { + code: number; + message: string; + data?: { + certificate_id: number; + file_path: string; + download_url: string; + issued_at: Date; + }; +} + +export interface GetCertificateInput { + token: string; + course_id: number; +} + +export interface GetCertificateResponse { + code: number; + message: string; + data?: { + certificate_id: number; + course_id: number; + course_title: { th: string; en: string }; + file_path: string; + download_url: string; + issued_at: Date; + }; +} + +export interface ListMyCertificatesInput { + token: string; +} + +export interface ListMyCertificatesResponse { + code: number; + message: string; + data: { + certificate_id: number; + course_id: number; + course_title: { th: string; en: string }; + download_url: string; + issued_at: Date; + }[]; +}