const fs = require("fs/promises"); const mongoose = require("mongoose"); const Course = require("../models/Course"); const AuditLog = require("../models/AuditLog"); const { uploadFromPath, deleteAsset, getSignedPlaybackUrl, resourceTypeForMimetype, uploadThumbnail: uploadThumbnailToCloudinary, deleteThumbnail, } = require("../utils/cloudinaryUpload"); const logger = require("../utils/logger"); function userHasAccess(user, courseId) { if (!user) return false; if (user.role === "admin") return true; return user.hasAccessToCourse(courseId); } // --------------------------------------------------------------------- // Learner-facing: view curriculum structure, and request a signed, // time-limited playback URL for one lesson at a time. // --------------------------------------------------------------------- async function getCourseCurriculum(req, res, next) { try { const course = await Course.findById(req.params.id); if (!course || !course.isPublished) return res.status(404).json({ error: "Course not found." }); const hasAccess = userHasAccess(req.user, course._id); res.json({ courseId: course._id, title: course.title, hasAccess, curriculum: hasAccess ? course.toCurriculumJSON() : course.toLockedCurriculumJSON(), }); } catch (err) { next(err); } } async function getLessonAccess(req, res, next) { try { const { id: courseId, sectionId, lessonId } = req.params; const course = await Course.findById(courseId); if (!course || !course.isPublished) return res.status(404).json({ error: "Course not found." }); const section = course.curriculum.id(sectionId); const lesson = section?.lessons.id(lessonId); if (!section || !lesson) return res.status(404).json({ error: "Lesson not found." }); const hasAccess = userHasAccess(req.user, course._id); if (!hasAccess && !lesson.isPreview) { return res.status(403).json({ error: "Purchase this course to access this lesson." }); } const { url, expiresAt } = getSignedPlaybackUrl(lesson.cloudinaryPublicId, lesson.cloudinaryResourceType, { format: lesson.format, viewer: { email: req.user.email }, }); await AuditLog.create({ actor: req.user._id, actorEmail: req.user.email, action: "lesson.access_granted", targetType: "Lesson", targetId: lesson._id, ip: req.ip, userAgent: req.headers["user-agent"], metadata: { courseId: course._id.toString() }, }); res.json({ url, expiresAt, type: lesson.type, title: lesson.title, }); } catch (err) { next(err); } } // --------------------------------------------------------------------- // Admin-only: manage curriculum structure and upload content to // Cloudinary. No learner-facing route ever exposes an upload capability. // --------------------------------------------------------------------- async function createSection(req, res, next) { try { const { title } = req.body; const course = await Course.findById(req.params.id); if (!course) return res.status(404).json({ error: "Course not found." }); const order = course.curriculum.length; course.curriculum.push({ title, order, lessons: [] }); await course.save(); res.status(201).json({ curriculum: course.toCurriculumJSON() }); } catch (err) { next(err); } } async function updateSection(req, res, next) { try { const { title, order } = req.body; const course = await Course.findById(req.params.id); if (!course) return res.status(404).json({ error: "Course not found." }); const section = course.curriculum.id(req.params.sectionId); if (!section) return res.status(404).json({ error: "Section not found." }); if (title !== undefined) section.title = title; if (order !== undefined) section.order = order; await course.save(); res.json({ curriculum: course.toCurriculumJSON() }); } catch (err) { next(err); } } async function deleteSection(req, res, next) { try { const course = await Course.findById(req.params.id); if (!course) return res.status(404).json({ error: "Course not found." }); const section = course.curriculum.id(req.params.sectionId); if (!section) return res.status(404).json({ error: "Section not found." }); // Clean up every Cloudinary asset in this section before removing it. await Promise.all( section.lessons.map((lesson) => deleteAsset(lesson.cloudinaryPublicId, lesson.cloudinaryResourceType).catch((err) => logger.error("Failed to delete Cloudinary asset", { error: err.message, lessonId: lesson._id.toString() }) ) ) ); section.deleteOne(); await course.save(); res.json({ curriculum: course.toCurriculumJSON() }); } catch (err) { next(err); } } async function uploadLesson(req, res, next) { const tempFilePath = req.file?.path; try { const { title, description, isPreview, order } = req.body; if (!req.file) return res.status(400).json({ error: "A file is required." }); const course = await Course.findById(req.params.id); if (!course) return res.status(404).json({ error: "Course not found." }); const section = course.curriculum.id(req.params.sectionId); if (!section) return res.status(404).json({ error: "Section not found." }); const resourceType = resourceTypeForMimetype(req.file.mimetype); const lessonType = resourceType === "video" ? "video" : "document"; const result = await uploadFromPath(tempFilePath, { resourceType, filenameHint: title, }); // From here on, the asset exists in Cloudinary. If anything below // fails (DB validation, a save error, etc.), we must delete it — // otherwise a failed attempt leaves an orphaned file in storage // forever, since nothing in the database ever references it to // clean it up later. try { section.lessons.push({ title, description: description || "", type: lessonType, order: order !== undefined ? Number(order) : section.lessons.length, isPreview: isPreview === "true" || isPreview === true, cloudinaryPublicId: result.public_id, cloudinaryResourceType: result.resource_type, format: result.format || "", bytes: result.bytes || 0, durationSeconds: result.duration ? Math.round(result.duration) : 0, }); await course.save(); await AuditLog.create({ actor: req.user._id, actorEmail: req.user.email, action: "admin.lesson_uploaded", targetType: "Course", targetId: course._id, ip: req.ip, userAgent: req.headers["user-agent"], metadata: { sectionId: section._id.toString(), title }, }); res.status(201).json({ curriculum: course.toCurriculumJSON() }); } catch (err) { await deleteAsset(result.public_id, result.resource_type).catch((cleanupErr) => logger.error("Failed to clean up orphaned Cloudinary asset after failed save", { error: cleanupErr.message, publicId: result.public_id, resourceType: result.resource_type, }) ); throw err; } } catch (err) { next(err); } finally { // Always clean up the local temp file, whether the upload succeeded, // failed partway, or Cloudinary itself errored out. if (tempFilePath) { fs.unlink(tempFilePath).catch((err) => logger.error("Failed to remove temp upload file", { error: err.message, tempFilePath }) ); } } } async function updateLesson(req, res, next) { try { const { title, description, isPreview, order } = req.body; const course = await Course.findById(req.params.id); if (!course) return res.status(404).json({ error: "Course not found." }); const section = course.curriculum.id(req.params.sectionId); const lesson = section?.lessons.id(req.params.lessonId); if (!section || !lesson) return res.status(404).json({ error: "Lesson not found." }); if (title !== undefined) lesson.title = title; if (description !== undefined) lesson.description = description; if (isPreview !== undefined) lesson.isPreview = isPreview === "true" || isPreview === true; if (order !== undefined) lesson.order = Number(order); await course.save(); res.json({ curriculum: course.toCurriculumJSON() }); } catch (err) { next(err); } } async function deleteLesson(req, res, next) { try { const course = await Course.findById(req.params.id); if (!course) return res.status(404).json({ error: "Course not found." }); const section = course.curriculum.id(req.params.sectionId); const lesson = section?.lessons.id(req.params.lessonId); if (!section || !lesson) return res.status(404).json({ error: "Lesson not found." }); await deleteAsset(lesson.cloudinaryPublicId, lesson.cloudinaryResourceType).catch((err) => logger.error("Failed to delete Cloudinary asset", { error: err.message }) ); lesson.deleteOne(); await course.save(); await AuditLog.create({ actor: req.user._id, actorEmail: req.user.email, action: "admin.lesson_deleted", targetType: "Course", targetId: course._id, ip: req.ip, userAgent: req.headers["user-agent"], }); res.json({ curriculum: course.toCurriculumJSON() }); } catch (err) { next(err); } } /** Admin editing view: full curriculum, same shape as the learner view (no raw Cloudinary IDs sent to the browser here either — uploads/deletes are the only mutating ops, playback still goes through the signed-URL endpoint). */ async function getCourseCurriculumForAdmin(req, res, next) { try { const course = await Course.findById(req.params.id); if (!course) return res.status(404).json({ error: "Course not found." }); res.json({ courseId: course._id, title: course.title, curriculum: course.toCurriculumJSON() }); } catch (err) { next(err); } } /** * Admin uploads/replaces a course's marketing thumbnail (shown on the * homepage "Our Academy" preview and the Academy catalogue page). If * the course already had one, the old Cloudinary asset is deleted so * replacing a thumbnail doesn't leave the previous image as an * orphaned file in storage. */ async function uploadThumbnail(req, res, next) { try { const course = await Course.findById(req.params.id); if (!course) return res.status(404).json({ error: "Course not found." }); if (!req.file) return res.status(400).json({ error: "No image file was uploaded." }); const previousPublicId = course.thumbnailPublicId; const result = await uploadThumbnailToCloudinary(req.file.path, { filenameHint: course.slug }); course.image = result.secure_url; course.thumbnailPublicId = result.public_id; await course.save(); if (previousPublicId) { deleteThumbnail(previousPublicId).catch((err) => logger.error("Failed to clean up replaced course thumbnail", { error: err.message, publicId: previousPublicId }) ); } await AuditLog.create({ actor: req.user._id, actorEmail: req.user.email, action: "admin.course_thumbnail_uploaded", targetType: "Course", targetId: course._id, ip: req.ip, userAgent: req.headers["user-agent"], }); res.json({ course }); } catch (err) { next(err); } finally { if (req.file) { fs.unlink(req.file.path).catch(() => {}); } } } module.exports = { getCourseCurriculum, getLessonAccess, getCourseCurriculumForAdmin, createSection, updateSection, deleteSection, uploadLesson, updateLesson, deleteLesson, uploadThumbnail, };