diff --git a/.env.example b/.env.example index 7cf7c41..1ad9a82 100644 --- a/.env.example +++ b/.env.example @@ -20,12 +20,11 @@ CLOUDINARY_URL=cloudinary://api_key:api_secret@cloud_name # EmailJS configuration for sending emails EMAILJS_API_URL=https://api.emailjs.com/api/v1.0/email/send -EMAILJS_PRIVATE_KEY=your_private_key -EMAILJS_PUBLIC_KEY=your_public_key -EMAILJS_SERVICE_ID=your_service_id -EMAILJS_TEMPLATE_ID=your_template_id - -# Stellar blockchain network (testnet or mainnet) +EMAILJS_PRIVATE_KEY=your_emailjs_private_key +EMAILJS_PUBLIC_KEY=your_emailjs_public_key +EMAILJS_SERVICE_ID=your_emailjs_service_id +EMAILJS_TEMPLATE_ID=your_emailjs_template_id +EMAILJS_RECEIPT_TEMPLATE_ID=your_emailjs_receipt_template_id STELLAR_NETWORK=testnet # Resilient Horizon Client Configuration (Optional) @@ -46,20 +45,8 @@ PLATFORM_WALLET_PUBLIC_KEY=GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX ACCESS_TOKEN_TTL=15m REFRESH_TOKEN_TTL=30d -# Redis Configuration (optional - app works without Redis but with reduced performance) -# Option 1: Use REDIS_URL for full connection string (recommended for cloud services) -# REDIS_URL=redis://username:password@host:port - -# Option 2: Use separate credentials -REDIS_HOST=localhost -REDIS_PORT=6379 -# REDIS_USERNAME=default -# REDIS_PASSWORD=your_password +# Durable Mongo-backed background jobs (tests use inline) +JOBS_ENABLED=true +QUEUE_DRIVER=mongo +JOBS_DASHBOARD_TOKEN=replace_with_a_long_random_token -# Jitsi configuration for video calls (optional) -# JITSI_MEET_DOMAIN=your_jitsi_domain -# JITSI_APP_ID=your_app_id -# JITSI_PRIVATE_KEY=your_private_key -# JITSI_PUBLIC_KEY_ID=your_public_key_id -# JITSI_KID=your_kid -# JITSI_TENANT=your_tenant diff --git a/README.md b/README.md index 8092f99..36aca37 100644 --- a/README.md +++ b/README.md @@ -78,9 +78,22 @@ The API runs at `http://localhost:5000`. | `JWT_SECRET` | Secret for signing tokens (32+ chars) | | `STELLAR_NETWORK` | `testnet` or `mainnet` | | `CLOUDINARY_*` | Cloudinary credentials for media uploads | +| `QUEUE_DRIVER` | `mongo` (durable production default) or `inline` (tests/CI) | +| `JOBS_ENABLED` | Start background workers; defaults to `true` | +| `JOBS_DASHBOARD_TOKEN` | Bearer token protecting `/admin/jobs` | See `.env.example` for the full list. +### Background jobs + +Slow email and Stellar verification work uses the thin `src/jobs/queue.js` +abstraction. The production `mongo` driver persists jobs in the mandatory +MongoDB deployment, so work and idempotency keys survive restarts without +making optional Redis a new requirement. The `inline` driver uses the same +handlers in tests and CI. Jobs retry with exponential backoff and jitter, +terminal failures are queryable at `/admin/jobs/dead`, and the token-protected +dashboard is available at `/admin/jobs`. + ### Scripts | Command | Purpose | @@ -89,6 +102,7 @@ See `.env.example` for the full list. | `npm start` | Start in production mode | | `npm test` | Run the Jest + Supertest suite | | `npm run seed` | Seed sample data | +| `npm run seed:categories` | Idempotent migration/seed script to populate initial categories based on current course categories | ## 🔗 API Overview diff --git a/app.js b/app.js index 6dd44dc..925c2ad 100644 --- a/app.js +++ b/app.js @@ -4,6 +4,7 @@ import cookieParser from "cookie-parser"; import compression from "compression"; import dotenv from "dotenv"; import crypto from "crypto"; +import "./src/jobs/handlers.js"; dotenv.config(); @@ -43,6 +44,8 @@ import stellarWalletRoutes from "./src/routes/stellar/walletRoutes.js"; import stellarPaymentRoutes from "./src/routes/stellar/paymentRoutes.js"; import stellarDonationRoutes from "./src/routes/stellar/donationRoutes.js"; import payoutRoutes from "./src/routes/payoutRoutes.js"; +import jobsRoutes from "./src/routes/jobsRoutes.js"; +import categoryRoutes from "./src/routes/categoryRoutes.js"; handleUncaughtException(); validateEnv(); @@ -165,6 +168,7 @@ app.use("/api", apiLimiter); app.use("/api/auth", authLimiter, authRoutes); // Other API routes +app.use("/api/categories", categoryRoutes); app.use("/api/courses", courseRoutes); app.use("/api/reels", reelsRoute); app.use("/api/books", bookRoutes); @@ -179,6 +183,7 @@ app.use("/api/stellar/wallet", stellarWalletRoutes); app.use("/api/stellar/payment", stellarPaymentRoutes); app.use("/api/stellar/donation", stellarDonationRoutes); app.use("/api/payouts", payoutRoutes); +app.use("/admin/jobs", jobsRoutes); // ====================== // ERROR HANDLING diff --git a/package.json b/package.json index 82ca065..4a62b99 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --forceExit", "start": "node server.js", "dev": "nodemon server.js", - "seed": "node src/scripts/seedDatabase.js", + "seed:categories": "node src/scripts/seedCategories.js", "test-redis": "node test-redis.js", "payouts:audit": "node src/scripts/auditPayouts.js" }, diff --git a/server.js b/server.js index c60f0bb..7a84801 100644 --- a/server.js +++ b/server.js @@ -1,6 +1,8 @@ import app from "./app.js"; import logger from "./src/config/logger.js"; import { initRedis, closeRedis } from "./src/config/redis.js"; +import { startJobs, stopJobs } from "./src/jobs/queue.js"; +import "./src/jobs/handlers.js"; const PORT = process.env.PORT || 5000; @@ -18,6 +20,8 @@ const server = app.listen(PORT, () => { logger.info(`Process ID: ${process.pid}`); }); +startJobs().catch((err) => logger.error(err, "Background job startup failed")); + // Graceful shutdown const gracefulShutdown = async (signal) => { logger.info(`${signal} received. Starting graceful shutdown...`); @@ -25,6 +29,8 @@ const gracefulShutdown = async (signal) => { server.close(async () => { logger.info("HTTP server closed"); + await stopJobs(); + // Close Redis connection await closeRedis(); diff --git a/services/emails/sendMail.js b/services/emails/sendMail.js index 30b19df..2558e5e 100644 --- a/services/emails/sendMail.js +++ b/services/emails/sendMail.js @@ -1,26 +1,27 @@ import axios from "axios"; import logger from "../../src/config/logger.js"; -const EMAILJS_SERVICE_ID = process.env.EMAILJS_SERVICE_ID || "service_5cyu19r"; -const EMAILJS_TEMPLATE_ID = process.env.EMAILJS_TEMPLATE_ID || "template_ph4f0rl"; -const EMAILJS_PRIVATE_KEY = process.env.EMAILJS_PRIVATE_KEY || "xsR_t0uapauk8d_6Tk0Y9"; -const EMAILJS_PUBLIC_KEY = process.env.EMAILJS_PUBLIC_KEY || "p_hFoYPb6o0w7206-"; +const EMAILJS_SERVICE_ID = process.env.EMAILJS_SERVICE_ID; +const EMAILJS_TEMPLATE_ID = process.env.EMAILJS_TEMPLATE_ID; +const EMAILJS_RECEIPT_TEMPLATE_ID = process.env.EMAILJS_RECEIPT_TEMPLATE_ID || EMAILJS_TEMPLATE_ID; +const EMAILJS_PRIVATE_KEY = process.env.EMAILJS_PRIVATE_KEY; +const EMAILJS_PUBLIC_KEY = process.env.EMAILJS_PUBLIC_KEY; const EMAILJS_API_URL = process.env.EMAILJS_API_URL || "https://api.emailjs.com/api/v1.0/email/send"; -const sendMail = async (otp, email) => { - if (!email || !otp) { - throw new Error("Email and OTP are required to send the email."); +const sendTemplate = async (templateId, email, templateParams) => { + if ( + process.env.NODE_ENV !== "test" && + (!EMAILJS_SERVICE_ID || !templateId || !EMAILJS_PUBLIC_KEY) + ) { + throw new Error("EmailJS environment variables are not configured"); } - logger.info(`Sending OTP email to: ${email}`); try { const response = await axios.post(EMAILJS_API_URL, { service_id: EMAILJS_SERVICE_ID, - template_id: EMAILJS_TEMPLATE_ID, + template_id: templateId, user_id: EMAILJS_PUBLIC_KEY, - template_params: { - "otp": otp, - "email": email, - }, + accessToken: EMAILJS_PRIVATE_KEY, + template_params: { ...templateParams, email }, }); return { status: response.status, text: response.statusText }; } catch (error) { @@ -29,4 +30,18 @@ const sendMail = async (otp, email) => { } }; +export const sendOtpEmail = async (otp, email) => { + if (!email || !otp) throw new Error("Email and OTP are required to send the email."); + logger.info(`Sending OTP email to: ${email}`); + return sendTemplate(EMAILJS_TEMPLATE_ID, email, { otp }); +}; + +export const sendReceiptEmail = async (receipt) => { + if (!receipt.email || !receipt.txHash) throw new Error("Receipt email and transaction hash are required"); + logger.info(`Sending receipt for ${receipt.txHash} to: ${receipt.email}`); + return sendTemplate(EMAILJS_RECEIPT_TEMPLATE_ID, receipt.email, receipt); +}; + +const sendMail = sendOtpEmail; + export default sendMail; diff --git a/src/config/validateEnv.js b/src/config/validateEnv.js index 8537e28..9bfcd97 100644 --- a/src/config/validateEnv.js +++ b/src/config/validateEnv.js @@ -24,17 +24,10 @@ const optionalEnvVars = [ "PAYOUT_ADMIN_USER_IDS", "ACCESS_TOKEN_TTL", "REFRESH_TOKEN_TTL", - // Redis configuration (optional - app works without Redis) - "REDIS_URL", - "REDIS_HOST", - "REDIS_PORT", - "REDIS_USERNAME", - "REDIS_PASSWORD", - "HORIZON_URLS", - "HORIZON_TIMEOUT_MS", - "HORIZON_MAX_RETRIES", - "HORIZON_CB_THRESHOLD", - "HORIZON_CB_COOLDOWN_MS", + "QUEUE_DRIVER", + "JOBS_ENABLED", + "JOBS_DASHBOARD_TOKEN", + "EMAILJS_RECEIPT_TEMPLATE_ID", ]; export const validateEnv = () => { diff --git a/src/controllers/authController.js b/src/controllers/authController.js index 388a999..0dda67d 100644 --- a/src/controllers/authController.js +++ b/src/controllers/authController.js @@ -7,6 +7,7 @@ import Session from "../models/Session.js"; import sendMail from "../../services/emails/sendMail.js"; import { generatedOtp } from "../routes/emailRoutes.js"; import logger from "../config/logger.js"; +import { enqueue } from "../jobs/queue.js"; import { catchAsync, APIError } from "../middlewares/errorHandler.js"; @@ -111,14 +112,6 @@ export const registerUser = catchAsync(async (req, res, next) => { return next(new APIError("Email already exists", 400)); } - // Send OTP email - try { - await sendMail(generatedOtp, email); - logger.info(`📧 OTP sent to: ${email}`); - } catch (error) { - logger.error(`Email sending failed for: ${email}`, error); - } - // Hash password const hashedPassword = await bcrypt.hash(password, 12); @@ -130,6 +123,12 @@ export const registerUser = catchAsync(async (req, res, next) => { role: role || "student", }); + await enqueue( + "sendOtpEmail", + { userId: user._id.toString(), otp: generatedOtp }, + { attempts: 5, backoffMs: 1000, idempotencyKey: `otp:${user._id}:${generatedOtp}` } + ); + logger.info(`✅ User registered successfully: ${email} (ID: ${user._id})`); // Generate session and tokens diff --git a/src/controllers/categoryController.js b/src/controllers/categoryController.js new file mode 100644 index 0000000..078b78e --- /dev/null +++ b/src/controllers/categoryController.js @@ -0,0 +1,214 @@ +import mongoose from 'mongoose'; +import Category from '../models/Category.js'; +import Course from '../models/Course.js'; +import { catchAsync } from '../middlewares/errorHandler.js'; + +export const getCategories = catchAsync(async (req, res) => { + const categories = await Category.aggregate([ + { $match: { isActive: true } }, + { + $lookup: { + from: 'courses', + localField: '_id', + foreignField: 'categoryRef', + as: 'courses' + } + }, + { + $project: { + name: 1, + slug: 1, + description: 1, + icon: 1, + parent: 1, + order: 1, + isActive: 1, + createdAt: 1, + updatedAt: 1, + courseCount: { $size: '$courses' }, + enrollmentCount: { + $sum: { + $map: { + input: '$courses', + as: 'course', + in: { $size: { $ifNull: ['$$course.enrolledUsers', []] } } + } + } + }, + freeCount: { + $size: { + $filter: { + input: '$courses', + as: 'course', + cond: { $lte: [{ $ifNull: ['$$course.price', 0] }, 0] } + } + } + }, + paidCount: { + $size: { + $filter: { + input: '$courses', + as: 'course', + cond: { $gt: [{ $ifNull: ['$$course.price', 0] }, 0] } + } + } + }, + minPrice: { + $min: { + $filter: { + input: { + $map: { + input: '$courses', + as: 'course', + in: { $ifNull: ['$$course.price', 0] } + } + }, + as: 'price', + cond: { $gt: ['$$price', 0] } + } + } + }, + maxPrice: { + $max: { + $map: { + input: '$courses', + as: 'course', + in: { $ifNull: ['$$course.price', 0] } + } + } + } + } + }, + { $sort: { order: 1, name: 1 } } + ]); + + res.status(200).json({ success: true, categories }); +}); + +export const getCategoryBySlug = catchAsync(async (req, res) => { + const { slug } = req.params; + const page = parseInt(req.query.page) || 1; + const limit = parseInt(req.query.limit) || 10; + const sortParam = req.query.sort || 'newest'; + + const category = await Category.findOne({ slug, isActive: true }).lean(); + + if (!category) { + return res.status(404).json({ success: false, message: 'Category not found' }); + } + + const skip = (page - 1) * limit; + let courses = []; + + if (sortParam === 'popular') { + courses = await Course.aggregate([ + { $match: { categoryRef: category._id } }, + { $addFields: { enrollCount: { $size: { $ifNull: ['$enrolledUsers', []] } } } }, + { $sort: { enrollCount: -1, createdAt: -1 } }, + { $skip: skip }, + { $limit: limit }, + { + $lookup: { + from: 'users', + localField: 'createdBy', + foreignField: '_id', + as: 'createdBy' + } + }, + { $unwind: { path: '$createdBy', preserveNullAndEmptyArrays: true } } + ]); + + courses = courses.map(c => { + if (c.createdBy) { + c.createdBy = { + _id: c.createdBy._id, + name: c.createdBy.name, + avatar: c.createdBy.avatar + }; + } + return c; + }); + } else { + let sort = { createdAt: -1 }; + if (sortParam === 'price') sort = { price: 1 }; + + courses = await Course.find({ categoryRef: category._id }) + .sort(sort) + .skip(skip) + .limit(limit) + .populate('createdBy', 'name avatar') + .lean(); + } + + const total = await Course.countDocuments({ categoryRef: category._id }); + + res.status(200).json({ + success: true, + category, + courses, + pagination: { + page, + limit, + total, + pages: Math.ceil(total / limit) + } + }); +}); + +export const createCategory = catchAsync(async (req, res) => { + const { name, description, icon, parent, order, isActive } = req.body; + if (!name) { + return res.status(400).json({ success: false, message: 'Name is required' }); + } + + const category = await Category.create({ name, description, icon, parent, order, isActive }); + res.status(201).json({ success: true, category }); +}); + +export const updateCategory = catchAsync(async (req, res) => { + const { name, slug, description, icon, parent, order, isActive } = req.body; + + const category = await Category.findById(req.params.id); + if (!category) { + return res.status(404).json({ success: false, message: 'Category not found' }); + } + + if (name !== undefined) category.name = name; + if (slug !== undefined) category.slug = slug; + if (description !== undefined) category.description = description; + if (icon !== undefined) category.icon = icon; + if (parent !== undefined) category.parent = parent; + if (order !== undefined) category.order = order; + if (isActive !== undefined) category.isActive = isActive; + + await category.save(); + + res.status(200).json({ success: true, category }); +}); + +export const deleteCategory = catchAsync(async (req, res) => { + const categoryId = req.params.id; + const category = await Category.findById(categoryId); + + if (!category) { + return res.status(404).json({ success: false, message: 'Category not found' }); + } + + const coursesCount = await Course.countDocuments({ categoryRef: categoryId }); + + if (coursesCount > 0) { + category.isActive = false; + await category.save(); + return res.status(200).json({ + success: true, + message: 'Category soft-deleted because it has associated courses', + category + }); + } else { + await Category.deleteOne({ _id: categoryId }); + return res.status(200).json({ + success: true, + message: 'Category hard-deleted because it has no associated courses' + }); + } +}); diff --git a/src/controllers/courses/courseController.js b/src/controllers/courses/courseController.js index d783da6..f671029 100644 --- a/src/controllers/courses/courseController.js +++ b/src/controllers/courses/courseController.js @@ -1,4 +1,5 @@ import Course from "../../models/Course.js"; +import Category from "../../models/Category.js"; import mongoose from "mongoose"; import logger from "../../config/logger.js"; import { catchAsync, APIError } from "../../middlewares/errorHandler.js"; @@ -20,11 +21,25 @@ export const createCourse = catchAsync(async (req, res, next) => { ); } + const isObjectId = mongoose.Types.ObjectId.isValid(category); + const catQuery = isObjectId ? { _id: category, isActive: true } : { slug: category, isActive: true }; + const resolvedCategory = await Category.findOne(catQuery); + + if (!resolvedCategory) { + const validCategories = await Category.find({ isActive: true }).select('slug').lean(); + const validSlugs = validCategories.map(c => c.slug).join(', '); + return res.status(400).json({ + success: false, + message: `Invalid or inactive category. Valid categories are: ${validSlugs}`, + }); + } + // Create course with URLs from frontend const course = await Course.create({ title, description, - category, + category: resolvedCategory.name, + categoryRef: resolvedCategory._id, price: price || 0, createdBy: req.user._id, thumbnail: thumbnail || null, // URL from frontend @@ -41,9 +56,19 @@ export const createCourse = catchAsync(async (req, res, next) => { }); // 📚 Get all courses -export const getCourses = async (_req, res) => { +export const getCourses = async (req, res) => { try { - const courses = await Course.find().populate( + const { category } = req.query; + let query = {}; + if (category) { + const cat = await Category.findOne({ slug: category }); + if (!cat) { + return res.status(200).json({ success: true, courses: [] }); + } + query.categoryRef = cat._id; + } + + const courses = await Course.find(query).populate( "createdBy", "name email avatar" ); @@ -178,9 +203,25 @@ export const updateCourse = catchAsync(async (req, res, next) => { // Update fields (URLs from frontend) course.title = title || course.title; course.description = description || course.description; - course.category = category || course.category; course.price = price !== undefined ? price : course.price; + if (category) { + const isObjectId = mongoose.Types.ObjectId.isValid(category); + const catQuery = isObjectId ? { _id: category, isActive: true } : { slug: category, isActive: true }; + const resolvedCategory = await Category.findOne(catQuery); + + if (!resolvedCategory) { + const validCategories = await Category.find({ isActive: true }).select('slug').lean(); + const validSlugs = validCategories.map(c => c.slug).join(', '); + return res.status(400).json({ + success: false, + message: `Invalid or inactive category. Valid categories are: ${validSlugs}`, + }); + } + course.category = resolvedCategory.name; + course.categoryRef = resolvedCategory._id; + } + // Update media URLs if provided if (thumbnail) course.thumbnail = thumbnail; if (video) course.video = video; diff --git a/src/controllers/stellar/donationController.js b/src/controllers/stellar/donationController.js index b16056b..50b05be 100644 --- a/src/controllers/stellar/donationController.js +++ b/src/controllers/stellar/donationController.js @@ -13,6 +13,7 @@ import { DONATION_WALLET_PUBLIC_KEY, } from "../../services/stellar/stellarService.js"; import logger from "../../config/logger.js"; +import { enqueue } from "../../jobs/queue.js"; import { paymentsInitialized, paymentsSubmitted, @@ -193,9 +194,32 @@ export const submitDonation = async (req, res) => { ]); if (!verification.verified) { + donation.stellarTxHash = result.hash; + if (verification.transient) { + donation.status = "retrying"; + donation.failureReason = verification.reason; + await donation.save({ session }); + await enqueue( + "verifyPaymentOnChain", + { transactionId: donation._id.toString() }, + { + attempts: 5, + backoffMs: 1000, + idempotencyKey: `verify:${result.hash}`, + session, + } + ); + await session.commitTransaction(); + return res.status(202).json({ + success: true, + message: "Donation submitted; confirmation is in progress", + donationId: donation._id, + txHash: result.hash, + status: "retrying", + }); + } donation.status = "failed"; donation.failureReason = `On-chain verification failed: ${verification.reason}`; - donation.stellarTxHash = result.hash; await donation.save({ session }); await session.commitTransaction(); paymentsFailed.inc({ type: "donation", reason: "verification_failed" }); @@ -217,6 +241,16 @@ export const submitDonation = async (req, res) => { donation.status = "confirmed"; donation.confirmedAt = new Date(); await donation.save({ session }); + await enqueue( + "generateReceipt", + { transactionId: donation._id.toString() }, + { + attempts: 5, + backoffMs: 1000, + idempotencyKey: `receipt:${result.hash}`, + session, + } + ); await session.commitTransaction(); paymentsConfirmed.inc({ type: "donation" }); diff --git a/src/controllers/stellar/paymentController.js b/src/controllers/stellar/paymentController.js index 97c61e6..28272a4 100644 --- a/src/controllers/stellar/paymentController.js +++ b/src/controllers/stellar/paymentController.js @@ -6,15 +6,21 @@ import Course from "../../models/Course.js"; import Transaction from "../../models/Transaction.js"; import { buildPaymentTransaction, + buildPathPaymentTransaction, buildSep7Uri, submitTransaction, verifyTransaction, verifyPaymentOperations, + findPaymentPaths, + applySlippage, NETWORK, getExplorerUrl, + USDC, PLATFORM_WALLET_PUBLIC_KEY, } from "../../services/stellar/stellarService.js"; +import * as StellarSdk from "@stellar/stellar-sdk"; import { recordSaleEarnings } from "../../services/payoutService.js"; +import { enqueue } from "../../jobs/queue.js"; import logger from "../../config/logger.js"; import { paymentsInitialized, @@ -23,6 +29,117 @@ import { paymentsFailed, } from "../../config/metrics.js"; +/** + * Get a quote for paying with a non-USDC asset via path payment + * POST /api/stellar/payment/quote + */ +export const getQuote = async (req, res) => { + try { + const { itemType, itemId, sendAssetCode, sendAssetIssuer } = req.body; + + if (!["book", "course"].includes(itemType)) { + return res.status(400).json({ + success: false, + message: "Invalid item type. Must be 'book' or 'course'", + }); + } + + const Model = itemType === "book" ? Book : Course; + const item = await Model.findById(itemId); + + if (!item) { + return res.status(404).json({ + success: false, + message: `${itemType} not found`, + }); + } + + if (!item.price || item.price === 0) { + return res.status(400).json({ + success: false, + message: "This item is free, no quote needed", + }); + } + + if (sendAssetCode && !sendAssetIssuer && sendAssetCode !== "XLM" && sendAssetCode !== "native") { + return res.status(400).json({ + success: false, + message: "Non-native assets require an issuer. Omit sendAssetIssuer only for native XLM.", + }); + } + + const sendAsset = sendAssetIssuer + ? new StellarSdk.Asset(sendAssetCode, sendAssetIssuer) + : StellarSdk.Asset.native(); + + const destAmount = item.price.toString(); + const paths = await findPaymentPaths(sendAsset, destAmount); + + if (!paths || paths.length === 0) { + return res.status(404).json({ + success: false, + message: "No payment path found for the given asset", + }); + } + + const bestPath = paths[0]; + const slippageBps = Math.min( + 500, + Math.max(10, Number(req.body.slippageBps) || 100) + ); + const sendMax = applySlippage(bestPath.source_amount, slippageBps); + + const sourceAsset = { + asset_type: bestPath.source_asset_type, + ...(bestPath.source_asset_type !== "native" && { + asset_code: bestPath.source_asset_code, + asset_issuer: bestPath.source_asset_issuer, + }), + }; + + const pathAssets = (bestPath.path || []).map((a) => ({ + asset_type: a.asset_type, + ...(a.asset_type !== "native" && { + asset_code: a.asset_code, + asset_issuer: a.asset_issuer, + }), + })); + + const expiresAt = new Date(Date.now() + 30 * 1000).toISOString(); + + res.status(200).json({ + success: true, + quote: { + source_asset: sourceAsset, + source_amount: bestPath.source_amount, + destination_asset: { asset_type: "credit_alphanum4", asset_code: "USDC", asset_issuer: USDC.getIssuer() }, + destination_amount: destAmount, + path: pathAssets, + sendMax, + slippageBps, + expiresAt, + note: "Quote is an estimate. The on-chain bound enforced is sendMax, not the quoted source_amount.", + }, + }); + } catch (error) { + logger.error("Quote error:", error); + if ( + error.message?.includes("Invalid asset") || + error.message?.includes("bad asset") + ) { + return res.status(400).json({ + success: false, + message: "Unknown or invalid asset", + }); + } + res.status(500).json({ + success: false, + message: "Failed to get quote", + error: process.env.NODE_ENV === "development" ? error.message : undefined, + }); + } +}; + /** * Initialize a payment - creates pending transaction and returns XDR to sign * POST /api/stellar/payment/initialize @@ -33,7 +150,7 @@ export const initializePayment = async (req, res) => { try { const buyerId = req.user._id; - const { itemType, itemId, buyerWallet } = req.body; + const { itemType, itemId, buyerWallet, sendAsset: sendAssetInput, sendMax, path: pathInput } = req.body; // Validate item type if (!["book", "course"].includes(itemType)) { @@ -157,20 +274,48 @@ export const initializePayment = async (req, res) => { const memo = `DNB-${itemType.toUpperCase()}-${itemId.toString().slice(-8)}`; // Build the payment transaction (single op full amount for platform collect, split for direct if fee configured) - const paymentTx = await buildPaymentTransaction({ - sourcePublicKey: buyer.stellarWallet.publicKey, - destinationPublicKey, - amount: item.price.toString(), - memo, - applyPlatformFee: settlementMode === "direct", - }); + const isPathPayment = sendAssetInput && sendMax; + let paymentTx; + let sep7Uri = null; + + if (isPathPayment) { + const sendAsset = sendAssetInput.issuer + ? new StellarSdk.Asset(sendAssetInput.code, sendAssetInput.issuer) + : StellarSdk.Asset.native(); + + const path = (pathInput || []).map((a) => ({ + asset_type: a.asset_type, + ...(a.asset_type !== "native" && { + asset_code: a.asset_code, + asset_issuer: a.asset_issuer, + }), + })); + + paymentTx = await buildPathPaymentTransaction({ + sourcePublicKey: buyer.stellarWallet.publicKey, + destinationPublicKey, + destAmount: item.price.toString(), + sendAsset, + sendMax, + path, + memo, + applyPlatformFee: settlementMode === "direct", + }); + } else { + paymentTx = await buildPaymentTransaction({ + sourcePublicKey: buyer.stellarWallet.publicKey, + destinationPublicKey, + amount: item.price.toString(), + memo, + applyPlatformFee: settlementMode === "direct", + }); - // SEP-7 URI so wallets can deep-link the payment - const sep7Uri = buildSep7Uri({ - destination: destinationPublicKey, - amount: item.price.toString(), - memo, - }); + sep7Uri = buildSep7Uri({ + destination: destinationPublicKey, + amount: item.price.toString(), + memo, + }); + } const feeSplit = paymentTx.feeSplit; @@ -188,7 +333,11 @@ export const initializePayment = async (req, res) => { network: NETWORK, status: "pending", settlement: settlementMode, - stellarTxHash: paymentTx.hash, // Temporary hash, will be replaced with actual + stellarTxHash: paymentTx.hash, + ...(sendAssetInput && { + sendAsset: sendAssetInput, + sendMax, + }), ...(feeSplit && { platformFee: { feePercent: feeSplit.feePercent, @@ -215,7 +364,11 @@ export const initializePayment = async (req, res) => { networkPassphrase: paymentTx.networkPassphrase, expectedHash: paymentTx.hash, }, - sep7Uri, + ...(sep7Uri && { sep7Uri }), + ...(isPathPayment && { + pathPaymentNote: + "Path payment XDR provided. SEP-7 URI is not available for path payments; use the XDR signing flow.", + }), item: { title: item.title, price: item.price, @@ -328,9 +481,32 @@ export const submitPayment = async (req, res) => { ); if (!verification.verified) { + transaction.stellarTxHash = result.hash; + if (verification.transient) { + transaction.status = "retrying"; + transaction.failureReason = verification.reason; + await transaction.save({ session }); + await enqueue( + "verifyPaymentOnChain", + { transactionId: transaction._id.toString() }, + { + attempts: 5, + backoffMs: 1000, + idempotencyKey: `verify:${result.hash}`, + session, + } + ); + await session.commitTransaction(); + return res.status(202).json({ + success: true, + message: "Payment submitted; confirmation is in progress", + transactionId: transaction._id, + txHash: result.hash, + status: "retrying", + }); + } transaction.status = "failed"; transaction.failureReason = `On-chain verification failed: ${verification.reason}`; - transaction.stellarTxHash = result.hash; await transaction.save({ session }); await session.commitTransaction(); paymentsFailed.inc({ type: "purchase", reason: "verification_failed" }); @@ -386,6 +562,16 @@ export const submitPayment = async (req, res) => { } await buyer.save({ session }); + await enqueue( + "generateReceipt", + { transactionId: transaction._id.toString() }, + { + attempts: 5, + backoffMs: 1000, + idempotencyKey: `receipt:${result.hash}`, + session, + } + ); await session.commitTransaction(); logger.info( diff --git a/src/jobs/handlers.js b/src/jobs/handlers.js new file mode 100644 index 0000000..c45d3fc --- /dev/null +++ b/src/jobs/handlers.js @@ -0,0 +1,92 @@ +import Transaction from "../models/Transaction.js"; +import User from "../models/User.js"; +import Course from "../models/Course.js"; +import { sendOtpEmail, sendReceiptEmail } from "../../services/emails/sendMail.js"; +import { verifyPaymentOperations, getExplorerUrl } from "../services/stellar/stellarService.js"; +import { recordSaleEarnings } from "../services/payoutService.js"; +import { registerJob, enqueue } from "./queue.js"; + +const expectedPaymentsFor = (transaction) => + transaction.type === "donation" + ? [{ destination: transaction.creatorWallet, amount: transaction.amount }] + : transaction.platformFee?.platformAmount + ? [ + { destination: transaction.creatorWallet, amount: transaction.platformFee.creatorAmount }, + { destination: transaction.platformFee.platformWallet, amount: transaction.platformFee.platformAmount }, + ] + : [{ destination: transaction.creatorWallet, amount: transaction.amount }]; + +const queueReceipt = (transaction) => + enqueue( + "generateReceipt", + { transactionId: transaction._id.toString() }, + { attempts: 5, backoffMs: 1000, idempotencyKey: `receipt:${transaction.stellarTxHash}` } + ); + +registerJob("sendOtpEmail", async ({ userId, otp }) => { + const user = await User.findById(userId).select("email"); + if (!user) throw new Error("OTP recipient no longer exists"); + await sendOtpEmail(otp, user.email); +}); + +registerJob("verifyPaymentOnChain", async ({ transactionId }, context) => { + const transaction = await Transaction.findById(transactionId); + if (!transaction || transaction.status === "failed") return; + if (transaction.status === "confirmed") { + await queueReceipt(transaction); + return; + } + + const verification = await verifyPaymentOperations( + transaction.stellarTxHash, + expectedPaymentsFor(transaction) + ); + if (!verification.verified) { + transaction.retryCount = context.attempt; + if (verification.transient && context.attempt < context.maxAttempts) { + await transaction.save(); + throw new Error(verification.reason); + } + transaction.status = "failed"; + transaction.failureReason = `On-chain verification failed: ${verification.reason}`; + await transaction.save(); + return; + } + + transaction.status = "confirmed"; + transaction.confirmedAt = new Date(); + transaction.failureReason = undefined; + await transaction.save(); + + if (transaction.type === "purchase") { + await recordSaleEarnings(transaction); + const purchase = { purchaseDate: transaction.confirmedAt }; + if (transaction.itemType === "book") { + purchase.bookId = transaction.itemId; + await User.updateOne({ _id: transaction.buyer }, { $addToSet: { purchasedBooks: purchase } }); + } else { + purchase.courseId = transaction.itemId; + await User.updateOne({ _id: transaction.buyer }, { $addToSet: { purchasedCourses: purchase } }); + await Course.updateOne({ _id: transaction.itemId }, { $addToSet: { enrolledUsers: transaction.buyer } }); + } + } + await queueReceipt(transaction); +}); + +registerJob("generateReceipt", async ({ transactionId }) => { + const transaction = await Transaction.findById(transactionId).populate("buyer", "email name"); + if (!transaction || transaction.status !== "confirmed") return; + await sendReceiptEmail({ + email: transaction.buyer.email, + name: transaction.buyer.name, + title: transaction.itemTitle || "Sadaqah donation", + amount: transaction.amount, + currency: transaction.currency, + platformAmount: transaction.platformFee?.platformAmount || "0", + creatorAmount: transaction.platformFee?.creatorAmount || transaction.amount, + txHash: transaction.stellarTxHash, + explorerUrl: getExplorerUrl(transaction.stellarTxHash), + }); +}); + +export { expectedPaymentsFor, queueReceipt }; diff --git a/src/jobs/queue.js b/src/jobs/queue.js new file mode 100644 index 0000000..6334b26 --- /dev/null +++ b/src/jobs/queue.js @@ -0,0 +1,147 @@ +import crypto from "crypto"; +import Job from "../models/Job.js"; +import logger from "../config/logger.js"; + +const handlers = new Map(); +const inlineKeys = new Set(); +const inFlight = new Set(); +let accepting = true; +let pollTimer; + +const driver = () => + process.env.QUEUE_DRIVER || (process.env.NODE_ENV === "test" ? "inline" : "mongo"); + +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const retryDelay = (base, attempt) => { + const exponential = base * 2 ** Math.max(0, attempt - 1); + return exponential + Math.floor(Math.random() * Math.max(1, exponential * 0.2)); +}; + +export const registerJob = (name, handler) => handlers.set(name, handler); + +const executeInline = async (name, payload, options) => { + const handler = handlers.get(name); + if (!handler) throw new Error(`No handler registered for job ${name}`); + let attempt = 0; + while (attempt < options.attempts) { + attempt += 1; + try { + await handler(payload, { attempt, maxAttempts: options.attempts }); + return; + } catch (error) { + if (attempt >= options.attempts) { + logger.error({ job: name, error: error.message }, "Inline job exhausted retries"); + return; + } + await delay(retryDelay(options.backoffMs, attempt)); + } + } +}; + +export const enqueue = async (name, payload, opts = {}) => { + if (!accepting) throw new Error("Job queue is shutting down"); + const options = { + attempts: opts.attempts || 1, + backoffMs: opts.backoffMs || 1000, + idempotencyKey: + opts.idempotencyKey || `${name}:${crypto.createHash("sha256").update(JSON.stringify(payload)).digest("hex")}`, + session: opts.session, + }; + + if (driver() === "inline") { + if (inlineKeys.has(options.idempotencyKey)) return { duplicate: true }; + inlineKeys.add(options.idempotencyKey); + const promise = new Promise((resolve) => setImmediate(resolve)) + .then(() => executeInline(name, payload, options)) + .finally(() => inFlight.delete(promise)); + inFlight.add(promise); + return { queued: true, idempotencyKey: options.idempotencyKey }; + } + + const job = await Job.findOneAndUpdate( + { idempotencyKey: options.idempotencyKey }, + { + $setOnInsert: { + name, + payload, + idempotencyKey: options.idempotencyKey, + maxAttempts: options.attempts, + backoffMs: options.backoffMs, + status: "queued", + runAt: new Date(), + }, + }, + { upsert: true, new: true, session: options.session } + ); + return { queued: true, id: job._id, duplicate: job.attemptsMade > 0 }; +}; + +const processNext = async () => { + const job = await Job.findOneAndUpdate( + { status: { $in: ["queued", "retrying"] }, runAt: { $lte: new Date() } }, + { $set: { status: "active", lockedAt: new Date() }, $inc: { attemptsMade: 1 } }, + { sort: { runAt: 1 }, new: true } + ); + if (!job) return; + + const promise = (async () => { + try { + const handler = handlers.get(job.name); + if (!handler) throw new Error(`No handler registered for job ${job.name}`); + await handler(job.payload, { + attempt: job.attemptsMade, + maxAttempts: job.maxAttempts, + }); + await Job.updateOne( + { _id: job._id }, + { $set: { status: "completed", completedAt: new Date() }, $unset: { lockedAt: 1 } } + ); + } catch (error) { + const exhausted = job.attemptsMade >= job.maxAttempts; + await Job.updateOne( + { _id: job._id }, + { + $set: exhausted + ? { status: "dead", failedAt: new Date(), lastError: error.message } + : { + status: "retrying", + runAt: new Date(Date.now() + retryDelay(job.backoffMs, job.attemptsMade)), + lastError: error.message, + }, + $unset: { lockedAt: 1 }, + } + ); + logger[exhausted ? "error" : "warn"]( + { job: job.name, jobId: job._id, attempt: job.attemptsMade, error: error.message }, + exhausted ? "Job moved to dead letter" : "Job scheduled for retry" + ); + } + })().finally(() => inFlight.delete(promise)); + inFlight.add(promise); +}; + +export const startJobs = async () => { + if (process.env.JOBS_ENABLED === "false" || driver() === "inline" || pollTimer) return; + accepting = true; + await Job.updateMany( + { status: "active" }, + { $set: { status: "retrying", runAt: new Date() }, $unset: { lockedAt: 1 } } + ); + pollTimer = setInterval(() => processNext().catch((error) => logger.error(error, "Job poll failed")), 500); + pollTimer.unref?.(); + logger.info({ driver: driver() }, "Background jobs started"); +}; + +export const stopJobs = async () => { + accepting = false; + if (pollTimer) clearInterval(pollTimer); + pollTimer = undefined; + await Promise.allSettled([...inFlight]); +}; + +export const waitForIdle = async () => Promise.allSettled([...inFlight]); + +export const resetInlineQueueForTests = () => { + inlineKeys.clear(); + accepting = true; +}; diff --git a/src/middlewares/authMiddleware.js b/src/middlewares/authMiddleware.js index dec7702..b4e2833 100644 --- a/src/middlewares/authMiddleware.js +++ b/src/middlewares/authMiddleware.js @@ -40,3 +40,15 @@ export const protect = async (req, res, next) => { .json({ success: false, message: "No token, authorization denied" }); } }; + +export const requireRole = (...roles) => { + return (req, res, next) => { + if (!req.user || !roles.includes(req.user.role)) { + return res.status(403).json({ + success: false, + message: "Forbidden: You do not have the required role to access this route", + }); + } + next(); + }; +}; diff --git a/src/models/Book.js b/src/models/Book.js index 53169ec..efbe342 100644 --- a/src/models/Book.js +++ b/src/models/Book.js @@ -11,6 +11,10 @@ const bookSchema = new mongoose.Schema({ required: true, }, category: String, + categoryRef: { + type: mongoose.Schema.Types.ObjectId, + ref: "Category", + }, price: { type: Number, default: 0, @@ -53,6 +57,16 @@ const bookSchema = new mongoose.Schema({ }, }); +bookSchema.pre('save', async function(next) { + if (this.isModified('categoryRef') && this.categoryRef) { + const category = await mongoose.model('Category').findById(this.categoryRef).select('name').lean(); + if (category) { + this.category = category.name; + } + } + next(); +}); + const Book = mongoose.model("Book", bookSchema); export default Book; diff --git a/src/models/Category.js b/src/models/Category.js new file mode 100644 index 0000000..a17bcc0 --- /dev/null +++ b/src/models/Category.js @@ -0,0 +1,88 @@ +import mongoose from 'mongoose'; + +// Pure function for initial slug generation +export function slugify(text) { + return text + .toString() + .normalize('NFD') // split an accented letter in the base letter and the accent + .replace(/[\u0300-\u036f]/g, '') // remove all previously split accents + .toLowerCase() + .trim() + .replace(/[^a-z0-9 ]/g, '') // remove all chars not letters, numbers and spaces (to be replaced) + .replace(/\s+/g, '-'); // replace spaces with - +} + +export async function generateUniqueSlug(name, existingSlugsChecker) { + const baseSlug = slugify(name); + let slug = baseSlug; + let counter = 2; + + while (await existingSlugsChecker(slug)) { + slug = `${baseSlug}-${counter}`; + counter++; + } + + return slug; +} + +const categorySchema = new mongoose.Schema( + { + name: { + type: String, + required: [true, 'Category name is required'], + unique: true, + trim: true, + }, + slug: { + type: String, + unique: true, + lowercase: true, + index: true, + }, + description: { + type: String, + }, + icon: { + type: String, + match: [/^https?:\/\//, 'Icon must be a valid URL'], + }, + parent: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Category', + }, + order: { + type: Number, + default: 0, + }, + isActive: { + type: Boolean, + default: true, + }, + }, + { + timestamps: true, + } +); + +categorySchema.index({ parent: 1, order: 1 }); + +categorySchema.pre('save', async function (next) { + if (this.isModified('name') || !this.slug) { + if (!this.slug) { + this.slug = await generateUniqueSlug(this.name, async (candidateSlug) => { + // If we found a document with the same slug that isn't the current one + const query = { slug: candidateSlug }; + if (this._id) { + query._id = { $ne: this._id }; + } + const existing = await mongoose.models.Category.findOne(query).select('_id').lean(); + return !!existing; + }); + } + } + next(); +}); + +const Category = mongoose.models.Category || mongoose.model('Category', categorySchema); + +export default Category; diff --git a/src/models/Course.js b/src/models/Course.js index 6ce5f41..2c76b38 100644 --- a/src/models/Course.js +++ b/src/models/Course.js @@ -15,6 +15,10 @@ const courseSchema = new mongoose.Schema( type: String, required: true, }, + categoryRef: { + type: mongoose.Schema.Types.ObjectId, + ref: "Category", + }, thumbnail: { type: String, // image URL }, @@ -48,4 +52,14 @@ const courseSchema = new mongoose.Schema( { timestamps: true } ); +courseSchema.pre('save', async function(next) { + if (this.isModified('categoryRef') && this.categoryRef) { + const category = await mongoose.model('Category').findById(this.categoryRef).select('name').lean(); + if (category) { + this.category = category.name; + } + } + next(); +}); + export default mongoose.model("Course", courseSchema); diff --git a/src/models/Job.js b/src/models/Job.js new file mode 100644 index 0000000..05e9171 --- /dev/null +++ b/src/models/Job.js @@ -0,0 +1,28 @@ +import mongoose from "mongoose"; + +const jobSchema = new mongoose.Schema( + { + name: { type: String, required: true, index: true }, + payload: { type: mongoose.Schema.Types.Mixed, required: true }, + idempotencyKey: { type: String, required: true, unique: true, index: true }, + status: { + type: String, + enum: ["queued", "active", "retrying", "completed", "dead"], + default: "queued", + index: true, + }, + attemptsMade: { type: Number, default: 0 }, + maxAttempts: { type: Number, default: 1 }, + backoffMs: { type: Number, default: 1000 }, + runAt: { type: Date, default: Date.now, index: true }, + lockedAt: Date, + completedAt: Date, + failedAt: Date, + lastError: String, + }, + { timestamps: true } +); + +jobSchema.index({ status: 1, runAt: 1 }); + +export default mongoose.model("Job", jobSchema); diff --git a/src/models/Transaction.js b/src/models/Transaction.js index 4652c75..9d460fc 100644 --- a/src/models/Transaction.js +++ b/src/models/Transaction.js @@ -85,6 +85,11 @@ const transactionSchema = new mongoose.Schema( default: "USDC", enum: ["USDC"], }, + sendAsset: { + code: { type: String }, + issuer: { type: String }, + }, + sendMax: { type: String }, network: { type: String, enum: ["testnet", "mainnet"], @@ -110,7 +115,7 @@ const transactionSchema = new mongoose.Schema( // Status tracking status: { type: String, - enum: ["pending", "submitted", "confirmed", "failed", "expired"], + enum: ["pending", "submitted", "retrying", "confirmed", "failed", "expired"], default: "pending", index: true, }, diff --git a/src/models/User.js b/src/models/User.js index fec9b43..c7b259b 100644 --- a/src/models/User.js +++ b/src/models/User.js @@ -41,7 +41,7 @@ const userSchema = new mongoose.Schema( }, role: { type: String, - enum: ["student", "tutor"], + enum: ["student", "tutor", "admin"], default: "student", }, isActive: { diff --git a/src/routes/categoryRoutes.js b/src/routes/categoryRoutes.js new file mode 100644 index 0000000..ca46151 --- /dev/null +++ b/src/routes/categoryRoutes.js @@ -0,0 +1,26 @@ +import express from 'express'; +import { getCategories, getCategoryBySlug, createCategory, updateCategory, deleteCategory } from '../controllers/categoryController.js'; +import { protect, requireRole } from '../middlewares/authMiddleware.js'; +import { cacheMiddleware, invalidateCacheMiddleware } from '../middlewares/cache.js'; +import { CACHE_TTL, CACHE_KEYS } from '../utils/cache.js'; + +const router = express.Router(); + +const categoriesListCacheKey = () => `${CACHE_KEYS.CATEGORIES}list`; +const categoryDetailCacheKey = (req) => { + // slug and query params (page, sort) affect the result, so include them in the key + const qs = new URLSearchParams(req.query).toString(); + return `${CACHE_KEYS.CATEGORY}${req.params.slug}${qs ? '?' + qs : ''}`; +}; + +router.get('/', cacheMiddleware(CACHE_TTL.CATEGORIES, categoriesListCacheKey), getCategories); +router.get('/:slug', cacheMiddleware(CACHE_TTL.CATEGORIES, categoryDetailCacheKey), getCategoryBySlug); + +// Admin CRUD routes +const invalidateCategories = invalidateCacheMiddleware([`${CACHE_KEYS.CATEGORIES}*`, `${CACHE_KEYS.CATEGORY}*`]); + +router.post('/', protect, requireRole('admin'), invalidateCategories, createCategory); +router.patch('/:id', protect, requireRole('admin'), invalidateCategories, updateCategory); +router.delete('/:id', protect, requireRole('admin'), invalidateCategories, deleteCategory); + +export default router; diff --git a/src/routes/courses/courseRoutes.js b/src/routes/courses/courseRoutes.js index be3bfb8..ccc6634 100644 --- a/src/routes/courses/courseRoutes.js +++ b/src/routes/courses/courseRoutes.js @@ -1,4 +1,6 @@ import express from "express"; +import { invalidateCacheMiddleware } from "../../middlewares/cache.js"; +import { CACHE_KEYS } from "../../utils/cache.js"; import { createCourse, getCourses, @@ -60,13 +62,13 @@ router.get( router.post( "/", protect, - invalidateCacheMiddleware([`${CACHE_KEYS.COURSES}*`]), + invalidateCacheMiddleware([`${CACHE_KEYS.COURSES}*`, `${CACHE_KEYS.CATEGORIES}*`, `${CACHE_KEYS.CATEGORY}*`]), createCourse ); router.post( "/:id/enroll", protect, - invalidateCacheMiddleware([`${CACHE_KEYS.COURSE}*`]), + invalidateCacheMiddleware([`${CACHE_KEYS.COURSE}*`, `${CACHE_KEYS.CATEGORIES}*`, `${CACHE_KEYS.CATEGORY}*`]), enrollInCourse ); router.post( @@ -78,7 +80,7 @@ router.post( router.put( "/:id", protect, - invalidateCacheMiddleware([`${CACHE_KEYS.COURSES}*`, `${CACHE_KEYS.COURSE}*`]), + invalidateCacheMiddleware([`${CACHE_KEYS.COURSES}*`, `${CACHE_KEYS.COURSE}*`, `${CACHE_KEYS.CATEGORIES}*`, `${CACHE_KEYS.CATEGORY}*`]), updateCourse ); diff --git a/src/routes/jobsRoutes.js b/src/routes/jobsRoutes.js new file mode 100644 index 0000000..ef55df2 --- /dev/null +++ b/src/routes/jobsRoutes.js @@ -0,0 +1,38 @@ +import express from "express"; +import Job from "../models/Job.js"; + +const router = express.Router(); +const escapeHtml = (value) => + String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); + +router.use((req, res, next) => { + const token = process.env.JOBS_DASHBOARD_TOKEN; + if (!token) return res.status(404).json({ success: false, message: "Not found" }); + if (req.headers.authorization !== `Bearer ${token}`) { + return res.status(401).json({ success: false, message: "Unauthorized" }); + } + next(); +}); + +router.get("/", async (req, res) => { + const jobs = await Job.find().sort({ createdAt: -1 }).limit(100).lean(); + if (req.accepts(["html", "json"]) === "html") { + const rows = jobs + .map((job) => `
| Name | Status | Attempts | Error |
|---|