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
@@ -0,0 +1,51 @@
/***********************************************************************************************************************************************************************
* File Name: accountStatus.service.js
* Type of Program: Service
* Description: Shared active/ban-status check + auto-lift for expired temporary
* bans. Used by every entry point that authenticates a user
* (login, Google OAuth callback, refresh token, JWT middleware)
* so the ban logic lives in exactly one place.
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jul. 4, 2026
***********************************************************************************************************************************************************************
* HOW TO USE:
* const { checkAccountStatus } = require('../services/accountStatus.service');
* const status = await checkAccountStatus(user);
* if (!status.ok) { ... map status.code ('deactivated' | 'banned') to a response ... }
***********************************************************************************************************************************************************************/
const mdl_UserBans = require('../models/users/user_bans.mdl');
/**
* Checks whether a user is allowed to authenticate right now.
* Auto-lifts an expired temporary ban as a side effect.
*
* @param {import('../models/users/users.mdl')} user
* @returns {Promise<{ok: true} | {ok: false, code: 'deactivated'|'banned', reason?: string|null, ban_type?: string|null, ban_expires_at?: Date|null}>}
*/
const checkAccountStatus = async (user) => {
if (!user.is_active) return { ok: false, code: 'deactivated' };
if (user.is_banned) {
const stillBanned = !user.ban_expires_at || new Date() < new Date(user.ban_expires_at);
if (stillBanned) {
const activeBan = await mdl_UserBans.findOne({
where: { user_id: user.user_id, is_lifted: false },
order: [['banned_at', 'DESC']],
attributes: ['reason', 'ban_type', 'expires_at'],
});
return {
ok: false,
code: 'banned',
reason: activeBan?.reason ?? null,
ban_type: activeBan?.ban_type ?? null,
ban_expires_at: activeBan?.expires_at ?? null,
};
}
// Expired temporary ban — auto-lift
await user.update({ is_banned: false, ban_expires_at: null });
}
return { ok: true };
};
module.exports = { checkAccountStatus };
+172
View File
@@ -0,0 +1,172 @@
/***********************************************************************************************************************************************************************
* File Name: achievements.service.js
* Type of Program: Service
* Description: Registry-based achievement auto-grant system.
* To add a new achievement — add an entry to utils/achievements.data.js.
* To trigger one — call grantAchievement(user_id, key, metadata).
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 11, 2026
***********************************************************************************************************************************************************************/
'use strict';
const { Op } = require('sequelize');
const mdl_Achievements = require('../models/users/achievements.mdl');
const mdl_AchievementDefinitions = require('../models/users/achievement_definitions.mdl');
const mdl_Users = require('../models/users/users.mdl');
const { EARLY_ACCESS_CUTOFF } = require('../data/achievements.data');
const UserNotification = require('../models/notifications/user_notification.mdl');
const { NOTIFICATION_REGISTRY } = require('../data/notifications.data');
// ─── Core grant function ──────────────────────────────────────────────────────
/**
* Grant an achievement to a user if not already granted.
* Safe to call multiple times — idempotent via findOrCreate.
*
* @param {string|number} user_id
* @param {string} key — must exist in the achievement_definitions catalog
* @param {Object} metadata — optional extra data (course_id, score, etc.)
* @param {string|number} granted_by — null = system, user_id = admin manual grant
* @returns {{ achievement, created }} or null on error
*/
async function grantAchievement(user_id, key, metadata = {}, granted_by = null) {
const def = await mdl_AchievementDefinitions.findOne({ where: { key, is_active: true } });
if (!def) {
console.warn(`[ACHIEVEMENTS] Unknown or inactive achievement key: "${key}"`);
return null;
}
try {
// CockroachDB doesn't support findOrCreate — use findOne + create
const existing = await mdl_Achievements.findOne({ where: { user_id, key } });
if (existing) return { achievement: existing, created: false };
const achievement = await mdl_Achievements.create({
user_id,
type: def.type,
key: def.key,
label: def.label,
description: def.description,
icon: def.icon,
granted_by: granted_by ?? null,
granted_at: new Date(),
metadata,
});
console.log(`[ACHIEVEMENTS] Granted "${key}" to user ${user_id}`);
// Fire-and-forget — notification failure never blocks the achievement grant
UserNotification.create({
user_id,
...NOTIFICATION_REGISTRY.achievement.build({
label: def.label,
description: def.description,
key: def.key,
}),
}).catch(err => console.error(`[ACHIEVEMENTS] Failed to emit notification for "${key}":`, err));
return { achievement, created: true };
} catch (err) {
// Ignore unique constraint violation (race condition) — already granted
if (err?.parent?.code === '23505') return null;
console.error(`[ACHIEVEMENTS] Failed to grant "${key}" to user ${user_id}:`, err);
return null;
}
}
// ─── Convenience triggers ─────────────────────────────────────────────────────
// Call these from controllers after the relevant event occurs.
/** Call after verifyOTP / register succeeds */
async function onUserRegistered(user_id) {
const user = await mdl_Users.findByPk(user_id, { attributes: ['user_id', 'createdAt'] });
if (!user) return;
if (new Date(user.createdAt) <= EARLY_ACCESS_CUTOFF) {
await grantAchievement(user_id, 'early_access');
}
}
/** Call after captureOrder succeeds and tier is activated */
async function onTierActivated(user_id, tier) {
if (tier === 'premium') await grantAchievement(user_id, 'premium_first_time');
if (tier === 'exclusive') await grantAchievement(user_id, 'exclusive_first_time');
}
/** Call after a course is marked complete for a user */
async function onCourseCompleted(user_id, course_id, totalCompleted, course_title = null) {
await grantAchievement(user_id, 'first_course_completed', { course_id, course_title });
if (totalCompleted >= 5) await grantAchievement(user_id, 'courses_completed_5');
if (totalCompleted >= 10) await grantAchievement(user_id, 'courses_completed_10');
}
/** Call after a perfect quiz score */
async function onPerfectQuiz(user_id, quiz_id) {
await grantAchievement(user_id, 'perfect_quiz_score', { quiz_id });
}
/** Call after a single task's requirements are all satisfied for a user */
async function onTaskCompleted(user_id, task_id, task_name = null) {
await grantAchievement(user_id, 'first_task_completed', { task_id, task_name });
}
/** Call after every task in a task list is complete for a user */
async function onTaskListCompleted(user_id, task_list_id, task_list_name = null) {
await grantAchievement(user_id, 'task_list_finisher', { task_list_id, task_list_name });
}
/** Call after profile is fully filled out */
async function onProfileCompleted(user_id) {
await grantAchievement(user_id, 'profile_completed');
}
/** Call after a successful referral */
async function onReferral(user_id, referred_user_id) {
await grantAchievement(user_id, 'first_referral', { referred_user_id });
}
// ─── Admin manual grant ───────────────────────────────────────────────────────
/** Manually grant any achievement from admin panel */
async function adminGrantAchievement(user_id, key, admin_id, metadata = {}) {
return grantAchievement(user_id, key, metadata, admin_id);
}
// ─── Backfill ─────────────────────────────────────────────────────────────────
/** Run once to grant early_access to all existing eligible users */
async function backfillEarlyAccess() {
const users = await mdl_Users.findAll({
where: { createdAt: { [Op.lte]: EARLY_ACCESS_CUTOFF } },
attributes: ['user_id'],
paranoid: false,
});
console.log(`[ACHIEVEMENTS] Backfilling early_access for ${users.length} users...`);
for (const user of users) {
await grantAchievement(user.user_id, 'early_access');
}
console.log('[ACHIEVEMENTS] Backfill complete.');
}
// ─── Exports ──────────────────────────────────────────────────────────────────
module.exports = {
grantAchievement,
// Convenience triggers
onUserRegistered,
onTierActivated,
onCourseCompleted,
onPerfectQuiz,
onProfileCompleted,
onReferral,
onTaskCompleted,
onTaskListCompleted,
// Admin
adminGrantAchievement,
// Backfill
backfillEarlyAccess,
};
@@ -0,0 +1,82 @@
// services/assetTranscode.service.js
//
// Orchestrates the background remux job for a single video asset: mints a
// presigned read URL, hands it to ffmpeg.service, uploads the result back to
// storage (streamed, not buffered), then swaps the asset row over to the new
// object. Called two ways:
// 1. Fire-and-forget from assets.controller.js#finalizeAssetFromStorage,
// right after a .mov/.mkv upload is finalized.
// 2. cron/jobs/retry_stuck_transcodes.cron.js — safety net for jobs that
// never got picked up (server restarted mid-remux) or are still marked
// "pending" (the fire-and-forget call in 1. never actually started,
// e.g. this process crashed between the DB commit and the call).
//
// The original .mov/.mkv object is intentionally left in storage on success
// — this only swaps which object the asset *plays from* (storage_key), it
// doesn't delete anything. Reclaiming that storage is a separate decision.
const fs = require("fs");
const Asset = require("../models/assets/assets.mdl");
const s3 = require("../services/s3.service");
const ffmpegSvc = require("../services/ffmpeg.service");
// ── One retry-worth of guardrails ──────────────────────────────────────────
// Only ever the fire-and-forget call or the retry cron should be racing to
// pick up a given asset — this claim step (pending/failed -> processing)
// makes double-processing harmless even if both fire close together.
async function claimForProcessing(assetId) {
const [count] = await Asset.update(
{ transcode_status: "processing", transcode_error: null },
{ where: { asset_id: assetId, transcode_status: ["pending", "failed"] } },
);
return count > 0;
}
async function transcodeAsset(asset) {
if (asset.storage_provider !== "s3" || !ffmpegSvc.needsRemux(asset.extension)) return;
const claimed = await claimForProcessing(asset.asset_id);
if (!claimed) return; // already being processed, or already done
let outputPath = null;
try {
const inputUrl = await s3.getSignedDownloadUrl(asset.storage_key);
outputPath = await ffmpegSvc.remuxToFaststartMp4(inputUrl);
const { size: file_size } = await fs.promises.stat(outputPath);
const readStream = fs.createReadStream(outputPath);
const originalname = `${(asset.original_name || asset.uuid || "video").replace(/\.[^.]+$/, "")}.mp4`;
const { url: file_url, uuid: storage_key } = await s3.uploadStream({
stream: readStream,
originalname,
mimetype: "video/mp4",
ownerType: "video",
});
await asset.update({
storage_key,
file_url,
file_size,
mime_type: "video/mp4",
extension: "mp4",
transcode_status: "done",
transcode_error: null,
});
console.log(`[ASSET][TRANSCODE] Remuxed asset ${asset.asset_id} (${asset.original_name}) to faststart mp4.`);
} catch (err) {
console.error(`[ASSET][TRANSCODE] Remux failed for asset ${asset.asset_id}:`, err.message);
await asset.update({
transcode_status: "failed",
transcode_error: String(err.message || err).slice(0, 2000),
}).catch(() => {});
} finally {
if (outputPath) fs.promises.unlink(outputPath).catch(() => {});
}
}
module.exports = { transcodeAsset };
+73
View File
@@ -0,0 +1,73 @@
/***********************************************************************************************************************************************************************
* File Name: avatar.service.js
* Type of Program: Service
* Description: Shared avatar processing/orchestration for admin + client self-profile.
* Server-side authoritative resize — every avatar is normalized to a fixed
* 200x200 JPEG regardless of what the client sends, so browser-side cropping
* (see AvatarUploadDialog.jsx) is a UX convenience, not the enforcement point.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Aug. 10, 2026
***********************************************************************************************************************************************************************/
'use strict';
const sharp = require('sharp');
const { uploadFile, deleteFile } = require('./s3.service');
const AVATAR_SIZE = 200;
const JPEG_QUALITY = 90;
// ─── resizeAvatarBuffer ─────────────────────────────────────────────────────────
// Normalizes any accepted input (JPEG/PNG/WebP/GIF) into a fixed 200x200 JPEG.
// GIF animation and PNG transparency are intentionally dropped — avatars render
// in an opaque round mask, so a single static frame is all that's ever shown.
async function resizeAvatarBuffer(buffer) {
try {
return await sharp(buffer)
.rotate() // respect EXIF orientation before crop
.resize(AVATAR_SIZE, AVATAR_SIZE, { fit: 'cover', position: 'centre' })
.jpeg({ quality: JPEG_QUALITY })
.toBuffer();
} catch (err) {
throw Object.assign(new Error('Could not process the uploaded image.'), { status: 400, cause: err });
}
}
// ─── replaceUserAvatar ──────────────────────────────────────────────────────────
// Deletes the old S3 object (if any), resizes the new upload, stores it, and
// returns the avatar metadata object. Does not persist to the user row —
// callers own that so they can merge it into personal_info their own way.
async function replaceUserAvatar(user, file) {
const oldKey = user.personal_info?.avatar?.uuid;
if (oldKey) await deleteFile(oldKey).catch(() => {});
const resized = await resizeAvatarBuffer(file.buffer);
const { url, uuid } = await uploadFile({
buffer: resized,
originalname: 'avatar.jpg',
mimetype: 'image/jpeg',
ownerType: 'avatar',
});
return {
url,
uuid,
name: file.originalname,
mime_type: 'image/jpeg',
size: resized.length,
};
}
// ─── removeUserAvatar ───────────────────────────────────────────────────────────
async function removeUserAvatar(user) {
const key = user.personal_info?.avatar?.uuid;
if (!key) throw Object.assign(new Error('No avatar to remove.'), { status: 404 });
await deleteFile(key).catch(() => {});
}
module.exports = { resizeAvatarBuffer, replaceUserAvatar, removeUserAvatar };
@@ -0,0 +1,107 @@
/***********************************************************************************************************************************************************************
* File Name : certificate-record.service.js
* Type : Service
* Description : Resolves or lazily creates the persisted `certificates` row for a
* user/course pair (cert_no/ref_no assignment, instructor snapshot).
* Shared by the PDF download endpoint (certificate.controller.js) and
* the hourly issuance cron (cron/jobs/issue_certificates.cron.js) so
* both write through the same cert_no/ref_no sequence.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jul. 2, 2026
***********************************************************************************************************************************************************************/
'use strict';
const { formatDuration } = require('../utils/duration.util');
const {
Course,
CourseAssessment,
QuizAttempt,
Certificate,
CourseInstructor,
} = require('../models/courses/courses.associations');
function formatInstructors(rows) {
const names = rows.map(r => r.display_name);
if (names.length === 0) return '';
if (names.length === 1) return names[0];
if (names.length === 2) return `${names[0]} and ${names[1]}`;
return `${names[0]}, ${names[1]} and et. al`;
}
// cert_no format: YYYYMM-{userId:6}-{userCertSeq:5}
async function buildCertNo(userId) {
const count = await Certificate.count({ where: { user_id: userId } });
const seq = String(count + 1).padStart(5, '0');
const uid = String(userId).padStart(6, '0');
const now = new Date();
const YYYYMM = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}`;
return `${YYYYMM}-${uid}-${seq}`;
}
// ref_no format: PP-YYYYMM-{globalSeq:5} (unique across all certs)
async function buildRefNo() {
const count = await Certificate.count();
const seq = String(count + 1).padStart(5, '0');
const now = new Date();
const YYYYMM = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}`;
return `PP-${YYYYMM}-${seq}`;
}
/**
* Resolves (or creates) the Certificate row for a user/course. Idempotent —
* safe to call from both the cron job and the on-demand download endpoint.
* Returns null if the course has no assessment or the user hasn't passed it.
*/
async function ensureCertificateRecord({ userId, courseId }) {
const course = await Course.findOne({
where: { course_id: courseId },
attributes: ['course_id', 'title', 'duration_seconds'],
include: [
{ model: CourseAssessment, as: 'assessment', attributes: ['assessment_id'], required: false },
{
model: CourseInstructor,
as: 'instructors',
attributes: ['display_name', 'order_index'],
required: false,
order: [['order_index', 'ASC']],
},
],
});
if (!course || !course.assessment) return null;
const passedAttempt = await QuizAttempt.findOne({
where: {
user_id: userId,
assessment_id: course.assessment.assessment_id,
passed: true,
},
order: [['createdAt', 'DESC']],
attributes: ['score', 'createdAt'],
});
if (!passedAttempt) return null;
const liveInstructors = formatInstructors(course.instructors ?? []);
// CockroachDB does not support findOrCreate (uses temp PL/pgSQL functions).
let cert = await Certificate.findOne({ where: { user_id: userId, course_id: courseId } });
if (!cert) {
cert = await Certificate.create({
user_id: userId,
course_id: courseId,
cert_no: await buildCertNo(userId),
ref_no: await buildRefNo(),
instructors: liveInstructors,
score: passedAttempt.score ?? null,
length_str: formatDuration(course.duration_seconds),
issued_at: passedAttempt.createdAt,
});
} else if (liveInstructors !== (cert.instructors ?? '')) {
await cert.update({ instructors: liveInstructors });
}
return cert;
}
module.exports = { ensureCertificateRecord, formatInstructors, buildCertNo, buildRefNo };
+67
View File
@@ -0,0 +1,67 @@
/***********************************************************************************************************************************************************************
* File Name: certificate.service.js
* Type of Program: Service
* Description: Generates a PDF certificate by compiling a Typst template.
* Data is passed to the template via --input CLI flags (no temp files needed for input).
* Output is written to a temp file, read into a Buffer, then cleaned up.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 18, 2026
***********************************************************************************************************************************************************************/
'use strict';
const { execFile } = require('child_process');
const { promisify } = require('util');
const fs = require('fs/promises');
const path = require('path');
const crypto = require('crypto');
const os = require('os');
const execFileAsync = promisify(execFile);
const TEMPLATE_PATH = path.join(__dirname, '../templates/certificate.typ');
const FONT_PATH = path.join(__dirname, '../templates/fonts/Arimo');
// The Docker image gets `typst` on PATH via COPY --from=typst. Bare-metal
// deploys (no Docker involved) don't have that, so TYPST_BIN lets ops point
// at a manually-installed binary instead.
const TYPST_BIN = process.env.TYPST_BIN || 'typst';
/**
* @param {{
* name: string,
* course: string,
* date: string,
* cert_no: string,
* ref_no: string,
* instructors?: string,
* length?: string,
* }} data
* @returns {Promise<Buffer>}
*/
async function generateCertificate({ name, course, date, cert_no, ref_no, instructors = '', length = '' }) {
const outPath = path.join(os.tmpdir(), `cert_${crypto.randomUUID()}.pdf`);
const args = [
'compile',
TEMPLATE_PATH,
outPath,
'--font-path', FONT_PATH,
'--input', `name=${name}`,
'--input', `course=${course}`,
'--input', `date=${date}`,
'--input', `cert_no=${cert_no}`,
'--input', `ref_no=${ref_no}`,
];
if (instructors) args.push('--input', `instructors=${instructors}`);
if (length) args.push('--input', `length=${length}`);
try {
await execFileAsync(TYPST_BIN, args);
return await fs.readFile(outPath);
} finally {
await fs.unlink(outPath).catch(() => {});
}
}
module.exports = { generateCertificate };
+178
View File
@@ -0,0 +1,178 @@
// services/chibisafe.service.js
//
// Wraps the Chibisafe REST API.
//
// Environment variables expected:
// CHIBISAFE_BASE_URL – e.g. https://cdn.yourdomain.com
// CHIBISAFE_API_KEY – your personal / service-account API key
// CHIBISAFE_ALBUM_AVATARS – album UUID for avatar images
// CHIBISAFE_ALBUM_VIDEOS – album UUID for videos
// CHIBISAFE_ALBUM_DOCUMENTS – album UUID for documents (pdf, docx, ppt, txt…)
// CHIBISAFE_ALBUM_THUMBNAILS – album UUID for video thumbnails
// CHIBISAFE_ALBUM_ARCHIVED – album UUID used as the "trash" / archived album
const FormData = require("form-data");
const axios = require("axios");
// ─── Config ───────────────────────────────────────────────────────────────────
const BASE_URL = (process.env.CHIBISAFE_BASE_URL || "").replace(/\/$/, "");
const API_KEY = process.env.CHIBISAFE_API_KEY || "";
const ALBUMS = {
avatars: process.env.CHIBISAFE_ALBUM_AVATARS || null,
videos: process.env.CHIBISAFE_ALBUM_VIDEOS || null,
documents: process.env.CHIBISAFE_ALBUM_DOCUMENTS || null,
thumbnails: process.env.CHIBISAFE_ALBUM_THUMBNAILS || null,
images: process.env.CHIBISAFE_ALBUM_IMAGES || null, // ← new
archived: process.env.CHIBISAFE_ALBUM_ARCHIVED || null,
};
// ─── Internal helpers ─────────────────────────────────────────────────────────
function resolveAlbumUuid(ownerType = "") {
switch (ownerType) {
case "avatar": return ALBUMS.avatars;
case "video": return ALBUMS.videos;
case "document": return ALBUMS.documents;
case "thumbnail": return ALBUMS.thumbnails;
case "image": return ALBUMS.images;
default: return null;
}
}
function baseHeaders(extra = {}) {
return {
"x-api-key": API_KEY,
...extra,
};
}
// ─── Axios instance ───────────────────────────────────────────────────────────
const chibi = axios.create({
baseURL: BASE_URL,
maxBodyLength: Infinity, // ← required for large file uploads
maxContentLength: Infinity,
});
/**
* Thin axios wrapper that throws a descriptive error on non-2xx.
*/
async function chibiRequest(path, { method = "GET", headers = {}, data } = {}) {
try {
const res = await chibi.request({
url: path,
method,
headers,
data,
});
return res.data;
} catch (err) {
const status = err.response?.status;
const body = err.response?.data ?? {};
const msg = body?.message || body?.error || err.message;
const friendly = new Error(`[Chibisafe] ${status ?? "?"} – ${msg}`);
friendly.status = status;
friendly.chibiBody = body;
throw friendly;
}
}
// ─── Public API ───────────────────────────────────────────────────────────────
/**
* Upload a file to Chibisafe, optionally straight into a typed album.
*/
async function uploadFile({ buffer, originalname, mimetype, ownerType = "" }) {
if (!BASE_URL || !API_KEY) {
throw new Error("[Chibisafe] CHIBISAFE_BASE_URL or CHIBISAFE_API_KEY is not configured.");
}
const albumUuid = resolveAlbumUuid(ownerType);
const form = new FormData();
form.append("file", buffer, {
filename: originalname,
contentType: mimetype,
});
const contentLength = await new Promise((resolve, reject) => {
form.getLength((err, length) => (err ? reject(err) : resolve(length)));
});
const data = await chibiRequest("/api/upload", {
method: "POST",
headers: {
...baseHeaders(),
...form.getHeaders(),
"Content-Length": contentLength,
...(albumUuid ? { albumuuid: albumUuid } : {}),
},
data: form,
});
return { uuid: data.uuid, url: data.url, name: data.name };
}
/**
* Permanently delete one file from Chibisafe by its UUID.
*/
async function deleteFile(uuid) {
await chibiRequest(`/api/file/${uuid}`, {
method: "DELETE",
headers: baseHeaders(),
});
}
/**
* Move one or more files into the "archived" album.
*/
async function archiveFiles(uuids) {
if (!ALBUMS.archived) {
throw new Error("[Chibisafe] CHIBISAFE_ALBUM_ARCHIVED is not configured.");
}
const ids = Array.isArray(uuids) ? uuids : [uuids];
if (!ids.length) return;
await chibiRequest("/api/files/album/add", {
method: "POST",
headers: {
...baseHeaders(),
"Content-Type": "application/json",
},
data: {
files: ids,
albumUuid: ALBUMS.archived,
},
});
}
/**
* Move one or more files into a specific album by UUID.
*/
async function addFilesToAlbum(uuids, albumUuid) {
const ids = Array.isArray(uuids) ? uuids : [uuids];
await chibiRequest("/api/files/album/add", {
method: "POST",
headers: {
...baseHeaders(),
"Content-Type": "application/json",
},
data: {
files: ids,
albumUuid,
},
});
}
module.exports = {
uploadFile,
deleteFile,
archiveFiles,
addFilesToAlbum,
ALBUMS,
resolveAlbumUuid,
};
@@ -0,0 +1,461 @@
/***********************************************************************************************************************************************************************
* File Name: completion_requirements.service.js
* Type of Program: Service
* Description: Consolidated completion evaluation + persistence. Replaces the duplicated
* derivation bodies previously spread across reading_progress.service.js
* (deriveUnitStatus), course_reading_progress.service.js (deriveUnitStatus /
* deriveCourseStatus), and an inline re-derivation in
* controllers/client/courses.controller.js#getLessonsByUnitUuid — all three now
* delegate to evaluateEntity() from utils/courses/completion_requirements.registry.js.
*
* evaluateEntity — re-exported from the registry (read-only, no persistence).
* recomputeAndPersist — evaluate one entity and upsert the result into the entity's
* system-of-record progress table (course-scoped → CourseReadingProgress;
* standalone/library → Unit/LessonReadingProgress, the only tables that
* tolerate a null course_id).
* recomputeCascade — the lesson-progress entry point: writes the raw client-asserted
* lesson fact, then re-evaluates+persists lesson → unit → course in one
* transaction. Replaces upsertLessonRead in both legacy services.
* recomputeUnitAfterQuiz / recomputeCourseAfterAssessment
* — thin wrappers called after a quiz/assessment submit, so passing a
* quiz/assessment immediately re-triggers parent evaluation instead of
* requiring a subsequent lesson read to notice (closes the gap where
* submitUnitQuiz/submitCourseAssessment never touched reading progress).
* recordWatchProgress / recordManualComplete
* — entry points backing the watch_percent / watch_video / listen_audio /
* manual_complete requirement types, writing CompletionRequirementProgress
* then cascading.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jul. 14, 2026
***********************************************************************************************************************************************************************/
'use strict';
const sequelize = require('../config/db.config');
const { withTransactionRetry } = require('../utils/withTransactionRetry.util');
const { evaluateEntity } = require('../utils/courses/completion_requirements.registry');
const CompletionRequirement = require('../models/courses/completion_requirement.mdl');
const CompletionRequirementProgress = require('../models/courses/completion_requirement_progress.mdl');
const Course = require('../models/courses/courses.mdl').Course;
const Unit = require('../models/courses/units.mdl');
const Lesson = require('../models/courses/lessons.mdl');
const LessonPage = require('../models/courses/lesson_page.mdl');
const { upsertProgress } = require('./course_reading_progress.service');
const {
upsertLessonProgress, upsertUnitProgress,
upsertLessonRead: mirrorLessonRead,
} = require('./reading_progress.service');
const { syncCompletedEntitiesToTaskProgress } = require('./task_reading_progress_sync.service');
// ─── Persist one entity's evaluated status into its system-of-record table ──────
async function persistStatus({ entityType, entityId, userId, courseId, referenceId, status }, t) {
if (courseId) {
// Course-scoped: CourseReadingProgress, UUID-keyed, requires a non-null course_id.
await upsertProgress({ userId, courseId, type: entityType, referenceId, status }, t);
return;
}
// Standalone/library: Unit/LessonReadingProgress, the tables that tolerate null course_id.
if (entityType === 'lesson') {
await upsertLessonProgress({ userId, courseId: null, unitId: null, lessonId: entityId, status }, t);
} else if (entityType === 'unit') {
await upsertUnitProgress({ userId, courseId: null, unitId: entityId, status }, t);
}
// No standalone system-of-record for 'course' — courses always have a courseId by definition.
}
/**
* Evaluate one entity and persist the result. Read-then-write — callers inside a cascade
* pass the same transaction so the write is visible to the next level's evaluation.
*/
async function recomputeAndPersist({ entityType, entityId, userId, courseId = null, referenceId }, t) {
const result = await evaluateEntity({ entityType, entityId, userId, courseId, transaction: t });
await persistStatus({ entityType, entityId, userId, courseId, referenceId, status: result.status }, t);
return result;
}
// ─── Main entry point: lesson read → unit → course cascade ──────────────────────
/**
* Called when a user reads (or finishes reading) a lesson via the legacy scroll-trigger
* endpoint. Writes the raw asserted lesson fact, then re-evaluates+persists lesson → unit →
* course in one transaction (a lesson configured with watch_percent/manual_complete/pass_quiz
* will simply ignore the raw fact during its own re-evaluation — see the registry's per-type
* dispatch — so this stays safe to call regardless of what's configured on the lesson).
*
* @param {number} userId
* @param {Object} payload
* @param {number|null} payload.courseId — course BIGINT PK, or null for standalone reads
* @param {string|null} payload.courseUuid
* @param {number|null} payload.unitId — unit BIGINT PK, or null for standalone lesson-only reads
* @param {string|null} payload.unitUuid
* @param {number} payload.lessonId
* @param {string} payload.lessonUuid
* @param {string} payload.lessonStatus — 'in_progress' | 'completed', the client's raw assertion
* @param {import('sequelize').Transaction} [externalTransaction] — reuse an already-open
* transaction (e.g. from recordWatchProgress/recordManualComplete) instead of opening
* and committing a new one, so the requirement-progress write and the cascade stay atomic.
* @returns {{ lesson, unit, course }}
*/
async function recomputeCascade(userId, {
courseId = null, courseUuid = null,
unitId = null, unitUuid = null,
lessonId, lessonUuid, lessonStatus = 'in_progress',
}, externalTransaction = null) {
const t = externalTransaction ?? await sequelize.transaction();
try {
// 1. Raw asserted fact — the data source for read_all_content / default lesson evaluation.
await persistStatus({ entityType: 'lesson', entityId: lessonId, userId, courseId, referenceId: lessonUuid, status: lessonStatus }, t);
// 2. Re-evaluate the lesson (respects whatever type is actually configured on it).
const lessonResult = await recomputeAndPersist(
{ entityType: 'lesson', entityId: lessonId, userId, courseId, referenceId: lessonUuid }, t
);
// 3. Unit — derived from all sibling lessons (skipped for lesson-only standalone reads).
let unitResult = null;
if (unitId) {
unitResult = await recomputeAndPersist(
{ entityType: 'unit', entityId: unitId, userId, courseId, referenceId: unitUuid }, t
);
}
// 4. Course — derived from all units (skipped for standalone reads, which have no courseId).
let courseResult = null;
if (courseId) {
courseResult = await recomputeAndPersist(
{ entityType: 'course', entityId: courseId, userId, courseId, referenceId: courseUuid }, t
);
}
if (!externalTransaction) await t.commit();
// Best-effort mirror into lesson_reading_progress/unit_reading_progress for course-scoped
// reads — standalone reads already write these tables directly as their system of record
// (see persistStatus above). Several admin dashboards (controllers/admin/units.controller.js,
// controllers/admin/courses.controller.js) still read these tables for completion stats;
// this keeps them populated without making them a decision source for the evaluator itself.
// Fired only after our own transaction is durable, and never awaited/allowed to fail the request.
if (courseId && !externalTransaction) {
mirrorLessonRead(userId, { courseId, unitId, lessonId, lessonStatus })
.catch((e) => console.error('[COMPLETION REQUIREMENTS] reading-progress mirror write failed:', e));
}
// Task-progress sync (read_lesson/read_unit/read_course auto-complete) only runs once
// this transaction is durable. When called with an externalTransaction (from
// recordWatchProgress/recordManualComplete), that caller commits and syncs itself instead —
// running it here would read pre-commit state. Runs regardless of courseId — a
// standalone (no parent course) lesson/unit read_lesson/read_unit task requirement
// needs this too, not just course-scoped ones.
let completedTasks = [];
if (!externalTransaction) {
completedTasks = await syncCompletedEntitiesToTaskProgress(userId, { lessonUuid, unitUuid, courseUuid });
}
return {
lesson: { lesson_id: lessonId, reference_id: lessonUuid, status: lessonResult.status },
unit: unitId ? { unit_id: unitId, reference_id: unitUuid, status: unitResult.status } : null,
course: courseId ? { course_id: courseId, reference_id: courseUuid, status: courseResult.status } : null,
completed_tasks: completedTasks,
};
} catch (err) {
if (!externalTransaction) await t.rollback();
throw err;
}
}
// ─── Quiz / assessment submit hooks ──────────────────────────────────────────────
// submitUnitQuiz / submitCourseAssessment never touched reading progress before this feature —
// these close that gap so a pass_quiz-configured unit/course reflects completion immediately.
async function recomputeUnitAfterQuiz(userId, { unitId, courseId }, t) {
const unit = await Unit.findOne({ where: { unit_id: unitId }, attributes: ['unit_id', 'uuid'], transaction: t });
if (!unit) return null;
const unitResult = await recomputeAndPersist(
{ entityType: 'unit', entityId: unitId, userId, courseId: courseId ?? null, referenceId: unit.uuid }, t
);
let courseResult = null;
let courseUuid = null;
if (courseId) {
const course = await Course.findOne({ where: { course_id: courseId }, attributes: ['course_id', 'uuid'], transaction: t });
if (course) {
courseUuid = course.uuid;
courseResult = await recomputeAndPersist(
{ entityType: 'course', entityId: courseId, userId, courseId, referenceId: course.uuid }, t
);
}
}
// Passing a quiz can complete a read_unit/read_course task requirement even though no
// lesson was ever read — the gap this whole function exists to close, so the task-sync
// needs to run here too, not just from the lesson-progress cascade.
const completedTasks = await syncCompletedEntitiesToTaskProgress(userId, { unitUuid: unit.uuid, courseUuid });
return { unit: unitResult, course: courseResult, completed_tasks: completedTasks };
}
async function recomputeCourseAfterAssessment(userId, courseId, t) {
const course = await Course.findOne({ where: { course_id: courseId }, attributes: ['course_id', 'uuid'], transaction: t });
if (!course) return null;
const result = await recomputeAndPersist(
{ entityType: 'course', entityId: courseId, userId, courseId, referenceId: course.uuid }, t
);
const completedTasks = await syncCompletedEntitiesToTaskProgress(userId, { courseUuid: course.uuid });
return { ...result, completed_tasks: completedTasks };
}
// ─── watch_percent / manual_complete entry points ────────────────────────────────
/**
* Upsert a lesson's watch progress, then cascade lesson → unit → course. Handles two
* independent requirement types in one write since both can be configured on the same
* lesson simultaneously:
* - watch_percent — one aggregate percent across whichever block is playing, monotonic,
* satisfied once it crosses the requirement's configured min_percent.
* - watch_video / listen_audio — only touched when the caller identifies which block sent
* the update (`blockId`/`blockType`); tracks each matching block's own
* running max in `block_progress`, satisfied once EVERY block of that
* type currently on the lesson's page is at 100 (re-checked against the
* live block set on every write, not a snapshot taken when configured).
* No-ops (returns null) if the lesson has neither type configured.
*/
// text-video counts as a video block for watch_video purposes — same <video> element
// under the hood, just paired with text. A lesson mixing plain "video" and "text-video"
// blocks needs both kinds counted together when checking "every video block hit 100%".
const VIDEO_LIKE_BLOCK_TYPES = ['video', 'text-video'];
// ─── Anti-gaming: wall-clock validation for reported watch progress ─────────────
// A single client-reported percent (whether from a scrubbed-to-the-end seek or a
// direct API call) is never trusted at face value. Each sample is checked against
// how much real wall-clock time has elapsed since that block's last recorded
// sample, allowing for playback up to MAX_PLAYBACK_SPEED — anything reported
// faster than that is clamped down to what's actually plausible.
const BASELINE_CEILING_PERCENT = 5; // a block's very first-ever sample is capped here
// Small fixed jitter buffer for network/DB latency between consecutive samples — kept
// low because it's an ABSOLUTE-seconds allowance added on top of real elapsed time, so a
// large value would let two back-to-back calls claim a big percent jump on short clips
// regardless of actual elapsed time. Legitimate throttled playback (~10s apart) is still
// credited in full since real elapsed time carries most of the allowance there.
const TOLERANCE_SECONDS = 2;
const MAX_PLAYBACK_SPEED = 2; // matches the client UI's speed cap — do not diverge from it
function getBlockDuration(page, blockId) {
const block = (page?.blocks ?? []).find((b) => String(b.id) === String(blockId));
return Number(block?.content?.duration_seconds) || 0;
}
// Returns the percent a sample should actually be credited with, after validating
// it against real elapsed wall-clock time since the block's last recorded sample.
function reconcileBlockSample({ prevEntry, reportedPercent, durationSeconds, now }) {
const clampedReported = Math.min(100, Math.max(0, Math.round(reportedPercent)));
if (!prevEntry) {
// Cold start: no prior sample to check elapsed time against. Accept as a
// baseline only, hard-capped — a bare first-ever call (e.g. a direct API
// replay bypassing the UI entirely) can never claim large/complete progress.
return { percent: Math.min(clampedReported, BASELINE_CEILING_PERCENT), updatedAt: now, isFirstSample: true };
}
const delta = clampedReported - prevEntry.percent;
if (delta <= 0 || !durationSeconds) {
// Not a forward increase (rewatch/no-op), or duration unknown — fail open
// rather than blocking tracking for content whose duration hasn't backfilled.
return { percent: Math.max(prevEntry.percent, clampedReported), updatedAt: now, isFirstSample: false };
}
const elapsedSeconds = Math.max(0, (now.getTime() - new Date(prevEntry.updatedAt).getTime()) / 1000);
const maxPlausibleDelta = ((elapsedSeconds + TOLERANCE_SECONDS) * MAX_PLAYBACK_SPEED / durationSeconds) * 100;
const creditedDelta = Math.min(delta, maxPlausibleDelta);
return { percent: Math.min(100, Math.round(prevEntry.percent + creditedDelta)), updatedAt: now, isFirstSample: false };
}
async function recordWatchProgress(userId, {
lessonId, lessonUuid, unitId = null, unitUuid = null, courseId = null, courseUuid = null,
percent, blockId = null, blockType = null,
}) {
const isVideoLikeBlock = VIDEO_LIKE_BLOCK_TYPES.includes(blockType);
const blockRequirementType = isVideoLikeBlock ? 'watch_video' : blockType === 'audio' ? 'listen_audio' : null;
const requirements = await CompletionRequirement.findAll({
where: {
entity_type: 'lesson',
entity_id: lessonId,
type: blockRequirementType ? ['watch_percent', blockRequirementType] : 'watch_percent',
},
});
if (!requirements.length) return null;
const percentRequirement = requirements.find((r) => r.type === 'watch_percent');
const blockRequirement = blockId ? requirements.find((r) => r.type === blockRequirementType) : null;
// Both branches need the block's canonical duration for wall-clock validation —
// fetched once up front rather than lazily inside the blockRequirement branch.
const page = (percentRequirement || blockRequirement)
? await LessonPage.findOne({ where: { lesson_id: lessonId }, attributes: ['blocks'] })
: null;
const durationSeconds = blockId ? getBlockDuration(page, blockId) : 0;
const { anyCompleted, aggregatePercent, cascade } = await withTransactionRetry(sequelize, async (t) => {
let anyCompleted = false;
let aggregatePercent = null;
const now = new Date();
if (percentRequirement) {
const existing = await CompletionRequirementProgress.findOne({
where: { requirement_id: percentRequirement.requirement_id, user_id: userId }, transaction: t,
});
const prevBlockProgress = existing?.block_progress ?? {};
let creditedPercent = Math.min(100, Math.max(0, Math.round(percent)));
let isFirstSample = false;
let nextBlockProgress = prevBlockProgress;
if (blockId) {
const result = reconcileBlockSample({ prevEntry: prevBlockProgress[blockId], reportedPercent: percent, durationSeconds, now });
creditedPercent = result.percent;
isFirstSample = result.isFirstSample;
nextBlockProgress = { ...prevBlockProgress, [blockId]: { percent: creditedPercent, updatedAt: result.updatedAt } };
}
// No blockId supplied — fall back to the unvalidated legacy behavior
// (shouldn't happen given current callers, but don't hard-fail the endpoint).
const nextPercent = Math.max(existing?.progress_percent ?? 0, creditedPercent);
// A block's very first-ever sample can never itself complete the requirement —
// guarantees at least one real elapsed-time check ran before crediting completion.
const completed = !isFirstSample && nextPercent >= (percentRequirement.min_percent ?? 100);
await CompletionRequirementProgress.upsert({
requirement_id: percentRequirement.requirement_id,
user_id: userId,
entity_type: 'lesson',
entity_id: lessonId,
progress_percent: nextPercent,
block_progress: nextBlockProgress,
completed,
completed_at: completed ? (existing?.completed_at ?? new Date()) : null,
updatedBy: userId,
}, { conflictFields: ['requirement_id', 'user_id'], transaction: t });
aggregatePercent = nextPercent;
if (completed) anyCompleted = true;
}
if (blockRequirement) {
const existing = await CompletionRequirementProgress.findOne({
where: { requirement_id: blockRequirement.requirement_id, user_id: userId }, transaction: t,
});
const prevBlockProgress = existing?.block_progress ?? {};
const result = reconcileBlockSample({ prevEntry: prevBlockProgress[blockId], reportedPercent: percent, durationSeconds, now });
const nextBlockProgress = { ...prevBlockProgress, [blockId]: { percent: result.percent, updatedAt: result.updatedAt } };
const matchingBlockIds = (page?.blocks ?? [])
.filter((b) => isVideoLikeBlock ? VIDEO_LIKE_BLOCK_TYPES.includes(b.type) : b.type === blockType)
.map((b) => b.id);
const completed = matchingBlockIds.length > 0
&& !result.isFirstSample
&& matchingBlockIds.every((id) => (nextBlockProgress[id]?.percent ?? 0) >= 100);
await CompletionRequirementProgress.upsert({
requirement_id: blockRequirement.requirement_id,
user_id: userId,
entity_type: 'lesson',
entity_id: lessonId,
block_progress: nextBlockProgress,
completed,
completed_at: completed ? (existing?.completed_at ?? new Date()) : null,
updatedBy: userId,
}, { conflictFields: ['requirement_id', 'user_id'], transaction: t });
if (completed) anyCompleted = true;
}
const cascade = await recomputeCascade(userId, {
courseId, courseUuid, unitId, unitUuid,
lessonId, lessonUuid,
// The lesson's own raw fact is irrelevant for a configured lesson (its evaluateEntity
// call ignores it and ANDs the actual per-type checks instead), but recomputeCascade
// still writes it for consistency with any other consumer reading the raw flag directly.
lessonStatus: anyCompleted ? 'completed' : 'in_progress',
}, t);
return { anyCompleted, aggregatePercent, cascade };
});
// recomputeCascade skipped its own task-sync since it ran under our externalTransaction
// (would've read pre-commit state) — run it now that everything is durable. Runs
// regardless of courseId — a standalone (no parent course) lesson can satisfy a
// read_lesson task requirement via watch_video/listen_audio/watch_percent too.
const completedTasks = await syncCompletedEntitiesToTaskProgress(userId, { lessonUuid, unitUuid, courseUuid });
return { progress_percent: aggregatePercent, completed: anyCompleted, cascade: { ...cascade, completed_tasks: completedTasks } };
}
/**
* Flip a manual_complete requirement's progress row to completed, then cascade upward from
* whichever entity it's configured on. No-ops (returns null) if no manual_complete requirement
* is configured for that entity.
*/
async function recordManualComplete(userId, { entityType, entityId, lessonId = null, lessonUuid = null, unitId = null, unitUuid = null, courseId = null, courseUuid = null }) {
const requirement = await CompletionRequirement.findOne({
where: { entity_type: entityType, entity_id: entityId, type: 'manual_complete' },
});
if (!requirement) return null;
const result = await withTransactionRetry(sequelize, async (t) => {
await CompletionRequirementProgress.upsert({
requirement_id: requirement.requirement_id,
user_id: userId,
entity_type: entityType,
entity_id: entityId,
completed: true,
completed_at: new Date(),
updatedBy: userId,
}, { conflictFields: ['requirement_id', 'user_id'], transaction: t });
let result;
if (entityType === 'lesson') {
result = await recomputeCascade(userId, {
courseId, courseUuid, unitId, unitUuid,
lessonId: entityId, lessonUuid,
lessonStatus: 'completed',
}, t);
} else if (entityType === 'unit') {
result = { unit: await recomputeAndPersist({ entityType: 'unit', entityId, userId, courseId, referenceId: unitUuid }, t) };
if (courseId) {
const course = await Course.findOne({ where: { course_id: courseId }, attributes: ['uuid'], transaction: t });
if (course) result.course = await recomputeAndPersist({ entityType: 'course', entityId: courseId, userId, courseId, referenceId: course.uuid }, t);
}
} else {
result = { course: await recomputeAndPersist({ entityType: 'course', entityId, userId, courseId: entityId, referenceId: courseUuid }, t) };
}
return result;
});
// Same reasoning as recordWatchProgress — recomputeCascade (lesson branch) skipped its
// own sync under our externalTransaction; the unit/course branches never called it at
// all. Runs regardless of courseId — a standalone (no parent course) lesson/unit
// manual_complete can satisfy a read_lesson/read_unit task requirement too.
const syncUuids = entityType === 'lesson'
? { lessonUuid, unitUuid, courseUuid }
: entityType === 'unit'
? { unitUuid, courseUuid }
: { courseUuid };
result.completed_tasks = await syncCompletedEntitiesToTaskProgress(userId, syncUuids);
return result;
}
module.exports = {
evaluateEntity,
recomputeAndPersist,
recomputeCascade,
recomputeUnitAfterQuiz,
recomputeCourseAfterAssessment,
recordWatchProgress,
recordManualComplete,
};
@@ -0,0 +1,212 @@
/***********************************************************************************************************************************************************************
* File Name: course_reading_progress.service.js
* Type of Program: Service
* Description: UPSERT-based progress tracking for user reading activity across the course hierarchy.
*
* upsertLessonRead — main entry point, called when a user reads a lesson.
* UPSERTs the lesson row, then derives and UPSERTs the parent unit and course rows.
* All three writes run in a single transaction.
*
* UPSERT key: (user_id, type, reference_id)
*
* Derivation rules:
* unit → completed when ALL its non-deleted lessons have a completed row for this user
* course → completed when ALL its non-deleted units have a completed row for this user
* AND, if the course has a course assessment, the user has passed it.
* A course with no assessment built yet can never reach 'completed' here —
* reading alone isn't course completion.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 21, 2026
***********************************************************************************************************************************************************************/
'use strict';
const sequelize = require('../config/db.config');
const CourseReadingProgress = require('../models/courses/course_reading_progress.mdl');
const Lesson = require('../models/courses/lessons.mdl');
const Unit = require('../models/courses/units.mdl');
const CourseUnit = require('../models/courses/course_units.mdl');
const UnitLesson = require('../models/courses/unit_lessons.mdl');
const CourseAssessment = require('../models/courses/course_assessment.mdl');
const QuizAttempt = require('../models/courses/quiz_attempt.mdl');
// A course only counts as fully complete once it has a built assessment AND the user passed it.
async function hasPassedCourseAssessment(userId, courseId, t) {
const assessment = await CourseAssessment.findOne({
where: { course_id: courseId },
attributes: ['assessment_id'],
transaction: t,
});
if (!assessment) return false;
const passedAttempt = await QuizAttempt.findOne({
where: { user_id: userId, assessment_id: assessment.assessment_id, passed: true },
transaction: t,
});
return !!passedAttempt;
}
// ─── Core UPSERT ─────────────────────────────────────────────────────────────
async function upsertProgress({ userId, courseId, type, referenceId, status }, t) {
const now = new Date();
const [record] = await CourseReadingProgress.upsert(
{
user_id: userId,
course_id: courseId,
reference_id: referenceId,
type,
status,
completed_at: status === 'completed' ? now : null,
last_accessed_at: now,
createdBy: userId,
updatedBy: userId,
},
{
conflictFields: ['user_id', 'type', 'reference_id'],
returning: true,
transaction: t,
}
);
return record;
}
// ─── Derivation helpers ───────────────────────────────────────────────────────
// Unit is completed when every non-deleted lesson attached to it (via unit_lessons)
// has a completed row for this user.
async function deriveUnitStatus(userId, courseId, unitId, t) {
const links = await UnitLesson.findAll({
where: { unit_id: unitId },
attributes: ['lesson_id'],
transaction: t,
});
if (!links.length) return 'in_progress';
const lessons = await Lesson.findAll({
where: { lesson_id: links.map(l => l.lesson_id) },
attributes: ['uuid'],
transaction: t,
});
if (!lessons.length) return 'in_progress';
const lessonUuids = lessons.map(l => l.uuid);
const completedCount = await CourseReadingProgress.count({
where: {
user_id: userId,
course_id: courseId,
type: 'lesson',
reference_id: lessonUuids,
status: 'completed',
},
transaction: t,
});
return completedCount === lessons.length ? 'completed' : 'in_progress';
}
// Course is completed when every non-deleted unit under it has a completed row for this user
// AND the course's assessment (if one has been built) has been passed by this user.
async function deriveCourseStatus(userId, courseId, t) {
const links = await CourseUnit.findAll({
where: { course_id: courseId },
attributes: ['unit_id'],
transaction: t,
});
if (!links.length) return 'in_progress';
const units = await Unit.findAll({
where: { unit_id: links.map(l => l.unit_id) },
attributes: ['uuid'],
transaction: t,
});
if (!units.length) return 'in_progress';
const unitUuids = units.map(u => u.uuid);
const completedCount = await CourseReadingProgress.count({
where: {
user_id: userId,
course_id: courseId,
type: 'unit',
reference_id: unitUuids,
status: 'completed',
},
transaction: t,
});
const allUnitsRead = completedCount === units.length;
if (!allUnitsRead) return 'in_progress';
const assessmentPassed = await hasPassedCourseAssessment(userId, courseId, t);
return assessmentPassed ? 'completed' : 'in_progress';
}
// ─── Main entry point ─────────────────────────────────────────────────────────
/**
* Called when a user reads (or finishes reading) a lesson.
* Writes three rows in one transaction: lesson → unit → course.
*
* @param {number} userId
* @param {Object} payload
* @param {number} payload.courseId — course BIGINT PK (FK on course_reading_progress)
* @param {string} payload.courseUuid — course UUID (reference_id for the course row)
* @param {number} payload.unitId — unit BIGINT PK (used to query sibling lessons)
* @param {string} payload.unitUuid — unit UUID (reference_id for the unit row)
* @param {string} payload.lessonUuid — lesson UUID (reference_id for the lesson row)
* @param {string} payload.lessonStatus — 'in_progress' | 'completed'
* @returns {{ lesson, unit, course }} — status snapshot for each level
*/
async function upsertLessonRead(userId, { courseId, courseUuid, unitId, unitUuid, lessonUuid, lessonStatus = 'in_progress' }) {
const t = await sequelize.transaction();
try {
// 1. Lesson
await upsertProgress({
userId,
courseId,
type: 'lesson',
referenceId: lessonUuid,
status: lessonStatus,
}, t);
// 2. Unit — derived from all sibling lessons
const unitStatus = await deriveUnitStatus(userId, courseId, unitId, t);
await upsertProgress({
userId,
courseId,
type: 'unit',
referenceId: unitUuid,
status: unitStatus,
}, t);
// 3. Course — derived from all units
const courseStatus = await deriveCourseStatus(userId, courseId, t);
await upsertProgress({
userId,
courseId,
type: 'course',
referenceId: courseUuid,
status: courseStatus,
}, t);
await t.commit();
return {
lesson: { reference_id: lessonUuid, status: lessonStatus },
unit: { reference_id: unitUuid, status: unitStatus },
course: { reference_id: courseUuid, status: courseStatus },
};
} catch (err) {
await t.rollback();
throw err;
}
}
// ─── Exports ──────────────────────────────────────────────────────────────────
module.exports = {
upsertLessonRead,
upsertProgress,
deriveUnitStatus,
deriveCourseStatus,
};
+92
View File
@@ -0,0 +1,92 @@
/***********************************************************************************************************************************************************************
* File Name: email.service.js
* Type of Program: Service
* Description: Gmail API-based email service (HTTPS, gmail.googleapis.com).
* Render blocks outbound raw SMTP (ports 25/465/587), so nodemailer's
* SMTP transport to smtp.gmail.com can never connect from this host —
* every send failed with nodemailer's own "Connection timeout" after
* its 2-minute connectionTimeout expired. Sending over the Gmail REST
* API instead rides on HTTPS/443, which isn't blocked.
* Auth is OAuth2 with a long-lived refresh token for services.philpro@gmail.com
* (see scripts/get_gmail_refresh_token.js to mint one), reusing the same
* GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET already registered for Google login.
* Subject + body per type are hardcoded in data/email_body.data.js —
* there is no admin UI or DB table. To add or change an email,
* edit that file directly and redeploy.
* Author: rgrgogu, Kenneth Obsequio (@lash0000)
* Date Created: Oct. 6, 2025
* Date Modified: Jul. 14, 2026 — Replaced nodemailer/SMTP with Gmail API over HTTPS;
* SMTP was silently black-holed by Render's network. (Kenneth Obsequio)
***********************************************************************************************************************************************************************
* HOW TO USE:
* const emailService = require('../services/email.service');
* await emailService.sendEmail({ to: user.email, type: 'OTP', data: { otp } });
***********************************************************************************************************************************************************************/
const axios = require('axios');
const { OAuth2Client } = require('google-auth-library');
const { emailTemplates } = require('../data/email_body.data');
const GMAIL_SEND_URL = 'https://gmail.googleapis.com/gmail/v1/users/me/messages/send';
// Lazily initialised so the module can be required before env is loaded.
let _oauth2Client;
const getOAuth2Client = () => {
if (!_oauth2Client) {
_oauth2Client = new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET);
_oauth2Client.setCredentials({ refresh_token: process.env.GMAIL_REFRESH_TOKEN });
}
return _oauth2Client;
};
const getAccessToken = async () => {
const { token } = await getOAuth2Client().getAccessToken();
if (!token) throw new Error('Failed to obtain Gmail access token');
return token;
};
// Encodes a MIME header value so non-ASCII subjects survive transit (RFC 2047).
const encodeHeader = (value) => `=?UTF-8?B?${Buffer.from(value, 'utf-8').toString('base64')}?=`;
const buildRawMessage = ({ to, subject, html }) => {
const message = [
`From: "STARR System" <${process.env.EMAIL_FROM}>`,
`To: ${to}`,
`Subject: ${encodeHeader(subject)}`,
'MIME-Version: 1.0',
'Content-Type: text/html; charset=UTF-8',
'',
html,
].join('\r\n');
return Buffer.from(message).toString('base64url');
};
const sendEmail = async ({ to, type, data = {} }) => {
try {
const templateFn = emailTemplates[type];
if (!templateFn) {
throw new Error(`Email template "${type}" not found`);
}
const { subject, html } = templateFn(data);
const accessToken = await getAccessToken();
const { data: result } = await axios.post(
GMAIL_SEND_URL,
{ raw: buildRawMessage({ to, subject, html }) },
{ headers: { Authorization: `Bearer ${accessToken}` } },
);
return result;
} catch (err) {
throw new Error(err.response?.data?.error?.message || err.message);
}
};
// Confirms the refresh token is valid and Gmail's OAuth endpoint is reachable.
// Deliberately doesn't call a Gmail read endpoint (e.g. users.getProfile) — the
// refresh token is scoped to gmail.send only, which doesn't grant read access.
const ping = () => getAccessToken();
module.exports = { sendEmail, ping };
+150
View File
@@ -0,0 +1,150 @@
// services/ffmpeg.service.js
//
// Fast container remux for video assets whose original format loads slowly
// in the browser. Not a transcode — the video/audio streams are copied
// byte-for-byte (`-c copy`), only the container is swapped:
//
// .mov — often exported without "fast start" (common for OBS/QuickTime
// screen recordings), which puts the moov atom — the index the
// browser needs before it can render anything — at the END of the
// file. Playback can't begin until that's reached.
// .mkv — same class of problem with its Cues index, plus native browser
// support for Matroska demuxing/seeking is inconsistent to begin
// with.
//
// Remuxing into a faststart .mp4 (moov moved to the front) makes both play
// exactly like this app's already-fast .mp4 uploads. .mp4/.mp3 are untouched
// — they don't have this problem.
//
// Reads directly from a presigned S3 URL — ffmpeg's own HTTP client handles
// that (same as ffprobe.service.js's probeUrl()), this process never buffers
// the original file. The mp4 muxer needs a seekable *output* to rewrite the
// moov atom after the fact, so the result is written to a local temp file —
// see services/assetTranscode.service.js for streaming that back to storage
// without buffering it into memory either.
const ffmpeg = require("fluent-ffmpeg");
const ffprobeStatic = require("ffprobe-static");
const os = require("os");
const path = require("path");
const fs = require("fs");
const crypto = require("crypto");
try {
const { execSync } = require("child_process");
execSync("which ffprobe", { stdio: "ignore" });
} catch {
ffmpeg.setFfprobePath(ffprobeStatic.path);
}
const REMUXABLE_EXTENSIONS = new Set(["mov", "mkv"]);
function needsRemux(extension = "") {
return REMUXABLE_EXTENSIONS.has((extension || "").toLowerCase());
}
function tempOutputPath() {
return path.join(os.tmpdir(), `remux_${Date.now()}_${crypto.randomBytes(4).toString("hex")}.mp4`);
}
// input: presigned GET URL for the original .mov/.mkv object
// output: local filesystem path to the remuxed .mp4 (caller owns cleanup)
//
// -map 0:v:0 -map 0:a:0? — take the first video stream and, if present, the
// first audio stream only. Drops subtitle/data streams some mkv/mov files
// carry, which the mp4 muxer either can't hold or chokes on.
// -max_muxing_queue_size — defensive bump; large copy-remuxes of files with
// bursty interleaving can otherwise hit "Too many packets buffered for
// output stream" and abort.
function remuxToFaststartMp4(inputUrl, { timeoutMs = 30 * 60 * 1000 } = {}) {
const outputPath = tempOutputPath();
return new Promise((resolve, reject) => {
let settled = false;
const command = ffmpeg(inputUrl)
.outputOptions([
"-map 0:v:0",
"-map 0:a:0?",
"-c:v copy",
"-c:a copy",
"-movflags +faststart",
"-max_muxing_queue_size 9999",
])
.format("mp4");
const timer = setTimeout(() => {
if (settled) return;
settled = true;
command.kill("SIGKILL");
fs.promises.unlink(outputPath).catch(() => {});
reject(new Error(`Remux timed out after ${timeoutMs}ms`));
}, timeoutMs);
command
.on("error", (err) => {
if (settled) return;
settled = true;
clearTimeout(timer);
fs.promises.unlink(outputPath).catch(() => {});
reject(err);
})
.on("end", () => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(outputPath);
})
.save(outputPath);
});
}
// Grabs a single frame from a video as a JPEG thumbnail — used when a video
// asset lands with no client-provided thumbnail (see finalizeAssetFromStorage
// in assets.controller.js). Unlike remuxToFaststartMp4 above, this is cheap
// enough (one frame, not a full re-encode/copy) to run inline during the
// upload-finalize request rather than as a background job.
//
// input: presigned GET URL for the video, + its duration (seconds, from
// ffprobe) to pick a safe seek point.
// output: local filesystem path to the extracted .jpg (caller owns cleanup).
function extractFrameThumbnail(inputUrl, duration, { timeoutMs = 30 * 1000 } = {}) {
// Seek 1s in, or the midpoint for clips shorter than ~2s — avoids grabbing
// frame 0, which is often black/blank on screen recordings and slates.
const atSeconds = !duration || duration <= 0 ? 0 : Math.min(1, duration / 2);
const outputPath = path.join(os.tmpdir(), `thumb_${Date.now()}_${crypto.randomBytes(4).toString("hex")}.jpg`);
return new Promise((resolve, reject) => {
let settled = false;
const command = ffmpeg(inputUrl)
.seekInput(atSeconds) // input-side seek — fast, keyframe-based
.outputOptions(["-frames:v 1", "-q:v 2"]);
const timer = setTimeout(() => {
if (settled) return;
settled = true;
command.kill("SIGKILL");
fs.promises.unlink(outputPath).catch(() => {});
reject(new Error(`Thumbnail extraction timed out after ${timeoutMs}ms`));
}, timeoutMs);
command
.on("error", (err) => {
if (settled) return;
settled = true;
clearTimeout(timer);
fs.promises.unlink(outputPath).catch(() => {});
reject(err);
})
.on("end", () => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(outputPath);
})
.save(outputPath);
});
}
module.exports = { needsRemux, remuxToFaststartMp4, extractFrameThumbnail };
+170
View File
@@ -0,0 +1,170 @@
// services/ffprobe.service.js
//
// Extracts media metadata (dimensions, duration, codecs, bitrate, frame rate).
// Also used for audio-only files — video-specific fields (width/height/
// frame_rate/video_codec) simply resolve to null when there's no video stream.
// Thumbnail is provided by the client as a separate uploaded file — not generated here.
//
// Dependencies:
// npm install fluent-ffmpeg ffprobe-static
const ffmpeg = require("fluent-ffmpeg");
const ffprobeStatic = require("ffprobe-static");
const os = require("os");
const path = require("path");
const fs = require("fs");
// Use system ffprobe if available, otherwise fall back to the static binary.
try {
const { execSync } = require("child_process");
execSync("which ffprobe", { stdio: "ignore" });
// system binary found — fluent-ffmpeg picks it up automatically
} catch {
ffmpeg.setFfprobePath(ffprobeStatic.path);
}
// ─── Internal helpers ─────────────────────────────────────────────────────────
function writeTempFile(buffer, extension) {
const tmpPath = path.join(
os.tmpdir(),
`asset_${Date.now()}_${Math.random().toString(36).slice(2)}.${extension}`,
);
fs.writeFileSync(tmpPath, buffer);
return tmpPath;
}
function cleanupTempFile(filePath) {
try { fs.unlinkSync(filePath); } catch { /* ignore */ }
}
/**
* Parse frame rate from ffprobe's fraction string (e.g. "30/1", "24000/1001").
*/
function parseFrameRate(rateStr = "") {
if (!rateStr || rateStr === "0/0") return null;
const [num, den] = rateStr.split("/").map(Number);
if (!den || den === 0) return num || null;
return parseFloat((num / den).toFixed(3));
}
/**
* Resolve human-readable resolution label. Mirrors the controller helper.
*/
function resolveResolution(width, height) {
if (!width || !height) return null;
const h = Math.min(width, height);
if (h >= 2160) return "4K";
if (h >= 1440) return "1440p";
if (h >= 1080) return "1080p";
if (h >= 720) return "720p";
if (h >= 480) return "480p";
if (h >= 360) return "360p";
if (h >= 240) return "240p";
return `${width}x${height}`;
}
function probeFile(filePath) {
return new Promise((resolve, reject) => {
ffmpeg.ffprobe(filePath, (err, metadata) => {
if (err) return reject(err);
resolve(metadata);
});
});
}
// ffprobe/libavformat accepts http(s):// URLs directly — used for presigned
// -upload assets, where no buffer or local temp file exists at all (the
// browser PUT the bytes straight to storage, this backend never touched
// them). No default network timeout applies to a remote input the way it
// would to a local file, so this races the probe against an explicit one to
// avoid hanging the finalize request on a slow/stuck remote read.
function probeUrl(url, timeoutMs = 30_000) {
return Promise.race([
new Promise((resolve, reject) => {
ffmpeg.ffprobe(url, (err, metadata) => {
if (err) return reject(err);
resolve(metadata);
});
}),
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`ffprobe timed out after ${timeoutMs}ms`)), timeoutMs)
),
]);
}
// ─── Public API ───────────────────────────────────────────────────────────────
/**
* Extract video metadata from either a Buffer or a remote URL.
* Thumbnail is NOT generated here — the client uploads it as a separate file.
*
* @param {object} opts
* @param {Buffer} [opts.buffer] raw video bytes (multer memoryStorage) —
* written to a temp file and probed there.
* @param {string} [opts.extension] file extension without dot, e.g. "mp4" —
* only used with `buffer`.
* @param {string} [opts.url] a presigned GET URL (or any ffprobe/
* libavformat-reachable address) to probe
* directly, with no local buffer or temp
* file at all — used for presigned-upload
* assets. Takes precedence over `buffer`
* when both are given.
*
* @returns {Promise<VideoMeta>}
*
* @typedef {object} VideoMeta
* @property {number|null} width
* @property {number|null} height
* @property {string|null} resolution "1080p", "720p", "4K", …
* @property {number|null} duration seconds
* @property {number|null} frame_rate fps
* @property {number|null} bitrate bps
* @property {string|null} video_codec "H.264", "H.265", …
* @property {string|null} audio_codec "AAC", "MP3", …
*/
async function extractVideoMeta({ buffer, extension, url }) {
const tmpPath = url ? null : writeTempFile(buffer, extension || "mp4");
try {
const raw = await (tmpPath ? probeFile(tmpPath) : probeUrl(url));
const videoStream = raw.streams?.find((s) => s.codec_type === "video") || {};
const audioStream = raw.streams?.find((s) => s.codec_type === "audio") || {};
const format = raw.format || {};
const width = videoStream.width || null;
const height = videoStream.height || null;
const duration = parseFloat(format.duration || videoStream.duration || 0) || null;
const bitrate = parseInt(format.bit_rate || videoStream.bit_rate || 0, 10) || null;
const frame_rate = parseFrameRate(videoStream.r_frame_rate || videoStream.avg_frame_rate);
const resolution = resolveResolution(width, height);
const VIDEO_CODEC_MAP = {
h264: "H.264", avc1: "H.264",
h265: "H.265", hevc: "H.265",
vp8: "VP8", vp9: "VP9",
av1: "AV1",
};
const AUDIO_CODEC_MAP = {
aac: "AAC",
mp3: "MP3", mp3float: "MP3",
opus: "Opus",
vorbis: "Vorbis",
flac: "FLAC",
pcm_s16le: "PCM",
};
const video_codec = VIDEO_CODEC_MAP[(videoStream.codec_name || "").toLowerCase()]
|| videoStream.codec_name || null;
const audio_codec = AUDIO_CODEC_MAP[(audioStream.codec_name || "").toLowerCase()]
|| audioStream.codec_name || null;
return { width, height, resolution, duration, frame_rate, bitrate, video_codec, audio_codec };
} finally {
if (tmpPath) cleanupTempFile(tmpPath);
}
}
module.exports = { extractVideoMeta };
+244
View File
@@ -0,0 +1,244 @@
/***********************************************************************************************************************************************************************
* File Name: health.service.js
* Type of Program: Service
* Description: Health check registry — runs all dependency checks and builds two response formats.
*
* runDashboard() → rich human-readable payload (for GET /api/health)
* runReadiness() → compact machine payload (for GET /api/health/ready)
*
* Both call runChecks() internally and share the same check results.
*
* Check registry (CHECKS array):
* Each entry is self-contained — name, criticality, enable condition, metadata, and runner.
* To add a new infrastructure check: push one entry here. Nothing else changes.
*
* Criticality model:
* critical: true → failure → overall "unhealthy" → HTTP 503
* critical: false → failure → overall "degraded" → HTTP 200
*
* Checks included:
* ✓ database — PostgreSQL via Sequelize (CRITICAL)
* ✓ cache — Redis PING; skipped in memory mode (optional)
* ✓ storage — S3/Garage HeadBucket (optional)
* ✓ email — Gmail API profile fetch (OAuth2) (optional)
*
* Checks intentionally excluded:
* ✗ chibisafe — third-party CDN; not owned infrastructure
* ✗ paypal — third-party payment gateway; not owned infrastructure
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 20, 2026
***********************************************************************************************************************************************************************/
"use strict";
const os = require('os');
const sequelize = require('../config/db.config');
const redis = require('../config/redis.config');
const { ping: pingS3 } = require('./s3.service');
const { ping: pingEmail } = require('./email.service');
const PKG = require('../package.json');
const TIMEOUT_MS = 3000;
// ─── Connection status labels (internal status → human label) ─────────────────
const CONNECTION_LABEL = {
healthy: 'established',
unhealthy: 'unavailable',
timeout: 'timeout',
skipped: 'not configured',
};
// ─── Check registry ───────────────────────────────────────────────────────────
//
// Fields:
// name {string} — key used in response JSON
// critical {boolean} — true: 503 on failure; false: degraded (200)
// enabled {boolean} — false: skipped entirely
// skipNote {string} — shown in dashboard when enabled = false
// meta {object} — extra context shown in the dashboard (provider, host, port, etc.)
// run {async fn} — throws on failure, resolves on success
const CHECKS = [
{
name: 'database',
critical: true,
enabled: true,
meta: { dialect: 'postgresql', host: process.env.DB_HOST, port: Number(process.env.DB_PORT) || 5432 },
run: () => sequelize.authenticate(),
},
{
name: 'cache',
critical: false,
enabled: !!redis,
skipNote: 'CACHE_DRIVER=memory — Redis is not used in this environment',
meta: { driver: 'redis', url: process.env.REDIS_URL || 'redis://127.0.0.1:6379' },
run: () => redis.ping(),
},
{
name: 'storage',
critical: false,
enabled: !!(process.env.S3_ENDPOINT || process.env.S3_PUBLIC_URL),
skipNote: 'S3_ENDPOINT is not configured',
meta: { provider: 's3', endpoint: process.env.S3_PUBLIC_URL ?? process.env.S3_ENDPOINT, bucket: process.env.S3_BUCKET },
run: pingS3,
},
{
name: 'email',
critical: false,
enabled: !!(process.env.GOOGLE_CLIENT_ID && process.env.GMAIL_REFRESH_TOKEN),
skipNote: 'GMAIL_REFRESH_TOKEN is not configured',
meta: { provider: 'gmail-api', account: process.env.EMAIL_FROM },
run: pingEmail,
},
];
// ─── Internal helpers ─────────────────────────────────────────────────────────
const mb = (bytes) => Math.round(bytes / 1024 / 1024 * 10) / 10;
function formatUptime(seconds) {
if (seconds < 60) return `${seconds} secs`;
if (seconds < 3600) return `${Math.floor(seconds / 60)} mins`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)} hours`;
if (seconds < 2592000) return `${Math.floor(seconds / 86400)} days`;
if (seconds < 31536000) return `${Math.floor(seconds / 2592000)} months`;
return `${Math.floor(seconds / 31536000)} years`;
}
function memoryInfo() {
const m = process.memoryUsage();
return {
heap_used_mb: mb(m.heapUsed),
heap_total_mb: mb(m.heapTotal),
rss_mb: mb(m.rss),
external_mb: mb(m.external),
};
}
function systemInfo() {
const load = os.loadavg();
return {
platform: process.platform,
arch: process.arch,
cpus: os.cpus().length,
load_avg: {
'1m': Math.round(load[0] * 100) / 100,
'5m': Math.round(load[1] * 100) / 100,
'15m': Math.round(load[2] * 100) / 100,
},
memory: memoryInfo(),
};
}
function appInfo() {
return {
name: PKG.name,
version: PKG.version,
environment: process.env.NODE_ENV || 'development',
node_version: process.version,
pid: process.pid,
};
}
// ─── Check runner ─────────────────────────────────────────────────────────────
async function runCheck(check) {
const { name, critical, enabled, skipNote, run } = check;
if (!enabled) {
return [name, { status: 'skipped', note: skipNote, critical }];
}
const start = Date.now();
try {
await Promise.race([
run(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('timeout')), TIMEOUT_MS)
),
]);
return [name, { status: 'healthy', latency_ms: Date.now() - start, critical }];
} catch (err) {
return [name, {
status: err.message === 'timeout' ? 'timeout' : 'unhealthy',
latency_ms: Date.now() - start,
critical,
}];
}
}
async function runChecks() {
const results = await Promise.all(CHECKS.map(runCheck));
const checks = Object.fromEntries(results);
const criticalFailed = results.some(
([, r]) => r.critical && r.status !== 'healthy' && r.status !== 'skipped'
);
const anyDegraded = results.some(
([, r]) => !r.critical && r.status !== 'healthy' && r.status !== 'skipped'
);
const overallStatus = criticalFailed ? 'unhealthy'
: anyDegraded ? 'degraded'
: 'healthy';
return { checks, criticalFailed, overallStatus };
}
// ─── Dashboard payload (GET /api/health) ─────────────────────────────────────
//
// Human-readable — full context: app info, system info, service connection details.
// Uses "established / unavailable / timeout / not configured" language.
async function runDashboard() {
const { checks, criticalFailed, overallStatus } = await runChecks();
const services = {};
for (const check of CHECKS) {
const result = checks[check.name];
services[check.name] = {
connection: CONNECTION_LABEL[result.status] ?? result.status,
...(result.latency_ms !== undefined && { latency_ms: result.latency_ms }),
...(result.note !== undefined && { note: result.note }),
...(check.meta !== undefined && check.meta),
critical: result.critical,
};
}
return {
httpStatus: criticalFailed ? 503 : 200,
body: {
status: overallStatus,
app: appInfo(),
uptime: formatUptime(Math.floor(process.uptime())),
timestamp: new Date().toISOString(),
system: systemInfo(),
services,
},
};
}
// ─── Readiness payload (GET /api/health/ready) ───────────────────────────────
//
// Compact — intended for machines (Kubernetes probe, deployment scripts, CI gates).
// Uses "healthy / unhealthy / timeout / skipped" language.
async function runReadiness() {
const { checks, criticalFailed, overallStatus } = await runChecks();
return {
httpStatus: criticalFailed ? 503 : 200,
body: {
status: overallStatus,
version: PKG.version,
uptime: formatUptime(Math.floor(process.uptime())),
timestamp: new Date().toISOString(),
checks,
memory: memoryInfo(),
},
};
}
module.exports = { runDashboard, runReadiness };
+128
View File
@@ -0,0 +1,128 @@
/***********************************************************************************************************************************************************************
* File Name: mediaToken.service.js
* Type of Program: Service
* Description: Issues + caches short-lived JWT stream tokens (and presigned S3
* thumbnail URLs) for asset preview. Shared by:
* - controllers/admin/media.controller.js (POST /admin/media/token(s))
* - controllers/admin/assets.controller.js (embeds tokens directly
* into GET /admin/assets rows so pickers don't need a second
* round-trip just to render thumbnails)
*
* Author: Kenneth Obsequio (@lash0000)
***********************************************************************************************************************************************************************/
"use strict";
const jwt = require("jsonwebtoken");
const s3 = require("./s3.service");
const MEDIA_SECRET = process.env.MEDIA_JWT_SECRET ?? process.env.JWT_SECRET;
const TOKEN_TTL_SEC = 30 * 60; // 30 min — admin preview session
const SUPPORTED_TYPES = ["video", "audio", "document", "image"];
// ─── In-memory token cache (no Redis yet) ──────────────────────────────────────
// Avoids re-signing a JWT / re-presigning the thumbnail S3 URL for an asset that
// already has a still-valid token. Keyed by (asset_id, ip) because the stream
// endpoint (/api/client/media/stream/:token) pins the token to the issuing
// request's IP — reusing a token minted for a different IP would get rejected.
// Single-process only; each app instance keeps its own cache.
const TOKEN_CACHE_MARGIN_SEC = 120; // re-mint a bit before actual expiry
const tokenCache = new Map(); // `${asset_id}:${ip}` -> { token, thumbnail_url, expiresAt }
function tokenCacheKey(assetId, ip) {
return `${assetId}:${ip}`;
}
function getCachedToken(assetId, ip) {
const key = tokenCacheKey(assetId, ip);
const entry = tokenCache.get(key);
if (!entry) return null;
if (Date.now() >= entry.expiresAt) {
tokenCache.delete(key);
return null;
}
return entry;
}
function setCachedToken(assetId, ip, token, thumbnail_url) {
tokenCache.set(tokenCacheKey(assetId, ip), {
token,
thumbnail_url,
expiresAt: Date.now() + (TOKEN_TTL_SEC - TOKEN_CACHE_MARGIN_SEC) * 1000,
});
}
// Collapses IPv4-mapped IPv6 ("::ffff:127.0.0.1") and IPv6 loopback ("::1")
// down to a single canonical form. Without this, a token minted off one
// "localhost" connection (IPv4) fails IP-pin verification on a sibling
// request that happened to land on the other stack (IPv6) — browsers race
// both when resolving "localhost", so mint and stream requests can land on
// different stacks even from the same client.
function normalizeIp(ip) {
if (ip === "::1") return "127.0.0.1";
if (ip.startsWith("::ffff:")) return ip.slice(7);
return ip;
}
function resolveIp(req) {
const forwarded = req.headers["x-forwarded-for"];
const raw = forwarded ? forwarded.split(",")[0].trim() : (req.ip ?? req.socket?.remoteAddress ?? "unknown");
return normalizeIp(raw);
}
// ─── signMediaToken ─────────────────────────────────────────────────────────────
//
// Low-level JWT signer shared by every media-token caller (asset previews here,
// avatar resolution in utils/resolveAvatar.util.js) so the secret-resolution +
// payload shape only lives in one place. `asset_id`/`user_id`/`ip` are optional —
// omitting `ip` means the stream endpoint's IP-pin check is skipped for that token.
function signMediaToken({ asset_id, storage_key, file_type, mime_type, user_id, ip, expiresIn = TOKEN_TTL_SEC }) {
return jwt.sign(
{ asset_id, user_id, storage_key, file_type, mime_type, ip },
MEDIA_SECRET,
{ expiresIn }
);
}
function signToken(asset, userId, ip) {
return signMediaToken({
asset_id: asset.asset_id,
storage_key: asset.storage_key,
file_type: asset.file_type,
mime_type: asset.mime_type,
user_id: userId,
ip,
});
}
// ─── issueForAsset ─────────────────────────────────────────────────────────────
//
// Returns { token, thumbnail_url } for an S3 asset, minting + caching on first
// call and serving from tokenCache on subsequent calls within the TTL margin.
// `asset` needs: asset_id, storage_key, file_type, mime_type, thumbnail_storage_key.
//
async function issueForAsset(asset, userId, ip) {
const cached = getCachedToken(asset.asset_id, ip);
if (cached) return { token: cached.token, thumbnail_url: cached.thumbnail_url };
const token = signToken(asset, userId, ip);
let thumbnail_url = null;
if (asset.thumbnail_storage_key) {
try {
thumbnail_url = await s3.getPublicUrl(asset.thumbnail_storage_key);
} catch {
// Non-fatal — thumbnail is cosmetic
}
}
setCachedToken(asset.asset_id, ip, token, thumbnail_url);
return { token, thumbnail_url };
}
module.exports = {
TOKEN_TTL_SEC,
SUPPORTED_TYPES,
resolveIp,
issueForAsset,
signMediaToken,
};
+120
View File
@@ -0,0 +1,120 @@
/***********************************************************************************************************************************************************************
* File Name: payment.service.js
* Type of Program: Service
* Description: Provider-agnostic payment orchestration.
* - Loads payment policy per plan (promo rules, refund policy, allowed providers)
* - Evaluates promo codes server-side (type: flat | percent)
* - Calculates refund eligibility window (unit: minutes | hours | days)
* - Delegates create/capture/refund to the provider registry
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 29, 2026
***********************************************************************************************************************************************************************/
'use strict';
const registry = require('../providers/registry');
const mdl_PaymentPolicies = require('../models/tiers/payment_policies.mdl');
const mdl_Payments = require('../models/tiers/payments.mdl');
// ─── Defaults ─────────────────────────────────────────────────────────────────
const DEFAULT_REFUND_POLICY = {
allowed: true,
window_value: 5,
window_unit: 'minutes',
reason_required: false,
};
const UNIT_MS = { minutes: 60_000, hours: 3_600_000, days: 86_400_000 };
// ─── Policy ───────────────────────────────────────────────────────────────────
async function getPolicyForPlan(plan_id) {
return mdl_PaymentPolicies.findOne({ where: { plan_id } });
}
// ─── Refund window ────────────────────────────────────────────────────────────
function getRefundWindowMs(policy) {
const rp = policy?.refund_policy ?? DEFAULT_REFUND_POLICY;
const { window_value = 5, window_unit = 'minutes' } = rp;
return Number(window_value) * (UNIT_MS[window_unit] ?? 60_000);
}
function isRefundAllowed(policy) {
return policy?.refund_policy?.allowed ?? DEFAULT_REFUND_POLICY.allowed;
}
// ─── Promo evaluation ─────────────────────────────────────────────────────────
// effectivePrice — pass the localized price when charging in a non-base currency
// so discounts are computed against the actual amount being charged.
async function evaluatePromo(policy, plan, rawCode, effectivePrice = null) {
const code = rawCode?.trim?.().toUpperCase?.() ?? null;
if (!code) return { valid: false, reason: 'No promo code provided.' };
const rules = policy?.promo_rules ?? [];
const rule = rules.find((r) => r.code?.toUpperCase() === code);
if (!rule) return { valid: false, reason: 'Invalid promo code.' };
if (rule.expires_at && new Date(rule.expires_at) < new Date())
return { valid: false, reason: 'Promo code has expired.' };
// Count how many completed payments used this code for this plan
if (rule.max_uses != null) {
const uses = await mdl_Payments.count({
where: { promo_code: code, plan_id: plan.plan_id },
});
if (uses >= Number(rule.max_uses))
return { valid: false, reason: 'Promo code has reached its usage limit.' };
}
const subtotal = effectivePrice !== null ? Number(effectivePrice) : Number(plan.price);
if (rule.min_amount != null && subtotal < Number(rule.min_amount))
return { valid: false, reason: `This promo code requires a minimum purchase of ${rule.min_amount}.` };
let discount;
if (rule.type === 'flat') {
discount = Math.min(Number(rule.value), subtotal);
} else if (rule.type === 'percent') {
const pct = Math.min(Number(rule.value), 100);
const raw = (subtotal * pct) / 100;
discount = rule.max_discount != null ? Math.min(raw, Number(rule.max_discount)) : raw;
discount = Math.min(discount, subtotal);
} else {
return { valid: false, reason: 'Unsupported promo type.' };
}
return {
valid: true,
code,
type: rule.type,
value: rule.value,
discount: Number(discount.toFixed(2)),
reason: null,
};
}
// ─── Provider delegation ──────────────────────────────────────────────────────
function createOrder(provider, opts) {
return registry.get(provider).createOrder(opts);
}
function captureOrder(provider, orderId) {
return registry.get(provider).captureOrder(orderId);
}
function refundCapture(provider, captureId, amount, currency) {
return registry.get(provider).refundCapture(captureId, amount, currency);
}
module.exports = {
getPolicyForPlan,
getRefundWindowMs,
isRefundAllowed,
evaluatePromo,
createOrder,
captureOrder,
refundCapture,
};
+120
View File
@@ -0,0 +1,120 @@
/***********************************************************************************************************************************************************************
* File Name: planAccess.service.js
* Type of Program: Service
* Description: Force-revokes access for every currently active subscriber of
* a Tier Plan (no refund). Always fired automatically as part of
* archivePlan/bulkArchivePlans (archiving a plan revokes its
* subscribers' access, full stop — no opt-in) and again as part
* of permanentlyDeletePlan/bulkPermanentlyDeletePlans, in case a
* plan was archived before this existed and still has active
* subscribers when it's finally deleted for good.
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Aug. 4, 2026
***********************************************************************************************************************************************************************/
'use strict';
const mdl_UserTiers = require('../models/tiers/user_tiers.mdl');
const mdl_Users = require('../models/users/users.mdl');
const UserNotification = require('../models/notifications/user_notification.mdl');
const { NOTIFICATION_REGISTRY } = require('../data/notifications.data');
const { sendEmail } = require('./email.service');
const { fmtDate } = require('../utils/datetime.util');
// Revokes every active user_tiers row tied to any of `plans`, replicating
// revokeTier's per-user "auto-downgrade to Free if no other active tier
// remains" rule (controllers/admin/tiers.controller.js) — a user can hold
// more than one concurrently-active plan, so this can't be a blanket status
// update. Batched into flat, count-independent queries (no per-user or
// per-plan loop hitting the DB) so this scales to any number of affected
// plans/subscribers in a fixed number of round trips.
async function revokePlanSubscriberAccessBulk(plans, revokedByUserId) {
if (!plans.length) return { revoked_user_count: 0 };
const planIds = plans.map((p) => p.plan_id);
const labelByPlanId = new Map(plans.map((p) => [String(p.plan_id), p.label]));
const activeRows = await mdl_UserTiers.findAll({
where: { plan_id: planIds, status: 'active' },
attributes: ['tier_id', 'user_id', 'plan_id'],
});
if (!activeRows.length) return { revoked_user_count: 0 };
const tierIds = activeRows.map((r) => r.tier_id);
const userIds = [...new Set(activeRows.map((r) => String(r.user_id)))];
const now = new Date();
await mdl_UserTiers.update(
{ status: 'revoked', revoked_by: revokedByUserId, revoked_at: now },
{ where: { tier_id: tierIds } },
);
// One grouped query replaces a per-user COUNT: finds everyone who still
// holds another active tier after the revoke above.
const stillActiveRows = await mdl_UserTiers.findAll({
where: { user_id: userIds, status: 'active' },
attributes: ['user_id'],
group: ['user_id'],
});
const stillActiveUserIds = new Set(stillActiveRows.map((r) => String(r.user_id)));
const usersToDowngrade = userIds.filter((user_id) => !stillActiveUserIds.has(user_id));
if (usersToDowngrade.length) {
await mdl_UserTiers.bulkCreate(
usersToDowngrade.map((user_id) => ({
user_id,
tier: 'free',
status: 'active',
starts_at: now,
expires_at: null,
granted_by: revokedByUserId,
notes: 'Auto-downgrade after plan access was force-revoked.',
})),
);
}
// One row per (user, plan) relationship revoked — a user in two of the
// selected plans gets two notices/emails, one per plan label.
const revokedPairs = [...new Map(activeRows.map((r) => [`${r.user_id}:${r.plan_id}`, r])).values()];
try {
const notifications = revokedPairs.map((r) => {
const notify = NOTIFICATION_REGISTRY.tier_plan_access_revoked.build({
label: labelByPlanId.get(String(r.plan_id)),
planId: r.plan_id,
});
return { user_id: String(r.user_id), ...notify, seen: false, createdAt: now, updatedAt: now };
});
await UserNotification.bulkCreate(notifications, { validate: false });
} catch (notifyErr) {
console.error('[PLAN ACCESS REVOKE][NOTIFY]', notifyErr);
}
try {
const users = await mdl_Users.findAll({
where: { user_id: userIds },
attributes: ['user_id', 'email', 'personal_info'],
});
const usersById = new Map(users.map((u) => [String(u.user_id), u]));
const dateStr = fmtDate(now);
for (const r of revokedPairs) {
const u = usersById.get(String(r.user_id));
if (!u) continue;
const name = u.personal_info?.name?.full_name ?? 'there';
sendEmail({
to: u.email,
type: 'TIER_ACCESS_REVOKED',
data: { name, label: labelByPlanId.get(String(r.plan_id)), date: dateStr },
}).catch((emailErr) => console.error('[PLAN ACCESS REVOKE][EMAIL]', emailErr));
}
} catch (emailBatchErr) {
console.error('[PLAN ACCESS REVOKE][EMAIL BATCH]', emailBatchErr);
}
return { revoked_user_count: userIds.length };
}
async function revokePlanSubscriberAccess(plan, revokedByUserId) {
return revokePlanSubscriberAccessBulk([plan], revokedByUserId);
}
module.exports = { revokePlanSubscriberAccess, revokePlanSubscriberAccessBulk };
@@ -0,0 +1,40 @@
/***********************************************************************************************************************************************************************
* File Name: playback_position.service.js
* Type of Program: Service
* Description: Last-known video/audio playback position per (user, lesson, block) — entirely
* decoupled from CompletionRequirement, tracked for ANY video/audio block regardless
* of whether the lesson has a watch-type completion requirement configured. Powers
* "resume where I left off" only; no anti-cheat/wall-clock validation here since
* there's nothing being gated — see completion_requirements.service.js for that.
*
* recordPlaybackPosition — last-write-wins upsert (not a ratcheted max — a deliberate rewind
* should resume there, not snap back to a prior high-water mark).
* getPlaybackPositions — { [block_id]: percent } for every block tracked on a lesson.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jul. 17, 2026
***********************************************************************************************************************************************************************/
'use strict';
const MediaPlaybackPosition = require('../models/courses/media_playback_position.mdl');
async function recordPlaybackPosition(userId, { lessonId, blockId, percent }) {
if (!blockId) return;
await MediaPlaybackPosition.upsert({
user_id: userId,
lesson_id: lessonId,
block_id: blockId,
percent: Math.min(100, Math.max(0, Math.round(percent))),
}, { conflictFields: ['user_id', 'lesson_id', 'block_id'] });
}
async function getPlaybackPositions(userId, lessonId) {
const rows = await MediaPlaybackPosition.findAll({
where: { user_id: userId, lesson_id: lessonId },
attributes: ['block_id', 'percent'],
});
return Object.fromEntries(rows.map((r) => [r.block_id, r.percent]));
}
module.exports = { recordPlaybackPosition, getPlaybackPositions };
@@ -0,0 +1,165 @@
/***********************************************************************************************************************************************************************
* File Name: reading_progress.service.js
* Type of Program: Service
* Description: UPSERT-based progress tracking for unit and lesson reading activity.
*
* upsertLessonRead — main entry point, called when a user reads a lesson.
* UPSERTs the lesson row in lesson_reading_progress, then derives and
* UPSERTs the parent unit row in unit_reading_progress.
* Both writes run in a single transaction.
*
* UPSERT keys:
* lesson_reading_progress → (user_id, lesson_id)
* unit_reading_progress → (user_id, unit_id)
*
* Derivation rule:
* unit → completed when ALL non-deleted lessons under it have a completed row for this user
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 26, 2026
***********************************************************************************************************************************************************************/
'use strict';
const sequelize = require('../config/db.config');
const LessonReadingProgress = require('../models/courses/lesson_reading_progress.mdl');
const UnitReadingProgress = require('../models/courses/unit_reading_progress.mdl');
const UnitLesson = require('../models/courses/unit_lessons.mdl');
const Lesson = require('../models/courses/lessons.mdl');
// ─── Core UPSERTs ────────────────────────────────────────────────────────────
async function upsertLessonProgress({ userId, courseId, unitId, lessonId, status }, t) {
const now = new Date();
const [record] = await LessonReadingProgress.upsert(
{
user_id: userId,
course_id: courseId ?? null, // NULL when read standalone
unit_id: unitId ?? null, // NULL when read standalone
lesson_id: lessonId,
status,
completed_at: status === 'completed' ? now : null,
last_accessed_at: now,
createdBy: userId,
updatedBy: userId,
},
{
conflictFields: ['user_id', 'lesson_id'],
returning: true,
transaction: t,
}
);
return record;
}
async function upsertUnitProgress({ userId, courseId, unitId, status }, t) {
const now = new Date();
const [record] = await UnitReadingProgress.upsert(
{
user_id: userId,
course_id: courseId ?? null, // NULL when read standalone
unit_id: unitId,
status,
completed_at: status === 'completed' ? now : null,
last_accessed_at: now,
createdBy: userId,
updatedBy: userId,
},
{
conflictFields: ['user_id', 'unit_id'],
returning: true,
transaction: t,
}
);
return record;
}
// ─── Derivation helper ────────────────────────────────────────────────────────
// Unit is completed when every non-deleted lesson attached to it (via unit_lessons)
// has a completed row for this user.
async function deriveUnitStatus(userId, unitId, t) {
const links = await UnitLesson.findAll({
where: { unit_id: unitId },
attributes: ['lesson_id'],
transaction: t,
});
if (!links.length) return 'in_progress';
const lessons = await Lesson.findAll({
where: { lesson_id: links.map(l => l.lesson_id) },
attributes: ['lesson_id'],
transaction: t,
});
if (!lessons.length) return 'in_progress';
const lessonIds = lessons.map(l => l.lesson_id);
const completedCount = await LessonReadingProgress.count({
where: {
user_id: userId,
lesson_id: lessonIds,
status: 'completed',
},
transaction: t,
});
return completedCount === lessons.length ? 'completed' : 'in_progress';
}
// ─── Main entry point ─────────────────────────────────────────────────────────
/**
* Called when a user reads (or finishes reading) a lesson.
* Writes two rows in one transaction: lesson → unit.
*
* @param {number} userId
* @param {Object} payload
* @param {number} payload.courseId — course BIGINT PK
* @param {number} payload.unitId — unit BIGINT PK
* @param {number} payload.lessonId — lesson BIGINT PK
* @param {string} payload.lessonStatus — 'in_progress' | 'completed'
* @returns {{ lesson, unit }} — status snapshot for each level
*/
async function upsertLessonRead(userId, { courseId = null, unitId = null, lessonId, lessonStatus = 'in_progress' }) {
const t = await sequelize.transaction();
try {
// 1. Lesson
await upsertLessonProgress({
userId,
courseId,
unitId,
lessonId,
status: lessonStatus,
}, t);
// 2. Unit — derived from all sibling lessons (skipped for standalone lesson reads)
let unitStatus = null;
if (unitId) {
unitStatus = await deriveUnitStatus(userId, unitId, t);
await upsertUnitProgress({
userId,
courseId,
unitId,
status: unitStatus,
}, t);
}
await t.commit();
return {
lesson: { lesson_id: lessonId, status: lessonStatus },
unit: unitId ? { unit_id: unitId, status: unitStatus } : null,
};
} catch (err) {
await t.rollback();
throw err;
}
}
// ─── Exports ──────────────────────────────────────────────────────────────────
module.exports = {
upsertLessonRead,
upsertLessonProgress,
upsertUnitProgress,
deriveUnitStatus,
};
+450
View File
@@ -0,0 +1,450 @@
// services/s3.service.js
//
// S3-compatible storage service targeting Garage (self-hosted).
// Exposes the same interface as chibisafe.service.js:
// uploadFile({ buffer, originalname, mimetype, ownerType }) → { url, uuid }
// deleteFile(key)
//
// Required .env vars:
// S3_ENDPOINT – address this backend uses for uploads/deletes.
// http://127.0.0.1:3900 when co-located with Garage.
// Otherwise (backend runs on a different machine than
// Garage) point this at a network-reachable address that
// terminates on Garage — e.g. the same tunneled domain as
// S3_PUBLIC_URL, since garage-anon-proxy re-signs any
// non-presigned request with real credentials before
// forwarding. Never leave this blank.
// S3_REGION – garage
// S3_ACCESS_KEY
// S3_SECRET_KEY
// S3_BUCKET – your-bucket-name
// S3_PUBLIC_URL – https://cdn.yourdomain.com (used for browser-facing URLs
// whenever S3_ENDPOINT isn't reachable from this machine —
// see resolvePublicHost() below)
const {
S3Client, DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, HeadBucketCommand, PutObjectCommand,
CreateMultipartUploadCommand, UploadPartCommand, CompleteMultipartUploadCommand, AbortMultipartUploadCommand,
} = require("@aws-sdk/client-s3");
const { Upload } = require("@aws-sdk/lib-storage");
const { getSignedUrl } = require("@aws-sdk/s3-request-presigner");
const crypto = require("crypto");
const path = require("path");
// ─── Clients ──────────────────────────────────────────────────────────────────
const credentials = {
accessKeyId: process.env.S3_ACCESS_KEY,
secretAccessKey: process.env.S3_SECRET_KEY,
};
// responseChecksumValidation: "WHEN_REQUIRED" — the AWS SDK v3 defaults to
// "WHEN_SUPPORTED", which validates any response carrying an x-amz-checksum-*
// header. Garage sends x-amz-checksum-crc32 on GetObject but its value doesn't
// match what the SDK recomputes over the body, so every direct GetObjectCommand
// (e.g. getObjectStream() below, used by the document-to-Markdown converter)
// threw "Checksum mismatch" even though the bytes themselves were fine.
// WHEN_REQUIRED skips validation unless the operation mandates it (GetObject
// never does) — safe here since Garage isn't computing/verifying per AWS spec
// anyway.
//
// requestChecksumCalculation: "WHEN_REQUIRED" — same fix, request side. SDK
// v3's default "WHEN_SUPPORTED" makes presignUpload()'s PutObjectCommand carry
// a signed x-amz-checksum-crc32 computed over an empty/phantom body (there's
// no real body yet at presign time), so Garage rejects the browser's actual
// PUT once real bytes arrive with "InvalidDigest ... expected Crc32([0,0,0,0])".
// PutObject doesn't require a checksum, so WHEN_REQUIRED just omits it.
const CHECKSUM_CONFIG = { responseChecksumValidation: "WHEN_REQUIRED", requestChecksumCalculation: "WHEN_REQUIRED" };
// Internal client — uploads, deletes, direct streams from the server itself.
// Always targets S3_ENDPOINT. When this backend is co-located with Garage,
// that's a local address; when it isn't, S3_ENDPOINT must instead be a
// network-reachable address that reaches Garage (e.g. the tunneled proxy
// domain) — see the .env docs above. Do not assume co-location here.
const s3 = new S3Client({
endpoint: process.env.S3_ENDPOINT,
region: process.env.S3_REGION || "garage",
credentials,
forcePathStyle: true,
...CHECKSUM_CONFIG,
});
const DEFAULT_BUCKET = process.env.S3_BUCKET;
// ─── Public host resolution ───────────────────────────────────────────────────
//
// URLs handed to browsers (file_url, presigned GET links) need a host reachable
// from wherever the client sits. S3_ENDPOINT (e.g. 127.0.0.1:3900) only works
// from the machine running Garage itself; S3_PUBLIC_URL is the externally
// reachable address (tunnel/CDN/domain).
//
// This used to probe S3_ENDPOINT from the server and prefer it when reachable,
// but that measures the wrong machine: Garage is always co-located with this
// backend (see docker-compose.yml), so the probe was *always* reachable from
// here and always resolved to 127.0.0.1 — even for browsers on other machines,
// which then failed to connect to it. There is no way for the server to
// determine what's reachable from the client by probing itself, so just trust
// config: prefer S3_PUBLIC_URL whenever it's set, and only fall back to
// S3_ENDPOINT for pure single-machine dev setups with no public URL at all.
function resolvePublicHost() {
return process.env.S3_PUBLIC_URL || process.env.S3_ENDPOINT || "";
}
// Public client — built against whichever host resolvePublicHost() picks.
function getPublicClient() {
const endpoint = resolvePublicHost();
return new S3Client({
endpoint,
region: process.env.S3_REGION || "garage",
credentials,
forcePathStyle: true,
...CHECKSUM_CONFIG,
});
}
// ─── Key prefix map ───────────────────────────────────────────────────────────
//
// ownerType mirrors what the controller already passes to chibisafe
// (image, video, audio, document, avatar, thumbnail).
// Maps to folder prefixes inside the bucket.
//
const PREFIX_MAP = {
image: "images",
video: "videos",
audio: "audios",
document: "documents",
avatar: "avatars",
thumbnail: "thumbnails",
badge: "badges",
};
function resolvePrefix(ownerType = "image") {
return PREFIX_MAP[ownerType] || "others";
}
function resolveExtension(originalname = "") {
return path.extname(originalname).replace(".", "").toLowerCase() || "bin";
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
// Builds the S3 object key: {prefix}/{uuid}.{ext}
// e.g. images/3f2a1b4c-uuid.jpg
function buildKey(originalname, ownerType) {
const prefix = resolvePrefix(ownerType);
const ext = resolveExtension(originalname);
const uuid = crypto.randomUUID();
return `${prefix}/${uuid}.${ext}`;
}
// Builds the public URL for a stored object.
// Garage path-style: {host}/{bucket}/{key}
// e.g. https://cdn.yourdomain.com/your-bucket/images/uuid.jpg
async function buildPublicUrl(key, bucket = DEFAULT_BUCKET) {
const host = (await resolvePublicHost()).replace(/\/$/, "");
return `${host}/${bucket}/${key}`;
}
// ─── uploadFile ───────────────────────────────────────────────────────────────
//
// Matches chibisafe.service.js interface exactly.
// input: { buffer, originalname, mimetype, ownerType? }
// output: { url, uuid } ← uuid = the S3 key, stored as storage_key in DB
//
// onProgress?: ({ loaded, total }) => void — real bytes-sent-to-Garage events,
// straight from the AWS SDK's own httpUploadProgress, not simulated. A plain
// PutObjectCommand (what this used to be) has no progress API at all; Upload
// auto-splits into multipart above its ~5MB partSize threshold, so large
// files (the case this actually matters for) report genuine incremental
// progress per part, while small ones just jump from 0 to 100 immediately.
async function uploadFile({ buffer, originalname, mimetype, ownerType = "image", onProgress }) {
if (!buffer) {
throw Object.assign(new Error("File buffer is required for S3 uploads."), { status: 400 });
}
const bucket = DEFAULT_BUCKET;
const key = buildKey(originalname, ownerType);
const uploader = new Upload({
client: s3,
params: { Bucket: bucket, Key: key, Body: buffer, ContentType: mimetype },
});
if (onProgress) {
uploader.on("httpUploadProgress", (progress) => {
onProgress({ loaded: progress.loaded ?? 0, total: progress.total ?? buffer.length });
});
}
await uploader.done();
return {
url: await buildPublicUrl(key, bucket),
uuid: key, // used as storage_key in DB — mirrors chibi_uuid usage
};
}
// ─── uploadStream ─────────────────────────────────────────────────────────────
//
// Same as uploadFile(), but Body is a Node Readable stream instead of a
// Buffer — used by assetTranscode.service.js to push a remuxed video back to
// storage straight off local disk, without ever holding the whole (possibly
// multi-GB) file in this process's memory. Upload (lib-storage) auto-chunks
// a stream body into multipart the same way it does a large Buffer.
//
// input: { stream, originalname, mimetype, ownerType? }
// output: { url, uuid } ← uuid = the S3 key, stored as storage_key in DB
//
async function uploadStream({ stream, originalname, mimetype, ownerType = "video" }) {
if (!stream) {
throw Object.assign(new Error("A readable stream is required for S3 uploads."), { status: 400 });
}
const bucket = DEFAULT_BUCKET;
const key = buildKey(originalname, ownerType);
const uploader = new Upload({
client: s3,
params: { Bucket: bucket, Key: key, Body: stream, ContentType: mimetype },
});
await uploader.done();
return {
url: await buildPublicUrl(key, bucket),
uuid: key,
};
}
// ─── deleteFile ───────────────────────────────────────────────────────────────
//
// Matches chibisafe.service.js interface.
// input: key — the value stored in storage_key column (e.g. "images/uuid.jpg")
//
async function deleteFile(key) {
if (!key) return;
await s3.send(new DeleteObjectCommand({
Bucket: DEFAULT_BUCKET,
Key: key,
}));
}
// ─── getSignedDownloadUrl ─────────────────────────────────────────────────────
//
// Generates a short-lived pre-signed GET URL against S3_ENDPOINT (internal —
// this backend is always co-located with Garage, see docker-compose.yml).
// For server-side reads only (e.g. media.controller.js streamAsset piping
// bytes to the browser itself) — never hand this URL to a browser directly.
//
// Must NOT be signed against S3_PUBLIC_URL when Garage is in use: that host is
// fronted by garage-anon-proxy, which re-signs every request itself (header-based
// SigV4, real credentials) regardless of any query-string signature already
// present. A presigned URL arriving there collides with the proxy's own
// signature and Garage rejects the request (400 "Header `x-amz-date` should
// be signed").
//
// Uses the internal `s3` client (always S3_ENDPOINT) when available; falls back
// to getPublicClient() for external S3-compatible services without Garage.
//
async function getSignedDownloadUrl(key, expiresInSeconds = 3600) {
const client = process.env.S3_ENDPOINT ? s3 : getPublicClient();
return getSignedUrl(
client,
new GetObjectCommand({
Bucket: DEFAULT_BUCKET,
Key: key,
}),
{
expiresIn: expiresInSeconds,
}
);
}
// ─── getPublicUrl ─────────────────────────────────────────────────────────────
//
// Browser-facing pre-signed GET URL, signed against S3_PUBLIC_URL. Safe again
// now that garage-anon-proxy detects a valid query-string SigV4 signature and
// forwards it unmodified instead of re-signing on top of it (see
// isPresignedRequest() in garage-anon-proxy/server.js) — that mismatch used to
// produce Garage's 400 "Header `x-amz-date` should be signed". Use this
// wherever a URL is handed directly to the browser (e.g. thumbnail previews).
//
// expiresInSeconds defaults to 4h to outlive the longest cache window a caller
// hands this URL out under (client media token TTL — see media.controller.js),
// so a cached thumbnail_url never outlives its own signature.
//
async function getPublicUrl(key, bucket = DEFAULT_BUCKET, expiresInSeconds = 4 * 60 * 60) {
const client = getPublicClient();
return getSignedUrl(
client,
new GetObjectCommand({ Bucket: bucket, Key: key }),
{ expiresIn: expiresInSeconds }
);
}
// ─── getObjectStream ────────────────────────────────────────────────────────
//
// Fetches an S3 object and returns its readable stream + metadata, for use
// in a server-side download proxy (Content-Disposition: attachment).
//
// input: key — the storage_key (e.g. "images/uuid.jpg")
// output: { stream, contentType, contentLength }
//
async function getObjectStream(key) {
if (!key) {
throw Object.assign(new Error("Storage key is required."), { status: 400 });
}
const result = await s3.send(new GetObjectCommand({
Bucket: DEFAULT_BUCKET,
Key: key,
}));
return {
stream: result.Body, // Node.js Readable stream
contentType: result.ContentType,
contentLength: result.ContentLength,
};
}
async function ping() {
const client = await getPublicClient();
await client.send(new HeadBucketCommand({ Bucket: DEFAULT_BUCKET }));
}
// S3's own hard ceiling for a single (non-multipart) PUT — files above this
// need real multipart upload instead (see presignUpload() below).
const MULTIPART_THRESHOLD = 5 * 1024 ** 3; // 5GB
// Fixed part size for multipart uploads — comfortably above S3's 5MB-per-part
// minimum, and keeps the part count reasonable even at the largest files this
// app expects (15GB / 50MB = 300 parts, well under S3's 10,000-part ceiling).
// No adaptive sizing needed at this scale.
const PART_SIZE = 50 * 1024 ** 2; // 50MB
// ─── presignUpload ────────────────────────────────────────────────────────────
//
// Presigned upload URL(s) for browser-direct uploads — the browser PUTs the
// file's bytes straight to storage; this backend never buffers them. Signed
// against the same public-facing host as getPublicUrl() (browsers can't
// reach S3_ENDPOINT when that's an internal-only address), and deliberately
// does NOT pin Content-Type on any signed command — the browser's actual
// Content-Type header (from the File object) would have to match whatever
// was signed exactly, and leaving it unsigned avoids that fragility.
//
// input: originalname, ownerType — same as uploadFile(), used to build the
// same {prefix}/{uuid}.{ext} key convention.
// size — total file size in bytes, decides single-PUT vs multipart.
// output: { key, uploadUrl } (size <= 5GB)
// or { key, multipart: true, uploadId, partSize, parts } (size > 5GB)
// parts: [{ partNumber, uploadUrl }, ...], one presigned UploadPart
// URL per part, all generated upfront in this one call.
//
async function presignUpload(originalname, ownerType = "image", size = 0) {
const key = buildKey(originalname, ownerType);
const client = getPublicClient();
if (size <= MULTIPART_THRESHOLD) {
// Generous expiry — even a file near the 5GB ceiling can take a long
// time to PUT on a slow connection, and the signature must still be
// valid when the browser actually gets around to sending it.
const uploadUrl = await getSignedUrl(
client,
new PutObjectCommand({ Bucket: DEFAULT_BUCKET, Key: key }),
{ expiresIn: 4 * 60 * 60 } // 4h
);
return { key, uploadUrl };
}
// Above the single-PUT ceiling — the backend initiates the multipart
// session itself with real credentials (a lightweight control-plane call,
// no file bytes involved); only the individual part uploads, which do
// carry real bytes, get presigned for the browser.
const { UploadId: uploadId } = await s3.send(new CreateMultipartUploadCommand({
Bucket: DEFAULT_BUCKET,
Key: key,
}));
const partCount = Math.ceil(size / PART_SIZE);
const parts = [];
for (let partNumber = 1; partNumber <= partCount; partNumber++) {
const uploadUrl = await getSignedUrl(
client,
new UploadPartCommand({ Bucket: DEFAULT_BUCKET, Key: key, UploadId: uploadId, PartNumber: partNumber }),
{ expiresIn: 24 * 60 * 60 } // 24h — a 15GB upload can genuinely take a while on a slow connection
);
parts.push({ partNumber, uploadUrl });
}
return { key, multipart: true, uploadId, partSize: PART_SIZE, parts };
}
// ─── completeMultipartUpload ──────────────────────────────────────────────────
//
// Finishes a multipart upload once the browser has PUT every part directly
// (see presignUpload() above). Parts must carry the ETag each part's own PUT
// response returned — order doesn't matter here, they're sorted by
// PartNumber before submitting. Real credentials, not presigned: this is a
// small control-plane call, no file bytes involved.
//
// input: key, uploadId, parts — [{ partNumber, etag }, ...]
//
async function completeMultipartUpload(key, uploadId, parts) {
await s3.send(new CompleteMultipartUploadCommand({
Bucket: DEFAULT_BUCKET,
Key: key,
UploadId: uploadId,
MultipartUpload: {
Parts: parts
.slice()
.sort((a, b) => a.partNumber - b.partNumber)
.map(({ partNumber, etag }) => ({ PartNumber: partNumber, ETag: etag })),
},
}));
}
// ─── abortMultipartUpload ──────────────────────────────────────────────────────
//
// Cancels an in-progress multipart upload (a part permanently failed after
// retries, or the browser gave up) so it doesn't linger as orphaned storage
// forever. Real credentials, called server-side — this only ever happens on
// failure, no need for browser-direct access to it.
//
async function abortMultipartUpload(key, uploadId) {
await s3.send(new AbortMultipartUploadCommand({
Bucket: DEFAULT_BUCKET,
Key: key,
UploadId: uploadId,
}));
}
// ─── getFileMetadata ──────────────────────────────────────────────────────────
//
// Reads back what the browser actually uploaded via presignUpload() — used by
// the finalize step in place of multer's req.file (size, mimetype), since no
// buffer ever passes through this backend to read those from directly. ETag
// doubles as the object's checksum: for a single (non-multipart) unencrypted
// PutObjectCommand, S3-compatible ETag is exactly the MD5 of the body.
//
// input: key
// output: { size, mimetype, checksum } — checksum is the ETag with its
// surrounding quotes stripped, or null if the object has no ETag.
//
async function getFileMetadata(key) {
const result = await s3.send(new HeadObjectCommand({
Bucket: DEFAULT_BUCKET,
Key: key,
}));
return {
size: result.ContentLength ?? null,
mimetype: result.ContentType ?? null,
checksum: result.ETag ? result.ETag.replace(/"/g, "") : null,
};
}
module.exports = {
uploadFile, uploadStream, deleteFile, getSignedDownloadUrl, getPublicUrl, getObjectStream,
presignUpload, completeMultipartUpload, abortMultipartUpload,
getFileMetadata, buildPublicUrl, ping,
};
@@ -0,0 +1,313 @@
/***********************************************************************************************************************************************************************
* File Name: task_reading_progress_sync.service.js
* Type of Program: Service
* Description: Bridges course/unit/lesson completion (course_reading_progress, plus the
* standalone lesson_reading_progress/unit_reading_progress system-of-record
* used when a lesson/unit has no parent course — see the junction revamp) to
* Task requirements of type read_course/read_unit/read_lesson, in both directions:
*
* hydrateReadTaskProgress(userId, requirements)
* — given a list of TaskRequirement rows (typically when a task/task list is newly
* assigned), backfills task_progress for any that reference content the user has
* already completed reading.
*
* syncCompletedEntitiesToTaskProgress(userId, { lessonUuid, unitUuid, courseUuid })
* — given the UUIDs of course/unit/lesson entities that just reached 'completed'
* (from any trigger — scroll-to-bottom, watch_percent threshold, manual_complete,
* pass_quiz, assessment pass), finds matching TaskRequirement rows the user is
* assigned to and marks them done, then reports any task whose read-only
* requirements are now ALL satisfied (eligible for "auto turned-in" display).
* Called from every completion.service.js entry point — NOT just the legacy
* lesson-progress endpoint — so a unit/course completing via pass_quiz/manual_complete
* alone (no lesson ever read) still satisfies read_unit/read_course task requirements.
***********************************************************************************************************************************************************************/
'use strict';
const { Op } = require('sequelize');
const CourseReadingProgress = require('../models/courses/course_reading_progress.mdl');
const LessonReadingProgress = require('../models/courses/lesson_reading_progress.mdl');
const UnitReadingProgress = require('../models/courses/unit_reading_progress.mdl');
const Lesson = require('../models/courses/lessons.mdl');
const Unit = require('../models/courses/units.mdl');
const { TaskProgress } = require('../models/task/task_progress.mdl');
const { Task, TaskRequirement, TaskListGroup } = require('../models/task/task.mdl');
const { mdl_UserGroupMembers } = require('../models/users/user_groups.mdl');
const READ_TYPE_TO_PROGRESS_TYPE = {
read_course: 'course',
read_unit: 'unit',
read_lesson: 'lesson',
};
const READ_REQUIREMENT_TYPES = Object.keys(READ_TYPE_TO_PROGRESS_TYPE);
function readAttr(row, attr) {
if (!row) return undefined;
if (typeof row.get === 'function') return row.get(attr);
return row[attr];
}
function normalizeRequirement(row) {
const type = readAttr(row, 'type');
if (!READ_REQUIREMENT_TYPES.includes(type)) return null;
const referenceId = readAttr(row, 'reference_id');
if (!referenceId) return null;
return {
task_id: readAttr(row, 'task_id'),
requirement_id: readAttr(row, 'requirement_id'),
reference_id: referenceId,
type,
};
}
// ─── Standalone (no parent course) lesson/unit completions ───────────────────
// lesson_reading_progress/unit_reading_progress are keyed by numeric PK, but
// TaskRequirement.reference_id is the lesson/unit UUID — resolve UUID -> PK
// first, then look up completion, then map back to the `type:uuid` key shape
// hydrateReadTaskProgress's caller expects. read_course has no standalone
// table (a course always has a courseId by definition), so it's skipped.
async function getStandaloneCompletedReading(userId, referencesByProgressType, transaction) {
const entries = [];
const lessonUuids = [...(referencesByProgressType.lesson ?? [])];
if (lessonUuids.length) {
const lessons = await Lesson.findAll({
where: { uuid: { [Op.in]: lessonUuids } },
attributes: ['lesson_id', 'uuid'],
transaction,
});
if (lessons.length) {
const uuidByLessonId = new Map(lessons.map((l) => [readAttr(l, 'lesson_id'), readAttr(l, 'uuid')]));
const rows = await LessonReadingProgress.findAll({
where: { user_id: userId, lesson_id: { [Op.in]: [...uuidByLessonId.keys()] }, status: 'completed' },
attributes: ['lesson_id', 'completed_at'],
transaction,
});
for (const row of rows) {
const uuid = uuidByLessonId.get(readAttr(row, 'lesson_id'));
if (uuid) entries.push([`lesson:${uuid}`, readAttr(row, 'completed_at')]);
}
}
}
const unitUuids = [...(referencesByProgressType.unit ?? [])];
if (unitUuids.length) {
const units = await Unit.findAll({
where: { uuid: { [Op.in]: unitUuids } },
attributes: ['unit_id', 'uuid'],
transaction,
});
if (units.length) {
const uuidByUnitId = new Map(units.map((u) => [readAttr(u, 'unit_id'), readAttr(u, 'uuid')]));
const rows = await UnitReadingProgress.findAll({
where: { user_id: userId, unit_id: { [Op.in]: [...uuidByUnitId.keys()] }, status: 'completed' },
attributes: ['unit_id', 'completed_at'],
transaction,
});
for (const row of rows) {
const uuid = uuidByUnitId.get(readAttr(row, 'unit_id'));
if (uuid) entries.push([`unit:${uuid}`, readAttr(row, 'completed_at')]);
}
}
}
return entries;
}
async function hydrateReadTaskProgress(userId, requirements = [], options = {}) {
const readRequirements = requirements
.map(normalizeRequirement)
.filter((req) => req && req.task_id && req.requirement_id);
if (!readRequirements.length) return [];
const referencesByProgressType = readRequirements.reduce((acc, req) => {
const progressType = READ_TYPE_TO_PROGRESS_TYPE[req.type];
if (!acc[progressType]) acc[progressType] = new Set();
acc[progressType].add(req.reference_id);
return acc;
}, {});
const where = {
user_id: userId,
status: 'completed',
[Op.or]: Object.entries(referencesByProgressType).map(([type, references]) => ({
type,
reference_id: { [Op.in]: [...references] },
})),
};
const completedReadingRows = await CourseReadingProgress.findAll({
where,
attributes: ['type', 'reference_id', 'completed_at'],
transaction: options.transaction,
});
const completedReading = new Map(
completedReadingRows.map((row) => [
`${readAttr(row, 'type')}:${readAttr(row, 'reference_id')}`,
readAttr(row, 'completed_at'),
])
);
// ── Standalone lessons/units (no parent course) don't have a
// CourseReadingProgress row at all — their system-of-record is
// lesson_reading_progress/unit_reading_progress instead (see
// completion_requirements.service.js#persistStatus). Merge those in too,
// resolving UUID (the TaskRequirement's reference_id) -> numeric PK first.
const standaloneEntries = await getStandaloneCompletedReading(userId, referencesByProgressType, options.transaction);
for (const [key, completedAt] of standaloneEntries) {
if (!completedReading.has(key)) completedReading.set(key, completedAt);
}
if (!completedReading.size) return [];
const now = new Date();
const rowsToUpsert = readRequirements.filter((req) =>
completedReading.has(`${READ_TYPE_TO_PROGRESS_TYPE[req.type]}:${req.reference_id}`)
);
if (!rowsToUpsert.length) return [];
const existingProgressRows = await TaskProgress.findAll({
where: {
user_id: userId,
completed: true,
requirement_id: { [Op.in]: rowsToUpsert.map((req) => req.requirement_id) },
reference_id: { [Op.in]: rowsToUpsert.map((req) => req.reference_id) },
},
attributes: ['requirement_id', 'reference_id'],
transaction: options.transaction,
});
const existingProgress = new Set(
existingProgressRows.map((row) =>
`${readAttr(row, 'requirement_id')}:${readAttr(row, 'reference_id')}`
)
);
const missingRows = rowsToUpsert.filter((req) =>
!existingProgress.has(`${req.requirement_id}:${req.reference_id}`)
);
if (!missingRows.length) return [];
await Promise.all(missingRows.map((req) =>
TaskProgress.upsert(
{
task_id: req.task_id,
requirement_id: req.requirement_id,
user_id: userId,
reference_id: req.reference_id,
type: req.type,
completed: true,
completed_at: completedReading.get(`${READ_TYPE_TO_PROGRESS_TYPE[req.type]}:${req.reference_id}`) ?? now,
createdBy: userId,
updatedBy: userId,
},
{
conflictFields: ['requirement_id', 'user_id', 'reference_id'],
transaction: options.transaction,
}
)
));
return missingRows;
}
// ─── Accessible task lists (via group membership) ────────────────────────────
async function getAccessibleTaskListIds(userId) {
const memberships = await mdl_UserGroupMembers.findAll({
where: { user_id: userId, deletedAt: null },
attributes: ['group_id'],
});
const groupIds = memberships.map((m) => m.group_id);
if (!groupIds.length) return [];
const taskListGroups = await TaskListGroup.findAll({
where: { group_id: groupIds },
attributes: ['task_list_id'],
});
return [...new Set(taskListGroups.map((tlg) => tlg.task_list_id))];
}
// ─── Sync from a completion event (any trigger) → task_progress ─────────────
/**
* Call after ANY course/unit/lesson reaches 'completed' for a user, regardless of which
* completion-requirement type triggered it. Finds TaskRequirement rows (read_course/
* read_unit/read_lesson) referencing the given UUIDs, among task lists the user's groups
* can access, backfills task_progress via hydrateReadTaskProgress, and reports any task
* whose read-only requirements are now ALL satisfied.
*
* @param {number} userId
* @param {{ lessonUuid?: string, unitUuid?: string, courseUuid?: string }} uuids
* @returns {Promise<{ task_id, task_name }[]>}
*/
async function syncCompletedEntitiesToTaskProgress(userId, { lessonUuid, unitUuid, courseUuid } = {}) {
const referenceIds = [lessonUuid, unitUuid, courseUuid].filter(Boolean);
if (!referenceIds.length) return [];
const taskListIds = await getAccessibleTaskListIds(userId);
if (!taskListIds.length) return [];
const requirements = await TaskRequirement.findAll({
where: {
reference_id: { [Op.in]: referenceIds },
type: { [Op.in]: READ_REQUIREMENT_TYPES },
deletedAt: null,
},
include: [{
model: Task,
as: 'task',
where: { task_list_id: { [Op.in]: taskListIds }, deletedAt: null },
required: true,
attributes: ['task_id', 'name', 'task_list_id'],
}],
attributes: ['requirement_id', 'task_id', 'type', 'reference_id'],
});
if (!requirements.length) return [];
const newlyCompleted = await hydrateReadTaskProgress(userId, requirements);
if (!newlyCompleted.length) return [];
// Check whether any impacted task now has ALL its read-only requirements satisfied —
// tasks with any non-read requirement (upload_file/visit_link/submit_text)
// still need manual submission, so they're excluded from auto-turn-in.
const taskIds = [...new Set(newlyCompleted.map((r) => r.task_id))];
const completedTasks = [];
for (const taskId of taskIds) {
const allReqs = await TaskRequirement.findAll({
where: { task_id: taskId, deletedAt: null },
attributes: ['requirement_id', 'type', 'reference_id'],
});
const hasNonReadReqs = allReqs.some((r) => !READ_REQUIREMENT_TYPES.includes(r.type));
if (hasNonReadReqs) continue;
const doneProgress = await TaskProgress.findAll({
where: { task_id: taskId, user_id: userId, completed: true },
attributes: ['requirement_id', 'reference_id'],
});
const doneSet = new Set(doneProgress.map((p) => `${p.requirement_id}:${p.reference_id}`));
const allDone = allReqs.every((r) => doneSet.has(`${r.requirement_id}:${r.reference_id}`));
if (allDone) {
const task = requirements.find((r) => r.task_id === taskId)?.task;
completedTasks.push({ task_id: taskId, task_name: task?.name ?? '' });
}
}
return completedTasks;
}
module.exports = {
hydrateReadTaskProgress,
syncCompletedEntitiesToTaskProgress,
getAccessibleTaskListIds,
READ_REQUIREMENT_TYPES,
};
+56
View File
@@ -0,0 +1,56 @@
/***********************************************************************************************************************************************************************
* File Name: tierGrants.service.js
* Type of Program: Service
* Description: Snapshots a Tier Plan's current bundle contents (plan_courses/
* plan_units/plan_lessons — exactly one of the three per plan,
* per the single-type bundle rule) into user_tier_grants for a
* given user_tiers row. Shared by the client purchase-capture
* flow (controllers/client/tiers.controller.js#captureOrder) and
* the admin manual-grant flow (controllers/admin/tiers.controller.js#grantTier)
* so both paths produce the same item-specific entitlement.
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Aug. 4, 2026
***********************************************************************************************************************************************************************/
'use strict';
const {
mdl_PlanCourses,
mdl_PlanUnits,
mdl_PlanLessons,
mdl_UserTierGrants,
} = require('../models/tiers/tier.associations');
// Replaces every grant row tied to this user_tiers purchase with a fresh
// snapshot of the plan's current bundle — safe to call again on repeat
// purchase/extension of the same plan (refreshes to whatever the plan
// currently contains, in case an admin edited it since the first purchase).
async function snapshotPlanGrants(userTierRow, planId) {
await mdl_UserTierGrants.destroy({ where: { user_tier_id: userTierRow.tier_id } });
if (!planId) return;
const [courseRows, unitRows, lessonRows] = await Promise.all([
mdl_PlanCourses.findAll({ where: { plan_id: planId }, attributes: ['course_id'] }),
mdl_PlanUnits.findAll({ where: { plan_id: planId }, attributes: ['unit_id'] }),
mdl_PlanLessons.findAll({ where: { plan_id: planId }, attributes: ['lesson_id'] }),
]);
const grants = [
...courseRows.map((r) => ({ item_type: 'course', item_id: r.course_id })),
...unitRows.map((r) => ({ item_type: 'unit', item_id: r.unit_id })),
...lessonRows.map((r) => ({ item_type: 'lesson', item_id: r.lesson_id })),
];
if (!grants.length) return;
await mdl_UserTierGrants.bulkCreate(
grants.map((g) => ({
user_tier_id: userTierRow.tier_id,
user_id: userTierRow.user_id,
plan_id: planId,
item_type: g.item_type,
item_id: g.item_id,
granted_at: new Date(),
})),
);
}
module.exports = { snapshotPlanGrants };
+164
View File
@@ -0,0 +1,164 @@
/***********************************************************************************************************************************************************************
* File Name: trustedDevice.service.js
* Type of Program: Service
* Description: Lets a login from an already-verified device skip the OTP
* gate. A device is trusted the first time its user clears an
* OTP; trust rolls forward 30 days on each trusted login and is
* tied to both an opaque cookie token (device_trust) and a
* User-Agent fingerprint, so a stolen cookie alone isn't enough
* once the fingerprint no longer matches. Ordinary logout does
* NOT revoke trust or clear the device_trust cookie — expires_at
* is the only thing that ends the OTP-skip window in the normal
* case, so logging out and back in on the same device still
* skips OTP until the 30-day window actually lapses. Trust is
* only force-revoked by password change/reset, admin ban/
* deactivate/force-logout, or a single session being explicitly
* terminated.
* Author: Kenneth Obsequio
* Date Created: Jul. 5, 2026
***********************************************************************************************************************************************************************
* HOW TO USE:
* const trustedDevice = require('../services/trustedDevice.service');
* const fingerprintHash = trustedDevice.getFingerprintHash(req);
* const trusted = await trustedDevice.findValid(user.user_id, req.cookies.device_trust, fingerprintHash);
* if (trusted) { ...skip OTP... }
* await trustedDevice.issueOrRefresh(res, user.user_id, fingerprintHash, session.session_id);
***********************************************************************************************************************************************************************/
const crypto = require('crypto');
const mdl_TrustedDevices = require('../models/users/trusted_devices.mdl');
const { parseUA } = require('../utils/session_info.util');
const { hashToken } = require('../utils/token.util');
const TRUST_DAYS = 30;
const COOKIE_NAME = 'device_trust';
const getFingerprintHash = (req) => {
const { browser, os, device } = parseUA(req.headers['user-agent']);
return crypto.createHash('sha256').update(`${browser}|${os}|${device}`).digest('hex');
};
// sameSite:'none' (not 'strict') in production — the frontend (Vercel) and
// this API (Render) are different sites, so this cookie only travels on the
// cross-site fetch/XHR calls the frontend makes if SameSite allows it.
// 'none' requires secure:true, which is already forced above in production.
const cookieOptions = (maxAge) => ({
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax',
maxAge,
});
/**
* Looks up a non-revoked, non-expired trusted device matching both the
* cookie token and the current request's fingerprint.
*
* Fails safe: any lookup error (e.g. table not migrated yet) is treated as
* "not trusted" rather than propagating — a broken trust check should never
* take down the login/OTP path itself, it should just fall back to OTP.
* @returns {Promise<import('../models/users/trusted_devices.mdl')|null>}
*/
const findValid = async (userId, rawToken, fingerprintHash) => {
if (!rawToken) return null;
try {
const row = await mdl_TrustedDevices.findOne({
where: {
user_id: userId,
device_token_hash: hashToken(rawToken),
fingerprint_hash: fingerprintHash,
revoked_at: null,
},
});
return (row && new Date(row.expires_at) > new Date()) ? row : null;
} catch (err) {
console.error('[TRUSTED DEVICE] findValid failed, falling back to OTP:', err.message);
return null;
}
};
/**
* Marks the current device as trusted for TRUST_DAYS, rolling the window
* forward on repeat use, and sets the device_trust cookie.
*
* Fails safe: called after tokens/session are already minted, so a failure
* here (e.g. table not migrated yet) must not break an otherwise-successful
* login — it just means this device won't skip OTP next time.
*/
const issueOrRefresh = async (res, userId, fingerprintHash, sessionId) => {
try {
const rawToken = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + TRUST_DAYS * 24 * 60 * 60 * 1000);
const fields = {
device_token_hash: hashToken(rawToken),
expires_at: expiresAt,
revoked_at: null,
last_session_id: sessionId,
};
// Plain find-then-create/update rather than findOrCreate() — Sequelize's
// postgres findOrCreate() relies on a temp PL/pgSQL function for atomicity
// that CockroachDB doesn't support ("cannot create user-defined functions
// under a temporary schema").
const row = await mdl_TrustedDevices.findOne({ where: { user_id: userId, fingerprint_hash: fingerprintHash } });
if (row) {
await row.update(fields);
} else {
await mdl_TrustedDevices.create({ user_id: userId, fingerprint_hash: fingerprintHash, ...fields });
}
res.cookie(COOKIE_NAME, rawToken, cookieOptions(TRUST_DAYS * 24 * 60 * 60 * 1000));
} catch (err) {
console.error('[TRUSTED DEVICE] issueOrRefresh failed:', err.message);
}
};
/**
* Revokes trust for one specific device by its cookie token. Not called by
* the normal logout flow (see auth.controller.js exports.logout) — ordinary
* logout intentionally leaves trust intact. Kept as a primitive for a
* future explicit "forget this device" action, should one be added.
*/
const revokeByToken = async (userId, rawToken) => {
if (!rawToken) return;
try {
await mdl_TrustedDevices.update(
{ revoked_at: new Date() },
{ where: { user_id: userId, device_token_hash: hashToken(rawToken), revoked_at: null } }
);
} catch (err) {
console.error('[TRUSTED DEVICE] revokeByToken failed:', err.message);
}
};
const revokeAllForUser = async (userId) => {
try {
await mdl_TrustedDevices.update(
{ revoked_at: new Date() },
{ where: { user_id: userId, revoked_at: null } }
);
} catch (err) {
console.error('[TRUSTED DEVICE] revokeAllForUser failed:', err.message);
}
};
const revokeBySessionId = async (sessionId) => {
if (!sessionId) return;
try {
await mdl_TrustedDevices.update(
{ revoked_at: new Date() },
{ where: { last_session_id: sessionId, revoked_at: null } }
);
} catch (err) {
console.error('[TRUSTED DEVICE] revokeBySessionId failed:', err.message);
}
};
module.exports = {
COOKIE_NAME,
getFingerprintHash,
findValid,
issueOrRefresh,
revokeByToken,
revokeAllForUser,
revokeBySessionId,
};