new commits

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-03 16:20:58 +08:00
parent 1372f4e975
commit 1d12f04967
93 changed files with 3849 additions and 1063 deletions
+110
View File
@@ -0,0 +1,110 @@
/***********************************************************************************************************************************************************************
* File Name : dispatch_email_broadcasts.cron.js
* Type : Cron Job
* Description : Sends real, paced SMTP email for queued email_broadcasts —
* the actual delivery half of controllers/admin/email_broadcasts
* .controller.js's createEmailBroadcast(), which only ever
* enqueues rows and returns immediately.
*
* Every tick, picks up to BATCH_SIZE 'pending' recipient rows
* (oldest broadcast first, FIFO within it) and sends them one
* at a time with a short delay between each — this is the "one
* by one, not a blocking for-loop in the API request" behavior:
* it's fine to block *here* because nothing is waiting on an
* HTTP response, and the delay keeps us well under Gmail SMTP's
* practical sustained-send pacing.
*
* Resumable by construction — if the process restarts mid-
* broadcast, the next tick just keeps consuming whatever rows
* are still 'pending'. Single-instance only: this does not use
* row-level locking, so running more than one app instance
* would let two ticks grab the same batch. Fine for the
* current single-process deployment; would need SELECT ... FOR
* UPDATE SKIP LOCKED before scaling horizontally.
*
* Schedule : Every minute ("* * * * *"). Registered by cron/admin.cron.js.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jul. 3, 2026
***********************************************************************************************************************************************************************/
const mdl_EmailBroadcast = require('../../models/email_templates/email_broadcast.mdl');
const mdl_EmailBroadcastRecipient = require('../../models/email_templates/email_broadcast_recipient.mdl');
const mdl_EmailTemplate = require('../../models/email_templates/email_templates.mdl');
const { sendEmail } = require('../../services/email.service');
const BATCH_SIZE = 25;
const DELAY_MS = 600; // pacing between individual sends — keeps us well under Gmail's throttling threshold
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function run() {
try {
const recipients = await mdl_EmailBroadcastRecipient.findAll({
where: { status: 'pending' },
include: [{
model: mdl_EmailBroadcast,
as: 'broadcast',
where: { status: ['queued', 'sending'] },
include: [{ model: mdl_EmailTemplate, as: 'template' }],
}],
order: [['email_broadcast_id', 'ASC'], ['email_broadcast_recipient_id', 'ASC']],
limit: BATCH_SIZE,
});
if (!recipients.length) return;
const touchedBroadcastIds = new Set();
for (const recipient of recipients) {
const broadcast = recipient.broadcast;
const template = broadcast?.template;
if (!broadcast || !template) {
await recipient.update({ status: 'failed', error: 'Broadcast or template no longer exists.', sent_at: new Date() });
continue;
}
if (broadcast.status === 'queued') {
await broadcast.update({ status: 'sending', started_at: broadcast.started_at ?? new Date() });
}
touchedBroadcastIds.add(broadcast.email_broadcast_id);
try {
await sendEmail({
to: recipient.email,
type: template.type,
data: { name: recipient.name || 'there', email: recipient.email },
});
await recipient.update({ status: 'sent', sent_at: new Date() });
await broadcast.increment('sent_count');
} catch (err) {
await recipient.update({ status: 'failed', error: err.message, sent_at: new Date() });
await broadcast.increment('failed_count');
console.error('[CRON][EMAIL BROADCAST] Send failed:', recipient.email, err.message);
}
await delay(DELAY_MS);
}
// Close out any broadcast that has no pending recipients left.
for (const broadcastId of touchedBroadcastIds) {
const remaining = await mdl_EmailBroadcastRecipient.count({ where: { email_broadcast_id: broadcastId, status: 'pending' } });
if (remaining === 0) {
await mdl_EmailBroadcast.update(
{ status: 'completed', completed_at: new Date() },
{ where: { email_broadcast_id: broadcastId, status: ['queued', 'sending'] } }
);
}
}
console.log(`[CRON][EMAIL BROADCAST] Processed ${recipients.length} recipient(s) across ${touchedBroadcastIds.size} broadcast(s).`);
} catch (err) {
console.error('[CRON][EMAIL BROADCAST] Failed:', err);
}
}
module.exports = {
name: 'dispatchEmailBroadcasts',
schedule: '* * * * *',
run,
};
+20 -14
View File
@@ -27,6 +27,7 @@ const { Op } = require('sequelize');
const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl');
const mdl_TierPlans = require('../../models/tiers/tier_plans.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');
require('../../models/tiers/tier.associations');
@@ -43,7 +44,7 @@ async function run() {
include: [{
model: mdl_TierPlans,
as: 'plan',
attributes: ['label', 'tier'],
attributes: ['plan_id', 'label', 'tier'],
required: false,
}],
attributes: ['tier_id', 'user_id', 'tier'],
@@ -69,20 +70,25 @@ async function run() {
}
// ── 3. Send in-app notifications (one per affected user) ──────────────────
const notifications = expired.map((t) =>
NOTIFICATION_REGISTRY.tier_expired.build({
tier: t.tier,
label: t.plan?.label ?? null,
})
).map((payload, i) => ({
user_id: expired[i].user_id,
...payload,
}));
// Status flip above always happens — only this step is skippable via settings.
const settings = await CronNotificationSetting.findOne({ where: { job_name: 'expireUserTiers' } });
if (!settings || settings.enabled) {
const notifications = expired.map((t) =>
NOTIFICATION_REGISTRY.tier_expired.build({
tier: t.tier,
label: t.plan?.label ?? null,
planId: t.plan?.plan_id ?? null,
})
).map((payload, i) => ({
user_id: expired[i].user_id,
...payload,
}));
try {
await UserNotification.bulkCreate(notifications, { ignoreDuplicates: true });
} catch (err) {
console.error('[CRON][EXPIRE TIERS] Notification bulkCreate failed:', err);
try {
await UserNotification.bulkCreate(notifications, { ignoreDuplicates: true });
} catch (err) {
console.error('[CRON][EXPIRE TIERS] Notification bulkCreate failed:', err);
}
}
console.log(`[CRON][EXPIRE TIERS] Expired ${expired.length} tier(s) for ${new Set(expired.map((t) => t.user_id)).size} user(s).`);
+27 -11
View File
@@ -9,8 +9,11 @@
* For each ready row it:
* 1. Grants the course_completed_<uuid> achievement (the key
* MyCertificates / Profile use to display certificate cards).
* 2. Sends a 'certificate_issued' UserNotification.
* 3. Marks the row processed_at = NOW() so it never fires again.
* 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
@@ -28,9 +31,15 @@ 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 {
@@ -69,16 +78,23 @@ async function run() {
});
}
// ── 3. Send certificate_issued notification ────────────────────────
await UserNotification.create({
user_id,
...NOTIFICATION_REGISTRY.certificate_issued.build({
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. Mark row processed ─────────────────────────────────────────
// ── 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 } }
+5
View File
@@ -25,6 +25,7 @@
const { Op } = require('sequelize');
const { Task } = require('../../models/task/task.mdl');
const AdminNotification = require('../../models/notifications/admin_notification.mdl');
const CronNotificationSetting = require('../../models/notifications/cron_notification_setting.mdl');
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
// ─── The actual sweep ────────────────────────────────────────────────────────
@@ -52,7 +53,11 @@ async function run() {
console.log(`[CRON][TASK OVERDUE] Marked ${affectedCount} task(s) as overdue.`);
// ── 2. Secondary: admin notification — isolated, never blocks step 1 ─────
// Skippable via /admin/notifications/settings — the status flip above always happens either way.
try {
const settings = await CronNotificationSetting.findOne({ where: { job_name: 'taskOverdue' } });
if (settings && !settings.enabled) return;
await AdminNotification.create(
NOTIFICATION_REGISTRY.task_overdue.build({ count: affectedCount })
);
+5
View File
@@ -24,11 +24,16 @@ const { Op, QueryTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const { Task } = require('../../models/task/task.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 WINDOW_MS = 65 * 60 * 1000; // 65-minute lookback
async function run() {
// Entire job exists to emit this notification — skippable via /admin/notifications/settings.
const settings = await CronNotificationSetting.findOne({ where: { job_name: 'userNotifications' } });
if (settings && !settings.enabled) return;
// ── 1. Find tasks that flipped to overdue in the last 65 minutes ──────────
let recentlyOverdue;
try {