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