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