AndreaNotes1 commited on
Commit
6e13570
·
verified ·
1 Parent(s): 5ef11cb

Upload 36 files

Browse files
.dockerignore CHANGED
@@ -1,8 +1,8 @@
1
- node_modules
2
- npm-debug.log*
3
- .env
4
- .env.*
5
- !.env.example
6
- .git
7
- .gitignore
8
- *.md
 
1
+ node_modules
2
+ npm-debug.log*
3
+ .env
4
+ .env.*
5
+ !.env.example
6
+ .git
7
+ .gitignore
8
+ *.md
.gitignore CHANGED
@@ -1,3 +1,3 @@
1
- node_modules/
2
- .env
3
- npm-debug.log*
 
1
+ node_modules/
2
+ .env
3
+ npm-debug.log*
Dockerfile CHANGED
@@ -1,33 +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"]
 
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"]
app.js CHANGED
@@ -1,91 +1,91 @@
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
- // Logged (not just thrown) so a mismatched frontend domain shows up
51
- // immediately in the server logs with the exact rejected value —
52
- // compare this directly against the CORS allow-list logged at
53
- // startup to spot typos, protocol mismatches, or a missing entry.
54
- logger.warn(`CORS rejected origin: "${origin}" (allow-list: ${JSON.stringify(env.clientOrigins)})`);
55
- return callback(new Error("Not allowed by CORS"));
56
- },
57
- credentials: true,
58
- })
59
- );
60
-
61
- app.use(compression());
62
- app.use(express.json({ limit: "100kb" })); // small limit: this API never needs large bodies
63
- app.use(express.urlencoded({ extended: true, limit: "100kb" }));
64
- app.use(cookieParser(env.cookieSecret));
65
-
66
- // Strip any keys starting with "$" or containing "." from user input to
67
- // block MongoDB operator-injection attacks.
68
- app.use(mongoSanitize());
69
- // Prevent HTTP parameter-pollution (e.g. ?role=user&role=admin).
70
- app.use(hpp());
71
-
72
- app.use(
73
- morgan("combined", {
74
- stream: { write: (message) => logger.info(message.trim()) },
75
- skip: () => env.nodeEnv === "test",
76
- })
77
- );
78
-
79
- app.use("/api/", apiLimiter);
80
-
81
- app.get("/api/health", (req, res) => res.json({ status: "ok", time: new Date().toISOString() }));
82
-
83
- app.use("/api/auth", authRoutes);
84
- app.use("/api/courses", courseRoutes);
85
- app.use("/api/orders", orderRoutes);
86
- app.use("/api/admin", adminRoutes);
87
-
88
- app.use(notFound);
89
- app.use(errorHandler);
90
-
91
- module.exports = app;
 
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
+ // Logged (not just thrown) so a mismatched frontend domain shows up
51
+ // immediately in the server logs with the exact rejected value —
52
+ // compare this directly against the CORS allow-list logged at
53
+ // startup to spot typos, protocol mismatches, or a missing entry.
54
+ logger.warn(`CORS rejected origin: "${origin}" (allow-list: ${JSON.stringify(env.clientOrigins)})`);
55
+ return callback(new Error("Not allowed by CORS"));
56
+ },
57
+ credentials: true,
58
+ })
59
+ );
60
+
61
+ app.use(compression());
62
+ app.use(express.json({ limit: "100kb" })); // small limit: this API never needs large bodies
63
+ app.use(express.urlencoded({ extended: true, limit: "100kb" }));
64
+ app.use(cookieParser(env.cookieSecret));
65
+
66
+ // Strip any keys starting with "$" or containing "." from user input to
67
+ // block MongoDB operator-injection attacks.
68
+ app.use(mongoSanitize());
69
+ // Prevent HTTP parameter-pollution (e.g. ?role=user&role=admin).
70
+ app.use(hpp());
71
+
72
+ app.use(
73
+ morgan("combined", {
74
+ stream: { write: (message) => logger.info(message.trim()) },
75
+ skip: () => env.nodeEnv === "test",
76
+ })
77
+ );
78
+
79
+ app.use("/api/", apiLimiter);
80
+
81
+ app.get("/api/health", (req, res) => res.json({ status: "ok", time: new Date().toISOString() }));
82
+
83
+ app.use("/api/auth", authRoutes);
84
+ app.use("/api/courses", courseRoutes);
85
+ app.use("/api/orders", orderRoutes);
86
+ app.use("/api/admin", adminRoutes);
87
+
88
+ app.use(notFound);
89
+ app.use(errorHandler);
90
+
91
+ module.exports = app;
package.json CHANGED
@@ -1,39 +1,39 @@
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
- "cors": "^2.8.5",
21
- "dotenv": "^16.4.5",
22
- "express": "^4.19.2",
23
- "express-mongo-sanitize": "^2.2.0",
24
- "express-rate-limit": "^7.4.0",
25
- "express-validator": "^7.2.0",
26
- "helmet": "^7.1.0",
27
- "hpp": "^0.2.3",
28
- "jsonwebtoken": "^9.0.2",
29
- "mongoose": "^8.6.0",
30
- "morgan": "^1.10.0",
31
- "multer": "^1.4.5-lts.1",
32
- "nodemailer": "^6.9.14",
33
- "uuid": "^9.0.1",
34
- "winston": "^3.14.2"
35
- },
36
- "devDependencies": {
37
- "nodemon": "^3.1.4"
38
- }
39
- }
 
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
+ "cors": "^2.8.5",
21
+ "dotenv": "^16.4.5",
22
+ "express": "^4.19.2",
23
+ "express-mongo-sanitize": "^2.2.0",
24
+ "express-rate-limit": "^7.4.0",
25
+ "express-validator": "^7.2.0",
26
+ "helmet": "^7.1.0",
27
+ "hpp": "^0.2.3",
28
+ "jsonwebtoken": "^9.0.2",
29
+ "mongoose": "^8.6.0",
30
+ "morgan": "^1.10.0",
31
+ "multer": "^1.4.5-lts.1",
32
+ "nodemailer": "^6.9.14",
33
+ "uuid": "^9.0.1",
34
+ "winston": "^3.14.2"
35
+ },
36
+ "devDependencies": {
37
+ "nodemon": "^3.1.4"
38
+ }
39
+ }
server.js CHANGED
@@ -1,53 +1,53 @@
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
- // Logged deliberately at startup: this is the #1 source of CORS
16
- // support confusion (a typo'd env var name, stray quotes, a
17
- // trailing slash, or the value falling back to its localhost
18
- // default because it was never actually set). Seeing the parsed
19
- // array here removes all guesswork versus staring at the raw
20
- // dashboard value.
21
- logger.info(`CORS allow-list (CLIENT_ORIGINS parsed as): ${JSON.stringify(env.clientOrigins)}`);
22
- });
23
- } catch (err) {
24
- logger.error("Failed to start server", { error: err.message });
25
- process.exit(1);
26
- }
27
- }
28
-
29
- async function shutdown(signal) {
30
- logger.info(`Received ${signal}, shutting down gracefully...`);
31
- if (server) {
32
- server.close(async () => {
33
- await disconnectDB();
34
- process.exit(0);
35
- });
36
- // Force-exit if something hangs
37
- setTimeout(() => process.exit(1), 10000).unref();
38
- } else {
39
- process.exit(0);
40
- }
41
- }
42
-
43
- process.on("SIGINT", () => shutdown("SIGINT"));
44
- process.on("SIGTERM", () => shutdown("SIGTERM"));
45
- process.on("unhandledRejection", (reason) => {
46
- logger.error("Unhandled promise rejection", { reason: reason?.message || reason });
47
- });
48
- process.on("uncaughtException", (err) => {
49
- logger.error("Uncaught exception", { error: err.message, stack: err.stack });
50
- process.exit(1);
51
- });
52
-
53
- start();
 
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
+ // Logged deliberately at startup: this is the #1 source of CORS
16
+ // support confusion (a typo'd env var name, stray quotes, a
17
+ // trailing slash, or the value falling back to its localhost
18
+ // default because it was never actually set). Seeing the parsed
19
+ // array here removes all guesswork versus staring at the raw
20
+ // dashboard value.
21
+ logger.info(`CORS allow-list (CLIENT_ORIGINS parsed as): ${JSON.stringify(env.clientOrigins)}`);
22
+ });
23
+ } catch (err) {
24
+ logger.error("Failed to start server", { error: err.message });
25
+ process.exit(1);
26
+ }
27
+ }
28
+
29
+ async function shutdown(signal) {
30
+ logger.info(`Received ${signal}, shutting down gracefully...`);
31
+ if (server) {
32
+ server.close(async () => {
33
+ await disconnectDB();
34
+ process.exit(0);
35
+ });
36
+ // Force-exit if something hangs
37
+ setTimeout(() => process.exit(1), 10000).unref();
38
+ } else {
39
+ process.exit(0);
40
+ }
41
+ }
42
+
43
+ process.on("SIGINT", () => shutdown("SIGINT"));
44
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
45
+ process.on("unhandledRejection", (reason) => {
46
+ logger.error("Unhandled promise rejection", { reason: reason?.message || reason });
47
+ });
48
+ process.on("uncaughtException", (err) => {
49
+ logger.error("Uncaught exception", { error: err.message, stack: err.stack });
50
+ process.exit(1);
51
+ });
52
+
53
+ start();
src/app.js CHANGED
@@ -1,99 +1,99 @@
1
- const express = require("express");
2
- const helmet = require("helmet");
3
- const cors = require("cors");
4
- const compression = require("compression");
5
- const morgan = require("morgan");
6
- const mongoSanitize = require("express-mongo-sanitize");
7
- const hpp = require("hpp");
8
-
9
- const { env } = require("./config/env");
10
- const { apiLimiter } = require("./middleware/rateLimiters");
11
- const { errorHandler, notFound } = require("./middleware/errorHandler");
12
- const logger = require("./utils/logger");
13
-
14
- const authRoutes = require("./routes/auth.routes");
15
- const courseRoutes = require("./routes/course.routes");
16
- const orderRoutes = require("./routes/order.routes");
17
- const adminRoutes = require("./routes/admin.routes");
18
-
19
- const app = express();
20
-
21
- // Trust the first proxy hop (Render/Heroku/Nginx/etc.) so req.ip and the
22
- // "secure" cookie/HSTS logic reflect the real client over HTTPS.
23
- app.set("trust proxy", 1);
24
-
25
- // --- Security headers -------------------------------------------------
26
- app.use(
27
- helmet({
28
- contentSecurityPolicy: {
29
- directives: {
30
- defaultSrc: ["'self'"],
31
- scriptSrc: ["'self'"],
32
- styleSrc: ["'self'", "'unsafe-inline'"],
33
- imgSrc: ["'self'", "data:", "https:"],
34
- objectSrc: ["'none'"],
35
- frameAncestors: ["'none'"],
36
- },
37
- },
38
- crossOriginResourcePolicy: { policy: "same-site" },
39
- hsts: { maxAge: 63072000, includeSubDomains: true, preload: true },
40
- })
41
- );
42
-
43
- // --- CORS: only the configured frontend origins may call the API.
44
- // `credentials: false` is deliberate — this API is designed to run
45
- // cross-site from its frontend (e.g. a Hugging Face Space backend +
46
- // a separate Vercel-hosted frontend), and some hosting proxies in that
47
- // kind of setup intercept CORS preflight requests before they reach
48
- // this app and strip the Access-Control-Allow-Credentials header,
49
- // which silently breaks anything relying on `credentials: include`.
50
- // Sessions are carried via a bearer access token (Authorization
51
- // header) plus a refresh token returned in the JSON response body
52
- // instead of a cookie, so no cross-site credentialed request is ever
53
- // needed — see controllers/auth.controller.js for the reasoning and
54
- // trade-offs of that approach in more detail. ---
55
- app.use(
56
- cors({
57
- origin(origin, callback) {
58
- if (!origin || env.clientOrigins.includes(origin)) return callback(null, true);
59
- // Logged (not just thrown) so a mismatched frontend domain shows up
60
- // immediately in the server logs with the exact rejected value —
61
- // compare this directly against the CORS allow-list logged at
62
- // startup to spot typos, protocol mismatches, or a missing entry.
63
- logger.warn(`CORS rejected origin: "${origin}" (allow-list: ${JSON.stringify(env.clientOrigins)})`);
64
- return callback(new Error("Not allowed by CORS"));
65
- },
66
- credentials: false,
67
- })
68
- );
69
-
70
- app.use(compression());
71
- app.use(express.json({ limit: "100kb" })); // small limit: this API never needs large bodies
72
- app.use(express.urlencoded({ extended: true, limit: "100kb" }));
73
-
74
- // Strip any keys starting with "$" or containing "." from user input to
75
- // block MongoDB operator-injection attacks.
76
- app.use(mongoSanitize());
77
- // Prevent HTTP parameter-pollution (e.g. ?role=user&role=admin).
78
- app.use(hpp());
79
-
80
- app.use(
81
- morgan("combined", {
82
- stream: { write: (message) => logger.info(message.trim()) },
83
- skip: () => env.nodeEnv === "test",
84
- })
85
- );
86
-
87
- app.use("/api/", apiLimiter);
88
-
89
- app.get("/api/health", (req, res) => res.json({ status: "ok", time: new Date().toISOString() }));
90
-
91
- app.use("/api/auth", authRoutes);
92
- app.use("/api/courses", courseRoutes);
93
- app.use("/api/orders", orderRoutes);
94
- app.use("/api/admin", adminRoutes);
95
-
96
- app.use(notFound);
97
- app.use(errorHandler);
98
-
99
- module.exports = app;
 
1
+ const express = require("express");
2
+ const helmet = require("helmet");
3
+ const cors = require("cors");
4
+ const compression = require("compression");
5
+ const morgan = require("morgan");
6
+ const mongoSanitize = require("express-mongo-sanitize");
7
+ const hpp = require("hpp");
8
+
9
+ const { env } = require("./config/env");
10
+ const { apiLimiter } = require("./middleware/rateLimiters");
11
+ const { errorHandler, notFound } = require("./middleware/errorHandler");
12
+ const logger = require("./utils/logger");
13
+
14
+ const authRoutes = require("./routes/auth.routes");
15
+ const courseRoutes = require("./routes/course.routes");
16
+ const orderRoutes = require("./routes/order.routes");
17
+ const adminRoutes = require("./routes/admin.routes");
18
+
19
+ const app = express();
20
+
21
+ // Trust the first proxy hop (Render/Heroku/Nginx/etc.) so req.ip and the
22
+ // "secure" cookie/HSTS logic reflect the real client over HTTPS.
23
+ app.set("trust proxy", 1);
24
+
25
+ // --- Security headers -------------------------------------------------
26
+ app.use(
27
+ helmet({
28
+ contentSecurityPolicy: {
29
+ directives: {
30
+ defaultSrc: ["'self'"],
31
+ scriptSrc: ["'self'"],
32
+ styleSrc: ["'self'", "'unsafe-inline'"],
33
+ imgSrc: ["'self'", "data:", "https:"],
34
+ objectSrc: ["'none'"],
35
+ frameAncestors: ["'none'"],
36
+ },
37
+ },
38
+ crossOriginResourcePolicy: { policy: "same-site" },
39
+ hsts: { maxAge: 63072000, includeSubDomains: true, preload: true },
40
+ })
41
+ );
42
+
43
+ // --- CORS: only the configured frontend origins may call the API.
44
+ // `credentials: false` is deliberate — this API is designed to run
45
+ // cross-site from its frontend (e.g. a Hugging Face Space backend +
46
+ // a separate Vercel-hosted frontend), and some hosting proxies in that
47
+ // kind of setup intercept CORS preflight requests before they reach
48
+ // this app and strip the Access-Control-Allow-Credentials header,
49
+ // which silently breaks anything relying on `credentials: include`.
50
+ // Sessions are carried via a bearer access token (Authorization
51
+ // header) plus a refresh token returned in the JSON response body
52
+ // instead of a cookie, so no cross-site credentialed request is ever
53
+ // needed — see controllers/auth.controller.js for the reasoning and
54
+ // trade-offs of that approach in more detail. ---
55
+ app.use(
56
+ cors({
57
+ origin(origin, callback) {
58
+ if (!origin || env.clientOrigins.includes(origin)) return callback(null, true);
59
+ // Logged (not just thrown) so a mismatched frontend domain shows up
60
+ // immediately in the server logs with the exact rejected value —
61
+ // compare this directly against the CORS allow-list logged at
62
+ // startup to spot typos, protocol mismatches, or a missing entry.
63
+ logger.warn(`CORS rejected origin: "${origin}" (allow-list: ${JSON.stringify(env.clientOrigins)})`);
64
+ return callback(new Error("Not allowed by CORS"));
65
+ },
66
+ credentials: false,
67
+ })
68
+ );
69
+
70
+ app.use(compression());
71
+ app.use(express.json({ limit: "100kb" })); // small limit: this API never needs large bodies
72
+ app.use(express.urlencoded({ extended: true, limit: "100kb" }));
73
+
74
+ // Strip any keys starting with "$" or containing "." from user input to
75
+ // block MongoDB operator-injection attacks.
76
+ app.use(mongoSanitize());
77
+ // Prevent HTTP parameter-pollution (e.g. ?role=user&role=admin).
78
+ app.use(hpp());
79
+
80
+ app.use(
81
+ morgan("combined", {
82
+ stream: { write: (message) => logger.info(message.trim()) },
83
+ skip: () => env.nodeEnv === "test",
84
+ })
85
+ );
86
+
87
+ app.use("/api/", apiLimiter);
88
+
89
+ app.get("/api/health", (req, res) => res.json({ status: "ok", time: new Date().toISOString() }));
90
+
91
+ app.use("/api/auth", authRoutes);
92
+ app.use("/api/courses", courseRoutes);
93
+ app.use("/api/orders", orderRoutes);
94
+ app.use("/api/admin", adminRoutes);
95
+
96
+ app.use(notFound);
97
+ app.use(errorHandler);
98
+
99
+ module.exports = app;
src/config/cloudinary.js CHANGED
@@ -1,11 +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;
 
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 CHANGED
@@ -1,44 +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 };
 
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 CHANGED
@@ -1,100 +1,100 @@
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
- "SMTP_HOST",
12
- "SMTP_USER",
13
- "SMTP_PASS",
14
- "CLOUDINARY_CLOUD_NAME",
15
- "CLOUDINARY_API_KEY",
16
- "CLOUDINARY_API_SECRET",
17
- ];
18
-
19
- const env = {
20
- nodeEnv: process.env.NODE_ENV || "development",
21
- port: parseInt(process.env.PORT, 10) || 5000,
22
- clientOrigins: (process.env.CLIENT_ORIGINS || "http://localhost:5173")
23
- .split(",")
24
- .map((s) => s.trim())
25
- .filter(Boolean),
26
-
27
- mongoUri: process.env.MONGODB_URI,
28
-
29
- jwtAccessSecret: process.env.JWT_ACCESS_SECRET,
30
- jwtRefreshSecret: process.env.JWT_REFRESH_SECRET,
31
- jwtAccessExpiresIn: process.env.JWT_ACCESS_EXPIRES_IN || "15m",
32
- jwtRefreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || "7d",
33
-
34
- fieldEncryptionKey: process.env.FIELD_ENCRYPTION_KEY,
35
-
36
- smtp: {
37
- host: process.env.SMTP_HOST,
38
- port: parseInt(process.env.SMTP_PORT, 10) || 587,
39
- secure: process.env.SMTP_SECURE === "true",
40
- user: process.env.SMTP_USER,
41
- pass: process.env.SMTP_PASS,
42
- },
43
- emailFrom: process.env.EMAIL_FROM || "Aynwerd Academy <no-reply@aynwerd.com>",
44
- adminNotifyEmail: process.env.ADMIN_NOTIFY_EMAIL,
45
-
46
- bank: {
47
- name: process.env.BANK_NAME || "",
48
- accountName: process.env.BANK_ACCOUNT_NAME || "",
49
- accountNumber: process.env.BANK_ACCOUNT_NUMBER || "",
50
- routingNumber: process.env.BANK_ROUTING_NUMBER || "",
51
- swiftBic: process.env.BANK_SWIFT_BIC || "",
52
- iban: process.env.BANK_IBAN || "",
53
- address: process.env.BANK_ADDRESS || "",
54
- },
55
- payoneer: {
56
- paymentUrl: process.env.PAYONEER_PAYMENT_URL || "https://www.payoneer.com/",
57
- accountEmail: process.env.PAYONEER_ACCOUNT_EMAIL || "",
58
- },
59
-
60
- seedAdmin: {
61
- name: process.env.SEED_ADMIN_NAME || "Site Admin",
62
- email: process.env.SEED_ADMIN_EMAIL,
63
- password: process.env.SEED_ADMIN_PASSWORD,
64
- },
65
-
66
- // Cloudinary: free-tier media storage used exclusively for admin-uploaded
67
- // course videos/documents. Files are uploaded with delivery type
68
- // "authenticated" so nothing is reachable without a freshly signed,
69
- // short-lived URL minted server-side for a verified, paying learner.
70
- cloudinary: {
71
- cloudName: process.env.CLOUDINARY_CLOUD_NAME,
72
- apiKey: process.env.CLOUDINARY_API_KEY,
73
- apiSecret: process.env.CLOUDINARY_API_SECRET,
74
- },
75
- };
76
-
77
- function assertProductionSafety() {
78
- if (env.nodeEnv !== "production") return;
79
-
80
- const missing = REQUIRED_IN_PRODUCTION.filter((key) => {
81
- const value = process.env[key];
82
- return !value || value.startsWith("replace_with_") || value.includes("<");
83
- });
84
-
85
- if (missing.length > 0) {
86
- // eslint-disable-next-line no-console
87
- console.error(
88
- `FATAL: refusing to start in production with missing/placeholder env vars: ${missing.join(", ")}`
89
- );
90
- process.exit(1);
91
- }
92
-
93
- if (Buffer.from(env.fieldEncryptionKey || "", "hex").length !== 32) {
94
- // eslint-disable-next-line no-console
95
- console.error("FATAL: FIELD_ENCRYPTION_KEY must be a 32-byte (64 hex character) value.");
96
- process.exit(1);
97
- }
98
- }
99
-
100
- module.exports = { env, assertProductionSafety };
 
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
+ "SMTP_HOST",
12
+ "SMTP_USER",
13
+ "SMTP_PASS",
14
+ "CLOUDINARY_CLOUD_NAME",
15
+ "CLOUDINARY_API_KEY",
16
+ "CLOUDINARY_API_SECRET",
17
+ ];
18
+
19
+ const env = {
20
+ nodeEnv: process.env.NODE_ENV || "development",
21
+ port: parseInt(process.env.PORT, 10) || 5000,
22
+ clientOrigins: (process.env.CLIENT_ORIGINS || "http://localhost:5173")
23
+ .split(",")
24
+ .map((s) => s.trim())
25
+ .filter(Boolean),
26
+
27
+ mongoUri: process.env.MONGODB_URI,
28
+
29
+ jwtAccessSecret: process.env.JWT_ACCESS_SECRET,
30
+ jwtRefreshSecret: process.env.JWT_REFRESH_SECRET,
31
+ jwtAccessExpiresIn: process.env.JWT_ACCESS_EXPIRES_IN || "15m",
32
+ jwtRefreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || "7d",
33
+
34
+ fieldEncryptionKey: process.env.FIELD_ENCRYPTION_KEY,
35
+
36
+ smtp: {
37
+ host: process.env.SMTP_HOST,
38
+ port: parseInt(process.env.SMTP_PORT, 10) || 587,
39
+ secure: process.env.SMTP_SECURE === "true",
40
+ user: process.env.SMTP_USER,
41
+ pass: process.env.SMTP_PASS,
42
+ },
43
+ emailFrom: process.env.EMAIL_FROM || "Aynwerd Academy <no-reply@aynwerd.com>",
44
+ adminNotifyEmail: process.env.ADMIN_NOTIFY_EMAIL,
45
+
46
+ bank: {
47
+ name: process.env.BANK_NAME || "",
48
+ accountName: process.env.BANK_ACCOUNT_NAME || "",
49
+ accountNumber: process.env.BANK_ACCOUNT_NUMBER || "",
50
+ routingNumber: process.env.BANK_ROUTING_NUMBER || "",
51
+ swiftBic: process.env.BANK_SWIFT_BIC || "",
52
+ iban: process.env.BANK_IBAN || "",
53
+ address: process.env.BANK_ADDRESS || "",
54
+ },
55
+ payoneer: {
56
+ paymentUrl: process.env.PAYONEER_PAYMENT_URL || "https://www.payoneer.com/",
57
+ accountEmail: process.env.PAYONEER_ACCOUNT_EMAIL || "",
58
+ },
59
+
60
+ seedAdmin: {
61
+ name: process.env.SEED_ADMIN_NAME || "Site Admin",
62
+ email: process.env.SEED_ADMIN_EMAIL,
63
+ password: process.env.SEED_ADMIN_PASSWORD,
64
+ },
65
+
66
+ // Cloudinary: free-tier media storage used exclusively for admin-uploaded
67
+ // course videos/documents. Files are uploaded with delivery type
68
+ // "authenticated" so nothing is reachable without a freshly signed,
69
+ // short-lived URL minted server-side for a verified, paying learner.
70
+ cloudinary: {
71
+ cloudName: process.env.CLOUDINARY_CLOUD_NAME,
72
+ apiKey: process.env.CLOUDINARY_API_KEY,
73
+ apiSecret: process.env.CLOUDINARY_API_SECRET,
74
+ },
75
+ };
76
+
77
+ function assertProductionSafety() {
78
+ if (env.nodeEnv !== "production") return;
79
+
80
+ const missing = REQUIRED_IN_PRODUCTION.filter((key) => {
81
+ const value = process.env[key];
82
+ return !value || value.startsWith("replace_with_") || value.includes("<");
83
+ });
84
+
85
+ if (missing.length > 0) {
86
+ // eslint-disable-next-line no-console
87
+ console.error(
88
+ `FATAL: refusing to start in production with missing/placeholder env vars: ${missing.join(", ")}`
89
+ );
90
+ process.exit(1);
91
+ }
92
+
93
+ if (Buffer.from(env.fieldEncryptionKey || "", "hex").length !== 32) {
94
+ // eslint-disable-next-line no-console
95
+ console.error("FATAL: FIELD_ENCRYPTION_KEY must be a 32-byte (64 hex character) value.");
96
+ process.exit(1);
97
+ }
98
+ }
99
+
100
+ module.exports = { env, assertProductionSafety };
src/controllers/admin.controller.js CHANGED
@@ -1,198 +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 };
 
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 CHANGED
@@ -1,193 +1,193 @@
1
- const User = require("../models/User");
2
- const AuditLog = require("../models/AuditLog");
3
- const { signAccessToken, signRefreshToken, verifyRefreshToken } = require("../utils/jwt");
4
- const logger = require("../utils/logger");
5
-
6
- const MAX_FAILED_ATTEMPTS = 5;
7
- const LOCK_DURATION_MS = 15 * 60 * 1000; // 15 minutes
8
- const REFRESH_TOKEN_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000;
9
-
10
- // --------------------------------------------------------------------
11
- // Refresh tokens are returned in the JSON response body (and stored by
12
- // the client in localStorage) rather than as an httpOnly cookie.
13
- //
14
- // Why: this API and its frontend are deployed on different domains
15
- // (e.g. a Hugging Face Space + a separate Vercel domain), and some
16
- // hosting proxies in that kind of cross-site setup intercept CORS
17
- // preflight requests before they reach the app and strip the
18
- // Access-Control-Allow-Credentials header — which silently breaks any
19
- // flow that depends on browsers sending a cross-site cookie. Avoiding
20
- // `credentials: include` entirely sidesteps that class of problem.
21
- //
22
- // The trade-off: an httpOnly cookie can never be read by JavaScript,
23
- // even from an XSS payload; a token sitting in localStorage can be.
24
- // We narrow that risk with what's still in our control — access
25
- // tokens stay short-lived (15 min) and refresh tokens rotate on every
26
- // use with reuse detection (see `refresh` below) — but this is
27
- // genuinely a lower security bar than the cookie-based approach, and
28
- // worth knowing if you migrate this API to a same-site deployment
29
- // later (at which point switching back to an httpOnly cookie is
30
- // recommended).
31
- // --------------------------------------------------------------------
32
-
33
- async function audit(action, req, extra = {}) {
34
- try {
35
- await AuditLog.create({
36
- actor: req.user ? req.user._id : null,
37
- actorEmail: req.user ? req.user.email : extra.email || null,
38
- action,
39
- ip: req.ip,
40
- userAgent: req.headers["user-agent"],
41
- metadata: extra.metadata || {},
42
- });
43
- } catch (err) {
44
- logger.error("Failed to write audit log", { action, error: err.message });
45
- }
46
- }
47
-
48
- function issueSession(user, req) {
49
- const accessToken = signAccessToken(user);
50
- const { token: refreshToken, jti } = signRefreshToken(user);
51
- user.activeRefreshTokens = (user.activeRefreshTokens || [])
52
- .filter((rt) => rt.expiresAt > new Date()) // prune expired sessions as we go
53
- .concat({
54
- jti,
55
- expiresAt: new Date(Date.now() + REFRESH_TOKEN_LIFETIME_MS),
56
- userAgent: req.headers["user-agent"],
57
- ip: req.ip,
58
- });
59
- return { accessToken, refreshToken };
60
- }
61
-
62
- async function signup(req, res, next) {
63
- try {
64
- const { name, email, password } = req.body;
65
-
66
- const existing = await User.findOne({ email: email.toLowerCase() });
67
- if (existing) {
68
- // Do not reveal which part of the credential pair is wrong to
69
- // avoid account enumeration.
70
- return res.status(409).json({ error: "Unable to create account with the details provided." });
71
- }
72
-
73
- const user = new User({ name, email: email.toLowerCase() });
74
- await user.setPassword(password);
75
- await user.save();
76
-
77
- await audit("user.signup", req, { email: user.email });
78
-
79
- const { accessToken, refreshToken } = issueSession(user, req);
80
- await user.save();
81
-
82
- res.status(201).json({ user: user.toSafeJSON(), accessToken, refreshToken });
83
- } catch (err) {
84
- next(err);
85
- }
86
- }
87
-
88
- async function login(req, res, next) {
89
- try {
90
- const { email, password } = req.body;
91
-
92
- const user = await User.findOne({ email: email.toLowerCase() }).select(
93
- "+passwordHash +failedLoginAttempts +lockUntil +activeRefreshTokens"
94
- );
95
-
96
- // Constant-shaped response whether the user exists or not.
97
- const genericError = () => res.status(401).json({ error: "Invalid email or password." });
98
-
99
- if (!user || !user.isActive) return genericError();
100
-
101
- if (user.isLocked()) {
102
- return res.status(423).json({
103
- error: "Account temporarily locked due to repeated failed attempts. Try again later.",
104
- });
105
- }
106
-
107
- const valid = await user.comparePassword(password);
108
- if (!valid) {
109
- user.failedLoginAttempts = (user.failedLoginAttempts || 0) + 1;
110
- if (user.failedLoginAttempts >= MAX_FAILED_ATTEMPTS) {
111
- user.lockUntil = new Date(Date.now() + LOCK_DURATION_MS);
112
- user.failedLoginAttempts = 0;
113
- await audit("user.login.locked", req, { email: user.email });
114
- }
115
- await user.save();
116
- return genericError();
117
- }
118
-
119
- user.failedLoginAttempts = 0;
120
- user.lockUntil = null;
121
-
122
- const { accessToken, refreshToken } = issueSession(user, req);
123
- await user.save();
124
-
125
- await audit("user.login", req, { email: user.email });
126
-
127
- res.json({ user: user.toSafeJSON(), accessToken, refreshToken });
128
- } catch (err) {
129
- next(err);
130
- }
131
- }
132
-
133
- async function refresh(req, res, next) {
134
- try {
135
- const { refreshToken: token } = req.body;
136
- if (!token) return res.status(401).json({ error: "No refresh token provided." });
137
-
138
- let payload;
139
- try {
140
- payload = verifyRefreshToken(token);
141
- } catch {
142
- return res.status(401).json({ error: "Invalid or expired session, please log in again." });
143
- }
144
-
145
- const user = await User.findById(payload.sub).select("+activeRefreshTokens");
146
- if (!user || !user.isActive || user.tokenVersion !== payload.tokenVersion) {
147
- return res.status(401).json({ error: "Session no longer valid, please log in again." });
148
- }
149
-
150
- const stillActive = (user.activeRefreshTokens || []).some((rt) => rt.jti === payload.jti);
151
- if (!stillActive) {
152
- // Possible reuse of an already-rotated (or logged-out) refresh
153
- // token — deny it. As a precaution, also revoke every other
154
- // active session for this account, since a replayed token is a
155
- // signal the original may have leaked.
156
- user.activeRefreshTokens = [];
157
- await user.save();
158
- return res.status(401).json({ error: "Session no longer valid, please log in again." });
159
- }
160
-
161
- // Rotate: remove old jti, issue a new refresh token.
162
- user.activeRefreshTokens = user.activeRefreshTokens.filter((rt) => rt.jti !== payload.jti);
163
- const { accessToken, refreshToken: newRefreshToken } = issueSession(user, req);
164
- await user.save();
165
-
166
- res.json({ accessToken, refreshToken: newRefreshToken, user: user.toSafeJSON() });
167
- } catch (err) {
168
- next(err);
169
- }
170
- }
171
-
172
- async function logout(req, res, next) {
173
- try {
174
- const { refreshToken: token } = req.body;
175
- if (token) {
176
- try {
177
- const payload = verifyRefreshToken(token);
178
- await User.findByIdAndUpdate(payload.sub, { $pull: { activeRefreshTokens: { jti: payload.jti } } });
179
- } catch {
180
- // token already invalid — nothing to revoke
181
- }
182
- }
183
- res.json({ success: true });
184
- } catch (err) {
185
- next(err);
186
- }
187
- }
188
-
189
- async function me(req, res) {
190
- res.json({ user: req.user.toSafeJSON() });
191
- }
192
-
193
- module.exports = { signup, login, refresh, logout, me };
 
1
+ const User = require("../models/User");
2
+ const AuditLog = require("../models/AuditLog");
3
+ const { signAccessToken, signRefreshToken, verifyRefreshToken } = require("../utils/jwt");
4
+ const logger = require("../utils/logger");
5
+
6
+ const MAX_FAILED_ATTEMPTS = 5;
7
+ const LOCK_DURATION_MS = 15 * 60 * 1000; // 15 minutes
8
+ const REFRESH_TOKEN_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000;
9
+
10
+ // --------------------------------------------------------------------
11
+ // Refresh tokens are returned in the JSON response body (and stored by
12
+ // the client in localStorage) rather than as an httpOnly cookie.
13
+ //
14
+ // Why: this API and its frontend are deployed on different domains
15
+ // (e.g. a Hugging Face Space + a separate Vercel domain), and some
16
+ // hosting proxies in that kind of cross-site setup intercept CORS
17
+ // preflight requests before they reach the app and strip the
18
+ // Access-Control-Allow-Credentials header — which silently breaks any
19
+ // flow that depends on browsers sending a cross-site cookie. Avoiding
20
+ // `credentials: include` entirely sidesteps that class of problem.
21
+ //
22
+ // The trade-off: an httpOnly cookie can never be read by JavaScript,
23
+ // even from an XSS payload; a token sitting in localStorage can be.
24
+ // We narrow that risk with what's still in our control — access
25
+ // tokens stay short-lived (15 min) and refresh tokens rotate on every
26
+ // use with reuse detection (see `refresh` below) — but this is
27
+ // genuinely a lower security bar than the cookie-based approach, and
28
+ // worth knowing if you migrate this API to a same-site deployment
29
+ // later (at which point switching back to an httpOnly cookie is
30
+ // recommended).
31
+ // --------------------------------------------------------------------
32
+
33
+ async function audit(action, req, extra = {}) {
34
+ try {
35
+ await AuditLog.create({
36
+ actor: req.user ? req.user._id : null,
37
+ actorEmail: req.user ? req.user.email : extra.email || null,
38
+ action,
39
+ ip: req.ip,
40
+ userAgent: req.headers["user-agent"],
41
+ metadata: extra.metadata || {},
42
+ });
43
+ } catch (err) {
44
+ logger.error("Failed to write audit log", { action, error: err.message });
45
+ }
46
+ }
47
+
48
+ function issueSession(user, req) {
49
+ const accessToken = signAccessToken(user);
50
+ const { token: refreshToken, jti } = signRefreshToken(user);
51
+ user.activeRefreshTokens = (user.activeRefreshTokens || [])
52
+ .filter((rt) => rt.expiresAt > new Date()) // prune expired sessions as we go
53
+ .concat({
54
+ jti,
55
+ expiresAt: new Date(Date.now() + REFRESH_TOKEN_LIFETIME_MS),
56
+ userAgent: req.headers["user-agent"],
57
+ ip: req.ip,
58
+ });
59
+ return { accessToken, refreshToken };
60
+ }
61
+
62
+ async function signup(req, res, next) {
63
+ try {
64
+ const { name, email, password } = req.body;
65
+
66
+ const existing = await User.findOne({ email: email.toLowerCase() });
67
+ if (existing) {
68
+ // Do not reveal which part of the credential pair is wrong to
69
+ // avoid account enumeration.
70
+ return res.status(409).json({ error: "Unable to create account with the details provided." });
71
+ }
72
+
73
+ const user = new User({ name, email: email.toLowerCase() });
74
+ await user.setPassword(password);
75
+ await user.save();
76
+
77
+ await audit("user.signup", req, { email: user.email });
78
+
79
+ const { accessToken, refreshToken } = issueSession(user, req);
80
+ await user.save();
81
+
82
+ res.status(201).json({ user: user.toSafeJSON(), accessToken, refreshToken });
83
+ } catch (err) {
84
+ next(err);
85
+ }
86
+ }
87
+
88
+ async function login(req, res, next) {
89
+ try {
90
+ const { email, password } = req.body;
91
+
92
+ const user = await User.findOne({ email: email.toLowerCase() }).select(
93
+ "+passwordHash +failedLoginAttempts +lockUntil +activeRefreshTokens"
94
+ );
95
+
96
+ // Constant-shaped response whether the user exists or not.
97
+ const genericError = () => res.status(401).json({ error: "Invalid email or password." });
98
+
99
+ if (!user || !user.isActive) return genericError();
100
+
101
+ if (user.isLocked()) {
102
+ return res.status(423).json({
103
+ error: "Account temporarily locked due to repeated failed attempts. Try again later.",
104
+ });
105
+ }
106
+
107
+ const valid = await user.comparePassword(password);
108
+ if (!valid) {
109
+ user.failedLoginAttempts = (user.failedLoginAttempts || 0) + 1;
110
+ if (user.failedLoginAttempts >= MAX_FAILED_ATTEMPTS) {
111
+ user.lockUntil = new Date(Date.now() + LOCK_DURATION_MS);
112
+ user.failedLoginAttempts = 0;
113
+ await audit("user.login.locked", req, { email: user.email });
114
+ }
115
+ await user.save();
116
+ return genericError();
117
+ }
118
+
119
+ user.failedLoginAttempts = 0;
120
+ user.lockUntil = null;
121
+
122
+ const { accessToken, refreshToken } = issueSession(user, req);
123
+ await user.save();
124
+
125
+ await audit("user.login", req, { email: user.email });
126
+
127
+ res.json({ user: user.toSafeJSON(), accessToken, refreshToken });
128
+ } catch (err) {
129
+ next(err);
130
+ }
131
+ }
132
+
133
+ async function refresh(req, res, next) {
134
+ try {
135
+ const { refreshToken: token } = req.body;
136
+ if (!token) return res.status(401).json({ error: "No refresh token provided." });
137
+
138
+ let payload;
139
+ try {
140
+ payload = verifyRefreshToken(token);
141
+ } catch {
142
+ return res.status(401).json({ error: "Invalid or expired session, please log in again." });
143
+ }
144
+
145
+ const user = await User.findById(payload.sub).select("+activeRefreshTokens");
146
+ if (!user || !user.isActive || user.tokenVersion !== payload.tokenVersion) {
147
+ return res.status(401).json({ error: "Session no longer valid, please log in again." });
148
+ }
149
+
150
+ const stillActive = (user.activeRefreshTokens || []).some((rt) => rt.jti === payload.jti);
151
+ if (!stillActive) {
152
+ // Possible reuse of an already-rotated (or logged-out) refresh
153
+ // token — deny it. As a precaution, also revoke every other
154
+ // active session for this account, since a replayed token is a
155
+ // signal the original may have leaked.
156
+ user.activeRefreshTokens = [];
157
+ await user.save();
158
+ return res.status(401).json({ error: "Session no longer valid, please log in again." });
159
+ }
160
+
161
+ // Rotate: remove old jti, issue a new refresh token.
162
+ user.activeRefreshTokens = user.activeRefreshTokens.filter((rt) => rt.jti !== payload.jti);
163
+ const { accessToken, refreshToken: newRefreshToken } = issueSession(user, req);
164
+ await user.save();
165
+
166
+ res.json({ accessToken, refreshToken: newRefreshToken, user: user.toSafeJSON() });
167
+ } catch (err) {
168
+ next(err);
169
+ }
170
+ }
171
+
172
+ async function logout(req, res, next) {
173
+ try {
174
+ const { refreshToken: token } = req.body;
175
+ if (token) {
176
+ try {
177
+ const payload = verifyRefreshToken(token);
178
+ await User.findByIdAndUpdate(payload.sub, { $pull: { activeRefreshTokens: { jti: payload.jti } } });
179
+ } catch {
180
+ // token already invalid — nothing to revoke
181
+ }
182
+ }
183
+ res.json({ success: true });
184
+ } catch (err) {
185
+ next(err);
186
+ }
187
+ }
188
+
189
+ async function me(req, res) {
190
+ res.json({ user: req.user.toSafeJSON() });
191
+ }
192
+
193
+ module.exports = { signup, login, refresh, logout, me };
src/controllers/content.controller.js CHANGED
@@ -1,278 +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
- };
 
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 CHANGED
@@ -1,56 +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 };
 
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 CHANGED
@@ -1,161 +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 };
 
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 CHANGED
@@ -1,45 +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 };
 
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 CHANGED
@@ -1,33 +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 };
 
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 CHANGED
@@ -1,31 +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 };
 
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 CHANGED
@@ -1,50 +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 };
 
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 CHANGED
@@ -1,14 +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 };
 
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 CHANGED
@@ -1,21 +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);
 
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 CHANGED
@@ -1,104 +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
-
 
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 CHANGED
@@ -1,81 +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;
 
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 CHANGED
@@ -1,88 +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);
 
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 CHANGED
@@ -1,49 +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
-
 
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 CHANGED
@@ -1,50 +1,50 @@
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(
42
- "/refresh",
43
- [body("refreshToken").notEmpty().withMessage("A refresh token is required.")],
44
- validate,
45
- ctrl.refresh
46
- );
47
- router.post("/logout", [body("refreshToken").optional({ nullable: true }).isString()], validate, ctrl.logout);
48
- router.get("/me", requireAuth, ctrl.me);
49
-
50
- module.exports = router;
 
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(
42
+ "/refresh",
43
+ [body("refreshToken").notEmpty().withMessage("A refresh token is required.")],
44
+ validate,
45
+ ctrl.refresh
46
+ );
47
+ router.post("/logout", [body("refreshToken").optional({ nullable: true }).isString()], validate, ctrl.logout);
48
+ router.get("/me", requireAuth, ctrl.me);
49
+
50
+ module.exports = router;
src/routes/course.routes.js CHANGED
@@ -1,33 +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
-
 
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 CHANGED
@@ -1,40 +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;
 
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 CHANGED
@@ -1,128 +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
- });
 
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 CHANGED
@@ -1,53 +1,53 @@
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
- // Logged deliberately at startup: this is the #1 source of CORS
16
- // support confusion (a typo'd env var name, stray quotes, a
17
- // trailing slash, or the value falling back to its localhost
18
- // default because it was never actually set). Seeing the parsed
19
- // array here removes all guesswork versus staring at the raw
20
- // dashboard value.
21
- logger.info(`CORS allow-list (CLIENT_ORIGINS parsed as): ${JSON.stringify(env.clientOrigins)}`);
22
- });
23
- } catch (err) {
24
- logger.error("Failed to start server", { error: err.message });
25
- process.exit(1);
26
- }
27
- }
28
-
29
- async function shutdown(signal) {
30
- logger.info(`Received ${signal}, shutting down gracefully...`);
31
- if (server) {
32
- server.close(async () => {
33
- await disconnectDB();
34
- process.exit(0);
35
- });
36
- // Force-exit if something hangs
37
- setTimeout(() => process.exit(1), 10000).unref();
38
- } else {
39
- process.exit(0);
40
- }
41
- }
42
-
43
- process.on("SIGINT", () => shutdown("SIGINT"));
44
- process.on("SIGTERM", () => shutdown("SIGTERM"));
45
- process.on("unhandledRejection", (reason) => {
46
- logger.error("Unhandled promise rejection", { reason: reason?.message || reason });
47
- });
48
- process.on("uncaughtException", (err) => {
49
- logger.error("Uncaught exception", { error: err.message, stack: err.stack });
50
- process.exit(1);
51
- });
52
-
53
- start();
 
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
+ // Logged deliberately at startup: this is the #1 source of CORS
16
+ // support confusion (a typo'd env var name, stray quotes, a
17
+ // trailing slash, or the value falling back to its localhost
18
+ // default because it was never actually set). Seeing the parsed
19
+ // array here removes all guesswork versus staring at the raw
20
+ // dashboard value.
21
+ logger.info(`CORS allow-list (CLIENT_ORIGINS parsed as): ${JSON.stringify(env.clientOrigins)}`);
22
+ });
23
+ } catch (err) {
24
+ logger.error("Failed to start server", { error: err.message });
25
+ process.exit(1);
26
+ }
27
+ }
28
+
29
+ async function shutdown(signal) {
30
+ logger.info(`Received ${signal}, shutting down gracefully...`);
31
+ if (server) {
32
+ server.close(async () => {
33
+ await disconnectDB();
34
+ process.exit(0);
35
+ });
36
+ // Force-exit if something hangs
37
+ setTimeout(() => process.exit(1), 10000).unref();
38
+ } else {
39
+ process.exit(0);
40
+ }
41
+ }
42
+
43
+ process.on("SIGINT", () => shutdown("SIGINT"));
44
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
45
+ process.on("unhandledRejection", (reason) => {
46
+ logger.error("Unhandled promise rejection", { reason: reason?.message || reason });
47
+ });
48
+ process.on("uncaughtException", (err) => {
49
+ logger.error("Uncaught exception", { error: err.message, stack: err.stack });
50
+ process.exit(1);
51
+ });
52
+
53
+ start();
src/utils/cloudinaryUpload.js CHANGED
@@ -1,61 +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 };
 
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 CHANGED
@@ -1,56 +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 };
 
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 CHANGED
@@ -1,147 +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
- };
 
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 CHANGED
@@ -1,33 +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 };
 
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 CHANGED
@@ -1,29 +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;
 
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 CHANGED
@@ -1,15 +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 };
 
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 };