chore: relocate backend into apps/api ahead of monorepo merge

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 12:20:40 +08:00
co-authored by Claude Sonnet 5
parent af44598df6
commit eaa6d2276a
388 changed files with 0 additions and 13998 deletions
+64
View File
@@ -0,0 +1,64 @@
/***********************************************************************************************************************************************************************
* File Name : admin.cron.js
* Type : Cron Registry — Admin side
* Description : Aggregates and registers every admin-facing scheduled job.
* Each job module in cron/jobs/ exports { name, schedule, run }:
* name {string} – unique identifier, used in logs
* schedule {string} – standard cron expression (node-cron)
* run {function} – async () => void, the actual work
*
* To add a new admin-side cron job:
* 1. Create cron/jobs/yourJob.cron.js exporting
* { name, schedule, run }
* 2. require() it below and add it to the `jobs` array
* 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/task_overdue.cron.js) — settings-backed
* - liftExpiredBans (cron/jobs/lift_expired_bans.cron.js)
* - retryStuckTranscodes (cron/jobs/retry_stuck_transcodes.cron.js)
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 17, 2026
***********************************************************************************************************************************************************************/
const cron = require('node-cron');
const taskOverdue = require('./jobs/task_overdue.cron');
const liftExpiredBans = require('./jobs/lift_expired_bans.cron');
const retryStuckTranscodes = require('./jobs/retry_stuck_transcodes.cron');
const { startSettingsBackedJobs } = require('./cronRegistry.util');
// ─── Registry — add future admin-side cron jobs here ─────────────────────────
const settingsBackedJobs = [
taskOverdue,
];
// Plain hardcoded-schedule jobs (not tied to any notification setting).
const plainJobs = [
liftExpiredBans,
retryStuckTranscodes,
];
// ─── Boot all registered admin-side jobs ──────────────────────────────────────
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(job.schedule, job.run);
registered.push({ name: job.name, scope: 'ADMIN', schedule: job.schedule });
}
return registered;
}
module.exports = { startAdminCronJobs };
+65
View File
@@ -0,0 +1,65 @@
/***********************************************************************************************************************************************************************
* File Name : client.cron.js
* Type : Cron Registry — Client side
* Description : Aggregates and registers every client-facing scheduled job.
* Same shape as admin.cron.js — each job module exports
* { name, schedule, run }, listed in the `jobs` array below.
*
* The settings-backed jobs emit notifications, so their
* schedule/enabled state lives in cron_notification_settings
* and is configurable from /admin/notifications/settings
* without a restart. expireAdvertisements has no notification
* tied to it, so it stays on a plain hardcoded schedule (same
* reasoning as liftExpiredBans in admin.cron.js).
*
* Currently registered:
* - userNotifications (cron/jobs/user_notifications.cron.js)
* - issueCertificates (cron/jobs/issue_certificates.cron.js)
* - expireUserTiers (cron/jobs/expire_user_tiers.cron.js)
* - taskDueSoon (cron/jobs/task_due_soon.cron.js)
* - expireAdvertisements (cron/jobs/expire_advertisements.cron.js) — plain
* - failStalePayments (cron/jobs/fail_stale_payments.cron.js) — plain
*
* 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 taskDueSoon = require('./jobs/task_due_soon.cron');
const expireAdvertisements = require('./jobs/expire_advertisements.cron');
const failStalePayments = require('./jobs/fail_stale_payments.cron');
const { startSettingsBackedJobs } = require('./cronRegistry.util');
// ─── Registry — add future client-side cron jobs here ────────────────────────
const settingsBackedJobs = [
userNotifications,
issueCertificates,
expireUserTiers,
taskDueSoon,
];
// Plain hardcoded-schedule jobs (not tied to any notification setting).
const plainJobs = [
expireAdvertisements,
failStalePayments,
];
// ─── Boot all registered client-side jobs ─────────────────────────────────────
async function startClientCronJobs() {
const registered = await startSettingsBackedJobs(settingsBackedJobs, 'CLIENT');
for (const job of plainJobs) {
if (!cron.validate(job.schedule)) {
console.error(`[CRON][CLIENT] Invalid schedule for "${job.name}": "${job.schedule}" — skipped.`);
continue;
}
cron.schedule(job.schedule, job.run);
registered.push({ name: job.name, scope: 'CLIENT', schedule: job.schedule });
}
return registered;
}
module.exports = { startClientCronJobs };
+82
View File
@@ -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,57 @@
/***********************************************************************************************************************************************************************
* File Name : expire_advertisements.cron.js
* Type : Cron Job
* Description : Auto-archives (soft-deletes) advertisements once their
* end_date has passed, so expired ads don't sit indefinitely
* in the active Advertisements list — they fall through to
* the Archived Advertisements table, same path as a manual
* archive action.
*
* Only touches rows with end_date IS NOT NULL so ads with no
* end date (run indefinitely) are never auto-archived.
*
* Schedule : Every minute ("* * * * *"). Registered by cron/client.cron.js.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jul. 11, 2026
***********************************************************************************************************************************************************************/
'use strict';
const { Op } = require('sequelize');
const mdl_Advertisements = require('../../models/advertisements/advertisements.mdl');
async function run() {
let expired;
try {
expired = await mdl_Advertisements.findAll({
where: {
deletedAt: null,
end_date: { [Op.ne]: null, [Op.lt]: new Date() },
},
attributes: ['advertisement_id'],
});
} catch (err) {
console.error('[CRON][EXPIRE ADVERTISEMENTS] Failed to query advertisements:', err);
return;
}
if (!expired.length) return;
const ids = expired.map((a) => a.advertisement_id);
try {
await mdl_Advertisements.update({ status: 'expired' }, { where: { advertisement_id: { [Op.in]: ids } } });
await mdl_Advertisements.destroy({ where: { advertisement_id: { [Op.in]: ids } } });
} catch (err) {
console.error('[CRON][EXPIRE ADVERTISEMENTS] Archive failed:', err);
return;
}
console.log(`[CRON][EXPIRE ADVERTISEMENTS] Auto-archived ${ids.length} expired advertisement(s).`);
}
module.exports = {
name: 'expireAdvertisements',
schedule: '* * * * *',
run,
};
@@ -0,0 +1,99 @@
/***********************************************************************************************************************************************************************
* File Name : expire_user_tiers.cron.js
* Type : Cron Job
* Description : Marks active user_tiers rows as 'expired' when their expires_at
* has passed. Runs every minute to support short-duration plans
* (minute- and hour-level plans in addition to day/month/year).
*
* For each expired batch it:
* 1. Bulk-updates matching rows to status = 'expired'.
* 2. Sends an in-app UserNotification to each affected user.
*
* Safety:
* - Only touches rows with expires_at IS NOT NULL so
* manually-granted unlimited tiers (expires_at = NULL) are
* never touched.
* - Bulk update happens before notifications so a restart
* mid-run never re-expires already-expired rows.
*
* Schedule : Every minute ("* * * * *"). Registered by cron/client.cron.js.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 29, 2026
***********************************************************************************************************************************************************************/
'use strict';
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');
async function run() {
// ── 1. Find all active tiers whose expires_at has passed ──────────────────
let expired;
try {
expired = await mdl_UserTiers.findAll({
where: {
status: 'active',
expires_at: { [Op.ne]: null, [Op.lte]: new Date() },
},
include: [{
model: mdl_TierPlans,
as: 'plan',
attributes: ['plan_id', 'label', 'tier'],
required: false,
}],
attributes: ['tier_id', 'user_id', 'tier'],
});
} catch (err) {
console.error('[CRON][EXPIRE TIERS] Failed to query user_tiers:', err);
return;
}
if (!expired.length) return;
const tierIds = expired.map((t) => t.tier_id);
// ── 2. Bulk-update to expired ──────────────────────────────────────────────
try {
await mdl_UserTiers.update(
{ status: 'expired' },
{ where: { tier_id: tierIds } }
);
} catch (err) {
console.error('[CRON][EXPIRE TIERS] Bulk update failed:', err);
return;
}
// ── 3. Send in-app notifications (one per affected user) ──────────────────
// 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) {
try {
const notifications = expired.map((t) => ({
user_id: t.user_id,
...NOTIFICATION_REGISTRY.tier_expired.build({
tier: t.tier,
label: t.plan?.label ?? null,
planId: t.plan?.plan_id ?? null,
}),
}));
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).`);
}
module.exports = {
name: 'expireUserTiers',
schedule: '* * * * *',
run,
};
@@ -0,0 +1,87 @@
/***********************************************************************************************************************************************************************
* File Name : fail_stale_payments.cron.js
* Type : Cron Job
* Description : Marks abandoned checkout attempts as 'failed' instead of
* leaving them stuck on 'pending' forever.
*
* A payment/purchase row is created as 'pending' the moment
* PayPal's create-order call succeeds (createOrder / createCourseOrder),
* before the buyer ever reaches PayPal's approval page. If PayPal's
* own hosted checkout then fails to load ("Things don't appear to
* be working at the moment") or the buyer just abandons the tab,
* the browser never gets redirected back to our return_url/cancel_url —
* so captureOrder/cancelOrder is never called, and the row sits as
* 'pending' indefinitely even though no money ever moved.
*
* This job sweeps 'pending' rows older than STALE_MINUTES and
* marks them 'failed', so payment history correctly reflects
* that nothing was charged, instead of silently doing nothing.
*
* Schedule : Every 10 minutes ("*\/10 * * * *"). Registered by cron/client.cron.js.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Aug. 3, 2026
***********************************************************************************************************************************************************************/
'use strict';
const { Op } = require('sequelize');
const mdl_Payments = require('../../models/tiers/payments.mdl');
const mdl_CoursePurchase = require('../../models/courses/course_purchases.mdl');
const STALE_MINUTES = 30;
async function run() {
const cutoff = new Date(Date.now() - STALE_MINUTES * 60_000);
try {
const stalePayments = await mdl_Payments.findAll({
where: { status: 'pending', createdAt: { [Op.lt]: cutoff } },
});
for (const payment of stalePayments) {
await payment.update({
status: 'failed',
provider_payload: {
...payment.provider_payload,
failed_reason: 'abandoned_checkout',
marked_failed_at: new Date().toISOString(),
},
});
}
if (stalePayments.length) {
console.log(`[CRON][FAIL STALE PAYMENTS] Marked ${stalePayments.length} abandoned tier payment(s) as failed.`);
}
} catch (err) {
console.error('[CRON][FAIL STALE PAYMENTS] Failed sweeping payments:', err);
}
try {
const stalePurchases = await mdl_CoursePurchase.findAll({
where: { status: 'pending', createdAt: { [Op.lt]: cutoff } },
});
for (const purchase of stalePurchases) {
await purchase.update({
status: 'failed',
provider_payload: {
...purchase.provider_payload,
failed_reason: 'abandoned_checkout',
marked_failed_at: new Date().toISOString(),
},
});
}
if (stalePurchases.length) {
console.log(`[CRON][FAIL STALE PAYMENTS] Marked ${stalePurchases.length} abandoned course purchase(s) as failed.`);
}
} catch (err) {
console.error('[CRON][FAIL STALE PAYMENTS] Failed sweeping course purchases:', err);
}
}
module.exports = {
name: 'failStalePayments',
schedule: '*/10 * * * *',
run,
};
@@ -0,0 +1,123 @@
/***********************************************************************************************************************************************************************
* 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 { 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_<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,
...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,
};
@@ -0,0 +1,85 @@
/***********************************************************************************************************************************************************************
* File Name : lift_expired_bans.cron.js
* Type : Cron Job
* Description : Automatically lifts temporary bans whose expires_at has passed.
* Clears is_banned + ban_expires_at on the user record and marks
* the ban row as lifted with a system lift_reason.
*
* Note: The auth middleware also auto-lifts expired bans inline on
* the next login attempt, so this cron is a safety net — it keeps
* the DB state clean even if a user never logs in again.
*
* Schedule : Every hour, on the hour ("0 * * * *"). Registered by
* cron/admin.cron.js.
*
* Author: Kenneth Obsequio
* Date Created: Jun. 27, 2026
***********************************************************************************************************************************************************************/
const { Op } = require('sequelize');
const sequelize = require('../../config/db.config');
const mdl_Users = require('../../models/users/users.mdl');
const mdl_UserBans = require('../../models/users/user_bans.mdl');
const { sendEmail } = require('../../services/email.service');
const { fmtDate } = require('../../utils/datetime.util');
async function run() {
try {
const expiredBans = await mdl_UserBans.findAll({
where: {
ban_type: 'temporary',
is_lifted: false,
expires_at: { [Op.lte]: new Date() },
},
});
if (!expiredBans.length) return;
const banIds = expiredBans.map((b) => b.ban_id);
const userIds = [...new Set(expiredBans.map((b) => Number(b.user_id)))];
const usersMap = await mdl_Users.findAll({
where: { user_id: userIds },
attributes: ['user_id', 'email', 'personal_info'],
}).then((rows) => Object.fromEntries(rows.map((u) => [u.user_id, u])));
await sequelize.transaction(async (t) => {
await mdl_UserBans.update(
{
is_lifted: true,
lifted_at: new Date(),
lift_reason: 'Automatically lifted — ban period expired.',
},
{ where: { ban_id: banIds }, transaction: t }
);
await mdl_Users.update(
{ is_banned: false, ban_expires_at: null },
{ where: { user_id: userIds }, transaction: t }
);
});
const dateStr = fmtDate(new Date());
userIds.forEach((uid) => {
const u = usersMap[uid];
if (!u) return;
sendEmail({
to: u.email,
type: 'BAN_LIFTED',
data: {
name: u.personal_info?.name?.full_name ?? 'User',
email: u.email,
date: dateStr,
},
}).catch((err) => console.error('[CRON][LIFT EXPIRED BANS] Email failed:', u.email, err));
});
console.log(`[CRON][LIFT EXPIRED BANS] Lifted ${expiredBans.length} ban(s) for ${userIds.length} user(s).`);
} catch (err) {
console.error('[CRON][LIFT EXPIRED BANS] Failed:', err);
}
}
module.exports = {
name: 'liftExpiredBans',
schedule: '0 * * * *',
run,
};
@@ -0,0 +1,65 @@
/***********************************************************************************************************************************************************************
* File Name : retry_stuck_transcodes.cron.js
* Type : Cron Job
* Description : Safety net for the .mov/.mkv -> faststart .mp4 background
* remux (see services/assetTranscode.service.js). Picks up:
* - "pending" — the fire-and-forget call in
* assets.controller.js#finalizeAssetFromStorage
* never actually started (e.g. this process
* crashed between the DB commit and the call).
* - "processing" for over 30 minutes — the job itself was
* running when the process restarted/crashed
* mid-remux and never got to flip the status.
*
* Processes at most 3 per run, sequentially — this runs on a
* small droplet, and remuxing is disk/CPU-bound; no reason to
* pile up concurrent ffmpeg processes for a background sweep.
*
* Schedule : Every 10 minutes ("*\/10 * * * *"). Registered by
* cron/admin.cron.js.
*
* Author: Kenneth Obsequio
* Date Created: Aug. 1, 2026
***********************************************************************************************************************************************************************/
const { Op } = require('sequelize');
const mdl_Assets = require('../../models/assets/assets.mdl');
const { transcodeAsset } = require('../../services/assetTranscode.service');
const MAX_PER_RUN = 3;
const STUCK_PROCESSING_MINUTES = 30;
async function run() {
try {
const stuckSince = new Date(Date.now() - STUCK_PROCESSING_MINUTES * 60 * 1000);
// Demote stale "processing" rows back to "pending" so transcodeAsset()'s
// own claim step (pending/failed -> processing) can pick them up again —
// it never claims an in-progress "processing" row, by design (avoids
// double-processing a job that's actually still running elsewhere).
await mdl_Assets.update(
{ transcode_status: 'pending' },
{ where: { transcode_status: 'processing', updatedAt: { [Op.lt]: stuckSince } } },
);
const candidates = await mdl_Assets.findAll({
where: { deletedAt: null, transcode_status: 'pending' },
limit: MAX_PER_RUN,
});
if (!candidates.length) return;
for (const asset of candidates) {
await transcodeAsset(asset);
}
console.log(`[CRON][RETRY STUCK TRANSCODES] Processed ${candidates.length} asset(s).`);
} catch (err) {
console.error('[CRON][RETRY STUCK TRANSCODES] Failed:', err);
}
}
module.exports = {
name: 'retryStuckTranscodes',
schedule: '*/10 * * * *',
run,
};
+114
View File
@@ -0,0 +1,114 @@
/***********************************************************************************************************************************************************************
* File Name : task_due_soon.cron.js
* Type : Cron Job
* Description : Emits a learner-facing "task_reminder" UserNotification for
* each user who has NOT yet completed a task whose deadline
* falls ~24h from now. Unlike task_overdue.cron.js (a single
* admin-facing status flip), completion here is per-user, so
* each candidate task's assigned-group members are checked
* individually via checkTaskCompletion before notifying.
*
* "Falls ~24h from now" = deadline between (now + 23h) and
* (now + 24h), a 1-hour sliding window — since this runs
* hourly, each task's deadline crosses that window exactly
* once, giving a single reminder ~24h before it's due
* without needing a separate "already notified" table.
*
* Schedule : Every hour, 10 minutes past ("10 * * * *"). Registered by
* cron/client.cron.js.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jul. 9, 2026
***********************************************************************************************************************************************************************/
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 { checkTaskCompletion } = require('../../controllers/client/task.controller');
const WINDOW_START_MS = 23 * 60 * 60 * 1000;
const WINDOW_END_MS = 24 * 60 * 60 * 1000;
async function run() {
const settings = await CronNotificationSetting.findOne({ where: { job_name: 'taskDueSoon' } });
if (settings && !settings.enabled) return;
const now = Date.now();
let dueSoonTasks;
try {
dueSoonTasks = await Task.findAll({
attributes: ['task_id', 'task_list_id', 'name', 'deadline'],
where: {
deadline: {
[Op.gte]: new Date(now + WINDOW_START_MS),
[Op.lt]: new Date(now + WINDOW_END_MS),
},
status: { [Op.notIn]: ['completed', 'overdue'] },
},
raw: true,
});
} catch (err) {
console.error('[CRON][TASK DUE SOON] Failed to query upcoming deadlines:', err);
return;
}
if (!dueSoonTasks.length) return;
console.log(`[CRON][TASK DUE SOON] ${dueSoonTasks.length} task(s) due in ~24h — resolving affected users.`);
try {
const taskListIds = [...new Set(dueSoonTasks.map((t) => t.task_list_id))];
const memberRows = await sequelize.query(
`SELECT DISTINCT tlg.task_list_id, ugm.user_id
FROM task_list_groups tlg
JOIN user_group_members ugm ON ugm.group_id = tlg.group_id
AND ugm."deletedAt" IS NULL
WHERE tlg.task_list_id IN (:taskListIds)`,
{ replacements: { taskListIds }, type: QueryTypes.SELECT }
);
const usersByTaskList = new Map();
for (const row of memberRows) {
const list = usersByTaskList.get(row.task_list_id) ?? [];
list.push(row.user_id);
usersByTaskList.set(row.task_list_id, list);
}
const now2 = new Date();
let notifiedCount = 0;
for (const task of dueSoonTasks) {
const candidateUserIds = usersByTaskList.get(task.task_list_id) ?? [];
if (!candidateUserIds.length) continue;
const incompleteUserIds = [];
for (const userId of candidateUserIds) {
const done = await checkTaskCompletion(userId, task.task_id);
if (!done) incompleteUserIds.push(userId);
}
if (!incompleteUserIds.length) continue;
const notify = NOTIFICATION_REGISTRY.task_reminder.build({
taskName: task.name, deadline: task.deadline,
});
await UserNotification.bulkCreate(
incompleteUserIds.map((user_id) => ({ user_id, ...notify, seen: false, createdAt: now2, updatedAt: now2 })),
{ validate: false }
);
notifiedCount += incompleteUserIds.length;
}
console.log(`[CRON][TASK DUE SOON] Sent ${notifiedCount} reminder(s) across ${dueSoonTasks.length} task(s).`);
} catch (err) {
console.error('[CRON][TASK DUE SOON] Failed to emit reminders:', err);
}
}
module.exports = {
name: 'taskDueSoon',
schedule: '10 * * * *',
run,
};
+98
View File
@@ -0,0 +1,98 @@
/***********************************************************************************************************************************************************************
* File Name : task_overdue.cron.js
* Type : Cron Job
* Description : Flips Task.status to a configurable target status — 'overdue'
* (default before Jul 2026) or 'completed' (current default) —
* once its deadline has passed, provided it isn't already
* 'completed' or 'overdue'. The target status is an admin-
* configurable setting (cron_notification_settings.target_status
* for job_name 'taskOverdue'; NULL is treated as 'completed').
* Purely an admin-facing lifecycle label — does NOT touch
* TaskCompletion/TaskLinkVisit/TaskProgress, does NOT affect
* per-user completion signals or client-side Ongoing/Done/
* Overdue bucketing, and does NOT block late submissions.
*
* Every task this job touches also gets auto_marked_at set to
* the current time — this is the ONLY writer of that column,
* so downstream consumers (e.g. cron/jobs/user_notifications.cron.js)
* can distinguish "the system just did this" from a user's own
* legitimate completion. A task already sitting in 'overdue' or
* 'completed' is never reclaimed by this sweep even if the
* target status changes later — this job only ever moves tasks
* OUT of 'pending'/'in_progress', never between the two terminal
* states.
*
* Safety pattern:
* - Task.update is the primary operation and must always succeed.
* - AdminNotification.create is secondary — wrapped in its own
* try/catch so a notification failure never rolls back or
* suppresses the status flip. If it fails once, the next
* hourly run will insert its own summary for that batch.
*
* Schedule : Every hour, on the hour ("0 * * * *"). Registered by
* cron/admin.cron.js, not scheduled here directly.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 17, 2026
* Modified : Jun. 19, 2026
***********************************************************************************************************************************************************************/
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 ────────────────────────────────────────────────────────
async function run() {
// ── 0. Load configured target status (defaults to 'completed') ───────────
let settings = null;
let targetStatus = 'completed';
try {
settings = await CronNotificationSetting.findOne({ where: { job_name: 'taskOverdue' } });
if (settings?.target_status === 'overdue' || settings?.target_status === 'completed') {
targetStatus = settings.target_status;
}
} catch (err) {
console.error('[CRON][TASK OVERDUE] Failed to load target_status setting, defaulting to "completed":', err);
}
// ── 1. Primary: flip task statuses ───────────────────────────────────────
let affectedCount = 0;
try {
[affectedCount] = await Task.update(
{ status: targetStatus, auto_marked_at: new Date() },
{
where: {
deadline: { [Op.lt]: new Date() },
status: { [Op.notIn]: ['completed', 'overdue'] },
},
}
);
} catch (err) {
console.error('[CRON][TASK OVERDUE] Failed to update task statuses:', err);
return;
}
if (affectedCount === 0) return;
console.log(`[CRON][TASK OVERDUE] Marked ${affectedCount} task(s) as ${targetStatus}.`);
// ── 2. Secondary: admin notification — isolated, never blocks step 1 ─────
// Skippable via /admin/notifications/settings — the status flip above always happens either way.
try {
if (settings && !settings.enabled) return;
await AdminNotification.create(
NOTIFICATION_REGISTRY.task_overdue.build({ count: affectedCount, targetStatus })
);
} catch (err) {
console.error('[CRON][TASK OVERDUE] Failed to insert admin notification:', err);
}
}
module.exports = {
name: 'taskOverdue',
schedule: '0 * * * *',
run,
};
@@ -0,0 +1,137 @@
/***********************************************************************************************************************************************************************
* File Name : user_task_overdue_notify.cron.js
* Type : Cron Job
* Description : Emits a UserNotification for every user who belongs to a group
* assigned to a task list that contains a task the admin
* taskOverdue cron JUST auto-flipped in the last hour — to
* either 'overdue' or 'completed', depending on that job's
* configured target_status.
*
* Runs 5 minutes after the admin taskOverdue cron (which fires at
* the top of each hour) so the status flips are already committed
* before this job queries them.
*
* "Just auto-flipped" = auto_marked_at is within the last 65
* minutes (1-hour window + 5-min drift buffer). auto_marked_at
* is written ONLY by cron/jobs/task_overdue.cron.js, never by a
* user's own completion flow, so this can't misfire on a task a
* user legitimately just completed themselves.
*
* Rows are grouped by status: 'overdue' tasks get the existing
* "Tasks Overdue" notification, 'completed' tasks get a
* separate "Tasks Auto-Completed" notification. In practice a
* single run is homogeneous (target_status is one job-wide
* setting), but the grouping keeps this correct even if the
* setting changed mid-window.
*
* When a status group contains exactly one task, its taskId/
* taskListId are included in the notification data (plus each
* recipient's own group_id) so the client can deep-link
* straight to that task. A multi-task batch can't pick just
* one task to link to, so it falls back to no link.
*
* Schedule : 5 minutes past every hour ("5 * * * *"). Registered by
* cron/client.cron.js.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 19, 2026
***********************************************************************************************************************************************************************/
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 the admin cron JUST auto-flipped in the last 65 minutes ─
let recentlyAutoMarked;
try {
recentlyAutoMarked = await Task.findAll({
attributes: ['task_id', 'task_list_id', 'name', 'status'],
where: {
status: { [Op.in]: ['overdue', 'completed'] },
auto_marked_at: { [Op.gte]: new Date(Date.now() - WINDOW_MS) },
},
raw: true,
});
} catch (err) {
console.error('[CRON][USER NOTIFY] Failed to query recently auto-marked tasks:', err);
return;
}
if (recentlyAutoMarked.length === 0) return;
console.log(`[CRON][USER NOTIFY] ${recentlyAutoMarked.length} recently auto-marked task(s) — resolving affected users.`);
// ── 2. Resolve affected users via task_list_groups → user_group_members ───
try {
const byStatus = {
overdue: recentlyAutoMarked.filter(t => t.status === 'overdue'),
completed: recentlyAutoMarked.filter(t => t.status === 'completed'),
};
const now = new Date();
for (const [status, tasks] of Object.entries(byStatus)) {
if (tasks.length === 0) continue;
const taskListIds = [...new Set(tasks.map(t => t.task_list_id))];
// DISTINCT ON picks one group per user (deterministic — lowest group_id)
// so each affected user gets a single notification even if they belong
// to more than one group assigned to these task lists.
const affectedUsers = await sequelize.query(
`SELECT DISTINCT ON (ugm.user_id) ugm.user_id, ugm.group_id
FROM task_list_groups tlg
JOIN user_group_members ugm ON ugm.group_id = tlg.group_id
AND ugm."deletedAt" IS NULL
WHERE tlg.task_list_id IN (:taskListIds)
ORDER BY ugm.user_id, ugm.group_id`,
{ replacements: { taskListIds }, type: QueryTypes.SELECT }
);
if (affectedUsers.length === 0) continue;
const count = tasks.length;
const registryKey = status === 'completed' ? 'user_task_auto_completed' : 'user_task_overdue';
// A single-task batch can deep-link straight to that task; a multi-task
// batch can't pick just one, so it falls back to the task-list link.
const single = count === 1 ? tasks[0] : null;
const notify = NOTIFICATION_REGISTRY[registryKey].build({
count,
task_list_ids: taskListIds,
taskId: single?.task_id ?? null,
taskListId: single?.task_list_id ?? null,
});
await UserNotification.bulkCreate(
affectedUsers.map(({ user_id, group_id }) => ({
user_id,
...notify,
data: { ...notify.data, groupId: single ? group_id : null },
seen: false,
createdAt: now,
updatedAt: now,
})),
{ validate: false }
);
console.log(`[CRON][USER NOTIFY] Notified ${affectedUsers.length} user(s) about ${count} ${status} task(s).`);
}
} catch (err) {
console.error('[CRON][USER NOTIFY] Failed to emit user notifications:', err);
}
}
module.exports = {
name: 'userNotifications',
schedule: '5 * * * *',
run,
};