mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
chore: relocate backend into apps/api ahead of monorepo merge
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user