auth-app-api / src /controllers /content.controller.js
AndreaNotes1's picture
Upload 46 files
4c6b1e8 verified
Raw History Blame Contribute Delete
12.1 kB
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,
};