chore: relocate backend into apps/api ahead of monorepo merge

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 12:20:40 +08:00
co-authored by Claude Sonnet 5
parent af44598df6
commit eaa6d2276a
388 changed files with 0 additions and 13998 deletions
@@ -0,0 +1,246 @@
// controllers/client/advertisements.controller.js
const Advertisement = require("../../models/advertisements/advertisements.mdl");
const mdl_Assets = require("../../models/assets/assets.mdl");
const mediaToken = require("../../services/mediaToken.service");
const R = require('../../utils/response.util');
const { PLACEMENT_MAP } = require("../../models/advertisements/advertisements.placements");
const { Op } = require('sequelize');
const AD_IMAGE_INCLUDE = {
model: mdl_Assets,
as: "image",
attributes: ["asset_id", "display_name", "file_url", "thumbnail_url", "storage_provider", "storage_key", "file_type", "mime_type"],
required: false,
};
const AD_CLIENT_EXCLUDE = ["createdBy", "updatedBy", "deletedBy", "deletedAt", "image_asset_id"];
// ─── Media proxying ─────────────────────────────────────────────────────────
// Mirrors controllers/admin/advertisements.controller.js's attachImageStreamToken.
// Kept duplicated rather than shared to avoid a cross-boundary import between
// admin and client controllers (same rationale as deriveStatus above). Private
// (S3-backed) images never expose a raw file_url — the frontend resolves the
// stream_token through GET /api/client/media/stream/:token instead.
async function attachImageStreamToken(image, req) {
if (!image || image.storage_provider !== "s3" || !mediaToken.SUPPORTED_TYPES.includes(image.file_type)) {
return image;
}
const ip = mediaToken.resolveIp(req);
const { token } = await mediaToken.issueForAsset(image, req.user?.user_id, ip);
image.stream_token = token;
image.file_url = null;
image.thumbnail_url = null;
delete image.storage_key;
return image;
}
// ─── 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";
}
// ─── Live window helper ─────────────────────────────────────────────────────
// "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.
function liveWhere(extra) {
const now = new Date();
return {
...extra,
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 } }] },
],
};
}
// ─── GET ACTIVE ───────────────────────────────────────────────────────────────
//
// Resolves the single highest-priority live advertisement for a given placement.
//
// GET /api/client/advertisements/active?placement=dashboard.hero
//
exports.getActiveAdvertisement = async (req, res) => {
try {
const { placement } = req.query;
if (!placement) return R.error(res, "placement is required.", 400);
if (!PLACEMENT_MAP[placement]) return R.error(res, "Invalid placement.", 400);
const advertisement = await Advertisement.findOne({
where: liveWhere({ placement }),
order: [["order", "ASC"], ["createdAt", "DESC"]],
include: [AD_IMAGE_INCLUDE],
attributes: { exclude: AD_CLIENT_EXCLUDE },
});
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
if (json.image) await attachImageStreamToken(json.image, req);
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);
}
};
// ─── GET ACTIVE (list) ──────────────────────────────────────────────────────
//
// Resolves every live advertisement for a single placement, ordered by
// priority — used by carousel-style slots (e.g. dashboard.hero) that rotate
// through several ads instead of showing only the single highest-priority one.
//
// GET /api/client/advertisements/active-list?placement=dashboard.hero&limit=8
//
exports.getActiveAdvertisementList = async (req, res) => {
try {
const { placement } = req.query;
if (!placement) return R.error(res, "placement is required.", 400);
if (!PLACEMENT_MAP[placement]) return R.error(res, "Invalid placement.", 400);
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 8, 1), 20);
const advertisements = await Advertisement.findAll({
where: liveWhere({ placement }),
order: [["order", "ASC"], ["createdAt", "DESC"]],
include: [AD_IMAGE_INCLUDE],
attributes: { exclude: AD_CLIENT_EXCLUDE },
limit,
});
const data = [];
for (const ad of advertisements) {
const json = ad.toJSON();
json.status = deriveStatus(json);
if (json.image) await attachImageStreamToken(json.image, req);
data.push(json);
}
return R.success(res, "Active advertisements retrieved.", { data });
} catch (err) {
console.error("[CLIENT][ADVERTISEMENT][GET ACTIVE LIST]", err);
return R.error(res, "Could not retrieve advertisements.", 500);
}
};
// ─── GET ACTIVE (batch) ─────────────────────────────────────────────────────
//
// Resolves the highest-priority live advertisement for each of several
// placements in a single round-trip — pages that need multiple simultaneous
// slots (e.g. dashboard.hero + dashboard.popup) use this instead of N calls
// to /active.
//
// GET /api/client/advertisements/active-batch?placements=dashboard.hero,dashboard.popup
//
exports.getActiveAdvertisements = async (req, res) => {
try {
const raw = req.query.placements;
const placements = (Array.isArray(raw) ? raw : String(raw ?? "").split(","))
.map((p) => p.trim())
.filter(Boolean);
if (!placements.length) return R.error(res, "placements is required.", 400);
const invalid = placements.filter((p) => !PLACEMENT_MAP[p]);
if (invalid.length) return R.error(res, `Invalid placement(s): ${invalid.join(", ")}`, 400);
const advertisements = await Advertisement.findAll({
where: liveWhere({ placement: { [Op.in]: placements } }),
order: [["order", "ASC"], ["createdAt", "DESC"]],
include: [AD_IMAGE_INCLUDE],
attributes: { exclude: AD_CLIENT_EXCLUDE },
});
// Keep only the highest-priority row per placement (order ASC, createdAt DESC already applied).
const data = Object.fromEntries(placements.map((p) => [p, null]));
for (const ad of advertisements) {
const json = ad.toJSON();
if (data[json.placement] !== null) continue; // already have the winner for this placement
json.status = deriveStatus(json);
if (json.image) await attachImageStreamToken(json.image, req);
data[json.placement] = json;
}
return R.success(res, "Active advertisements retrieved.", { data });
} catch (err) {
console.error("[CLIENT][ADVERTISEMENT][GET ACTIVE BATCH]", err);
return R.error(res, "Could not retrieve advertisements.", 500);
}
};
// ─── GET BY UUID ──────────────────────────────────────────────────────────────
//
// Resolves a single live advertisement by uuid for its own landing page — the
// destination CTA/banner clicks resolve to when the ad has no redirect_link
// (see /ads/:uuid on the client).
//
// GET /api/client/advertisements/uuid/:uuid
//
exports.getAdvertisementByUuid = async (req, res) => {
try {
const { uuid } = req.params;
if (!uuid) return R.error(res, "uuid is required.", 400);
const advertisement = await Advertisement.findOne({
where: liveWhere({ uuid }),
include: [AD_IMAGE_INCLUDE],
attributes: { exclude: AD_CLIENT_EXCLUDE },
});
if (!advertisement) return R.error(res, "Advertisement not found.", 404);
const json = advertisement.toJSON();
json.status = deriveStatus(json);
if (json.image) await attachImageStreamToken(json.image, req);
return R.success(res, "Advertisement retrieved.", { data: json });
} catch (err) {
console.error("[CLIENT][ADVERTISEMENT][GET BY UUID]", 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,113 @@
/***********************************************************************************************************************************************************************
* 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 { generateCertificate } = require('../../services/certificate.service');
const { ensureCertificateRecord, formatInstructors } = require('../../services/certificate-record.service');
const { fmtDate } = require('../../utils/datetime.util');
const {
Course,
CourseAssessment,
CourseInstructor,
} = require('../../models/courses/courses.associations');
const notDeleted = { deletedAt: null };
// ─── 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. 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';
// ── 3. Resolve or create the certificate record ─────────────────────────────
// Shared with the hourly issuance cron (cron/jobs/issue_certificates.cron.js)
// so both write through the same cert_no/ref_no sequence.
const cert = await ensureCertificateRecord({ userId: user_id, courseId: course.course_id });
if (!cert) {
return R.error(res, 'Certificate not available — course assessment not passed yet.', 403);
}
// Always use live instructors from course_instructors table for the PDF,
// in case they changed since the certificate row was created.
const liveInstructors = formatInstructors(course.instructors ?? []);
// ── 4. Format issued date as MM/DD/YY HH:MM AM/PM ───────────────────────────
const issuedDate = new Date(cert.issued_at);
const dateStr = fmtDate(issuedDate);
// ── 5. 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 ?? '',
});
// ── 6. 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`;
const asciiName = filename.replace(/[^\x20-\x7E]/g, '_').replace(/"/g, '_');
const encodedName = encodeURIComponent(filename);
res.set({
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${asciiName}"; filename*=UTF-8''${encodedName}`,
'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,182 @@
'use strict';
const mdl_CoursePurchase = require('../../models/courses/course_purchases.mdl');
const mdl_Product = require('../../models/courses/products.mdl');
const paymentSvc = require('../../services/payment.service');
const R = require('../../utils/response.util');
const { resolvePurchasable, checkoutPath } = require('../../utils/purchasable.util');
// ─── CREATE ORDER ─────────────────────────────────────────────────────────────
// Despite the "course" naming (historical — this predates Units/Lessons being
// individually purchasable), this endpoint and table are generic: a Product's
// purchasable_type/purchasable_id drives everything below.
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);
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 ${product.purchasable_type}.`, 409);
}
const target = await resolvePurchasable(product.purchasable_type, product.purchasable_id);
if (!target) return R.error(res, 'Purchasable content not found.', 404);
const path = checkoutPath(product.purchasable_type, target);
const ppOrder = await paymentSvc.createOrder('paypal', {
amount: Number(product.price).toFixed(2),
currency: product.currency,
referenceId: `user_${req.user.user_id}_product_${product_id}`,
returnUrl: `${process.env.FRONTEND_URL}${path}`,
cancelUrl: `${process.env.FRONTEND_URL}${path}?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' },
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 paymentSvc.captureOrder(purchase.provider, order_id);
} catch (ppErr) {
await purchase.update({
status: 'failed',
provider_payload: { ...purchase.provider_payload, error: ppErr?.response?.data ?? {} },
});
return R.error(res, 'Payment capture failed.', 402);
}
const capture = captureData.purchase_units?.[0]?.payments?.captures?.[0];
// PayPal can return an HTTP 2xx from the capture endpoint even when the
// charge itself was declined or held for review (e.g. capture.status
// "DECLINED"/"PENDING") — axios only throws on non-2xx, so the actual
// status field must be checked explicitly before granting any access.
const captureStatus = capture?.status ?? captureData.status;
if (captureStatus !== 'COMPLETED') {
await purchase.update({
status: 'failed',
provider_payload: { ...purchase.provider_payload, capture: captureData, failed_reason: captureStatus ?? 'unknown' },
});
return R.error(res, `Payment was not completed by PayPal (status: ${captureStatus ?? 'unknown'}).`, 402);
}
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,
},
});
const target = await resolvePurchasable(purchase.product.purchasable_type, purchase.product.purchasable_id);
return R.success(res, 'Payment successful. Access granted.', {
purchase_id: purchase.id,
expires_at: purchase.expires_at,
purchasable_type: purchase.product.purchasable_type,
purchasable_id: purchase.product.purchasable_id,
target_uuid: target?.uuid ?? null,
});
} 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' },
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', 'purchasable_type', 'purchasable_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,755 @@
/***********************************************************************************************************************************************************************
* 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/completed
* → every completed lesson/unit/course for the current user, course-scoped and
* standalone reads unioned together, most-recently-completed first
*
* GET /client/courses/:courseId/progress/summary
* → compact snapshot: lesson counts + percentage + course status
*
* GET /client/courses/:courseId/progress
* → all progress rows for this user + course (flat, frontend builds the map)
*
* GET /client/courses/:courseId/task-context
* → all pending task requirements (read_*) for this course's UUIDs that the user is assigned to
*
* POST /client/courses/:courseId/units/:unitId/lessons/:lessonId/progress
* → UPSERT lesson + derives + UPSERTs parent unit + course in one transaction
* → side-effects: writes to lesson_reading_progress / unit_reading_progress,
* syncs task_progress for matching task requirements,
* returns completed_tasks for any task whose read requirements are now all done
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 21, 2026
***********************************************************************************************************************************************************************/
'use strict';
const { Op } = require('sequelize');
const R = require('../../utils/response.util');
const logActivity = require('../../utils/logActivity.util');
const { recomputeCascade, recordWatchProgress, recordManualComplete } = require('../../services/completion_requirements.service');
const { recordPlaybackPosition } = require('../../services/playback_position.service');
const CourseReadingProgress = require('../../models/courses/course_reading_progress.mdl');
const UnitReadingProgress = require('../../models/courses/unit_reading_progress.mdl');
const LessonReadingProgress = require('../../models/courses/lesson_reading_progress.mdl');
const Certificate = require('../../models/courses/certificate.mdl');
const {
Course, Unit, Lesson,
CourseUnit, UnitLesson,
UnitQuiz, CourseAssessment, QuizAttempt,
} = require('../../models/courses/courses.associations');
const { countCourseLessons, getCourseUnitIds, flattenUnits } = require('../../utils/courses/hierarchy.util');
const { Task, TaskRequirement, TaskListGroup } = require('../../models/task/task.mdl');
const { TaskProgress } = require('../../models/task/task_progress.mdl');
const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
const notDeleted = { deletedAt: null };
// ─── Internal helper: get task list IDs accessible to a user ─────────────────
async function getAccessibleTaskListIds(userId) {
const memberships = await mdl_UserGroupMembers.findAll({
where: { user_id: userId, deletedAt: null },
attributes: ['group_id'],
});
const groupIds = memberships.map((m) => m.group_id);
if (!groupIds.length) return { taskListIds: [], taskListToGroup: {} };
const taskListGroups = await TaskListGroup.findAll({
where: { group_id: groupIds },
attributes: ['task_list_id', 'group_id'],
});
const taskListToGroup = Object.fromEntries(taskListGroups.map((tlg) => [tlg.task_list_id, tlg.group_id]));
return { taskListIds: Object.keys(taskListToGroup), taskListToGroup };
}
// Task-progress auto-sync (read_lesson/read_unit/read_course requirements) now lives in
// services/task_reading_progress_sync.service.js#syncCompletedEntitiesToTaskProgress, called
// directly from completion_requirements.service.js's cascade/recompute functions — covers every
// completion trigger (scroll, watch_percent, manual_complete, pass_quiz, assessment), not just
// this endpoint. getAccessibleTaskListIds stays here (below) since getCourseTaskContext still
// needs its richer { taskListIds, taskListToGroup } shape.
// =============================================================================
// ── IN-PROGRESS COURSES — profile learning progress card ──────────────────────
// =============================================================================
exports.getMyInProgressCourses = async (req, res) => {
try {
const userId = req.user.user_id;
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.', []);
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 [lessons_total, lessons_completed] = await Promise.all([
countCourseLessons(courseId),
CourseReadingProgress.count({
where: { user_id: userId, course_id: courseId, type: 'lesson', status: 'completed' },
}),
]);
// "Reading done" is derived independently from lesson counts — row.status now also
// requires the course assessment to be passed, so it can't be used as the reading gate.
const readingDone = lessons_total > 0 && lessons_completed === lessons_total;
let pending_quizzes = [];
let pending_assessment = null;
let assessment_configured = true;
if (readingDone) {
const courseUnitIds = await getCourseUnitIds(courseId);
const unitQuizzes = courseUnitIds.length ? await UnitQuiz.findAll({
attributes: ['quiz_id', 'title', 'is_required', 'passing_score'],
include: [{
model: Unit,
as: 'unit',
attributes: ['unit_id', 'title'],
where: notDeleted,
required: true,
}],
where: { unit_id: courseUnitIds, ...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,
});
}
}
const assessment = await CourseAssessment.findOne({
attributes: ['assessment_id', 'title', 'is_required', 'passing_score'],
where: { course_id: courseId },
});
assessment_configured = !!assessment;
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: readingDone ? 'completed' : 'in_progress',
assessment_configured,
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);
}
};
// =============================================================================
// ── COMPLETED CONTENT — "live view" of every finished lesson/unit/course ──────
// =============================================================================
// GET /client/courses/completed
// Unions the two completion systems (see completion_requirements.service.js header):
// - CourseReadingProgress — course-scoped lessons/units/courses (course_id NOT NULL)
// - Unit/LessonReadingProgress, filtered to course_id IS NULL — genuinely standalone
// reads. Course-scoped reads also get a best-effort mirror written into these same
// tables (see recomputeCascade's mirrorLessonRead call) but that mirror always
// carries a course_id, so the IS NULL filter here excludes it and avoids double-
// counting the same completion from both systems.
exports.getMyCompletedContent = async (req, res) => {
try {
const userId = req.user.user_id;
const courseScoped = await CourseReadingProgress.findAll({
where: { user_id: userId, status: 'completed' },
attributes: ['reference_id', 'type', 'completed_at'],
include: [{
model: Course,
as: 'course',
attributes: ['course_id', 'uuid', 'title'],
where: notDeleted,
required: true,
}],
});
const completedCourseRows = courseScoped.filter((r) => r.type === 'course');
const completedUnitRows = courseScoped.filter((r) => r.type === 'unit');
const completedLessonRows = courseScoped.filter((r) => r.type === 'lesson');
const unitUuids = completedUnitRows.map((r) => r.reference_id);
const lessonUuids = completedLessonRows.map((r) => r.reference_id);
const [unitRows, lessonRows, standaloneUnits, standaloneLessons] = await Promise.all([
unitUuids.length
? Unit.findAll({ where: { uuid: unitUuids, ...notDeleted }, attributes: ['unit_id', 'uuid', 'title'] })
: [],
lessonUuids.length
? Lesson.findAll({ where: { uuid: lessonUuids, ...notDeleted }, attributes: ['lesson_id', 'uuid', 'title'] })
: [],
UnitReadingProgress.findAll({
where: { user_id: userId, status: 'completed', course_id: null },
attributes: ['completed_at'],
include: [{
model: Unit, as: 'unit', attributes: ['unit_id', 'uuid', 'title'], where: notDeleted, required: true,
}],
}),
LessonReadingProgress.findAll({
where: { user_id: userId, status: 'completed', course_id: null },
attributes: ['completed_at'],
include: [{
model: Lesson, as: 'lesson', attributes: ['lesson_id', 'uuid', 'title'], where: notDeleted, required: true,
}],
}),
]);
const unitByUuid = Object.fromEntries(unitRows.map((u) => [u.uuid, u]));
const lessonByUuid = Object.fromEntries(lessonRows.map((l) => [l.uuid, l]));
const courseIds = completedCourseRows.map((r) => r.course.course_id);
const certificates = courseIds.length
? await Certificate.findAll({
where: { user_id: userId, course_id: courseIds },
attributes: ['uuid', 'cert_no', 'issued_at', 'score', 'course_id'],
})
: [];
const certByCourseId = Object.fromEntries(certificates.map((c) => [String(c.course_id), c]));
const courses = completedCourseRows.map((r) => {
const cert = certByCourseId[String(r.course.course_id)] ?? null;
return {
course_id: r.course.course_id,
uuid: r.course.uuid,
title: r.course.title,
completed_at: r.completed_at,
certificate: cert ? { uuid: cert.uuid, cert_no: cert.cert_no, issued_at: cert.issued_at, score: cert.score } : null,
};
}).sort((a, b) => new Date(b.completed_at) - new Date(a.completed_at));
const units = [
...completedUnitRows
.filter((r) => unitByUuid[r.reference_id])
.map((r) => ({
unit_id: unitByUuid[r.reference_id].unit_id,
uuid: r.reference_id,
title: unitByUuid[r.reference_id].title,
completed_at: r.completed_at,
course: { course_id: r.course.course_id, title: r.course.title },
})),
...standaloneUnits.map((r) => ({
unit_id: r.unit.unit_id,
uuid: r.unit.uuid,
title: r.unit.title,
completed_at: r.completed_at,
course: null,
})),
].sort((a, b) => new Date(b.completed_at) - new Date(a.completed_at));
const lessons = [
...completedLessonRows
.filter((r) => lessonByUuid[r.reference_id])
.map((r) => ({
lesson_id: lessonByUuid[r.reference_id].lesson_id,
uuid: r.reference_id,
title: lessonByUuid[r.reference_id].title,
completed_at: r.completed_at,
course: { course_id: r.course.course_id, title: r.course.title },
})),
...standaloneLessons.map((r) => ({
lesson_id: r.lesson.lesson_id,
uuid: r.lesson.uuid,
title: r.lesson.title,
completed_at: r.completed_at,
course: null,
})),
].sort((a, b) => new Date(b.completed_at) - new Date(a.completed_at));
return R.success(res, 'Completed content retrieved.', {
courses, units, lessons,
counts: { courses: courses.length, units: units.length, lessons: lessons.length },
});
} catch (err) {
console.error('[CLIENT][COURSE READING PROGRESS][COMPLETED CONTENT]', err);
return R.error(res, 'Could not retrieve completed content.', 500);
}
};
// =============================================================================
// ── GET PROGRESS SUMMARY ──────────────────────────────────────────────────────
// =============================================================================
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([
countCourseLessons(courseId),
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 ─────────────────────────────────────────────────────
// =============================================================================
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);
}
};
// =============================================================================
// ── TASK CONTEXT FOR A COURSE ─────────────────────────────────────────────────
// =============================================================================
// GET /client/courses/:courseId/task-context
// Returns all pending task requirements (read_course / read_unit / read_lesson)
// whose reference_id matches this course, any of its units, or any of its lessons,
// filtered to tasks the current user is actually assigned to (via group membership).
// UnitList calls this on mount when no task context is passed via navigation state.
exports.getCourseTaskContext = 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', 'uuid'],
include: [{
model: Unit,
as: 'units',
where: notDeleted,
required: false,
attributes: ['unit_id', 'uuid'],
through: { attributes: [] },
include: [{
model: Lesson,
as: 'lessons',
where: notDeleted,
required: false,
attributes: ['lesson_id', 'uuid'],
through: { attributes: [] },
}],
}],
});
if (!course) return R.error(res, 'Course not found.', 404);
const units = course.units ?? [];
const allUuids = [
course.uuid,
...units.map((u) => u.uuid),
...units.flatMap((u) => (u.lessons ?? []).map((l) => l.uuid)),
];
const { taskListIds, taskListToGroup } = await getAccessibleTaskListIds(userId);
if (!taskListIds.length) {
return R.success(res, 'Task context retrieved.', { has_task: false, contexts: [] });
}
const requirements = await TaskRequirement.findAll({
where: {
type: { [Op.in]: ['read_course', 'read_unit', 'read_lesson'] },
reference_id: { [Op.in]: allUuids },
deletedAt: null,
},
include: [{
model: Task,
as: 'task',
where: { task_list_id: { [Op.in]: taskListIds }, deletedAt: null },
required: true,
attributes: ['task_id', 'name', 'task_list_id'],
}],
attributes: ['requirement_id', 'task_id', 'type', 'reference_id', 'reference_label'],
});
if (!requirements.length) {
return R.success(res, 'Task context retrieved.', { has_task: false, contexts: [] });
}
// Mark which requirements are already completed
const taskIds = [...new Set(requirements.map((r) => r.task_id))];
const doneProgress = await TaskProgress.findAll({
where: { task_id: { [Op.in]: taskIds }, user_id: userId, completed: true },
attributes: ['requirement_id', 'reference_id'],
});
const doneSet = new Set(doneProgress.map((p) => `${p.requirement_id}:${p.reference_id}`));
const contexts = requirements.map((req) => ({
task_id: req.task_id,
task_name: req.task.name,
task_list_id: req.task.task_list_id,
group_id: taskListToGroup[req.task.task_list_id],
requirement_id: req.requirement_id,
type: req.type,
reference_id: req.reference_id,
reference_label: req.reference_label,
already_completed: doneSet.has(`${req.requirement_id}:${req.reference_id}`),
}));
return R.success(res, 'Task context retrieved.', { has_task: true, contexts });
} catch (err) {
console.error('[CLIENT][COURSE TASK CONTEXT]', err);
return R.error(res, 'Could not retrieve task context.', 500);
}
};
// =============================================================================
// ── TASK CONTEXT FOR A STANDALONE LESSON / UNIT ───────────────────────────────
// =============================================================================
//
// Same idea as getCourseTaskContext, but scoped to a single lesson/unit UUID
// rather than a whole course tree — the fallback source for LessonDetails.jsx/
// UnitReader.jsx (the standalone/library readers reached via /lessons/:uuid and
// /units/:uuid/read) when the page is opened directly rather than navigated to
// from a task's requirement card, so the "Task mode" banner still shows up.
// GET /client/courses/lesson/uuid/:uuid/task-context
exports.getLessonTaskContext = async (req, res) => {
try {
const { uuid } = req.params;
const userId = req.user.user_id;
const lesson = await Lesson.findOne({ where: { uuid, ...notDeleted }, attributes: ['lesson_id', 'uuid'] });
if (!lesson) return R.error(res, 'Lesson not found.', 404);
const { taskListIds, taskListToGroup } = await getAccessibleTaskListIds(userId);
if (!taskListIds.length) return R.success(res, 'Task context retrieved.', { has_task: false, contexts: [] });
const requirements = await TaskRequirement.findAll({
where: { type: 'read_lesson', reference_id: uuid, deletedAt: null },
include: [{
model: Task,
as: 'task',
where: { task_list_id: { [Op.in]: taskListIds }, deletedAt: null },
required: true,
attributes: ['task_id', 'name', 'task_list_id'],
}],
attributes: ['requirement_id', 'task_id', 'reference_id'],
});
if (!requirements.length) return R.success(res, 'Task context retrieved.', { has_task: false, contexts: [] });
const taskIds = [...new Set(requirements.map((r) => r.task_id))];
const doneProgress = await TaskProgress.findAll({
where: { task_id: { [Op.in]: taskIds }, user_id: userId, completed: true },
attributes: ['requirement_id', 'reference_id'],
});
const doneSet = new Set(doneProgress.map((p) => `${p.requirement_id}:${p.reference_id}`));
const contexts = requirements.map((req) => ({
task_id: req.task_id,
task_name: req.task.name,
task_list_id: req.task.task_list_id,
group_id: taskListToGroup[req.task.task_list_id],
requirement_id: req.requirement_id,
already_completed: doneSet.has(`${req.requirement_id}:${req.reference_id}`),
}));
return R.success(res, 'Task context retrieved.', { has_task: true, contexts });
} catch (err) {
console.error('[CLIENT][LESSON TASK CONTEXT]', err);
return R.error(res, 'Could not retrieve task context.', 500);
}
};
// GET /client/courses/unit/uuid/:uuid/task-context
exports.getUnitTaskContext = async (req, res) => {
try {
const { uuid } = req.params;
const userId = req.user.user_id;
const unit = await Unit.findOne({ where: { uuid, ...notDeleted }, attributes: ['unit_id', 'uuid'] });
if (!unit) return R.error(res, 'Unit not found.', 404);
const { taskListIds, taskListToGroup } = await getAccessibleTaskListIds(userId);
if (!taskListIds.length) return R.success(res, 'Task context retrieved.', { has_task: false, contexts: [] });
const requirements = await TaskRequirement.findAll({
where: { type: 'read_unit', reference_id: uuid, deletedAt: null },
include: [{
model: Task,
as: 'task',
where: { task_list_id: { [Op.in]: taskListIds }, deletedAt: null },
required: true,
attributes: ['task_id', 'name', 'task_list_id'],
}],
attributes: ['requirement_id', 'task_id', 'reference_id'],
});
if (!requirements.length) return R.success(res, 'Task context retrieved.', { has_task: false, contexts: [] });
const taskIds = [...new Set(requirements.map((r) => r.task_id))];
const doneProgress = await TaskProgress.findAll({
where: { task_id: { [Op.in]: taskIds }, user_id: userId, completed: true },
attributes: ['requirement_id', 'reference_id'],
});
const doneSet = new Set(doneProgress.map((p) => `${p.requirement_id}:${p.reference_id}`));
const contexts = requirements.map((req) => ({
task_id: req.task_id,
task_name: req.task.name,
task_list_id: req.task.task_list_id,
group_id: taskListToGroup[req.task.task_list_id],
requirement_id: req.requirement_id,
already_completed: doneSet.has(`${req.requirement_id}:${req.reference_id}`),
}));
return R.success(res, 'Task context retrieved.', { has_task: true, contexts });
} catch (err) {
console.error('[CLIENT][UNIT TASK CONTEXT]', err);
return R.error(res, 'Could not retrieve task context.', 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 recomputeCascade (completion_requirements service) — lesson + unit + course
// evaluated against any configured CompletionRequirement rows (or the default implicit rule),
// all in one transaction
// 3. Side-effect: sync task_progress for matching task requirements
// 4. Return result + completed_tasks (tasks whose all read requirements are now satisfied)
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, courseLink, lessonLink] = await Promise.all([
Course.findOne({
where: { course_id: courseId, ...notDeleted },
attributes: ['course_id', 'uuid'],
}),
Unit.findOne({
where: { unit_id: unitId, ...notDeleted },
attributes: ['unit_id', 'uuid'],
}),
Lesson.findOne({
where: { lesson_id: lessonId, ...notDeleted },
attributes: ['lesson_id', 'uuid'],
}),
CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId } }),
UnitLesson.findOne({ where: { unit_id: unitId, lesson_id: lessonId } }),
]);
if (!course) return R.error(res, 'Course not found.', 404);
if (!unit || !courseLink) return R.error(res, 'Unit not found.', 404);
if (!lesson || !lessonLink) return R.error(res, 'Lesson not found.', 404);
// ── 1. Consolidated evaluation + persistence: lesson → unit → course ──
const result = await recomputeCascade(userId, {
courseId: course.course_id,
courseUuid: course.uuid,
unitId: unit.unit_id,
unitUuid: unit.uuid,
lessonId: lesson.lesson_id,
lessonUuid: lesson.uuid,
lessonStatus: status,
});
// Task-progress sync (read_lesson/read_unit/read_course auto-complete) already ran
// inside recomputeCascade — result.completed_tasks reflects it directly.
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);
}
};
// =============================================================================
// ── WATCH PROGRESS (watch_percent completion requirement) ────────────────────
// =============================================================================
// POST /client/courses/:courseId/units/:unitId/lessons/:lessonId/watch-progress
// Body: { percent, block_id?, block_type? } — running max % of video/audio watched, 0-100.
// block_id/block_type identify which block on the lesson's page sent this update — needed
// to drive watch_video/listen_audio (every block of that type must individually reach 100);
// omit them and only the aggregate watch_percent requirement (if configured) is touched.
// No-ops (still 200s) if the lesson has neither requirement type configured.
exports.upsertWatchProgress = async (req, res) => {
try {
const { courseId, unitId, lessonId } = req.params;
const userId = req.user.user_id;
const percent = Number(req.body.percent);
if (!Number.isFinite(percent)) return R.error(res, 'percent must be a number.', 400);
const blockId = req.body.block_id ?? null;
const blockType = req.body.block_type ?? null;
const [course, unit, lesson, courseLink, lessonLink] = await Promise.all([
Course.findOne({ where: { course_id: courseId, ...notDeleted }, attributes: ['course_id', 'uuid'] }),
Unit.findOne({ where: { unit_id: unitId, ...notDeleted }, attributes: ['unit_id', 'uuid'] }),
Lesson.findOne({ where: { lesson_id: lessonId, ...notDeleted }, attributes: ['lesson_id', 'uuid'] }),
CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId } }),
UnitLesson.findOne({ where: { unit_id: unitId, lesson_id: lessonId } }),
]);
if (!course) return R.error(res, 'Course not found.', 404);
if (!unit || !courseLink) return R.error(res, 'Unit not found.', 404);
if (!lesson || !lessonLink) return R.error(res, 'Lesson not found.', 404);
// Resume-position tracking is unconditional — every block gets it regardless of
// whether a completion requirement is configured. recordWatchProgress, below, is
// the anti-cheat-validated path and stays a no-op when nothing's configured.
await recordPlaybackPosition(userId, { lessonId: lesson.lesson_id, blockId, percent });
const result = await recordWatchProgress(userId, {
lessonId: lesson.lesson_id, lessonUuid: lesson.uuid,
unitId: unit.unit_id, unitUuid: unit.uuid,
courseId: course.course_id, courseUuid: course.uuid,
percent, blockId, blockType,
});
return R.success(res, 'Watch progress updated.', result, 200);
} catch (err) {
console.error('[CLIENT][COURSE READING PROGRESS][WATCH PROGRESS]', err);
return R.error(res, 'Could not update watch progress.', 500);
}
};
// =============================================================================
// ── MARK COMPLETE (manual_complete completion requirement) ───────────────────
// =============================================================================
// POST /client/courses/:courseId/units/:unitId/lessons/:lessonId/mark-complete
// No-ops (still 200s) if the lesson has no configured manual_complete requirement.
exports.markLessonComplete = async (req, res) => {
try {
const { courseId, unitId, lessonId } = req.params;
const userId = req.user.user_id;
const [course, unit, lesson, courseLink, lessonLink] = await Promise.all([
Course.findOne({ where: { course_id: courseId, ...notDeleted }, attributes: ['course_id', 'uuid'] }),
Unit.findOne({ where: { unit_id: unitId, ...notDeleted }, attributes: ['unit_id', 'uuid'] }),
Lesson.findOne({ where: { lesson_id: lessonId, ...notDeleted }, attributes: ['lesson_id', 'uuid'] }),
CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId } }),
UnitLesson.findOne({ where: { unit_id: unitId, lesson_id: lessonId } }),
]);
if (!course) return R.error(res, 'Course not found.', 404);
if (!unit || !courseLink) return R.error(res, 'Unit not found.', 404);
if (!lesson || !lessonLink) return R.error(res, 'Lesson not found.', 404);
const result = await recordManualComplete(userId, {
entityType: 'lesson', entityId: lesson.lesson_id,
lessonUuid: lesson.uuid,
unitId: unit.unit_id, unitUuid: unit.uuid,
courseId: course.course_id, courseUuid: course.uuid,
});
return R.success(res, 'Lesson marked complete.', result, 200);
} catch (err) {
console.error('[CLIENT][COURSE READING PROGRESS][MARK COMPLETE]', err);
return R.error(res, 'Could not mark lesson complete.', 500);
}
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,291 @@
/***********************************************************************************************************************************************************************
* 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 ───────────────────────────────────────────────
// Collapses IPv4-mapped IPv6 ("::ffff:127.0.0.1") and IPv6 loopback ("::1")
// down to a single canonical form. Without this, a token minted off one
// "localhost" connection (IPv4) fails IP-pin verification on a sibling
// request that happened to land on the other stack (IPv6) — browsers race
// both when resolving "localhost", so mint and stream requests can land on
// different stacks even from the same client.
function normalizeIp(ip) {
if (ip === "::1") return "127.0.0.1";
if (ip.startsWith("::ffff:")) return ip.slice(7);
return ip;
}
function resolveIp(req) {
// x-forwarded-for is set by reverse proxies (nginx, Caddy, Cloudflare)
const forwarded = req.headers["x-forwarded-for"];
const raw = forwarded ? forwarded.split(",")[0].trim() : (req.ip ?? req.socket?.remoteAddress ?? "unknown");
return normalizeIp(raw);
}
// ─── 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;
// Tracks whether the client dropped the connection first.
// proxyReq.destroy() itself fires an "error" event — we silence it when
// we were the ones who triggered the teardown (client-closed case).
let clientClosed = false;
const proxyReq = transport.request(remoteUrl, { headers: proxyHeaders }, (proxyRes) => {
const status = proxyRes.statusCode ?? 502;
// Upstream (Garage/S3) returned something other than a successful
// content response — surface the real failure instead of piping its
// (often tiny XML/JSON) error body through as if it were the file.
if (status !== 200 && status !== 206) {
proxyRes.resume(); // drain so the socket can close cleanly
console.error(`[CLIENT][MEDIA][PROXY] Upstream returned ${status} for ${remoteUrl}`);
if (!res.headersSent) res.status(502).json({ message: "Stream unavailable." });
return;
}
[
"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) => {
if (clientClosed) return; // browser navigated away / component unmounted — expected
console.error("[CLIENT][MEDIA][PROXY] Stream error:", err.message);
if (!res.headersSent) res.status(502).json({ message: "Stream unavailable." });
});
req.on("close", () => {
clientClosed = true;
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 }
//
// TOKEN HITS: If a consumer (e.g. ClientNav badge) re-fetches unexpectedly,
// the fix lives on the frontend — not here. Use a useRef cache key by
// asset_id on the consumer side so this endpoint is called exactly once per
// asset per session. The 4h token TTL makes ref-caching safe within a session.
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, "File not found.", 404);
if (!SUPPORTED_TYPES.includes(asset.file_type)) {
return R.error(res, `File type "${asset.file_type}" is not supported.`, 400);
}
if (asset.storage_provider !== "s3") {
return R.error(res, "Token flow is for S3 files 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.getPublicUrl(asset.thumbnail_storage_key);
} 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).
// pipeRemoteStream() above forwards the real upstream status instead of
// collapsing everything to 200 — see its non-200/206 branch. A similar
// swallowed-status issue may still exist in s3.service.js (~line 168-171),
// not addressed here.
exports.streamAsset = async (req, res) => {
const { token } = req.params;
// ── CORS ──────────────────────────────────────────────────────────────────
// Mirrors server.js's global cors() origin check (reflect against
// ALLOWED_ORIGINS) instead of a single hardcoded FRONTEND_URL — a static
// origin here silently overwrote the correct header the global middleware
// already set, breaking any CORS-checked read (e.g. pdf.js's Range-header
// fetch) whenever FRONTEND_URL drifted from the deployed frontend domain.
// <img>/<video> tags were unaffected since opaque loads skip CORS checks.
const allowedOrigins = (process.env.ALLOWED_ORIGINS || process.env.APP_URL || "*").split(",");
const requestOrigin = req.headers.origin;
if (requestOrigin && allowedOrigins.includes(requestOrigin)) {
res.setHeader("Access-Control-Allow-Origin", requestOrigin);
}
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);
// ── Just comment out for debug if S3_ENDPOINT is undefined ────────────────
// console.log("Presigned URL:", presignedUrl);
} 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,155 @@
/***********************************************************************************************************************************************************************
* 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
* DELETE /client/notifications/clear-all — delete all notifications
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 19, 2026
***********************************************************************************************************************************************************************/
const UserNotification = require('../../models/notifications/user_notification.mdl');
const mdl_Assets = require('../../models/assets/assets.mdl');
const mediaToken = require('../../services/mediaToken.service');
const R = require('../../utils/response.util');
const { notInFutureOrExpired } = require('../../utils/notificationVisibility.util');
const STICKY_LIMIT = 2;
const IMAGE_INCLUDE = {
model: mdl_Assets,
as: 'image',
attributes: ["asset_id", "display_name", "file_url", "thumbnail_url", "storage_provider", "storage_key", "file_type", "mime_type"],
required: false,
};
// Mirrors controllers/admin/advertisements.controller.js's attachImageStreamToken
// — kept duplicated rather than shared across the admin/client boundary.
async function attachImageStreamToken(image, req) {
if (!image || image.storage_provider !== 's3' || !mediaToken.SUPPORTED_TYPES.includes(image.file_type)) {
return image;
}
const ip = mediaToken.resolveIp(req);
const { token } = await mediaToken.issueForAsset(image, req.user?.user_id, ip);
image.stream_token = token;
image.file_url = null;
image.thumbnail_url = null;
delete image.storage_key;
return image;
}
// ─── 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, show_in_notifications: true, ...notInFutureOrExpired() },
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, show_in_notifications: true, ...notInFutureOrExpired() },
});
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.');
}
}
// ─── GET /client/notifications/sticky ─────────────────────────────────────
async function stickyAnnouncement(req, res) {
try {
const rows = await UserNotification.findAll({
where: {
user_id: req.user.user_id,
seen: false,
show_in_sticky: true,
type: "announcement",
...notInFutureOrExpired(),
},
include: [IMAGE_INCLUDE],
order: [["createdAt", "DESC"]],
limit: STICKY_LIMIT,
});
const notifications = await Promise.all(rows.map(async (row) => {
const json = row.toJSON();
if (json.image) await attachImageStreamToken(json.image, req);
return json;
}));
return R.success(res, "Sticky alerts fetched.", { announcements: notifications });
} catch (err) {
console.error("[CLIENT NOTIFICATION] stickyAnnouncement error:", err);
return R.error(res, "Failed to fetch sticky announcement.");
}
}
// ─── 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.');
}
}
// ─── DELETE /client/notifications/clear-all ──────────────────────────────────
async function clearAll(req, res) {
try {
const count = await UserNotification.destroy({
where: { user_id: req.user.user_id },
});
return R.success(res, `${count} notification(s) cleared.`, { count });
} catch (err) {
console.error('[CLIENT NOTIFICATION] clearAll error:', err);
return R.error(res, 'Failed to clear notifications.');
}
}
module.exports = { list, unseenCount, stickyAnnouncement, markSeen, markAllSeen, clearAll };
@@ -0,0 +1,194 @@
/***********************************************************************************************************************************************************************
* File Name: profile.controller.js (client)
* Type of Program: Controller
* 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/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 trustedDevice = require('../../services/trustedDevice.service');
const logActivity = require('../../utils/logActivity.util');
const R = require('../../utils/response.util');
const { replaceUserAvatar, removeUserAvatar } = require('../../services/avatar.service');
const { resolveUserAvatar } = require('../../utils/resolveAvatar.util');
// ─── GET own profile ───────────────────────────────────────────────────────────
exports.getProfile = async (req, res) => {
try {
const user = await mdl_Users.findByPk(req.user.user_id, {
attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] },
});
return R.success(res, 'Profile retrieved.', await resolveUserAvatar(user));
} catch (err) {
return R.error(res, 'Could not retrieve profile.', 500);
}
};
// ─── PUT update own profile ────────────────────────────────────────────────────
exports.updateProfile = async (req, res) => {
try {
const user = await mdl_Users.findByPk(req.user.user_id);
const { personal_info } = req.body;
// Deep-merge personal_info so partial updates don't wipe existing data
const merged = {
...(user.personal_info || {}),
...(personal_info || {}),
name: {
...((user.personal_info?.name) || {}),
...((personal_info?.name) || {}),
},
};
await user.update({ personal_info: merged, needs_intro: false });
const updated = await mdl_Users.findByPk(req.user.user_id, {
attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] },
});
logActivity(req.user.user_id, 'update_profile');
return R.success(res, 'Profile updated.', await resolveUserAvatar(updated));
} catch (err) {
console.error('[CLIENT] updateProfile error:', err);
return R.error(res, 'Profile update failed.', 500);
}
};
// ─── 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']],
attributes: { exclude: ['refresh_token_hash'] },
});
return R.success(res, 'Sessions retrieved.', sessions);
} catch (err) {
return R.error(res, 'Could not retrieve sessions.', 500);
}
};
// ─── DELETE revoke a session ───────────────────────────────────────────────────
exports.revokeSession = async (req, res) => {
try {
const session = await mdl_UserSessions.findOne({
where: { session_id: req.params.id, user_id: req.user.user_id },
});
if (!session) return R.error(res, 'Session not found.', 404);
await session.update({
is_active: false,
logout_info: { date: new Date().toISOString(), ip_address: req.ip },
});
await trustedDevice.revokeBySessionId(session.session_id);
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);
const avatarMeta = await replaceUserAvatar(user, req.file);
const merged = { ...(user.personal_info || {}), avatar: avatarMeta };
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.', await resolveUserAvatar(updated));
} catch (err) {
if (err.status === 400) return R.error(res, err.message, 400);
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);
await removeUserAvatar(user);
const merged = { ...(user.personal_info || {}), avatar: null };
await user.update({ personal_info: merged });
return R.success(res, 'Avatar removed.');
} catch (err) {
if (err.status === 404) return R.error(res, err.message, 404);
console.error('[CLIENT] deleteAvatar error:', err);
return R.error(res, 'Could not remove avatar.', 500);
}
};
// ─── DELETE own account ────────────────────────────────────────────────────────
exports.deleteAccount = async (req, res) => {
try {
const user = await mdl_Users.findByPk(req.user.user_id);
if (!user) return R.error(res, 'User not found.', 404);
// Revoke all active sessions first
await mdl_UserSessions.update(
{ is_active: false, logout_info: { date: new Date().toISOString(), ip_address: req.ip, reason: 'account_deleted' } },
{ where: { user_id: req.user.user_id, is_active: true } },
);
await trustedDevice.revokeAllForUser(req.user.user_id);
// Anonymize email before soft-delete so the unique slot is freed for re-registration
await user.update({ email: `deleted_${req.user.user_id}@deleted.invalid`, deletedBy: req.user.user_id });
await user.destroy(); // paranoid soft-delete — sets deleted_at
logActivity(req.user.user_id, 'delete_account');
res.clearCookie('refreshToken');
res.clearCookie('_csrf');
return R.success(res, 'Account deleted.');
} catch (err) {
console.error('[CLIENT] deleteAccount error:', err);
return R.error(res, 'Could not delete account.', 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,851 @@
/***********************************************************************************************************************************************************************
* 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, TaskPrerequisite } = 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');
const { hydrateReadTaskProgress } = require('../../services/task_reading_progress_sync.service');
const UserNotification = require('../../models/notifications/user_notification.mdl');
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
const { onTaskCompleted, onTaskListCompleted } = require('../../services/achievements.service');
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const isUUID = (v) => UUID_RE.test(v);
// =============================================================================
// ── 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: [],
where: { deletedAt: null },
},
},
{
model: TaskList,
as: 'taskLists',
attributes: [],
through: { attributes: [] },
required: false,
},
],
where: { is_active: true },
attributes: [
'group_id',
'name',
'group_code',
'description',
[sequelize.fn('COUNT', sequelize.fn('DISTINCT', sequelize.col('taskLists.task_list_id'))), 'task_list_count'],
],
group: [
'UserGroup.group_id',
'UserGroup.name',
'UserGroup.group_code',
'UserGroup.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;
};
// ─── Helper: per-user completion signals for a batch of tasks ─────────────────
// Shared by getGroupTaskList/getGroupTaskLists. upload_file/submit_text share
// one TaskCompletion per task (resubmit-anytime — latest by submitted_at wins).
const getTaskCompletionSignals = async (userId, taskIds) => {
const [completions, linkVisits, progressRows] = await Promise.all([
taskIds.length
? TaskCompletion.findAll({
where: { task_id: { [Op.in]: taskIds }, user_id: userId },
attributes: ['task_id', 'status', 'submitted_at'],
order: [['submitted_at', 'DESC']],
})
: [],
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'],
})
: [],
]);
// First row per task_id wins — completions are ordered submitted_at DESC.
const latestCompletionByTask = new Map();
for (const c of completions) {
if (!latestCompletionByTask.has(c.task_id)) latestCompletionByTask.set(c.task_id, c);
}
return {
latestCompletionByTask,
visitedRequirementIds: new Set(linkVisits.map((v) => v.requirement_id)),
completedProgressKeys: new Set(progressRows.map((p) => `${p.requirement_id}:${p.reference_id}`)),
};
};
// ─── Helper: has this requirement been satisfied by the user? ─────────────────
const isRequirementDone = (r, signals) => {
switch (r.type) {
case 'upload_file':
case 'submit_text': {
const completion = signals.latestCompletionByTask.get(r.task_id);
if (!completion) return false;
return r.requires_review ? completion.status === 'approved' : true;
}
case 'visit_link':
return signals.visitedRequirementIds.has(r.requirement_id);
case 'read_course':
case 'read_unit':
case 'read_lesson':
return signals.completedProgressKeys.has(`${r.requirement_id}:${r.reference_id}`);
default:
return true; // unknown requirement types don't block completion
}
};
// ─── Helper: resolve which sibling task(s) are blocking a locked task ───────
// Prerequisites are always siblings within the same task list (enforced by
// admin's syncTaskPrerequisites), so names can always be resolved from `arr`.
// A task with no explicit task_prerequisites rows is never locked.
const resolveLockedBy = (task, i, arr, prereqsByTask, completedById) => {
const prereqIds = prereqsByTask.get(task.task_id);
if (!prereqIds || !prereqIds.length) return [];
const blockers = arr.filter((t) => prereqIds.includes(t.task_id) && completedById.get(t.task_id) !== true);
return blockers.map((t) => ({ task_id: t.task_id, name: t.name }));
};
// ─── Helper: explicit prerequisite gate ─────────────────────────────────────
// Returns true/false when `taskId` has explicit task_prerequisites rows —
// ALL of them must be completed by this user. Returns true (unlocked) when
// the task has no explicit prerequisites configured.
const checkPrerequisitesUnlocked = async (userId, taskId) => {
const prereqRows = await TaskPrerequisite.findAll({ where: { task_id: taskId } });
if (!prereqRows.length) return true;
const results = await Promise.all(prereqRows.map((r) => checkTaskCompletion(userId, r.prerequisite_task_id)));
return results.every(Boolean);
};
// ─── Helper: server-side sequencing gate ───────────────────────────────────
// A task is locked only by its own explicit task_prerequisites rows — no
// implicit locking based on list position. Shared by this file's submitTask
// and task_progress.controller.js's visitLink/updateProgress.
const assertTaskUnlocked = async (userId, taskId) => checkPrerequisitesUnlocked(userId, taskId);
// ─── Helper: is this one task fully done for this user, right now? ─────────
const checkTaskCompletion = async (userId, taskId) => {
const reqs = await TaskRequirement.findAll({ where: { task_id: taskId } });
if (!reqs.length) return false;
const plainReqs = reqs.map((r) => r.toJSON());
const signals = await getTaskCompletionSignals(userId, [taskId]);
return plainReqs.every((r) => isRequirementDone(r, signals));
};
// ─── Helper: fire task_completed (+ task_list_finisher achievement) on the
// 0→1 completion transition. Callers compute `wasComplete` themselves right
// before their write, then call this after, so it only fires once per task.
const fireTaskCompletedEvent = async (userId, taskId) => {
try {
const task = await Task.findByPk(taskId);
if (!task) return;
try {
const notify = NOTIFICATION_REGISTRY.task_completed.build({ taskName: task.name });
await UserNotification.create({ user_id: userId, ...notify, seen: false });
} catch (notifyErr) {
console.error('[TASK][NOTIFY COMPLETED]', notifyErr);
}
await onTaskCompleted(userId, taskId, task.name);
// ── Whole-list completion — check every sibling task too ───────────
const siblingTasks = await Task.findAll({ where: { task_list_id: task.task_list_id } });
const allDone = siblingTasks.length > 0 && (
await Promise.all(siblingTasks.map((t) => checkTaskCompletion(userId, t.task_id)))
).every(Boolean);
if (allDone) {
const taskList = await TaskList.findByPk(task.task_list_id);
if (taskList) await onTaskListCompleted(userId, taskList.task_list_id, taskList.name);
}
} catch (err) {
console.error('[TASK][FIRE COMPLETED EVENT]', err);
}
};
exports.getTaskCompletionSignals = getTaskCompletionSignals;
exports.isRequirementDone = isRequirementDone;
exports.assertTaskUnlocked = assertTaskUnlocked;
exports.checkPrerequisitesUnlocked = checkPrerequisitesUnlocked;
exports.checkTaskCompletion = checkTaskCompletion;
exports.fireTaskCompletedEvent = fireTaskCompletedEvent;
// ─────────────────────────────────────────────────────────────────────────────
// REPLACEMENT: getGroupTaskList in task.controller.js (client)
//
// GET /client/groups/:groupId/task-lists/:taskListId?status=ongoing|completed|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:
// completed → 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 completed AND task.deadline < now
// ongoing → otherwise
// ─────────────────────────────────────────────────────────────────────────────
exports.getGroupTaskList = async (req, res) => {
try {
const { groupId, taskListId } = req.params;
const { status } = req.query; // optional: 'ongoing' | 'completed' | '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: [['order_index', '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);
const readRequirements = tasks.flatMap((task) =>
(task.requirements ?? [])
.filter((req) => ['read_course', 'read_unit', 'read_lesson'].includes(req.type))
.map((req) => ({ ...req, task_id: task.task_id }))
);
await hydrateReadTaskProgress(userId, readRequirements);
// ── Fetch user's completion signals for these tasks ────────────────────
const signals = await getTaskCompletionSignals(userId, taskIds);
const now = Date.now();
// ── has_completed per task (needed up-front — both the bucket AND the
// locked computation below depend on sibling tasks' completion) ────────
const completedById = new Map(tasks.map((task) => {
const requirements = task.requirements ?? [];
return [task.task_id, requirements.length > 0 && requirements.every((r) => isRequirementDone(r, signals))];
}));
// ── Explicit prerequisite edges for these tasks ─────────────────────────
const prereqEdges = taskIds.length
? await TaskPrerequisite.findAll({ where: { task_id: { [Op.in]: taskIds } }, attributes: ['task_id', 'prerequisite_task_id'] })
: [];
const prereqsByTask = new Map();
for (const { task_id, prerequisite_task_id } of prereqEdges) {
if (!prereqsByTask.has(task_id)) prereqsByTask.set(task_id, []);
prereqsByTask.get(task_id).push(prerequisite_task_id);
}
// ── Bucket + lock each task — a task with explicit prerequisites is
// locked until ALL of them are done; a task with none is never locked
// (same rule assertTaskUnlocked enforces).
const bucketedTasks = tasks.map((task, i, arr) => {
const has_completed = completedById.get(task.task_id);
let bucket;
if (has_completed) {
bucket = 'completed';
} else if (task.deadline && new Date(task.deadline).getTime() < now) {
bucket = 'overdue';
} else {
bucket = 'ongoing';
}
const lockedBy = resolveLockedBy(task, i, arr, prereqsByTask, completedById);
const locked = lockedBy.length > 0;
return { ...task, has_completed, locked, lockedBy, _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|completed|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 → Completed
// NOT all completed 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' | 'completed' | '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: [['order_index', '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);
const readRequirements = allTasks.flatMap((task) =>
(task.requirements ?? [])
.filter((req) => ['read_course', 'read_unit', 'read_lesson'].includes(req.type))
.map((req) => ({
task_id: task.task_id,
requirement_id: req.requirement_id,
reference_id: req.reference_id,
type: req.type,
}))
);
await hydrateReadTaskProgress(userId, readRequirements);
// ── Fetch user's completion signals for these tasks ────────────────────
const signals = await getTaskCompletionSignals(userId, taskIds);
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) => isRequirementDone(r, signals));
};
// ── Explicit prerequisite edges for these tasks ─────────────────────────
const prereqEdges = taskIds.length
? await TaskPrerequisite.findAll({ where: { task_id: { [Op.in]: taskIds } }, attributes: ['task_id', 'prerequisite_task_id'] })
: [];
const prereqsByTask = new Map();
for (const { task_id, prerequisite_task_id } of prereqEdges) {
if (!prereqsByTask.has(task_id)) prereqsByTask.set(task_id, []);
prereqsByTask.get(task_id).push(prerequisite_task_id);
}
// ── 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);
});
// has_completed lookup scoped to THIS list's tasks (prerequisite
// edges only ever point at siblings within the same list).
const completedById = new Map(tasks.map((task) => [task.task_id, task.has_completed]));
tasks.forEach((task, i, arr) => {
task.lockedBy = resolveLockedBy(task, i, arr, prereqsByTask, completedById);
task.locked = task.lockedBy.length > 0;
});
let bucket;
if (tasks.length === 0) {
bucket = 'ongoing';
} else {
const allDone = tasks.every((t) => t.has_completed);
if (allDone) {
bucket = 'completed';
} 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;
if (!isUUID(taskId) || !isUUID(taskListId)) return R.error(res, 'Task not found.', 404);
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;
data.locked = !(await assertTaskUnlocked(req.user.user_id, taskId));
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 = [], response_text } = 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 (task.accepts_submissions === false) {
await t.rollback();
return R.error(res, 'This task no longer accepts submissions.', 409);
}
if (!(await assertTaskUnlocked(req.user.user_id, taskId))) {
await t.rollback();
return R.error(res, 'Complete this task\'s prerequisite tasks first.', 409);
}
const wasComplete = await checkTaskCompletion(req.user.user_id, taskId);
// ── Which submission-based requirement(s) does this task have? ─────────
const submissionRequirements = await TaskRequirement.findAll({
where: { task_id: taskId, type: { [Op.in]: ['upload_file', 'submit_text'] } },
transaction: t,
});
const uploadRequirement = submissionRequirements.find((r) => r.type === 'upload_file');
const textRequirement = submissionRequirements.find((r) => r.type === 'submit_text');
if (!uploadRequirement && !textRequirement) {
await t.rollback();
return R.error(res, 'This task has no requirement that accepts a submission.', 400);
}
if (uploadRequirement && !files.length) {
await t.rollback();
return R.error(res, 'At least one file is required to submit.', 400);
}
if (!uploadRequirement && textRequirement && !(response_text ?? '').trim()) {
await t.rollback();
return R.error(res, 'A response 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);
}
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,
response_text: response_text || null,
submitted_at: new Date(),
createdBy: req.user.user_id,
updatedBy: req.user.user_id,
}, { transaction: t });
if (files.length) {
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),
});
if (!wasComplete && await checkTaskCompletion(req.user.user_id, taskId)) {
fireTaskCompletedEvent(req.user.user_id, taskId); // fire-and-forget
}
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;
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,482 @@
/***********************************************************************************************************************************************************************
* 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)
* DELETE /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/requirements/:requirementId/visit
* → DELETE TaskLinkVisit (unsubmit)
*
* 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 { Op } = require('sequelize');
const sequelize = require('../../config/db.config');
const { Task, TaskRequirement } = require('../../models/task/task.mdl');
const { assertTaskUnlocked, checkTaskCompletion, fireTaskCompletedEvent } = require('./task.controller');
const { TaskLinkVisit, TaskProgress } = require('../../models/task/task_progress.mdl');
const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
const { Course } = require('../../models/courses/courses.mdl');
const CourseAssessment = require('../../models/courses/course_assessment.mdl');
const QuizAttempt = require('../../models/courses/quiz_attempt.mdl');
const logActivity = require('../../utils/logActivity.util');
const R = require('../../utils/response.util');
const { hydrateReadTaskProgress } = require('../../services/task_reading_progress_sync.service');
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const isUUID = (v) => UUID_RE.test(v);
// ─── 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 AND, if the course has a built assessment,
// the user has passed it. A course with no assessment yet can never be "complete" —
// finishing the reading alone isn't course completion.
const deriveCourseCompletion = async (userId, courseRequirementId, courseUuid, t) => {
const rows = await TaskProgress.findAll({
where: {
requirement_id: courseRequirementId,
user_id: userId,
type: 'read_unit',
},
transaction: t,
});
if (!rows.length) return false;
const allUnitsRead = rows.every((r) => r.completed);
if (!allUnitsRead) return false;
const course = await Course.findOne({
where: { uuid: courseUuid },
attributes: ['course_id'],
transaction: t,
});
if (!course) return false;
const assessment = await CourseAssessment.findOne({
where: { course_id: course.course_id },
attributes: ['assessment_id'],
transaction: t,
});
if (!assessment) return false;
const passedAttempt = await QuizAttempt.findOne({
where: { user_id: userId, assessment_id: assessment.assessment_id, passed: true },
transaction: t,
});
return !!passedAttempt;
};
// =============================================================================
// ── 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;
if (!isUUID(taskId) || !isUUID(taskListId)) return R.error(res, 'Task not found.', 404);
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 readRequirements = await TaskRequirement.findAll({
where: {
task_id: taskId,
type: { [Op.in]: ['read_course', 'read_unit', 'read_lesson'] },
},
attributes: ['task_id', 'requirement_id', 'reference_id', 'type'],
});
await hydrateReadTaskProgress(req.user.user_id, readRequirements);
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 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 (!(await assertTaskUnlocked(req.user.user_id, taskId))) {
await t.rollback();
return R.error(res, 'Complete this task\'s prerequisite tasks first.', 409);
}
const wasComplete = await checkTaskCompletion(req.user.user_id, taskId);
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 },
});
}
if (!wasComplete && await checkTaskCompletion(req.user.user_id, taskId)) {
fireTaskCompletedEvent(req.user.user_id, taskId); // fire-and-forget
}
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);
}
};
// =============================================================================
// ── UNVISIT LINK (DELETE TaskLinkVisit) ──────────────────────────────────────
// =============================================================================
// DELETE /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/requirements/:requirementId/visit
exports.unvisitLink = 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);
}
await TaskLinkVisit.destroy({
where: { requirement_id: requirementId, user_id: req.user.user_id, task_id: taskId },
transaction: t,
});
await t.commit();
return R.success(res, 'Link visit removed.', { requirement_id: requirementId });
} catch (err) {
await t.rollback();
console.error('[CLIENT][UNVISIT LINK]', err);
return R.error(res, 'Could not remove 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 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 (!(await assertTaskUnlocked(req.user.user_id, taskId))) {
await t.rollback();
return R.error(res, 'Complete this task\'s prerequisite tasks first.', 409);
}
const now = new Date();
const userId = req.user.user_id;
const wasComplete = await checkTaskCompletion(userId, taskId);
// ── 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();
if (!wasComplete && await checkTaskCompletion(userId, taskId)) {
fireTaskCompletedEvent(userId, taskId); // fire-and-forget
}
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, courseReq.reference_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();
if (!wasComplete && await checkTaskCompletion(userId, taskId)) {
fireTaskCompletedEvent(userId, taskId); // fire-and-forget
}
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://cdn.yourdomain.com/your-bucket/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,630 @@
/***********************************************************************************************************************************************************************
* 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)
* - Promo code validation (server-side)
* - PayPal redirect checkout (create order → capture → cancel → refund)
* - View own payment history
* Author: rgrgogu
* Date Created: Jun. 6, 2026
* Modified: Jun. 29, 2026
***********************************************************************************************************************************************************************/
const mdl_TierCategories = require('../../models/tiers/tier_categories.mdl');
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_SystemBadges = require('../../models/system_badges/system_badges.mdl');
const { mdl_UserTierGrants } = require('../../models/tiers/tier.associations');
const Asset = require('../../models/assets/assets.mdl');
const UserNotification = require('../../models/notifications/user_notification.mdl');
const { onTierActivated } = require('../../services/achievements.service');
const { Course } = require('../../models/courses/courses.mdl');
const Unit = require('../../models/courses/units.mdl');
const Lesson = require('../../models/courses/lessons.mdl');
const paymentSvc = require('../../services/payment.service');
const { snapshotPlanGrants } = require('../../services/tierGrants.service');
const R = require('../../utils/response.util');
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
const { sendEmail } = require('../../services/email.service');
const { fmtDate } = require('../../utils/datetime.util');
require('../../models/tiers/tier.associations');
// ─── MY TIER ──────────────────────────────────────────────────────────────────
// A user can hold more than one active tier concurrently (e.g. premium + exclusive
// bought separately). Returns the full active set plus the highest-rank one as
// `top_tier`, for callers that just want "the best tier this user currently has".
exports.getMyTier = async (req, res) => {
try {
const badgeInclude = { model: Asset, as: 'badgeAsset', attributes: ['asset_id', 'storage_provider', 'file_url', 'display_name'], required: false };
const tiers = await mdl_UserTiers.findAll({
where: { user_id: req.user.user_id, status: 'active' },
include: [{
model: mdl_TierPlans,
as: 'plan',
required: false,
include: [{ model: mdl_TierCategories, as: 'category', required: false, include: [badgeInclude] }],
}],
order: [['createdAt', 'DESC']],
});
// ── Inline safety net: expire between cron ticks ──────────────────────────
let just_expired = false;
const stillActive = [];
for (const tier of tiers) {
if (tier.expires_at && new Date(tier.expires_at) <= new Date()) {
await tier.update({ status: 'expired' });
UserNotification.create({
user_id: req.user.user_id,
...NOTIFICATION_REGISTRY.tier_expired.build({
tier: tier.tier,
label: tier.plan?.label ?? null,
planId: tier.plan?.plan_id ?? null,
}),
}).catch(() => {});
just_expired = true;
continue;
}
stillActive.push(tier);
}
if (!stillActive.length) {
const freeCategory = await mdl_TierCategories.findOne({ where: { slug: 'free' }, include: [badgeInclude] });
const freeTier = { tier: 'free', status: 'active', category: freeCategory ?? null };
// Spread freeTier at top level too — keeps `myTier.tier`/`myTier.status`/`myTier.category`
// working for existing frontend code that predates the active_tiers/top_tier shape.
return R.success(res, 'Active subscription retrieved.', {
...freeTier,
active_tiers: [freeTier],
top_tier: 'free',
just_expired,
my_grants: { course_ids: [], unit_ids: [], lesson_ids: [] },
});
}
// Item-specific entitlement (Tier Plans v2) — every course/unit/lesson id
// granted by ANY of this user's currently-active tiers, flattened, so the
// client can compute per-plan overlap (see PlanList.jsx) without a
// separate endpoint per plan.
const myGrantRows = await mdl_UserTierGrants.findAll({
where: { user_tier_id: stillActive.map((t) => t.tier_id) },
attributes: ['item_type', 'item_id'],
});
const my_grants = { course_ids: [], unit_ids: [], lesson_ids: [] };
for (const g of myGrantRows) {
if (g.item_type === 'course') my_grants.course_ids.push(g.item_id);
else if (g.item_type === 'unit') my_grants.unit_ids.push(g.item_id);
else if (g.item_type === 'lesson') my_grants.lesson_ids.push(g.item_id);
}
const categories = await mdl_TierCategories.findAll({ attributes: ['slug', 'rank'] });
const rankMap = Object.fromEntries(categories.map((c) => [c.slug, c.rank]));
const active_tiers = [];
for (const tier of stillActive) {
if (!tier.plan?.category) {
const category = await mdl_TierCategories.findOne({ where: { slug: tier.tier }, include: [badgeInclude] });
const plain = tier.toJSON();
plain.category = category?.toJSON() ?? null;
active_tiers.push(plain);
} else {
active_tiers.push(tier.toJSON());
}
}
let top_tier = active_tiers[0].tier;
for (const t of active_tiers) {
if ((rankMap[t.tier] ?? 0) > (rankMap[top_tier] ?? 0)) top_tier = t.tier;
}
const topTierObj = active_tiers.find((t) => t.tier === top_tier) ?? active_tiers[0];
// Spread topTierObj at top level too — keeps `myTier.tier`/`myTier.status`/
// `myTier.category`/`myTier.expires_at` working for existing frontend code
// that predates the active_tiers/top_tier shape (it'll just see the best tier).
return R.success(res, 'Active subscription retrieved.', { ...topTierObj, active_tiers, top_tier, just_expired, my_grants });
} catch (err) {
console.error('[CLIENT][GET MY TIER]', err);
return R.error(res, 'Could not retrieve subscription.', 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, 'Subscription history retrieved.', history);
} catch (err) {
console.error('[CLIENT][GET MY TIER HISTORY]', err);
return R.error(res, 'Could not retrieve subscription history.', 500);
}
};
// ─── PLANS ────────────────────────────────────────────────────────────────────
exports.getPlans = async (req, res) => {
try {
const plans = await mdl_TierPlans.findAll({
where: { status: 'published' },
order: [['tier', 'ASC'], ['duration_days', 'ASC']],
attributes: ['plan_id', 'tier', 'label', 'description', 'features', 'duration_days', 'duration_unit', 'price', 'currency', 'is_active', 'is_recommended'],
include: [
{
model: Course,
as: 'courses',
attributes: ['course_id', 'uuid', 'title', 'course_code', 'level', 'duration_seconds'],
through: { attributes: [] },
},
{
model: Unit,
as: 'units',
attributes: ['unit_id', 'uuid', 'title', 'duration_seconds'],
through: { attributes: [] },
},
{
model: Lesson,
as: 'lessons',
attributes: ['lesson_id', 'uuid', 'title', 'duration_seconds'],
through: { attributes: [] },
},
],
});
// Each plan holds exactly one bundle type (single-type bundles, Tier Plans
// v2) — expose the exact item id sets so the client can compute
// overlap-with-existing-access without extra round-trips (see PlanList.jsx).
const result = plans.map((p) => {
const plain = p.toJSON();
plain.course_count = plain.courses?.length ?? 0;
plain.unit_count = plain.units?.length ?? 0;
plain.lesson_count = plain.lessons?.length ?? 0;
plain.course_ids = (plain.courses ?? []).map((c) => c.course_id);
plain.unit_ids = (plain.units ?? []).map((u) => u.unit_id);
plain.lesson_ids = (plain.lessons ?? []).map((l) => l.lesson_id);
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);
}
};
// ─── PROMO CODE VALIDATION ────────────────────────────────────────────────────
exports.validatePromo = async (req, res) => {
try {
const { plan_id, code } = req.body;
if (!plan_id || !code) return R.error(res, 'plan_id and code are required.', 400);
const plan = await mdl_TierPlans.findOne({ where: { plan_id, is_active: true, status: 'published' } });
if (!plan) return R.error(res, 'Plan not found or inactive.', 404);
const policy = await paymentSvc.getPolicyForPlan(plan_id);
const result = await paymentSvc.evaluatePromo(policy, plan, code, null);
return R.success(res, result.valid ? 'Promo code is valid.' : result.reason, result);
} catch (err) {
console.error('[CLIENT][VALIDATE PROMO]', err);
return R.error(res, 'Could not validate promo code.', 500);
}
};
// ─── 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, status: 'published' } });
if (!plan) return R.error(res, 'Plan not found or inactive.', 404);
// Repurchasing the SAME plan while it's already active is allowed — it
// extends the existing grant's expires_at (see captureOrder) rather than
// being blocked. A different plan at the same tier slug is NOT the same
// purchase — it creates its own independent user_tiers row with its own
// item-specific grants, so this check is keyed on plan_id, not tier.
// Surfaced here only for checkout-page messaging.
const existingActive = await mdl_UserTiers.findOne({
where: { user_id: req.user.user_id, plan_id, status: 'active' },
attributes: ['expires_at'],
});
const effectivePrice = Number(plan.price);
const effectiveCurrency = plan.currency;
const policy = await paymentSvc.getPolicyForPlan(plan_id);
let promoResult = { valid: false, code: null, discount: 0 };
if (promo_code) {
promoResult = await paymentSvc.evaluatePromo(policy, plan, promo_code, effectivePrice);
if (!promoResult.valid)
return R.error(res, promoResult.reason ?? 'Invalid promo code.', 400);
}
const subtotal = effectivePrice;
const discount = promoResult.discount ?? 0;
const total = Math.max(subtotal - discount, 0).toFixed(2);
if (Number(total) <= 0)
return R.error(res, 'PayPal checkout requires a payable amount.', 400);
const provider = (policy?.allowed_providers?.[0]) ?? 'paypal';
const ppOrder = await paymentSvc.createOrder(provider, {
amount: total,
currency: effectiveCurrency,
referenceId: `user_${req.user.user_id}_plan_${plan_id}`,
});
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: total,
currency: effectiveCurrency,
promo_code: promoResult.code,
discount: discount.toFixed(2),
provider,
provider_payload: {
order_id: ppOrder.id,
approval_url: approvalUrl,
checkout: {
subtotal: subtotal.toFixed(2),
discount: discount.toFixed(2),
promo_code: promoResult.code,
base_price: Number(plan.price).toFixed(2),
base_currency: plan.currency,
},
},
});
return R.success(res, 'Order created.', {
payment_id: payment.payment_id,
order_id: ppOrder.id,
approval_url: approvalUrl,
amount: total,
currency: effectiveCurrency,
promo_code: promoResult.code,
discount: discount.toFixed(2),
extends_existing: !!existingActive,
current_expires_at: existingActive?.expires_at ?? null,
}, 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', user_id: req.user.user_id },
include: [{ model: mdl_TierPlans, as: 'plan' }],
order: [['createdAt', 'DESC']],
});
if (!payment || payment.provider_payload?.order_id !== order_id)
return R.error(res, 'Pending payment not found.', 404);
// Guard: plan was deactivated while user was on PayPal's approval page
if (!payment.plan?.is_active) {
await payment.update({
status: 'cancelled',
provider_payload: {
...payment.provider_payload,
cancelled_at: new Date().toISOString(),
cancelled_by: 'system',
cancel_reason: 'plan_deactivated',
},
});
return R.error(res, 'This plan is no longer available. No payment was taken.', 409);
}
let captureData;
try {
captureData = await paymentSvc.captureOrder(payment.provider, order_id);
} catch (ppErr) {
await payment.update({
status: 'failed',
provider_payload: { ...payment.provider_payload, error: ppErr?.response?.data ?? {} },
});
return R.error(res, 'Payment capture failed.', 402);
}
const capture = captureData.purchase_units?.[0]?.payments?.captures?.[0];
// PayPal can return an HTTP 2xx from the capture endpoint even when the
// charge itself was declined or held for review (e.g. capture.status
// "DECLINED"/"PENDING") — axios only throws on non-2xx, so the actual
// status field must be checked explicitly before granting any access.
const captureStatus = capture?.status ?? captureData.status;
if (captureStatus !== 'COMPLETED') {
await payment.update({
status: 'failed',
provider_payload: { ...payment.provider_payload, capture: captureData, failed_reason: captureStatus ?? 'unknown' },
});
return R.error(res, `Payment was not completed by PayPal (status: ${captureStatus ?? 'unknown'}).`, 402);
}
// Repurchasing THIS SAME plan while already active extends its expires_at
// by the new duration, rather than being blocked/refunded. A different
// plan — even at the same tier slug — is a distinct purchase and gets its
// own user_tiers row with its own item-specific grants (see
// snapshotPlanGrants below); it must NOT be merged into an unrelated
// plan's row just because the tier slug matches (Tier Plans v2 — a Unit
// bundle and a Course bundle can both be "premium" and both need to stay
// independently active/tracked).
const existingActive = await mdl_UserTiers.findOne({
where: { user_id: req.user.user_id, plan_id: payment.plan_id, status: 'active' },
});
let resultTier;
let successMessage;
if (existingActive) {
const newExpiresAt = new Date(existingActive.expires_at.getTime() + payment.plan.duration_days * 86400000);
await existingActive.update({ expires_at: newExpiresAt });
resultTier = existingActive;
successMessage = `Payment successful. Your ${payment.plan.tier} access has been extended to ${newExpiresAt.toLocaleDateString()}.`;
} else {
const startsAt = new Date();
const expiresAt = new Date(startsAt.getTime() + payment.plan.duration_days * 86400000);
resultTier = 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,
});
successMessage = 'Payment successful. Subscription activated.';
}
// Snapshot the plan's current bundle contents into user_tier_grants —
// refreshed on every purchase/extension so an admin's bundle edits since
// the last purchase are picked up, but past purchasers of OTHER plans are
// never retroactively affected (Tier Plans v2 item-specific entitlement).
await snapshotPlanGrants(resultTier, payment.plan_id);
await payment.update({
status: 'completed',
tier_id: resultTier.tier_id,
paid_at: new Date(),
provider_payload: {
...payment.provider_payload,
capture_id: capture?.id,
payer_id: captureData.payer?.payer_id,
capture: captureData,
},
});
// Idempotent (grantAchievement checks for an existing row first) — safe
// to call again on an extension, won't grant a duplicate achievement.
await onTierActivated(req.user.user_id, resultTier.tier);
return R.success(res, successMessage, {
tier: resultTier.tier,
expires_at: resultTier.expires_at,
extended: !!existingActive,
});
} 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' },
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);
}
};
exports.refundOrder = async (req, res) => {
try {
const user_id = req.user.user_id;
// plan_id disambiguates which active subscription to refund now that a user
// can hold more than one concurrently — optional only while a user has just one.
const { plan_id } = req.body;
const activeTierWhere = { user_id, status: 'active' };
if (plan_id) activeTierWhere.plan_id = plan_id;
const activeTierCandidates = await mdl_UserTiers.findAll({
where: activeTierWhere,
order: [['createdAt', 'DESC']],
});
if (!activeTierCandidates.length) return R.error(res, 'No active subscription to refund.', 404);
if (activeTierCandidates.length > 1) {
return R.error(res, 'You have more than one active subscription — specify plan_id to refund a specific one.', 400);
}
const activeTier = activeTierCandidates[0];
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 subscription.', 404);
// Load plan's payment policy to get the configured refund window
const policy = await paymentSvc.getPolicyForPlan(payment.plan_id);
if (!paymentSvc.isRefundAllowed(policy))
return R.error(res, 'Refunds are not available for this plan.', 403);
const windowMs = paymentSvc.getRefundWindowMs(policy);
if (!payment.paid_at || Date.now() - new Date(payment.paid_at).getTime() > windowMs) {
const rp = policy?.refund_policy ?? {};
const label = `${rp.window_value ?? 5} ${rp.window_unit ?? 'minutes'}`;
return R.error(res, `Refund window has expired. Refunds are only available within ${label} of payment.`, 403);
}
const captureId = payment.provider_payload?.capture_id;
if (!captureId) return R.error(res, 'Capture ID not found. Cannot process refund.', 400);
let refundData;
try {
refundData = await paymentSvc.refundCapture(payment.provider, captureId, payment.amount, payment.currency);
} catch (ppErr) {
console.error('[CLIENT][REFUND] provider error:', ppErr?.response?.data);
return R.error(res, 'Refund failed. Please try again.', 402);
}
await payment.update({
status: 'refunded',
provider_payload: {
...payment.provider_payload,
refund: refundData,
refunded_at: new Date().toISOString(),
},
});
const now = new Date();
await activeTier.update({ status: 'revoked', expires_at: now, revoked_at: now });
const plan = await mdl_TierPlans.findByPk(payment.plan_id, { attributes: ['plan_id', 'label'] });
try {
const notify = NOTIFICATION_REGISTRY.payment_refunded.build({
label: plan?.label ?? 'your plan',
amount: payment.amount,
currency: payment.currency,
planId: plan?.plan_id ?? null,
});
await UserNotification.create({ user_id, ...notify, seen: false });
} catch (notifyErr) {
console.error('[CLIENT][REFUND][NOTIFY]', notifyErr);
}
try {
const name = req.user.personal_info?.name?.full_name ?? 'there';
sendEmail({
to: req.user.email,
type: 'REFUND_PROCESSED',
data: {
name,
label: plan?.label ?? 'your plan',
amount: payment.amount,
currency: payment.currency,
date: fmtDate(now),
refundId: refundData.id,
},
}).catch((emailErr) => console.error('[CLIENT][REFUND][EMAIL]', emailErr));
} catch (emailErr) {
console.error('[CLIENT][REFUND][EMAIL]', emailErr);
}
// Only fall back to free if the user has no other concurrently active tier —
// revoking one subscription shouldn't drop them below a tier they still hold.
const remainingActive = await mdl_UserTiers.count({ where: { user_id, status: 'active' } });
if (remainingActive > 0) {
return R.success(res, 'Refund processed successfully. Your access to this plan has been revoked.', {
refund_id: refundData.id,
status: refundData.status,
});
}
await mdl_UserTiers.create({
user_id,
tier: 'free',
status: 'active',
starts_at: now,
expires_at: null,
granted_by: null,
notes: 'Auto-downgrade after refund.',
});
return R.success(res, 'Refund processed successfully. Your access has been revoked.', {
refund_id: refundData.id,
status: refundData.status,
});
} catch (err) {
console.error('[CLIENT][REFUND]', err);
return R.error(res, 'Could not process refund.', 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);
}
};
// ─── TIER CATEGORIES + SYSTEM BADGES ─────────────────────────────────────────
exports.getCategories = async (req, res) => {
try {
const categories = await mdl_TierCategories.findAll({
where: { is_active: true },
attributes: ['tier_category_id', 'slug', 'name', 'rank', 'color', 'badge_icon', 'badge_label', 'is_default'],
include: [{ model: Asset, as: 'badgeAsset', attributes: ['asset_id', 'storage_provider', 'file_url', 'display_name'], required: false }],
order: [['rank', 'ASC']],
});
return R.success(res, 'Subscription categories retrieved.', categories);
} catch (err) {
console.error('[CLIENT][GET TIER CATEGORIES]', err);
return R.error(res, 'Could not retrieve subscription categories.', 500);
}
};
exports.getSystemBadges = async (req, res) => {
try {
const badges = await mdl_SystemBadges.findAll({
attributes: ['key', 'label', 'description', 'information', 'active_from', 'active_until'],
include: [{ model: Asset, as: 'asset', attributes: ['file_url', 'display_name'], required: false }],
order: [['key', 'ASC']],
});
return R.success(res, 'System badges retrieved.', badges);
} catch (err) {
console.error('[CLIENT][GET SYSTEM BADGES]', err);
return R.error(res, 'Could not retrieve system badges.', 500);
}
};
@@ -0,0 +1,491 @@
/***********************************************************************************************************************************************************************
* File Name: units.controller.js (client)
* Type of Program: Controller
* Description: Standalone Unit / Lesson consumption — the junction revamp lets
* learners run Units and Lessons outside any Course:
*
* GET /client/units → INDEPENDENT units only (no course affiliation)
* GET /client/lessons → INDEPENDENT lessons only (no unit is course-affiliated)
* GET /client/units/:uuid → unit metadata (shared handler)
* GET /client/units/:uuid/lessons → unit + ALL lesson data (shared handler)
* GET /client/units/:uuid/quiz → the unit's quiz, no course context
* POST /client/units/:uuid/quiz/:quizId/submit→ graded attempt with course_id NULL
* GET /client/lessons/:uuid → single lesson, runs independently (shared handler)
* POST /client/lessons/:uuid/progress → standalone reading progress (course NULL, unit optional)
*
* Access rule: a unit attached to no course is open; otherwise the user must
* be able to access at least one attached course. Lessons resolve through
* their parent units the same way.
*
* Discovery rule (getUnits/getLessons only): ALL non-deleted units/lessons
* are listed, whether or not they're attached to a course — course_count/
* courses[] (published courses only) and is_locked tell the learner whether
* a given item is standalone or bound, and if bound, whether they already
* have access. This does not affect the single-item endpoints above
* (:uuid) — those still enforce access normally for direct links, and
* course-scoped consumption runs through a separate controller entirely.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jul. 7, 2026
***********************************************************************************************************************************************************************/
"use strict";
const R = require("../../utils/response.util");
const logActivity = require("../../utils/logActivity.util");
const sequelize = require("../../config/db.config");
const {
Unit, Lesson,
CourseUnit, UnitLesson,
UnitQuiz, QuizQuestion, QuizOption, QuizAttempt, QuizSession,
} = require("../../models/courses/courses.associations");
const coursesCtrl = require("./courses.controller"); // canAccessUnit / canAccessLesson / shared uuid handlers
const { gradeSubmission } = require("../../utils/courses/grading.util");
const { shuffleOptions, shuffleQuestions, getAttemptStatus } = require("../../utils/courses/quiz_security.util");
const { recomputeCascade, recordWatchProgress, recordManualComplete } = require("../../services/completion_requirements.service");
const { recordPlaybackPosition } = require("../../services/playback_position.service");
const notDeleted = { deletedAt: null };
// Strip correct-answer data (same policy as course-scoped quiz endpoints)
function sanitizeQuestions(questions = []) {
return questions.map((q) => {
const plain = q.toJSON ? q.toJSON() : { ...q };
if (plain.type === "multi_select") {
plain.correct_count = (plain.options ?? []).filter((o) => o.is_correct).length;
}
plain.options = (plain.options ?? []).map(({ is_correct: _drop, ...o }) => o);
delete plain.explanation;
return plain;
});
}
// ─── UNIT LIBRARY (learner view) ──────────────────────────────────────────────
// Client-side Units/Lessons browsing shows ALL content, bound to a course or
// not — course_count/courses[] + is_locked below tell the learner which is
// which. This does not affect course-scoped consumption (which runs through
// ClientCoursesContext/getCourse, a separate path) or direct-link access to
// UnitDetails/LessonDetails, which still enforce access normally.
exports.getUnits = async (req, res) => {
try {
const rows = await sequelize.query(`
SELECT
u.unit_id, u.uuid, u.title, u.subscription, u.description, u.duration_seconds,
(SELECT CAST(COUNT(*) AS INTEGER) FROM unit_lessons ul
JOIN lessons l ON l.lesson_id = ul.lesson_id AND l."deletedAt" IS NULL
WHERE ul.unit_id = u.unit_id) AS lesson_count,
(SELECT CAST(COUNT(*) AS INTEGER) FROM course_units cu
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL AND c.status = 'published'
WHERE cu.unit_id = u.unit_id) AS course_count,
(SELECT quiz_id FROM unit_quizzes q
WHERE q.unit_id = u.unit_id AND q."deletedAt" IS NULL LIMIT 1) AS quiz_id
FROM units u
WHERE u."deletedAt" IS NULL
ORDER BY u.title ASC
`, { type: sequelize.QueryTypes.SELECT });
// Batch-fetch attached courses for every returned unit in one query, so the
// learner-facing upsell modal can say which course(s)/tier(s) unlock a unit
// (a unit may sit under several courses at different tiers — no single "Buy").
const unitIds = rows.map((r) => r.unit_id);
const courseLinkRows = unitIds.length ? await sequelize.query(`
SELECT cu.unit_id, c.course_id, c.uuid, c.title, c.subscription
FROM course_units cu
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL AND c.status = 'published'
WHERE cu.unit_id IN (:unitIds)
`, { replacements: { unitIds }, type: sequelize.QueryTypes.SELECT }) : [];
const coursesByUnit = new Map();
for (const row of courseLinkRows) {
const list = coursesByUnit.get(row.unit_id) ?? [];
list.push({ course_id: row.course_id, uuid: row.uuid, title: row.title, subscription: row.subscription });
coursesByUnit.set(row.unit_id, list);
}
// is_locked mirrors canAccessUnit: a unit with its own subscription or at
// least one attached course needs an access check; a fully open standalone
// unit (no subscription, no course links) is never locked.
const result = [];
for (const row of rows) {
const is_locked = (row.subscription || Number(row.course_count) > 0)
? !(await coursesCtrl.canAccessUnit(req.user.user_id, row.unit_id))
: false;
result.push({
...row,
courses: coursesByUnit.get(row.unit_id) ?? [],
is_locked,
});
}
return R.success(res, "Units retrieved.", result);
} catch (err) {
console.error("[CLIENT][UNITS][GET ALL]", err);
return R.error(res, "Could not retrieve units.", 500);
}
};
// ─── LESSON LIBRARY (learner view) ────────────────────────────────────────────
// Mirrors getUnits above — a Lesson may sit in several Units (each possibly in
// different courses), so is_locked/courses are resolved across ALL attached
// units rather than a single direct course link.
exports.getLessons = async (req, res) => {
try {
const rows = await sequelize.query(`
SELECT
l.lesson_id, l.uuid, l.title, l.subscription, l.description, l.duration_seconds,
(SELECT CAST(COUNT(*) AS INTEGER) FROM unit_lessons ul
JOIN units u ON u.unit_id = ul.unit_id AND u."deletedAt" IS NULL
WHERE ul.lesson_id = l.lesson_id) AS unit_count
FROM lessons l
WHERE l."deletedAt" IS NULL
ORDER BY l.title ASC
`, { type: sequelize.QueryTypes.SELECT });
// Batch-fetch every course reachable through any attached unit, for every
// returned lesson, in one query — same batching style as getUnits.
const lessonIds = rows.map((r) => r.lesson_id);
const courseLinkRows = lessonIds.length ? await sequelize.query(`
SELECT DISTINCT ul.lesson_id, c.course_id, c.uuid, c.title, c.subscription
FROM unit_lessons ul
JOIN course_units cu ON cu.unit_id = ul.unit_id
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL AND c.status = 'published'
WHERE ul.lesson_id IN (:lessonIds)
`, { replacements: { lessonIds }, type: sequelize.QueryTypes.SELECT }) : [];
const coursesByLesson = new Map();
for (const row of courseLinkRows) {
const list = coursesByLesson.get(row.lesson_id) ?? [];
list.push({ course_id: row.course_id, uuid: row.uuid, title: row.title, subscription: row.subscription });
coursesByLesson.set(row.lesson_id, list);
}
// is_locked mirrors canAccessLesson: a lesson with its own subscription or
// at least one attached unit needs an access check; a fully open
// standalone lesson (no subscription, no unit links) is never locked.
const result = [];
for (const row of rows) {
const is_locked = (row.subscription || Number(row.unit_count) > 0)
? !(await coursesCtrl.canAccessLesson(req.user.user_id, row.lesson_id))
: false;
result.push({
...row,
courses: coursesByLesson.get(row.lesson_id) ?? [],
is_locked,
});
}
return R.success(res, "Lessons retrieved.", result);
} catch (err) {
console.error("[CLIENT][LESSONS][GET ALL]", err);
return R.error(res, "Could not retrieve lessons.", 500);
}
};
// ─── STANDALONE UNIT QUIZ ─────────────────────────────────────────────────────
exports.getUnitQuiz = async (req, res) => {
try {
const { uuid } = req.params;
const unit = await Unit.findOne({ where: { uuid, ...notDeleted } });
if (!unit) return R.error(res, "Unit not found.", 404);
if (!await coursesCtrl.canAccessUnit(req.user.user_id, unit.unit_id)) {
return R.error(res, "You do not have access to this unit.", 403);
}
const quiz = await UnitQuiz.findOne({
where: { unit_id: unit.unit_id, ...notDeleted },
attributes: [
"quiz_id", "uuid", "title",
"is_required", "passing_score", "max_questions", "shuffle_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", "is_correct"],
}],
}],
order: [[{ model: QuizQuestion, as: "questions" }, "order_index", "ASC"]],
});
if (!quiz) return R.error(res, "Quiz not found.", 404);
const plain = quiz.toJSON();
let qs = sanitizeQuestions(plain.questions ?? []);
if (plain.shuffle_questions) qs = shuffleQuestions(qs);
plain.questions = shuffleOptions(qs);
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, "quiz");
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;
const activeSession = await QuizSession.findOne({
where: { quiz_id: quiz.quiz_id, user_id: req.user.user_id, status: "in_progress" },
attributes: ["session_id", "draft_answers", "started_at", "last_saved_at"],
});
plain.active_session = activeSession ?? null;
return R.success(res, "Quiz retrieved.", plain);
} catch (err) {
console.error("[CLIENT][UNITS][QUIZ][GET]", err);
return R.error(res, "Could not retrieve quiz.", 500);
}
};
exports.submitUnitQuiz = async (req, res) => {
try {
const { uuid, quizId } = req.params;
const { answers = {} } = req.body;
const user_id = req.user.user_id;
const unit = await Unit.findOne({ where: { uuid, ...notDeleted } });
if (!unit) return R.error(res, "Unit not found.", 404);
if (!await coursesCtrl.canAccessUnit(user_id, unit.unit_id)) {
return R.error(res, "You do not have access to this unit.", 403);
}
const quiz = await UnitQuiz.findOne({
where: { quiz_id: quizId, unit_id: unit.unit_id, ...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 { 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: null, // standalone — no course context
attempt_number: priorAttempts.length + 1,
answers,
total_points: totalPoints,
earned_points: earnedPoints,
score,
passing_score: quiz.passing_score ?? 70,
passed,
});
await QuizSession.update(
{ status: "submitted" },
{ where: { quiz_id: quiz.quiz_id, user_id, status: "in_progress" } }
);
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,
});
} catch (err) {
console.error("[CLIENT][UNITS][QUIZ][SUBMIT]", err);
return R.error(res, "Could not submit quiz.", 500);
}
};
// ─── STANDALONE UNIT QUIZ DRAFT ───────────────────────────────────────────────
// PATCH /client/units/:uuid/quiz/:quizId/draft — mirrors the course-scoped
// saveQuizDraft in courses.controller.js; only quiz_id + user_id are needed to
// locate the session, course_id/unit_id are just extra nullable columns on it.
exports.saveUnitQuizDraft = async (req, res) => {
try {
const { uuid, quizId } = req.params;
const { answers = {} } = req.body;
const user_id = req.user.user_id;
const unit = await Unit.findOne({ where: { uuid, ...notDeleted } });
if (!unit) return R.error(res, "Unit not found.", 404);
const [updatedCount] = await QuizSession.update(
{ draft_answers: answers, last_saved_at: new Date() },
{ where: { quiz_id: quizId, user_id, status: "in_progress" } }
);
if (updatedCount === 0) {
await QuizSession.create({
quiz_id: quizId,
user_id,
course_id: null,
unit_id: unit.unit_id,
draft_answers: answers,
started_at: new Date(),
});
}
return res.status(204).end();
} catch (err) {
console.error("[CLIENT][UNITS][QUIZ][DRAFT]", err);
return R.error(res, "Could not save quiz draft.", 500);
}
};
// ─── STANDALONE LESSON PROGRESS ───────────────────────────────────────────────
// POST /client/lessons/:uuid/progress Body: { status, unit_uuid? }
// Evaluated + written via completion_requirements.service#recomputeCascade with
// courseId null, which persists to lesson_reading_progress/unit_reading_progress
// (the tables that tolerate a null course_id) instead of course_reading_progress.
// When unit_uuid is given (unit context, still no course) the parent unit is
// re-evaluated + upserted too, against any configured CompletionRequirement rows.
exports.upsertStandaloneLessonProgress = async (req, res) => {
try {
const { uuid } = req.params;
const userId = req.user.user_id;
const status = req.body.status === "completed" ? "completed" : "in_progress";
const unitUuid = req.body.unit_uuid ?? null;
const lesson = await Lesson.findOne({ where: { uuid, ...notDeleted }, attributes: ["lesson_id", "uuid"] });
if (!lesson) return R.error(res, "Lesson not found.", 404);
if (!await coursesCtrl.canAccessLesson(userId, lesson.lesson_id)) {
return R.error(res, "You do not have access to this lesson.", 403);
}
let unitId = null;
if (unitUuid) {
const unit = await Unit.findOne({ where: { uuid: unitUuid, ...notDeleted }, attributes: ["unit_id"] });
if (!unit) return R.error(res, "Unit not found.", 404);
const link = await UnitLesson.findOne({ where: { unit_id: unit.unit_id, lesson_id: lesson.lesson_id } });
if (!link) return R.error(res, "Lesson is not attached to this unit.", 404);
unitId = unit.unit_id;
}
const result = await recomputeCascade(userId, {
courseId: null,
unitId,
unitUuid,
lessonId: lesson.lesson_id,
lessonUuid: lesson.uuid,
lessonStatus: status,
});
logActivity(userId, "lesson_read", {
entityType: "lesson",
entityId: lesson.lesson_id,
details: { lesson_uuid: lesson.uuid, status, standalone: true },
});
return R.success(res, "Progress updated.", result, 200);
} catch (err) {
console.error("[CLIENT][LESSONS][STANDALONE PROGRESS]", err);
return R.error(res, "Could not update progress.", 500);
}
};
// ─── STANDALONE WATCH PROGRESS ─────────────────────────────────────────────────
// POST /client/lessons/:uuid/watch-progress Body: { percent, unit_uuid?, block_id?, block_type? }
// block_id/block_type identify which block on the lesson's page sent this update — needed
// to drive watch_video/listen_audio; omit them and only watch_percent (if configured) is touched.
exports.upsertStandaloneWatchProgress = async (req, res) => {
try {
const { uuid } = req.params;
const userId = req.user.user_id;
const percent = Number(req.body.percent);
if (!Number.isFinite(percent)) return R.error(res, "percent must be a number.", 400);
const unitUuid = req.body.unit_uuid ?? null;
const blockId = req.body.block_id ?? null;
const blockType = req.body.block_type ?? null;
const lesson = await Lesson.findOne({ where: { uuid, ...notDeleted }, attributes: ["lesson_id", "uuid"] });
if (!lesson) return R.error(res, "Lesson not found.", 404);
if (!await coursesCtrl.canAccessLesson(userId, lesson.lesson_id)) {
return R.error(res, "You do not have access to this lesson.", 403);
}
let unitId = null;
if (unitUuid) {
const unit = await Unit.findOne({ where: { uuid: unitUuid, ...notDeleted }, attributes: ["unit_id"] });
if (!unit) return R.error(res, "Unit not found.", 404);
unitId = unit.unit_id;
}
// Resume-position tracking is unconditional — every block gets it regardless of
// whether a completion requirement is configured. recordWatchProgress, below, is
// the anti-cheat-validated path and stays a no-op when nothing's configured.
await recordPlaybackPosition(userId, { lessonId: lesson.lesson_id, blockId, percent });
const result = await recordWatchProgress(userId, {
lessonId: lesson.lesson_id, lessonUuid: lesson.uuid,
unitId, unitUuid,
courseId: null, courseUuid: null,
percent, blockId, blockType,
});
return R.success(res, "Watch progress updated.", result, 200);
} catch (err) {
console.error("[CLIENT][LESSONS][STANDALONE WATCH PROGRESS]", err);
return R.error(res, "Could not update watch progress.", 500);
}
};
// ─── STANDALONE MARK COMPLETE ──────────────────────────────────────────────────
// POST /client/lessons/:uuid/mark-complete Body: { unit_uuid? }
exports.markStandaloneLessonComplete = async (req, res) => {
try {
const { uuid } = req.params;
const userId = req.user.user_id;
const unitUuid = req.body.unit_uuid ?? null;
const lesson = await Lesson.findOne({ where: { uuid, ...notDeleted }, attributes: ["lesson_id", "uuid"] });
if (!lesson) return R.error(res, "Lesson not found.", 404);
if (!await coursesCtrl.canAccessLesson(userId, lesson.lesson_id)) {
return R.error(res, "You do not have access to this lesson.", 403);
}
let unitId = null;
if (unitUuid) {
const unit = await Unit.findOne({ where: { uuid: unitUuid, ...notDeleted }, attributes: ["unit_id"] });
if (!unit) return R.error(res, "Unit not found.", 404);
unitId = unit.unit_id;
}
const result = await recordManualComplete(userId, {
entityType: "lesson", entityId: lesson.lesson_id,
lessonUuid: lesson.uuid,
unitId, unitUuid,
courseId: null, courseUuid: null,
});
return R.success(res, "Lesson marked complete.", result, 200);
} catch (err) {
console.error("[CLIENT][LESSONS][STANDALONE MARK COMPLETE]", err);
return R.error(res, "Could not mark lesson complete.", 500);
}
};
// ─── Shared UUID handlers re-exported for the standalone routes ───────────────
exports.getUnitByUuid = coursesCtrl.getUnitByUuid;
exports.getLessonsByUnitUuid = coursesCtrl.getLessonsByUnitUuid;
exports.getLessonByUuid = coursesCtrl.getLessonByUuid;