365 lines
14 KiB
TypeScript
365 lines
14 KiB
TypeScript
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';
|
|
import { auditService } from './audit.service';
|
|
import { AuditAction } from '@prisma/client';
|
|
|
|
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(),
|
|
},
|
|
});
|
|
|
|
auditService.log({
|
|
userId: decoded.id,
|
|
action: AuditAction.CREATE,
|
|
entityType: 'Certificate',
|
|
entityId: certificate.id,
|
|
newValue: { file_path: certificate.file_path, issued_at: certificate.issued_at },
|
|
});
|
|
|
|
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 });
|
|
const decoded = jwt.decode(input.token) as { id: number } | null;
|
|
await auditService.logSync({
|
|
userId: decoded?.id,
|
|
action: AuditAction.ERROR,
|
|
entityType: 'Certificate',
|
|
entityId: 0,
|
|
metadata: {
|
|
operation: 'generate_certificate',
|
|
course_id: input.course_id,
|
|
error: error instanceof Error ? error.message : String(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 });
|
|
const decoded = jwt.decode(input.token) as { id: number } | null;
|
|
await auditService.logSync({
|
|
userId: decoded?.id,
|
|
action: AuditAction.ERROR,
|
|
entityType: 'Certificate',
|
|
entityId: 0,
|
|
metadata: {
|
|
operation: 'get_certificate',
|
|
course_id: input.course_id,
|
|
error: error instanceof Error ? error.message : String(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 });
|
|
const decoded = jwt.decode(input.token) as { id: number } | null;
|
|
await auditService.logSync({
|
|
userId: decoded?.id,
|
|
action: AuditAction.ERROR,
|
|
entityType: 'Certificate',
|
|
entityId: 0,
|
|
metadata: {
|
|
operation: 'list_my_certificates',
|
|
error: error instanceof Error ? error.message : String(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();
|
|
}
|
|
}
|