AndreaNotes1 commited on
Commit
486b599
·
verified ·
1 Parent(s): 7988be1

Upload 35 files

Browse files
.dockerignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ node_modules
2
+ npm-debug.log*
3
+ .env
4
+ .env.*
5
+ !.env.example
6
+ .git
7
+ .gitignore
8
+ *.md
.gitignore ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ node_modules/
2
+ .env
3
+ npm-debug.log*
Dockerfile ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:20-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Install dependencies in their own layer so this only re-runs when
6
+ # package*.json actually changes, speeding up rebuilds. Using `npm
7
+ # install` rather than `npm ci` since this repo doesn't ship a
8
+ # package-lock.json — if you generate one locally (recommended:
9
+ # `npm install` once, then commit package-lock.json), switch this to
10
+ # `npm ci --omit=dev` for fully reproducible, faster builds.
11
+ COPY package*.json ./
12
+ RUN npm install --omit=dev
13
+
14
+ # Copy the rest of the application source
15
+ COPY . .
16
+
17
+ # Hugging Face Spaces (Docker SDK) route external traffic to whatever
18
+ # port is declared as "app_port" in the Space's README.md metadata —
19
+ # 7860 is Hugging Face's own default, so we match it here. If you set a
20
+ # different app_port in your Space's README, update PORT to match.
21
+ ENV NODE_ENV=production
22
+ ENV PORT=7860
23
+ EXPOSE 7860
24
+
25
+ # Run as the non-root "node" user (built into the official Node image)
26
+ # rather than root, as one more layer of defense-in-depth.
27
+ RUN chown -R node:node /app
28
+ USER node
29
+
30
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
31
+ CMD node -e "require('http').get('http://localhost:7860/api/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"
32
+
33
+ CMD ["node", "src/server.js"]
README.md CHANGED
@@ -1,9 +1,10 @@
1
  ---
2
- title: Auth App API
3
- emoji: 🔐
4
- colorFrom: indigo
5
  colorTo: purple
6
  sdk: docker
 
7
  pinned: false
8
  ---
9
 
 
1
  ---
2
+ title: Aynwerdstudy
3
+ emoji: 🎓
4
+ colorFrom: blue
5
  colorTo: purple
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
  ---
10
 
package.json ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "aynwerd-server",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "description": "Node.js/Express + MongoDB Atlas backend for courses, checkout, and admin-approved enrolment.",
6
+ "type": "commonjs",
7
+ "main": "src/server.js",
8
+ "scripts": {
9
+ "start": "node src/server.js",
10
+ "dev": "nodemon src/server.js",
11
+ "seed": "node src/seed/seed.js"
12
+ },
13
+ "engines": {
14
+ "node": ">=18.0.0"
15
+ },
16
+ "dependencies": {
17
+ "bcryptjs": "^2.4.3",
18
+ "cloudinary": "^2.5.1",
19
+ "compression": "^1.7.4",
20
+ "cookie-parser": "^1.4.6",
21
+ "cors": "^2.8.5",
22
+ "dotenv": "^16.4.5",
23
+ "express": "^4.19.2",
24
+ "express-mongo-sanitize": "^2.2.0",
25
+ "express-rate-limit": "^7.4.0",
26
+ "express-validator": "^7.2.0",
27
+ "helmet": "^7.1.0",
28
+ "hpp": "^0.2.3",
29
+ "jsonwebtoken": "^9.0.2",
30
+ "mongoose": "^8.6.0",
31
+ "morgan": "^1.10.0",
32
+ "multer": "^1.4.5-lts.1",
33
+ "nodemailer": "^6.9.14",
34
+ "uuid": "^9.0.1",
35
+ "winston": "^3.14.2"
36
+ },
37
+ "devDependencies": {
38
+ "nodemon": "^3.1.4"
39
+ }
40
+ }
src/app.js ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const express = require("express");
2
+ const helmet = require("helmet");
3
+ const cors = require("cors");
4
+ const cookieParser = require("cookie-parser");
5
+ const compression = require("compression");
6
+ const morgan = require("morgan");
7
+ const mongoSanitize = require("express-mongo-sanitize");
8
+ const hpp = require("hpp");
9
+
10
+ const { env } = require("./config/env");
11
+ const { apiLimiter } = require("./middleware/rateLimiters");
12
+ const { errorHandler, notFound } = require("./middleware/errorHandler");
13
+ const logger = require("./utils/logger");
14
+
15
+ const authRoutes = require("./routes/auth.routes");
16
+ const courseRoutes = require("./routes/course.routes");
17
+ const orderRoutes = require("./routes/order.routes");
18
+ const adminRoutes = require("./routes/admin.routes");
19
+
20
+ const app = express();
21
+
22
+ // Trust the first proxy hop (Render/Heroku/Nginx/etc.) so req.ip and the
23
+ // "secure" cookie/HSTS logic reflect the real client over HTTPS.
24
+ app.set("trust proxy", 1);
25
+
26
+ // --- Security headers -------------------------------------------------
27
+ app.use(
28
+ helmet({
29
+ contentSecurityPolicy: {
30
+ directives: {
31
+ defaultSrc: ["'self'"],
32
+ scriptSrc: ["'self'"],
33
+ styleSrc: ["'self'", "'unsafe-inline'"],
34
+ imgSrc: ["'self'", "data:", "https:"],
35
+ objectSrc: ["'none'"],
36
+ frameAncestors: ["'none'"],
37
+ },
38
+ },
39
+ crossOriginResourcePolicy: { policy: "same-site" },
40
+ hsts: { maxAge: 63072000, includeSubDomains: true, preload: true },
41
+ })
42
+ );
43
+
44
+ // --- CORS: only the configured frontend origins may call the API, and
45
+ // credentials (refresh-token cookie) are only sent to those origins. ---
46
+ app.use(
47
+ cors({
48
+ origin(origin, callback) {
49
+ if (!origin || env.clientOrigins.includes(origin)) return callback(null, true);
50
+ return callback(new Error("Not allowed by CORS"));
51
+ },
52
+ credentials: true,
53
+ })
54
+ );
55
+
56
+ app.use(compression());
57
+ app.use(express.json({ limit: "100kb" })); // small limit: this API never needs large bodies
58
+ app.use(express.urlencoded({ extended: true, limit: "100kb" }));
59
+ app.use(cookieParser(env.cookieSecret));
60
+
61
+ // Strip any keys starting with "$" or containing "." from user input to
62
+ // block MongoDB operator-injection attacks.
63
+ app.use(mongoSanitize());
64
+ // Prevent HTTP parameter-pollution (e.g. ?role=user&role=admin).
65
+ app.use(hpp());
66
+
67
+ app.use(
68
+ morgan("combined", {
69
+ stream: { write: (message) => logger.info(message.trim()) },
70
+ skip: () => env.nodeEnv === "test",
71
+ })
72
+ );
73
+
74
+ app.use("/api/", apiLimiter);
75
+
76
+ app.get("/api/health", (req, res) => res.json({ status: "ok", time: new Date().toISOString() }));
77
+
78
+ app.use("/api/auth", authRoutes);
79
+ app.use("/api/courses", courseRoutes);
80
+ app.use("/api/orders", orderRoutes);
81
+ app.use("/api/admin", adminRoutes);
82
+
83
+ app.use(notFound);
84
+ app.use(errorHandler);
85
+
86
+ module.exports = app;
src/config/cloudinary.js ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const { v2: cloudinary } = require("cloudinary");
2
+ const { env } = require("./env");
3
+
4
+ cloudinary.config({
5
+ cloud_name: env.cloudinary.cloudName,
6
+ api_key: env.cloudinary.apiKey,
7
+ api_secret: env.cloudinary.apiSecret,
8
+ secure: true,
9
+ });
10
+
11
+ module.exports = cloudinary;
src/config/db.js ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const mongoose = require("mongoose");
2
+ const { env } = require("./env");
3
+ const logger = require("../utils/logger");
4
+
5
+ mongoose.set("strictQuery", true);
6
+
7
+ async function connectDB() {
8
+ if (!env.mongoUri) {
9
+ throw new Error("MONGODB_URI is not set. Copy .env.example to .env and configure your Atlas connection string.");
10
+ }
11
+
12
+ mongoose.connection.on("connected", () => logger.info("MongoDB connected"));
13
+ mongoose.connection.on("error", (err) => logger.error("MongoDB connection error", { error: err.message }));
14
+ mongoose.connection.on("disconnected", () => logger.warn("MongoDB disconnected"));
15
+
16
+ try {
17
+ await mongoose.connect(env.mongoUri, {
18
+ // Modern mongoose driver already defaults to these; kept explicit for clarity.
19
+ autoIndex: env.nodeEnv !== "production", // build indexes lazily in prod via migration/ops task
20
+ maxPoolSize: 20,
21
+ serverSelectionTimeoutMS: 10000,
22
+ });
23
+ } catch (err) {
24
+ if (err.code === "ECONNREFUSED" && err.syscall === "querySrv") {
25
+ throw new Error(
26
+ "Could not resolve your MongoDB Atlas SRV connection string " +
27
+ `(${err.hostname || "the cluster hostname"}). This almost always means your network, router, ` +
28
+ "or ISP is blocking DNS SRV record lookups (common on home/office Wi-Fi) — it is not a code " +
29
+ "or credentials problem. Fix: in Atlas, go to your cluster → Connect → Drivers, and use the " +
30
+ "'standard connection string' (starts with mongodb:// and lists three hosts) instead of the " +
31
+ "mongodb+srv:// one, then update MONGODB_URI in your .env. See server/README.md for details."
32
+ );
33
+ }
34
+ throw err;
35
+ }
36
+
37
+ return mongoose.connection;
38
+ }
39
+
40
+ async function disconnectDB() {
41
+ await mongoose.disconnect();
42
+ }
43
+
44
+ module.exports = { connectDB, disconnectDB };
src/config/env.js ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ require("dotenv").config();
2
+
3
+ // Fail fast if critical secrets are missing or left as placeholders — this
4
+ // is a cheap, high-value guard against accidentally deploying with
5
+ // insecure defaults.
6
+ const REQUIRED_IN_PRODUCTION = [
7
+ "MONGODB_URI",
8
+ "JWT_ACCESS_SECRET",
9
+ "JWT_REFRESH_SECRET",
10
+ "FIELD_ENCRYPTION_KEY",
11
+ "COOKIE_SECRET",
12
+ "SMTP_HOST",
13
+ "SMTP_USER",
14
+ "SMTP_PASS",
15
+ "CLOUDINARY_CLOUD_NAME",
16
+ "CLOUDINARY_API_KEY",
17
+ "CLOUDINARY_API_SECRET",
18
+ ];
19
+
20
+ const env = {
21
+ nodeEnv: process.env.NODE_ENV || "development",
22
+ port: parseInt(process.env.PORT, 10) || 5000,
23
+ clientOrigins: (process.env.CLIENT_ORIGINS || "http://localhost:5173")
24
+ .split(",")
25
+ .map((s) => s.trim())
26
+ .filter(Boolean),
27
+
28
+ mongoUri: process.env.MONGODB_URI,
29
+
30
+ jwtAccessSecret: process.env.JWT_ACCESS_SECRET,
31
+ jwtRefreshSecret: process.env.JWT_REFRESH_SECRET,
32
+ jwtAccessExpiresIn: process.env.JWT_ACCESS_EXPIRES_IN || "15m",
33
+ jwtRefreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || "7d",
34
+
35
+ fieldEncryptionKey: process.env.FIELD_ENCRYPTION_KEY,
36
+ cookieSecret: process.env.COOKIE_SECRET,
37
+ cookieSecure: process.env.COOKIE_SECURE === "true",
38
+
39
+ smtp: {
40
+ host: process.env.SMTP_HOST,
41
+ port: parseInt(process.env.SMTP_PORT, 10) || 587,
42
+ secure: process.env.SMTP_SECURE === "true",
43
+ user: process.env.SMTP_USER,
44
+ pass: process.env.SMTP_PASS,
45
+ },
46
+ emailFrom: process.env.EMAIL_FROM || "Aynwerd Academy <no-reply@aynwerd.com>",
47
+ adminNotifyEmail: process.env.ADMIN_NOTIFY_EMAIL,
48
+
49
+ bank: {
50
+ name: process.env.BANK_NAME || "",
51
+ accountName: process.env.BANK_ACCOUNT_NAME || "",
52
+ accountNumber: process.env.BANK_ACCOUNT_NUMBER || "",
53
+ routingNumber: process.env.BANK_ROUTING_NUMBER || "",
54
+ swiftBic: process.env.BANK_SWIFT_BIC || "",
55
+ iban: process.env.BANK_IBAN || "",
56
+ address: process.env.BANK_ADDRESS || "",
57
+ },
58
+ payoneer: {
59
+ paymentUrl: process.env.PAYONEER_PAYMENT_URL || "https://www.payoneer.com/",
60
+ accountEmail: process.env.PAYONEER_ACCOUNT_EMAIL || "",
61
+ },
62
+
63
+ seedAdmin: {
64
+ name: process.env.SEED_ADMIN_NAME || "Site Admin",
65
+ email: process.env.SEED_ADMIN_EMAIL,
66
+ password: process.env.SEED_ADMIN_PASSWORD,
67
+ },
68
+
69
+ // Cloudinary: free-tier media storage used exclusively for admin-uploaded
70
+ // course videos/documents. Files are uploaded with delivery type
71
+ // "authenticated" so nothing is reachable without a freshly signed,
72
+ // short-lived URL minted server-side for a verified, paying learner.
73
+ cloudinary: {
74
+ cloudName: process.env.CLOUDINARY_CLOUD_NAME,
75
+ apiKey: process.env.CLOUDINARY_API_KEY,
76
+ apiSecret: process.env.CLOUDINARY_API_SECRET,
77
+ },
78
+ };
79
+
80
+ function assertProductionSafety() {
81
+ if (env.nodeEnv !== "production") return;
82
+
83
+ const missing = REQUIRED_IN_PRODUCTION.filter((key) => {
84
+ const value = process.env[key];
85
+ return !value || value.startsWith("replace_with_") || value.includes("<");
86
+ });
87
+
88
+ if (missing.length > 0) {
89
+ // eslint-disable-next-line no-console
90
+ console.error(
91
+ `FATAL: refusing to start in production with missing/placeholder env vars: ${missing.join(", ")}`
92
+ );
93
+ process.exit(1);
94
+ }
95
+
96
+ if (Buffer.from(env.fieldEncryptionKey || "", "hex").length !== 32) {
97
+ // eslint-disable-next-line no-console
98
+ console.error("FATAL: FIELD_ENCRYPTION_KEY must be a 32-byte (64 hex character) value.");
99
+ process.exit(1);
100
+ }
101
+ }
102
+
103
+ module.exports = { env, assertProductionSafety };
src/controllers/admin.controller.js ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const mongoose = require("mongoose");
2
+ const Order = require("../models/Order");
3
+ const User = require("../models/User");
4
+ const Course = require("../models/Course");
5
+ const AuditLog = require("../models/AuditLog");
6
+ const { sendPaymentConfirmedEmail, sendPaymentRejectedEmail } = require("../utils/email");
7
+ const logger = require("../utils/logger");
8
+
9
+ async function listOrders(req, res, next) {
10
+ try {
11
+ const { status } = req.query;
12
+ const filter = {};
13
+ if (status) filter.status = status;
14
+
15
+ const orders = await Order.find(filter)
16
+ .sort({ createdAt: -1 })
17
+ .populate("user", "name email")
18
+ .populate("course", "title");
19
+
20
+ res.json({
21
+ orders: orders.map((o) => ({
22
+ ...o.toPublicJSON(),
23
+ user: o.user ? { id: o.user._id, name: o.user.name, email: o.user.email } : null,
24
+ })),
25
+ });
26
+ } catch (err) {
27
+ next(err);
28
+ }
29
+ }
30
+
31
+ /** Admin views the decrypted payment reference only when actively reviewing a specific order. */
32
+ async function getOrderDetail(req, res, next) {
33
+ try {
34
+ const order = await Order.findById(req.params.id)
35
+ .select("+paymentReferenceEncrypted +payerNoteEncrypted")
36
+ .populate("user", "name email")
37
+ .populate("course", "title price");
38
+ if (!order) return res.status(404).json({ error: "Order not found." });
39
+
40
+ await AuditLog.create({
41
+ actor: req.user._id,
42
+ actorEmail: req.user.email,
43
+ action: "admin.order_viewed",
44
+ targetType: "Order",
45
+ targetId: order._id,
46
+ ip: req.ip,
47
+ userAgent: req.headers["user-agent"],
48
+ });
49
+
50
+ res.json({
51
+ order: {
52
+ ...order.toPublicJSON({ includeSensitive: true }),
53
+ user: order.user ? { id: order.user._id, name: order.user.name, email: order.user.email } : null,
54
+ },
55
+ });
56
+ } catch (err) {
57
+ next(err);
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Confirms a payment: marks the order confirmed, grants course access to
63
+ * the user, and emails the customer. Wrapped in a transaction so a
64
+ * partial failure can't leave the order confirmed without access
65
+ * granted (or vice versa).
66
+ */
67
+ async function confirmOrder(req, res, next) {
68
+ const session = await mongoose.startSession();
69
+ try {
70
+ let resultOrder;
71
+ let resultUser;
72
+ let course;
73
+
74
+ await session.withTransaction(async () => {
75
+ const order = await Order.findById(req.params.id).session(session);
76
+ if (!order) throw Object.assign(new Error("Order not found."), { status: 404 });
77
+ if (order.status === "confirmed") throw Object.assign(new Error("Order already confirmed."), { status: 409 });
78
+ if (!["awaiting_confirmation", "pending_payment"].includes(order.status)) {
79
+ throw Object.assign(new Error("Order cannot be confirmed from its current status."), { status: 409 });
80
+ }
81
+
82
+ order.status = "confirmed";
83
+ order.confirmedBy = req.user._id;
84
+ order.confirmedAt = new Date();
85
+ await order.save({ session });
86
+
87
+ const user = await User.findById(order.user).session(session);
88
+ if (!user) throw Object.assign(new Error("Associated user not found."), { status: 404 });
89
+
90
+ const alreadyEnrolled = user.enrolledCourses.some((ec) => ec.course.toString() === order.course.toString());
91
+ if (!alreadyEnrolled) {
92
+ user.enrolledCourses.push({ course: order.course, order: order._id, accessGrantedAt: new Date() });
93
+ await user.save({ session });
94
+ }
95
+
96
+ course = await Course.findById(order.course).session(session);
97
+ resultOrder = order;
98
+ resultUser = user;
99
+ });
100
+
101
+ await AuditLog.create({
102
+ actor: req.user._id,
103
+ actorEmail: req.user.email,
104
+ action: "admin.order_confirmed",
105
+ targetType: "Order",
106
+ targetId: resultOrder._id,
107
+ ip: req.ip,
108
+ userAgent: req.headers["user-agent"],
109
+ metadata: { userId: resultUser._id.toString() },
110
+ });
111
+
112
+ sendPaymentConfirmedEmail({
113
+ to: resultUser.email,
114
+ name: resultUser.name,
115
+ order: resultOrder,
116
+ courseTitle: course ? course.title : resultOrder.courseTitleSnapshot,
117
+ }).catch((err) => logger.error("Payment-confirmed email failed", { error: err.message }));
118
+
119
+ res.json({ order: resultOrder.toPublicJSON() });
120
+ } catch (err) {
121
+ next(err);
122
+ } finally {
123
+ session.endSession();
124
+ }
125
+ }
126
+
127
+ async function rejectOrder(req, res, next) {
128
+ try {
129
+ const { reason } = req.body;
130
+ const order = await Order.findById(req.params.id).populate("user", "name email");
131
+ if (!order) return res.status(404).json({ error: "Order not found." });
132
+ if (order.status === "confirmed") {
133
+ return res.status(409).json({ error: "A confirmed order cannot be rejected. Consider revoking access separately." });
134
+ }
135
+
136
+ order.status = "rejected";
137
+ order.rejectionReason = reason || null;
138
+ await order.save();
139
+
140
+ await AuditLog.create({
141
+ actor: req.user._id,
142
+ actorEmail: req.user.email,
143
+ action: "admin.order_rejected",
144
+ targetType: "Order",
145
+ targetId: order._id,
146
+ ip: req.ip,
147
+ userAgent: req.headers["user-agent"],
148
+ metadata: { reason: reason || null },
149
+ });
150
+
151
+ if (order.user) {
152
+ sendPaymentRejectedEmail({
153
+ to: order.user.email,
154
+ name: order.user.name,
155
+ order,
156
+ courseTitle: order.courseTitleSnapshot,
157
+ reason,
158
+ }).catch((err) => logger.error("Payment-rejected email failed", { error: err.message }));
159
+ }
160
+
161
+ res.json({ order: order.toPublicJSON() });
162
+ } catch (err) {
163
+ next(err);
164
+ }
165
+ }
166
+
167
+ async function dashboardStats(req, res, next) {
168
+ try {
169
+ const [pendingCount, confirmedCount, rejectedCount, totalUsers, revenueAgg] = await Promise.all([
170
+ Order.countDocuments({ status: "awaiting_confirmation" }),
171
+ Order.countDocuments({ status: "confirmed" }),
172
+ Order.countDocuments({ status: "rejected" }),
173
+ User.countDocuments({ role: "user" }),
174
+ Order.aggregate([{ $match: { status: "confirmed" } }, { $group: { _id: "$currency", total: { $sum: "$amount" } } }]),
175
+ ]);
176
+
177
+ res.json({
178
+ pendingOrders: pendingCount,
179
+ confirmedOrders: confirmedCount,
180
+ rejectedOrders: rejectedCount,
181
+ totalUsers,
182
+ revenueByCurrency: revenueAgg.map((r) => ({ currency: r._id, total: r.total })),
183
+ });
184
+ } catch (err) {
185
+ next(err);
186
+ }
187
+ }
188
+
189
+ async function listUsers(req, res, next) {
190
+ try {
191
+ const users = await User.find({}).sort({ createdAt: -1 }).limit(500);
192
+ res.json({ users: users.map((u) => u.toSafeJSON()) });
193
+ } catch (err) {
194
+ next(err);
195
+ }
196
+ }
197
+
198
+ module.exports = { listOrders, getOrderDetail, confirmOrder, rejectOrder, dashboardStats, listUsers };
src/controllers/auth.controller.js ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const User = require("../models/User");
2
+ const AuditLog = require("../models/AuditLog");
3
+ const { signAccessToken, signRefreshToken, verifyRefreshToken } = require("../utils/jwt");
4
+ const { env } = require("../config/env");
5
+ const logger = require("../utils/logger");
6
+
7
+ const MAX_FAILED_ATTEMPTS = 5;
8
+ const LOCK_DURATION_MS = 15 * 60 * 1000; // 15 minutes
9
+ const REFRESH_COOKIE_NAME = "aynwerd_rt";
10
+
11
+ function refreshCookieOptions() {
12
+ return {
13
+ httpOnly: true,
14
+ secure: env.cookieSecure,
15
+ sameSite: "lax",
16
+ path: "/api/auth",
17
+ maxAge: 7 * 24 * 60 * 60 * 1000,
18
+ };
19
+ }
20
+
21
+ async function audit(action, req, extra = {}) {
22
+ try {
23
+ await AuditLog.create({
24
+ actor: req.user ? req.user._id : null,
25
+ actorEmail: req.user ? req.user.email : extra.email || null,
26
+ action,
27
+ ip: req.ip,
28
+ userAgent: req.headers["user-agent"],
29
+ metadata: extra.metadata || {},
30
+ });
31
+ } catch (err) {
32
+ logger.error("Failed to write audit log", { action, error: err.message });
33
+ }
34
+ }
35
+
36
+ async function signup(req, res, next) {
37
+ try {
38
+ const { name, email, password } = req.body;
39
+
40
+ const existing = await User.findOne({ email: email.toLowerCase() });
41
+ if (existing) {
42
+ // Do not reveal which part of the credential pair is wrong to
43
+ // avoid account enumeration.
44
+ return res.status(409).json({ error: "Unable to create account with the details provided." });
45
+ }
46
+
47
+ const user = new User({ name, email: email.toLowerCase() });
48
+ await user.setPassword(password);
49
+ await user.save();
50
+
51
+ await audit("user.signup", req, { email: user.email });
52
+
53
+ const accessToken = signAccessToken(user);
54
+ const { token: refreshToken, jti } = signRefreshToken(user);
55
+ user.activeRefreshTokens = user.activeRefreshTokens || [];
56
+ user.activeRefreshTokens.push({
57
+ jti,
58
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
59
+ userAgent: req.headers["user-agent"],
60
+ ip: req.ip,
61
+ });
62
+ await user.save();
63
+
64
+ res.cookie(REFRESH_COOKIE_NAME, refreshToken, refreshCookieOptions());
65
+ res.status(201).json({ user: user.toSafeJSON(), accessToken });
66
+ } catch (err) {
67
+ next(err);
68
+ }
69
+ }
70
+
71
+ async function login(req, res, next) {
72
+ try {
73
+ const { email, password } = req.body;
74
+
75
+ const user = await User.findOne({ email: email.toLowerCase() }).select(
76
+ "+passwordHash +failedLoginAttempts +lockUntil +activeRefreshTokens"
77
+ );
78
+
79
+ // Constant-shaped response whether the user exists or not.
80
+ const genericError = () => res.status(401).json({ error: "Invalid email or password." });
81
+
82
+ if (!user || !user.isActive) return genericError();
83
+
84
+ if (user.isLocked()) {
85
+ return res.status(423).json({
86
+ error: "Account temporarily locked due to repeated failed attempts. Try again later.",
87
+ });
88
+ }
89
+
90
+ const valid = await user.comparePassword(password);
91
+ if (!valid) {
92
+ user.failedLoginAttempts = (user.failedLoginAttempts || 0) + 1;
93
+ if (user.failedLoginAttempts >= MAX_FAILED_ATTEMPTS) {
94
+ user.lockUntil = new Date(Date.now() + LOCK_DURATION_MS);
95
+ user.failedLoginAttempts = 0;
96
+ await audit("user.login.locked", req, { email: user.email });
97
+ }
98
+ await user.save();
99
+ return genericError();
100
+ }
101
+
102
+ user.failedLoginAttempts = 0;
103
+ user.lockUntil = null;
104
+
105
+ const accessToken = signAccessToken(user);
106
+ const { token: refreshToken, jti } = signRefreshToken(user);
107
+ user.activeRefreshTokens = (user.activeRefreshTokens || [])
108
+ .filter((rt) => rt.expiresAt > new Date()) // prune expired sessions
109
+ .concat({
110
+ jti,
111
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
112
+ userAgent: req.headers["user-agent"],
113
+ ip: req.ip,
114
+ });
115
+ await user.save();
116
+
117
+ await audit("user.login", req, { email: user.email });
118
+
119
+ res.cookie(REFRESH_COOKIE_NAME, refreshToken, refreshCookieOptions());
120
+ res.json({ user: user.toSafeJSON(), accessToken });
121
+ } catch (err) {
122
+ next(err);
123
+ }
124
+ }
125
+
126
+ async function refresh(req, res, next) {
127
+ try {
128
+ const token = req.cookies[REFRESH_COOKIE_NAME];
129
+ if (!token) return res.status(401).json({ error: "No refresh token provided." });
130
+
131
+ let payload;
132
+ try {
133
+ payload = verifyRefreshToken(token);
134
+ } catch {
135
+ res.clearCookie(REFRESH_COOKIE_NAME, refreshCookieOptions());
136
+ return res.status(401).json({ error: "Invalid or expired session, please log in again." });
137
+ }
138
+
139
+ const user = await User.findById(payload.sub).select("+activeRefreshTokens");
140
+ if (!user || !user.isActive || user.tokenVersion !== payload.tokenVersion) {
141
+ return res.status(401).json({ error: "Session no longer valid, please log in again." });
142
+ }
143
+
144
+ const stillActive = (user.activeRefreshTokens || []).some((rt) => rt.jti === payload.jti);
145
+ if (!stillActive) {
146
+ // Possible token reuse after logout/rotation — deny and clear cookie.
147
+ res.clearCookie(REFRESH_COOKIE_NAME, refreshCookieOptions());
148
+ return res.status(401).json({ error: "Session no longer valid, please log in again." });
149
+ }
150
+
151
+ // Rotate: remove old jti, issue a new refresh token.
152
+ user.activeRefreshTokens = user.activeRefreshTokens.filter((rt) => rt.jti !== payload.jti);
153
+ const accessToken = signAccessToken(user);
154
+ const { token: newRefreshToken, jti: newJti } = signRefreshToken(user);
155
+ user.activeRefreshTokens.push({
156
+ jti: newJti,
157
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
158
+ userAgent: req.headers["user-agent"],
159
+ ip: req.ip,
160
+ });
161
+ await user.save();
162
+
163
+ res.cookie(REFRESH_COOKIE_NAME, newRefreshToken, refreshCookieOptions());
164
+ res.json({ accessToken, user: user.toSafeJSON() });
165
+ } catch (err) {
166
+ next(err);
167
+ }
168
+ }
169
+
170
+ async function logout(req, res, next) {
171
+ try {
172
+ const token = req.cookies[REFRESH_COOKIE_NAME];
173
+ if (token) {
174
+ try {
175
+ const payload = verifyRefreshToken(token);
176
+ await User.findByIdAndUpdate(payload.sub, { $pull: { activeRefreshTokens: { jti: payload.jti } } });
177
+ } catch {
178
+ // token already invalid — nothing to revoke
179
+ }
180
+ }
181
+ res.clearCookie(REFRESH_COOKIE_NAME, refreshCookieOptions());
182
+ res.json({ success: true });
183
+ } catch (err) {
184
+ next(err);
185
+ }
186
+ }
187
+
188
+ async function me(req, res) {
189
+ res.json({ user: req.user.toSafeJSON() });
190
+ }
191
+
192
+ module.exports = { signup, login, refresh, logout, me };
src/controllers/content.controller.js ADDED
@@ -0,0 +1,278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const fs = require("fs/promises");
2
+ const mongoose = require("mongoose");
3
+ const Course = require("../models/Course");
4
+ const AuditLog = require("../models/AuditLog");
5
+ const { uploadFromPath, deleteAsset, getSignedPlaybackUrl, resourceTypeForMimetype } = require("../utils/cloudinaryUpload");
6
+ const logger = require("../utils/logger");
7
+
8
+ function userHasAccess(user, courseId) {
9
+ if (!user) return false;
10
+ if (user.role === "admin") return true;
11
+ return user.hasAccessToCourse(courseId);
12
+ }
13
+
14
+ // ---------------------------------------------------------------------
15
+ // Learner-facing: view curriculum structure, and request a signed,
16
+ // time-limited playback URL for one lesson at a time.
17
+ // ---------------------------------------------------------------------
18
+
19
+ async function getCourseCurriculum(req, res, next) {
20
+ try {
21
+ const course = await Course.findById(req.params.id);
22
+ if (!course || !course.isPublished) return res.status(404).json({ error: "Course not found." });
23
+
24
+ const hasAccess = userHasAccess(req.user, course._id);
25
+ res.json({
26
+ courseId: course._id,
27
+ title: course.title,
28
+ hasAccess,
29
+ curriculum: hasAccess ? course.toCurriculumJSON() : course.toLockedCurriculumJSON(),
30
+ });
31
+ } catch (err) {
32
+ next(err);
33
+ }
34
+ }
35
+
36
+ async function getLessonAccess(req, res, next) {
37
+ try {
38
+ const { id: courseId, sectionId, lessonId } = req.params;
39
+
40
+ const course = await Course.findById(courseId);
41
+ if (!course || !course.isPublished) return res.status(404).json({ error: "Course not found." });
42
+
43
+ const section = course.curriculum.id(sectionId);
44
+ const lesson = section?.lessons.id(lessonId);
45
+ if (!section || !lesson) return res.status(404).json({ error: "Lesson not found." });
46
+
47
+ const hasAccess = userHasAccess(req.user, course._id);
48
+ if (!hasAccess && !lesson.isPreview) {
49
+ return res.status(403).json({ error: "Purchase this course to access this lesson." });
50
+ }
51
+
52
+ const { url, expiresAt } = getSignedPlaybackUrl(lesson.cloudinaryPublicId, lesson.cloudinaryResourceType, {
53
+ format: lesson.format,
54
+ });
55
+
56
+ await AuditLog.create({
57
+ actor: req.user._id,
58
+ actorEmail: req.user.email,
59
+ action: "lesson.access_granted",
60
+ targetType: "Lesson",
61
+ targetId: lesson._id,
62
+ ip: req.ip,
63
+ userAgent: req.headers["user-agent"],
64
+ metadata: { courseId: course._id.toString() },
65
+ });
66
+
67
+ res.json({
68
+ url,
69
+ expiresAt,
70
+ type: lesson.type,
71
+ title: lesson.title,
72
+ });
73
+ } catch (err) {
74
+ next(err);
75
+ }
76
+ }
77
+
78
+ // ---------------------------------------------------------------------
79
+ // Admin-only: manage curriculum structure and upload content to
80
+ // Cloudinary. No learner-facing route ever exposes an upload capability.
81
+ // ---------------------------------------------------------------------
82
+
83
+ async function createSection(req, res, next) {
84
+ try {
85
+ const { title } = req.body;
86
+ const course = await Course.findById(req.params.id);
87
+ if (!course) return res.status(404).json({ error: "Course not found." });
88
+
89
+ const order = course.curriculum.length;
90
+ course.curriculum.push({ title, order, lessons: [] });
91
+ await course.save();
92
+
93
+ res.status(201).json({ curriculum: course.toCurriculumJSON() });
94
+ } catch (err) {
95
+ next(err);
96
+ }
97
+ }
98
+
99
+ async function updateSection(req, res, next) {
100
+ try {
101
+ const { title, order } = req.body;
102
+ const course = await Course.findById(req.params.id);
103
+ if (!course) return res.status(404).json({ error: "Course not found." });
104
+
105
+ const section = course.curriculum.id(req.params.sectionId);
106
+ if (!section) return res.status(404).json({ error: "Section not found." });
107
+
108
+ if (title !== undefined) section.title = title;
109
+ if (order !== undefined) section.order = order;
110
+ await course.save();
111
+
112
+ res.json({ curriculum: course.toCurriculumJSON() });
113
+ } catch (err) {
114
+ next(err);
115
+ }
116
+ }
117
+
118
+ async function deleteSection(req, res, next) {
119
+ try {
120
+ const course = await Course.findById(req.params.id);
121
+ if (!course) return res.status(404).json({ error: "Course not found." });
122
+
123
+ const section = course.curriculum.id(req.params.sectionId);
124
+ if (!section) return res.status(404).json({ error: "Section not found." });
125
+
126
+ // Clean up every Cloudinary asset in this section before removing it.
127
+ await Promise.all(
128
+ section.lessons.map((lesson) =>
129
+ deleteAsset(lesson.cloudinaryPublicId, lesson.cloudinaryResourceType).catch((err) =>
130
+ logger.error("Failed to delete Cloudinary asset", { error: err.message, lessonId: lesson._id.toString() })
131
+ )
132
+ )
133
+ );
134
+
135
+ section.deleteOne();
136
+ await course.save();
137
+
138
+ res.json({ curriculum: course.toCurriculumJSON() });
139
+ } catch (err) {
140
+ next(err);
141
+ }
142
+ }
143
+
144
+ async function uploadLesson(req, res, next) {
145
+ const tempFilePath = req.file?.path;
146
+ try {
147
+ const { title, description, isPreview, order } = req.body;
148
+ if (!req.file) return res.status(400).json({ error: "A file is required." });
149
+
150
+ const course = await Course.findById(req.params.id);
151
+ if (!course) return res.status(404).json({ error: "Course not found." });
152
+
153
+ const section = course.curriculum.id(req.params.sectionId);
154
+ if (!section) return res.status(404).json({ error: "Section not found." });
155
+
156
+ const resourceType = resourceTypeForMimetype(req.file.mimetype);
157
+ const lessonType = resourceType === "video" ? "video" : "document";
158
+
159
+ const result = await uploadFromPath(tempFilePath, {
160
+ resourceType,
161
+ filenameHint: title,
162
+ });
163
+
164
+ section.lessons.push({
165
+ title,
166
+ description: description || "",
167
+ type: lessonType,
168
+ order: order !== undefined ? Number(order) : section.lessons.length,
169
+ isPreview: isPreview === "true" || isPreview === true,
170
+ cloudinaryPublicId: result.public_id,
171
+ cloudinaryResourceType: result.resource_type,
172
+ format: result.format || "",
173
+ bytes: result.bytes || 0,
174
+ durationSeconds: result.duration ? Math.round(result.duration) : 0,
175
+ });
176
+ await course.save();
177
+
178
+ await AuditLog.create({
179
+ actor: req.user._id,
180
+ actorEmail: req.user.email,
181
+ action: "admin.lesson_uploaded",
182
+ targetType: "Course",
183
+ targetId: course._id,
184
+ ip: req.ip,
185
+ userAgent: req.headers["user-agent"],
186
+ metadata: { sectionId: section._id.toString(), title },
187
+ });
188
+
189
+ res.status(201).json({ curriculum: course.toCurriculumJSON() });
190
+ } catch (err) {
191
+ next(err);
192
+ } finally {
193
+ // Always clean up the local temp file, whether the upload succeeded,
194
+ // failed partway, or Cloudinary itself errored out.
195
+ if (tempFilePath) {
196
+ fs.unlink(tempFilePath).catch((err) =>
197
+ logger.error("Failed to remove temp upload file", { error: err.message, tempFilePath })
198
+ );
199
+ }
200
+ }
201
+ }
202
+
203
+ async function updateLesson(req, res, next) {
204
+ try {
205
+ const { title, description, isPreview, order } = req.body;
206
+ const course = await Course.findById(req.params.id);
207
+ if (!course) return res.status(404).json({ error: "Course not found." });
208
+
209
+ const section = course.curriculum.id(req.params.sectionId);
210
+ const lesson = section?.lessons.id(req.params.lessonId);
211
+ if (!section || !lesson) return res.status(404).json({ error: "Lesson not found." });
212
+
213
+ if (title !== undefined) lesson.title = title;
214
+ if (description !== undefined) lesson.description = description;
215
+ if (isPreview !== undefined) lesson.isPreview = isPreview === "true" || isPreview === true;
216
+ if (order !== undefined) lesson.order = Number(order);
217
+ await course.save();
218
+
219
+ res.json({ curriculum: course.toCurriculumJSON() });
220
+ } catch (err) {
221
+ next(err);
222
+ }
223
+ }
224
+
225
+ async function deleteLesson(req, res, next) {
226
+ try {
227
+ const course = await Course.findById(req.params.id);
228
+ if (!course) return res.status(404).json({ error: "Course not found." });
229
+
230
+ const section = course.curriculum.id(req.params.sectionId);
231
+ const lesson = section?.lessons.id(req.params.lessonId);
232
+ if (!section || !lesson) return res.status(404).json({ error: "Lesson not found." });
233
+
234
+ await deleteAsset(lesson.cloudinaryPublicId, lesson.cloudinaryResourceType).catch((err) =>
235
+ logger.error("Failed to delete Cloudinary asset", { error: err.message })
236
+ );
237
+
238
+ lesson.deleteOne();
239
+ await course.save();
240
+
241
+ await AuditLog.create({
242
+ actor: req.user._id,
243
+ actorEmail: req.user.email,
244
+ action: "admin.lesson_deleted",
245
+ targetType: "Course",
246
+ targetId: course._id,
247
+ ip: req.ip,
248
+ userAgent: req.headers["user-agent"],
249
+ });
250
+
251
+ res.json({ curriculum: course.toCurriculumJSON() });
252
+ } catch (err) {
253
+ next(err);
254
+ }
255
+ }
256
+
257
+ /** 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). */
258
+ async function getCourseCurriculumForAdmin(req, res, next) {
259
+ try {
260
+ const course = await Course.findById(req.params.id);
261
+ if (!course) return res.status(404).json({ error: "Course not found." });
262
+ res.json({ courseId: course._id, title: course.title, curriculum: course.toCurriculumJSON() });
263
+ } catch (err) {
264
+ next(err);
265
+ }
266
+ }
267
+
268
+ module.exports = {
269
+ getCourseCurriculum,
270
+ getLessonAccess,
271
+ getCourseCurriculumForAdmin,
272
+ createSection,
273
+ updateSection,
274
+ deleteSection,
275
+ uploadLesson,
276
+ updateLesson,
277
+ deleteLesson,
278
+ };
src/controllers/course.controller.js ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const Course = require("../models/Course");
2
+
3
+ async function listCourses(req, res, next) {
4
+ try {
5
+ const filter = req.user?.role === "admin" ? {} : { isPublished: true };
6
+ const courses = await Course.find(filter).sort({ createdAt: 1 });
7
+ res.json({ courses });
8
+ } catch (err) {
9
+ next(err);
10
+ }
11
+ }
12
+
13
+ async function getCourse(req, res, next) {
14
+ try {
15
+ const course = await Course.findById(req.params.id);
16
+ if (!course) return res.status(404).json({ error: "Course not found." });
17
+ res.json({ course });
18
+ } catch (err) {
19
+ next(err);
20
+ }
21
+ }
22
+
23
+ async function createCourse(req, res, next) {
24
+ try {
25
+ const course = await Course.create(req.body);
26
+ res.status(201).json({ course });
27
+ } catch (err) {
28
+ if (err.code === 11000) return res.status(409).json({ error: "A course with this slug already exists." });
29
+ next(err);
30
+ }
31
+ }
32
+
33
+ async function updateCourse(req, res, next) {
34
+ try {
35
+ const course = await Course.findByIdAndUpdate(req.params.id, req.body, {
36
+ new: true,
37
+ runValidators: true,
38
+ });
39
+ if (!course) return res.status(404).json({ error: "Course not found." });
40
+ res.json({ course });
41
+ } catch (err) {
42
+ next(err);
43
+ }
44
+ }
45
+
46
+ async function deleteCourse(req, res, next) {
47
+ try {
48
+ const course = await Course.findByIdAndUpdate(req.params.id, { isPublished: false }, { new: true });
49
+ if (!course) return res.status(404).json({ error: "Course not found." });
50
+ res.json({ success: true });
51
+ } catch (err) {
52
+ next(err);
53
+ }
54
+ }
55
+
56
+ module.exports = { listCourses, getCourse, createCourse, updateCourse, deleteCourse };
src/controllers/order.controller.js ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const mongoose = require("mongoose");
2
+ const Order = require("../models/Order");
3
+ const Course = require("../models/Course");
4
+ const AuditLog = require("../models/AuditLog");
5
+ const { generateOrderNumber } = require("../utils/orderNumber");
6
+ const { env } = require("../config/env");
7
+ const { sendOrderPlacedEmail, sendAdminNewOrderAlert } = require("../utils/email");
8
+ const logger = require("../utils/logger");
9
+
10
+ /**
11
+ * Public checkout configuration — payment instructions shown on the
12
+ * frontend before the customer submits their reference number. No
13
+ * secrets here, just receiving-account details that are meant to be
14
+ * shared with paying customers.
15
+ */
16
+ function getPaymentOptions(req, res) {
17
+ res.json({
18
+ bankWire: {
19
+ currency: "USD",
20
+ bankName: env.bank.name,
21
+ accountName: env.bank.accountName,
22
+ accountNumber: env.bank.accountNumber,
23
+ routingNumber: env.bank.routingNumber,
24
+ swiftBic: env.bank.swiftBic,
25
+ iban: env.bank.iban || undefined,
26
+ address: env.bank.address,
27
+ },
28
+ payoneer: {
29
+ accountEmail: env.payoneer.accountEmail,
30
+ paymentUrl: env.payoneer.paymentUrl,
31
+ },
32
+ });
33
+ }
34
+
35
+ async function createOrder(req, res, next) {
36
+ try {
37
+ const { courseId, paymentMethod } = req.body;
38
+
39
+ if (!mongoose.isValidObjectId(courseId)) {
40
+ return res.status(400).json({ error: "Invalid course selected." });
41
+ }
42
+
43
+ const course = await Course.findById(courseId);
44
+ if (!course || !course.isPublished) {
45
+ return res.status(404).json({ error: "Course not found or unavailable." });
46
+ }
47
+
48
+ if (req.user.hasAccessToCourse(course._id)) {
49
+ return res.status(409).json({ error: "You already have access to this course." });
50
+ }
51
+
52
+ // Prevent duplicate open orders for the same course by the same user.
53
+ const existingOpenOrder = await Order.findOne({
54
+ user: req.user._id,
55
+ course: course._id,
56
+ status: { $in: ["pending_payment", "awaiting_confirmation"] },
57
+ });
58
+ if (existingOpenOrder) {
59
+ return res.status(200).json({ order: existingOpenOrder.toPublicJSON(), reused: true });
60
+ }
61
+
62
+ const order = new Order({
63
+ orderNumber: generateOrderNumber(),
64
+ user: req.user._id,
65
+ course: course._id,
66
+ courseTitleSnapshot: course.title,
67
+ amount: course.price,
68
+ currency: course.currency || "USD",
69
+ paymentMethod,
70
+ status: "awaiting_confirmation",
71
+ });
72
+ await order.save();
73
+
74
+ await AuditLog.create({
75
+ actor: req.user._id,
76
+ actorEmail: req.user.email,
77
+ action: "order.created",
78
+ targetType: "Order",
79
+ targetId: order._id,
80
+ ip: req.ip,
81
+ userAgent: req.headers["user-agent"],
82
+ metadata: { courseId: course._id.toString(), paymentMethod },
83
+ });
84
+
85
+ // Fire-and-forget emails; failures are logged but never block checkout.
86
+ sendOrderPlacedEmail({
87
+ to: req.user.email,
88
+ name: req.user.name,
89
+ order,
90
+ courseTitle: course.title,
91
+ bankDetails: env.bank,
92
+ payoneerDetails: env.payoneer,
93
+ })
94
+ .then(() => {
95
+ order.confirmationEmailSentAt = new Date();
96
+ return order.save();
97
+ })
98
+ .catch((err) => logger.error("Order confirmation email failed", { error: err.message }));
99
+
100
+ sendAdminNewOrderAlert({ order, courseTitle: course.title, customerEmail: req.user.email }).catch((err) =>
101
+ logger.error("Admin new-order alert failed", { error: err.message })
102
+ );
103
+
104
+ res.status(201).json({ order: order.toPublicJSON() });
105
+ } catch (err) {
106
+ next(err);
107
+ }
108
+ }
109
+
110
+ /** Customer submits their bank wire reference / Payoneer transaction ID after paying. */
111
+ async function submitPaymentProof(req, res, next) {
112
+ try {
113
+ const { reference, note } = req.body;
114
+ const order = await Order.findOne({ _id: req.params.id, user: req.user._id });
115
+ if (!order) return res.status(404).json({ error: "Order not found." });
116
+
117
+ if (!["pending_payment", "awaiting_confirmation"].includes(order.status)) {
118
+ return res.status(409).json({ error: "This order can no longer be updated." });
119
+ }
120
+
121
+ order.setPaymentReference(reference);
122
+ if (note) order.setPayerNote(note);
123
+ order.status = "awaiting_confirmation";
124
+ await order.save();
125
+
126
+ await AuditLog.create({
127
+ actor: req.user._id,
128
+ actorEmail: req.user.email,
129
+ action: "order.payment_proof_submitted",
130
+ targetType: "Order",
131
+ targetId: order._id,
132
+ ip: req.ip,
133
+ userAgent: req.headers["user-agent"],
134
+ });
135
+
136
+ res.json({ order: order.toPublicJSON() });
137
+ } catch (err) {
138
+ next(err);
139
+ }
140
+ }
141
+
142
+ async function listMyOrders(req, res, next) {
143
+ try {
144
+ const orders = await Order.find({ user: req.user._id }).sort({ createdAt: -1 });
145
+ res.json({ orders: orders.map((o) => o.toPublicJSON()) });
146
+ } catch (err) {
147
+ next(err);
148
+ }
149
+ }
150
+
151
+ async function getMyOrder(req, res, next) {
152
+ try {
153
+ const order = await Order.findOne({ _id: req.params.id, user: req.user._id });
154
+ if (!order) return res.status(404).json({ error: "Order not found." });
155
+ res.json({ order: order.toPublicJSON({ includeSensitive: true }) });
156
+ } catch (err) {
157
+ next(err);
158
+ }
159
+ }
160
+
161
+ module.exports = { getPaymentOptions, createOrder, submitPaymentProof, listMyOrders, getMyOrder };
src/middleware/auth.js ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const { verifyAccessToken } = require("../utils/jwt");
2
+ const User = require("../models/User");
3
+
4
+ /**
5
+ * Requires a valid, non-expired access token in the Authorization header.
6
+ * Attaches the full (fresh) user document to req.user so downstream
7
+ * handlers always see current role/lock/tokenVersion state rather than
8
+ * trusting stale claims baked into the JWT.
9
+ */
10
+ async function requireAuth(req, res, next) {
11
+ try {
12
+ const header = req.headers.authorization || "";
13
+ const [scheme, token] = header.split(" ");
14
+ if (scheme !== "Bearer" || !token) {
15
+ return res.status(401).json({ error: "Authentication required." });
16
+ }
17
+
18
+ const payload = verifyAccessToken(token);
19
+ const user = await User.findById(payload.sub);
20
+
21
+ if (!user || !user.isActive) {
22
+ return res.status(401).json({ error: "Account not found or disabled." });
23
+ }
24
+ if (user.tokenVersion !== payload.tokenVersion) {
25
+ // Token was issued before a password change / forced logout.
26
+ return res.status(401).json({ error: "Session expired, please log in again." });
27
+ }
28
+
29
+ req.user = user;
30
+ next();
31
+ } catch (err) {
32
+ return res.status(401).json({ error: "Invalid or expired token." });
33
+ }
34
+ }
35
+
36
+ function requireRole(...roles) {
37
+ return (req, res, next) => {
38
+ if (!req.user || !roles.includes(req.user.role)) {
39
+ return res.status(403).json({ error: "You do not have permission to perform this action." });
40
+ }
41
+ next();
42
+ };
43
+ }
44
+
45
+ module.exports = { requireAuth, requireRole };
src/middleware/errorHandler.js ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const logger = require("../utils/logger");
2
+
3
+ // eslint-disable-next-line no-unused-vars
4
+ function errorHandler(err, req, res, next) {
5
+ let status = err.status || 500;
6
+
7
+ // Multer surfaces file-size/type problems as plain Errors/MulterErrors
8
+ // without a status code — treat those as client errors (400), not
9
+ // server failures.
10
+ if (err.name === "MulterError" || /Unsupported file type/.test(err.message || "")) {
11
+ status = 400;
12
+ }
13
+
14
+ logger.error("Unhandled error", {
15
+ message: err.message,
16
+ status,
17
+ path: req.path,
18
+ method: req.method,
19
+ // stack only ever goes to server-side logs, never to the client
20
+ stack: err.stack,
21
+ });
22
+
23
+ const isProd = process.env.NODE_ENV === "production";
24
+ res.status(status).json({
25
+ error: status === 500 && isProd ? "Something went wrong. Please try again later." : err.message,
26
+ });
27
+ }
28
+
29
+ function notFound(req, res) {
30
+ res.status(404).json({ error: "Not found." });
31
+ }
32
+
33
+ module.exports = { errorHandler, notFound };
src/middleware/rateLimiters.js ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const rateLimit = require("express-rate-limit");
2
+
3
+ // General API traffic
4
+ const apiLimiter = rateLimit({
5
+ windowMs: 15 * 60 * 1000,
6
+ limit: 300,
7
+ standardHeaders: true,
8
+ legacyHeaders: false,
9
+ message: { error: "Too many requests, please try again later." },
10
+ });
11
+
12
+ // Tight limits on auth endpoints to blunt credential stuffing / brute force
13
+ const authLimiter = rateLimit({
14
+ windowMs: 15 * 60 * 1000,
15
+ limit: 10,
16
+ standardHeaders: true,
17
+ legacyHeaders: false,
18
+ skipSuccessfulRequests: true,
19
+ message: { error: "Too many attempts. Please wait a few minutes and try again." },
20
+ });
21
+
22
+ // Checkout/order creation limiter to prevent order-spam / enumeration
23
+ const orderLimiter = rateLimit({
24
+ windowMs: 60 * 60 * 1000,
25
+ limit: 20,
26
+ standardHeaders: true,
27
+ legacyHeaders: false,
28
+ message: { error: "Too many order attempts. Please try again later or contact support." },
29
+ });
30
+
31
+ module.exports = { apiLimiter, authLimiter, orderLimiter };
src/middleware/upload.js ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const multer = require("multer");
2
+ const os = require("os");
3
+ const path = require("path");
4
+ const crypto = require("crypto");
5
+
6
+ // Only the file types actually needed for course content are allowed.
7
+ // Uploads are only ever accepted from an authenticated admin (enforced
8
+ // in the route, not here), but validating the mimetype/extension server-
9
+ // side is still essential since a client can lie about content-type.
10
+ const ALLOWED_MIMETYPES = new Set([
11
+ "video/mp4",
12
+ "video/webm",
13
+ "video/quicktime",
14
+ "application/pdf",
15
+ "application/msword",
16
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
17
+ "application/vnd.ms-powerpoint",
18
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
19
+ "image/png",
20
+ "image/jpeg",
21
+ "image/webp",
22
+ ]);
23
+
24
+ const MAX_FILE_SIZE_BYTES = 500 * 1024 * 1024; // 500MB — generous for course videos on Cloudinary's free tier
25
+
26
+ // Files are streamed to a temp file on disk rather than buffered in
27
+ // memory: buffering large videos in RAM can exhaust the memory limit on
28
+ // small/free-tier hosts (e.g. a 512MB instance) well before hitting the
29
+ // 500MB file-size cap. The temp file is deleted right after the upload
30
+ // completes (see content.controller.js), whether it succeeds or fails.
31
+ const storage = multer.diskStorage({
32
+ destination: (req, file, cb) => cb(null, os.tmpdir()),
33
+ filename: (req, file, cb) => {
34
+ const unique = crypto.randomBytes(16).toString("hex");
35
+ cb(null, `aynwerd-upload-${unique}${path.extname(file.originalname)}`);
36
+ },
37
+ });
38
+
39
+ const upload = multer({
40
+ storage,
41
+ limits: { fileSize: MAX_FILE_SIZE_BYTES, files: 1 },
42
+ fileFilter(req, file, cb) {
43
+ if (!ALLOWED_MIMETYPES.has(file.mimetype)) {
44
+ return cb(new Error("Unsupported file type. Allowed: mp4/webm/mov video, pdf, doc(x), ppt(x), png/jpg/webp."));
45
+ }
46
+ cb(null, true);
47
+ },
48
+ });
49
+
50
+ module.exports = { upload };
src/middleware/validate.js ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const { validationResult } = require("express-validator");
2
+
3
+ function validate(req, res, next) {
4
+ const errors = validationResult(req);
5
+ if (!errors.isEmpty()) {
6
+ return res.status(400).json({
7
+ error: "Validation failed.",
8
+ details: errors.array().map((e) => ({ field: e.path, message: e.msg })),
9
+ });
10
+ }
11
+ next();
12
+ }
13
+
14
+ module.exports = { validate };
src/models/AuditLog.js ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const mongoose = require("mongoose");
2
+
3
+ // Immutable-by-convention trail of security-sensitive actions (payment
4
+ // confirmations/rejections, role changes, logins) for accountability and
5
+ // incident investigation. Nothing sensitive (passwords, tokens, full
6
+ // payment references) is ever written here.
7
+ const auditLogSchema = new mongoose.Schema(
8
+ {
9
+ actor: { type: mongoose.Schema.Types.ObjectId, ref: "User", default: null },
10
+ actorEmail: { type: String, default: null },
11
+ action: { type: String, required: true, index: true },
12
+ targetType: { type: String, default: null },
13
+ targetId: { type: mongoose.Schema.Types.ObjectId, default: null },
14
+ metadata: { type: mongoose.Schema.Types.Mixed, default: {} },
15
+ ip: { type: String, default: null },
16
+ userAgent: { type: String, default: null },
17
+ },
18
+ { timestamps: true, capped: { size: 50 * 1024 * 1024, max: 200000 } }
19
+ );
20
+
21
+ module.exports = mongoose.model("AuditLog", auditLogSchema);
src/models/Course.js ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const mongoose = require("mongoose");
2
+
3
+ const lessonSchema = new mongoose.Schema(
4
+ {
5
+ title: { type: String, required: true, trim: true, maxlength: 200 },
6
+ description: { type: String, default: "", maxlength: 1000 },
7
+ type: { type: String, enum: ["video", "document"], required: true },
8
+ order: { type: Number, default: 0 },
9
+
10
+ // Free preview lessons can be viewed without purchasing the course
11
+ // (e.g. an intro video) — everything else requires confirmed access.
12
+ isPreview: { type: Boolean, default: false },
13
+
14
+ // Cloudinary storage details. Uploaded with delivery type
15
+ // "authenticated" so files can NEVER be fetched by guessing/sharing
16
+ // the public ID — every playback URL must be freshly signed
17
+ // server-side (see utils/cloudinaryUpload.js) for a user who is
18
+ // confirmed to have access. These are plain (selected-by-default)
19
+ // fields — deliberately NOT using Mongoose's `select: false` here,
20
+ // because that projection option is unreliable two levels deep
21
+ // inside an array-of-subdocuments-within-an-array-of-subdocuments
22
+ // (curriculum[].lessons[].field) across Mongoose versions. The real
23
+ // security boundary is that every response is built through
24
+ // toCurriculumJSON()/toLockedCurriculumJSON() below, which simply
25
+ // never copies these fields into what gets sent to the client.
26
+ cloudinaryPublicId: { type: String, required: true },
27
+ cloudinaryResourceType: { type: String, enum: ["video", "raw", "image"], required: true },
28
+ format: { type: String, default: "" },
29
+ bytes: { type: Number, default: 0 },
30
+ durationSeconds: { type: Number, default: 0 },
31
+ },
32
+ { timestamps: true }
33
+ );
34
+
35
+ const sectionSchema = new mongoose.Schema(
36
+ {
37
+ title: { type: String, required: true, trim: true, maxlength: 200 },
38
+ order: { type: Number, default: 0 },
39
+ lessons: { type: [lessonSchema], default: [] },
40
+ },
41
+ { timestamps: true }
42
+ );
43
+
44
+ const courseSchema = new mongoose.Schema(
45
+ {
46
+ title: { type: String, required: true, trim: true, maxlength: 200 },
47
+ slug: { type: String, required: true, unique: true, lowercase: true, trim: true, index: true },
48
+ price: { type: Number, required: true, min: 0 },
49
+ currency: { type: String, default: "USD", uppercase: true, minlength: 3, maxlength: 3 },
50
+ duration: { type: String, required: true, trim: true },
51
+ level: { type: String, required: true, trim: true },
52
+ description: { type: String, required: true },
53
+ image: { type: String, default: "" },
54
+ highlights: { type: [String], default: [] },
55
+ isPublished: { type: Boolean, default: true },
56
+
57
+ // Study content, uploaded exclusively by admins (see content.controller.js).
58
+ curriculum: { type: [sectionSchema], default: [] },
59
+ },
60
+ { timestamps: true }
61
+ );
62
+
63
+ /** Curriculum view for enrolled users / admins: structure + metadata, no storage internals. */
64
+ courseSchema.methods.toCurriculumJSON = function toCurriculumJSON() {
65
+ return this.curriculum
66
+ .slice()
67
+ .sort((a, b) => a.order - b.order)
68
+ .map((section) => ({
69
+ id: section._id,
70
+ title: section.title,
71
+ order: section.order,
72
+ lessons: section.lessons
73
+ .slice()
74
+ .sort((a, b) => a.order - b.order)
75
+ .map((lesson) => ({
76
+ id: lesson._id,
77
+ title: lesson.title,
78
+ description: lesson.description,
79
+ type: lesson.type,
80
+ order: lesson.order,
81
+ isPreview: lesson.isPreview,
82
+ durationSeconds: lesson.durationSeconds,
83
+ })),
84
+ }));
85
+ };
86
+
87
+ /** Locked view for logged-in users who have NOT purchased the course: titles only, previews flagged. */
88
+ courseSchema.methods.toLockedCurriculumJSON = function toLockedCurriculumJSON() {
89
+ return this.toCurriculumJSON().map((section) => ({
90
+ ...section,
91
+ lessons: section.lessons.map((lesson) => ({
92
+ id: lesson.id,
93
+ title: lesson.title,
94
+ type: lesson.type,
95
+ order: lesson.order,
96
+ isPreview: lesson.isPreview,
97
+ durationSeconds: lesson.durationSeconds,
98
+ locked: !lesson.isPreview,
99
+ })),
100
+ }));
101
+ };
102
+
103
+ module.exports = mongoose.model("Course", courseSchema);
104
+
src/models/Order.js ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const mongoose = require("mongoose");
2
+ const { encryptField, decryptField, hmacIndex } = require("../utils/crypto");
3
+
4
+ const PAYMENT_METHODS = ["bank_wire", "payoneer"];
5
+ const ORDER_STATUSES = ["pending_payment", "awaiting_confirmation", "confirmed", "rejected", "cancelled"];
6
+
7
+ const orderSchema = new mongoose.Schema(
8
+ {
9
+ orderNumber: { type: String, required: true, unique: true, index: true },
10
+ user: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true, index: true },
11
+ course: { type: mongoose.Schema.Types.ObjectId, ref: "Course", required: true },
12
+
13
+ // Snapshot of course price/title at time of purchase so historical
14
+ // orders remain accurate even if the course is later edited.
15
+ courseTitleSnapshot: { type: String, required: true },
16
+ amount: { type: Number, required: true, min: 0 },
17
+ currency: { type: String, default: "USD" },
18
+
19
+ paymentMethod: { type: String, enum: PAYMENT_METHODS, required: true },
20
+
21
+ // Sensitive: encrypted at rest with AES-256-GCM. Never returned by
22
+ // default queries (select: false) and never logged in plaintext.
23
+ // paymentReferenceIndex is a keyed HMAC used only to detect
24
+ // duplicate submissions without ever decrypting.
25
+ paymentReferenceEncrypted: { type: String, default: null, select: false },
26
+ paymentReferenceIndex: { type: String, default: null, index: true },
27
+ payerNoteEncrypted: { type: String, default: null, select: false },
28
+
29
+ status: { type: String, enum: ORDER_STATUSES, default: "pending_payment", index: true },
30
+
31
+ confirmedBy: { type: mongoose.Schema.Types.ObjectId, ref: "User", default: null },
32
+ confirmedAt: { type: Date, default: null },
33
+ rejectionReason: { type: String, default: null },
34
+
35
+ confirmationEmailSentAt: { type: Date, default: null },
36
+ },
37
+ { timestamps: true }
38
+ );
39
+
40
+ // --- Helpers to write/read the encrypted payment reference safely ---
41
+ orderSchema.methods.setPaymentReference = function setPaymentReference(plainReference) {
42
+ this.paymentReferenceEncrypted = encryptField(plainReference);
43
+ this.paymentReferenceIndex = hmacIndex(plainReference);
44
+ };
45
+
46
+ orderSchema.methods.getPaymentReference = function getPaymentReference() {
47
+ return decryptField(this.paymentReferenceEncrypted);
48
+ };
49
+
50
+ orderSchema.methods.setPayerNote = function setPayerNote(note) {
51
+ this.payerNoteEncrypted = encryptField(note);
52
+ };
53
+
54
+ orderSchema.methods.getPayerNote = function getPayerNote() {
55
+ return decryptField(this.payerNoteEncrypted);
56
+ };
57
+
58
+ orderSchema.methods.toPublicJSON = function toPublicJSON({ includeSensitive = false } = {}) {
59
+ const json = {
60
+ id: this._id,
61
+ orderNumber: this.orderNumber,
62
+ course: this.course,
63
+ courseTitleSnapshot: this.courseTitleSnapshot,
64
+ amount: this.amount,
65
+ currency: this.currency,
66
+ paymentMethod: this.paymentMethod,
67
+ status: this.status,
68
+ confirmedAt: this.confirmedAt,
69
+ rejectionReason: this.rejectionReason,
70
+ createdAt: this.createdAt,
71
+ };
72
+ if (includeSensitive) {
73
+ json.paymentReference = this.getPaymentReference();
74
+ json.payerNote = this.getPayerNote();
75
+ }
76
+ return json;
77
+ };
78
+
79
+ module.exports = mongoose.model("Order", orderSchema);
80
+ module.exports.PAYMENT_METHODS = PAYMENT_METHODS;
81
+ module.exports.ORDER_STATUSES = ORDER_STATUSES;
src/models/User.js ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const mongoose = require("mongoose");
2
+ const bcrypt = require("bcryptjs");
3
+
4
+ const enrolledCourseSchema = new mongoose.Schema(
5
+ {
6
+ course: { type: mongoose.Schema.Types.ObjectId, ref: "Course", required: true },
7
+ order: { type: mongoose.Schema.Types.ObjectId, ref: "Order", required: true },
8
+ accessGrantedAt: { type: Date, default: Date.now },
9
+ },
10
+ { _id: false }
11
+ );
12
+
13
+ const userSchema = new mongoose.Schema(
14
+ {
15
+ name: { type: String, required: true, trim: true, maxlength: 120 },
16
+ email: {
17
+ type: String,
18
+ required: true,
19
+ unique: true,
20
+ lowercase: true,
21
+ trim: true,
22
+ maxlength: 254,
23
+ index: true,
24
+ },
25
+ passwordHash: { type: String, required: true, select: false },
26
+ role: { type: String, enum: ["user", "admin"], default: "user" },
27
+
28
+ // Access-control / session-security bookkeeping
29
+ tokenVersion: { type: Number, default: 0 }, // bump to invalidate all issued JWTs
30
+ activeRefreshTokens: {
31
+ // store only the jti + expiry, never the raw token, so a leaked DB
32
+ // dump can't be replayed as a session
33
+ type: [
34
+ {
35
+ jti: String,
36
+ expiresAt: Date,
37
+ userAgent: String,
38
+ ip: String,
39
+ },
40
+ ],
41
+ default: [],
42
+ select: false,
43
+ },
44
+
45
+ // Brute-force protection
46
+ failedLoginAttempts: { type: Number, default: 0, select: false },
47
+ lockUntil: { type: Date, default: null, select: false },
48
+
49
+ enrolledCourses: { type: [enrolledCourseSchema], default: [] },
50
+
51
+ isActive: { type: Boolean, default: true },
52
+ },
53
+ { timestamps: true }
54
+ );
55
+
56
+ userSchema.methods.setPassword = async function setPassword(plainPassword) {
57
+ const salt = await bcrypt.genSalt(12);
58
+ this.passwordHash = await bcrypt.hash(plainPassword, salt);
59
+ };
60
+
61
+ userSchema.methods.comparePassword = function comparePassword(plainPassword) {
62
+ return bcrypt.compare(plainPassword, this.passwordHash);
63
+ };
64
+
65
+ userSchema.methods.isLocked = function isLocked() {
66
+ return !!(this.lockUntil && this.lockUntil > Date.now());
67
+ };
68
+
69
+ userSchema.methods.hasAccessToCourse = function hasAccessToCourse(courseId) {
70
+ return this.enrolledCourses.some((ec) => ec.course.toString() === courseId.toString());
71
+ };
72
+
73
+ userSchema.methods.toSafeJSON = function toSafeJSON() {
74
+ return {
75
+ id: this._id,
76
+ name: this.name,
77
+ email: this.email,
78
+ role: this.role,
79
+ enrolledCourses: this.enrolledCourses.map((ec) => ({
80
+ course: ec.course,
81
+ order: ec.order,
82
+ accessGrantedAt: ec.accessGrantedAt,
83
+ })),
84
+ createdAt: this.createdAt,
85
+ };
86
+ };
87
+
88
+ module.exports = mongoose.model("User", userSchema);
src/routes/admin.routes.js ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const express = require("express");
2
+ const { body } = require("express-validator");
3
+ const { validate } = require("../middleware/validate");
4
+ const { requireAuth, requireRole } = require("../middleware/auth");
5
+ const { upload } = require("../middleware/upload");
6
+ const ctrl = require("../controllers/admin.controller");
7
+ const contentCtrl = require("../controllers/content.controller");
8
+
9
+ const router = express.Router();
10
+
11
+ router.use(requireAuth, requireRole("admin"));
12
+
13
+ router.get("/dashboard", ctrl.dashboardStats);
14
+ router.get("/users", ctrl.listUsers);
15
+ router.get("/orders", ctrl.listOrders);
16
+ router.get("/orders/:id", ctrl.getOrderDetail);
17
+ router.post("/orders/:id/confirm", ctrl.confirmOrder);
18
+ router.post(
19
+ "/orders/:id/reject",
20
+ [body("reason").optional({ nullable: true }).trim().isLength({ max: 500 })],
21
+ validate,
22
+ ctrl.rejectOrder
23
+ );
24
+
25
+ // --- Course content management (admin-only; only admins may ever
26
+ // upload or modify study materials, per course requirements) ----------
27
+ router.get("/courses/:id/curriculum", contentCtrl.getCourseCurriculumForAdmin);
28
+
29
+ router.post(
30
+ "/courses/:id/sections",
31
+ [body("title").trim().isLength({ min: 1, max: 200 })],
32
+ validate,
33
+ contentCtrl.createSection
34
+ );
35
+ router.put("/courses/:id/sections/:sectionId", contentCtrl.updateSection);
36
+ router.delete("/courses/:id/sections/:sectionId", contentCtrl.deleteSection);
37
+
38
+ router.post(
39
+ "/courses/:id/sections/:sectionId/lessons",
40
+ upload.single("file"),
41
+ [body("title").trim().isLength({ min: 1, max: 200 })],
42
+ validate,
43
+ contentCtrl.uploadLesson
44
+ );
45
+ router.put("/courses/:id/sections/:sectionId/lessons/:lessonId", contentCtrl.updateLesson);
46
+ router.delete("/courses/:id/sections/:sectionId/lessons/:lessonId", contentCtrl.deleteLesson);
47
+
48
+ module.exports = router;
49
+
src/routes/auth.routes.js ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const express = require("express");
2
+ const { body } = require("express-validator");
3
+ const { validate } = require("../middleware/validate");
4
+ const { requireAuth } = require("../middleware/auth");
5
+ const { authLimiter } = require("../middleware/rateLimiters");
6
+ const ctrl = require("../controllers/auth.controller");
7
+
8
+ const router = express.Router();
9
+
10
+ router.post(
11
+ "/signup",
12
+ authLimiter,
13
+ [
14
+ body("name").trim().isLength({ min: 2, max: 120 }).withMessage("Name must be 2-120 characters."),
15
+ body("email").isEmail().normalizeEmail().withMessage("A valid email is required."),
16
+ body("password")
17
+ .isLength({ min: 10 })
18
+ .withMessage("Password must be at least 10 characters.")
19
+ .matches(/[A-Z]/)
20
+ .withMessage("Password must include an uppercase letter.")
21
+ .matches(/[a-z]/)
22
+ .withMessage("Password must include a lowercase letter.")
23
+ .matches(/[0-9]/)
24
+ .withMessage("Password must include a number."),
25
+ ],
26
+ validate,
27
+ ctrl.signup
28
+ );
29
+
30
+ router.post(
31
+ "/login",
32
+ authLimiter,
33
+ [
34
+ body("email").isEmail().normalizeEmail().withMessage("A valid email is required."),
35
+ body("password").notEmpty().withMessage("Password is required."),
36
+ ],
37
+ validate,
38
+ ctrl.login
39
+ );
40
+
41
+ router.post("/refresh", ctrl.refresh);
42
+ router.post("/logout", ctrl.logout);
43
+ router.get("/me", requireAuth, ctrl.me);
44
+
45
+ module.exports = router;
src/routes/course.routes.js ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const express = require("express");
2
+ const { body } = require("express-validator");
3
+ const { validate } = require("../middleware/validate");
4
+ const { requireAuth, requireRole } = require("../middleware/auth");
5
+ const ctrl = require("../controllers/course.controller");
6
+ const contentCtrl = require("../controllers/content.controller");
7
+
8
+ const router = express.Router();
9
+
10
+ router.get("/", ctrl.listCourses);
11
+ router.get("/:id", ctrl.getCourse);
12
+
13
+ // Curriculum viewing / lesson playback (learner-facing, requires login;
14
+ // access to actual lesson files is enforced per-lesson in the controller
15
+ // based on confirmed purchase, not just being logged in).
16
+ router.get("/:id/curriculum", requireAuth, contentCtrl.getCourseCurriculum);
17
+ router.get("/:id/sections/:sectionId/lessons/:lessonId/access", requireAuth, contentCtrl.getLessonAccess);
18
+
19
+ const courseValidators = [
20
+ body("title").trim().isLength({ min: 2, max: 200 }),
21
+ body("slug").trim().isSlug().withMessage("Slug must be URL-safe (lowercase, hyphens)."),
22
+ body("price").isFloat({ min: 0 }),
23
+ body("duration").trim().notEmpty(),
24
+ body("level").trim().notEmpty(),
25
+ body("description").trim().notEmpty(),
26
+ ];
27
+
28
+ router.post("/", requireAuth, requireRole("admin"), courseValidators, validate, ctrl.createCourse);
29
+ router.put("/:id", requireAuth, requireRole("admin"), ctrl.updateCourse);
30
+ router.delete("/:id", requireAuth, requireRole("admin"), ctrl.deleteCourse);
31
+
32
+ module.exports = router;
33
+
src/routes/order.routes.js ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const express = require("express");
2
+ const { body } = require("express-validator");
3
+ const { validate } = require("../middleware/validate");
4
+ const { requireAuth } = require("../middleware/auth");
5
+ const { orderLimiter } = require("../middleware/rateLimiters");
6
+ const { PAYMENT_METHODS } = require("../models/Order");
7
+ const ctrl = require("../controllers/order.controller");
8
+
9
+ const router = express.Router();
10
+
11
+ router.get("/payment-options", ctrl.getPaymentOptions);
12
+
13
+ router.use(requireAuth);
14
+
15
+ router.get("/", ctrl.listMyOrders);
16
+ router.get("/:id", ctrl.getMyOrder);
17
+
18
+ router.post(
19
+ "/",
20
+ orderLimiter,
21
+ [
22
+ body("courseId").isMongoId().withMessage("A valid course must be selected."),
23
+ body("paymentMethod").isIn(PAYMENT_METHODS).withMessage("Choose bank_wire or payoneer."),
24
+ ],
25
+ validate,
26
+ ctrl.createOrder
27
+ );
28
+
29
+ router.post(
30
+ "/:id/payment-proof",
31
+ orderLimiter,
32
+ [
33
+ body("reference").trim().isLength({ min: 3, max: 200 }).withMessage("Enter your payment reference/transaction ID."),
34
+ body("note").optional({ nullable: true }).trim().isLength({ max: 500 }),
35
+ ],
36
+ validate,
37
+ ctrl.submitPaymentProof
38
+ );
39
+
40
+ module.exports = router;
src/seed/seed.js ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* eslint-disable no-console */
2
+ const { env } = require("../config/env");
3
+ const { connectDB, disconnectDB } = require("../config/db");
4
+ const User = require("../models/User");
5
+ const Course = require("../models/Course");
6
+
7
+ const courses = [
8
+ {
9
+ title: "Artificial Intelligence Essentials",
10
+ slug: "artificial-intelligence-essentials",
11
+ price: 350,
12
+ duration: "8 weeks",
13
+ level: "Beginner",
14
+ description:
15
+ "A practical introduction to AI: learn what AI can (and cannot) do, work with real datasets, and build your first machine-learning models.",
16
+ highlights: [
17
+ "AI concepts, terminology and real-world use cases",
18
+ "Hands-on Python & ML experiments",
19
+ "Build a simple predictive model end-to-end",
20
+ ],
21
+ },
22
+ {
23
+ title: "Cyber Security Essentials",
24
+ slug: "cyber-security-essentials",
25
+ price: 300,
26
+ duration: "6 weeks",
27
+ level: "Beginner",
28
+ description:
29
+ "Learn the foundations of cyber defense: protect devices, networks, and data against the most common digital threats.",
30
+ highlights: [
31
+ "Threat landscape and attacker tactics",
32
+ "Secure browsing, password & identity hygiene",
33
+ "Basic network and endpoint protection",
34
+ ],
35
+ },
36
+ {
37
+ title: "AI-Powered Cybersecurity",
38
+ slug: "ai-powered-cybersecurity",
39
+ price: 550,
40
+ duration: "10 weeks",
41
+ level: "Intermediate to Advanced",
42
+ description:
43
+ "Combine AI with security operations: deploy intelligent threat detection, automate incident response, and build resilient SOC workflows.",
44
+ highlights: [
45
+ "ML-based threat detection and anomaly analytics",
46
+ "Automated incident response playbooks",
47
+ "AI governance, ethics and adversarial risks",
48
+ ],
49
+ },
50
+ {
51
+ title: "The AI Career Guidance",
52
+ slug: "the-ai-career-guidance",
53
+ price: 200,
54
+ duration: "4 weeks",
55
+ level: "Career Foundations",
56
+ description:
57
+ "Map your path into the AI economy. Discover roles, build a portfolio, and prepare for interviews with guided mentorship.",
58
+ highlights: [
59
+ "AI career landscape and skill roadmap",
60
+ "Portfolio project planning and resume review",
61
+ "Mock interviews and mentorship sessions",
62
+ ],
63
+ },
64
+ {
65
+ title: "AI for Business Managers",
66
+ slug: "ai-for-business-managers",
67
+ price: 450,
68
+ duration: "6 weeks",
69
+ level: "Manager Level",
70
+ description:
71
+ "Learn how to evaluate, procure, and manage AI projects that drive real business value without needing to code.",
72
+ highlights: [
73
+ "AI strategy, ROI and use-case prioritisation",
74
+ "Managing data, vendors and project risk",
75
+ "Leading AI-enabled teams and change",
76
+ ],
77
+ },
78
+ {
79
+ title: "AI for Business Executives",
80
+ slug: "ai-for-business-executives",
81
+ price: 650,
82
+ duration: "5 weeks",
83
+ level: "Executive Level",
84
+ description:
85
+ "A leadership-level course on AI governance, competitive strategy, and building an AI-ready organisation.",
86
+ highlights: [
87
+ "Board-level AI vision and investment decisions",
88
+ "Regulatory, ethical and governance frameworks",
89
+ "Building an AI culture and Centre of Excellence",
90
+ ],
91
+ },
92
+ ];
93
+
94
+ async function seed() {
95
+ await connectDB();
96
+
97
+ for (const c of courses) {
98
+ await Course.findOneAndUpdate({ slug: c.slug }, c, { upsert: true, new: true, setDefaultsOnInsert: true });
99
+ }
100
+ console.log(`Seeded ${courses.length} courses.`);
101
+
102
+ if (env.seedAdmin.email && env.seedAdmin.password) {
103
+ const existingAdmin = await User.findOne({ email: env.seedAdmin.email.toLowerCase() });
104
+ if (existingAdmin) {
105
+ console.log(`Admin account already exists for ${env.seedAdmin.email}, skipping.`);
106
+ } else {
107
+ const admin = new User({
108
+ name: env.seedAdmin.name,
109
+ email: env.seedAdmin.email.toLowerCase(),
110
+ role: "admin",
111
+ });
112
+ await admin.setPassword(env.seedAdmin.password);
113
+ await admin.save();
114
+ console.log(`Created admin account: ${env.seedAdmin.email}`);
115
+ console.log("IMPORTANT: log in and change this password immediately.");
116
+ }
117
+ } else {
118
+ console.log("SEED_ADMIN_EMAIL / SEED_ADMIN_PASSWORD not set — skipping admin creation.");
119
+ }
120
+
121
+ await disconnectDB();
122
+ process.exit(0);
123
+ }
124
+
125
+ seed().catch((err) => {
126
+ console.error("Seeding failed:", err);
127
+ process.exit(1);
128
+ });
src/server.js ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const { env, assertProductionSafety } = require("./config/env");
2
+ const { connectDB, disconnectDB } = require("./config/db");
3
+ const logger = require("./utils/logger");
4
+ const app = require("./app");
5
+
6
+ assertProductionSafety();
7
+
8
+ let server;
9
+
10
+ async function start() {
11
+ try {
12
+ await connectDB();
13
+ server = app.listen(env.port, () => {
14
+ logger.info(`Aynwerd API listening on port ${env.port} [${env.nodeEnv}]`);
15
+ });
16
+ } catch (err) {
17
+ logger.error("Failed to start server", { error: err.message });
18
+ process.exit(1);
19
+ }
20
+ }
21
+
22
+ async function shutdown(signal) {
23
+ logger.info(`Received ${signal}, shutting down gracefully...`);
24
+ if (server) {
25
+ server.close(async () => {
26
+ await disconnectDB();
27
+ process.exit(0);
28
+ });
29
+ // Force-exit if something hangs
30
+ setTimeout(() => process.exit(1), 10000).unref();
31
+ } else {
32
+ process.exit(0);
33
+ }
34
+ }
35
+
36
+ process.on("SIGINT", () => shutdown("SIGINT"));
37
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
38
+ process.on("unhandledRejection", (reason) => {
39
+ logger.error("Unhandled promise rejection", { reason: reason?.message || reason });
40
+ });
41
+ process.on("uncaughtException", (err) => {
42
+ logger.error("Uncaught exception", { error: err.message, stack: err.stack });
43
+ process.exit(1);
44
+ });
45
+
46
+ start();
src/utils/cloudinaryUpload.js ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const cloudinary = require("../config/cloudinary");
2
+
3
+ const FOLDER = "aynwerd-courses";
4
+
5
+ /**
6
+ * Uploads a file from a local temp path to Cloudinary under private,
7
+ * signed-only access. `type: "authenticated"` means the asset can
8
+ * NEVER be fetched by its plain URL or by guessing the public ID —
9
+ * every request must carry a valid signature that we only ever mint
10
+ * for a verified, paying learner (see getSignedPlaybackUrl below).
11
+ *
12
+ * Videos use `upload_large`, which chunks the upload — required for
13
+ * anything beyond a small size and generally more reliable for video
14
+ * regardless of size. Everything else uses a normal upload.
15
+ */
16
+ function uploadFromPath(filePath, { resourceType, filenameHint }) {
17
+ const options = {
18
+ folder: FOLDER,
19
+ resource_type: resourceType, // "video" | "image" | "raw"
20
+ type: "authenticated",
21
+ use_filename: true,
22
+ unique_filename: true,
23
+ filename_override: filenameHint,
24
+ };
25
+
26
+ if (resourceType === "video") {
27
+ return cloudinary.uploader.upload_large(filePath, { ...options, chunk_size: 6 * 1024 * 1024 });
28
+ }
29
+ return cloudinary.uploader.upload(filePath, options);
30
+ }
31
+
32
+ function deleteAsset(publicId, resourceType) {
33
+ return cloudinary.uploader.destroy(publicId, { resource_type: resourceType, type: "authenticated" });
34
+ }
35
+
36
+ /**
37
+ * Mints a short-lived, signed URL for a specific lesson asset. Valid for
38
+ * a couple of hours only, so a leaked link stops working quickly and
39
+ * cannot be redistributed as a permanent download.
40
+ */
41
+ function getSignedPlaybackUrl(publicId, resourceType, { format, expiresInSeconds = 2 * 60 * 60 } = {}) {
42
+ const expiresAt = Math.floor(Date.now() / 1000) + expiresInSeconds;
43
+ const url = cloudinary.url(publicId, {
44
+ resource_type: resourceType,
45
+ type: "authenticated",
46
+ sign_url: true,
47
+ secure: true,
48
+ expires_at: expiresAt,
49
+ format: format || undefined,
50
+ });
51
+ return { url, expiresAt };
52
+ }
53
+
54
+ /** Maps an uploaded file's mimetype to the Cloudinary resource_type bucket it belongs in. */
55
+ function resourceTypeForMimetype(mimetype) {
56
+ if (mimetype.startsWith("video/")) return "video";
57
+ if (mimetype.startsWith("image/")) return "image";
58
+ return "raw"; // PDFs, docs, slides, zips, etc.
59
+ }
60
+
61
+ module.exports = { uploadFromPath, deleteAsset, getSignedPlaybackUrl, resourceTypeForMimetype };
src/utils/crypto.js ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const crypto = require("crypto");
2
+ const { env } = require("../config/env");
3
+
4
+ const ALGORITHM = "aes-256-gcm";
5
+ const IV_LENGTH = 12; // 96-bit IV recommended for GCM
6
+
7
+ function getKey() {
8
+ const key = Buffer.from(env.fieldEncryptionKey || "", "hex");
9
+ if (key.length !== 32) {
10
+ throw new Error(
11
+ "FIELD_ENCRYPTION_KEY must be a 32-byte value encoded as 64 hex characters. " +
12
+ "Generate one with: node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\""
13
+ );
14
+ }
15
+ return key;
16
+ }
17
+
18
+ /**
19
+ * Encrypts a plaintext string for storage at rest (e.g. bank wire
20
+ * reference numbers, Payoneer transaction IDs). Returns a single
21
+ * base64 payload containing iv + authTag + ciphertext so it can be
22
+ * stored as one string field in MongoDB.
23
+ */
24
+ function encryptField(plaintext) {
25
+ if (plaintext === undefined || plaintext === null || plaintext === "") return null;
26
+ const iv = crypto.randomBytes(IV_LENGTH);
27
+ const cipher = crypto.createCipheriv(ALGORITHM, getKey(), iv);
28
+ const ciphertext = Buffer.concat([cipher.update(String(plaintext), "utf8"), cipher.final()]);
29
+ const authTag = cipher.getAuthTag();
30
+ return Buffer.concat([iv, authTag, ciphertext]).toString("base64");
31
+ }
32
+
33
+ /**
34
+ * Decrypts a payload produced by encryptField. Returns null if the
35
+ * input is empty rather than throwing, so callers can treat "no
36
+ * payment reference yet" gracefully.
37
+ */
38
+ function decryptField(payload) {
39
+ if (!payload) return null;
40
+ const raw = Buffer.from(payload, "base64");
41
+ const iv = raw.subarray(0, IV_LENGTH);
42
+ const authTag = raw.subarray(IV_LENGTH, IV_LENGTH + 16);
43
+ const ciphertext = raw.subarray(IV_LENGTH + 16);
44
+ const decipher = crypto.createDecipheriv(ALGORITHM, getKey(), iv);
45
+ decipher.setAuthTag(authTag);
46
+ const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
47
+ return plaintext.toString("utf8");
48
+ }
49
+
50
+ /** One-way hash used for equality lookups on encrypted fields (e.g. duplicate detection) without decrypting. */
51
+ function hmacIndex(value) {
52
+ if (!value) return null;
53
+ return crypto.createHmac("sha256", getKey()).update(String(value)).digest("hex");
54
+ }
55
+
56
+ module.exports = { encryptField, decryptField, hmacIndex };
src/utils/email.js ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const nodemailer = require("nodemailer");
2
+ const { env } = require("../config/env");
3
+ const logger = require("./logger");
4
+
5
+ let transporter = null;
6
+
7
+ function getTransporter() {
8
+ if (transporter) return transporter;
9
+ transporter = nodemailer.createTransport({
10
+ host: env.smtp.host,
11
+ port: env.smtp.port,
12
+ secure: env.smtp.secure, // true for 465, false for other ports (STARTTLS)
13
+ auth: env.smtp.user ? { user: env.smtp.user, pass: env.smtp.pass } : undefined,
14
+ requireTLS: !env.smtp.secure, // enforce STARTTLS when not already on a TLS port
15
+ });
16
+ return transporter;
17
+ }
18
+
19
+ async function sendMail({ to, subject, html, text }) {
20
+ try {
21
+ const info = await getTransporter().sendMail({
22
+ from: env.emailFrom,
23
+ to,
24
+ subject,
25
+ html,
26
+ text,
27
+ });
28
+ return info;
29
+ } catch (err) {
30
+ // Email failures must never crash a request or leak SMTP credentials.
31
+ logger.error("Failed to send email", { subject, error: err.message });
32
+ return null;
33
+ }
34
+ }
35
+
36
+ function baseLayout(bodyHtml) {
37
+ return `
38
+ <div style="font-family:Arial,Helvetica,sans-serif;max-width:600px;margin:0 auto;color:#1a1a1a;">
39
+ <div style="padding:24px 0;border-bottom:2px solid #eee;">
40
+ <h2 style="margin:0;color:#111;">Aynwerd Academy</h2>
41
+ </div>
42
+ <div style="padding:24px 0;">${bodyHtml}</div>
43
+ <div style="padding:16px 0;border-top:1px solid #eee;font-size:12px;color:#777;">
44
+ This is an automated message regarding your order. Never share your password or
45
+ full payment reference by email. If you didn't make this request, contact
46
+ support immediately.
47
+ </div>
48
+ </div>`;
49
+ }
50
+
51
+ async function sendOrderPlacedEmail({ to, name, order, courseTitle, bankDetails, payoneerDetails }) {
52
+ const methodBlock =
53
+ order.paymentMethod === "bank_wire"
54
+ ? `<h3>Bank wire transfer instructions</h3>
55
+ <table style="border-collapse:collapse;width:100%;font-size:14px;">
56
+ <tr><td style="padding:4px 0;color:#555;">Bank name</td><td><strong>${bankDetails.name}</strong></td></tr>
57
+ <tr><td style="padding:4px 0;color:#555;">Account name</td><td><strong>${bankDetails.accountName}</strong></td></tr>
58
+ <tr><td style="padding:4px 0;color:#555;">Account number</td><td><strong>${bankDetails.accountNumber}</strong></td></tr>
59
+ <tr><td style="padding:4px 0;color:#555;">Routing number</td><td><strong>${bankDetails.routingNumber}</strong></td></tr>
60
+ <tr><td style="padding:4px 0;color:#555;">SWIFT/BIC</td><td><strong>${bankDetails.swiftBic}</strong></td></tr>
61
+ <tr><td style="padding:4px 0;color:#555;">Bank address</td><td><strong>${bankDetails.address}</strong></td></tr>
62
+ </table>
63
+ <p>Please use order number <strong>${order.orderNumber}</strong> as your payment reference.</p>`
64
+ : `<h3>Payoneer payment instructions</h3>
65
+ <p>Send payment to Payoneer account <strong>${payoneerDetails.accountEmail}</strong>
66
+ or use your Payoneer app's "Request a Payment" link: <a href="${payoneerDetails.paymentUrl}">${payoneerDetails.paymentUrl}</a>.</p>
67
+ <p>Please use order number <strong>${order.orderNumber}</strong> as your payment note.</p>`;
68
+
69
+ const html = baseLayout(`
70
+ <p>Hi ${name},</p>
71
+ <p>Thanks for enrolling in <strong>${courseTitle}</strong>. Your order has been created and is
72
+ awaiting payment${order.status === "awaiting_confirmation" ? " confirmation" : ""}.</p>
73
+ <p><strong>Order number:</strong> ${order.orderNumber}<br/>
74
+ <strong>Amount due:</strong> ${order.currency} ${order.amount.toFixed(2)}<br/>
75
+ <strong>Payment method:</strong> ${order.paymentMethod === "bank_wire" ? "Bank wire transfer" : "Payoneer"}</p>
76
+ ${methodBlock}
77
+ <p>Once our team confirms your payment, you'll receive a separate email and full course access
78
+ will be unlocked automatically on your account.</p>
79
+ `);
80
+
81
+ return sendMail({
82
+ to,
83
+ subject: `Your Aynwerd order ${order.orderNumber} — payment instructions`,
84
+ html,
85
+ text: `Order ${order.orderNumber} created for ${courseTitle}. Amount due: ${order.currency} ${order.amount.toFixed(2)}.`,
86
+ });
87
+ }
88
+
89
+ async function sendPaymentConfirmedEmail({ to, name, order, courseTitle }) {
90
+ const html = baseLayout(`
91
+ <p>Hi ${name},</p>
92
+ <p>Great news — we've confirmed your payment for <strong>${courseTitle}</strong>.</p>
93
+ <p><strong>Order number:</strong> ${order.orderNumber}<br/>
94
+ <strong>Amount paid:</strong> ${order.currency} ${order.amount.toFixed(2)}<br/>
95
+ <strong>Confirmed on:</strong> ${new Date(order.confirmedAt).toUTCString()}</p>
96
+ <p>Full access to the course has been unlocked on your account. Log in any time to get started.</p>
97
+ `);
98
+
99
+ return sendMail({
100
+ to,
101
+ subject: `Payment confirmed — order ${order.orderNumber}`,
102
+ html,
103
+ text: `Payment confirmed for order ${order.orderNumber}. Course access unlocked.`,
104
+ });
105
+ }
106
+
107
+ async function sendPaymentRejectedEmail({ to, name, order, courseTitle, reason }) {
108
+ const html = baseLayout(`
109
+ <p>Hi ${name},</p>
110
+ <p>We were unable to confirm your payment for <strong>${courseTitle}</strong> (order
111
+ <strong>${order.orderNumber}</strong>).</p>
112
+ ${reason ? `<p><strong>Reason:</strong> ${reason}</p>` : ""}
113
+ <p>Please reply to this email or contact support so we can help resolve this.</p>
114
+ `);
115
+
116
+ return sendMail({
117
+ to,
118
+ subject: `Action needed on order ${order.orderNumber}`,
119
+ html,
120
+ text: `We could not confirm payment for order ${order.orderNumber}.${reason ? ` Reason: ${reason}` : ""}`,
121
+ });
122
+ }
123
+
124
+ async function sendAdminNewOrderAlert({ order, courseTitle, customerEmail }) {
125
+ if (!env.adminNotifyEmail) return null;
126
+ const html = baseLayout(`
127
+ <p>New order awaiting confirmation.</p>
128
+ <p><strong>Order:</strong> ${order.orderNumber}<br/>
129
+ <strong>Customer:</strong> ${customerEmail}<br/>
130
+ <strong>Course:</strong> ${courseTitle}<br/>
131
+ <strong>Amount:</strong> ${order.currency} ${order.amount.toFixed(2)}<br/>
132
+ <strong>Method:</strong> ${order.paymentMethod}</p>
133
+ <p>Review and confirm it from the admin dashboard.</p>
134
+ `);
135
+ return sendMail({
136
+ to: env.adminNotifyEmail,
137
+ subject: `New order ${order.orderNumber} awaiting confirmation`,
138
+ html,
139
+ });
140
+ }
141
+
142
+ module.exports = {
143
+ sendOrderPlacedEmail,
144
+ sendPaymentConfirmedEmail,
145
+ sendPaymentRejectedEmail,
146
+ sendAdminNewOrderAlert,
147
+ };
src/utils/jwt.js ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const jwt = require("jsonwebtoken");
2
+ const crypto = require("crypto");
3
+ const { env } = require("../config/env");
4
+
5
+ function signAccessToken(user) {
6
+ return jwt.sign(
7
+ { sub: user._id.toString(), role: user.role, tokenVersion: user.tokenVersion },
8
+ env.jwtAccessSecret,
9
+ { expiresIn: env.jwtAccessExpiresIn, issuer: "aynwerd-api", audience: "aynwerd-client" }
10
+ );
11
+ }
12
+
13
+ function signRefreshToken(user) {
14
+ // A random jti lets us revoke a single refresh token (e.g. on logout /
15
+ // password change) without invalidating every session at once.
16
+ const jti = crypto.randomUUID();
17
+ const token = jwt.sign(
18
+ { sub: user._id.toString(), tokenVersion: user.tokenVersion, jti },
19
+ env.jwtRefreshSecret,
20
+ { expiresIn: env.jwtRefreshExpiresIn, issuer: "aynwerd-api", audience: "aynwerd-client" }
21
+ );
22
+ return { token, jti };
23
+ }
24
+
25
+ function verifyAccessToken(token) {
26
+ return jwt.verify(token, env.jwtAccessSecret, { issuer: "aynwerd-api", audience: "aynwerd-client" });
27
+ }
28
+
29
+ function verifyRefreshToken(token) {
30
+ return jwt.verify(token, env.jwtRefreshSecret, { issuer: "aynwerd-api", audience: "aynwerd-client" });
31
+ }
32
+
33
+ module.exports = { signAccessToken, signRefreshToken, verifyAccessToken, verifyRefreshToken };
src/utils/logger.js ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const winston = require("winston");
2
+
3
+ // Structured JSON logging so records can be shipped to a log aggregator.
4
+ // Never log secrets, raw passwords, tokens, or full payment identifiers —
5
+ // see redact() usage in controllers.
6
+ const logger = winston.createLogger({
7
+ level: process.env.LOG_LEVEL || "info",
8
+ format: winston.format.combine(
9
+ winston.format.timestamp(),
10
+ winston.format.errors({ stack: true }),
11
+ winston.format.json()
12
+ ),
13
+ defaultMeta: { service: "aynwerd-api" },
14
+ transports: [new winston.transports.Console()],
15
+ });
16
+
17
+ /**
18
+ * Redact all but the last `keep` characters of a sensitive string, e.g.
19
+ * for logging a payment reference without exposing it in full.
20
+ */
21
+ function redact(value, keep = 4) {
22
+ if (!value) return value;
23
+ const str = String(value);
24
+ if (str.length <= keep) return "*".repeat(str.length);
25
+ return `${"*".repeat(str.length - keep)}${str.slice(-keep)}`;
26
+ }
27
+
28
+ module.exports = logger;
29
+ module.exports.redact = redact;
src/utils/orderNumber.js ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const crypto = require("crypto");
2
+
3
+ /**
4
+ * Human-friendly, hard-to-guess order number, e.g. AYN-20260825-4F9K2C.
5
+ * Not used as a security token (the order is always looked up by its
6
+ * MongoDB _id + the requesting user's identity), just a reference the
7
+ * customer can quote in support emails.
8
+ */
9
+ function generateOrderNumber() {
10
+ const date = new Date().toISOString().slice(0, 10).replace(/-/g, "");
11
+ const random = crypto.randomBytes(4).toString("hex").toUpperCase().slice(0, 6);
12
+ return `AYN-${date}-${random}`;
13
+ }
14
+
15
+ module.exports = { generateOrderNumber };