Files
starr-philproperties/controllers/client/certificate.controller.js
T
kennethobsequio 89acdfc239 push
pushy

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
2026-06-28 11:29:07 +08:00

172 lines
7.7 KiB
JavaScript

/***********************************************************************************************************************************************************************
* File Name: certificate.controller.js (client)
* Type of Program: Controller
* Description: Issues a PDF certificate for a completed course.
* A certificate is available only when the user has passed the course assessment.
* Certificate records are persisted (findOrCreate) so the same cert_no/ref_no is
* returned on every subsequent download.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 18, 2026
***********************************************************************************************************************************************************************/
'use strict';
const R = require('../../utils/response.util');
const mdl_Users = require('../../models/users/users.mdl');
const { generateCertificate } = require('../../services/certificate.service');
const { formatDuration } = require('../../utils/duration.util');
const { fmtDate } = require('../../utils/datetime.util');
const {
Course,
CourseAssessment,
QuizAttempt,
Certificate,
CourseInstructor,
} = require('../../models/courses/courses.associations');
const notDeleted = { deletedAt: null };
// ─── Helpers ───────────────────────────────────────────────────────────────────
// cert_no format: YYYYMM-{userId:6}-{userCertSeq:5}
// userCertSeq = how many certs this user will have after this insert
async function buildCertNo(userId) {
const count = await Certificate.count({ where: { user_id: userId } });
const seq = String(count + 1).padStart(5, '0');
const uid = String(userId).padStart(6, '0');
const now = new Date();
const YYYYMM = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}`;
return `${YYYYMM}-${uid}-${seq}`;
}
// ref_no format: PP-YYYYMM-{globalSeq:5} (unique across all certs)
async function buildRefNo() {
const count = await Certificate.count();
const seq = String(count + 1).padStart(5, '0');
const now = new Date();
const YYYYMM = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}`;
return `PP-${YYYYMM}-${seq}`;
}
function formatInstructors(rows) {
const names = rows.map(r => r.display_name);
if (names.length === 0) return '';
if (names.length === 1) return names[0];
if (names.length === 2) return `${names[0]} and ${names[1]}`;
return `${names[0]}, ${names[1]} and et. al`;
}
// ─── GET /api/client/certificates/:courseUuid ──────────────────────────────────
exports.getCertificate = async (req, res) => {
try {
const { courseUuid } = req.params;
const user_id = req.user.user_id;
// ── 1. Resolve course ──────────────────────────────────────────────────────
const course = await Course.findOne({
where: { uuid: courseUuid, ...notDeleted },
attributes: ['course_id', 'title', 'course_code', 'duration_seconds'],
include: [
{
model: CourseAssessment,
as: 'assessment',
attributes: ['assessment_id'],
required: false,
},
{
model: CourseInstructor,
as: 'instructors',
attributes: ['display_name', 'order_index'],
required: false,
order: [['order_index', 'ASC']],
},
],
});
if (!course) return R.error(res, 'Course not found.', 404);
if (!course.assessment) {
return R.error(res, 'This course does not have an assessment — no certificate available.', 404);
}
// ── 2. Verify the user passed ──────────────────────────────────────────────
const passedAttempt = await QuizAttempt.findOne({
where: {
user_id,
assessment_id: course.assessment.assessment_id,
passed: true,
},
order: [['createdAt', 'DESC']],
attributes: ['score', 'createdAt'],
});
if (!passedAttempt) {
return R.error(res, 'Certificate not available — course assessment not passed yet.', 403);
}
// ── 3. Get user's name ─────────────────────────────────────────────────────
const user = await mdl_Users.findByPk(user_id, { attributes: ['personal_info'] });
const fullName = user?.personal_info?.name?.full_name?.trim() || 'Participant';
// ── 4. Resolve or create the certificate record ────────────────────────────
// CockroachDB does not support findOrCreate (uses temp PL/pgSQL functions).
let cert = await Certificate.findOne({ where: { user_id, course_id: course.course_id } });
if (!cert) {
cert = await Certificate.create({
user_id,
course_id: course.course_id,
cert_no: await buildCertNo(user_id),
ref_no: await buildRefNo(),
instructors: formatInstructors(course.instructors ?? []),
score: passedAttempt.score ?? null,
length_str: formatDuration(course.duration_seconds),
issued_at: passedAttempt.createdAt,
});
}
// Always use live instructors from course_instructors table for the PDF.
// Keep the snapshot in sync so it reflects the current state.
const liveInstructors = formatInstructors(course.instructors ?? []);
if (liveInstructors !== (cert.instructors ?? '')) {
await cert.update({ instructors: liveInstructors });
}
// ── 5. Format issued date as MM/DD/YY HH:MM AM/PM ───────────────────────────
const issuedDate = new Date(cert.issued_at);
const dateStr = fmtDate(issuedDate);
// ── 6. Generate PDF ────────────────────────────────────────────────────────
const pdf = await generateCertificate({
name: fullName,
course: course.title,
date: dateStr,
cert_no: cert.cert_no,
ref_no: cert.ref_no,
instructors: liveInstructors,
length: cert.length_str ?? '',
});
// ── 7. Stream response ─────────────────────────────────────────────────────
const nameParts = fullName.trim().split(/\s+/);
const lastName = nameParts.length > 1 ? nameParts[nameParts.length - 1] : nameParts[0];
const firstName = nameParts.length > 1 ? nameParts.slice(0, -1).join(' ') : '';
const safeTitle = course.title.replace(/[/\\:*?"<>|]/g, '').trim();
const filename = `${lastName},${firstName}_${safeTitle}_${cert.cert_no}.pdf`;
const asciiName = filename.replace(/[^\x20-\x7E]/g, '_').replace(/"/g, '_');
const encodedName = encodeURIComponent(filename);
res.set({
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${asciiName}"; filename*=UTF-8''${encodedName}`,
'Content-Length': pdf.length,
});
return res.send(pdf);
} catch (err) {
console.error('[CLIENT][CERTIFICATE]', err);
return R.error(res, 'Could not generate certificate.', 500);
}
};