mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
ready to test
Testing Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user