feat: add certificate generation system with PDF template and Thai font support
This commit is contained in:
parent
c72b76c2a5
commit
9ed2347843
8 changed files with 440 additions and 0 deletions
BIN
Backend/assets/fonts/THSarabunNew Bold.ttf
Executable file
BIN
Backend/assets/fonts/THSarabunNew Bold.ttf
Executable file
Binary file not shown.
BIN
Backend/assets/fonts/THSarabunNew BoldItalic.ttf
Executable file
BIN
Backend/assets/fonts/THSarabunNew BoldItalic.ttf
Executable file
Binary file not shown.
BIN
Backend/assets/fonts/THSarabunNew Italic.ttf
Executable file
BIN
Backend/assets/fonts/THSarabunNew Italic.ttf
Executable file
Binary file not shown.
BIN
Backend/assets/fonts/THSarabunNew.ttf
Executable file
BIN
Backend/assets/fonts/THSarabunNew.ttf
Executable file
Binary file not shown.
BIN
Backend/assets/templates/Certificate.pdf
Normal file
BIN
Backend/assets/templates/Certificate.pdf
Normal file
Binary file not shown.
61
Backend/src/controllers/CertificateController.ts
Normal file
61
Backend/src/controllers/CertificateController.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
326
Backend/src/services/certificate.service.ts
Normal file
326
Backend/src/services/certificate.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
53
Backend/src/types/certificate.types.ts
Normal file
53
Backend/src/types/certificate.types.ts
Normal 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;
|
||||
}[];
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue