mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
215 lines
9.4 KiB
JavaScript
215 lines
9.4 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 mdl_Achievements = require('../../models/users/achievements.mdl');
|
|
const { generateCertificate } = require('../../services/certificate.service');
|
|
const { formatDuration } = require('../../utils/duration.util');
|
|
const UserNotification = require('../../models/notifications/user_notification.mdl');
|
|
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
|
|
|
|
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 ────────────────────────────
|
|
const [cert, created] = await Certificate.findOrCreate({
|
|
where: { user_id, course_id: course.course_id },
|
|
defaults: {
|
|
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. On first issue: fire notification + achievements ────────────────────
|
|
if (created) {
|
|
// Certificate issued notification
|
|
UserNotification.create({
|
|
user_id,
|
|
...NOTIFICATION_REGISTRY.certificate_issued.build({
|
|
courseTitle: course.title,
|
|
courseUuid,
|
|
}),
|
|
}).catch(err => console.error('[CERTIFICATE] Failed to emit notification:', err));
|
|
|
|
// Per-course completion achievement
|
|
mdl_Achievements.findOrCreate({
|
|
where: { user_id, key: `course_completed_${courseUuid}` },
|
|
defaults: {
|
|
type: 'milestone',
|
|
label: 'Certificate of Completion',
|
|
description: course.title,
|
|
granted_at: passedAttempt.createdAt,
|
|
metadata: { courseTitle: course.title, courseUuid },
|
|
},
|
|
}).catch(err => console.error('[CERTIFICATE] Failed to grant course achievement:', err));
|
|
|
|
// First-course achievement (only if this is their very first certificate)
|
|
const totalCerts = await Certificate.count({ where: { user_id } });
|
|
if (totalCerts === 1) {
|
|
mdl_Achievements.findOrCreate({
|
|
where: { user_id, key: 'first_course_completed' },
|
|
defaults: {
|
|
type: 'milestone',
|
|
label: 'First Course Completed',
|
|
description: 'Completed your very first course on Philproperties.',
|
|
granted_at: passedAttempt.createdAt,
|
|
metadata: { courseTitle: course.title, courseUuid },
|
|
},
|
|
}).catch(err => console.error('[CERTIFICATE] Failed to grant first-course achievement:', err));
|
|
}
|
|
}
|
|
|
|
// ── 6. Format issued date as MM/DD/YY HH:MM AM/PM ────────────────────────
|
|
const issuedDate = new Date(cert.issued_at);
|
|
const dateStr = new Intl.DateTimeFormat('en-US', {
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
year: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hour12: true,
|
|
}).format(issuedDate);
|
|
|
|
// ── 7. 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 ?? '',
|
|
});
|
|
|
|
// ── 8. 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`;
|
|
|
|
res.set({
|
|
'Content-Type': 'application/pdf',
|
|
'Content-Disposition': `attachment; filename="${filename}"`,
|
|
'Content-Length': pdf.length,
|
|
});
|
|
|
|
return res.send(pdf);
|
|
} catch (err) {
|
|
console.error('[CLIENT][CERTIFICATE]', err);
|
|
return R.error(res, 'Could not generate certificate.', 500);
|
|
}
|
|
};
|