new commits

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-03 16:20:58 +08:00
parent 1372f4e975
commit 1d12f04967
93 changed files with 3849 additions and 1063 deletions
+9 -8
View File
@@ -10,10 +10,11 @@
***********************************************************************************************************************************************************************/
'use strict';
const { Op } = require('sequelize');
const mdl_Achievements = require('../models/users/achievements.mdl');
const mdl_Users = require('../models/users/users.mdl');
const { EARLY_ACCESS_CUTOFF, ACHIEVEMENT_REGISTRY } = require('../data/achievements.data');
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');
@@ -24,15 +25,15 @@ const { NOTIFICATION_REGISTRY } = require('../data/notifications.data');
* Safe to call multiple times — idempotent via findOrCreate.
*
* @param {string|number} user_id
* @param {string} key — must exist in ACHIEVEMENT_REGISTRY
* @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 = ACHIEVEMENT_REGISTRY[key];
const def = await mdl_AchievementDefinitions.findOne({ where: { key, is_active: true } });
if (!def) {
console.warn(`[ACHIEVEMENTS] Unknown achievement key: "${key}"`);
console.warn(`[ACHIEVEMENTS] Unknown or inactive achievement key: "${key}"`);
return null;
}
@@ -47,6 +48,7 @@ async function grantAchievement(user_id, key, metadata = {}, granted_by = null)
key: def.key,
label: def.label,
description: def.description,
icon: def.icon,
granted_by: granted_by ?? null,
granted_at: new Date(),
metadata,
@@ -140,7 +142,6 @@ async function backfillEarlyAccess() {
// ─── Exports ──────────────────────────────────────────────────────────────────
module.exports = {
ACHIEVEMENT_REGISTRY,
grantAchievement,
// Convenience triggers
+107
View File
@@ -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 };
+34 -11
View File
@@ -2,18 +2,22 @@
* File Name: email.service.js
* Type of Program: Service
* Description: Nodemailer-based email service.
* Provides:
* - sendOTPEmail() → sends a 6-digit OTP verification email
* - sendWelcomeEmail() → sent after successful email verification
* Author: rgrgogu
* Subject + body per type are loaded from the email_templates
* table (admin-editable, see controllers/admin/email_templates.controller.js).
* The outer layout (header/footer/signature) below is fixed in
* code and is NOT admin-editable — only the body content is.
* Author: rgrgogu, Kenneth Obsequio (@lash0000)
* Date Created: Oct. 6, 2025
* Date Modified: Jul. 3, 2026 — templates moved from data/email_body.data.js into the DB
***********************************************************************************************************************************************************************
* HOW TO USE:
* const emailService = require('../services/email.service');
* await emailService.sendOTPEmail(user.email, otp);
* await emailService.sendEmail({ to: user.email, type: 'OTP', data: { otp } });
***********************************************************************************************************************************************************************/
const nodemailer = require('nodemailer');
const { emailTemplates } = require('../data/email_body.data')
const mdl_EmailTemplate = require('../models/email_templates/email_templates.mdl');
const { enrichEmailData } = require('../data/email_template_enrichers.data');
const { renderTemplate } = require('../utils/renderTemplate.util');
const port = Number(process.env.SMTP_PORT);
@@ -31,15 +35,34 @@ const transporter = nodemailer.createTransport({
},
});
// Fixed layout — admins cannot change header/footer/signature via the CRUD,
// only the body content per template type.
const FONT = 'font-family: Arial, sans-serif; font-size: 14px; color: #000;';
const wrap = (body) => `
<html>
<body style="${FONT} line-height: 1.6;">
${body.trim()}
<br><br>
<p style="margin: 0;">Regards,<br>Philproperties IT Team</p>
<p style="margin: 0; font-size: 12px; color: #555;">This is an automated message from STARR System. Please do not reply.</p>
</body>
</html>`.trim();
const sendEmail = async ({ to, type, data = {} }) => {
try {
const templateFn = emailTemplates[type];
if (!templateFn) {
const template = await mdl_EmailTemplate.findOne({ where: { type } });
if (!template) {
throw new Error(`Email template "${type}" not found`);
}
// Draft content (or a template that's never been sent) never reaches
// real mail — only the live subject/html_body columns count as "published".
if (!template.subject || !template.html_body) {
throw new Error(`Email template "${type}" has no published (sent) version yet`);
}
const { subject, html } = templateFn(data);
const enriched = enrichEmailData(type, data);
const subject = renderTemplate(template.subject, enriched);
const html = wrap(renderTemplate(template.html_body, enriched));
return await new Promise((resolve, reject) => {
transporter.sendMail(
@@ -65,4 +88,4 @@ const sendEmail = async ({ to, type, data = {} }) => {
const ping = () => transporter.verify();
module.exports = { sendEmail, ping };
module.exports = { sendEmail, ping };
+117
View File
@@ -0,0 +1,117 @@
/***********************************************************************************************************************************************************************
* 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);
}
function signToken(asset, userId, ip) {
return jwt.sign(
{
asset_id: asset.asset_id,
user_id: userId,
storage_key: asset.storage_key,
file_type: asset.file_type,
mime_type: asset.mime_type,
ip,
},
MEDIA_SECRET,
{ expiresIn: TOKEN_TTL_SEC }
);
}
// ─── 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.getSignedDownloadUrl(asset.thumbnail_storage_key, TOKEN_TTL_SEC);
} 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,
};
-75
View File
@@ -1,75 +0,0 @@
/***********************************************************************************************************************************************************************
* File Name: paypal.service.js
* Type of Program: Service
* Description: PayPal Orders API helpers — create order, capture order.
* Uses client-side JS SDK button → server capture flow.
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 6, 2026
***********************************************************************************************************************************************************************/
const axios = require('axios');
const BASE_URL = process.env.PAYPAL_ENV === 'live'
? 'https://api-m.paypal.com'
: 'https://api-m.sandbox.paypal.com';
const getAccessToken = async () => {
const { data } = await axios.post(
`${BASE_URL}/v1/oauth2/token`,
'grant_type=client_credentials',
{
auth: {
username: process.env.PAYPAL_CLIENT_ID,
password: process.env.PAYPAL_CLIENT_SECRET,
},
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
}
);
return data.access_token;
};
exports.createOrder = async ({ amount, currency = 'USD', referenceId, returnUrl, cancelUrl }) => {
const token = await getAccessToken();
const { data } = await axios.post(
`${BASE_URL}/v2/checkout/orders`,
{
intent: 'CAPTURE',
purchase_units: [{
reference_id: referenceId,
amount: { currency_code: currency, value: String(amount) },
}],
application_context: {
return_url: returnUrl ?? `${process.env.FRONTEND_URL}/plans/checkout`,
cancel_url: cancelUrl ?? `${process.env.FRONTEND_URL}/plans/checkout?cancelled=true`,
brand_name: 'Philproperties',
user_action: 'PAY_NOW',
},
},
{ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } }
);
return data;
};
exports.captureOrder = async (orderId) => {
const token = await getAccessToken();
const { data } = await axios.post(
`${BASE_URL}/v2/checkout/orders/${orderId}/capture`,
{},
{ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } }
);
return data; // { id, status, purchase_units, payer }
};
exports.refundCapture = async (captureId, amount, currency = 'USD') => {
const token = await getAccessToken();
const { data } = await axios.post(
`${BASE_URL}/v2/payments/captures/${captureId}/refund`,
{
amount: {
value: String(amount),
currency_code: currency,
},
},
{ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } }
);
return data; // { id, status, amount, ... }
};
@@ -0,0 +1,138 @@
/***********************************************************************************************************************************************************************
* File Name: task_reading_progress_sync.service.js
* Type of Program: Service
* Description: Backfills task_progress for read_* task requirements from course_reading_progress.
*
* This covers the case where a user already completed reading a course/unit/lesson
* before a task requiring that item was created or assigned.
***********************************************************************************************************************************************************************/
'use strict';
const { Op } = require('sequelize');
const CourseReadingProgress = require('../models/courses/course_reading_progress.mdl');
const { TaskProgress } = require('../models/task/task_progress.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,
};
}
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,
});
if (!completedReadingRows.length) return [];
const completedReading = new Map(
completedReadingRows.map((row) => [
`${readAttr(row, 'type')}:${readAttr(row, 'reference_id')}`,
readAttr(row, 'completed_at'),
])
);
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;
}
module.exports = {
hydrateReadTaskProgress,
READ_REQUIREMENT_TYPES,
};