mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
+29
-12
@@ -14,8 +14,16 @@
|
||||
* That's the only wiring required — server.js never needs to
|
||||
* change when admin-side jobs are added/removed.
|
||||
*
|
||||
* taskOverdue is settings-backed (see cronRegistry.util.js) —
|
||||
* its schedule/enabled state lives in cron_notification_settings
|
||||
* and is configurable from /admin/notifications/settings without
|
||||
* a restart. liftExpiredBans is not notification-related, so it
|
||||
* stays on a plain hardcoded schedule.
|
||||
*
|
||||
* Currently registered:
|
||||
* - taskOverdue (cron/jobs/taskOverdue.cron.js)
|
||||
* - taskOverdue (cron/jobs/task_overdue.cron.js) — settings-backed
|
||||
* - liftExpiredBans (cron/jobs/lift_expired_bans.cron.js)
|
||||
* - dispatchEmailBroadcasts (cron/jobs/dispatch_email_broadcasts.cron.js)
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jun. 17, 2026
|
||||
@@ -23,25 +31,34 @@
|
||||
const cron = require('node-cron');
|
||||
const taskOverdue = require('./jobs/task_overdue.cron');
|
||||
const liftExpiredBans = require('./jobs/lift_expired_bans.cron');
|
||||
const dispatchEmailBroadcasts = require('./jobs/dispatch_email_broadcasts.cron');
|
||||
const { startSettingsBackedJobs } = require('./cronRegistry.util');
|
||||
|
||||
// ─── Registry — add future admin-side cron jobs here ─────────────────────────
|
||||
const jobs = [
|
||||
const settingsBackedJobs = [
|
||||
taskOverdue,
|
||||
];
|
||||
|
||||
// Plain hardcoded-schedule jobs (not tied to any notification setting).
|
||||
const plainJobs = [
|
||||
liftExpiredBans,
|
||||
dispatchEmailBroadcasts,
|
||||
];
|
||||
|
||||
// ─── Boot all registered admin-side jobs ──────────────────────────────────────
|
||||
function startAdminCronJobs() {
|
||||
const registered = [];
|
||||
jobs.forEach(({ name, schedule, run }) => {
|
||||
if (!cron.validate(schedule)) {
|
||||
console.error(`[CRON][ADMIN] Invalid schedule for "${name}": "${schedule}" — skipped.`);
|
||||
return;
|
||||
async function startAdminCronJobs() {
|
||||
const registered = await startSettingsBackedJobs(settingsBackedJobs, 'ADMIN');
|
||||
|
||||
for (const job of plainJobs) {
|
||||
if (!cron.validate(job.schedule)) {
|
||||
console.error(`[CRON][ADMIN] Invalid schedule for "${job.name}": "${job.schedule}" — skipped.`);
|
||||
continue;
|
||||
}
|
||||
cron.schedule(schedule, run);
|
||||
registered.push({ name, scope: 'ADMIN', schedule });
|
||||
});
|
||||
cron.schedule(job.schedule, job.run);
|
||||
registered.push({ name: job.name, scope: 'ADMIN', schedule: job.schedule });
|
||||
}
|
||||
|
||||
return registered;
|
||||
}
|
||||
|
||||
module.exports = { startAdminCronJobs };
|
||||
module.exports = { startAdminCronJobs };
|
||||
|
||||
+9
-13
@@ -5,6 +5,11 @@
|
||||
* Same shape as admin.cron.js — each job module exports
|
||||
* { name, schedule, run }, listed in the `jobs` array below.
|
||||
*
|
||||
* All three are settings-backed (see cronRegistry.util.js) —
|
||||
* schedule/enabled state lives in cron_notification_settings
|
||||
* and is configurable from /admin/notifications/settings
|
||||
* without a restart.
|
||||
*
|
||||
* Currently registered:
|
||||
* - userNotifications (cron/jobs/user_notifications.cron.js)
|
||||
* - issueCertificates (cron/jobs/issue_certificates.cron.js)
|
||||
@@ -13,10 +18,10 @@
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jun. 17, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
const cron = require('node-cron');
|
||||
const userNotifications = require('./jobs/user_notifications.cron');
|
||||
const issueCertificates = require('./jobs/issue_certificates.cron');
|
||||
const expireUserTiers = require('./jobs/expire_user_tiers.cron');
|
||||
const { startSettingsBackedJobs } = require('./cronRegistry.util');
|
||||
|
||||
// ─── Registry — add future client-side cron jobs here ────────────────────────
|
||||
const jobs = [
|
||||
@@ -26,17 +31,8 @@ const jobs = [
|
||||
];
|
||||
|
||||
// ─── Boot all registered client-side jobs ─────────────────────────────────────
|
||||
function startClientCronJobs() {
|
||||
const registered = [];
|
||||
jobs.forEach(({ name, schedule, run }) => {
|
||||
if (!cron.validate(schedule)) {
|
||||
console.error(`[CRON][CLIENT] Invalid schedule for "${name}": "${schedule}" — skipped.`);
|
||||
return;
|
||||
}
|
||||
cron.schedule(schedule, run);
|
||||
registered.push({ name, scope: 'CLIENT', schedule });
|
||||
});
|
||||
return registered;
|
||||
async function startClientCronJobs() {
|
||||
return startSettingsBackedJobs(jobs, 'CLIENT');
|
||||
}
|
||||
|
||||
module.exports = { startClientCronJobs };
|
||||
module.exports = { startClientCronJobs };
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name : cronRegistry.util.js
|
||||
* Type : Utility
|
||||
* Description : Shared machinery for settings-backed cron jobs (the 4 jobs
|
||||
* that emit notifications and are configurable from
|
||||
* /admin/notifications/settings). Not every cron job in the
|
||||
* app goes through this — jobs with no notification tied to
|
||||
* them (e.g. lift_expired_bans) keep using node-cron directly.
|
||||
*
|
||||
* startSettingsBackedJobs() reads each job's schedule from
|
||||
* cron_notification_settings (falling back to — and seeding —
|
||||
* the job's own hardcoded default on first boot), then keeps
|
||||
* a live reference to the scheduled task so it can be swapped
|
||||
* out later via rescheduleJob() without a server restart.
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jul. 2, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
'use strict';
|
||||
|
||||
const cron = require('node-cron');
|
||||
const CronNotificationSetting = require('../models/notifications/cron_notification_setting.mdl');
|
||||
|
||||
// job_name -> { task: ScheduledTask, run: fn }
|
||||
const runningTasks = new Map();
|
||||
|
||||
// CockroachDB can't run Sequelize's findOrCreate() — it wraps the insert in a
|
||||
// pg_temp PL/pgSQL function to atomically catch unique_violation, which
|
||||
// CockroachDB rejects ("cannot create user-defined functions under a temporary
|
||||
// schema"). Plain findOne-then-create sidesteps it; the race window (two boots
|
||||
// racing to seed the same job_name) is a non-issue here — jobs are seeded once.
|
||||
async function getOrCreateSetting(jobName, defaultSchedule) {
|
||||
let row = await CronNotificationSetting.findOne({ where: { job_name: jobName } });
|
||||
if (row) return row;
|
||||
|
||||
try {
|
||||
row = await CronNotificationSetting.create({ job_name: jobName, enabled: true, schedule: defaultSchedule });
|
||||
} catch (err) {
|
||||
row = await CronNotificationSetting.findOne({ where: { job_name: jobName } });
|
||||
if (!row) throw err;
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
async function startSettingsBackedJobs(jobs, scopeLabel) {
|
||||
const registered = [];
|
||||
|
||||
for (const { name, schedule: defaultSchedule, run } of jobs) {
|
||||
let schedule = defaultSchedule;
|
||||
try {
|
||||
const settings = await getOrCreateSetting(name, defaultSchedule);
|
||||
schedule = settings.schedule || defaultSchedule;
|
||||
} catch (err) {
|
||||
console.error(`[CRON][${scopeLabel}] Failed to load settings for "${name}", using hardcoded default:`, err);
|
||||
}
|
||||
|
||||
if (!cron.validate(schedule)) {
|
||||
console.error(`[CRON][${scopeLabel}] Invalid schedule for "${name}": "${schedule}" — skipped.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const task = cron.schedule(schedule, run);
|
||||
runningTasks.set(name, { task, run });
|
||||
registered.push({ name, scope: scopeLabel, schedule });
|
||||
}
|
||||
|
||||
return registered;
|
||||
}
|
||||
|
||||
// Live-swap a running job's schedule — used by notificationSettings.controller.js
|
||||
// after an admin picks a new preset. No server restart required.
|
||||
function rescheduleJob(jobName, newSchedule) {
|
||||
const entry = runningTasks.get(jobName);
|
||||
if (!entry) throw new Error(`No running cron task found for "${jobName}".`);
|
||||
if (!cron.validate(newSchedule)) throw new Error(`Invalid cron schedule: "${newSchedule}".`);
|
||||
|
||||
entry.task.stop();
|
||||
const task = cron.schedule(newSchedule, entry.run);
|
||||
runningTasks.set(jobName, { task, run: entry.run });
|
||||
}
|
||||
|
||||
module.exports = { startSettingsBackedJobs, rescheduleJob, getOrCreateSetting };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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).`);
|
||||
|
||||
@@ -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 } }
|
||||
|
||||
@@ -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 })
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user