From 062f3b7cfaf1cc979227b1d8e269ad3589716bc9 Mon Sep 17 00:00:00 2001 From: Kenneth Obsequio Date: Tue, 7 Jul 2026 13:39:46 +0800 Subject: [PATCH] testing 127.0.0.1 issue Signed-off-by: Kenneth Obsequio --- controllers/auth.controller.js | 161 ++++++++++++++---- .../course_reading_progress.controller.js | 22 ++- controllers/client/courses.controller.js | 7 + controllers/client/task.controller.js | 20 +-- .../client/task_progress.controller.js | 34 +++- routes/auth.routes.js | 15 +- routes/client/groups.routes.js | 2 +- services/course_reading_progress.service.js | 30 +++- services/s3.service.js | 69 ++------ services/trustedDevice.service.js | 17 +- validators/auth.validator.js | 8 +- 11 files changed, 262 insertions(+), 123 deletions(-) diff --git a/controllers/auth.controller.js b/controllers/auth.controller.js index 8b249a3..d950c98 100644 --- a/controllers/auth.controller.js +++ b/controllers/auth.controller.js @@ -71,6 +71,24 @@ const setRefreshCookie = (res, refreshToken) => { }); }; +// The Google OAuth callback can't carry its outcome (otpRequired, ban details, +// errors) as a query string on the redirect without flashing it in the address +// bar — the browser lands on that literal URL before any frontend JS runs, so +// client-side scrubbing is always at least a frame too late. Instead, the +// outcome is stashed in a short-lived signed cookie and handed to the frontend +// only when it explicitly asks for it via GET /auth/google/result. +const GOOGLE_RESULT_COOKIE = '_googleAuthResult'; + +const setGoogleResultCookie = (res, payload) => { + res.cookie(GOOGLE_RESULT_COOKIE, JSON.stringify(payload), { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 2 * 60 * 1000, // 2 minutes — just long enough for the callback redirect to land + signed: true, + }); +}; + // ─── Mint Session ────────────────────────────────────────────────────────────── // Shared by verifyOTP and the trusted-device fast path in login/googleCallback — // the only two places tokens/sessions get minted. @@ -390,19 +408,24 @@ exports.googleCallback = async (req, res) => { const { code, state, error } = req.query; if (error) { - return res.redirect(`${CALLBACK_PAGE}?error=${encodeURIComponent(error)}`); + setGoogleResultCookie(res, { error }); + return res.redirect(CALLBACK_PAGE); } // Read and immediately clear the oauth state cookie. const rawCookie = req.signedCookies['_oauth']; res.clearCookie('_oauth'); - if (!rawCookie) return res.redirect(`${CALLBACK_PAGE}?error=session_expired`); + if (!rawCookie) { + setGoogleResultCookie(res, { error: 'session_expired' }); + return res.redirect(CALLBACK_PAGE); + } const { state: expectedState, nonce, codeVerifier } = JSON.parse(rawCookie); if (!state || state !== expectedState) { - return res.redirect(`${CALLBACK_PAGE}?error=state_mismatch`); + setGoogleResultCookie(res, { error: 'state_mismatch' }); + return res.redirect(CALLBACK_PAGE); } // Exchange authorization code → { id_token, access_token, ... } @@ -467,13 +490,16 @@ exports.googleCallback = async (req, res) => { const status = await checkAccountStatus(user); if (!status.ok) { if (status.code === 'deactivated') { - return res.redirect(`${CALLBACK_PAGE}?error=account_deactivated`); + setGoogleResultCookie(res, { error: 'account_deactivated' }); + return res.redirect(CALLBACK_PAGE); } - const params = new URLSearchParams({ error: 'account_banned' }); - if (status.reason) params.set('reason', status.reason); - if (status.ban_type) params.set('ban_type', status.ban_type); - if (status.ban_expires_at) params.set('expires_at', new Date(status.ban_expires_at).toISOString()); - return res.redirect(`${CALLBACK_PAGE}?${params.toString()}`); + setGoogleResultCookie(res, { + error: 'account_banned', + reason: status.reason ?? null, + ban_type: status.ban_type ?? null, + expires_at: status.ban_expires_at ? new Date(status.ban_expires_at).toISOString() : null, + }); + return res.redirect(CALLBACK_PAGE); } // Every Google sign-in (new or returning account) still has to clear the @@ -492,7 +518,9 @@ exports.googleCallback = async (req, res) => { logActivity(user.user_id, 'login', { entityType: 'session', entityId: Number(session.session_id) }); - return res.redirect(`${CALLBACK_PAGE}?otpRequired=false`); + // No cookie needed here — the refresh cookie set above is itself the + // signal. The frontend just calls restoreSession() and it succeeds. + return res.redirect(CALLBACK_PAGE); } const otp = generateOTP(); @@ -501,10 +529,29 @@ exports.googleCallback = async (req, res) => { sendEmail({ to: user.email, type: 'LOGIN_OTP', data: { otp } }) .catch(err => console.error('[AUTH] googleCallback: Failed to send login OTP email:', err)); - return res.redirect(`${CALLBACK_PAGE}?otpRequired=true&email=${encodeURIComponent(user.email)}`); + setGoogleResultCookie(res, { otpRequired: true, email: user.email }); + return res.redirect(CALLBACK_PAGE); } catch (err) { console.error('[AUTH] googleCallback OIDC error:', err); - return res.redirect(`${process.env.FRONTEND_URL}/auth/callback/google?error=auth_failed`); + setGoogleResultCookie(res, { error: 'auth_failed' }); + return res.redirect(`${process.env.FRONTEND_URL}/auth/callback/google`); + } +}; + +// ─── Google OIDC — Result handoff ───────────────────────────────────────────── +// Single-use: reads and immediately clears the cookie stashed by googleCallback. +// Returns {} when nothing is pending (trusted-device path — frontend should +// just call restoreSession(), since the real refresh cookie was already set). +exports.googleResult = (req, res) => { + const raw = req.signedCookies[GOOGLE_RESULT_COOKIE]; + res.clearCookie(GOOGLE_RESULT_COOKIE); + + if (!raw) return R.success(res, 'No pending Google auth result.', {}); + + try { + return R.success(res, 'Pending Google auth result.', JSON.parse(raw)); + } catch (_) { + return R.success(res, 'No pending Google auth result.', {}); } }; @@ -563,11 +610,17 @@ exports.logout = async (req, res) => { logActivity(req.user.user_id, 'logout', { entityType: 'session', entityId: session_id ? Number(session_id) : null }); - await trustedDevice.revokeByToken(req.user.user_id, req.cookies[trustedDevice.COOKIE_NAME]); + // Ordinary logout intentionally does NOT touch trusted_devices or clear + // device_trust: expires_at (rolling 30-day window) is what ends the + // OTP-skip, not the act of logging out. Clearing/revoking here would + // force OTP on the very next login on the same device, which defeats + // the point of trusted_devices. Trust is only force-revoked elsewhere + // for actual security events — password change/reset, admin ban/ + // deactivate/force-logout, or a specific session being terminated + // (see trustedDevice.service.js: revokeAllForUser / revokeBySessionId). res.clearCookie('refreshToken') res.clearCookie('_csrf') - res.clearCookie(trustedDevice.COOKIE_NAME) return R.success(res, 'Logged out successfully.'); } catch (err) { @@ -606,47 +659,85 @@ exports.changePassword = async (req, res) => { // ─── Forgot Password — Request OTP ───────────────────────────────────────────── // Same procedure for every acc_type (admin/staff/user) — only reg_type matters. +// Response is identical whether the email is unknown or deactivated/suspended — +// only a real, eligible account actually gets an OTP — EXCEPT for Google-linked +// accounts, which get an explicit "use Google sign-in" dialog by design (accepted +// tradeoff: this does reveal that a given email is a Google-linked account). exports.forgotPassword = async (req, res) => { try { const { email } = req.body; - const user = await mdl_Users.findOne({ where: { email } }); - if (!user) return R.error(res, 'User not found.', 404); - if (user.reg_type === 'google') - return R.error(res, 'This account uses Google sign-in. Please log in with Google.', 400); + const genericMessage = 'If an account exists for this email, a reset code has been sent.'; - const status = await checkAccountStatus(user); - if (!status.ok) { - if (status.code === 'deactivated') return R.error(res, 'Account is deactivated.', 403); - return R.error(res, 'Your account has been suspended.', 403, { banned: true }); + const user = await mdl_Users.findOne({ where: { email } }); + + if (user && user.reg_type === 'google') { + return R.error( + res, + 'This account was created using Google. Sign in with Google instead — there’s no password to reset for accounts created this way.', + 400, + { google: true }, + ); } - const otp = generateOTP(); - await user.update({ otp_code: otp, otp_expires_at: getOTPExpiry() }); + if (user) { + const status = await checkAccountStatus(user); + if (status.ok) { + const otp = generateOTP(); + await user.update({ otp_code: otp, otp_expires_at: getOTPExpiry() }); - sendEmail({ to: email, type: 'RESET_PASSWORD_OTP', data: { otp } }) - .catch(err => console.error('[AUTH] Failed to send reset-password OTP email:', err)); + sendEmail({ to: email, type: 'RESET_PASSWORD_OTP', data: { otp } }) + .catch(err => console.error('[AUTH] Failed to send reset-password OTP email:', err)); + } + } - return R.success(res, 'An OTP has been sent to your email.', { email: user.email }); + return R.success(res, genericMessage, { email }); } catch (err) { console.error('[AUTH] forgotPassword error:', err); return R.error(res, 'Could not process request.', 500); } }; -// ─── Forgot Password — Reset with OTP ────────────────────────────────────────── -exports.resetPassword = async (req, res) => { +// ─── Forgot Password — Verify OTP only (step 2 of 3) ─────────────────────────── +// Checks the code without consuming it or touching the password, so the reset +// flow can gate the "new password" step behind a verified code. resetPassword +// re-checks the same OTP when the password is actually submitted. +exports.verifyResetOTP = async (req, res) => { try { - const { email, otp, new_password } = req.body; + const { email, otp } = req.body; + const genericError = 'Invalid or expired code.'; + const user = await mdl_Users.findOne({ where: { email } }); - if (!user) return R.error(res, 'User not found.', 404); - if (user.reg_type === 'google') - return R.error(res, 'This account uses Google sign-in. Please log in with Google.', 400); + if (!user || user.reg_type === 'google') return R.error(res, genericError, 400); const storedOTP = Buffer.from(user.otp_code ?? ''); const givenOTP = Buffer.from(otp ?? ''); if (storedOTP.length !== givenOTP.length || !crypto.timingSafeEqual(storedOTP, givenOTP)) - return R.error(res, 'Invalid OTP.', 400); - if (isOTPExpired(user.otp_expires_at)) return R.error(res, 'OTP has expired. Please request a new one.', 400); + return R.error(res, genericError, 400); + if (isOTPExpired(user.otp_expires_at)) return R.error(res, genericError, 400); + + return R.success(res, 'Code verified.'); + } catch (err) { + console.error('[AUTH] verifyResetOTP error:', err); + return R.error(res, 'Could not process request.', 500); + } +}; + +// ─── Forgot Password — Reset with OTP ────────────────────────────────────────── +// Same generic error for unknown email / Google-linked / wrong OTP / expired OTP +// so this endpoint can't be used to enumerate accounts either. +exports.resetPassword = async (req, res) => { + try { + const { email, otp, new_password } = req.body; + const genericError = 'Invalid or expired code.'; + + const user = await mdl_Users.findOne({ where: { email } }); + if (!user || user.reg_type === 'google') return R.error(res, genericError, 400); + + const storedOTP = Buffer.from(user.otp_code ?? ''); + const givenOTP = Buffer.from(otp ?? ''); + if (storedOTP.length !== givenOTP.length || !crypto.timingSafeEqual(storedOTP, givenOTP)) + return R.error(res, genericError, 400); + if (isOTPExpired(user.otp_expires_at)) return R.error(res, genericError, 400); const hashed = await bcrypt.hash(new_password, 12); await user.update({ password: hashed, otp_code: null, otp_expires_at: null }); diff --git a/controllers/client/course_reading_progress.controller.js b/controllers/client/course_reading_progress.controller.js index b6b77d9..35728f1 100644 --- a/controllers/client/course_reading_progress.controller.js +++ b/controllers/client/course_reading_progress.controller.js @@ -189,8 +189,7 @@ exports.getMyInProgressCourses = async (req, res) => { if (!pending.length) return R.success(res, 'No courses in progress.', []); const result = await Promise.all(pending.map(async (row) => { - const courseId = row.course_id; - const readingDone = row.status === 'completed'; + const courseId = row.course_id; const [lessons_total, lessons_completed] = await Promise.all([ Lesson.count({ @@ -207,8 +206,13 @@ exports.getMyInProgressCourses = async (req, res) => { }), ]); - let pending_quizzes = []; - let pending_assessment = null; + // "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 unitQuizzes = await UnitQuiz.findAll({ @@ -244,6 +248,7 @@ exports.getMyInProgressCourses = async (req, res) => { 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 } }), @@ -262,12 +267,13 @@ exports.getMyInProgressCourses = async (req, res) => { } return { - course_id: courseId, - title: row.course.title, - reading_status: row.status, + 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, + last_accessed_at: row.last_accessed_at, pending_quizzes, pending_assessment, }; diff --git a/controllers/client/courses.controller.js b/controllers/client/courses.controller.js index b79ad3c..191f86e 100644 --- a/controllers/client/courses.controller.js +++ b/controllers/client/courses.controller.js @@ -299,6 +299,13 @@ exports.getCourse = async (req, res) => { required: false, }], }, + { + model: mdl_Category, + as: "categories", + through: { attributes: [] }, + required: false, + attributes: ["id", "name", "slug"], + }, ], order: [ [{ model: Unit, as: "units" }, "order_index", "ASC"], diff --git a/controllers/client/task.controller.js b/controllers/client/task.controller.js index 0387f73..53a8af1 100644 --- a/controllers/client/task.controller.js +++ b/controllers/client/task.controller.js @@ -132,7 +132,7 @@ const isMember = async (userId, groupId) => { // ───────────────────────────────────────────────────────────────────────────── // REPLACEMENT: getGroupTaskList in task.controller.js (client) // -// GET /client/groups/:groupId/task-lists/:taskListId?status=ongoing|done|overdue +// 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 @@ -147,17 +147,17 @@ const isMember = async (userId, groupId) => { // requirement_id (+ reference_id) // // Task bucket: -// done → every requirement passes its check above +// 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 done AND task.deadline < now +// 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' | 'done' | 'overdue' + const { status } = req.query; // optional: 'ongoing' | 'completed' | 'overdue' const userId = req.user.user_id; const member = await isMember(userId, groupId); @@ -259,7 +259,7 @@ exports.getGroupTaskList = async (req, res) => { let bucket; if (has_completed) { - bucket = 'done'; + bucket = 'completed'; } else if (task.deadline && new Date(task.deadline).getTime() < now) { bucket = 'overdue'; } else { @@ -287,7 +287,7 @@ exports.getGroupTaskList = async (req, res) => { // ───────────────────────────────────────────────────────────────────────────── // REPLACEMENT: getGroupTaskLists in task.controller.js (client) — plural // -// GET /client/groups/:groupId/task-lists?status=ongoing|done|overdue +// 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 @@ -295,8 +295,8 @@ exports.getGroupTaskList = async (req, res) => { // // TaskList bucket (based on per-task has_completed, computed below): // TaskList has zero tasks → Ongoing (nothing to do yet) -// ALL tasks have has_completed → Done -// NOT all done AND any incomplete +// ALL tasks have has_completed → Completed +// NOT all completed AND any incomplete // task has deadline < now → Overdue // Otherwise → Ongoing // ───────────────────────────────────────────────────────────────────────────── @@ -304,7 +304,7 @@ exports.getGroupTaskList = async (req, res) => { exports.getGroupTaskLists = async (req, res) => { try { const { groupId } = req.params; - const { status } = req.query; // optional: 'ongoing' | 'done' | 'overdue' + const { status } = req.query; // optional: 'ongoing' | 'completed' | 'overdue' const userId = req.user.user_id; const member = await isMember(userId, groupId); @@ -423,7 +423,7 @@ exports.getGroupTaskLists = async (req, res) => { } else { const allDone = tasks.every((t) => t.has_completed); if (allDone) { - bucket = 'done'; + bucket = 'completed'; } else { const anyOverdue = tasks.some((t) => !t.has_completed && t.deadline && new Date(t.deadline).getTime() < now diff --git a/controllers/client/task_progress.controller.js b/controllers/client/task_progress.controller.js index 17624d0..71751dd 100644 --- a/controllers/client/task_progress.controller.js +++ b/controllers/client/task_progress.controller.js @@ -27,6 +27,9 @@ const sequelize = require('../../config/db.config'); const { Task, TaskRequirement } = require('../../models/task/task.mdl'); const { TaskLinkVisit, TaskProgress } = require('../../models/task/task_progress.mdl'); const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl'); +const { 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'); @@ -67,8 +70,10 @@ const deriveUnitCompletion = async (userId, unitRequirementId, t) => { // ─── Helper: derive course completion ──────────────────────────────────────── // Course is complete when ALL read_unit progress rows under this course requirement -// for this user are marked completed. -const deriveCourseCompletion = async (userId, courseRequirementId, t) => { +// 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, @@ -78,7 +83,28 @@ const deriveCourseCompletion = async (userId, courseRequirementId, t) => { transaction: t, }); if (!rows.length) return false; - return rows.every((r) => r.completed); + 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; }; // ============================================================================= @@ -352,7 +378,7 @@ exports.updateProgress = async (req, res) => { if (course_requirement_id) { const courseReq = await getRequirement(course_requirement_id, taskId); if (courseReq && courseReq.type === 'read_course') { - const courseDone = await deriveCourseCompletion(userId, course_requirement_id, t); + const courseDone = await deriveCourseCompletion(userId, course_requirement_id, courseReq.reference_id, t); await TaskProgress.upsert( { diff --git a/routes/auth.routes.js b/routes/auth.routes.js index adfd88a..aa686c9 100644 --- a/routes/auth.routes.js +++ b/routes/auth.routes.js @@ -18,14 +18,21 @@ * POST /api/auth/change-password → change password (requires authenticate) * POST /api/auth/forgot-password → same procedure for every acc_type — checks * reg_type is 'system' (not Google), sends OTP + * POST /api/auth/verify-reset-otp → checks OTP validity only, does not consume it + * or change the password (step 2 of the 3-step + * reset flow: email → OTP → new password) * POST /api/auth/reset-password → verifies OTP + sets new password in one step * GET /api/auth/google → initiate Google OIDC (generates state/nonce/PKCE) * GET /api/auth/google/callback → verifies + exchanges code, sends a login OTP, - * redirects to the frontend with otpRequired=true + * redirects to the frontend callback page (no query + * params — outcome is stashed in a signed cookie) + * GET /api/auth/google/result → single-use read of that cookie (otpRequired/email, + * or an error), so it never has to live in the URL * * Author: rgrgogu * Date Created: Oct. 6, 2025 - * Date Modified: Jul. 4, 2026 — mandatory OTP on every login + forgot/reset password (Kenneth Obsequio) + * Date Modified: Jul. 6, 2026 — Google callback handoff moved from URL params to a signed + * cookie + /google/result endpoint (Kenneth Obsequio) ***********************************************************************************************************************************************************************/ const express = require('express'); const router = express.Router(); @@ -38,7 +45,7 @@ const { validate } = require('../middleware/validate.middleware'); const { registerValidator, loginValidator, verifyOTPValidator, resendOTPValidator, changePassValidator, - forgotPasswordValidator, resetPasswordValidator, + forgotPasswordValidator, verifyResetOTPValidator, resetPasswordValidator, } = require('../validators/auth.validator'); // ── CSRF token (GET — no CSRF needed on GETs) ────────────────────────────────── @@ -53,10 +60,12 @@ router.post('/refresh', authLimiter, authCtrl.refreshToken); router.post('/logout', authenticate, authLimiter, authCtrl.logout); router.post('/change-password', authenticate, sensitiveOpsLimiter, ...changePassValidator, validate, authCtrl.changePassword); router.post('/forgot-password', otpLimiter, ...forgotPasswordValidator, validate, authCtrl.forgotPassword); +router.post('/verify-reset-otp', otpLimiter, ...verifyResetOTPValidator, validate, authCtrl.verifyResetOTP); router.post('/reset-password', otpLimiter, ...resetPasswordValidator, validate, authCtrl.resetPassword); // ── Google OIDC ──────────────────────────────────────────────────────────────── router.get('/google', authLimiter, authCtrl.googleRedirect); router.get('/google/callback', authCtrl.googleCallback); +router.get('/google/result', authCtrl.googleResult); module.exports = router; \ No newline at end of file diff --git a/routes/client/groups.routes.js b/routes/client/groups.routes.js index 51a2d05..76fe4ac 100644 --- a/routes/client/groups.routes.js +++ b/routes/client/groups.routes.js @@ -7,7 +7,7 @@ * Route Map: * GET /client/groups → my groups * GET /client/groups/:groupId → single group - * GET /client/groups/:groupId/task-lists → task lists (?status=ongoing|done|overdue) + * GET /client/groups/:groupId/task-lists → task lists (?status=ongoing|completed|overdue) * GET /client/groups/:groupId/task-lists/:taskListId → single task list * GET /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId → task + requirements + latest completion * GET /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/progress → full progress snapshot diff --git a/services/course_reading_progress.service.js b/services/course_reading_progress.service.js index d8063b7..77a8349 100644 --- a/services/course_reading_progress.service.js +++ b/services/course_reading_progress.service.js @@ -12,6 +12,9 @@ * Derivation rules: * unit → completed when ALL its non-deleted lessons have a completed row for this user * course → completed when ALL its non-deleted units have a completed row for this user + * AND, if the course has a course assessment, the user has passed it. + * A course with no assessment built yet can never reach 'completed' here — + * reading alone isn't course completion. * * Author: Kenneth Obsequio (@lash0000) * Date Created: Jun. 21, 2026 @@ -22,6 +25,24 @@ const sequelize = require('../config/db.config'); const CourseReadingProgress = require('../models/courses/course_reading_progress.mdl'); const Lesson = require('../models/courses/lessons.mdl'); const Unit = require('../models/courses/units.mdl'); +const CourseAssessment = require('../models/courses/course_assessment.mdl'); +const QuizAttempt = require('../models/courses/quiz_attempt.mdl'); + +// A course only counts as fully complete once it has a built assessment AND the user passed it. +async function hasPassedCourseAssessment(userId, courseId, t) { + const assessment = await CourseAssessment.findOne({ + where: { course_id: courseId }, + 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; +} // ─── Core UPSERT ───────────────────────────────────────────────────────────── @@ -74,7 +95,8 @@ async function deriveUnitStatus(userId, courseId, unitId, t) { return completedCount === lessons.length ? 'completed' : 'in_progress'; } -// Course is completed when every non-deleted unit under it has a completed row for this user. +// Course is completed when every non-deleted unit under it has a completed row for this user +// AND the course's assessment (if one has been built) has been passed by this user. async function deriveCourseStatus(userId, courseId, t) { const units = await Unit.findAll({ where: { course_id: courseId }, @@ -95,7 +117,11 @@ async function deriveCourseStatus(userId, courseId, t) { transaction: t, }); - return completedCount === units.length ? 'completed' : 'in_progress'; + const allUnitsRead = completedCount === units.length; + if (!allUnitsRead) return 'in_progress'; + + const assessmentPassed = await hasPassedCourseAssessment(userId, courseId, t); + return assessmentPassed ? 'completed' : 'in_progress'; } // ─── Main entry point ───────────────────────────────────────────────────────── diff --git a/services/s3.service.js b/services/s3.service.js index 2d5b792..5968d29 100644 --- a/services/s3.service.js +++ b/services/s3.service.js @@ -46,64 +46,21 @@ const DEFAULT_BUCKET = process.env.S3_BUCKET; // from the machine running Garage itself; S3_PUBLIC_URL is the externally // reachable address (tunnel/CDN/domain). // -// Rather than always preferring one, probe S3_ENDPOINT and use it when it's -// actually reachable (same-machine dev setup — no extra hop through the -// tunnel), falling back to S3_PUBLIC_URL when it isn't (any other machine). -// -// The probe runs once at startup and then on a background timer — never on -// the request path itself. A machine without Garage would otherwise pay the -// full HeadBucket timeout on whichever upload/asset request happens to land -// right after the cache expires; polling in the background means every -// request just reads the last known-good host instantly. -const PROBE_TIMEOUT_MS = 1500; -const PROBE_CACHE_MS = 15000; - -let hostCache = { host: process.env.S3_PUBLIC_URL || process.env.S3_ENDPOINT || "" }; - -async function probeEndpoint(endpoint) { - const probe = new S3Client({ - endpoint, - region: process.env.S3_REGION || "garage", - credentials, - forcePathStyle: true, - }); - await Promise.race([ - probe.send(new HeadBucketCommand({ Bucket: DEFAULT_BUCKET })), - new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), PROBE_TIMEOUT_MS)), - ]); +// This used to probe S3_ENDPOINT from the server and prefer it when reachable, +// but that measures the wrong machine: Garage is always co-located with this +// backend (see docker-compose.yml), so the probe was *always* reachable from +// here and always resolved to 127.0.0.1 — even for browsers on other machines, +// which then failed to connect to it. There is no way for the server to +// determine what's reachable from the client by probing itself, so just trust +// config: prefer S3_PUBLIC_URL whenever it's set, and only fall back to +// S3_ENDPOINT for pure single-machine dev setups with no public URL at all. +function resolvePublicHost() { + return process.env.S3_PUBLIC_URL || process.env.S3_ENDPOINT || ""; } -async function refreshHostCache() { - const endpoint = process.env.S3_ENDPOINT; - const publicUrl = process.env.S3_PUBLIC_URL || ""; - - if (!endpoint) { hostCache = { host: publicUrl }; return; } - if (!publicUrl) { hostCache = { host: endpoint }; return; } - - try { - await probeEndpoint(endpoint); - hostCache = { host: endpoint }; - } catch { - hostCache = { host: publicUrl }; - } -} - -// Kick off the first probe immediately so the cache is populated before any -// request needs it, then keep it fresh in the background. unref() so this -// timer alone doesn't keep the process (or a test run) alive. -const initialProbe = refreshHostCache(); -const refreshTimer = setInterval(refreshHostCache, PROBE_CACHE_MS); -refreshTimer.unref?.(); - -async function resolvePublicHost() { - await initialProbe; // no-op after the first call — already resolved - return hostCache.host; -} - -// Public client — lazily built against whichever host resolvePublicHost() -// picks, so it follows the reachability check instead of a fixed endpoint. -async function getPublicClient() { - const endpoint = await resolvePublicHost(); +// Public client — built against whichever host resolvePublicHost() picks. +function getPublicClient() { + const endpoint = resolvePublicHost(); return new S3Client({ endpoint, region: process.env.S3_REGION || "garage", diff --git a/services/trustedDevice.service.js b/services/trustedDevice.service.js index 45fd113..9bd77a1 100644 --- a/services/trustedDevice.service.js +++ b/services/trustedDevice.service.js @@ -6,9 +6,14 @@ * OTP; trust rolls forward 30 days on each trusted login and is * tied to both an opaque cookie token (device_trust) and a * User-Agent fingerprint, so a stolen cookie alone isn't enough - * once the fingerprint no longer matches. Trust is revoked on - * logout, password change/reset, admin ban/deactivate, or a - * single session being terminated. + * once the fingerprint no longer matches. Ordinary logout does + * NOT revoke trust or clear the device_trust cookie — expires_at + * is the only thing that ends the OTP-skip window in the normal + * case, so logging out and back in on the same device still + * skips OTP until the 30-day window actually lapses. Trust is + * only force-revoked by password change/reset, admin ban/ + * deactivate/force-logout, or a single session being explicitly + * terminated. * Author: Kenneth Obsequio * Date Created: Jul. 5, 2026 *********************************************************************************************************************************************************************** @@ -103,6 +108,12 @@ const issueOrRefresh = async (res, userId, fingerprintHash, sessionId) => { } }; +/** + * Revokes trust for one specific device by its cookie token. Not called by + * the normal logout flow (see auth.controller.js exports.logout) — ordinary + * logout intentionally leaves trust intact. Kept as a primitive for a + * future explicit "forget this device" action, should one be added. + */ const revokeByToken = async (userId, rawToken) => { if (!rawToken) return; try { diff --git a/validators/auth.validator.js b/validators/auth.validator.js index 2255bf1..c138b39 100644 --- a/validators/auth.validator.js +++ b/validators/auth.validator.js @@ -8,6 +8,7 @@ * - resendOTPValidator → POST /auth/resend-otp * - changePassValidator → POST /auth/change-password * - forgotPasswordValidator → POST /auth/forgot-password + * - verifyResetOTPValidator → POST /auth/verify-reset-otp * - resetPasswordValidator → POST /auth/reset-password * Author: rgrgogu * Date Created: Oct. 6, 2025 @@ -50,6 +51,11 @@ const forgotPasswordValidator = [ body('email').isEmail().withMessage('Valid email is required.'), ]; +const verifyResetOTPValidator = [ + body('email').isEmail().withMessage('Valid email is required.'), + body('otp').isLength({ min: 6, max: 6 }).isNumeric().withMessage('OTP must be 6 digits.'), +]; + const resetPasswordValidator = [ body('email').isEmail().withMessage('Valid email is required.'), body('otp').isLength({ min: 6, max: 6 }).isNumeric().withMessage('OTP must be 6 digits.'), @@ -62,5 +68,5 @@ const resetPasswordValidator = [ module.exports = { registerValidator, loginValidator, verifyOTPValidator, resendOTPValidator, changePassValidator, - forgotPasswordValidator, resetPasswordValidator, + forgotPasswordValidator, verifyResetOTPValidator, resetPasswordValidator, }; \ No newline at end of file