/*********************************************************************************************************************************************************************** * File Name : issue_certificates.cron.js * Type : Cron Job * Description : Issues certificates for users who passed a course assessment. * Runs every hour on the hour and processes any * pending_certificates row where issue_at <= NOW() and * processed_at IS NULL. * * For each ready row it: * 1. Grants the course_completed_ achievement (the key * MyCertificates / Profile use to display certificate cards). * 2. Persists the certificate record (cert_no/ref_no) via * services/certificate-record.service.js, so course.certificate * is populated immediately instead of only on first PDF download. * 3. Sends a 'certificate_issued' UserNotification. * 4. Marks the row processed_at = NOW() so it never fires again. * * Safety pattern: processed_at is set only after both step 1 and * step 2 succeed. If the process restarts mid-run the row will be * picked up again on the next tick — both DB writes are idempotent. * * Schedule : Every hour on the hour ("0 * * * *"). Registered by * cron/client.cron.js. * * Author: Kenneth Obsequio (@lash0000) * Date Created: Jun. 24, 2026 ***********************************************************************************************************************************************************************/ 'use strict'; const { Op } = require('sequelize'); const PendingCertificate = require('../../models/courses/pending_certificate.mdl'); const mdl_Achievements = require('../../models/users/achievements.mdl'); const UserNotification = require('../../models/notifications/user_notification.mdl'); const CronNotificationSetting = require('../../models/notifications/cron_notification_setting.mdl'); const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data'); const { ensureCertificateRecord } = require('../../services/certificate-record.service'); async function run() { // Certificate/achievement issuance always happens — only the notification step is skippable. const settings = await CronNotificationSetting.findOne({ where: { job_name: 'issueCertificates' } }); const notificationsEnabled = !settings || settings.enabled; // ── 1. Fetch all rows ready to process ──────────────────────────────────── let rows; try { rows = await PendingCertificate.findAll({ where: { issue_at: { [Op.lte]: new Date() }, processed_at: null, }, raw: true, }); } catch (err) { console.error('[CRON][ISSUE CERTS] Failed to query pending_certificates:', err); return; } if (rows.length === 0) return; console.log(`[CRON][ISSUE CERTS] Processing ${rows.length} pending certificate(s).`); for (const row of rows) { const { pending_id, user_id, course_uuid, course_title } = row; const achKey = `course_completed_${course_uuid}`; try { // ── 2. Grant course_completed_ achievement (idempotent) ────── const existing = await mdl_Achievements.findOne({ where: { user_id, key: achKey } }); if (!existing) { await mdl_Achievements.create({ user_id, type: 'milestone', key: achKey, label: 'Certificate of Completion', description: course_title ?? '', granted_at: new Date(), metadata: { courseTitle: course_title, courseUuid: course_uuid }, }); } // ── 3. Persist the actual certificate record (cert_no/ref_no) so // course.certificate is populated immediately, instead of only // lazily on first PDF download ──────────────────────────────── await ensureCertificateRecord({ userId: user_id, courseId: row.course_id }); // ── 4. Send certificate_issued notification ───────────────────────── if (notificationsEnabled) { await UserNotification.create({ user_id, ...NOTIFICATION_REGISTRY.certificate_issued.build({ courseTitle: course_title ?? '', courseUuid: course_uuid, }), }); } // ── 5. Mark row processed ───────────────────────────────────────── await PendingCertificate.update( { processed_at: new Date() }, { where: { pending_id } } ); console.log(`[CRON][ISSUE CERTS] Issued certificate for user ${user_id} / course ${course_uuid}.`); } catch (err) { // Log and continue — next tick will retry this row if (err?.parent?.code !== '23505') { console.error(`[CRON][ISSUE CERTS] Failed for pending_id ${pending_id}:`, err); } else { // Unique constraint: achievement already exists — still mark processed await PendingCertificate.update( { processed_at: new Date() }, { where: { pending_id } } ).catch(() => {}); } } } } module.exports = { name: 'issueCertificates', schedule: '0 * * * *', run, };