/*********************************************************************************************************************************************************************** * 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); } };