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