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) => `${escapeHtml(job.name)}${escapeHtml(job.status)}${job.attemptsMade}/${job.maxAttempts}${escapeHtml(job.lastError || "")}`) + .join(""); + return res.type("html").send(`

DeenBridge Jobs

${rows}
NameStatusAttemptsError
`); + } + res.json({ success: true, jobs }); +}); + +router.get("/dead", async (req, res) => { + const jobs = await Job.find({ status: "dead" }).sort({ failedAt: -1 }).lean(); + res.json({ success: true, jobs }); +}); + +export default router; diff --git a/src/routes/stellar/paymentRoutes.js b/src/routes/stellar/paymentRoutes.js index 554889c..114e68e 100644 --- a/src/routes/stellar/paymentRoutes.js +++ b/src/routes/stellar/paymentRoutes.js @@ -4,6 +4,7 @@ import { protect } from "../../middlewares/authMiddleware.js"; import { initializePayment, submitPayment, + getQuote, getTransactionHistory, getTransaction, cancelTransaction, @@ -15,6 +16,7 @@ const router = express.Router(); router.use(protect); // Payment flow +router.post("/quote", getQuote); router.post("/initialize", initializePayment); router.post("/submit", submitPayment); diff --git a/src/scripts/migrateCategories.js b/src/scripts/migrateCategories.js new file mode 100644 index 0000000..a2446cb --- /dev/null +++ b/src/scripts/migrateCategories.js @@ -0,0 +1,130 @@ +import 'dotenv/config'; +import mongoose from 'mongoose'; +import Course from '../models/Course.js'; +import Book from '../models/Book.js'; +import Category, { slugify } from '../models/Category.js'; + +// Canonical core disciplines from Phase 4 +const SEED_CATEGORIES = [ + "Qur'an", + "Hadith", + "Aqeedah", + "Fiqh", + "Seerah/History", + "Arabic Language", + "Islamic Finance", + "Spirituality/Tazkiyah" +]; + +const canonicalMap = new Map(); +for (const cat of SEED_CATEGORIES) { + canonicalMap.set(slugify(cat), cat); +} + +function getCanonicalName(originalName, firstSeenMap) { + const slug = slugify(originalName); + if (canonicalMap.has(slug)) { + return canonicalMap.get(slug); + } + if (firstSeenMap.has(slug)) { + return firstSeenMap.get(slug); + } + // Title case it as a fallback + const titleCased = originalName + .split(' ') + .map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) + .join(' '); + firstSeenMap.set(slug, titleCased); + return titleCased; +} + +export async function runMigration(silent = false) { + let categoriesCreated = 0; + let categoriesMatched = 0; + let coursesBackfilled = 0; + let booksBackfilled = 0; + let unmatched = 0; + + const firstSeenMap = new Map(); + + // 1. Get all distinct categories + const courseCategories = await Course.distinct('category'); + const bookCategories = await Book.distinct('category'); + + const allDistinct = [...new Set([...courseCategories, ...bookCategories])].filter(Boolean); + + // 2. Process each distinct legacy category string + for (const legacyName of allDistinct) { + const canonicalName = getCanonicalName(legacyName, firstSeenMap); + + // Find or create category + let category = await Category.findOne({ name: canonicalName }); + if (!category) { + category = new Category({ name: canonicalName }); + await category.save(); // pre-save hook handles slug + categoriesCreated++; + } else { + categoriesMatched++; + } + + // 3. Backfill Courses (only where categoryRef is missing or null) + const courseResult = await Course.updateMany( + { + category: legacyName, + $or: [{ categoryRef: { $exists: false } }, { categoryRef: null }] + }, + { $set: { categoryRef: category._id } } + ); + coursesBackfilled += courseResult.modifiedCount; + + // 4. Backfill Books + const bookResult = await Book.updateMany( + { + category: legacyName, + $or: [{ categoryRef: { $exists: false } }, { categoryRef: null }] + }, + { $set: { categoryRef: category._id } } + ); + booksBackfilled += bookResult.modifiedCount; + } + + // Find any that didn't match + const remainingCourses = await Course.countDocuments({ + $or: [{ categoryRef: { $exists: false } }, { categoryRef: null }] + }); + const remainingBooks = await Book.countDocuments({ + $or: [{ categoryRef: { $exists: false } }, { categoryRef: null }] + }); + unmatched = remainingCourses + remainingBooks; + + if (!silent) { + console.log('--- Migration Summary ---'); + console.log(`Categories Created: ${categoriesCreated}`); + console.log(`Categories Matched: ${categoriesMatched}`); + console.log(`Courses Backfilled: ${coursesBackfilled}`); + console.log(`Books Backfilled: ${booksBackfilled}`); + if (unmatched > 0) { + console.warn(`WARNING: ${unmatched} documents left without categoryRef!`); + } else { + console.log('All documents successfully backfilled.'); + } + } + + return { + categoriesCreated, + categoriesMatched, + coursesBackfilled, + booksBackfilled, + unmatched + }; +} + +if (process.argv[1] && process.argv[1].endsWith('migrateCategories.js')) { + mongoose.connect(process.env.MONGO_URI).then(async () => { + await runMigration(); + process.exit(0); + }).catch(err => { + console.error(err); + process.exit(1); + }); +} diff --git a/src/scripts/seedCategories.js b/src/scripts/seedCategories.js new file mode 100644 index 0000000..dcde61d --- /dev/null +++ b/src/scripts/seedCategories.js @@ -0,0 +1,84 @@ +import 'dotenv/config'; +import mongoose from 'mongoose'; +import Category, { slugify } from '../models/Category.js'; + +const coreCategories = [ + { name: "Qur'an", description: "The central religious text of Islam.", order: 10 }, + { name: "Tajweed", description: "Rules of Qur'anic recitation.", order: 11, parentName: "Qur'an" }, + { name: "Tafsir", description: "Exegesis and interpretation of the Qur'an.", order: 12, parentName: "Qur'an" }, + { name: "Hadith", description: "Record of the words, actions, and the silent approval of the Islamic prophet Muhammad.", order: 20 }, + { name: "Aqeedah", description: "Islamic creed and belief system.", order: 30 }, + { name: "Fiqh", description: "Islamic jurisprudence.", order: 40 }, + { name: "Seerah/History", description: "Life of the Prophet and Islamic history.", order: 50 }, + { name: "Arabic Language", description: "Classical and modern Arabic studies.", order: 60 }, + { name: "Islamic Finance", description: "Islamic banking, economics, and finance.", order: 70 }, + { name: "Spirituality/Tazkiyah", description: "Purification of the soul and spiritual development.", order: 80 }, +]; + +export async function seedCategories(silent = false) { + let countCreated = 0; + let countUpdated = 0; + + const parentIdMap = new Map(); + const parents = coreCategories.filter(c => !c.parentName); + const children = coreCategories.filter(c => c.parentName); + + async function processCategory(catData) { + const targetSlug = slugify(catData.name); + let category = await Category.findOne({ slug: targetSlug }); + + let parentId = null; + if (catData.parentName) { + parentId = parentIdMap.get(slugify(catData.parentName)); + } + + if (!category) { + // Create new + category = new Category({ + name: catData.name, + slug: targetSlug, // Set slug explicitly so it doesn't need to generate one + description: catData.description, + order: catData.order, + parent: parentId, + isActive: true, + }); + await category.save(); + countCreated++; + } else { + // Update existing + category.name = catData.name; + category.description = catData.description; + category.order = catData.order; + category.parent = parentId; + category.isActive = true; + await category.save(); + countUpdated++; + } + parentIdMap.set(targetSlug, category._id); + } + + for (const cat of parents) { + await processCategory(cat); + } + + for (const cat of children) { + await processCategory(cat); + } + + if (!silent) { + console.log('--- Category Seed Summary ---'); + console.log(`Created: ${countCreated}`); + console.log(`Updated: ${countUpdated}`); + console.log(`Total processed: ${countCreated + countUpdated}`); + } +} + +if (process.argv[1] && process.argv[1].endsWith('seedCategories.js')) { + mongoose.connect(process.env.MONGO_URI).then(async () => { + await seedCategories(); + process.exit(0); + }).catch(err => { + console.error(err); + process.exit(1); + }); +} diff --git a/src/services/stellar/stellarService.js b/src/services/stellar/stellarService.js index 4b3dc64..d5174df 100644 --- a/src/services/stellar/stellarService.js +++ b/src/services/stellar/stellarService.js @@ -35,7 +35,7 @@ const PLATFORM_FEE_PERCENT = (() => { return percent; })(); -const STROOPS_PER_UNIT = 10000000n; +export const STROOPS_PER_UNIT = 10000000n; async function timedHorizonCall(operation, fn) { const start = Date.now(); @@ -76,6 +76,123 @@ export const fromStroops = (stroops) => { return frac ? `${whole}.${frac}` : whole.toString(); }; +export const applySlippage = (amount, bps) => { + const stroops = toStroops(amount); + const extra = (stroops * BigInt(bps)) / 10000n; + return fromStroops(stroops + extra); +}; + +const applySlippageStroops = (stroops, bps) => { + return stroops + (stroops * BigInt(bps)) / 10000n; +}; + +export const findPaymentPaths = async (sendAsset, destAmount) => { + try { + const records = await timedHorizonCall("strictReceivePaths", () => + server.strictReceivePaths([sendAsset], USDC, destAmount.toString()).call() + ); + return records.records; + } catch (error) { + if (error.response?.status === 400 || error.response?.status === 404) { + return []; + } + logger.error("Error finding payment paths:", error); + throw error; + } +}; + +const assetFromHorizonRecord = (record) => { + if (record.asset_type === "native") { + return StellarSdk.Asset.native(); + } + return new StellarSdk.Asset(record.asset_code, record.asset_issuer); +}; + +export const buildPathPaymentTransaction = async ({ + sourcePublicKey, + destinationPublicKey, + destAmount, + sendAsset, + sendMax, + path = [], + memo, + applyPlatformFee = false, +}) => { + try { + const sourceAccount = await timedHorizonCall("loadAccount", () => + server.loadAccount(sourcePublicKey) + ); + + const feeSplit = applyPlatformFee ? calculateFeeSplit(destAmount) : null; + const totalDestStroops = toStroops(destAmount); + const sendMaxStroops = toStroops(sendMax); + + const pathAssets = path.map(assetFromHorizonRecord); + + const builder = new StellarSdk.TransactionBuilder(sourceAccount, { + fee: StellarSdk.BASE_FEE, + networkPassphrase, + }); + + if (feeSplit) { + const creatorDestStroops = toStroops(feeSplit.creatorAmount); + const creatorSendMaxStroops = + (sendMaxStroops * creatorDestStroops) / totalDestStroops; + const platformSendMaxStroops = sendMaxStroops - creatorSendMaxStroops; + + builder.addOperation( + StellarSdk.Operation.pathPaymentStrictReceive({ + sendAsset, + sendMax: fromStroops(creatorSendMaxStroops), + destination: destinationPublicKey, + destAsset: USDC, + destAmount: feeSplit.creatorAmount, + path: pathAssets, + }) + ); + + if (platformSendMaxStroops > 0n) { + builder.addOperation( + StellarSdk.Operation.pathPaymentStrictReceive({ + sendAsset, + sendMax: fromStroops(platformSendMaxStroops), + destination: feeSplit.platformWallet, + destAsset: USDC, + destAmount: feeSplit.platformAmount, + path: pathAssets, + }) + ); + } + } else { + builder.addOperation( + StellarSdk.Operation.pathPaymentStrictReceive({ + sendAsset, + sendMax: sendMax.toString(), + destination: destinationPublicKey, + destAsset: USDC, + destAmount: destAmount.toString(), + path: pathAssets, + }) + ); + } + + const transaction = builder + .addMemo(StellarSdk.Memo.text(memo || "DeenBridge Purchase")) + .setTimeout(300) + .build(); + + return { + xdr: transaction.toXDR(), + hash: transaction.hash().toString("hex"), + networkPassphrase, + feeSplit, + }; + } catch (error) { + logger.error("Error building path payment transaction:", error); + throw error; + } +}; + export const calculateFeeSplit = ( amount, feePercent = PLATFORM_FEE_PERCENT, @@ -251,6 +368,15 @@ export const submitTransaction = async (signedXdr) => { if (codes.operations?.includes("op_underfunded")) { throw new Error("Insufficient USDC balance"); } + if ( + codes.operations?.some( + (c) => + typeof c === "string" && + (c.includes("over_sendmax") || c.includes("over_source_max")) + ) + ) { + throw new Error("Price moved, request a new quote"); + } if (codes.operations?.includes("op_no_trust")) { throw new Error( "Recipient does not have a USDC trustline. They need to add USDC to their wallet first." @@ -295,25 +421,37 @@ export const verifyPaymentOperations = async (txHash, expectedPayments) => { const verification = await verifyTransaction(txHash); if (!verification.exists) { - return { verified: false, reason: "Transaction not found on network" }; + return { verified: false, transient: true, reason: "Transaction not found on network" }; } if (!verification.successful) { return { verified: false, reason: "Transaction was not successful" }; } - const paymentOps = verification.operations.filter( - (op) => - op.type === "payment" && - op.asset_code === "USDC" && - op.asset_issuer === USDC_ISSUER - ); + const paymentOps = verification.operations.filter((op) => { + if (op.type === "payment") { + return ( + op.asset_code === "USDC" && op.asset_issuer === USDC_ISSUER + ); + } + if (op.type === "path_payment_strict_receive") { + return ( + op.destination_asset_code === "USDC" && + op.destination_asset_issuer === USDC_ISSUER + ); + } + return false; + }); for (const expected of expectedPayments) { - const match = paymentOps.find( - (op) => - op.to === expected.destination && - toStroops(op.amount) === toStroops(expected.amount) - ); + const match = paymentOps.find((op) => { + if (op.to !== expected.destination) return false; + + const opAmount = + op.type === "path_payment_strict_receive" + ? op.destination_amount + : op.amount; + return toStroops(opAmount) === toStroops(expected.amount); + }); if (!match) { return { verified: false, @@ -325,7 +463,7 @@ export const verifyPaymentOperations = async (txHash, expectedPayments) => { return { verified: true }; } catch (error) { logger.error("Error verifying payment operations:", error); - return { verified: false, reason: "Verification failed" }; + return { verified: false, transient: true, reason: "Verification failed" }; } }; diff --git a/src/utils/cache.js b/src/utils/cache.js index 2056d4d..2f22ef3 100644 --- a/src/utils/cache.js +++ b/src/utils/cache.js @@ -12,6 +12,7 @@ export const CACHE_TTL = { // Specific entities COURSES: 60 * 15, // 15 minutes + CATEGORIES: 60 * 60, // 1 hour BOOKS: 60 * 15, // 15 minutes USERS: 60 * 10, // 10 minutes SPACES: 60 * 5, // 5 minutes @@ -25,6 +26,8 @@ export const CACHE_TTL = { export const CACHE_KEYS = { COURSES: "courses:", COURSE: "course:", + CATEGORIES: "categories:", + CATEGORY: "category:", BOOKS: "books:", BOOK: "book:", USERS: "users:", diff --git a/start-mongo.js b/start-mongo.js new file mode 100644 index 0000000..d21dbfa --- /dev/null +++ b/start-mongo.js @@ -0,0 +1,18 @@ +import { MongoMemoryServer } from 'mongodb-memory-server'; +import fs from 'fs'; + +async function start() { + const mongod = await MongoMemoryServer.create(); + const uri = mongod.getUri(); + + // Write to .env + fs.writeFileSync('.env', `MONGO_URI=${uri}\nJWT_SECRET=supersecret\nNODE_ENV=test\nPORT=5000\n`); + + console.log(`MongoDB Memory Server started at ${uri}`); + console.log('Keeping process alive. Press Ctrl+C to stop.'); + + // keep alive + setInterval(() => {}, 1000 * 60 * 60); +} + +start().catch(console.error); diff --git a/test-phase5.js b/test-phase5.js new file mode 100644 index 0000000..033a584 --- /dev/null +++ b/test-phase5.js @@ -0,0 +1,98 @@ +import 'dotenv/config'; +import mongoose from 'mongoose'; +import request from 'supertest'; +import app from './app.js'; +import Category from './src/models/Category.js'; +import Course from './src/models/Course.js'; +import User from './src/models/User.js'; +import jwt from 'jsonwebtoken'; + +async function runTests() { + await mongoose.connect(process.env.MONGO_URI); + console.log('Connected to DB'); + + await Category.deleteMany({}); + await Course.deleteMany({}); + await User.deleteMany({}); + + const user = await User.create({ name: 'Admin User', email: 'admin@phase5.com', password: 'password123', role: 'tutor' }); + const token = jwt.sign({ userId: user._id, sessionId: '123' }, process.env.JWT_SECRET || "deenbridge-temp-secret-key-2024", { expiresIn: '1h' }); + + // 1. Seed categories + const cat1 = await Category.create({ name: 'Fiqh', order: 1 }); + const cat2 = await Category.create({ name: 'Aqeedah', order: 2 }); + + // 2. Create course using the new endpoint with category SLUG + const createRes = await request(app) + .post('/api/courses') + .set('Authorization', `Bearer ${token}`) + .send({ + title: 'Intro to Fiqh', + description: 'Test course', + category: 'fiqh', // Valid slug + price: 10 + }); + + console.log('\n--- createCourse (Valid Slug) ---'); + console.log(JSON.stringify(createRes.body, null, 2)); + + // 3. Create course with INVALID category + const createResInvalid = await request(app) + .post('/api/courses') + .set('Authorization', `Bearer ${token}`) + .send({ + title: 'Intro to Math', + description: 'Test course', + category: 'math', // Invalid + price: 10 + }); + + console.log('\n--- createCourse (Invalid Slug -> 400) ---'); + console.log(JSON.stringify(createResInvalid.body, null, 2)); + + // 4. Update course + const courseId = createRes.body.course._id; + const updateRes = await request(app) + .patch(`/api/courses/${courseId}`) + .set('Authorization', `Bearer ${token}`) + .send({ + category: 'aqeedah' // Change category + }); + + console.log('\n--- updateCourse (Change Category) ---'); + console.log(JSON.stringify(updateRes.body, null, 2)); + + // 5. Create another course to test stats + await request(app) + .post('/api/courses') + .set('Authorization', `Bearer ${token}`) + .send({ + title: 'Advanced Aqeedah', + description: 'Test course 2', + category: 'aqeedah', + price: 0 // free + }); + + // 6. Test GET /api/categories + const catsRes = await request(app).get('/api/categories'); + console.log('\n--- GET /api/categories (Stats) ---'); + console.log(JSON.stringify(catsRes.body, null, 2)); + + // 7. Test GET /api/categories/:slug + const catSlugRes = await request(app).get('/api/categories/aqeedah?sort=price'); + console.log('\n--- GET /api/categories/aqeedah ---'); + console.log(JSON.stringify(catSlugRes.body, null, 2)); + + // 8. Test 404 for unknown slug + const catSlug404 = await request(app).get('/api/categories/unknown-slug'); + console.log('\n--- GET /api/categories/unknown-slug (404) ---'); + console.log(JSON.stringify(catSlug404.body, null, 2)); + + // 9. Test ?category= filter on GET /api/courses + const coursesFilterRes = await request(app).get('/api/courses?category=aqeedah'); + console.log('\n--- GET /api/courses?category=aqeedah ---'); + console.log(JSON.stringify(coursesFilterRes.body, null, 2)); + + await mongoose.disconnect(); +} +runTests().catch(console.error); diff --git a/test-phase6.js b/test-phase6.js new file mode 100644 index 0000000..e73d536 --- /dev/null +++ b/test-phase6.js @@ -0,0 +1,80 @@ +import 'dotenv/config'; +import mongoose from 'mongoose'; +import request from 'supertest'; +import app from './app.js'; +import Category from './src/models/Category.js'; +import User from './src/models/User.js'; +import Course from './src/models/Course.js'; +import jwt from 'jsonwebtoken'; + +async function runTests() { + await mongoose.connect(process.env.MONGO_URI); + console.log('Connected to DB'); + + await Category.deleteMany({}); + await User.deleteMany({}); + await Course.deleteMany({}); + + const admin = await User.create({ name: 'Admin User', email: 'admin@test.com', password: 'password123', role: 'admin' }); + const adminToken = jwt.sign({ userId: admin._id, sessionId: '123' }, process.env.JWT_SECRET || "deenbridge-temp-secret-key-2024", { expiresIn: '1h' }); + + const student = await User.create({ name: 'Student', email: 'student@test.com', password: 'password123', role: 'student' }); + const studentToken = jwt.sign({ userId: student._id, sessionId: '456' }, process.env.JWT_SECRET || "deenbridge-temp-secret-key-2024", { expiresIn: '1h' }); + + // 1. POST /api/categories + console.log('\n--- POST /api/categories (401 - No Token) ---'); + const post401 = await request(app).post('/api/categories').send({ name: 'New Cat' }); + console.log(post401.status, JSON.stringify(post401.body, null, 2)); + + console.log('\n--- POST /api/categories (403 - Wrong Role) ---'); + const post403 = await request(app).post('/api/categories').set('Authorization', `Bearer ${studentToken}`).send({ name: 'New Cat' }); + console.log(post403.status, JSON.stringify(post403.body, null, 2)); + + console.log('\n--- POST /api/categories (Success) ---'); + const post201 = await request(app).post('/api/categories').set('Authorization', `Bearer ${adminToken}`).send({ name: 'New Cat', description: 'desc' }); + console.log(post201.status, JSON.stringify(post201.body, null, 2)); + + const catId = post201.body.category._id; + + // Duplicate key (400) + console.log('\n--- POST /api/categories (Duplicate - 400) ---'); + const postDup = await request(app).post('/api/categories').set('Authorization', `Bearer ${adminToken}`).send({ name: 'New Cat' }); + console.log(postDup.status, JSON.stringify(postDup.body, null, 2)); + + // 2. PATCH /api/categories/:id + console.log('\n--- PATCH /api/categories/:id (401 - No Token) ---'); + const patch401 = await request(app).patch(`/api/categories/${catId}`).send({ name: 'Updated Cat' }); + console.log(patch401.status, JSON.stringify(patch401.body, null, 2)); + + console.log('\n--- PATCH /api/categories/:id (403 - Wrong Role) ---'); + const patch403 = await request(app).patch(`/api/categories/${catId}`).set('Authorization', `Bearer ${studentToken}`).send({ name: 'Updated Cat' }); + console.log(patch403.status, JSON.stringify(patch403.body, null, 2)); + + console.log('\n--- PATCH /api/categories/:id (Success) ---'); + const patch200 = await request(app).patch(`/api/categories/${catId}`).set('Authorization', `Bearer ${adminToken}`).send({ name: 'Updated Cat' }); + console.log(patch200.status, JSON.stringify(patch200.body, null, 2)); + + // 3. DELETE /api/categories/:id + console.log('\n--- DELETE /api/categories/:id (401 - No Token) ---'); + const delete401 = await request(app).delete(`/api/categories/${catId}`); + console.log(delete401.status, JSON.stringify(delete401.body, null, 2)); + + console.log('\n--- DELETE /api/categories/:id (403 - Wrong Role) ---'); + const delete403 = await request(app).delete(`/api/categories/${catId}`).set('Authorization', `Bearer ${studentToken}`); + console.log(delete403.status, JSON.stringify(delete403.body, null, 2)); + + // Soft delete test + await Course.create({ title: 'C', description: 'D', category: 'Updated Cat', categoryRef: catId, createdBy: admin._id }); + console.log('\n--- DELETE /api/categories/:id (Soft Delete Success) ---'); + const softDel = await request(app).delete(`/api/categories/${catId}`).set('Authorization', `Bearer ${adminToken}`); + console.log(softDel.status, JSON.stringify(softDel.body, null, 2)); + + // Hard delete test + await Course.deleteMany({}); + console.log('\n--- DELETE /api/categories/:id (Hard Delete Success) ---'); + const hardDel = await request(app).delete(`/api/categories/${catId}`).set('Authorization', `Bearer ${adminToken}`); + console.log(hardDel.status, JSON.stringify(hardDel.body, null, 2)); + + await mongoose.disconnect(); +} +runTests().catch(console.error); diff --git a/test/category.integration.test.js b/test/category.integration.test.js new file mode 100644 index 0000000..5f9828a --- /dev/null +++ b/test/category.integration.test.js @@ -0,0 +1,137 @@ +import 'dotenv/config'; +import mongoose from 'mongoose'; +import request from 'supertest'; +import app from '../app.js'; +import Category from '../src/models/Category.js'; +import Course from '../src/models/Course.js'; +import User from '../src/models/User.js'; +import jwt from 'jsonwebtoken'; + +describe('Category Integration Tests', () => { + let adminToken; + let studentToken; + let adminUser; + let studentUser; + let catFiqh; + let catAqeedah; + + beforeAll(async () => { + await mongoose.connect(process.env.MONGO_URI); + await Category.deleteMany({}); + await Course.deleteMany({}); + await User.deleteMany({}); + + adminUser = await User.create({ name: 'Admin', email: 'admin@test.com', password: 'password123', role: 'admin' }); + studentUser = await User.create({ name: 'Student', email: 'student@test.com', password: 'password123', role: 'student' }); + + adminToken = jwt.sign({ userId: adminUser._id }, process.env.JWT_SECRET || "deenbridge-temp-secret-key-2024"); + studentToken = jwt.sign({ userId: studentUser._id }, process.env.JWT_SECRET || "deenbridge-temp-secret-key-2024"); + }); + + afterAll(async () => { + await mongoose.disconnect(); + }); + + beforeEach(async () => { + await Category.deleteMany({}); + await Course.deleteMany({}); + }); + + it('Stats aggregation correctness against a seeded fixture with known counts', async () => { + catFiqh = await Category.create({ name: 'Fiqh', order: 1 }); + catAqeedah = await Category.create({ name: 'Aqeedah', order: 2 }); + + // Fiqh: 1 paid course (price 50), 2 enrolled users + const c1 = await Course.create({ + title: 'Fiqh 101', + description: 'Desc', + categoryRef: catFiqh._id, + category: 'Fiqh', + price: 50, + createdBy: adminUser._id, + enrolledUsers: [studentUser._id, adminUser._id] + }); + + // Fiqh: 1 free course (price 0) + const c2 = await Course.create({ + title: 'Fiqh Basics', + description: 'Desc', + categoryRef: catFiqh._id, + category: 'Fiqh', + price: 0, + createdBy: adminUser._id + }); + + // Aqeedah: No courses yet + + const res = await request(app).get('/api/categories'); + expect(res.status).toBe(200); + expect(res.body.categories).toHaveLength(2); + + const fiqhStat = res.body.categories.find(c => c.slug === 'fiqh'); + expect(fiqhStat.courseCount).toBe(2); + expect(fiqhStat.enrollmentCount).toBe(2); // 2 enrolled in c1 + expect(fiqhStat.freeCount).toBe(1); + expect(fiqhStat.paidCount).toBe(1); + expect(fiqhStat.minPrice).toBe(50); + expect(fiqhStat.maxPrice).toBe(50); + + const aqeedahStat = res.body.categories.find(c => c.slug === 'aqeedah'); + expect(aqeedahStat.courseCount).toBe(0); + expect(aqeedahStat.enrollmentCount).toBe(0); + expect(aqeedahStat.freeCount).toBe(0); + expect(aqeedahStat.paidCount).toBe(0); + expect(aqeedahStat.minPrice).toBeNull(); + expect(aqeedahStat.maxPrice).toBeNull(); + }); + + it('Slug collision handling in API', async () => { + await Category.create({ name: "Qur'an" }); // slug: quran + const res = await request(app) + .post('/api/categories') + .set('Authorization', `Bearer ${adminToken}`) + .send({ name: 'Quran' }); // slug: quran-2 + + expect(res.status).toBe(201); + expect(res.body.category.slug).toBe('quran-2'); + }); + + it('GET /api/courses?category= filtering', async () => { + catFiqh = await Category.create({ name: 'Fiqh' }); + await Course.create({ title: 'Fiqh Course', description: 'Desc', categoryRef: catFiqh._id, category: 'Fiqh', createdBy: adminUser._id }); + + const res = await request(app).get('/api/courses?category=fiqh'); + expect(res.status).toBe(200); + expect(res.body.courses).toHaveLength(1); + expect(res.body.courses[0].category).toBe('Fiqh'); + }); + + it('Admin route authorization (401, 403, 201)', async () => { + const res401 = await request(app).post('/api/categories').send({ name: 'Admin Test' }); + expect(res401.status).toBe(401); + + const res403 = await request(app).post('/api/categories').set('Authorization', `Bearer ${studentToken}`).send({ name: 'Admin Test' }); + expect(res403.status).toBe(403); + + const res201 = await request(app).post('/api/categories').set('Authorization', `Bearer ${adminToken}`).send({ name: 'Admin Test' }); + expect(res201.status).toBe(201); + }); + + it('404 on unknown category slug', async () => { + const res = await request(app).get('/api/categories/non-existent-slug'); + expect(res.status).toBe(404); + }); + + it('400 on invalid category in createCourse', async () => { + const res = await request(app) + .post('/api/courses') + .set('Authorization', `Bearer ${adminToken}`) // Admin acts as tutor creating course + .send({ + title: 'Invalid Cat Course', + description: 'Test', + category: 'NonExistent' + }); + expect(res.status).toBe(400); + expect(res.body.message).toMatch(/Invalid or inactive category/); + }); +}); diff --git a/test/category.test.js b/test/category.test.js new file mode 100644 index 0000000..f0cf3a0 --- /dev/null +++ b/test/category.test.js @@ -0,0 +1,76 @@ +import 'dotenv/config'; +import mongoose from 'mongoose'; +import Category, { slugify, generateUniqueSlug } from '../src/models/Category.js'; + +beforeAll(async () => { + await mongoose.connect(process.env.MONGO_URI); +}); + +afterAll(async () => { + await mongoose.disconnect(); +}); + +beforeEach(async () => { + await Category.deleteMany({}); +}); + +describe('Category Model', () => { + describe('Slug Generation Helpers', () => { + it('slugify handles diacritics and apostrophes correctly', () => { + expect(slugify("Qur'an")).toBe('quran'); + expect(slugify('Tafsīr')).toBe('tafsir'); + expect(slugify('Islamic Finance')).toBe('islamic-finance'); + expect(slugify(' Messy Name ')).toBe('messy-name'); + }); + + it('generateUniqueSlug appends suffixes on collision', async () => { + const existing = ['quran', 'quran-2']; + const checker = async (slug) => existing.includes(slug); + + const result = await generateUniqueSlug("Qur'an", checker); + expect(result).toBe('quran-3'); + }); + }); + + describe('Model Pre-save Hook', () => { + it('generates a slug automatically if not provided', async () => { + const category = await Category.create({ name: 'Aqeedah' }); + expect(category.slug).toBe('aqeedah'); + }); + + it('generates unique slugs on collision (different names, same slug)', async () => { + const cat1 = await Category.create({ name: "Qur'an" }); + expect(cat1.slug).toBe('quran'); + + // "Quran" resolves to "quran", which is taken. It should get a suffix. + const cat2 = await Category.create({ name: 'Quran' }); + expect(cat2.slug).toBe('quran-2'); + }); + + it('enforces name uniqueness at the DB level', async () => { + await Category.create({ name: 'Seerah' }); + + let error; + try { + await Category.create({ name: 'Seerah' }); + } catch (err) { + error = err; + } + expect(error).toBeDefined(); + expect(error.code).toBe(11000); // MongoDB duplicate key error code + }); + + it('enforces slug uniqueness at the DB level (if set manually)', async () => { + await Category.create({ name: 'Test1', slug: 'test' }); + + let error; + try { + await Category.create({ name: 'Test2', slug: 'test' }); + } catch (err) { + error = err; + } + expect(error).toBeDefined(); + expect(error.code).toBe(11000); + }); + }); +}); diff --git a/test/jobHandlers.test.js b/test/jobHandlers.test.js new file mode 100644 index 0000000..2db1e39 --- /dev/null +++ b/test/jobHandlers.test.js @@ -0,0 +1,97 @@ +import { jest } from "@jest/globals"; + +const registered = new Map(); +const enqueue = jest.fn().mockResolvedValue({ queued: true }); +const findById = jest.fn(); +const verifyPaymentOperations = jest.fn(); + +jest.unstable_mockModule("../src/jobs/queue.js", () => ({ + registerJob: (name, handler) => registered.set(name, handler), + enqueue, +})); +jest.unstable_mockModule("../src/models/Transaction.js", () => ({ + default: { findById }, +})); +jest.unstable_mockModule("../src/models/User.js", () => ({ + default: { findById: jest.fn(), updateOne: jest.fn() }, +})); +jest.unstable_mockModule("../src/models/Course.js", () => ({ + default: { updateOne: jest.fn() }, +})); +jest.unstable_mockModule("../services/emails/sendMail.js", () => ({ + sendOtpEmail: jest.fn(), + sendReceiptEmail: jest.fn(), +})); +jest.unstable_mockModule("../src/services/stellar/stellarService.js", () => ({ + verifyPaymentOperations, + getExplorerUrl: (hash) => `https://explorer/${hash}`, +})); +jest.unstable_mockModule("../src/services/payoutService.js", () => ({ + recordSaleEarnings: jest.fn(), +})); + +await import("../src/jobs/handlers.js"); + +const transaction = () => ({ + _id: { toString: () => "transaction-id" }, + type: "donation", + status: "retrying", + stellarTxHash: "stellar-hash", + creatorWallet: "destination", + amount: "10", + save: jest.fn().mockResolvedValue(), +}); + +describe("verifyPaymentOnChain job", () => { + const handler = registered.get("verifyPaymentOnChain"); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("keeps transient Horizon failures retryable", async () => { + const record = transaction(); + findById.mockResolvedValue(record); + verifyPaymentOperations.mockResolvedValue({ + verified: false, + transient: true, + reason: "Horizon timeout", + }); + + await expect(handler({ transactionId: "transaction-id" }, { attempt: 1, maxAttempts: 3 })) + .rejects.toThrow("Horizon timeout"); + + expect(record.status).toBe("retrying"); + expect(record.retryCount).toBe(1); + }); + + it("definitively fails after the final transient attempt", async () => { + const record = transaction(); + findById.mockResolvedValue(record); + verifyPaymentOperations.mockResolvedValue({ + verified: false, + transient: true, + reason: "Transaction not found on network", + }); + + await handler({ transactionId: "transaction-id" }, { attempt: 3, maxAttempts: 3 }); + + expect(record.status).toBe("failed"); + expect(record.failureReason).toContain("Transaction not found"); + }); + + it("confirms a verified transaction and enqueues one receipt", async () => { + const record = transaction(); + findById.mockResolvedValue(record); + verifyPaymentOperations.mockResolvedValue({ verified: true }); + + await handler({ transactionId: "transaction-id" }, { attempt: 2, maxAttempts: 3 }); + + expect(record.status).toBe("confirmed"); + expect(enqueue).toHaveBeenCalledWith( + "generateReceipt", + { transactionId: "transaction-id" }, + expect.objectContaining({ idempotencyKey: "receipt:stellar-hash" }) + ); + }); +}); diff --git a/test/jobs.test.js b/test/jobs.test.js new file mode 100644 index 0000000..a4af8c4 --- /dev/null +++ b/test/jobs.test.js @@ -0,0 +1,66 @@ +import { jest } from "@jest/globals"; +import request from "supertest"; +import app from "../app.js"; +import { + enqueue, + registerJob, + resetInlineQueueForTests, + waitForIdle, +} from "../src/jobs/queue.js"; + +describe("background job queue", () => { + beforeEach(() => { + process.env.QUEUE_DRIVER = "inline"; + resetInlineQueueForTests(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("deduplicates jobs with the same idempotency key", async () => { + const handler = jest.fn(); + registerJob("test-idempotency", handler); + + const first = await enqueue("test-idempotency", { recordId: "one" }, { idempotencyKey: "same" }); + const second = await enqueue("test-idempotency", { recordId: "one" }, { idempotencyKey: "same" }); + await waitForIdle(); + + expect(first.queued).toBe(true); + expect(second.duplicate).toBe(true); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("retries failed work with backoff", async () => { + jest.useFakeTimers(); + const handler = jest + .fn() + .mockRejectedValueOnce(new Error("temporary outage")) + .mockRejectedValueOnce(new Error("temporary outage")) + .mockResolvedValueOnce(); + registerJob("test-retry", handler); + + await enqueue( + "test-retry", + { recordId: "retry-me" }, + { attempts: 3, backoffMs: 100, idempotencyKey: "retry-once" } + ); + await jest.runAllTimersAsync(); + await waitForIdle(); + + expect(handler).toHaveBeenCalledTimes(3); + expect(handler.mock.calls.map((call) => call[1].attempt)).toEqual([1, 2, 3]); + }); + + it("protects the jobs dashboard with a bearer token", async () => { + process.env.JOBS_DASHBOARD_TOKEN = "dashboard-secret"; + + const missing = await request(app).get("/admin/jobs"); + const wrong = await request(app) + .get("/admin/jobs") + .set("Authorization", "Bearer wrong"); + + expect(missing.statusCode).toBe(401); + expect(wrong.statusCode).toBe(401); + }); +}); diff --git a/test/migrateCategories.test.js b/test/migrateCategories.test.js new file mode 100644 index 0000000..969e5d2 --- /dev/null +++ b/test/migrateCategories.test.js @@ -0,0 +1,81 @@ +import 'dotenv/config'; +import mongoose from 'mongoose'; +import Course from '../src/models/Course.js'; +import Book from '../src/models/Book.js'; +import Category from '../src/models/Category.js'; +import User from '../src/models/User.js'; +import { runMigration } from '../src/scripts/migrateCategories.js'; + +beforeAll(async () => { + await mongoose.connect(process.env.MONGO_URI); +}); + +afterAll(async () => { + await mongoose.disconnect(); +}); + +beforeEach(async () => { + await Category.deleteMany({}); + await Course.deleteMany({}); + await Book.deleteMany({}); + await User.deleteMany({}); +}); + +describe('Migration Script: Categories', () => { + it('is idempotent and correctly backfills data', async () => { + // 1. Seed fixture data + const user = await User.create({ + name: 'Test Author', + email: 'test@example.com', + password: 'password123', + }); + + // Courses with mixed casings + await Course.create({ title: 'Course 1', description: 'desc', category: "Qur'an", createdBy: user._id }); + await Course.create({ title: 'Course 2', description: 'desc', category: 'Quran', createdBy: user._id }); + await Course.create({ title: 'Course 3', description: 'desc', category: 'quran', createdBy: user._id }); + + // Books with unrelated category + await Book.create({ title: 'Book 1', author: user._id, description: 'desc', image: 'url', fileUrl: 'url', category: 'fIqH' }); + + // Unrelated fallback category + await Course.create({ title: 'Course 4', description: 'desc', category: 'random topic', createdBy: user._id }); + + // 2. First Run + const run1 = await runMigration(true); + + expect(run1.categoriesCreated).toBe(3); // Qur'an, Fiqh, Random Topic + expect(run1.coursesBackfilled).toBe(4); + expect(run1.booksBackfilled).toBe(1); + expect(run1.unmatched).toBe(0); + + const categoriesCount1 = await Category.countDocuments(); + expect(categoriesCount1).toBe(3); + + // Verify canonicalization + const quranCat = await Category.findOne({ name: "Qur'an" }); + expect(quranCat).toBeTruthy(); + + const coursesAfter1 = await Course.find({ categoryRef: quranCat._id }); + expect(coursesAfter1.length).toBe(3); + + const quranCoursesRefs = coursesAfter1.map(c => c.categoryRef.toString()); + + // 3. Second Run (Idempotency) + const run2 = await runMigration(true); + + expect(run2.categoriesCreated).toBe(0); + expect(run2.coursesBackfilled).toBe(0); + expect(run2.booksBackfilled).toBe(0); + expect(run2.unmatched).toBe(0); + + const categoriesCount2 = await Category.countDocuments(); + expect(categoriesCount2).toBe(3); + + // Ensure categoryRefs didn't change + const coursesAfter2 = await Course.find({ categoryRef: quranCat._id }); + const quranCoursesRefs2 = coursesAfter2.map(c => c.categoryRef.toString()); + + expect(quranCoursesRefs).toEqual(quranCoursesRefs2); + }); +}); diff --git a/test/pathPayments.test.js b/test/pathPayments.test.js new file mode 100644 index 0000000..701e7df --- /dev/null +++ b/test/pathPayments.test.js @@ -0,0 +1,172 @@ +import * as StellarSdk from "@stellar/stellar-sdk"; +import { + applySlippage, + calculateFeeSplit, + toStroops, + fromStroops, +} from "../src/services/stellar/stellarService.js"; + +const PLATFORM_WALLET = + "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; + +describe("applySlippage", () => { + it("adds 100 bps (1%) to an amount", () => { + expect(applySlippage("100", 100)).toBe("101"); + }); + + it("adds 50 bps (0.5%) to an amount", () => { + expect(applySlippage("200", 50)).toBe("201"); + }); + + it("handles fractional amounts with 7-decimal precision", () => { + expect(applySlippage("105.5", 100)).toBe("106.555"); + }); + + it("handles 500 bps (5%)", () => { + expect(applySlippage("100", 500)).toBe("105"); + }); + + it("handles 10 bps (0.1%)", () => { + expect(applySlippage("1000", 10)).toBe("1001"); + }); + + it("handles zero bps (no slippage)", () => { + expect(applySlippage("50", 0)).toBe("50"); + }); + + it("produces stroop-exact results", () => { + const result = applySlippage("0.0000001", 100); + const stroops = toStroops(result); + expect(stroops).toBe( + toStroops("0.0000001") + toStroops("0.0000001") * 100n / 10000n + ); + }); + + it("creates a valid sendMax for the quote use case", () => { + const sourceAmount = "525.5"; + const sendMax = applySlippage(sourceAmount, 100); + const sourceStroops = toStroops(sourceAmount); + const sendMaxStroops = toStroops(sendMax); + const diff = sendMaxStroops - sourceStroops; + expect(diff).toBe(sourceStroops * 100n / 10000n); + }); +}); + +describe("pathPaymentStrictReceive XDR shape (built directly via SDK)", () => { + it("builds a transaction with a path_payment_strict_receive operation", () => { + const source = StellarSdk.Keypair.random(); + const account = new StellarSdk.Account(source.publicKey(), "1234"); + + const usdc = new StellarSdk.Asset( + "USDC", + "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" + ); + + const tx = new StellarSdk.TransactionBuilder(account, { + fee: StellarSdk.BASE_FEE, + networkPassphrase: StellarSdk.Networks.TESTNET, + }) + .addOperation( + StellarSdk.Operation.pathPaymentStrictReceive({ + sendAsset: StellarSdk.Asset.native(), + sendMax: "106.555", + destination: PLATFORM_WALLET, + destAsset: usdc, + destAmount: "100", + path: [], + }) + ) + .addMemo(StellarSdk.Memo.text("DNB-TEST")) + .setTimeout(300) + .build(); + + const parsed = StellarSdk.TransactionBuilder.fromXDR( + tx.toXDR(), + StellarSdk.Networks.TESTNET + ); + + expect(parsed.operations).toHaveLength(1); + const op = parsed.operations[0]; + expect(op.type).toBe("pathPaymentStrictReceive"); + expect(toStroops(op.destAmount.toString())).toBe(toStroops("100")); + expect(toStroops(op.sendMax.toString())).toBe(toStroops("106.555")); + expect(op.destination).toBe(PLATFORM_WALLET); + }); + + it("builds two path_payment_strict_receive operations with fee split", () => { + const source = StellarSdk.Keypair.random(); + const account = new StellarSdk.Account(source.publicKey(), "1234"); + const usdc = new StellarSdk.Asset( + "USDC", + "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" + ); + + const feeSplit = calculateFeeSplit("100", 5, PLATFORM_WALLET); + const totalSendMaxStroops = toStroops("106.555"); + const totalDestStroops = toStroops("100"); + const creatorDestStroops = toStroops(feeSplit.creatorAmount); + const creatorSendMaxStroops = + (totalSendMaxStroops * creatorDestStroops) / totalDestStroops; + const platformSendMaxStroops = totalSendMaxStroops - creatorSendMaxStroops; + + const tx = new StellarSdk.TransactionBuilder(account, { + fee: StellarSdk.BASE_FEE, + networkPassphrase: StellarSdk.Networks.TESTNET, + }) + .addOperation( + StellarSdk.Operation.pathPaymentStrictReceive({ + sendAsset: StellarSdk.Asset.native(), + sendMax: fromStroops(creatorSendMaxStroops), + destination: PLATFORM_WALLET, + destAsset: usdc, + destAmount: feeSplit.creatorAmount, + path: [], + }) + ) + .addOperation( + StellarSdk.Operation.pathPaymentStrictReceive({ + sendAsset: StellarSdk.Asset.native(), + sendMax: fromStroops(platformSendMaxStroops), + destination: PLATFORM_WALLET, + destAsset: usdc, + destAmount: feeSplit.platformAmount, + path: [], + }) + ) + .addMemo(StellarSdk.Memo.text("DNB-TEST")) + .setTimeout(300) + .build(); + + const parsed = StellarSdk.TransactionBuilder.fromXDR( + tx.toXDR(), + StellarSdk.Networks.TESTNET + ); + + expect(parsed.operations).toHaveLength(2); + parsed.operations.forEach((op) => { + expect(op.type).toBe("pathPaymentStrictReceive"); + }); + + const op1Dest = toStroops(parsed.operations[0].destAmount); + const op2Dest = toStroops(parsed.operations[1].destAmount); + expect(op1Dest + op2Dest).toBe(toStroops("100")); + + const op1SendMax = toStroops(parsed.operations[0].sendMax); + const op2SendMax = toStroops(parsed.operations[1].sendMax); + expect(op1SendMax + op2SendMax).toBe(totalSendMaxStroops); + }); +}); + +describe("toStroops / fromStroops consistency", () => { + it("round-trips correctly", () => { + const amounts = ["0", "1", "100.5", "0.0000001", "9999.9999999"]; + for (const a of amounts) { + expect(fromStroops(toStroops(a))).toBe(a); + } + }); + + it("fromStroops / toStroops on large numbers", () => { + const large = "1000000000.0000001"; + expect(fromStroops(toStroops(large))).toBe(large); + }); +});