feat: add certificate generation system with PDF template and Thai font support

This commit is contained in:
JakkrapartXD 2026-01-30 14:14:00 +07:00
parent c72b76c2a5
commit 9ed2347843
8 changed files with 440 additions and 0 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -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<ListMyCertificatesResponse> {
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<GetCertificateResponse> {
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<GenerateCertificateResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await this.certificateService.generateCertificate({ token, course_id: courseId });
}
}

View file

@ -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<GenerateCertificateResponse> {
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<GetCertificateResponse> {
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<ListMyCertificatesResponse> {
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<Uint8Array> {
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();
}
}

View file

@ -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;
}[];
}