ready to test

Testing

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-06-22 10:06:58 +08:00
parent bf48c95467
commit 439bb33f77
189 changed files with 17559 additions and 686 deletions
@@ -0,0 +1,100 @@
// controllers/client/advertisements.controller.js
const Advertisement = require("../../models/advertisements/advertisements.mdl");
const mdl_Assets = require("../../models/assets/assets.mdl");
const R = require('../../utils/response.util');
const { Op } = require('sequelize');
const ALLOWED_TYPES = ["hero", "banner", "popup", "sidebar"];
// ─── Status derivation ─────────────────────────────────────────────────────
// Mirrors admin controller's deriveStatus — single source of truth for what
// "live right now" means. Kept duplicated rather than shared to avoid a
// cross-boundary import between admin and client controllers.
function deriveStatus(advertisement) {
if (advertisement.deletedAt) return "archived";
if (!advertisement.is_active) return "draft";
const now = new Date();
const start = advertisement.start_date ? new Date(advertisement.start_date) : null;
const end = advertisement.end_date ? new Date(advertisement.end_date) : null;
if (end && end < now) return "expired";
if (start && start > now) return "scheduled";
return "active";
}
// ─── GET ACTIVE ───────────────────────────────────────────────────────────────
//
// Resolves the single highest-priority live advertisement for a given placement
// type. "Live" means is_active = true AND within start_date/end_date window —
// computed the same way as deriveStatus, but expressed as a SQL WHERE clause
// here since we want the DB to do the filtering/ordering, not JS.
//
// GET /api/client/advertisements/active?type=hero
//
exports.getActiveAdvertisement = async (req, res) => {
try {
const { type } = req.query;
if (!type) return R.error(res, "type is required.", 400);
if (!ALLOWED_TYPES.includes(type)) return R.error(res, `Invalid type. Must be one of: ${ALLOWED_TYPES.join(", ")}`, 400);
const now = new Date();
const advertisement = await Advertisement.findOne({
where: {
type,
is_active: true,
deletedAt: null,
[Op.and]: [
{ [Op.or]: [{ start_date: null }, { start_date: { [Op.lte]: now } }] },
{ [Op.or]: [{ end_date: null }, { end_date: { [Op.gte]: now } }] },
],
},
order: [["order", "ASC"], ["createdAt", "DESC"]],
include: [{
model: mdl_Assets,
as: "image",
attributes: ["asset_id", "display_name", "file_url", "thumbnail_url"],
required: false,
}],
attributes: { exclude: ["createdBy", "updatedBy", "deletedBy", "deletedAt", "image_asset_id"] },
});
if (!advertisement) return R.success(res, "No active advertisement for this placement.", { data: null });
const json = advertisement.toJSON();
json.status = deriveStatus(json); // will always be "active" given the WHERE clause, but kept for shape consistency
return R.success(res, "Active advertisement retrieved.", { data: json });
} catch (err) {
console.error("[CLIENT][ADVERTISEMENT][GET ACTIVE]", err);
return R.error(res, "Could not retrieve advertisement.", 500);
}
};
// ─── TRACK CLICK ──────────────────────────────────────────────────────────────
//
// POST /api/client/advertisements/:advertisementId/click
// Fire-and-forget increment. Never blocks or surfaces errors to the user —
// a failed click tracking call should never disrupt navigation to the CTA link.
//
exports.trackClick = async (req, res) => {
try {
const { advertisementId } = req.params;
if (!advertisementId || advertisementId === "undefined") return R.error(res, "Invalid advertisement ID.", 400);
const advertisement = await Advertisement.findOne({ where: { advertisement_id: advertisementId, deletedAt: null } });
if (!advertisement) return R.success(res, "Advertisement not found, skipped.", { data: null });
await advertisement.increment("click_count");
return R.success(res, "Click tracked.", { data: { click_count: advertisement.click_count + 1 } });
} catch (err) {
console.error("[CLIENT][ADVERTISEMENT][TRACK CLICK]", err);
// Still respond 200-ish/success shape — click tracking failures shouldn't surface to the user.
return R.success(res, "Click tracking failed silently.", { data: null });
}
};
@@ -0,0 +1,214 @@
/***********************************************************************************************************************************************************************
* File Name: certificate.controller.js (client)
* Type of Program: Controller
* Description: Issues a PDF certificate for a completed course.
* A certificate is available only when the user has passed the course assessment.
* Certificate records are persisted (findOrCreate) so the same cert_no/ref_no is
* returned on every subsequent download.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 18, 2026
***********************************************************************************************************************************************************************/
'use strict';
const R = require('../../utils/response.util');
const mdl_Users = require('../../models/users/users.mdl');
const mdl_Achievements = require('../../models/users/achievements.mdl');
const { generateCertificate } = require('../../services/certificate.service');
const { formatDuration } = require('../../utils/duration.util');
const UserNotification = require('../../models/notifications/user_notification.mdl');
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
const {
Course,
CourseAssessment,
QuizAttempt,
Certificate,
CourseInstructor,
} = require('../../models/courses/courses.associations');
const notDeleted = { deletedAt: null };
// ─── Helpers ───────────────────────────────────────────────────────────────────
// cert_no format: YYYYMM-{userId:6}-{userCertSeq:5}
// userCertSeq = how many certs this user will have after this insert
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}`;
}
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`;
}
// ─── GET /api/client/certificates/:courseUuid ──────────────────────────────────
exports.getCertificate = async (req, res) => {
try {
const { courseUuid } = req.params;
const user_id = req.user.user_id;
// ── 1. Resolve course ──────────────────────────────────────────────────────
const course = await Course.findOne({
where: { uuid: courseUuid, ...notDeleted },
attributes: ['course_id', 'title', 'course_code', '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) return R.error(res, 'Course not found.', 404);
if (!course.assessment) {
return R.error(res, 'This course does not have an assessment — no certificate available.', 404);
}
// ── 2. Verify the user passed ──────────────────────────────────────────────
const passedAttempt = await QuizAttempt.findOne({
where: {
user_id,
assessment_id: course.assessment.assessment_id,
passed: true,
},
order: [['createdAt', 'DESC']],
attributes: ['score', 'createdAt'],
});
if (!passedAttempt) {
return R.error(res, 'Certificate not available — course assessment not passed yet.', 403);
}
// ── 3. Get user's name ─────────────────────────────────────────────────────
const user = await mdl_Users.findByPk(user_id, { attributes: ['personal_info'] });
const fullName = user?.personal_info?.name?.full_name?.trim() || 'Participant';
// ── 4. Resolve or create the certificate record ────────────────────────────
const [cert, created] = await Certificate.findOrCreate({
where: { user_id, course_id: course.course_id },
defaults: {
cert_no: await buildCertNo(user_id),
ref_no: await buildRefNo(),
instructors: formatInstructors(course.instructors ?? []),
score: passedAttempt.score ?? null,
length_str: formatDuration(course.duration_seconds),
issued_at: passedAttempt.createdAt,
},
});
// Always use live instructors from course_instructors table for the PDF.
// Keep the snapshot in sync so it reflects the current state.
const liveInstructors = formatInstructors(course.instructors ?? []);
if (liveInstructors !== (cert.instructors ?? '')) {
await cert.update({ instructors: liveInstructors });
}
// ── 5. On first issue: fire notification + achievements ────────────────────
if (created) {
// Certificate issued notification
UserNotification.create({
user_id,
...NOTIFICATION_REGISTRY.certificate_issued.build({
courseTitle: course.title,
courseUuid,
}),
}).catch(err => console.error('[CERTIFICATE] Failed to emit notification:', err));
// Per-course completion achievement
mdl_Achievements.findOrCreate({
where: { user_id, key: `course_completed_${courseUuid}` },
defaults: {
type: 'milestone',
label: 'Certificate of Completion',
description: course.title,
granted_at: passedAttempt.createdAt,
metadata: { courseTitle: course.title, courseUuid },
},
}).catch(err => console.error('[CERTIFICATE] Failed to grant course achievement:', err));
// First-course achievement (only if this is their very first certificate)
const totalCerts = await Certificate.count({ where: { user_id } });
if (totalCerts === 1) {
mdl_Achievements.findOrCreate({
where: { user_id, key: 'first_course_completed' },
defaults: {
type: 'milestone',
label: 'First Course Completed',
description: 'Completed your very first course on Philproperties.',
granted_at: passedAttempt.createdAt,
metadata: { courseTitle: course.title, courseUuid },
},
}).catch(err => console.error('[CERTIFICATE] Failed to grant first-course achievement:', err));
}
}
// ── 6. Format issued date as MM/DD/YY HH:MM AM/PM ────────────────────────
const issuedDate = new Date(cert.issued_at);
const dateStr = new Intl.DateTimeFormat('en-US', {
month: '2-digit',
day: '2-digit',
year: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: true,
}).format(issuedDate);
// ── 7. Generate PDF ────────────────────────────────────────────────────────
const pdf = await generateCertificate({
name: fullName,
course: course.title,
date: dateStr,
cert_no: cert.cert_no,
ref_no: cert.ref_no,
instructors: liveInstructors,
length: cert.length_str ?? '',
});
// ── 8. Stream response ─────────────────────────────────────────────────────
const nameParts = fullName.trim().split(/\s+/);
const lastName = nameParts.length > 1 ? nameParts[nameParts.length - 1] : nameParts[0];
const firstName = nameParts.length > 1 ? nameParts.slice(0, -1).join(' ') : '';
const safeTitle = course.title.replace(/[/\\:*?"<>|]/g, '').trim();
const filename = `${lastName},${firstName}_${safeTitle}_${cert.cert_no}.pdf`;
res.set({
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${filename}"`,
'Content-Length': pdf.length,
});
return res.send(pdf);
} catch (err) {
console.error('[CLIENT][CERTIFICATE]', err);
return R.error(res, 'Could not generate certificate.', 500);
}
};
@@ -0,0 +1,157 @@
'use strict';
const mdl_CoursePurchase = require('../../models/courses/course_purchases.mdl');
const mdl_Product = require('../../models/courses/products.mdl');
const paypal = require('../../services/paypal.service');
const R = require('../../utils/response.util');
// ─── CREATE ORDER ─────────────────────────────────────────────────────────────
exports.createCourseOrder = async (req, res) => {
try {
const { product_id } = req.body;
if (!product_id) return R.error(res, 'product_id is required.', 400);
const product = await mdl_Product.findOne({ where: { id: product_id, is_active: true } });
if (!product) return R.error(res, 'Product not found or inactive.', 404);
// Block if user already has an active completed purchase for this product
const existing = await mdl_CoursePurchase.findOne({
where: { user_id: req.user.user_id, product_id, status: 'completed' },
});
if (existing) {
const stillActive = !existing.expires_at || new Date(existing.expires_at) > new Date();
if (stillActive) return R.error(res, 'You already have active access to this course.', 409);
}
const ppOrder = await paypal.createOrder({
amount: Number(product.price).toFixed(2),
currency: product.currency,
referenceId: `user_${req.user.user_id}_product_${product_id}`,
returnUrl: `${process.env.FRONTEND_URL}/course/${product.course_id}/checkout`,
cancelUrl: `${process.env.FRONTEND_URL}/course/${product.course_id}/checkout?cancelled=true`,
});
const approvalUrl = ppOrder.links?.find((l) => l.rel === 'approve')?.href ?? null;
const expiresAt = product.access_days
? new Date(Date.now() + product.access_days * 86400000)
: null;
const purchase = await mdl_CoursePurchase.create({
user_id: req.user.user_id,
product_id,
amount: product.price,
currency: product.currency,
status: 'pending',
provider: 'paypal',
expires_at: expiresAt,
provider_payload: { order_id: ppOrder.id, approval_url: approvalUrl },
});
return R.success(res, 'Order created.', {
purchase_id: purchase.id,
order_id: ppOrder.id,
approval_url: approvalUrl,
amount: product.price,
currency: product.currency,
}, 201);
} catch (err) {
console.error('[CLIENT][COURSE PURCHASE][CREATE ORDER]', err);
return R.error(res, 'Could not create order.', 500);
}
};
// ─── CAPTURE ORDER ────────────────────────────────────────────────────────────
exports.captureCourseOrder = async (req, res) => {
try {
const { order_id } = req.body;
if (!order_id) return R.error(res, 'order_id is required.', 400);
const purchase = await mdl_CoursePurchase.findOne({
where: { user_id: req.user.user_id, status: 'pending', provider: 'paypal' },
include: [{ model: mdl_Product, as: 'product' }],
order: [['createdAt', 'DESC']],
});
if (!purchase || purchase.provider_payload?.order_id !== order_id)
return R.error(res, 'Pending purchase not found.', 404);
let captureData;
try {
captureData = await paypal.captureOrder(order_id);
} catch (ppErr) {
await purchase.update({
status: 'failed',
provider_payload: { ...purchase.provider_payload, error: ppErr?.response?.data ?? {} },
});
return R.error(res, 'PayPal capture failed.', 402);
}
const capture = captureData.purchase_units?.[0]?.payments?.captures?.[0];
await purchase.update({
status: 'completed',
paid_at: new Date(),
provider_payload: {
...purchase.provider_payload,
capture_id: capture?.id,
payer_id: captureData.payer?.payer_id,
capture: captureData,
},
});
return R.success(res, 'Payment successful. Course access granted.', {
purchase_id: purchase.id,
expires_at: purchase.expires_at,
course_id: purchase.product.course_id,
});
} catch (err) {
console.error('[CLIENT][COURSE PURCHASE][CAPTURE]', err);
return R.error(res, 'Could not capture order.', 500);
}
};
// ─── CANCEL ORDER ─────────────────────────────────────────────────────────────
exports.cancelCourseOrder = async (req, res) => {
try {
const { order_id } = req.body;
if (!order_id) return R.error(res, 'order_id is required.', 400);
const purchase = await mdl_CoursePurchase.findOne({
where: { user_id: req.user.user_id, status: 'pending', provider: 'paypal' },
order: [['createdAt', 'DESC']],
});
if (!purchase || purchase.provider_payload?.order_id !== order_id)
return R.error(res, 'Pending purchase not found.', 404);
await purchase.update({
status: 'cancelled',
provider_payload: { ...purchase.provider_payload, cancelled_at: new Date().toISOString() },
});
return R.success(res, 'Purchase cancelled.');
} catch (err) {
console.error('[CLIENT][COURSE PURCHASE][CANCEL]', err);
return R.error(res, 'Could not cancel purchase.', 500);
}
};
// ─── MY PURCHASES ─────────────────────────────────────────────────────────────
exports.getMyPurchases = async (req, res) => {
try {
const purchases = await mdl_CoursePurchase.findAll({
where: { user_id: req.user.user_id },
include: [{ model: mdl_Product, as: 'product', attributes: ['id', 'name', 'course_id', 'access_days'] }],
attributes: { exclude: ['provider_payload'] },
order: [['createdAt', 'DESC']],
});
return R.success(res, 'Purchases retrieved.', purchases);
} catch (err) {
console.error('[CLIENT][COURSE PURCHASE][GET MINE]', err);
return R.error(res, 'Could not retrieve purchases.', 500);
}
};
@@ -0,0 +1,312 @@
/***********************************************************************************************************************************************************************
* File Name: course_reading_progress.controller.js (client)
* Type of Program: Controller
* Description: Tracks user reading progress through a course hierarchy (course → unit → lesson).
*
* GET /client/courses/in-progress
* → courses where the current user has status = 'in_progress', with lesson counts
*
* GET /client/courses/:courseId/progress
* → returns all progress rows for this user + course (flat, frontend builds the map)
*
* POST /client/courses/:courseId/units/:unitId/lessons/:lessonId/progress
* → UPSERT lesson + derives + UPSERTs parent unit + course in one transaction
*
* Body (POST):
* { status: 'in_progress' | 'completed' }
* Defaults to 'in_progress' if omitted (on first visit).
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 21, 2026
***********************************************************************************************************************************************************************/
'use strict';
const R = require('../../utils/response.util');
const logActivity = require('../../utils/logActivity.util');
const { upsertLessonRead } = require('../../services/course_reading_progress.service');
const CourseReadingProgress = require('../../models/courses/course_reading_progress.mdl');
const Certificate = require('../../models/courses/certificate.mdl');
const {
Course, Unit, Lesson,
UnitQuiz, CourseAssessment, QuizAttempt,
} = require('../../models/courses/courses.associations');
const notDeleted = { deletedAt: null };
// =============================================================================
// ── IN-PROGRESS COURSES — profile learning progress card ──────────────────────
// =============================================================================
// GET /client/courses/in-progress
// Returns courses the user has started but not yet completed (no certificate).
// Includes both:
// • reading in_progress → still working through lessons
// • reading completed → finished lessons but quiz / assessment still pending
// Excludes any course where the user already holds a certificate.
exports.getMyInProgressCourses = async (req, res) => {
try {
const userId = req.user.user_id;
// All course-level progress rows for this user (any reading status)
const courseRows = await CourseReadingProgress.findAll({
where: { user_id: userId, type: 'course' },
attributes: ['course_id', 'status', 'last_accessed_at'],
include: [{
model: Course,
as: 'course',
attributes: ['course_id', 'title'],
where: notDeleted,
required: true,
}],
order: [['last_accessed_at', 'DESC']],
});
if (!courseRows.length) return R.success(res, 'No courses in progress.', []);
// Courses the user has already earned a certificate for — exclude these
const certificates = await Certificate.findAll({
where: { user_id: userId },
attributes: ['course_id'],
});
const certSet = new Set(certificates.map((c) => String(c.course_id)));
const pending = courseRows.filter((r) => !certSet.has(String(r.course_id)));
if (!pending.length) return R.success(res, 'No courses in progress.', []);
const result = await Promise.all(pending.map(async (row) => {
const courseId = row.course_id;
const readingDone = row.status === 'completed';
const [lessons_total, lessons_completed] = await Promise.all([
Lesson.count({
include: [{
model: Unit,
as: 'unit',
where: { course_id: courseId, ...notDeleted },
required: true,
}],
where: notDeleted,
}),
CourseReadingProgress.count({
where: { user_id: userId, course_id: courseId, type: 'lesson', status: 'completed' },
}),
]);
// Only compute pending quiz/assessment detail when all lessons are read
let pending_quizzes = [];
let pending_assessment = null;
if (readingDone) {
// All unit quizzes in this course
const unitQuizzes = await UnitQuiz.findAll({
attributes: ['quiz_id', 'title', 'is_required', 'passing_score'],
include: [{
model: Unit,
as: 'unit',
attributes: ['unit_id', 'title'],
where: { course_id: courseId, ...notDeleted },
required: true,
}],
where: notDeleted,
});
for (const quiz of unitQuizzes) {
const [hasPassed, attemptCount] = await Promise.all([
QuizAttempt.findOne({ where: { user_id: userId, quiz_id: quiz.quiz_id, passed: true } }),
QuizAttempt.count({ where: { user_id: userId, quiz_id: quiz.quiz_id } }),
]);
if (!hasPassed) {
pending_quizzes.push({
quiz_id: quiz.quiz_id,
title: quiz.title,
unit_title: quiz.unit.title,
is_required: quiz.is_required,
passing_score: quiz.passing_score,
attempt_count: attemptCount,
});
}
}
// Course assessment
const assessment = await CourseAssessment.findOne({
attributes: ['assessment_id', 'title', 'is_required', 'passing_score'],
where: { course_id: courseId },
});
if (assessment) {
const [hasPassed, attemptCount] = await Promise.all([
QuizAttempt.findOne({ where: { user_id: userId, assessment_id: assessment.assessment_id, passed: true } }),
QuizAttempt.count({ where: { user_id: userId, assessment_id: assessment.assessment_id } }),
]);
if (!hasPassed) {
pending_assessment = {
assessment_id: assessment.assessment_id,
title: assessment.title,
is_required: assessment.is_required,
passing_score: assessment.passing_score,
attempt_count: attemptCount,
};
}
}
}
return {
course_id: courseId,
title: row.course.title,
reading_status: row.status,
lessons_total,
lessons_completed,
last_accessed_at: row.last_accessed_at,
pending_quizzes,
pending_assessment,
};
}));
return R.success(res, 'In-progress courses retrieved.', result);
} catch (err) {
console.error('[CLIENT][COURSE READING PROGRESS][IN PROGRESS]', err);
return R.error(res, 'Could not retrieve in-progress courses.', 500);
}
};
// =============================================================================
// ── GET PROGRESS SUMMARY ──────────────────────────────────────────────────────
// =============================================================================
// GET /client/courses/:courseId/progress/summary
// Returns a compact progress snapshot: lesson counts + percentage + course status.
// Used by the ReadCourse block to render the inline progress bar without needing
// the full flat row list.
exports.getCourseProgressSummary = async (req, res) => {
try {
const { courseId } = req.params;
const userId = req.user.user_id;
const course = await Course.findOne({
where: { course_id: courseId, ...notDeleted },
attributes: ['course_id'],
});
if (!course) return R.error(res, 'Course not found.', 404);
const [lessons_total, lessons_completed, courseRow] = await Promise.all([
Lesson.count({
include: [{
model: Unit,
as: 'unit',
where: { course_id: courseId, ...notDeleted },
required: true,
}],
where: notDeleted,
}),
CourseReadingProgress.count({
where: { user_id: userId, course_id: courseId, type: 'lesson', status: 'completed' },
}),
CourseReadingProgress.findOne({
where: { user_id: userId, course_id: courseId, type: 'course' },
attributes: ['status'],
}),
]);
const percent = lessons_total > 0 ? Math.round((lessons_completed / lessons_total) * 100) : 0;
return R.success(res, 'Progress summary retrieved.', {
lessons_total,
lessons_completed,
percent,
status: courseRow?.status ?? null,
});
} catch (err) {
console.error('[CLIENT][COURSE READING PROGRESS][SUMMARY]', err);
return R.error(res, 'Could not retrieve progress summary.', 500);
}
};
// =============================================================================
// ── GET PROGRESS SNAPSHOT ─────────────────────────────────────────────────────
// =============================================================================
// GET /client/courses/:courseId/progress
// Returns all progress rows for this user+course.
// Frontend uses this to decorate the sidebar (completed checkmarks, locked states, etc.)
exports.getCourseProgress = async (req, res) => {
try {
const { courseId } = req.params;
const userId = req.user.user_id;
const course = await Course.findOne({
where: { course_id: courseId, ...notDeleted },
attributes: ['course_id'],
});
if (!course) return R.error(res, 'Course not found.', 404);
const rows = await CourseReadingProgress.findAll({
where: { user_id: userId, course_id: courseId },
attributes: ['progress_id', 'reference_id', 'type', 'status', 'completed_at', 'last_accessed_at'],
});
return R.success(res, 'Course progress retrieved.', rows);
} catch (err) {
console.error('[CLIENT][COURSE READING PROGRESS][GET]', err);
return R.error(res, 'Could not retrieve course progress.', 500);
}
};
// =============================================================================
// ── UPSERT LESSON PROGRESS ────────────────────────────────────────────────────
// =============================================================================
// POST /client/courses/:courseId/units/:unitId/lessons/:lessonId/progress
// Body: { status: 'in_progress' | 'completed' }
//
// Flow:
// 1. Resolve course / unit / lesson to get their UUIDs
// 2. Delegate to upsertLessonRead — handles lesson + unit + course in one tx
exports.upsertLessonProgress = async (req, res) => {
try {
const { courseId, unitId, lessonId } = req.params;
const userId = req.user.user_id;
const status = req.body.status === 'completed' ? 'completed' : 'in_progress';
const [course, unit, lesson] = await Promise.all([
Course.findOne({
where: { course_id: courseId, ...notDeleted },
attributes: ['course_id', 'uuid'],
}),
Unit.findOne({
where: { unit_id: unitId, course_id: courseId, ...notDeleted },
attributes: ['unit_id', 'uuid'],
}),
Lesson.findOne({
where: { lesson_id: lessonId, unit_id: unitId, ...notDeleted },
attributes: ['lesson_id', 'uuid'],
}),
]);
if (!course) return R.error(res, 'Course not found.', 404);
if (!unit) return R.error(res, 'Unit not found.', 404);
if (!lesson) return R.error(res, 'Lesson not found.', 404);
const result = await upsertLessonRead(userId, {
courseId: course.course_id,
courseUuid: course.uuid,
unitId: unit.unit_id,
unitUuid: unit.uuid,
lessonUuid: lesson.uuid,
lessonStatus: status,
});
logActivity(userId, 'lesson_read', {
entityType: 'lesson',
entityId: lesson.lesson_id,
details: { lesson_uuid: lesson.uuid, status },
});
return R.success(res, 'Progress updated.', result, 200);
} catch (err) {
console.error('[CLIENT][COURSE READING PROGRESS][UPSERT]', err);
return R.error(res, 'Could not update progress.', 500);
}
};
+727
View File
@@ -0,0 +1,727 @@
/***********************************************************************************************************************************************************************
* File Name: courses.controller.js (client)
* Type of Program: Controller
* Description: User-facing course endpoints (read-only).
* Access rules:
* - All courses are returned in the list (for upsell visibility)
* - Each course has is_locked: boolean based on the user's active tier
* - free / no active tier → unassigned courses are open; plan courses are locked
* - premium (active tier) → unassigned + courses under their plan are open
* - getCourse still enforces hard 403 on locked access
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 7, 2026
***********************************************************************************************************************************************************************/
"use strict";
const { Op } = require("sequelize");
const R = require("../../utils/response.util");
const mdl_UserTiers = require("../../models/tiers/user_tiers.mdl");
const mdl_PlanCourses = require("../../models/tiers/plan_courses.mdl");
const mdl_TierPlans = require("../../models/tiers/tier_plans.mdl");
const mdl_CoursePurchase = require("../../models/courses/course_purchases.mdl");
const mdl_Product = require("../../models/courses/products.mdl");
const mdl_Category = require("../../models/courses/categories.mdl");
const {
Course,
Unit, Lesson, LessonPage,
CourseObjective, LessonObjective,
CoursePrerequisite, CourseAssessment,
UnitQuiz, QuizQuestion, QuizOption, QuizAttempt
} = require("../../models/courses/courses.associations");
const { gradeSubmission } = require("../../utils/courses/grading.util");
const { shuffleOptions, getAttemptStatus, MAX_ATTEMPTS } = require("../../utils/courses/quiz_security.util");
const { onCourseCompleted } = require('../../services/achievements.service')
const notDeleted = { deletedAt: null };
const COURSE_LIST_ATTRS = [
"course_id", "uuid", "title", "description",
"course_code", "level", "subscription",
"duration_seconds", "order_index",
];
// Strip correct-answer data before sending quiz questions to the client
function sanitizeQuestions(questions = []) {
return questions.map((q) => {
const plain = q.toJSON ? q.toJSON() : { ...q };
plain.options = (plain.options ?? []).map(({ is_correct: _drop, ...o }) => o);
delete plain.explanation;
return plain;
});
}
// Resolve the caller's active tier (returns null if free/expired)
async function getActiveTier(user_id) {
return mdl_UserTiers.findOne({
where: { user_id, status: "active" },
order: [["createdAt", "DESC"]],
});
}
// ─── COURSES (all visible, is_locked per user tier) ───────────────────────────
exports.getCourses = async (req, res) => {
try {
const { category } = req.query; // optional slug filter
const activeTier = await getActiveTier(req.user.user_id);
const userTier = activeTier?.tier ?? 'free';
const tierRank = { free: 0, premium: 1, exclusive: 2 };
const userRank = tierRank[userTier] ?? 0;
// Fetch all completed purchases for this user (for has_purchased check)
const myPurchases = await mdl_CoursePurchase.findAll({
where: { user_id: req.user.user_id, status: 'completed' },
include: [{ model: mdl_Product, as: 'product', attributes: ['course_id', 'access_days'] }],
attributes: ['id', 'expires_at', 'product_id'],
});
const purchasedCourseIds = new Set(
myPurchases
.filter((p) => !p.expires_at || new Date(p.expires_at) > new Date())
.map((p) => String(p.product?.course_id))
);
// Build category filter
const categoryInclude = {
model: mdl_Category,
as: 'categories',
through: { attributes: [] },
attributes: ['id', 'name', 'slug'],
required: !!category,
...(category ? { where: { slug: category } } : {}),
};
const courses = await Course.findAll({
where: { ...notDeleted },
attributes: COURSE_LIST_ATTRS,
include: [
{
model: mdl_PlanCourses,
as: 'planCourse',
required: false,
attributes: ['id', 'plan_id'],
include: [{ model: mdl_TierPlans, as: 'plan', attributes: ['tier'] }],
},
{
model: mdl_Product,
as: 'product',
required: false,
attributes: ['id', 'name', 'price', 'currency', 'access_days', 'is_active'],
paranoid: false,
},
categoryInclude,
],
order: [['order_index', 'ASC'], ['title', 'ASC']],
});
const result = courses.map((c) => {
const plain = c.toJSON();
const planCourse = plain.planCourse;
const plan_tier = planCourse?.plan?.tier ?? null;
const has_purchased = purchasedCourseIds.has(String(plain.course_id));
let is_locked = false;
if (plan_tier && plan_tier !== 'free') {
const reqRank = tierRank[plan_tier] ?? 0;
if (userRank < reqRank && !has_purchased) is_locked = true;
}
delete plain.planCourse;
return { ...plain, is_locked, plan_tier, has_purchased };
});
return R.success(res, "Courses retrieved.", result);
} catch (err) {
console.error("[CLIENT][COURSES][GET ALL]", err);
return R.error(res, "Could not retrieve courses.", 500);
}
};
// ─── COURSE DETAIL (hard access check) ───────────────────────────────────────
exports.getCourse = async (req, res) => {
try {
const { courseId } = req.params;
// Access check — tier OR individual purchase
const activeTier = await getActiveTier(req.user.user_id);
const planCourse = await mdl_PlanCourses.findOne({ where: { course_id: courseId } });
let plan = null;
if (planCourse) {
plan = await mdl_TierPlans.findByPk(planCourse.plan_id, { attributes: ['tier'] });
const requiredTier = plan?.tier ?? 'free';
const tierRank = { free: 0, premium: 1, exclusive: 2 };
const userRank = tierRank[activeTier?.tier ?? 'free'] ?? 0;
const reqRank = tierRank[requiredTier] ?? 0;
if (userRank < reqRank) {
// Check individual purchase as fallback
const product = await mdl_Product.findOne({ where: { course_id: courseId } });
const hasPurchase = product && await mdl_CoursePurchase.findOne({
where: {
user_id: req.user.user_id, product_id: product.id, status: 'completed',
[Op.or]: [{ expires_at: null }, { expires_at: { [Op.gt]: new Date() } }],
},
});
if (!hasPurchase) return R.error(res, "You do not have access to this course.", 403);
}
}
const course = await Course.findOne({
where: { course_id: courseId, ...notDeleted },
attributes: COURSE_LIST_ATTRS,
include: [
{
model: Unit, as: "units",
where: notDeleted, required: false,
attributes: [
"unit_id", "uuid", "title", "description",
"order_index", "duration_seconds",
],
include: [
{
model: Lesson, as: "lessons",
where: notDeleted, required: false,
attributes: [
"lesson_id", "uuid", "title", "description",
"order_index", "duration_seconds",
],
},
{
model: UnitQuiz, as: "quiz",
required: false,
attributes: [
"quiz_id", "uuid", "title",
"is_required", "passing_score", "max_questions",
],
},
],
},
{
model: CourseObjective, as: "objectives",
required: false,
attributes: ["objective_id", "text", "order_index"],
},
{
model: CoursePrerequisite, as: "prerequisites",
required: false,
attributes: ["prereq_id", "ref_type", "ref_id", "order_index"],
},
{
model: CourseAssessment, as: "assessment",
required: false,
attributes: [
"assessment_id", "uuid", "title",
"is_required", "passing_score",
"time_limit_minutes", "max_questions",
],
},
],
order: [
[{ model: Unit, as: "units" }, "order_index", "ASC"],
[{ model: Unit, as: "units" }, { model: Lesson, as: "lessons" }, "order_index", "ASC"],
[{ model: CourseObjective, as: "objectives" }, "order_index", "ASC"],
[{ model: CoursePrerequisite, as: "prerequisites" }, "order_index", "ASC"],
],
});
if (!course) return R.error(res, "Course not found.", 404);
const plain = course.toJSON();
// Attach has_passed to each unit's quiz in one query
const quizIds = plain.units
?.map((u) => u.quiz?.quiz_id)
.filter(Boolean) ?? [];
if (quizIds.length) {
const passedQuizAttempts = await QuizAttempt.findAll({
where: { quiz_id: quizIds, user_id: req.user.user_id, passed: true },
attributes: ["quiz_id"],
});
const passedSet = new Set(passedQuizAttempts.map((a) => String(a.quiz_id)));
plain.units = plain.units.map((u) => ({
...u,
quiz: u.quiz ? { ...u.quiz, has_passed: passedSet.has(String(u.quiz.quiz_id)) } : null,
}));
}
let is_completed = false;
if (plain.assessment) {
const passedAttempt = await QuizAttempt.findOne({
where: { assessment_id: plain.assessment.assessment_id, user_id: req.user.user_id, passed: true },
});
is_completed = !!passedAttempt;
}
plain.is_completed = is_completed;
const plan_tier = plan?.tier ?? null;
// Attach product info and purchase status for the buy-course flow
const product = await mdl_Product.findOne({
where: { course_id: courseId, is_active: true },
attributes: ['id', 'name', 'price', 'currency', 'access_days'],
});
const hasPurchase = product && await mdl_CoursePurchase.findOne({
where: {
user_id: req.user.user_id, product_id: product.id, status: 'completed',
[Op.or]: [{ expires_at: null }, { expires_at: { [Op.gt]: new Date() } }],
},
});
return R.success(res, "Course retrieved.", {
...plain,
plan_tier,
product: product ?? null,
has_purchased: !!hasPurchase,
});
} catch (err) {
console.error("[CLIENT][COURSES][GET ONE]", err);
return R.error(res, "Could not retrieve course.", 500);
}
};
// ─── UNIT ─────────────────────────────────────────────────────────────────────
exports.getUnit = async (req, res) => {
try {
const { courseId, unitId } = req.params;
const unit = await Unit.findOne({
where: { unit_id: unitId, course_id: courseId, ...notDeleted },
attributes: [
"unit_id", "uuid", "title", "description",
"order_index", "duration_seconds",
],
include: [
{
model: Lesson, as: "lessons",
where: notDeleted, required: false,
attributes: [
"lesson_id", "uuid", "title", "description",
"order_index", "duration_seconds",
],
},
{
model: UnitQuiz, as: "quiz",
required: false,
attributes: [
"quiz_id", "uuid", "title",
"is_required", "passing_score", "max_questions",
],
},
],
order: [[{ model: Lesson, as: "lessons" }, "order_index", "ASC"]],
});
if (!unit) return R.error(res, "Unit not found.", 404);
return R.success(res, "Unit retrieved.", unit);
} catch (err) {
console.error("[CLIENT][UNIT][GET ONE]", err);
return R.error(res, "Could not retrieve unit.", 500);
}
};
// ─── LESSON ───────────────────────────────────────────────────────────────────
exports.getLesson = async (req, res) => {
try {
const { courseId, unitId, lessonId } = req.params;
const lesson = await Lesson.findOne({
where: { lesson_id: lessonId, unit_id: unitId, ...notDeleted },
attributes: [
"lesson_id", "uuid", "unit_id", "title",
"description", "order_index", "duration_seconds",
],
include: [
{
model: Unit, as: "unit",
where: { course_id: courseId, ...notDeleted },
attributes: [],
},
{
model: LessonPage, as: "page",
required: false,
attributes: ["page_id", "blocks"],
},
{
model: LessonObjective, as: "objectives",
required: false,
attributes: ["objective_id", "text", "order_index"],
},
],
order: [[{ model: LessonObjective, as: "objectives" }, "order_index", "ASC"]],
});
if (!lesson) return R.error(res, "Lesson not found.", 404);
return R.success(res, "Lesson retrieved.", lesson);
} catch (err) {
console.error("[CLIENT][LESSON][GET ONE]", err);
return R.error(res, "Could not retrieve lesson.", 500);
}
};
// ─── QUIZ (no answers) ────────────────────────────────────────────────────────
exports.getUnitQuiz = async (req, res) => {
try {
const { courseId, unitId } = req.params;
const unit = await Unit.findOne({ where: { unit_id: unitId, course_id: courseId, ...notDeleted } });
if (!unit) return R.error(res, "Unit not found.", 404);
const quiz = await UnitQuiz.findOne({
where: { unit_id: unitId, ...notDeleted },
attributes: [
"quiz_id", "uuid", "title",
"is_required", "passing_score", "max_questions",
],
include: [{
model: QuizQuestion, as: "questions",
where: notDeleted, required: false,
attributes: ["question_id", "uuid", "type", "question", "order_index", "points"],
include: [{
model: QuizOption, as: "options",
attributes: ["option_id", "text", "order_index"],
}],
}],
order: [[{ model: QuizQuestion, as: "questions" }, "order_index", "ASC"]],
});
if (!quiz) return R.error(res, "Quiz not found.", 404);
const plain = quiz.toJSON();
plain.questions = shuffleOptions(sanitizeQuestions(plain.questions ?? []));
const attempts = await QuizAttempt.findAll({
where: { quiz_id: quiz.quiz_id, user_id: req.user.user_id },
attributes: ["attempt_id", "attempt_number", "score", "passing_score", "passed", "createdAt"],
});
const status = getAttemptStatus(attempts);
plain.attempt_count = status.attempt_count;
plain.has_passed = status.has_passed;
plain.best_attempt = status.best_attempt;
plain.attempts_remaining = status.attempts_remaining;
plain.cooldown_until = status.cooldown_until;
plain.window_reset_at = status.window_reset_at;
plain.can_attempt = status.can_attempt;
return R.success(res, "Quiz retrieved.", plain);
} catch (err) {
console.error("[CLIENT][QUIZ][GET]", err);
return R.error(res, "Could not retrieve quiz.", 500);
}
};
// ─── ASSESSMENT (no answers) ──────────────────────────────────────────────────
exports.getCourseAssessment = async (req, res) => {
try {
const { courseId } = req.params;
const assessment = await CourseAssessment.findOne({
where: { course_id: courseId, ...notDeleted },
attributes: [
"assessment_id", "uuid", "title",
"is_required", "passing_score",
"time_limit_minutes", "max_questions",
],
include: [{
model: QuizQuestion, as: "questions",
where: notDeleted, required: false,
attributes: ["question_id", "uuid", "type", "question", "order_index", "points"],
include: [{
model: QuizOption, as: "options",
attributes: ["option_id", "text", "order_index"],
}],
}],
order: [[{ model: QuizQuestion, as: "questions" }, "order_index", "ASC"]],
});
if (!assessment) return R.error(res, "Assessment not found.", 404);
const plain = assessment.toJSON();
plain.questions = shuffleOptions(sanitizeQuestions(plain.questions ?? []));
const attempts = await QuizAttempt.findAll({
where: { assessment_id: assessment.assessment_id, user_id: req.user.user_id },
attributes: ["attempt_id", "attempt_number", "score", "passing_score", "passed", "createdAt"],
});
const status = getAttemptStatus(attempts);
plain.attempt_count = status.attempt_count;
plain.has_passed = status.has_passed;
plain.best_attempt = status.best_attempt;
plain.attempts_remaining = status.attempts_remaining;
plain.cooldown_until = status.cooldown_until;
plain.window_reset_at = status.window_reset_at;
plain.can_attempt = status.can_attempt;
return R.success(res, "Assessment retrieved.", plain);
} catch (err) {
console.error("[CLIENT][ASSESSMENT][GET]", err);
return R.error(res, "Could not retrieve assessment.", 500);
}
};
// ─── QUIZ SUBMIT ──────────────────────────────────────────────────────────────
exports.submitUnitQuiz = async (req, res) => {
try {
const { courseId, unitId, quizId } = req.params;
const { answers = {} } = req.body;
const user_id = req.user.user_id;
const unit = await Unit.findOne({ where: { unit_id: unitId, course_id: courseId, ...notDeleted } });
if (!unit) return R.error(res, "Unit not found.", 404);
const quiz = await UnitQuiz.findOne({
where: { quiz_id: quizId, unit_id: unitId, ...notDeleted },
include: [{
model: QuizQuestion, as: "questions",
where: notDeleted, required: false,
include: [{ model: QuizOption, as: "options" }],
}],
});
if (!quiz) return R.error(res, "Quiz not found.", 404);
const priorAttempts = await QuizAttempt.findAll({
where: { quiz_id: quiz.quiz_id, user_id },
attributes: ["attempt_id", "score", "passed", "createdAt"],
});
const status = getAttemptStatus(priorAttempts);
if (!status.can_attempt) {
if (status.cooldown_until) {
return R.error(res, "Please wait a bit before retaking this quiz — check the quiz screen for when you can try again.", 429);
}
return R.error(res, `You've used all ${MAX_ATTEMPTS} attempts for this ${ATTEMPT_WINDOW_HOURS}-hour window — check the quiz screen for when it resets.`, 429);
}
const { totalPoints, earnedPoints, score } = gradeSubmission(quiz.questions ?? [], answers);
const passed = score >= (quiz.passing_score ?? 70);
const attempt = await QuizAttempt.create({
user_id,
quiz_id: quiz.quiz_id,
course_id: courseId,
attempt_number: priorAttempts.length + 1,
answers,
total_points: totalPoints,
earned_points: earnedPoints,
score,
passing_score: quiz.passing_score ?? 70,
passed,
});
return R.success(res, "Quiz submitted.", {
attempt_id: attempt.attempt_id,
attempt_number: attempt.attempt_number,
score,
passed,
passing_score: attempt.passing_score,
total_points: totalPoints,
earned_points: earnedPoints,
attempts_remaining: Math.max(0, status.attempts_remaining - 1),
});
} catch (err) {
console.error("[CLIENT][QUIZ][SUBMIT]", err);
return R.error(res, "Could not submit quiz.", 500);
}
};
exports.submitCourseAssessment = async (req, res) => {
try {
const { courseId, assessmentId } = req.params;
const { answers = {} } = req.body;
const user_id = req.user.user_id;
const assessment = await CourseAssessment.findOne({
where: { assessment_id: assessmentId, course_id: courseId, ...notDeleted },
include: [{
model: QuizQuestion, as: "questions",
where: notDeleted, required: false,
include: [{ model: QuizOption, as: "options" }],
}],
});
if (!assessment) return R.error(res, "Assessment not found.", 404);
const priorAttempts = await QuizAttempt.findAll({
where: { assessment_id: assessment.assessment_id, user_id },
attributes: ["attempt_id", "score", "passed", "createdAt"],
});
const status = getAttemptStatus(priorAttempts);
if (!status.can_attempt) {
if (status.cooldown_until) {
return R.error(res, "Please wait a bit before retaking this quiz — check the quiz screen for when you can try again.", 429);
}
return R.error(res, `You've used all ${MAX_ATTEMPTS} attempts for this ${ATTEMPT_WINDOW_HOURS}-hour window — check the quiz screen for when it resets.`, 429);
}
const { totalPoints, earnedPoints, score } = gradeSubmission(assessment.questions ?? [], answers);
const passed = score >= (assessment.passing_score ?? 70);
const attempt = await QuizAttempt.create({
user_id,
assessment_id: assessment.assessment_id,
course_id: courseId,
attempt_number: priorAttempts.length + 1,
answers,
total_points: totalPoints,
earned_points: earnedPoints,
score,
passing_score: assessment.passing_score ?? 70,
passed,
});
let course_completed = false;
if (passed) {
course_completed = true;
const course = await Course.findOne({ where: { course_id: courseId }, attributes: ["course_id", "title"] });
const totalCompleted = await QuizAttempt.count({
where: { user_id, passed: true, assessment_id: { [Op.ne]: null } },
distinct: true,
col: "assessment_id",
});
await onCourseCompleted(user_id, courseId, totalCompleted, course?.title ?? null);
}
return R.success(res, "Assessment submitted.", {
attempt_id: attempt.attempt_id,
attempt_number: attempt.attempt_number,
score,
passed,
passing_score: attempt.passing_score,
total_points: totalPoints,
earned_points: earnedPoints,
attempts_remaining: Math.max(0, status.attempts_remaining - 1),
course_completed,
});
} catch (err) {
console.error("[CLIENT][ASSESSMENT][SUBMIT]", err);
return R.error(res, "Could not submit assessment.", 500);
}
};
// ─── UUID LOOKUPS (task requirement detail blocks) ────────────────────────────
exports.getCourseByUuid = async (req, res) => {
try {
const { uuid } = req.params;
const course = await Course.findOne({
where: { uuid, ...notDeleted },
attributes: ["course_id", "uuid", "title", "description", "level", "subscription"],
});
if (!course) return R.error(res, "Course not found.", 404);
return R.success(res, "Course retrieved.", course);
} catch (err) {
console.error("[CLIENT][COURSES][BY UUID]", err);
return R.error(res, "Could not retrieve course.", 500);
}
};
exports.getUnitByUuid = async (req, res) => {
try {
const { uuid } = req.params;
const unit = await Unit.findOne({
where: { uuid, ...notDeleted },
attributes: ["unit_id", "uuid", "title", "description"],
include: [{ model: Course, as: "course", attributes: ["course_id", "title"] }],
});
if (!unit) return R.error(res, "Unit not found.", 404);
return R.success(res, "Unit retrieved.", unit);
} catch (err) {
console.error("[CLIENT][UNITS][BY UUID]", err);
return R.error(res, "Could not retrieve unit.", 500);
}
};
exports.getLessonsByUnitUuid = async (req, res) => {
try {
const { uuid } = req.params;
const unit = await Unit.findOne({
where: { uuid, ...notDeleted },
attributes: ["unit_id", "uuid", "title", "description", "order_index"],
include: [
{ model: Course, as: "course", attributes: ["course_id", "title"] },
{
model: Lesson,
as: "lessons",
where: notDeleted,
required: false,
attributes: ["lesson_id", "uuid", "title", "description", "order_index"],
include: [{ model: LessonPage, as: "page", attributes: ["blocks"], required: false }],
order: [["order_index", "ASC"]],
},
],
});
if (!unit) return R.error(res, "Unit not found.", 404);
const lessons = (unit.lessons ?? [])
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
.map((l) => ({
lesson_id: l.lesson_id,
uuid: l.uuid,
title: l.title,
description: l.description,
order_index: l.order_index ?? 0,
blocks: l.page?.blocks ?? [],
}));
return R.success(res, "Unit lessons retrieved.", {
unit_id: unit.unit_id,
uuid: unit.uuid,
title: unit.title,
description: unit.description,
course: unit.course ?? null,
lessons,
});
} catch (err) {
console.error("[CLIENT][UNITS][LESSONS BY UUID]", err);
return R.error(res, "Could not retrieve unit lessons.", 500);
}
};
exports.getLessonByUuid = async (req, res) => {
try {
const { uuid } = req.params;
const lesson = await Lesson.findOne({
where: { uuid, ...notDeleted },
attributes: ["lesson_id", "uuid", "title", "description"],
include: [
{
model: LessonPage,
as: "page",
attributes: ["blocks"],
required: false,
},
{
model: Unit,
as: "unit",
attributes: ["unit_id", "title", "order_index"],
include: [{ model: Course, as: "course", attributes: ["course_id", "title"] }],
},
],
});
if (!lesson) return R.error(res, "Lesson not found.", 404);
const data = {
lesson_id: lesson.lesson_id,
uuid: lesson.uuid,
title: lesson.title,
description: lesson.description,
blocks: lesson.page?.blocks ?? [],
unit: lesson.unit ?? null,
};
return R.success(res, "Lesson retrieved.", data);
} catch (err) {
console.error("[CLIENT][LESSONS][BY UUID]", err);
return R.error(res, "Could not retrieve lesson.", 500);
}
};
+240
View File
@@ -0,0 +1,240 @@
/***********************************************************************************************************************************************************************
* File Name: media.controller.js (client)
* Type of Program: Controller
* Description: Secure media delivery for S3/Garage assets only.
*
* Chibisafe assets use their raw file_url directly — no token needed.
* The block content already has the URL saved at CMS time (handleSelect).
*
* S3 Flow:
* 1. POST /client/media/token { asset_id }
* → validates tier access
* → signs JWT with user_id + IP binding
* → returns { token, provider: "s3", file_type }
*
* 2. Browser sets <video/audio src> = API_BASE + "/client/media/stream/" + token
* → Express verifies JWT
* → Checks IP matches the one that issued the token
* → Generates 60s pre-signed Garage URL, proxies bytes
* → Real S3 URL never reaches the browser
*
* Protection layers:
* 1. JWT signature — token can't be forged
* 2. 5-min TTL — token expires quickly
* 3. IP binding — token is useless if shared with another machine
* 4. Token tracking — tokens are tracked; logged after first use
* (range requests from the same token are allowed
* since the browser reuses the token for seeking)
*
* Supported file_type values: video, audio, document, image
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 12, 2026
***********************************************************************************************************************************************************************/
"use strict";
const https = require("https");
const http = require("http");
const jwt = require("jsonwebtoken");
const R = require("../../utils/response.util");
const mdl_Assets = require("../../models/assets/assets.mdl");
const s3 = require("../../services/s3.service");
const MEDIA_SECRET = process.env.MEDIA_JWT_SECRET ?? process.env.JWT_SECRET;
const TOKEN_TTL_SEC = 4 * 60 * 60; // 4 hours — token must outlive the longest video
const SUPPORTED_TYPES = ["video", "audio", "document", "image"];
// ─── In-memory token tracker ──────────────────────────────────────────────────
//
// Tracks tokens that have been used at least once.
// Allows reuse within TTL for range requests (browser seeking reuses the token).
// Auto-cleans after TTL to prevent unbounded memory growth.
// In production with multiple server instances, replace with Redis.
//
const activeTokens = new Map(); // token → { firstUsed, ip }
function trackToken(token, ip) {
if (activeTokens.has(token)) return; // already tracked, allow reuse
activeTokens.set(token, { firstUsed: Date.now(), ip });
setTimeout(() => activeTokens.delete(token), TOKEN_TTL_SEC * 1000);
}
// ─── Helper: resolve client IP ───────────────────────────────────────────────
function resolveIp(req) {
// x-forwarded-for is set by reverse proxies (nginx, Caddy, Cloudflare)
const forwarded = req.headers["x-forwarded-for"];
if (forwarded) return forwarded.split(",")[0].trim();
return req.ip ?? req.socket?.remoteAddress ?? "unknown";
}
// ─── Helper: pipe S3 pre-signed URL to response (Range-aware) ────────────────
function pipeRemoteStream(remoteUrl, req, res) {
const parsed = new URL(remoteUrl);
const transport = parsed.protocol === "https:" ? https : http;
const proxyHeaders = { "User-Agent": "StarrMediaProxy/1.0" };
if (req.headers.range) proxyHeaders["Range"] = req.headers.range;
const proxyReq = transport.request(remoteUrl, { headers: proxyHeaders }, (proxyRes) => {
const status = proxyRes.statusCode === 206 ? 206 : 200;
[
"content-type",
"content-length",
"content-range",
"accept-ranges",
"last-modified",
"etag",
"content-disposition",
].forEach((h) => {
if (proxyRes.headers[h]) res.setHeader(h, proxyRes.headers[h]);
});
res.setHeader("Cache-Control", "no-store");
res.setHeader("X-Content-Type-Options", "nosniff");
res.status(status);
proxyRes.pipe(res);
});
proxyReq.on("error", (err) => {
console.error("[CLIENT][MEDIA][PROXY] Stream error:", err.message);
if (!res.headersSent) res.status(502).json({ message: "Stream unavailable." });
});
req.on("close", () => proxyReq.destroy());
proxyReq.end();
}
// ─── POST /client/media/token ─────────────────────────────────────────────────
//
// S3 assets only — Chibisafe assets use their raw file_url directly.
// Returns: { token, provider: "s3", file_type }
exports.issueToken = async (req, res) => {
try {
const { asset_id } = req.body;
if (!asset_id) return R.error(res, "asset_id is required.", 400);
const asset = await mdl_Assets.findOne({
where: { asset_id, deletedAt: null },
attributes: ["asset_id", "file_type", "storage_provider", "storage_key", "mime_type", "thumbnail_storage_key"],
});
if (!asset) return R.error(res, "Asset not found.", 404);
if (!SUPPORTED_TYPES.includes(asset.file_type)) {
return R.error(res, `Asset type "${asset.file_type}" is not supported.`, 400);
}
if (asset.storage_provider !== "s3") {
return R.error(res, "Token flow is for S3 assets only. Use the raw file_url for other providers.", 400);
}
// ── Bind token to the requester's IP ──────────────────────────────────────
const ip = resolveIp(req);
const token = jwt.sign(
{
asset_id,
user_id: req.user.user_id,
storage_key: asset.storage_key,
file_type: asset.file_type,
mime_type: asset.mime_type,
ip, // ← IP binding — verified on every stream request
},
MEDIA_SECRET,
{ expiresIn: TOKEN_TTL_SEC }
);
// ── Presign thumbnail URL so the browser can load it directly ─────────────
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
}
}
return R.success(res, "Token issued.", {
token,
provider: "s3",
file_type: asset.file_type,
thumbnail_url,
});
} catch (err) {
console.error("[CLIENT][MEDIA][TOKEN]", err);
return R.error(res, "Could not issue media token.", 500);
}
};
// ─── GET /client/media/stream/:token ─────────────────────────────────────────
//
// Called ONLY by the browser's <video>/<audio>/document element.
// Never called via axios — that would consume the stream as JSON.
//
// Protection checks (in order):
// 1. JWT signature valid
// 2. Token not expired (TTL enforced by JWT)
// 3. Requester IP matches the IP that issued the token
//
// Range requests for the same token are allowed (browser seeking).
exports.streamAsset = async (req, res) => {
const { token } = req.params;
// ── CORS ──────────────────────────────────────────────────────────────────
const allowedOrigin = process.env.FRONTEND_URL ?? "http://localhost:5173";
res.setHeader("Access-Control-Allow-Origin", allowedOrigin);
res.setHeader("Access-Control-Allow-Credentials", "true");
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Range, Authorization");
res.setHeader("Access-Control-Expose-Headers", "Content-Range, Content-Length, Accept-Ranges, Content-Disposition");
if (req.method === "OPTIONS") return res.sendStatus(204);
// ── Block direct browser navigation ──────────────────────────────────────
// Sec-Fetch-Mode is "navigate" when a user pastes the URL into the address
// bar or opens it in a new tab. Legitimate <video src> requests use "no-cors"
// and fetch() calls use "cors" — both are allowed.
const fetchMode = req.headers["sec-fetch-mode"];
if (fetchMode === "navigate") {
return res.status(401).json({ message: "Unauthorized." });
}
// ── Verify JWT ────────────────────────────────────────────────────────────
let payload;
try {
payload = jwt.verify(token, MEDIA_SECRET);
} catch {
return res.status(401).json({ message: "Invalid or expired media token." });
}
const { storage_key, ip: tokenIp } = payload;
if (!storage_key) return res.status(401).json({ message: "Unauthorized." });
// ── IP binding check ──────────────────────────────────────────────────────
const requestIp = resolveIp(req);
if (tokenIp && requestIp !== tokenIp) {
console.warn(`[CLIENT][MEDIA][STREAM] IP mismatch — token: ${tokenIp}, request: ${requestIp}`);
return res.status(403).json({ message: "Token IP mismatch." });
}
// ── Track token (allow reuse for range requests) ──────────────────────────
trackToken(token, requestIp);
// ── Generate pre-signed URL and proxy bytes ───────────────────────────────
let presignedUrl;
try {
presignedUrl = await s3.getSignedDownloadUrl(storage_key, 60);
} catch (err) {
console.error("[CLIENT][MEDIA][STREAM] Pre-sign failed:", err.message);
return res.status(500).json({ message: "Could not resolve media stream." });
}
return pipeRemoteStream(presignedUrl, req, res);
};
@@ -0,0 +1,86 @@
/***********************************************************************************************************************************************************************
* File Name : notification.controller.js
* Type : Controller (Client)
* Description : Per-user notification management.
* GET /client/notifications — paginated list for the auth user
* GET /client/notifications/unseen — unseen count
* PATCH /client/notifications/:id/seen — mark one as seen
* PATCH /client/notifications/seen-all — mark all as seen
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 19, 2026
***********************************************************************************************************************************************************************/
const UserNotification = require('../../models/notifications/user_notification.mdl');
const R = require('../../utils/response.util');
// ─── GET /client/notifications ────────────────────────────────────────────────
async function list(req, res) {
try {
const userId = req.user.user_id;
const page = Math.max(1, parseInt(req.query.page) || 1);
const limit = Math.min(50, parseInt(req.query.limit) || 20);
const offset = (page - 1) * limit;
const { count, rows } = await UserNotification.findAndCountAll({
where: { user_id: userId },
order: [['createdAt', 'DESC']],
limit,
offset,
});
return R.success(res, 'Notifications fetched.', {
notifications: rows,
pagination: { page, limit, total: count, pages: Math.ceil(count / limit) },
});
} catch (err) {
console.error('[CLIENT NOTIFICATION] list error:', err);
return R.error(res, 'Failed to fetch notifications.');
}
}
// ─── GET /client/notifications/unseen ────────────────────────────────────────
async function unseenCount(req, res) {
if (!req.user) return R.success(res, 'Unseen count fetched.', { count: null });
try {
const count = await UserNotification.count({
where: { user_id: req.user.user_id, seen: false },
});
return R.success(res, 'Unseen count fetched.', { count });
} catch (err) {
console.error('[CLIENT NOTIFICATION] unseenCount error:', err);
return R.error(res, 'Failed to fetch unseen count.');
}
}
// ─── PATCH /client/notifications/:id/seen ────────────────────────────────────
async function markSeen(req, res) {
try {
const notification = await UserNotification.findOne({
where: { notification_id: req.params.id, user_id: req.user.user_id },
});
if (!notification) return R.error(res, 'Notification not found.', 404);
await notification.update({ seen: true, seen_at: new Date() });
return R.success(res, 'Notification marked as seen.', notification);
} catch (err) {
console.error('[CLIENT NOTIFICATION] markSeen error:', err);
return R.error(res, 'Failed to mark notification as seen.');
}
}
// ─── PATCH /client/notifications/seen-all ────────────────────────────────────
async function markAllSeen(req, res) {
try {
const now = new Date();
const [count] = await UserNotification.update(
{ seen: true, seen_at: now },
{ where: { user_id: req.user.user_id, seen: false } }
);
return R.success(res, `${count} notification(s) marked as seen.`, { count });
} catch (err) {
console.error('[CLIENT NOTIFICATION] markAllSeen error:', err);
return R.error(res, 'Failed to mark all notifications as seen.');
}
}
module.exports = { list, unseenCount, markSeen, markAllSeen };
+95 -7
View File
@@ -1,23 +1,28 @@
/***********************************************************************************************************************************************************************
* File Name: profile.controller.js (client)
* Type of Program: Controller
* Description: Self-service profile management for CLIENT users.
* Description: Self-service profile management for all end users.
* All routes require: authenticate → requireClient()
*
* Endpoints:
* GET /api/client/profile → view own profile
* PUT /api/client/profile → update personal_info
* GET /api/client/sessions → view own active sessions
* DELETE /api/client/sessions/:id → revoke a specific session
* GET /api/client/profile → view own profile
* PUT /api/client/profile → update personal_info
* GET /api/client/sessions → view own active sessions
* DELETE /api/client/sessions/:id → revoke a specific session
* GET /api/client/achievements → view own achievements
*
* Author: rgrgogu
* Date Created: Oct. 6, 2025
***********************************************************************************************************************************************************************/
const mdl_Users = require('../../models/users/users.mdl');
const mdl_UserSessions = require('../../models/users/user_sessions.mdl');
const mdl_Achievements = require('../../models/users/achievements.mdl');
const logActivity = require('../../utils/logActivity.util');
const R = require('../../utils/response.util');
const { uploadFile, deleteFile } = require('../../services/s3.service');
// ─── GET own profile ───────────────────────────────────────────────────────────
exports.getProfile = async (req, res) => {
try {
const user = await mdl_Users.findByPk(req.user.user_id, {
@@ -30,6 +35,7 @@ exports.getProfile = async (req, res) => {
};
// ─── PUT update own profile ────────────────────────────────────────────────────
exports.updateProfile = async (req, res) => {
try {
const user = await mdl_Users.findByPk(req.user.user_id);
@@ -51,6 +57,8 @@ exports.updateProfile = async (req, res) => {
attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] },
});
logActivity(req.user.user_id, 'update_profile');
return R.success(res, 'Profile updated.', updated);
} catch (err) {
console.error('[CLIENT] updateProfile error:', err);
@@ -59,11 +67,12 @@ exports.updateProfile = async (req, res) => {
};
// ─── GET own sessions ──────────────────────────────────────────────────────────
exports.getSessions = async (req, res) => {
try {
const sessions = await mdl_UserSessions.findAll({
where: { user_id: req.user.user_id, is_active: true },
order: [['createdAt', 'DESC']],
where: { user_id: req.user.user_id, is_active: true },
order: [['createdAt', 'DESC']],
attributes: { exclude: ['refresh_token_hash'] },
});
return R.success(res, 'Sessions retrieved.', sessions);
@@ -73,6 +82,7 @@ exports.getSessions = async (req, res) => {
};
// ─── DELETE revoke a session ───────────────────────────────────────────────────
exports.revokeSession = async (req, res) => {
try {
const session = await mdl_UserSessions.findOne({
@@ -85,8 +95,86 @@ exports.revokeSession = async (req, res) => {
logout_info: { date: new Date().toISOString(), ip_address: req.ip },
});
logActivity(req.user.user_id, 'revoke_session', { entityType: 'session', entityId: session.session_id });
return R.success(res, 'Session revoked.');
} catch (err) {
return R.error(res, 'Could not revoke session.', 500);
}
};
// ─── POST upload own avatar ────────────────────────────────────────────────────
exports.uploadAvatar = async (req, res) => {
try {
if (!req.file) return R.error(res, 'No file provided.', 400);
const user = await mdl_Users.findByPk(req.user.user_id);
// Remove old avatar from S3 before replacing
const oldKey = user.personal_info?.avatar?.uuid;
if (oldKey) await deleteFile(oldKey).catch(() => {});
const { url, uuid } = await uploadFile({
buffer: req.file.buffer,
originalname: req.file.originalname,
mimetype: req.file.mimetype,
ownerType: 'avatar',
});
const merged = {
...(user.personal_info || {}),
avatar: {
url,
uuid,
name: req.file.originalname,
mime_type: req.file.mimetype,
size: req.file.size,
},
};
await user.update({ personal_info: merged });
const updated = await mdl_Users.findByPk(req.user.user_id, {
attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] },
});
return R.success(res, 'Avatar updated.', updated);
} catch (err) {
console.error('[CLIENT] uploadAvatar error:', err);
return R.error(res, 'Avatar upload failed.', 500);
}
};
// ─── DELETE remove own avatar ──────────────────────────────────────────────────
exports.deleteAvatar = async (req, res) => {
try {
const user = await mdl_Users.findByPk(req.user.user_id);
const key = user.personal_info?.avatar?.uuid;
if (!key) return R.error(res, 'No avatar to remove.', 404);
await deleteFile(key).catch(() => {});
const merged = { ...(user.personal_info || {}), avatar: null };
await user.update({ personal_info: merged });
return R.success(res, 'Avatar removed.');
} catch (err) {
console.error('[CLIENT] deleteAvatar error:', err);
return R.error(res, 'Could not remove avatar.', 500);
}
};
// ─── GET own achievements ──────────────────────────────────────────────────────
exports.getAchievements = async (req, res) => {
try {
const achievements = await mdl_Achievements.findAll({
where: { user_id: req.user.user_id },
order: [['granted_at', 'DESC']],
});
return R.success(res, 'Achievements retrieved.', achievements);
} catch (err) {
return R.error(res, 'Could not retrieve achievements.', 500);
}
};
+682
View File
@@ -0,0 +1,682 @@
/***********************************************************************************************************************************************************************
* File Name: task.controller.js (client)
* Type of Program: Controller
* Description: Client-level task access.
* Users can view groups they belong to, task lists assigned to those
* groups, tasks within those lists, and submit work for tasks.
* Read-only except for completions.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 13, 2026
***********************************************************************************************************************************************************************/
const { Op } = require('sequelize');
const sequelize = require('../../config/db.config');
const { Task, TaskList, TaskRequirement, TaskListGroup } = require('../../models/task/task.mdl');
const { TaskCompletion, TaskCompletionFile } = require('../../models/task/task_completion.mdl');
const { TaskLinkVisit, TaskProgress } = require('../../models/task/task_progress.mdl');
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
const mdl_Users = require('../../models/users/users.mdl');
const { userExclude } = require('../../models/task/task.attributes');
const { clientExclude } = require('../../models/task/task_completion.attributes');
const logActivity = require('../../utils/logActivity.util');
const R = require('../../utils/response.util');
// =============================================================================
// ── GROUPS ────────────────────────────────────────────────────────────────────
// =============================================================================
// ─── GET MY GROUPS ────────────────────────────────────────────────────────────
// GET /client/groups
// Returns all active groups the authenticated user belongs to.
exports.getMyGroups = async (req, res) => {
try {
const groups = await mdl_UserGroups.findAll({
include: [
{
model: mdl_Users,
as: 'members',
where: { user_id: req.user.user_id },
attributes: [],
through: {
model: mdl_UserGroupMembers,
attributes: ['joined_at'],
where: { deletedAt: null },
},
},
],
where: { is_active: true },
attributes: ['group_id', 'name', 'group_code', 'description'],
order: [['name', 'ASC']],
});
return R.success(res, 'Groups retrieved.', groups);
} catch (err) {
console.error('[CLIENT][GET MY GROUPS]', err);
return R.error(res, 'Could not retrieve groups.', 500);
}
};
// ─── GET ONE GROUP ────────────────────────────────────────────────────────────
// GET /client/groups/:groupId
// Returns group info — verifies the user is a member before responding.
exports.getMyGroup = async (req, res) => {
try {
const { groupId } = req.params;
const group = await mdl_UserGroups.findOne({
where: { group_id: groupId, is_active: true },
attributes: ['group_id', 'name', 'group_code', 'description'],
include: [
{
model: mdl_Users,
as: 'members',
where: { user_id: req.user.user_id },
attributes: [],
through: {
model: mdl_UserGroupMembers,
attributes: [],
where: { deletedAt: null },
},
},
],
});
if (!group) return R.error(res, 'Group not found or you are not a member.', 404);
return R.success(res, 'Group retrieved.', group);
} catch (err) {
console.error('[CLIENT][GET MY GROUP]', err);
return R.error(res, 'Could not retrieve group.', 500);
}
};
// =============================================================================
// ── TASK LISTS ────────────────────────────────────────────────────────────────
// =============================================================================
// ─── Helper: verify user is member of group ───────────────────────────────────
const isMember = async (userId, groupId) => {
const membership = await mdl_UserGroupMembers.findOne({
where: { user_id: userId, group_id: groupId, deletedAt: null },
});
return !!membership;
};
// ─────────────────────────────────────────────────────────────────────────────
// REPLACEMENT: getGroupTaskList in task.controller.js (client)
//
// GET /client/groups/:groupId/task-lists/:taskListId?status=ongoing|done|overdue
//
// has_completed is now computed per-task as: ALL of the task's requirements
// individually have a completion signal — matching RequirementsStatusPanel's
// "Overall progress: X / Y done" logic exactly.
//
// Per-requirement-type completion:
// upload_file → task has at least one TaskCompletion (binary, task-level)
// visit_link → a TaskLinkVisit exists for THIS requirement_id
// read_course/
// read_unit/
// read_lesson → a TaskProgress with completed=true exists for THIS
// requirement_id (+ reference_id)
//
// Task bucket:
// done → every requirement passes its check above
// (a task with zero requirements is vacuously "ongoing", per
// earlier spec — zero requirements should not normally happen)
// overdue → not done AND task.deadline < now
// ongoing → otherwise
// ─────────────────────────────────────────────────────────────────────────────
exports.getGroupTaskList = async (req, res) => {
try {
const { groupId, taskListId } = req.params;
const { status } = req.query; // optional: 'ongoing' | 'done' | 'overdue'
const userId = req.user.user_id;
const member = await isMember(userId, groupId);
if (!member) return R.error(res, 'Group not found or you are not a member.', 403);
const taskList = await TaskList.findOne({
where: { task_list_id: taskListId },
attributes: { exclude: userExclude },
include: [
{
model: mdl_UserGroups,
as: 'groups',
where: { group_id: groupId },
attributes: [],
through: { model: TaskListGroup, attributes: [] },
},
{
model: Task,
as: 'tasks',
required: false,
attributes: { exclude: userExclude },
include: [{
model: TaskRequirement,
as: 'requirements',
attributes: { exclude: userExclude },
order: [['order', 'ASC']],
}],
order: [['createdAt', 'ASC']],
},
],
});
if (!taskList) return R.error(res, 'Task list not found or not assigned to your group.', 404);
const json = taskList.toJSON();
const tasks = json.tasks ?? [];
const taskIds = tasks.map((t) => t.task_id);
// ── Fetch user's completion signals for these tasks ────────────────────
const [completions, linkVisits, progressRows] = await Promise.all([
taskIds.length
? TaskCompletion.findAll({
where: { task_id: { [Op.in]: taskIds }, user_id: userId },
attributes: ['task_id'],
})
: [],
taskIds.length
? TaskLinkVisit.findAll({
where: { task_id: { [Op.in]: taskIds }, user_id: userId },
attributes: ['task_id', 'requirement_id'],
})
: [],
taskIds.length
? TaskProgress.findAll({
where: { task_id: { [Op.in]: taskIds }, user_id: userId, completed: true },
attributes: ['task_id', 'requirement_id', 'reference_id'],
})
: [],
]);
// ── Lookup sets/maps for per-requirement checks ─────────────────────────
const tasksWithCompletion = new Set(completions.map((c) => c.task_id));
const visitedRequirementIds = new Set(linkVisits.map((v) => v.requirement_id));
const completedProgressKeys = new Set(
progressRows.map((p) => `${p.requirement_id}:${p.reference_id}`)
);
const now = Date.now();
// ── Bucket each task by per-requirement completion ──────────────────────
const bucketedTasks = tasks.map((task) => {
const requirements = task.requirements ?? [];
const allRequirementsDone = requirements.length > 0 && requirements.every((r) => {
switch (r.type) {
case 'upload_file':
return tasksWithCompletion.has(task.task_id);
case 'visit_link':
return visitedRequirementIds.has(r.requirement_id);
case 'read_course':
case 'read_unit':
case 'read_lesson':
return completedProgressKeys.has(`${r.requirement_id}:${r.reference_id}`);
default:
return true; // unknown requirement types don't block completion
}
});
const has_completed = allRequirementsDone;
let bucket;
if (has_completed) {
bucket = 'done';
} else if (task.deadline && new Date(task.deadline).getTime() < now) {
bucket = 'overdue';
} else {
bucket = 'ongoing';
}
return { ...task, has_completed, _bucket: bucket };
});
// ── Filter by requested status, strip internal _bucket field ──────────
const filteredTasks = status
? bucketedTasks.filter((t) => t._bucket === status)
: bucketedTasks;
json.tasks = filteredTasks.map(({ _bucket, ...rest }) => rest);
return R.success(res, 'Task list retrieved.', json);
} catch (err) {
console.error('[CLIENT][GET GROUP TASK LIST]', err);
return R.error(res, 'Could not retrieve task list.', 500);
}
};
// ─────────────────────────────────────────────────────────────────────────────
// REPLACEMENT: getGroupTaskLists in task.controller.js (client) — plural
//
// GET /client/groups/:groupId/task-lists?status=ongoing|done|overdue
//
// Updated to match getGroupTaskList (singular): has_completed per task now
// means ALL of that task's requirements individually have a completion signal
// (not just "any"), matching RequirementsStatusPanel's "X / Y done" logic.
//
// TaskList bucket (based on per-task has_completed, computed below):
// TaskList has zero tasks → Ongoing (nothing to do yet)
// ALL tasks have has_completed → Done
// NOT all done AND any incomplete
// task has deadline < now → Overdue
// Otherwise → Ongoing
// ─────────────────────────────────────────────────────────────────────────────
exports.getGroupTaskLists = async (req, res) => {
try {
const { groupId } = req.params;
const { status } = req.query; // optional: 'ongoing' | 'done' | 'overdue'
const userId = req.user.user_id;
const member = await isMember(userId, groupId);
if (!member) return R.error(res, 'Group not found or you are not a member.', 403);
// ── Fetch ALL task lists assigned to this group, no status filter ─────
const taskLists = await TaskList.findAll({
include: [
{
model: mdl_UserGroups,
as: 'groups',
where: { group_id: groupId },
attributes: [],
through: { model: TaskListGroup, attributes: [] },
},
{
model: Task,
as: 'tasks',
required: false,
attributes: { exclude: userExclude },
include: [
{
model: TaskRequirement,
as: 'requirements',
attributes: { exclude: userExclude },
order: [['order', 'ASC']],
},
],
order: [['createdAt', 'ASC']],
},
],
attributes: { exclude: userExclude },
order: [['createdAt', 'ASC']],
});
// ── Gather all task_ids across the group's task lists ──────────────────
const allTasks = taskLists.flatMap((tl) => tl.tasks ?? []);
const taskIds = allTasks.map((t) => t.task_id);
// ── Fetch user's completion signals for these tasks ────────────────────
const [completions, linkVisits, progressRows] = await Promise.all([
taskIds.length
? TaskCompletion.findAll({
where: { task_id: { [Op.in]: taskIds }, user_id: userId },
attributes: ['task_id'],
})
: [],
taskIds.length
? TaskLinkVisit.findAll({
where: { task_id: { [Op.in]: taskIds }, user_id: userId },
attributes: ['task_id', 'requirement_id'],
})
: [],
taskIds.length
? TaskProgress.findAll({
where: { task_id: { [Op.in]: taskIds }, user_id: userId, completed: true },
attributes: ['task_id', 'requirement_id', 'reference_id'],
})
: [],
]);
// ── Lookup sets/maps for per-requirement checks ─────────────────────────
const tasksWithCompletion = new Set(completions.map((c) => c.task_id));
const visitedRequirementIds = new Set(linkVisits.map((v) => v.requirement_id));
const completedProgressKeys = new Set(
progressRows.map((p) => `${p.requirement_id}:${p.reference_id}`)
);
const now = Date.now();
// ── Compute per-task has_completed via per-requirement checks ──────────
const computeHasCompleted = (task) => {
const requirements = task.requirements ?? [];
if (requirements.length === 0) return false; // vacuously not done
return requirements.every((r) => {
switch (r.type) {
case 'upload_file':
return tasksWithCompletion.has(task.task_id);
case 'visit_link':
return visitedRequirementIds.has(r.requirement_id);
case 'read_course':
case 'read_unit':
case 'read_lesson':
return completedProgressKeys.has(`${r.requirement_id}:${r.reference_id}`);
default:
return true;
}
});
};
// ── Bucket each task list based on per-task has_completed ───────────────
const bucketed = taskLists.map((tl) => {
const json = tl.toJSON();
const tasks = json.tasks ?? [];
tasks.forEach((task) => {
task.has_completed = computeHasCompleted(task);
});
let bucket;
if (tasks.length === 0) {
bucket = 'ongoing';
} else {
const allDone = tasks.every((t) => t.has_completed);
if (allDone) {
bucket = 'done';
} else {
const anyOverdue = tasks.some((t) =>
!t.has_completed && t.deadline && new Date(t.deadline).getTime() < now
);
bucket = anyOverdue ? 'overdue' : 'ongoing';
}
}
return { ...json, tasks, _bucket: bucket };
});
// ── Filter by requested status, then strip internal _bucket field ──────
const filtered = status
? bucketed.filter((tl) => tl._bucket === status)
: bucketed;
const data = filtered.map(({ _bucket, ...rest }) => rest);
return R.success(res, 'Task lists retrieved.', data);
} catch (err) {
console.error('[CLIENT][GET GROUP TASK LISTS]', err);
return R.error(res, 'Could not retrieve task lists.', 500);
}
};
// =============================================================================
// ── TASKS ─────────────────────────────────────────────────────────────────────
// =============================================================================
// ─── GET ONE TASK ─────────────────────────────────────────────────────────────
// GET /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId
//
// Returns the task with its requirements + the user's latest completion.
exports.getTask = async (req, res) => {
try {
const { groupId, taskListId, taskId } = req.params;
const member = await isMember(req.user.user_id, groupId);
if (!member) return R.error(res, 'Group not found or you are not a member.', 403);
const task = await Task.findOne({
where: { task_id: taskId, task_list_id: taskListId },
attributes: { exclude: userExclude },
include: [
{
model: TaskList,
as: 'taskList',
attributes: { exclude: userExclude },
include: [
{
model: mdl_UserGroups,
as: 'groups',
where: { group_id: groupId },
attributes: [],
through: { model: TaskListGroup, attributes: [] },
},
],
},
{
model: TaskRequirement,
as: 'requirements',
attributes: { exclude: userExclude },
order: [['order', 'ASC']],
},
{
// Latest completion by this user
model: TaskCompletion,
as: 'completions',
where: { user_id: req.user.user_id },
required: false,
attributes: { exclude: clientExclude },
include: [{
model: TaskCompletionFile,
as: 'files',
attributes: { exclude: clientExclude },
separate: true,
order: [['createdAt', 'ASC']],
}],
order: [['submitted_at', 'DESC']],
limit: 1,
},
],
});
if (!task) return R.error(res, 'Task not found or not accessible.', 404);
// Flatten: expose latest_completion directly instead of array
const data = task.toJSON();
data.latest_completion = data.completions?.[0] ?? null;
delete data.completions;
return R.success(res, 'Task retrieved.', data);
} catch (err) {
console.error('[CLIENT][GET TASK]', err);
return R.error(res, 'Could not retrieve task.', 500);
}
};
// =============================================================================
// ── SUBMISSIONS ───────────────────────────────────────────────────────────────
// =============================================================================
// ─── GET MY SUBMISSIONS FOR A TASK ───────────────────────────────────────────
// GET /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/completions
// Returns all past completions by this user for this task (newest first).
exports.getMySubmissions = async (req, res) => {
try {
const { groupId, taskListId, taskId } = req.params;
const member = await isMember(req.user.user_id, groupId);
if (!member) return R.error(res, 'Group not found or you are not a member.', 403);
const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId } });
if (!task) return R.error(res, 'Task not found.', 404);
const completions = await TaskCompletion.findAll({
where: { task_id: taskId, user_id: req.user.user_id },
attributes: { exclude: clientExclude },
include: [{
model: TaskCompletionFile,
as: 'files',
attributes: { exclude: clientExclude },
}],
order: [['submitted_at', 'DESC']],
});
return R.success(res, 'Completions retrieved.', completions);
} catch (err) {
console.error('[CLIENT][GET MY SUBMISSIONS]', err);
return R.error(res, 'Could not retrieve completions.', 500);
}
};
// ─── SUBMIT ───────────────────────────────────────────────────────────────────
//
// Adds validation against the task's `upload_file` TaskRequirement:
// - allowed_file_types: array of uppercase extensions (e.g. ["PDF","DOCX",...])
// - max_file_count: integer cap on number of files per completion
//
// Validation happens BEFORE creating the TaskCompletion row, at submit time only
// (not at /upload). If validation fails, the transaction is rolled back and a
// 400 is returned — the already-uploaded files remain orphaned in S3, which is
// acceptable per current design (no cleanup-on-reject requirement).
exports.submitTask = async (req, res) => {
const t = await sequelize.transaction();
try {
const { groupId, taskListId, taskId } = req.params;
const { note, files = [] } = req.body;
const member = await isMember(req.user.user_id, groupId);
if (!member) { await t.rollback(); return R.error(res, 'Group not found or you are not a member.', 403); }
const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId }, transaction: t });
if (!task) { await t.rollback(); return R.error(res, 'Task not found.', 404); }
if (!files.length) {
await t.rollback();
return R.error(res, 'At least one file is required to submit.', 400);
}
// Validate file entries have required fields
const invalid = files.some((f) => !f.file_url || !f.file_name);
if (invalid) {
await t.rollback();
return R.error(res, 'Each file must have file_url and file_name.', 400);
}
// ── Validate against upload_file requirement (if defined) ──────────────
const uploadRequirement = await TaskRequirement.findOne({
where: { task_id: taskId, type: 'upload_file' },
transaction: t,
});
if (uploadRequirement) {
// ── max_file_count ───────────────────────────────────────────────────
const maxFiles = uploadRequirement.max_file_count;
if (maxFiles && files.length > maxFiles) {
await t.rollback();
return R.error(
res,
`You can only submit up to ${maxFiles} file${maxFiles !== 1 ? 's' : ''} for this task.`,
400
);
}
// ── allowed_file_types ───────────────────────────────────────────────
const allowedTypes = (uploadRequirement.allowed_file_types ?? [])
.map((ext) => String(ext).toUpperCase());
if (allowedTypes.length) {
const rejected = files.filter((f) => {
const ext = (f.file_name.split('.').pop() ?? '').toUpperCase();
return !allowedTypes.includes(ext);
});
if (rejected.length) {
await t.rollback();
const rejectedNames = rejected.map((f) => f.file_name).join(', ');
return R.error(
res,
`These files are not allowed: ${rejectedNames}. Allowed types: ${allowedTypes.join(', ')}.`,
400
);
}
}
}
// ── Create completion ──────────────────────────────────────────────────
const completion = await TaskCompletion.create({
task_id: taskId,
user_id: req.user.user_id,
note: note || null,
submitted_at: new Date(),
createdBy: req.user.user_id,
updatedBy: req.user.user_id,
}, { transaction: t });
const fileRows = files.map((f) => ({
completion_id: completion.completion_id,
file_url: f.file_url,
file_name: f.file_name,
file_size: f.file_size ?? null,
mime_type: f.mime_type ?? null,
storage_key: f.storage_key ?? null,
createdBy: req.user.user_id,
updatedBy: req.user.user_id,
}));
await TaskCompletionFile.bulkCreate(fileRows, { transaction: t });
await t.commit();
// Return full completion with files
const full = await TaskCompletion.findByPk(completion.completion_id, {
attributes: { exclude: clientExclude },
include: [{
model: TaskCompletionFile,
as: 'files',
attributes: { exclude: clientExclude },
}],
});
logActivity(req.user.user_id, 'submit_task', {
entityType: 'task',
entityId: Number(taskId),
});
return R.success(res, 'Task submitted successfully.', full, 201);
} catch (err) {
await t.rollback();
console.error('[CLIENT][SUBMIT TASK]', err);
return R.error(res, 'Could not submit task.', 500);
}
};
// ─────────────────────────────────────────────────────────────────────────────
// ADD THIS to the bottom of task.controller.js (client)
// ─────────────────────────────────────────────────────────────────────────────
// ─── GET LATEST COMPLETION ────────────────────────────────────────────────────
// GET /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/completions/latest
// Returns only the most recent completion for this user on this task.
// Returns null if the user has not submitted yet — that is valid.
exports.getLatestCompletion = async (req, res) => {
try {
const { groupId, taskListId, taskId } = req.params;
const member = await isMember(req.user.user_id, groupId);
if (!member) return R.error(res, 'Group not found or you are not a member.', 403);
const task = await Task.findOne({
where: { task_id: taskId, task_list_id: taskListId },
});
if (!task) return R.error(res, 'Task not found.', 404);
const completion = await TaskCompletion.findOne({
where: { task_id: taskId, user_id: req.user.user_id },
attributes: { exclude: clientExclude },
include: [{
model: TaskCompletionFile,
as: 'files',
attributes: { exclude: clientExclude },
separate: true,
order: [['createdAt', 'ASC']],
}],
order: [['submitted_at', 'DESC']],
});
return R.success(res, 'Latest completion retrieved.', completion ?? null);
} catch (err) {
console.error('[CLIENT][GET LATEST COMPLETION]', err);
return R.error(res, 'Could not retrieve latest completion.', 500);
}
};
@@ -0,0 +1,172 @@
/***********************************************************************************************************************************************************************
* File Name: task_download.controller.js (client)
* Type of Program: Controller
* Description: Proxies file downloads for task completion attachments through
* the backend, so the raw Garage/S3 URL is never exposed to the
* browser. Sets Content-Disposition: attachment with the original
* filename.
*
* storage_key is DERIVED from file_url at request time (no schema
* change needed) by stripping the known S3_PUBLIC_URL + bucket
* prefix, since both are constants defined in s3.service.js / .env.
*
* Route: GET /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/completions/:completionId/files/:fileId/download
*
* Access: only the completion's owner (req.user.user_id === completion.user_id)
* can download — admin downloads go through a separate admin route.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 15, 2026
***********************************************************************************************************************************************************************/
const { Task, TaskList, TaskListGroup } = require('../../models/task/task.mdl');
const { TaskCompletion, TaskCompletionFile } = require('../../models/task/task_completion.mdl');
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
const { getObjectStream } = require('../../services/s3.service');
const R = require('../../utils/response.util');
// ─── Helper: verify user is a member of the group ─────────────────────────────
const isMember = async (userId, groupId) => {
const membership = await mdl_UserGroupMembers.findOne({
where: { user_id: userId, group_id: groupId, deletedAt: null },
});
return !!membership;
};
// ─── Helper: derive S3 storage_key from a public file_url ─────────────────────
// Strips "{S3_PUBLIC_URL}/{S3_BUCKET}/" prefix, leaving e.g. "images/uuid.jpg"
const deriveStorageKey = (fileUrl) => {
const publicUrl = (process.env.S3_PUBLIC_URL || '').replace(/\/$/, '');
const bucket = process.env.S3_BUCKET || 'philproperties';
const prefix = `${publicUrl}/${bucket}/`;
if (fileUrl && fileUrl.startsWith(prefix)) {
return fileUrl.slice(prefix.length);
}
return null;
};
// =============================================================================
// ── STREAM FILE (inline preview — no Content-Disposition: attachment) ────────
// =============================================================================
//
// GET /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/completions/:completionId/files/:fileId/stream
//
// Used by FilePreview.jsx for <img>/<video>/<audio>/<iframe> src — proxies the
// object inline so the raw Garage/S3 URL never appears, but does NOT force
// download (no Content-Disposition: attachment).
exports.streamCompletionFile = async (req, res) => {
try {
const { groupId, taskListId, taskId, completionId, fileId } = req.params;
const member = await isMember(req.user.user_id, groupId);
if (!member) return R.error(res, 'Group not found or you are not a member.', 403);
const task = await Task.findOne({
where: { task_id: taskId, task_list_id: taskListId },
include: [{
model: TaskList,
as: 'taskList',
required: true,
include: [{
model: mdl_UserGroups,
as: 'groups',
where: { group_id: groupId },
required: true,
attributes: [],
through: { model: TaskListGroup, attributes: [] },
}],
}],
});
if (!task) return R.error(res, 'Task not found or not accessible.', 404);
const completion = await TaskCompletion.findOne({
where: { completion_id: completionId, task_id: taskId, user_id: req.user.user_id },
});
if (!completion) return R.error(res, 'Completion not found.', 404);
const file = await TaskCompletionFile.findOne({
where: { file_id: fileId, completion_id: completionId },
});
if (!file) return R.error(res, 'File not found.', 404);
const storageKey = deriveStorageKey(file.file_url);
if (!storageKey) {
return R.error(res, 'This file cannot be previewed (unrecognized storage URL).', 422);
}
const { stream, contentType, contentLength } = await getObjectStream(storageKey);
res.setHeader('Content-Type', contentType || file.mime_type || 'application/octet-stream');
if (contentLength) res.setHeader('Content-Length', contentLength);
// No Content-Disposition — browser renders inline based on Content-Type
stream.pipe(res);
} catch (err) {
console.error('[CLIENT][STREAM COMPLETION FILE]', err);
return R.error(res, 'Could not load file.', 500);
}
};
// =============================================================================
// ── DOWNLOAD FILE ──────────────────────────────────────────────────────────────
// =============================================================================
exports.downloadCompletionFile = async (req, res) => {
try {
const { groupId, taskListId, taskId, completionId, fileId } = req.params;
// ── Validate member ───────────────────────────────────────────────────
const member = await isMember(req.user.user_id, groupId);
if (!member) return R.error(res, 'Group not found or you are not a member.', 403);
// ── Validate task belongs to task list + group ────────────────────────
const task = await Task.findOne({
where: { task_id: taskId, task_list_id: taskListId },
include: [{
model: TaskList,
as: 'taskList',
required: true,
include: [{
model: mdl_UserGroups,
as: 'groups',
where: { group_id: groupId },
required: true,
attributes: [],
through: { model: TaskListGroup, attributes: [] },
}],
}],
});
if (!task) return R.error(res, 'Task not found or not accessible.', 404);
// ── Validate completion belongs to this user + task ───────────────────
const completion = await TaskCompletion.findOne({
where: { completion_id: completionId, task_id: taskId, user_id: req.user.user_id },
});
if (!completion) return R.error(res, 'Completion not found.', 404);
// ── Validate file belongs to completion ───────────────────────────────
const file = await TaskCompletionFile.findOne({
where: { file_id: fileId, completion_id: completionId },
});
if (!file) return R.error(res, 'File not found.', 404);
// ── Derive storage_key from file_url ──────────────────────────────────
const storageKey = deriveStorageKey(file.file_url);
if (!storageKey) {
return R.error(res, 'This file cannot be downloaded (unrecognized storage URL).', 422);
}
// ── Stream from S3/Garage ──────────────────────────────────────────────
const { stream, contentType, contentLength } = await getObjectStream(storageKey);
res.setHeader('Content-Type', contentType || file.mime_type || 'application/octet-stream');
if (contentLength) res.setHeader('Content-Length', contentLength);
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(file.file_name)}"`);
stream.pipe(res);
} catch (err) {
console.error('[CLIENT][DOWNLOAD COMPLETION FILE]', err);
return R.error(res, 'Could not download file.', 500);
}
};
@@ -0,0 +1,371 @@
/***********************************************************************************************************************************************************************
* File Name: task_progress.controller.js (client)
* Type of Program: Controller
* Description: Client-side progress tracking via UPSERT for all requirement types.
*
* GET /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/progress
* → returns full progress snapshot: { link_visits, progress }
*
* POST /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/requirements/:requirementId/visit
* → UPSERT TaskLinkVisit (visit_link)
*
* POST /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/requirements/:requirementId/progress
* → UPSERT TaskProgress (read_lesson) + derives read_unit + read_course
*
* UPSERT keys:
* TaskLinkVisit : (requirement_id, user_id)
* TaskProgress : (requirement_id, user_id, reference_id)
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 13, 2026
***********************************************************************************************************************************************************************/
const sequelize = require('../../config/db.config');
const { Task, TaskRequirement } = require('../../models/task/task.mdl');
const { TaskLinkVisit, TaskProgress } = require('../../models/task/task_progress.mdl');
const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
const logActivity = require('../../utils/logActivity.util');
const R = require('../../utils/response.util');
// ─── Helper: verify user is member of group ───────────────────────────────────
const isMember = async (userId, groupId) => {
const membership = await mdl_UserGroupMembers.findOne({
where: { user_id: userId, group_id: groupId, deletedAt: null },
});
return !!membership;
};
// ─── Helper: verify requirement belongs to task ───────────────────────────────
const getRequirement = async (requirementId, taskId) => {
return TaskRequirement.findOne({
where: { requirement_id: requirementId, task_id: taskId },
});
};
// ─── Helper: derive unit completion ──────────────────────────────────────────
// Unit is complete when ALL read_lesson progress rows under this unit requirement
// for this user are marked completed.
const deriveUnitCompletion = async (userId, unitRequirementId, t) => {
const rows = await TaskProgress.findAll({
where: {
requirement_id: unitRequirementId,
user_id: userId,
type: 'read_lesson',
},
transaction: t,
});
if (!rows.length) return false;
return rows.every((r) => r.completed);
};
// ─── Helper: derive course completion ────────────────────────────────────────
// Course is complete when ALL read_unit progress rows under this course requirement
// for this user are marked completed.
const deriveCourseCompletion = async (userId, courseRequirementId, t) => {
const rows = await TaskProgress.findAll({
where: {
requirement_id: courseRequirementId,
user_id: userId,
type: 'read_unit',
},
transaction: t,
});
if (!rows.length) return false;
return rows.every((r) => r.completed);
};
// =============================================================================
// ── GET FULL PROGRESS SNAPSHOT ────────────────────────────────────────────────
// =============================================================================
// GET /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/progress
// Called once on ViewTaskDetails mount.
// Returns { link_visits: [], progress: [] } — frontend builds lookup maps from these.
exports.getTaskProgress = async (req, res) => {
try {
const { groupId, taskListId, taskId } = req.params;
const member = await isMember(req.user.user_id, groupId);
if (!member) return R.error(res, 'Group not found or you are not a member.', 403);
const task = await Task.findOne({
where: { task_id: taskId, task_list_id: taskListId },
});
if (!task) return R.error(res, 'Task not found.', 404);
const [linkVisits, progress] = await Promise.all([
TaskLinkVisit.findAll({
where: { task_id: taskId, user_id: req.user.user_id },
attributes: ['visit_id', 'requirement_id', 'visited_at'],
}),
TaskProgress.findAll({
where: { task_id: taskId, user_id: req.user.user_id },
attributes: ['progress_id', 'requirement_id', 'reference_id', 'type', 'completed', 'completed_at'],
}),
]);
return R.success(res, 'Task progress retrieved.', { link_visits: linkVisits, progress });
} catch (err) {
console.error('[CLIENT][GET TASK PROGRESS]', err);
return R.error(res, 'Could not retrieve task progress.', 500);
}
};
// =============================================================================
// ── VISIT LINK (UPSERT) ───────────────────────────────────────────────────────
// =============================================================================
// POST /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/requirements/:requirementId/visit
//
// UPSERT on (requirement_id, user_id):
// First visit → INSERT new row
// Revisit → UPDATE visited_at to NOW()
exports.visitLink = async (req, res) => {
const t = await sequelize.transaction();
try {
const { groupId, taskListId, taskId, requirementId } = req.params;
const member = await isMember(req.user.user_id, groupId);
if (!member) { await t.rollback(); return R.error(res, 'Group not found or you are not a member.', 403); }
const requirement = await getRequirement(requirementId, taskId);
if (!requirement) { await t.rollback(); return R.error(res, 'Requirement not found.', 404); }
if (requirement.type !== 'visit_link') {
await t.rollback();
return R.error(res, 'Requirement is not a visit_link type.', 400);
}
const now = new Date();
const [record, created] = await TaskLinkVisit.upsert(
{
task_id: taskId,
requirement_id: requirementId,
user_id: req.user.user_id,
visited_at: now,
createdBy: req.user.user_id,
updatedBy: req.user.user_id,
},
{
conflictFields: ['requirement_id', 'user_id'],
returning: true,
transaction: t,
}
);
await t.commit();
if (created) {
logActivity(req.user.user_id, 'visit_link', {
entityType: 'task',
entityId: Number(taskId),
details: { requirement_id: requirementId },
});
}
return R.success(
res,
created ? 'Link visited.' : 'Link visit updated.',
{ requirement_id: requirementId, visited_at: now },
created ? 201 : 200
);
} catch (err) {
await t.rollback();
console.error('[CLIENT][VISIT LINK]', err);
return R.error(res, 'Could not record link visit.', 500);
}
};
// =============================================================================
// ── UPDATE LESSON PROGRESS (UPSERT — derives unit + course) ──────────────────
// =============================================================================
// POST /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/requirements/:requirementId/progress
//
// Body:
// {
// reference_id : UUID — lesson_id being marked
// completed : boolean
// unit_requirement_id? : UUID — read_unit requirement this lesson belongs to
// course_requirement_id?: UUID — read_course requirement this unit belongs to
// }
//
// Flow:
// 1. UPSERT lesson progress row
// 2. If unit_requirement_id provided → derive unit completion → UPSERT unit row
// 3. If course_requirement_id provided → derive course completion → UPSERT course row
exports.updateProgress = async (req, res) => {
const t = await sequelize.transaction();
try {
const { groupId, taskListId, taskId, requirementId } = req.params;
const { reference_id, completed, unit_requirement_id, course_requirement_id } = req.body;
if (!reference_id || completed === undefined) {
await t.rollback();
return R.error(res, 'reference_id and completed are required.', 400);
}
const member = await isMember(req.user.user_id, groupId);
if (!member) { await t.rollback(); return R.error(res, 'Group not found or you are not a member.', 403); }
const requirement = await getRequirement(requirementId, taskId);
if (!requirement) { await t.rollback(); return R.error(res, 'Requirement not found.', 404); }
const ALLOWED = ['read_lesson', 'read_unit', 'read_course'];
if (!ALLOWED.includes(requirement.type)) {
await t.rollback();
return R.error(res, `Cannot update progress for requirement type: ${requirement.type}.`, 400);
}
const now = new Date();
const userId = req.user.user_id;
// ── Direct UPSERT for read_unit / read_course ─────────────────────────
if (requirement.type === 'read_unit' || requirement.type === 'read_course') {
await TaskProgress.upsert(
{
task_id: taskId,
requirement_id: requirementId,
user_id: userId,
reference_id,
type: requirement.type,
completed: !!completed,
completed_at: completed ? now : null,
createdBy: userId,
updatedBy: userId,
},
{
conflictFields: ['requirement_id', 'user_id', 'reference_id'],
transaction: t,
}
);
await t.commit();
return R.success(res, 'Progress updated.', {
requirement_id: requirementId,
reference_id,
completed: !!completed,
completed_at: completed ? now : null,
});
}
// ── 1. UPSERT lesson ──────────────────────────────────────────────────
await TaskProgress.upsert(
{
task_id: taskId,
requirement_id: requirementId,
user_id: userId,
reference_id,
type: 'read_lesson',
completed: !!completed,
completed_at: completed ? now : null,
createdBy: userId,
updatedBy: userId,
},
{
conflictFields: ['requirement_id', 'user_id', 'reference_id'],
transaction: t,
}
);
// ── 2. Derive + UPSERT unit ───────────────────────────────────────────
if (unit_requirement_id) {
const unitReq = await getRequirement(unit_requirement_id, taskId);
if (unitReq && unitReq.type === 'read_unit') {
const unitDone = await deriveUnitCompletion(userId, unit_requirement_id, t);
await TaskProgress.upsert(
{
task_id: taskId,
requirement_id: unit_requirement_id,
user_id: userId,
reference_id: unitReq.reference_id,
type: 'read_unit',
completed: unitDone,
completed_at: unitDone ? now : null,
createdBy: userId,
updatedBy: userId,
},
{
conflictFields: ['requirement_id', 'user_id', 'reference_id'],
transaction: t,
}
);
// ── 3. Derive + UPSERT course ─────────────────────────────────
if (course_requirement_id) {
const courseReq = await getRequirement(course_requirement_id, taskId);
if (courseReq && courseReq.type === 'read_course') {
const courseDone = await deriveCourseCompletion(userId, course_requirement_id, t);
await TaskProgress.upsert(
{
task_id: taskId,
requirement_id: course_requirement_id,
user_id: userId,
reference_id: courseReq.reference_id,
type: 'read_course',
completed: courseDone,
completed_at: courseDone ? now : null,
createdBy: userId,
updatedBy: userId,
},
{
conflictFields: ['requirement_id', 'user_id', 'reference_id'],
transaction: t,
}
);
}
}
}
}
await t.commit();
return R.success(res, 'Progress updated.', {
requirement_id: requirementId,
reference_id,
completed: !!completed,
completed_at: completed ? now : null,
});
} catch (err) {
await t.rollback();
console.error('[CLIENT][UPDATE PROGRESS]', err);
return R.error(res, 'Could not update progress.', 500);
}
};
// ─────────────────────────────────────────────────────────────────────────────
// NOTE: getLatestCompletion lives in task.controller.js as it shares
// the isMember + Task lookup pattern already established there.
// Add this function to the BOTTOM of task.controller.js:
//
// exports.getLatestCompletion = async (req, res) => {
// try {
// const { groupId, taskListId, taskId } = req.params;
//
// const member = await isMember(req.user.user_id, groupId);
// if (!member) return R.error(res, 'Group not found or you are not a member.', 403);
//
// const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId } });
// if (!task) return R.error(res, 'Task not found.', 404);
//
// const completion = await TaskCompletion.findOne({
// where: { task_id: taskId, user_id: req.user.user_id },
// attributes: { exclude: clientExclude },
// include: [{
// model: TaskCompletionFile,
// as: 'files',
// attributes: { exclude: clientExclude },
// separate: true,
// order: [['createdAt', 'ASC']],
// }],
// order: [['submitted_at', 'DESC']],
// });
//
// return R.success(res, 'Latest completion retrieved.', completion ?? null);
// } catch (err) {
// console.error('[CLIENT][GET LATEST COMPLETION]', err);
// return R.error(res, 'Could not retrieve latest completion.', 500);
// }
// };
@@ -0,0 +1,118 @@
/***********************************************************************************************************************************************************************
* File Name: task_upload.controller.js (client)
* Type of Program: Controller
* Description: Handles file uploads for task completion attachments.
* Files are uploaded to S3 (Garage) via s3.service.js.
* Returns file metadata for use in the completion submit payload.
*
* This is intentionally separate from the completion submit endpoint
* so the client can upload files first, then submit completion with
* the returned file references — matching the two-step flow in
* ViewTaskDetails.jsx handleSubmit().
*
* Route: POST /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/upload
*
* Author: rgrgogu
* Date Created: Jun. 13, 2026
***********************************************************************************************************************************************************************/
const { Task, TaskList, TaskListGroup } = require('../../models/task/task.mdl');
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
const { uploadFile } = require('../../services/s3.service');
const R = require('../../utils/response.util');
// ─── Helper: verify user is a member of the group ─────────────────────────────
const isMember = async (userId, groupId) => {
const membership = await mdl_UserGroupMembers.findOne({
where: { user_id: userId, group_id: groupId, deletedAt: null },
});
return !!membership;
};
// ─── Helper: verify task belongs to task list AND is assigned to this group ───
const getAccessibleTask = async (groupId, taskListId, taskId) => {
return Task.findOne({
where: { task_id: taskId, task_list_id: taskListId },
include: [
{
model: TaskList,
as: 'taskList',
required: true,
include: [
{
model: mdl_UserGroups,
as: 'groups',
where: { group_id: groupId },
required: true,
attributes: [],
through: { model: TaskListGroup, attributes: [] },
},
],
},
],
});
};
// ─── Resolve S3 ownerType from mime type ──────────────────────────────────────
const resolveOwnerType = (mimetype = '') => {
if (mimetype.startsWith('image/')) return 'image';
if (mimetype.startsWith('video/')) return 'video';
if (mimetype.startsWith('audio/')) return 'audio';
return 'document';
};
// =============================================================================
// ── UPLOAD FILE ───────────────────────────────────────────────────────────────
// =============================================================================
// POST /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/upload
//
// Accepts: multipart/form-data
// file — single file field (multer attaches to req.file)
//
// Returns:
// {
// file_url : "https://garage.philproperties.com/philproperties/documents/uuid.pdf",
// file_name : "social_media_slides.pdf",
// file_size : 2400000,
// mime_type : "application/pdf",
// storage_key: "documents/uuid.pdf" ← for admin reference / future delete
// }
exports.uploadTaskFile = async (req, res) => {
try {
const { groupId, taskListId, taskId } = req.params;
// ── Validate member ───────────────────────────────────────────────────
const member = await isMember(req.user.user_id, groupId);
if (!member) return R.error(res, 'Group not found or you are not a member.', 403);
// ── Validate task accessibility ───────────────────────────────────────
const task = await getAccessibleTask(groupId, taskListId, taskId);
if (!task) return R.error(res, 'Task not found or not accessible.', 404);
// ── Validate file presence ────────────────────────────────────────────
if (!req.file) return R.error(res, 'No file provided.', 400);
const { buffer, originalname, mimetype, size } = req.file;
const ownerType = resolveOwnerType(mimetype);
// ── Upload to S3 ──────────────────────────────────────────────────────
const { url, uuid: storage_key } = await uploadFile({
buffer,
originalname,
mimetype,
ownerType,
});
return R.success(res, 'File uploaded successfully.', {
file_url: url,
file_name: originalname,
file_size: size,
mime_type: mimetype,
storage_key,
}, 201);
} catch (err) {
console.error('[CLIENT][UPLOAD TASK FILE]', err);
return R.error(res, 'Could not upload file.', 500);
}
};
+340
View File
@@ -0,0 +1,340 @@
/***********************************************************************************************************************************************************************
* File Name: tiers.controller.js (client)
* Type of Program: Controller
* Description: User-facing tier and payment endpoints.
* - View active tier + history
* - Browse active plans (with courses per plan)
* - PayPal redirect checkout (create order → capture → cancel)
* - View own payment history
* Author: rgrgogu
* Date Created: Jun. 6, 2026
* Modified: Jun. 9, 2026
***********************************************************************************************************************************************************************/
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl');
const mdl_Payments = require('../../models/tiers/payments.mdl');
const mdl_PlanCourses = require('../../models/tiers/plan_courses.mdl');
const { onTierActivated } = require('../../services/achievements.service');
const { Course } = require('../../models/courses/courses.mdl');
const paypal = require('../../services/paypal.service');
const R = require('../../utils/response.util');
require('../../models/tiers/tier.associations');
// ─── Promo codes ──────────────────────────────────────────────────────────────
const PROMO_CODES = {
PHIL10: 10, // $10 flat discount
};
const calculateCheckoutAmount = (price, promoCode) => {
const subtotalCents = Math.round(Number(price) * 100);
const normalizedCode = promoCode?.trim?.().toUpperCase?.() ?? null;
const discountCents = normalizedCode && PROMO_CODES[normalizedCode]
? Math.min(PROMO_CODES[normalizedCode] * 100, subtotalCents)
: 0;
const totalCents = Math.max(subtotalCents - discountCents, 0);
return {
promoCode: discountCents > 0 ? normalizedCode : null,
subtotal: (subtotalCents / 100).toFixed(2),
discount: (discountCents / 100).toFixed(2),
total: (totalCents / 100).toFixed(2),
};
};
// ─── MY TIER ──────────────────────────────────────────────────────────────────
exports.getMyTier = async (req, res) => {
try {
const tier = await mdl_UserTiers.findOne({
where: { user_id: req.user.user_id, status: 'active' },
order: [['createdAt', 'DESC']],
});
return R.success(res, 'Active tier retrieved.', tier ?? { tier: 'free', status: 'active' });
} catch (err) {
console.error('[CLIENT][GET MY TIER]', err);
return R.error(res, 'Could not retrieve tier.', 500);
}
};
exports.getMyTierHistory = async (req, res) => {
try {
const history = await mdl_UserTiers.findAll({
where: { user_id: req.user.user_id },
order: [['createdAt', 'DESC']],
});
return R.success(res, 'Tier history retrieved.', history);
} catch (err) {
console.error('[CLIENT][GET MY TIER HISTORY]', err);
return R.error(res, 'Could not retrieve tier history.', 500);
}
};
// ─── PLANS (with courses) ─────────────────────────────────────────────────────
exports.getPlans = async (req, res) => {
try {
const plans = await mdl_TierPlans.findAll({
where: { is_active: true },
order: [['tier', 'ASC'], ['duration_days', 'ASC']],
attributes: ['plan_id', 'tier', 'label', 'duration_days', 'price', 'currency'],
include: [{
model: Course,
as: 'courses',
attributes: ['course_id', 'uuid', 'title', 'course_code', 'level', 'duration_seconds'],
through: { attributes: [] },
}],
});
const result = plans.map((p) => {
const plain = p.toJSON();
plain.course_count = plain.courses?.length ?? 0;
return plain;
});
return R.success(res, 'Plans retrieved.', result);
} catch (err) {
console.error('[CLIENT][GET PLANS]', err);
return R.error(res, 'Could not retrieve plans.', 500);
}
};
// ─── PAYPAL CHECKOUT ──────────────────────────────────────────────────────────
exports.createOrder = async (req, res) => {
try {
const { plan_id, promo_code } = req.body;
if (!plan_id) return R.error(res, 'plan_id is required.', 400);
const plan = await mdl_TierPlans.findOne({ where: { plan_id, is_active: true } });
if (!plan) return R.error(res, 'Plan not found or inactive.', 404);
const checkout = calculateCheckoutAmount(plan.price, promo_code);
if (Number(checkout.total) <= 0)
return R.error(res, 'PayPal checkout requires a payable amount.', 400);
const ppOrder = await paypal.createOrder({
amount: checkout.total,
currency: plan.currency,
referenceId: `user_${req.user.user_id}_plan_${plan_id}`,
});
// Extract PayPal approval URL from links array
const approvalUrl = ppOrder.links?.find((l) => l.rel === 'approve')?.href ?? null;
const payment = await mdl_Payments.create({
user_id: req.user.user_id,
plan_id,
status: 'pending',
amount: checkout.total,
currency: plan.currency,
promo_code: checkout.promoCode,
discount: checkout.discount,
provider: 'paypal',
provider_payload: {
order_id: ppOrder.id,
approval_url: approvalUrl,
checkout: {
subtotal: checkout.subtotal,
discount: checkout.discount,
promo_code: checkout.promoCode,
},
},
});
return R.success(res, 'Order created.', {
payment_id: payment.payment_id,
order_id: ppOrder.id,
approval_url: approvalUrl,
amount: checkout.total,
currency: plan.currency,
promo_code: checkout.promoCode,
discount: checkout.discount,
}, 201);
} catch (err) {
console.error('[CLIENT][CREATE ORDER]', err);
return R.error(res, 'Could not create order.', 500);
}
};
exports.captureOrder = async (req, res) => {
try {
const { order_id } = req.body;
if (!order_id) return R.error(res, 'order_id is required.', 400);
const payment = await mdl_Payments.findOne({
where: {
status: 'pending',
provider: 'paypal',
user_id: req.user.user_id,
},
include: [{ model: mdl_TierPlans, as: 'plan' }],
order: [['createdAt', 'DESC']],
});
// Match by order_id inside provider_payload
if (!payment || payment.provider_payload?.order_id !== order_id)
return R.error(res, 'Pending payment not found.', 404);
let captureData;
try {
captureData = await paypal.captureOrder(order_id);
} catch (ppErr) {
await payment.update({
status: 'failed',
provider_payload: {
...payment.provider_payload,
error: ppErr?.response?.data ?? {},
},
});
return R.error(res, 'PayPal capture failed.', 402);
}
const capture = captureData.purchase_units?.[0]?.payments?.captures?.[0];
// Expire current active tier
await mdl_UserTiers.update(
{ status: 'expired' },
{ where: { user_id: req.user.user_id, status: 'active' } }
);
const startsAt = new Date();
const expiresAt = new Date(startsAt.getTime() + payment.plan.duration_days * 86400000);
const newTier = await mdl_UserTiers.create({
user_id: req.user.user_id,
tier: payment.plan.tier,
plan_id: payment.plan_id,
status: 'active',
starts_at: startsAt,
expires_at: expiresAt,
granted_by: null,
});
await payment.update({
status: 'completed',
tier_id: newTier.tier_id,
paid_at: new Date(),
provider_payload: {
...payment.provider_payload,
capture_id: capture?.id,
payer_id: captureData.payer?.payer_id,
capture: captureData,
},
});
// await grantAchievement(req.user.user_id, payment.plan.tier);
await onTierActivated(req.user.user_id, newTier.tier);
return R.success(res, 'Payment successful. Tier activated.', {
tier: newTier.tier,
expires_at: newTier.expires_at,
});
} catch (err) {
console.error('[CLIENT][CAPTURE ORDER]', err);
return R.error(res, 'Could not capture order.', 500);
}
};
exports.cancelOrder = async (req, res) => {
try {
const { order_id } = req.body;
if (!order_id) return R.error(res, 'order_id is required.', 400);
const payment = await mdl_Payments.findOne({
where: { user_id: req.user.user_id, status: 'pending', provider: 'paypal' },
order: [['createdAt', 'DESC']],
});
if (!payment || payment.provider_payload?.order_id !== order_id)
return R.error(res, 'Pending payment not found.', 404);
await payment.update({
status: 'cancelled',
provider_payload: {
...payment.provider_payload,
cancelled_at: new Date().toISOString(),
cancelled_by: 'payer',
},
});
return R.success(res, 'Payment cancelled.');
} catch (err) {
console.error('[CLIENT][CANCEL ORDER]', err);
return R.error(res, 'Could not cancel payment.', 500);
}
};
// ─── MY PAYMENTS ──────────────────────────────────────────────────────────────
exports.getMyPayments = async (req, res) => {
try {
const payments = await mdl_Payments.findAll({
where: { user_id: req.user.user_id },
include: [{ model: mdl_TierPlans, as: 'plan', attributes: ['label', 'tier', 'duration_days'] }],
attributes: { exclude: ['provider_payload'] },
order: [['createdAt', 'DESC']],
});
return R.success(res, 'Payment history retrieved.', payments);
} catch (err) {
console.error('[CLIENT][GET MY PAYMENTS]', err);
return R.error(res, 'Could not retrieve payment history.', 500);
}
};
// — add refundOrder export ────────────────
exports.refundOrder = async (req, res) => {
try {
const user_id = req.user.user_id;
// Get the active tier
const activeTier = await mdl_UserTiers.findOne({
where: { user_id, status: 'active' },
order: [['createdAt', 'DESC']],
});
if (!activeTier) return R.error(res, 'No active tier to refund.', 404);
// Get the completed payment for this tier
const payment = await mdl_Payments.findOne({
where: { user_id, tier_id: activeTier.tier_id, status: 'completed' },
order: [['paid_at', 'DESC']],
});
if (!payment) return R.error(res, 'No completed payment found for this tier.', 404);
// Get capture_id from provider_payload
const captureId = payment.provider_payload?.capture_id;
if (!captureId) return R.error(res, 'Capture ID not found. Cannot process refund.', 400);
// Call PayPal refund API
let refundData;
try {
refundData = await paypal.refundCapture(captureId, payment.amount, payment.currency);
} catch (ppErr) {
console.error('[CLIENT][REFUND] PayPal error:', ppErr?.response?.data);
return R.error(res, 'PayPal refund failed. Please try again.', 402);
}
// Update payment status to refunded
await payment.update({
status: 'refunded',
provider_payload: {
...payment.provider_payload,
refund: refundData,
refunded_at: new Date().toISOString(),
},
});
// Cancel tier at end of period — keep access until expires_at
await activeTier.update({ status: 'revoked' });
return R.success(res, 'Refund processed successfully. Your access will remain until the end of the billing period.', {
refund_id: refundData.id,
status: refundData.status,
expires_at: activeTier.expires_at,
});
} catch (err) {
console.error('[CLIENT][REFUND]', err);
return R.error(res, 'Could not process refund.', 500);
}
};