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, };