auth-app-api / src /controllers /voucher.controller.js
AndreaNotes1's picture
Upload 46 files
4c6b1e8 verified
Raw History Blame Contribute Delete
9.12 kB
const mongoose = require("mongoose");
const Voucher = require("../models/Voucher");
const Order = require("../models/Order");
const User = require("../models/User");
const Course = require("../models/Course");
const AuditLog = require("../models/AuditLog");
const { generateOrderNumber } = require("../utils/orderNumber");
const { generateVoucherCode } = require("../utils/voucherCode");
const { sendVoucherRedeemedEmail } = require("../utils/email");
const logger = require("../utils/logger");
function normalizeEmails(emails) {
if (!Array.isArray(emails)) return [];
return [...new Set(emails.map((e) => String(e).toLowerCase().trim()).filter(Boolean))];
}
// ---------------------------------------------------------------------
// Admin: create / list / inspect / deactivate vouchers
// ---------------------------------------------------------------------
/**
* Creates a voucher for a specific course. If no code is supplied, one is
* generated server-side (retried on the astronomically unlikely chance of
* a collision) so admins can just click "Generate" without inventing a
* code themselves.
*/
async function createVoucher(req, res, next) {
try {
const { courseId, code, maxRedemptions, restrictedToEmails, expiresAt, note } = req.body;
if (!mongoose.isValidObjectId(courseId)) {
return res.status(400).json({ error: "Invalid course selected." });
}
const course = await Course.findById(courseId);
if (!course) return res.status(404).json({ error: "Course not found." });
let finalCode = code ? String(code).toUpperCase().trim() : null;
if (finalCode) {
const existing = await Voucher.findOne({ code: finalCode });
if (existing) return res.status(409).json({ error: "That voucher code is already in use." });
} else {
for (let attempt = 0; attempt < 5 && !finalCode; attempt++) {
const candidate = generateVoucherCode();
// eslint-disable-next-line no-await-in-loop
const existing = await Voucher.findOne({ code: candidate });
if (!existing) finalCode = candidate;
}
if (!finalCode) {
return res.status(500).json({ error: "Could not generate a unique voucher code. Please try again." });
}
}
const voucher = await Voucher.create({
code: finalCode,
course: course._id,
createdBy: req.user._id,
maxRedemptions: maxRedemptions || 1,
restrictedToEmails: normalizeEmails(restrictedToEmails),
expiresAt: expiresAt || null,
note: note || "",
});
await AuditLog.create({
actor: req.user._id,
actorEmail: req.user.email,
action: "admin.voucher_created",
targetType: "Voucher",
targetId: voucher._id,
ip: req.ip,
userAgent: req.headers["user-agent"],
metadata: {
courseId: course._id.toString(),
maxRedemptions: voucher.maxRedemptions,
restrictedToEmails: voucher.restrictedToEmails,
},
});
res.status(201).json({ voucher: voucher.toAdminJSON() });
} catch (err) {
if (err && err.code === 11000) {
return res.status(409).json({ error: "That voucher code is already in use." });
}
next(err);
}
}
async function listVouchers(req, res, next) {
try {
const { courseId, active } = req.query;
const filter = {};
if (courseId && mongoose.isValidObjectId(courseId)) filter.course = courseId;
if (active === "true") filter.isActive = true;
if (active === "false") filter.isActive = false;
const vouchers = await Voucher.find(filter)
.sort({ createdAt: -1 })
.populate("course", "title")
.populate("redemptions.user", "name email");
res.json({
vouchers: vouchers.map((v) => ({
...v.toAdminJSON(),
course: v.course ? { id: v.course._id, title: v.course.title } : null,
redemptions: v.redemptions.map((r) => ({
user: r.user ? { id: r.user._id, name: r.user.name, email: r.user.email } : null,
order: r.order,
redeemedAt: r.redeemedAt,
})),
})),
});
} catch (err) {
next(err);
}
}
async function getVoucher(req, res, next) {
try {
const voucher = await Voucher.findById(req.params.id)
.populate("course", "title")
.populate("redemptions.user", "name email");
if (!voucher) return res.status(404).json({ error: "Voucher not found." });
res.json({
voucher: {
...voucher.toAdminJSON(),
course: voucher.course ? { id: voucher.course._id, title: voucher.course.title } : null,
redemptions: voucher.redemptions.map((r) => ({
user: r.user ? { id: r.user._id, name: r.user.name, email: r.user.email } : null,
order: r.order,
redeemedAt: r.redeemedAt,
})),
},
});
} catch (err) {
next(err);
}
}
function makeSetVoucherActiveHandler(isActive) {
return async function handler(req, res, next) {
try {
const voucher = await Voucher.findById(req.params.id);
if (!voucher) return res.status(404).json({ error: "Voucher not found." });
voucher.isActive = isActive;
await voucher.save();
await AuditLog.create({
actor: req.user._id,
actorEmail: req.user.email,
action: isActive ? "admin.voucher_reactivated" : "admin.voucher_deactivated",
targetType: "Voucher",
targetId: voucher._id,
ip: req.ip,
userAgent: req.headers["user-agent"],
});
res.json({ voucher: voucher.toAdminJSON() });
} catch (err) {
next(err);
}
};
}
const deactivateVoucher = makeSetVoucherActiveHandler(false);
const reactivateVoucher = makeSetVoucherActiveHandler(true);
// ---------------------------------------------------------------------
// User: redeem a voucher code for free course access
// ---------------------------------------------------------------------
/**
* Redeeming a voucher mirrors utils/orderConfirmation.js's
* confirmOrderAndGrantAccess: it produces a real (already-confirmed,
* zero-amount) Order so the course shows up consistently in "my
* orders"/admin reporting, and grants access the same way a paid order
* would. Wrapped in a transaction so the order, the user's enrolment,
* and the voucher's redemption count can never drift out of sync.
*/
async function redeemVoucher(req, res, next) {
const { code } = req.body;
const normalizedCode = String(code || "")
.toUpperCase()
.trim();
const session = await mongoose.startSession();
try {
let resultOrder;
let resultCourse;
let blockReason;
await session.withTransaction(async () => {
const voucher = await Voucher.findOne({ code: normalizedCode }).session(session);
if (!voucher) {
blockReason = "That voucher code isn't valid.";
return;
}
const course = await Course.findById(voucher.course).session(session);
if (!course || !course.isPublished) {
blockReason = "That voucher's course is no longer available.";
return;
}
if (req.user.hasAccessToCourse(course._id)) {
blockReason = "You already have access to this course.";
return;
}
const reason = voucher.redemptionBlockReason(req.user);
if (reason) {
blockReason = reason;
return;
}
const order = new Order({
orderNumber: generateOrderNumber(),
user: req.user._id,
course: course._id,
courseTitleSnapshot: course.title,
amount: 0,
currency: course.currency || "USD",
paymentMethod: "voucher",
voucher: voucher._id,
status: "confirmed",
confirmedAt: new Date(),
});
await order.save({ session });
const user = await User.findById(req.user._id).session(session);
user.enrolledCourses.push({ course: course._id, order: order._id, accessGrantedAt: new Date() });
await user.save({ session });
voucher.redemptions.push({ user: user._id, order: order._id, redeemedAt: new Date() });
await voucher.save({ session });
resultOrder = order;
resultCourse = course;
});
if (blockReason) {
return res.status(409).json({ error: blockReason });
}
await AuditLog.create({
actor: req.user._id,
actorEmail: req.user.email,
action: "voucher.redeemed",
targetType: "Order",
targetId: resultOrder._id,
ip: req.ip,
userAgent: req.headers["user-agent"],
metadata: { courseId: resultCourse._id.toString(), voucherCode: normalizedCode },
});
sendVoucherRedeemedEmail({
to: req.user.email,
name: req.user.name,
order: resultOrder,
courseTitle: resultCourse.title,
voucherCode: normalizedCode,
}).catch((err) => logger.error("Voucher-redeemed email failed", { error: err.message }));
res.status(201).json({ order: resultOrder.toPublicJSON() });
} catch (err) {
next(err);
} finally {
session.endSession();
}
}
module.exports = {
createVoucher,
listVouchers,
getVoucher,
deactivateVoucher,
reactivateVoucher,
redeemVoucher,
};