Files
starr-philproperties/cron/jobs/issue_certificates.cron.js
T
kennethobsequio a572c1e25f add: more commits
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
2026-07-03 21:40:24 +08:00

128 lines
6.2 KiB
JavaScript

/***********************************************************************************************************************************************************************
* 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_<uuid> 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 { getNotificationTemplate, renderNotificationContent } = require('../../services/notificationTemplate.service');
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).`);
// Fetched once outside the loop — content differs per row (courseTitle),
// but there's no need to re-query the template for every row.
const certificateTemplate = notificationsEnabled ? await getNotificationTemplate('certificate_issued') : null;
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_<uuid> 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,
...renderNotificationContent(certificateTemplate, {
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,
};