diff --git a/.env.example b/.env.example index 7b8b8cee..63d767fb 100644 --- a/.env.example +++ b/.env.example @@ -36,9 +36,22 @@ SENDLIB_API_URL=https://sendlib.samueltuoyo.com/api/send # SendLib. Prefer a Workspace address on your own domain (e.g. no-reply@deenbridge.app). EMAIL_FROM=no-reply@deenbridge.com -# Stellar blockchain network (testnet or mainnet) +# Stellar blockchain network (testnet or mainnet; "public" is accepted as an +# alias for mainnet). Validated fail-fast at boot — a mainnet flag paired +# with a testnet Horizon URL or USDC issuer aborts startup with the exact +# problem named. See docs/MAINNET.md for the full mainnet switch checklist. STELLAR_NETWORK=testnet +# Soroban loyalty points program (contracts/loyalty-points). The contract id +# (C…55-char StrKey) of the deployed program; the backend only builds +# unsigned XDR and reads state, so no secret keys belong here. +LOYALTY_CONTRACT_ID= + +# Public Soroban RPC endpoint for contract queries/simulation. Optional — +# falls back to https://soroban-testnet.stellar.org on testnet; REQUIRED on +# mainnet where no public default exists. +# SOROBAN_RPC_URL=https://soroban-testnet.stellar.org + # Resilient Horizon Client Configuration (Optional) # HORIZON_URLS=https://horizon-testnet.stellar.org,https://horizon-testnet.stellar.org (Comma-separated list of Horizon endpoints) # HORIZON_TIMEOUT_MS=10000 (Request timeout in milliseconds) @@ -53,6 +66,27 @@ DONATION_WALLET_PUBLIC_KEY=GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX PLATFORM_FEE_PERCENT=0 PLATFORM_WALLET_PUBLIC_KEY=GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +# ── Fee-bump sponsorship (#30) ──────────────────────────────────────────────── +# The platform can pay a user's Stellar network fee by wrapping their signed +# transaction in a fee-bump. A user holding USDC but ~no XLM can then transact. +# All of these are OPTIONAL: with FEE_SPONSOR_ENABLED unset/false (the default) +# the feature is completely inert and the payment/donation flow is unchanged. +# +# Master switch. Leave false to disable sponsorship entirely. +FEE_SPONSOR_ENABLED=false +# DEDICATED fee-source secret (S…) for an account holding a small XLM float used +# ONLY to pay network fees. It MUST NOT be the donation or platform receiving +# wallet, and can never move user funds — it only signs the fee-bump wrapper. +# Required (and validated at boot) when FEE_SPONSOR_ENABLED=true. Never commit a +# real secret; keep it out of source control like the donation secret. +FEE_SPONSOR_SECRET= +# Per-transaction fee ceiling in stroops (default 1000000 = 0.1 XLM). +FEE_SPONSOR_MAX_FEE_STROOPS=1000000 +# Total XLM-fee spend allowed per UTC day, in stroops (default 100000000 = 10 XLM). +FEE_SPONSOR_DAILY_CAP_STROOPS=100000000 +# Max sponsored transactions per user per UTC day (default 10). +FEE_SPONSOR_PER_USER_DAILY_LIMIT=10 + # SEP-1 stellar.toml (/.well-known/stellar.toml) — optional, omitted when blank STELLAR_PLATFORM_PUBLIC_KEY= ORG_NAME= @@ -84,6 +118,31 @@ GIVING_ESCROW_CONTRACT_ID= ACCESS_TOKEN_TTL=15m REFRESH_TOKEN_TTL=30d +# ── Authentication abuse hardening (issue #89) ────────────────────────────── +# Progressive per-account login lockout. After LOGIN_MAX_ATTEMPTS consecutive +# failures the account is locked for an escalating backoff (base * 2^(excess)), +# capped at LOGIN_LOCKOUT_MAX_MS. Clears automatically on success/backoff. +LOGIN_MAX_ATTEMPTS=5 +LOGIN_LOCKOUT_BASE_MS=60000 +LOGIN_LOCKOUT_MAX_MS=86400000 + +# Breached-password check (HaveIBeenPwned range API). Only the 5-char SHA-1 +# prefix is sent; fails OPEN on outage so signups/resets never break. +# HIBP_RANGE_URL=https://api.pwnedpasswords.com/range/ +# HIBP_TIMEOUT_MS=2000 + +# Per-email signup / verification-resend throttle (survives IP rotation). +RATE_LIMIT_EMAIL_AUTH_MAX=20 +RATE_LIMIT_EMAIL_AUTH_WINDOW_MS=900000 +# RATE_LIMIT_EMAIL_AUTH_DISABLE=true + +# Optional captcha gate for /register and /resend-verification. No-op when +# unset; fails OPEN on provider outage. Supports hCaptcha (default) and +# Google reCAPTCHA v2/v3 via CAPTCHA_VERIFY_URL override. +# CAPTCHA_SECRET_KEY= +# CAPTCHA_VERIFY_URL=https://hcaptcha.com/siteverify +# CAPTCHA_TIMEOUT_MS=5000 + # 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 @@ -106,3 +165,31 @@ JOBS_ENABLED=true QUEUE_DRIVER=mongo JOBS_DASHBOARD_TOKEN=replace_with_a_long_random_token +# Service-to-service auth for the AI service (dnb-ai). A JSON array of signed, +# scoped, rotatable keys keyed by `kid`. REQUIRED in production (boot fails +# fast if missing); optional in dev/test. Keep >=1 entry active; to rotate, +# add a new active kid, deploy, switch dnb-ai over, then set the old kid +# "active": false. See docs/service-to-service-auth.md. +# AI_SERVICE_KEYS=[{"kid":"k1","secret":"replace_with_a_long_random_secret","scopes":["ai:read-content"],"active":true}] + + +# ── Outbound webhooks (issue #45) ──────────────────────────────────────────── +# Webhook signing secrets are stored ENCRYPTED at rest (AES-256-GCM). This key +# derives the encryption key (SHA-256). REQUIRED in production (boot fails fast +# if missing); a fixed dev fallback is used in development/test. Rotating this +# invalidates all stored secrets — rotate per-endpoint secrets via the API. +# WEBHOOK_SECRET_ENCRYPTION_KEY=replace_with_a_long_random_value +# Enable the interval delivery worker on this process (like INGESTION_WORKER_ENABLED). +WEBHOOK_WORKER_ENABLED=false +# Delivery loop poll interval (ms). +WEBHOOK_POLL_INTERVAL_MS=5000 +# Total attempts before a delivery is dead-lettered. +WEBHOOK_MAX_ATTEMPTS=6 +# Consecutive dead deliveries that auto-disable an endpoint. +WEBHOOK_AUTO_DISABLE_THRESHOLD=5 +# Max random jitter (ms) added to each backoff delay. +WEBHOOK_BACKOFF_JITTER_MS=30000 +# Per-request HTTP timeout for delivery POSTs (ms). +WEBHOOK_HTTP_TIMEOUT_MS=10000 +# Payload envelope version advertised to consumers. +WEBHOOK_API_VERSION=2025-01-01 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 09b7f9b0..292a8948 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: services: mongodb: - image: mongo:7 + image: mongo:6.0 ports: - 27017:27017 @@ -24,12 +24,23 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' cache: 'npm' - name: Install dependencies run: npm ci + - name: Wait for MongoDB + run: | + for i in $(seq 1 30); do + if nc -z localhost 27017 2>/dev/null || (exec 6<>/dev/tcp/localhost/27017) 2>/dev/null; then + echo "MongoDB is listening on port 27017" + break + fi + echo "Waiting for MongoDB..." + sleep 1 + done + - name: Run tests run: npm test env: @@ -41,16 +52,19 @@ jobs: CLOUDINARY_CLOUD_NAME: ci_test_cloud CLOUDINARY_API_KEY: ci_test_key CLOUDINARY_API_SECRET: ci_test_secret_that_should_not_leak - build: name: Syntax and Boot Check runs-on: ubuntu-latest services: mongodb: - image: mongo:7 + image: mongo:6.0 ports: - 27017:27017 + redis: + image: redis:7 + ports: + - 6379:6379 steps: - name: Checkout code @@ -59,12 +73,34 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' cache: 'npm' - name: Install dependencies run: npm ci + - name: Wait for MongoDB + run: | + for i in $(seq 1 30); do + if nc -z localhost 27017 2>/dev/null || (exec 6<>/dev/tcp/localhost/27017) 2>/dev/null; then + echo "MongoDB is listening on port 27017" + break + fi + echo "Waiting for MongoDB..." + sleep 1 + done + + - name: Wait for Redis + run: | + for i in $(seq 1 30); do + if nc -z localhost 6379 2>/dev/null || (exec 6<>/dev/tcp/localhost/6379) 2>/dev/null; then + echo "Redis is listening on port 6379" + break + fi + echo "Waiting for Redis..." + sleep 1 + done + - name: Check syntax of all source files run: | find . -name "*.js" -not -path "./node_modules/*" -print0 \ @@ -94,3 +130,4 @@ jobs: CLOUDINARY_CLOUD_NAME: ci_test_cloud CLOUDINARY_API_KEY: ci_test_key CLOUDINARY_API_SECRET: ci_test_secret_that_should_not_leak + REDIS_URL: redis://localhost:6379 diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml new file mode 100644 index 00000000..602b7001 --- /dev/null +++ b/.github/workflows/contracts.yml @@ -0,0 +1,37 @@ +name: Contracts CI + +on: + pull_request: + branches: [main, dev] + paths: + - "contracts/**" + - ".github/workflows/contracts.yml" + push: + branches: [main, dev] + paths: + - "contracts/**" + - ".github/workflows/contracts.yml" + +jobs: + rust: + name: Rust contract checks + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32v1-none + components: rustfmt, clippy + + - name: Check formatting + run: cargo fmt --manifest-path contracts/Cargo.toml -- --check + + - name: Run clippy + run: cargo clippy --manifest-path contracts/Cargo.toml --all-targets -- -D warnings + + - name: Run contract tests + run: cargo test --manifest-path contracts/Cargo.toml diff --git a/README.md b/README.md index 4800763d..af0bb777 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ The platform is composed of three services: - 🎓 **Course Management** — create, enroll, review, and track courses - 📚 **Digital Library** — upload, purchase, and read Islamic books - ⭐ **Stellar Payments** — USDC payment initialize → sign → submit → on-chain verify flow +- ⛽ **Fee Sponsorship** — optional platform-paid network fees via fee-bump, with a structural whitelist and spend caps ([docs](docs/fee-sponsorship.md)) - 👛 **Wallet Management** — connect Freighter, xBull, or Albedo; balance and trustline checks - 💬 **Real-time** — Socket.io messaging and notifications - ☁️ **Media** — Cloudinary uploads for avatars, covers, books, and reels @@ -76,12 +77,14 @@ The API runs at `http://localhost:5000`. | `PORT` | Server port (default `5000`) | | `MONGO_URI` | MongoDB connection string | | `JWT_SECRET` | Secret for signing tokens (32+ chars) | -| `STELLAR_NETWORK` | `testnet` or `mainnet` | +| `STELLAR_NETWORK` | `testnet` or `mainnet` (`public` accepted; validated at boot) | +| — | **Switching to mainnet? See [docs/MAINNET.md](docs/MAINNET.md)** — env changes, creator trustlines, smoke-test checklist | | `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` | | `STELLAR_PLATFORM_PUBLIC_KEY` | Public key published in `stellar.toml` `ACCOUNTS[]` | +| `FEE_SPONSOR_ENABLED` | Turn on platform-paid network fees (fee-bump). Off by default; when on, `FEE_SPONSOR_SECRET` is validated at boot ([docs](docs/fee-sponsorship.md)) | See `.env.example` for the full list. @@ -142,3 +145,6 @@ Read **[CONTRIBUTING.md](CONTRIBUTING.md)** for the full workflow, coding standa - 🌐 Website: [dnb-frontend.vercel.app](https://dnb-frontend.vercel.app) - 🐦 X/Twitter: [@deen_bridge](https://x.com/deen_bridge) - 🏢 Organization: [github.com/Deen-Bridge](https://github.com/Deen-Bridge) +# Course categories + +Seed the curated Islamic-discipline taxonomy with `npm run seed:categories`. Existing free-text course and book categories can be linked without removing their legacy string values by running `npm run migrate:categories`. Both commands are idempotent and require `MONGO_URI`. diff --git a/app.js b/app.js index 316d1e2c..12312200 100644 --- a/app.js +++ b/app.js @@ -2,27 +2,18 @@ import express from "express"; import cors from "cors"; import cookieParser from "cookie-parser"; import compression from "compression"; -import dotenv from "dotenv"; import crypto from "crypto"; import "./src/jobs/handlers.js"; -// Load env vars, except in tests where test/jest.setup.js has already loaded -// (and stripped) secrets — re-loading .env here would leak SMTP/REDIS creds -// back into the test process and cause real network calls. -if (process.env.NODE_ENV !== "test") { - dotenv.config(); -} - -import connectDB from "./src/config/db.js"; -import validateEnv from "./src/config/validateEnv.js"; import logger from "./src/config/logger.js"; -import { registry, metricsMiddleware, observeHttpDuration } from "./src/config/metrics.js"; +import { metricsMiddleware, observeHttpDuration } from "./src/config/metrics.js"; import { helmetMiddleware, standardLimiter, generousLimiter, authLimiter, + paymentLimiter, mongoSanitizeMiddleware, hppMiddleware, customSecurityHeaders, @@ -31,39 +22,49 @@ import { sanitizeInput } from "./src/middlewares/validate.js"; import { errorHandler, notFound, - handleUnhandledRejection, - handleUncaughtException, } from "./src/middlewares/errorHandler.js"; import authRoutes from "./src/routes/authRoutes.js"; import courseRoutes from "./src/routes/courses/courseRoutes.js"; +import courseAnalyticsRoutes from "./src/routes/courses/analyticsRoutes.js"; import reelsRoute from "./src/routes/reelsRoutes.js"; import userRoutes from "./src/routes/userRoutes.js"; import bookRoutes from "./src/routes/books/bookRoutes.js"; import recommendedBooksRoutes from "./src/routes/books/recommendedBooksRoutes.js"; +import readingProgressRoutes from "./src/routes/books/readingProgressRoutes.js"; import spacesRoutes from "./src/routes/spaceRoutes.js"; import emailRoutes from "./src/routes/emailRoutes.js"; import purchaseRoutes from "./src/routes/books/purchaseBookRoutes.js"; import searchRoutes from "./src/routes/searchRoutes.js"; import callRoutes from "./src/routes/callRoutes.js"; import stellarWalletRoutes from "./src/routes/stellar/walletRoutes.js"; +import stellarAnalyticsRoutes from "./src/routes/stellar/analyticsRoutes.js"; import stellarPaymentRoutes from "./src/routes/stellar/paymentRoutes.js"; import stellarDonationRoutes from "./src/routes/stellar/donationRoutes.js"; +import stellarOnrampRoutes from "./src/routes/stellar/onrampRoutes.js"; +import stellarPledgeRoutes from "./src/routes/stellar/pledgeRoutes.js"; +import stellarGiftRoutes from "./src/routes/stellar/giftRoutes.js"; import payoutRoutes from "./src/routes/payoutRoutes.js"; import uploadRoutes from "./src/routes/uploadRoutes.js"; import notificationRoutes from "./src/routes/notificationRoutes.js"; import jobsRoutes from "./src/routes/jobsRoutes.js"; +import internalAiRoutes from "./src/routes/internal/aiRoutes.js"; import wellKnownRoutes from "./src/routes/wellKnownRoutes.js"; import auditRoutes from "./src/routes/admin/auditRoutes.js"; import educatorRoutes from "./src/routes/educatorRoutes.js"; - -handleUncaughtException(); -validateEnv(); - -// Connect to MongoDB (skip during tests as tests handle their own connections) -if (process.env.NODE_ENV !== "test") { - connectDB(); -} +import educatorVerificationRoutes from "./src/routes/educatorVerificationRoutes.js"; +import educatorVerificationAdminRoutes from "./src/routes/admin/educatorVerificationAdminRoutes.js"; +import webhookRoutes from "./src/routes/webhookRoutes.js"; +import adminModerationRoutes from "./src/routes/admin/moderationRoutes.js"; +import categoryRoutes from "./src/routes/categoryRoutes.js"; +import readingGroupRoutes from "./src/routes/readingGroupRoutes.js"; +import courseBundleRoutes from "./src/routes/course-bundle.routes.js"; +import certificateRoutes from "./src/routes/certificate.routes.js"; +import badgeRoutes from "./src/routes/badge.routes.js"; +import messagingRoutes from "./src/routes/messaging.routes.js"; +import { healthCheck, ping } from "./src/controllers/healthController.js"; +import databaseHealthRoutes from "./src/routes/health/database.js"; +import databaseMetricsRoutes from "./src/routes/metrics/database.js"; const app = express(); @@ -143,7 +144,17 @@ const corsOptions = { app.use(cors(corsOptions)); -app.use(express.json({ limit: "10mb" })); +// Capture the raw request bytes so the service-to-service auth middleware can +// verify HMAC signatures over the exact body (see middlewares/serviceAuth.js). +// This only stashes a Buffer reference and does not alter parsing behaviour. +app.use( + express.json({ + limit: "10mb", + verify: (req, _res, buf) => { + req.rawBody = buf; + }, + }) +); app.use(express.urlencoded({ extended: true, limit: "10mb" })); app.use(cookieParser()); app.use(compression()); @@ -164,13 +175,9 @@ app.get("/", (req, res) => { }); }); -app.get("/health", (req, res) => { - res.json({ - success: true, - message: "pong", - timestamp: new Date().toISOString(), - }); -}); +app.get("/ping", ping); +app.get("/health", healthCheck); +app.use("/health/database", databaseHealthRoutes); // SEP-1 stellar.toml — must be outside /api rate limiter app.use("/.well-known", wellKnownRoutes); @@ -185,23 +192,54 @@ app.use("/api/uploads", standardLimiter, uploadRoutes); app.use("/api/payouts", standardLimiter, payoutRoutes); // Read-heavy & content routes — generous limiter +// Creator analytics is mounted before the generic course routes so the static +// "/analytics" segment is not captured by the courseRoutes "/:id" matcher. +app.use("/api/courses/analytics", generousLimiter, courseAnalyticsRoutes); +app.use("/api/course-bundles", generousLimiter, courseBundleRoutes); +app.use("/api/courses/bundles", generousLimiter, courseBundleRoutes); +app.use("/api/certificates", generousLimiter, certificateRoutes); +app.use("/api/badges", generousLimiter, badgeRoutes); app.use("/api/courses", generousLimiter, courseRoutes); +app.use("/api/categories", generousLimiter, categoryRoutes); app.use("/api/reels", generousLimiter, reelsRoute); app.use("/api/books", generousLimiter, bookRoutes); app.use("/api/books", generousLimiter, recommendedBooksRoutes); +// Reading progress sync (#203) — resume position, cross-device sync, library %. +app.use("/api/books", generousLimiter, readingProgressRoutes); +app.use("/api/books/reading-groups", generousLimiter, readingGroupRoutes); +app.use("/api/reading-groups", generousLimiter, readingGroupRoutes); app.use("/api/spaces", generousLimiter, spacesRoutes); app.use("/api/users", generousLimiter, userRoutes); app.use("/api/search", generousLimiter, searchRoutes); app.use("/api/calls", generousLimiter, callRoutes); app.use("/api/educators", generousLimiter, educatorRoutes); +app.use("/api/educator-verification", standardLimiter, educatorVerificationRoutes); app.use("/api/stellar/wallet", generousLimiter, stellarWalletRoutes); -app.use("/api/stellar/payment", generousLimiter, stellarPaymentRoutes); +app.use("/api/stellar/analytics", generousLimiter, stellarAnalyticsRoutes); +// Payment routes mutate money state — stricter per-user limiter (issue #4). +app.use("/api/stellar/payment", paymentLimiter, stellarPaymentRoutes); app.use("/api/stellar/donation", generousLimiter, stellarDonationRoutes); +app.use("/api/stellar/onramp", generousLimiter, stellarOnrampRoutes); +app.use("/api/stellar/pledges", generousLimiter, stellarPledgeRoutes); +app.use("/api/stellar/gifts", generousLimiter, stellarGiftRoutes); app.use("/api/notifications", generousLimiter, notificationRoutes); +app.use("/api/messaging", generousLimiter, messagingRoutes); + +// Outbound webhook management API (admin-gated) +app.use("/api/webhooks", standardLimiter, webhookRoutes); + +// Internal service-to-service (dnb-ai) — signed-request auth, no user JWTs +app.use("/api/internal/ai", internalAiRoutes); + +// MongoDB connection-pool metrics (Prometheus text format) — see +// docs/connection-pool-metrics.md for scrape config + Grafana panels. +app.use("/metrics/database", databaseMetricsRoutes); // Admin — no rate limit app.use("/admin/jobs", jobsRoutes); app.use("/api/admin/audit", auditRoutes); +app.use("/api/admin/educator-verification", educatorVerificationAdminRoutes); +app.use("/api/admin/moderation", adminModerationRoutes); // ====================== // ERROR HANDLING @@ -209,7 +247,6 @@ app.use("/api/admin/audit", auditRoutes); app.use(notFound); app.use(errorHandler); -handleUnhandledRejection(); logger.info("DeenBridge API initialized"); logger.info(`Logging enabled - Level: ${logger.level}`); diff --git a/contracts/.gitignore b/contracts/.gitignore new file mode 100644 index 00000000..2d67a90c --- /dev/null +++ b/contracts/.gitignore @@ -0,0 +1,2 @@ +target/ +test_snapshots/ diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock new file mode 100644 index 00000000..a30dd318 --- /dev/null +++ b/contracts/Cargo.lock @@ -0,0 +1,2121 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "arbitrary" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5a26814d8dcb93b0e5a0ff3c6d80a8843bafb21b39e8e18a6f05471870e110" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "ark-bls12-381" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df4dcc01ff89867cd86b0da835f23c3f02738353aaee7dde7495af71363b8d5" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-serialize", + "ark-std", +] + +[[package]] +name = "ark-bn254" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d69eab57e8d2663efa5c63135b2af4f396d66424f88954c21104125ab6b3e6bc" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-std", +] + +[[package]] +name = "ark-ec" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d68f2d516162846c1238e755a7c4d131b892b70cc70c471a8e3ca3ed818fce" +dependencies = [ + "ahash", + "ark-ff", + "ark-poly", + "ark-serialize", + "ark-std", + "educe", + "fnv", + "hashbrown 0.15.5", + "itertools", + "num-bigint", + "num-integer", + "num-traits", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a177aba0ed1e0fbb62aa9f6d0502e9b46dad8c2eab04c14258a1212d2557ea70" +dependencies = [ + "ark-ff-asm", + "ark-ff-macros", + "ark-serialize", + "ark-std", + "arrayvec", + "digest 0.10.7", + "educe", + "itertools", + "num-bigint", + "num-traits", + "paste", + "zeroize", +] + +[[package]] +name = "ark-ff-asm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ark-ff-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09be120733ee33f7693ceaa202ca41accd5653b779563608f1234f78ae07c4b3" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ark-poly" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "579305839da207f02b89cd1679e50e67b4331e2f9294a57693e5051b7703fe27" +dependencies = [ + "ahash", + "ark-ff", + "ark-serialize", + "ark-std", + "educe", + "fnv", + "hashbrown 0.15.5", +] + +[[package]] +name = "ark-serialize" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7" +dependencies = [ + "ark-serialize-derive", + "ark-std", + "arrayvec", + "digest 0.10.7", + "num-bigint", +] + +[[package]] +name = "ark-serialize-derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213888f660fddcca0d257e88e54ac05bca01885f258ccdf695bafd77031bb69d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ark-std" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a" +dependencies = [ + "num-traits", + "rand", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes-lit" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b04f2b1d34cb428043f14aa4c853d14294532e8bbde3b6a3bc2faaaae31a1dd" +dependencies = [ + "num-bigint", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "cc" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_eval" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45565fc9416b9896014f5732ac776f810ee53a66730c17e4020c3ec064a8f88f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crate-git-revision" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c521bf1f43d31ed2f73441775ed31935d77901cb3451e44b38a1c1612fcbaf98" +dependencies = [ + "serde", + "serde_derive", + "serde_json", +] + +[[package]] +name = "crate-git-revision" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54851b5b3f24621804b1cded2820975623c205e3055d2d44031cdb1237339ac8" +dependencies = [ + "serde", + "serde_derive", + "serde_json", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctor" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67773048316103656a637612c4a62477603b777d91d9c62ff2290f9cde178fdb" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2" + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto 0.2.9", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto 0.3.0", + "rustc_version", + "subtle", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_arbitrary" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67e77553c4162a157adbf834ebae5b415acbecbeafc7a74b0e886657506a7611" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid", + "crypto-common 0.1.6", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dtor" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404d02eeb088a82cfd873006cb713fe411306c7d182c344905e101fb1167d301" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek 4.1.3", + "ed25519", + "rand_core", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "enum-ordinalize" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89dd01549b09589510cf0647475075d12071456586d70f5c75c98ae2a5537677" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a65863d15a4ce2888bd2f0f543cc963d3879c3a022c8ee43f6141d479a3ac815" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "escape-bytes" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfcf67fea2815c2fc3b90873fae90957be12ff417335dfadc7f52927feb03b2" + +[[package]] +name = "ethnum" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40404c3f5f511ec4da6fe866ddf6a717c309fdbb69fbbad7b0f3edab8f2e835f" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +dependencies = [ + "serde", +] + +[[package]] +name = "hex-literal" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "indexmap-nostd" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e04e2fd2b8188ea827b32ef11de88377086d690286ab35747ef7f9bf3ccb590" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "sha2", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "macro-string" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b27834086c65ec3f9387b096d66e99f221cf081c2b738042aa252bcd41204e3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "scholarship-escrow" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "subtle", + "zeroize", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_with" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +dependencies = [ + "base64", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "jiff", + "schemars 0.8.22", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "soroban-builtin-sdk-macros" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b77bc93d930032c487cb1506b6ed166b2af49db76d52678ec4887ac621ecce01" +dependencies = [ + "itertools", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "soroban-env-common" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b22e9981cdd444f3aa6734bc58d76195bf7eca3ccf1dd432b875af5d02da068" +dependencies = [ + "arbitrary", + "crate-git-revision 0.0.6", + "ethnum", + "num-derive", + "num-traits", + "serde", + "soroban-env-macros", + "soroban-wasmi", + "static_assertions", + "stellar-xdr", + "wasmparser", +] + +[[package]] +name = "soroban-env-guest" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6072f99ca6bf8e8d5b04e05d083dac785e5357d9c0f36a6658f819c2fd7d67" +dependencies = [ + "soroban-env-common", + "static_assertions", +] + +[[package]] +name = "soroban-env-host" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c06afd7c75ce150ce53e4d77a77645b18e3fb61856a0ddc42bfcecdc39fa3b9" +dependencies = [ + "ark-bls12-381", + "ark-bn254", + "ark-ec", + "ark-ff", + "ark-serialize", + "curve25519-dalek 5.0.0", + "ecdsa", + "ed25519-dalek", + "elliptic-curve", + "generic-array", + "getrandom", + "hex-literal", + "hmac", + "k256", + "num-derive", + "num-integer", + "num-traits", + "p256", + "rand", + "rand_chacha", + "sec1", + "sha2", + "sha3", + "soroban-builtin-sdk-macros", + "soroban-env-common", + "soroban-wasmi", + "static_assertions", + "stellar-strkey 0.0.13", + "wasmparser", +] + +[[package]] +name = "soroban-env-macros" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "647811bdd28a3ec40296987f6635781e5e1141c8f5affbbd53ba12b6295b7bb6" +dependencies = [ + "itertools", + "proc-macro2", + "quote", + "serde", + "serde_json", + "stellar-xdr", + "syn 2.0.119", +] + +[[package]] +name = "soroban-ledger-snapshot" +version = "27.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b59883d8bd0d1aed8d57579a9974ab88eaf787dd0a1af104f6881b5707450558" +dependencies = [ + "serde", + "serde_json", + "serde_with", + "soroban-env-common", + "soroban-env-host", + "thiserror 1.0.69", +] + +[[package]] +name = "soroban-sdk" +version = "27.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c3f21971c84fcfb08957e3e8f5a9a70f134cb07ad9ee053ac7e6d7a887a82af" +dependencies = [ + "arbitrary", + "bytes-lit", + "crate-git-revision 0.0.9", + "ctor", + "derive_arbitrary", + "ed25519-dalek", + "rand", + "rustc_version", + "serde", + "serde_json", + "soroban-env-guest", + "soroban-env-host", + "soroban-ledger-snapshot", + "soroban-sdk-macros", + "stellar-strkey 0.0.16", + "visibility", +] + +[[package]] +name = "soroban-sdk-macros" +version = "27.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bd4a847273d749807fe2eb52e2b9c1917ee482cd6a39465cad5c389548996ad" +dependencies = [ + "darling 0.20.11", + "heck", + "itertools", + "macro-string", + "proc-macro2", + "quote", + "sha2", + "soroban-env-common", + "soroban-spec", + "soroban-spec-rust", + "stellar-xdr", + "syn 2.0.119", +] + +[[package]] +name = "soroban-spec" +version = "27.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473404322827b285cbcd87517f365986bd63af7842c78b2a86ee061715fda61e" +dependencies = [ + "base64", + "sha2", + "stellar-xdr", + "thiserror 1.0.69", + "wasmparser", +] + +[[package]] +name = "soroban-spec-rust" +version = "27.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f25698b6ce2125850a9ef075cf9ba1e8d25b4cfa0c46aca42dadd80cc29d881" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "sha2", + "soroban-spec", + "stellar-xdr", + "syn 2.0.119", + "thiserror 1.0.69", +] + +[[package]] +name = "soroban-wasmi" +version = "0.31.1-soroban.20.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710403de32d0e0c35375518cb995d4fc056d0d48966f2e56ea471b8cb8fc9719" +dependencies = [ + "smallvec", + "spin", + "wasmi_arena", + "wasmi_core", + "wasmparser-nostd", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "stellar-strkey" +version = "0.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee1832fb50c651ad10f734aaf5d31ca5acdfb197a6ecda64d93fcdb8885af913" +dependencies = [ + "crate-git-revision 0.0.6", + "data-encoding", +] + +[[package]] +name = "stellar-strkey" +version = "0.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "084afcb0d458c3d5d5baa2d294b18f881e62cc258ef539d8fdf68be7dbe45520" +dependencies = [ + "crate-git-revision 0.0.6", + "data-encoding", + "heapless", +] + +[[package]] +name = "stellar-xdr" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05ff843326969bdf1ef673dcdba94c08f4a3c8f1e58d6e6ef39b1bd4f749179a" +dependencies = [ + "arbitrary", + "base64", + "cfg_eval", + "crate-git-revision 0.0.6", + "escape-bytes", + "ethnum", + "hex", + "serde", + "serde_with", + "sha2", + "stellar-strkey 0.0.13", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "visibility" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasmi_arena" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "104a7f73be44570cac297b3035d76b169d6599637631cf37a1703326a0727073" + +[[package]] +name = "wasmi_core" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcf1a7db34bff95b85c261002720c00c3a6168256dcb93041d3fa2054d19856a" +dependencies = [ + "downcast-rs", + "libm", + "num-traits", + "paste", +] + +[[package]] +name = "wasmparser" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a58e28b80dd8340cb07b8242ae654756161f6fc8d0038123d679b7b99964fa50" +dependencies = [ + "indexmap 2.14.0", + "semver", +] + +[[package]] +name = "wasmparser-nostd" +version = "0.100.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5a015fe95f3504a94bb1462c717aae75253e39b9dd6c3fb1062c934535c64aa" +dependencies = [ + "indexmap-nostd", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml new file mode 100644 index 00000000..7067e861 --- /dev/null +++ b/contracts/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] +resolver = "2" +members = ["scholarship_escrow"] diff --git a/contracts/README.md b/contracts/README.md new file mode 100644 index 00000000..1d7aaad4 --- /dev/null +++ b/contracts/README.md @@ -0,0 +1,60 @@ +# Scholarship Escrow Contract + +This directory contains the Stage 1 Soroban scholarship escrow contract for +issue 34. The contract stores an immutable scholarship schedule, accepts +non-custodial donor funding in a Stellar Asset Contract token, releases exact +milestone amounts after arbiter authorization, and permits pro-rata refunds +after expiry. + +## Local Setup + +Install Rust with `rustup`, then install the target used by current Soroban +tooling: + +```bash +rustup target add wasm32v1-none +``` + +Install the Stellar CLI using the official Stellar CLI instructions. Check the +local toolchain before building: + +```bash +rustc --version +cargo --version +stellar --version +``` + +## Test and Format + +Run these commands from the repository root: + +```bash +cargo fmt --manifest-path contracts/Cargo.toml -- --check +cargo test --manifest-path contracts/Cargo.toml +cargo clippy --manifest-path contracts/Cargo.toml --all-targets -- -D warnings +``` + +## Build + +Build the contract through Cargo: + +```bash +cargo build --manifest-path contracts/Cargo.toml \ + --package scholarship-escrow \ + --target wasm32v1-none \ + --release +``` + +The resulting WASM is written to +`contracts/target/wasm32v1-none/release/scholarship_escrow.wasm`. + +The Stellar CLI can also build the workspace once it is installed: + +```bash +stellar contract build --package scholarship-escrow +``` + +Testnet deployment, SAC wrapping for the configured USDC issuer, contract ID +configuration, and the unsigned JavaScript invocation flow are Stage 2 work. +Do not put secret keys in this repository or in application environment +variables. diff --git a/contracts/loyalty-points/Cargo.toml b/contracts/loyalty-points/Cargo.toml new file mode 100644 index 00000000..c9308cad --- /dev/null +++ b/contracts/loyalty-points/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "loyalty-points" +version = "0.1.0" +edition = "2021" +publish = false +description = "Deen Bridge loyalty points: earn, redeem and transfer platform reward points on Soroban" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = "=27.0.6" + +[dev-dependencies] +soroban-sdk = { version = "=27.0.6", features = ["testutils"] } diff --git a/contracts/loyalty-points/src/lib.rs b/contracts/loyalty-points/src/lib.rs new file mode 100644 index 00000000..0a59bc9c --- /dev/null +++ b/contracts/loyalty-points/src/lib.rs @@ -0,0 +1,673 @@ +//! Deen Bridge Loyalty Points — on-chain points system for platform activities. +//! +//! Users earn points for purchases, referrals, and milestones. Points live in +//! an internal ledger (not a SAC token) so the admin keeps full control over +//! issuance, while earning/redemption/transfers remain fully on-chain and +//! auditable through indexable events: +//! +//! - **Earning**: `earn` mints points per configurable per-activity rules +//! (points-per-unit-spend for purchases, flat bonuses for referrals and +//! milestones). Rules are set by the admin via `set_rate`. +//! - **Redemption**: `redeem` burns points against discounts/rewards; the +//! discount itself is granted off-chain by the backend after the burn. +//! - **Transfers**: users can gift points to other users via `transfer`. +//! +//! Storage layout mirrors scholarship_escrow: one persistent `State` entry, +//! per-user `Balance` entries, and per-activity `Rate` entries. + +#![no_std] + +use soroban_sdk::{ + contract, contracterror, contractevent, contractimpl, contracttype, Address, Env, Symbol, +}; + +/// One whole unit of a 7-decimal Stellar asset (e.g. USDC), used to normalize +/// purchase spend into whole units before applying the points rate. +const ASSET_PRECISION: i128 = 10_000_000; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum Error { + AlreadyInitialized = 1, + NotInitialized = 2, + InvalidAmount = 3, + InvalidRate = 4, + RateNotSet = 5, + InsufficientBalance = 6, + ArithmeticOverflow = 7, + SameAccount = 8, +} + +/// Platform activities that award loyalty points. +#[contracttype] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum Activity { + /// Course/book purchase — points scale with the amount spent. + Purchase = 0, + /// Successful user referral — flat bonus. + Referral = 1, + /// Platform milestone (e.g. first course completed) — flat bonus. + Milestone = 2, +} + +/// Global counters and admin identity. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LoyaltyState { + pub admin: Address, + pub total_issued: i128, + pub total_redeemed: i128, +} + +#[contracttype] +#[derive(Clone)] +enum DataKey { + State, + Balance(Address), + Rate(Activity), +} + +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Initialized { + #[topic] + pub admin: Address, +} + +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RateUpdated { + #[topic] + pub activity: Symbol, + pub rate: i128, +} + +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Earned { + #[topic] + pub user: Address, + #[topic] + pub activity: Symbol, + pub earned: i128, + pub balance: i128, +} + +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Minted { + #[topic] + pub user: Address, + pub amount: i128, + pub balance: i128, +} + +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Redeemed { + #[topic] + pub user: Address, + pub amount: i128, + pub balance: i128, +} + +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Transferred { + #[topic] + pub from: Address, + #[topic] + pub to: Address, + pub amount: i128, + pub from_balance: i128, + pub to_balance: i128, +} + +#[contract] +pub struct LoyaltyPoints; + +#[contractimpl] +impl LoyaltyPoints { + /// Initialize the program with its admin (the platform issuer account). + pub fn init(env: Env, admin: Address) -> Result<(), Error> { + if env.storage().persistent().has(&DataKey::State) { + return Err(Error::AlreadyInitialized); + } + + admin.require_auth(); + + let state = LoyaltyState { + admin: admin.clone(), + total_issued: 0, + total_redeemed: 0, + }; + env.storage().persistent().set(&DataKey::State, &state); + + Initialized { admin }.publish(&env); + + Ok(()) + } + + /// Configure how many points an activity awards. + /// + /// For [`Activity::Purchase`] the rate is expressed as points per whole + /// asset unit of spend (a spend of 5 USDC at rate 100 awards 500 points). + /// For referrals and milestones it is the flat bonus per event. + pub fn set_rate(env: Env, activity: Activity, rate: i128) -> Result<(), Error> { + let mut state = Self::load_state(&env); + state.admin.require_auth(); + + if rate < 0 { + return Err(Error::InvalidRate); + } + + env.storage() + .persistent() + .set(&DataKey::Rate(activity.clone()), &rate); + + RateUpdated { + activity: Self::activity_symbol(&env, &activity), + rate, + } + .publish(&env); + + Ok(()) + } + + /// Award points to `user` for completing `activity`. + /// + /// `spend_amount` is only meaningful for purchases (the amount spent, in + /// raw asset units); it is ignored for flat-bonus activities. The user + /// authorizes the claim so points cannot be attributed without consent. + pub fn earn( + env: Env, + user: Address, + activity: Activity, + spend_amount: i128, + ) -> Result { + let mut state = Self::load_state(&env); + Self::ensure_spend_valid(activity, spend_amount)?; + + let rate = env + .storage() + .persistent() + .get::<_, i128>(&DataKey::Rate(activity.clone())) + .ok_or(Error::RateNotSet)?; + if rate == 0 { + return Err(Error::RateNotSet); + } + + let earned = match activity { + // Points scale with spend: (spend / precision) * rate, floored. + Activity::Purchase => spend_amount + .checked_mul(rate) + .ok_or(Error::ArithmeticOverflow)? + / ASSET_PRECISION, + _ => rate, + }; + + user.require_auth(); + + let balance_key = DataKey::Balance(user.clone()); + let balance = env + .storage() + .persistent() + .get(&balance_key) + .unwrap_or(0_i128) + .checked_add(earned) + .ok_or(Error::ArithmeticOverflow)?; + + state.total_issued = state + .total_issued + .checked_add(earned) + .ok_or(Error::ArithmeticOverflow)?; + + env.storage().persistent().set(&balance_key, &balance); + env.storage().persistent().set(&DataKey::State, &state); + + Earned { + user: user.clone(), + activity: Self::activity_symbol(&env, &activity), + earned, + balance, + } + .publish(&env); + + Ok(balance) + } + + /// Admin-only issuance for support/reward flows outside the standard + /// earning rules (e.g. contest prizes). + pub fn mint(env: Env, user: Address, amount: i128) -> Result { + let mut state = Self::load_state(&env); + state.admin.require_auth(); + + if amount <= 0 { + return Err(Error::InvalidAmount); + } + + let balance_key = DataKey::Balance(user.clone()); + let balance = env + .storage() + .persistent() + .get(&balance_key) + .unwrap_or(0_i128) + .checked_add(amount) + .ok_or(Error::ArithmeticOverflow)?; + + state.total_issued = state + .total_issued + .checked_add(amount) + .ok_or(Error::ArithmeticOverflow)?; + + env.storage().persistent().set(&balance_key, &balance); + env.storage().persistent().set(&DataKey::State, &state); + + Minted { + user: user.clone(), + amount, + balance, + } + .publish(&env); + + Ok(balance) + } + + /// Burn points as payment for a discount/reward. The reward itself is + /// granted by the backend once this redemption is observed on-chain. + pub fn redeem(env: Env, user: Address, amount: i128) -> Result { + let mut state = Self::load_state(&env); + + if amount <= 0 { + return Err(Error::InvalidAmount); + } + + user.require_auth(); + + let balance_key = DataKey::Balance(user.clone()); + let balance = env + .storage() + .persistent() + .get(&balance_key) + .unwrap_or(0_i128); + if balance < amount { + return Err(Error::InsufficientBalance); + } + + let new_balance = balance - amount; + state.total_redeemed = state + .total_redeemed + .checked_add(amount) + .ok_or(Error::ArithmeticOverflow)?; + + env.storage().persistent().set(&balance_key, &new_balance); + env.storage().persistent().set(&DataKey::State, &state); + + Redeemed { + user: user.clone(), + amount, + balance: new_balance, + } + .publish(&env); + + Ok(new_balance) + } + + /// Move points between two users (gifting). The sender authorizes. + pub fn transfer(env: Env, from: Address, to: Address, amount: i128) -> Result<(), Error> { + if amount <= 0 { + return Err(Error::InvalidAmount); + } + if from == to { + return Err(Error::SameAccount); + } + + from.require_auth(); + + let from_key = DataKey::Balance(from.clone()); + let from_balance = env + .storage() + .persistent() + .get(&from_key) + .unwrap_or(0_i128); + if from_balance < amount { + return Err(Error::InsufficientBalance); + } + + let to_key = DataKey::Balance(to.clone()); + let to_balance = env + .storage() + .persistent() + .get(&to_key) + .unwrap_or(0_i128) + .checked_add(amount) + .ok_or(Error::ArithmeticOverflow)?; + + env.storage() + .persistent() + .set(&from_key, &(from_balance - amount)); + env.storage().persistent().set(&to_key, &to_balance); + + Transferred { + from, + to, + amount, + from_balance: from_balance - amount, + to_balance, + } + .publish(&env); + + Ok(()) + } + + /// Current point balance of `user`. + pub fn balance(env: Env, user: Address) -> i128 { + env.storage() + .persistent() + .get(&DataKey::Balance(user)) + .unwrap_or(0) + } + + /// Program state (admin, totals) for transparency dashboards. + pub fn state(env: Env) -> LoyaltyState { + Self::load_state(&env) + } + + /// Configured award rate for `activity`; `None`-equivalent is an error + /// path in `try_rate`, here it surfaces as 0 for view simplicity. + pub fn rate(env: Env, activity: Activity) -> i128 { + env.storage() + .persistent() + .get(&DataKey::Rate(activity)) + .unwrap_or(0) + } + + fn load_state(env: &Env) -> LoyaltyState { + env.storage().persistent().get(&DataKey::State).unwrap() + } + + fn ensure_spend_valid(activity: Activity, spend_amount: i128) -> Result<(), Error> { + match activity { + Activity::Purchase if spend_amount <= 0 => Err(Error::InvalidAmount), + _ => Ok(()), + } + } + + fn activity_symbol(env: &Env, activity: &Activity) -> Symbol { + Symbol::new( + env, + match activity { + Activity::Purchase => "purchase", + Activity::Referral => "referral", + Activity::Milestone => "milestone", + }, + ) + } +} + +#[cfg(test)] +mod test { + extern crate std; + + use super::*; + use soroban_sdk::{ + testutils::{Address as _, AuthorizedFunction, Events as _}, + Env, Event, IntoVal, Symbol, + }; + + struct Context { + env: Env, + contract_id: Address, + admin: Address, + user: Address, + other: Address, + } + + impl Context { + fn client(&self) -> LoyaltyPointsClient<'_> { + LoyaltyPointsClient::new(&self.env, &self.contract_id) + } + } + + fn context() -> Context { + let env = Env::default(); + let admin = Address::generate(&env); + let user = Address::generate(&env); + let other = Address::generate(&env); + + let contract_id = env.register(LoyaltyPoints, ()); + let client = LoyaltyPointsClient::new(&env, &contract_id); + + env.mock_all_auths(); + client.init(&admin); + + Context { + env, + contract_id, + admin, + user, + other, + } + } + + #[test] + fn init_stores_admin_and_emits_event() { + let ctx = context(); + assert_eq!(ctx.client().state().admin, ctx.admin); + assert_eq!(ctx.client().state().total_issued, 0); + assert_eq!( + ctx.env.events().all().filter_by_contract(&ctx.contract_id).events(), + &[Initialized { + admin: ctx.admin.clone(), + } + .to_xdr(&ctx.env, &ctx.contract_id)] + ); + } + + #[test] + fn purchase_points_scale_with_spend_and_configured_rate() { + let ctx = context(); + ctx.client().set_rate(&Activity::Purchase, &100); + + // 5 whole units spent at 100 points/unit → 500 points. + assert_eq!(ctx.client().earn(&ctx.user, &Activity::Purchase, &50_000_000), 500); + assert_eq!(ctx.client().balance(&ctx.user), 500); + assert_eq!(ctx.client().state().total_issued, 500); + } + + #[test] + fn flat_activities_award_their_bonus_exactly_once_per_claim() { + let ctx = context(); + ctx.client().set_rate(&Activity::Referral, &250); + ctx.client().set_rate(&Activity::Milestone, &1_000); + + assert_eq!(ctx.client().earn(&ctx.user, &Activity::Referral, &0), 250); + assert_eq!(ctx.client().earn(&ctx.other, &Activity::Milestone, &0), 1_000); + assert_eq!(ctx.client().state().total_issued, 1_250); + } + + #[test] + fn unconfigured_rates_are_rejected_rather_than_awarding_zero() { + let ctx = context(); + assert_eq!( + ctx.client().try_earn(&ctx.user, &Activity::Milestone, &0), + Err(Ok(Error::RateNotSet)) + ); + + // Explicitly zeroed rates behave the same way. + ctx.client().set_rate(&Activity::Referral, &0); + assert_eq!( + ctx.client().try_earn(&ctx.user, &Activity::Referral, &0), + Err(Ok(Error::RateNotSet)) + ); + } + + #[test] + fn purchase_requires_positive_spend() { + let ctx = context(); + ctx.client().set_rate(&Activity::Purchase, &100); + assert_eq!( + ctx.client().try_earn(&ctx.user, &Activity::Purchase, &0), + Err(Ok(Error::InvalidAmount)) + ); + } + + #[test] + fn redemption_burns_points_and_tracks_totals() { + let ctx = context(); + ctx.client().mint(&ctx.user, &400); + assert_eq!(ctx.client().redeem(&ctx.user, &150), 250); + assert_eq!(ctx.client().balance(&ctx.user), 250); + assert_eq!(ctx.client().state().total_redeemed, 150); + + assert_eq!( + ctx.client().try_redeem(&ctx.user, &251), + Err(Ok(Error::InsufficientBalance)) + ); + } + + #[test] + fn transfers_move_balances_between_users() { + let ctx = context(); + ctx.client().mint(&ctx.user, &300); + ctx.client().transfer(&ctx.user, &ctx.other, &180); + + assert_eq!(ctx.client().balance(&ctx.user), 120); + assert_eq!(ctx.client().balance(&ctx.other), 180); + assert_eq!( + ctx.client().try_transfer(&ctx.user, &ctx.other, &121), + Err(Ok(Error::InsufficientBalance)) + ); + } + + #[test] + fn transfers_to_self_are_rejected() { + let ctx = context(); + ctx.client().mint(&ctx.user, &300); + assert_eq!( + ctx.client().try_transfer(&ctx.user, &ctx.user, &10), + Err(Ok(Error::SameAccount)) + ); + } + + #[test] + fn invalid_mints_and_redemptions_are_rejected() { + let ctx = context(); + assert_eq!( + ctx.client().try_mint(&ctx.user, &0), + Err(Ok(Error::InvalidAmount)) + ); + assert_eq!( + ctx.client().try_redeem(&ctx.user, &0), + Err(Ok(Error::InvalidAmount)) + ); + assert_eq!( + ctx.client().try_set_rate(&Activity::Purchase, &-1), + Err(Ok(Error::InvalidRate)) + ); + } + + #[test] + fn initialization_cannot_run_twice() { + let ctx = context(); + assert_eq!( + ctx.client().try_init(&ctx.admin), + Err(Ok(Error::AlreadyInitialized)) + ); + } + + #[test] + fn earning_requires_user_authorization() { + let ctx = context(); + ctx.client().set_rate(&Activity::Referral, &250); + ctx.env.set_auths(&[]); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + ctx.client().earn(&ctx.user, &Activity::Referral, &0); + })); + assert!(result.is_err()); + } + + #[test] + fn rate_changes_require_admin_authorization() { + let ctx = context(); + ctx.env.set_auths(&[]); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + ctx.client().set_rate(&Activity::Purchase, &100); + })); + assert!(result.is_err()); + } + + #[test] + fn redemption_requires_user_authorization() { + let ctx = context(); + ctx.client().mint(&ctx.user, &100); + ctx.env.set_auths(&[]); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + ctx.client().redeem(&ctx.user, &50); + })); + assert!(result.is_err()); + } + + #[test] + fn state_changes_emit_indexable_events() { + let ctx = context(); + ctx.client().set_rate(&Activity::Purchase, &100); + + assert_eq!( + ctx.env.events().all().filter_by_contract(&ctx.contract_id).events(), + &[RateUpdated { + activity: Symbol::new(&ctx.env, "purchase"), + rate: 100, + } + .to_xdr(&ctx.env, &ctx.contract_id)] + ); + + ctx.client().earn(&ctx.user, &Activity::Purchase, &30_000_000); + assert_eq!( + ctx.env.events().all().filter_by_contract(&ctx.contract_id).events(), + &[Earned { + user: ctx.user.clone(), + activity: Symbol::new(&ctx.env, "purchase"), + earned: 300, + balance: 300, + } + .to_xdr(&ctx.env, &ctx.contract_id)] + ); + + ctx.client().redeem(&ctx.user, &100); + assert_eq!( + ctx.env.events().all().filter_by_contract(&ctx.contract_id).events(), + &[Redeemed { + user: ctx.user.clone(), + amount: 100, + balance: 200, + } + .to_xdr(&ctx.env, &ctx.contract_id)] + ); + } + + #[test] + fn auth_tree_contains_admin_and_user_authorizations() { + let ctx = context(); + ctx.client().set_rate(&Activity::Purchase, &100); + let rate_auths = ctx.env.auths(); + assert!(rate_auths.iter().any(|(address, _)| address == &ctx.admin)); + + ctx.client().earn(&ctx.user, &Activity::Purchase, &10_000_000); + let earn_auths = ctx.env.auths(); + assert!(earn_auths + .iter() + .any(|(address, invocation)| address == &ctx.user + && invocation.function + == AuthorizedFunction::Contract(( + ctx.contract_id.clone(), + Symbol::new(&ctx.env, "earn"), + (&ctx.user, &Activity::Purchase, &10_000_000_i128).into_val(&ctx.env), + )))); + } +} diff --git a/contracts/scholarship_escrow/Cargo.toml b/contracts/scholarship_escrow/Cargo.toml new file mode 100644 index 00000000..065b96e6 --- /dev/null +++ b/contracts/scholarship_escrow/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "scholarship-escrow" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = "=27.0.6" + +[dev-dependencies] +soroban-sdk = { version = "=27.0.6", features = ["testutils"] } diff --git a/contracts/scholarship_escrow/src/lib.rs b/contracts/scholarship_escrow/src/lib.rs new file mode 100644 index 00000000..c5a9db4f --- /dev/null +++ b/contracts/scholarship_escrow/src/lib.rs @@ -0,0 +1,829 @@ +#![no_std] + +use soroban_sdk::{ + contract, contracterror, contractevent, contractimpl, contracttype, token, Address, Env, Vec, +}; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum Error { + AlreadyInitialized = 1, + EmptyMilestones = 2, + InvalidExpiry = 3, + InvalidMilestoneAmount = 4, + InvalidMilestoneState = 5, + MilestoneTotalOverflow = 6, + InvalidAmount = 7, + FundingCapExceeded = 8, + EscrowExpired = 9, + InvalidMilestoneIndex = 10, + MilestoneAlreadyReleased = 11, + InsufficientFunds = 12, + DonorNotFound = 13, + AlreadyRefunded = 14, + NoRefundAvailable = 15, + ArithmeticOverflow = 16, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Milestone { + pub amount: i128, + pub released: bool, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EscrowState { + pub arbiter: Address, + pub beneficiary: Address, + pub token: Address, + pub expiry: u32, + pub milestone_total: i128, + pub funded_total: i128, + pub released_total: i128, + pub refunded_total: i128, + pub refund_pool: Option, +} + +#[contracttype] +enum DataKey { + State, + Milestones, + Donors, + DonorContribution(Address), + DonorRefund(Address), + RefundClaimed(Address), +} + +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Initialized { + #[topic] + pub arbiter: Address, + #[topic] + pub beneficiary: Address, + pub token: Address, + pub expiry: u32, + pub milestone_total: i128, +} + +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Funded { + #[topic] + pub donor: Address, + pub amount: i128, + pub donor_total: i128, + pub funded_total: i128, +} + +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MilestoneApproved { + #[topic] + pub index: u32, + pub amount: i128, + pub released_total: i128, +} + +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Refunded { + #[topic] + pub donor: Address, + pub amount: i128, + pub refunded_total: i128, +} + +#[contract] +pub struct ScholarshipEscrow; + +#[contractimpl] +impl ScholarshipEscrow { + pub fn init( + env: Env, + arbiter: Address, + beneficiary: Address, + token: Address, + milestones: Vec, + expiry: u32, + ) -> Result<(), Error> { + if env.storage().persistent().has(&DataKey::State) { + return Err(Error::AlreadyInitialized); + } + + arbiter.require_auth(); + + if milestones.is_empty() { + return Err(Error::EmptyMilestones); + } + if expiry <= env.ledger().sequence() { + return Err(Error::InvalidExpiry); + } + + let mut milestone_total = 0_i128; + for milestone in milestones.iter() { + if milestone.amount <= 0 { + return Err(Error::InvalidMilestoneAmount); + } + if milestone.released { + return Err(Error::InvalidMilestoneState); + } + milestone_total = milestone_total + .checked_add(milestone.amount) + .ok_or(Error::MilestoneTotalOverflow)?; + } + + let state = EscrowState { + arbiter: arbiter.clone(), + beneficiary: beneficiary.clone(), + token: token.clone(), + expiry, + milestone_total, + funded_total: 0, + released_total: 0, + refunded_total: 0, + refund_pool: None, + }; + + env.storage().persistent().set(&DataKey::State, &state); + env.storage() + .persistent() + .set(&DataKey::Milestones, &milestones); + env.storage() + .persistent() + .set(&DataKey::Donors, &Vec::
::new(&env)); + + Initialized { + arbiter, + beneficiary, + token, + expiry, + milestone_total, + } + .publish(&env); + + Ok(()) + } + + pub fn fund(env: Env, donor: Address, amount: i128) -> Result<(), Error> { + let mut state = Self::load_state(&env); + Self::ensure_active(&env, &state)?; + + if amount <= 0 { + return Err(Error::InvalidAmount); + } + + let funded_total = state + .funded_total + .checked_add(amount) + .ok_or(Error::ArithmeticOverflow)?; + if funded_total > state.milestone_total { + return Err(Error::FundingCapExceeded); + } + + donor.require_auth(); + + let donor_key = DataKey::DonorContribution(donor.clone()); + let donor_total = env + .storage() + .persistent() + .get(&donor_key) + .unwrap_or(0_i128) + .checked_add(amount) + .ok_or(Error::ArithmeticOverflow)?; + + let mut donors: Vec
= env + .storage() + .persistent() + .get(&DataKey::Donors) + .unwrap_or_else(|| Vec::new(&env)); + if !donors.contains(&donor) { + donors.push_back(donor.clone()); + env.storage().persistent().set(&DataKey::Donors, &donors); + } + + let token_client = token::Client::new(&env, &state.token); + token_client.transfer(&donor, env.current_contract_address(), &amount); + + state.funded_total = funded_total; + env.storage().persistent().set(&DataKey::State, &state); + env.storage().persistent().set(&donor_key, &donor_total); + + Funded { + donor, + amount, + donor_total, + funded_total, + } + .publish(&env); + + Ok(()) + } + + pub fn approve_milestone(env: Env, index: u32) -> Result<(), Error> { + let mut state = Self::load_state(&env); + Self::ensure_active(&env, &state)?; + state.arbiter.require_auth(); + + let mut milestones: Vec = env + .storage() + .persistent() + .get(&DataKey::Milestones) + .unwrap(); + let mut milestone = milestones.get(index).ok_or(Error::InvalidMilestoneIndex)?; + + if milestone.released { + return Err(Error::MilestoneAlreadyReleased); + } + + let available = state + .funded_total + .checked_sub(state.released_total) + .and_then(|value| value.checked_sub(state.refunded_total)) + .ok_or(Error::ArithmeticOverflow)?; + if available < milestone.amount { + return Err(Error::InsufficientFunds); + } + + let released_total = state + .released_total + .checked_add(milestone.amount) + .ok_or(Error::ArithmeticOverflow)?; + + let token_client = token::Client::new(&env, &state.token); + token_client.transfer( + &env.current_contract_address(), + &state.beneficiary, + &milestone.amount, + ); + + milestone.released = true; + milestones.set(index, milestone.clone()); + state.released_total = released_total; + env.storage() + .persistent() + .set(&DataKey::Milestones, &milestones); + env.storage().persistent().set(&DataKey::State, &state); + + MilestoneApproved { + index, + amount: milestone.amount, + released_total, + } + .publish(&env); + + Ok(()) + } + + pub fn refund(env: Env, donor: Address) -> Result { + let mut state = Self::load_state(&env); + if env.ledger().sequence() < state.expiry { + return Err(Error::InvalidExpiry); + } + + donor.require_auth(); + + let donor_key = DataKey::DonorContribution(donor.clone()); + let contribution: i128 = env + .storage() + .persistent() + .get(&donor_key) + .ok_or(Error::DonorNotFound)?; + let claimed_key = DataKey::RefundClaimed(donor.clone()); + if env + .storage() + .persistent() + .get(&claimed_key) + .unwrap_or(false) + { + return Err(Error::AlreadyRefunded); + } + + let refund_pool = match state.refund_pool { + Some(pool) => pool, + None => { + let pool = state + .funded_total + .checked_sub(state.released_total) + .and_then(|value| value.checked_sub(state.refunded_total)) + .ok_or(Error::ArithmeticOverflow)?; + if pool <= 0 { + return Err(Error::NoRefundAvailable); + } + state.refund_pool = Some(pool); + pool + } + }; + + if refund_pool <= 0 { + return Err(Error::NoRefundAvailable); + } + + let donors: Vec
= env + .storage() + .persistent() + .get(&DataKey::Donors) + .unwrap_or_else(|| Vec::new(&env)); + let unclaimed_donors = donors + .iter() + .filter(|address| { + !env.storage() + .persistent() + .get(&DataKey::RefundClaimed(address.clone())) + .unwrap_or(false) + }) + .count(); + + let amount = if unclaimed_donors == 1 { + refund_pool + .checked_sub(state.refunded_total) + .ok_or(Error::ArithmeticOverflow)? + } else { + contribution + .checked_mul(refund_pool) + .ok_or(Error::ArithmeticOverflow)? + / state.funded_total + }; + + let refunded_total = state + .refunded_total + .checked_add(amount) + .ok_or(Error::ArithmeticOverflow)?; + + if amount > 0 { + let token_client = token::Client::new(&env, &state.token); + token_client.transfer(&env.current_contract_address(), &donor, &amount); + } + + env.storage().persistent().set(&claimed_key, &true); + env.storage() + .persistent() + .set(&DataKey::DonorRefund(donor.clone()), &amount); + state.refunded_total = refunded_total; + env.storage().persistent().set(&DataKey::State, &state); + + Refunded { + donor, + amount, + refunded_total, + } + .publish(&env); + + Ok(amount) + } + + pub fn state(env: Env) -> EscrowState { + Self::load_state(&env) + } + + pub fn funded_total(env: Env) -> i128 { + Self::load_state(&env).funded_total + } + + pub fn milestone(env: Env, index: u32) -> Milestone { + let milestones: Vec = env + .storage() + .persistent() + .get(&DataKey::Milestones) + .unwrap(); + milestones.get(index).unwrap() + } + + pub fn donor_contribution(env: Env, donor: Address) -> i128 { + env.storage() + .persistent() + .get(&DataKey::DonorContribution(donor)) + .unwrap_or(0) + } + + fn load_state(env: &Env) -> EscrowState { + env.storage().persistent().get(&DataKey::State).unwrap() + } + + fn ensure_active(env: &Env, state: &EscrowState) -> Result<(), Error> { + if env.ledger().sequence() >= state.expiry { + Err(Error::EscrowExpired) + } else { + Ok(()) + } + } +} + +#[cfg(test)] +mod test { + extern crate std; + + use super::*; + use soroban_sdk::{ + testutils::{Address as _, AuthorizedFunction, Events as _, Ledger as _}, + token::StellarAssetClient, + Env, Event, IntoVal, Symbol, + }; + + const NOW: u32 = 1_000; + const EXPIRY: u32 = 2_000; + + struct Context { + env: Env, + contract_id: Address, + token: Address, + arbiter: Address, + beneficiary: Address, + donor_a: Address, + donor_b: Address, + } + + impl Context { + fn client(&self) -> ScholarshipEscrowClient<'_> { + ScholarshipEscrowClient::new(&self.env, &self.contract_id) + } + + fn token_client(&self) -> StellarAssetClient<'_> { + StellarAssetClient::new(&self.env, &self.token) + } + } + + fn context() -> Context { + let env = Env::default(); + env.ledger().set_sequence_number(NOW); + + let arbiter = Address::generate(&env); + let beneficiary = Address::generate(&env); + let donor_a = Address::generate(&env); + let donor_b = Address::generate(&env); + + let token_contract = env.register_stellar_asset_contract_v2(arbiter.clone()); + let token = token_contract.address(); + let token_client = StellarAssetClient::new(&env, &token); + + let contract_id = env.register(ScholarshipEscrow, ()); + let client = ScholarshipEscrowClient::new(&env, &contract_id); + + env.mock_all_auths(); + token_client.mint(&donor_a, &1_000); + token_client.mint(&donor_b, &1_000); + client.init( + &arbiter, + &beneficiary, + &token, + &soroban_sdk::vec![ + &env, + Milestone { + amount: 600, + released: false, + }, + Milestone { + amount: 400, + released: false, + }, + ], + &EXPIRY, + ); + + Context { + env, + contract_id, + token, + arbiter, + beneficiary, + donor_a, + donor_b, + } + } + + #[test] + fn init_stores_fixed_state_and_emits_event() { + let ctx = context(); + let events = ctx.env.events().all().filter_by_contract(&ctx.contract_id); + assert_eq!( + events.events(), + &[Initialized { + arbiter: ctx.arbiter.clone(), + beneficiary: ctx.beneficiary.clone(), + token: ctx.token.clone(), + expiry: EXPIRY, + milestone_total: 1_000, + } + .to_xdr(&ctx.env, &ctx.contract_id)] + ); + + let state = ctx.client().state(); + assert_eq!(state.arbiter, ctx.arbiter); + assert_eq!(state.beneficiary, ctx.beneficiary); + assert_eq!(state.token, ctx.token); + assert_eq!(state.expiry, EXPIRY); + assert_eq!(state.milestone_total, 1_000); + assert_eq!(ctx.client().milestone(&0).amount, 600); + assert!(!ctx.client().milestone(&0).released); + } + + #[test] + fn funding_is_tracked_per_donor_and_capped_at_milestone_total() { + let ctx = context(); + ctx.client().fund(&ctx.donor_a, &600); + ctx.client().fund(&ctx.donor_b, &400); + + assert_eq!(ctx.client().funded_total(), 1_000); + assert_eq!(ctx.client().donor_contribution(&ctx.donor_a), 600); + assert_eq!(ctx.client().donor_contribution(&ctx.donor_b), 400); + assert_eq!(ctx.token_client().balance(&ctx.contract_id), 1_000); + assert_eq!( + ctx.client().try_fund(&ctx.donor_a, &1), + Err(Ok(Error::FundingCapExceeded)) + ); + } + + #[test] + fn arbiter_releases_exact_milestone_amount_once() { + let ctx = context(); + ctx.client().fund(&ctx.donor_a, &600); + ctx.client().approve_milestone(&0); + + assert_eq!(ctx.token_client().balance(&ctx.beneficiary), 600); + assert_eq!(ctx.client().state().released_total, 600); + assert!(ctx.client().milestone(&0).released); + assert_eq!( + ctx.client().try_approve_milestone(&0), + Err(Ok(Error::MilestoneAlreadyReleased)) + ); + } + + #[test] + fn cannot_release_more_than_funded() { + let ctx = context(); + ctx.client().fund(&ctx.donor_a, &500); + + assert_eq!( + ctx.client().try_approve_milestone(&0), + Err(Ok(Error::InsufficientFunds)) + ); + } + + #[test] + fn refunds_are_pro_rata_after_expiry_and_return_the_rounding_remainder() { + let ctx = context(); + ctx.client().fund(&ctx.donor_a, &600); + ctx.client().fund(&ctx.donor_b, &400); + ctx.client().approve_milestone(&0); + ctx.env.ledger().set_sequence_number(EXPIRY); + + assert_eq!(ctx.client().refund(&ctx.donor_b), 160); + assert_eq!(ctx.client().refund(&ctx.donor_a), 240); + assert_eq!(ctx.token_client().balance(&ctx.donor_a), 640); + assert_eq!(ctx.token_client().balance(&ctx.donor_b), 760); + assert_eq!(ctx.token_client().balance(&ctx.contract_id), 0); + assert_eq!(ctx.client().state().refunded_total, 400); + assert_eq!( + ctx.client().try_refund(&ctx.donor_a), + Err(Ok(Error::AlreadyRefunded)) + ); + } + + #[test] + fn refund_rounding_dust_is_paid_to_the_final_claimant() { + let ctx = context(); + ctx.client().fund(&ctx.donor_a, &333); + ctx.client().fund(&ctx.donor_b, &667); + ctx.client().approve_milestone(&0); + ctx.env.ledger().set_sequence_number(EXPIRY); + + assert_eq!(ctx.client().refund(&ctx.donor_a), 133); + assert_eq!(ctx.client().refund(&ctx.donor_b), 267); + assert_eq!(ctx.client().state().refunded_total, 400); + assert_eq!(ctx.token_client().balance(&ctx.contract_id), 0); + } + + #[test] + fn refund_before_expiry_is_rejected() { + let ctx = context(); + ctx.client().fund(&ctx.donor_a, &600); + + assert_eq!( + ctx.client().try_refund(&ctx.donor_a), + Err(Ok(Error::InvalidExpiry)) + ); + } + + #[test] + fn funding_and_approval_stop_at_expiry() { + let ctx = context(); + ctx.env.ledger().set_sequence_number(EXPIRY); + + assert_eq!( + ctx.client().try_fund(&ctx.donor_a, &1), + Err(Ok(Error::EscrowExpired)) + ); + assert_eq!( + ctx.client().try_approve_milestone(&0), + Err(Ok(Error::EscrowExpired)) + ); + } + + #[test] + fn funding_requires_donor_authorization() { + let ctx = context(); + ctx.env.set_auths(&[]); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + ctx.client().fund(&ctx.donor_a, &1); + })); + assert!(result.is_err()); + } + + #[test] + fn approval_requires_arbiter_authorization() { + let ctx = context(); + ctx.client().fund(&ctx.donor_a, &600); + ctx.env.set_auths(&[]); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + ctx.client().approve_milestone(&0); + })); + assert!(result.is_err()); + } + + #[test] + fn refund_requires_donor_authorization() { + let ctx = context(); + ctx.client().fund(&ctx.donor_a, &600); + ctx.env.ledger().set_sequence_number(EXPIRY); + ctx.env.set_auths(&[]); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + ctx.client().refund(&ctx.donor_a); + })); + assert!(result.is_err()); + } + + #[test] + fn state_changes_emit_indexable_events() { + let ctx = context(); + + ctx.client().fund(&ctx.donor_a, &600); + assert_eq!( + ctx.env + .events() + .all() + .filter_by_contract(&ctx.contract_id) + .events(), + &[Funded { + donor: ctx.donor_a.clone(), + amount: 600, + donor_total: 600, + funded_total: 600, + } + .to_xdr(&ctx.env, &ctx.contract_id)] + ); + + ctx.client().fund(&ctx.donor_b, &400); + ctx.client().approve_milestone(&0); + assert_eq!( + ctx.env + .events() + .all() + .filter_by_contract(&ctx.contract_id) + .events(), + &[MilestoneApproved { + index: 0, + amount: 600, + released_total: 600, + } + .to_xdr(&ctx.env, &ctx.contract_id)] + ); + + ctx.env.ledger().set_sequence_number(EXPIRY); + ctx.client().refund(&ctx.donor_b); + assert_eq!( + ctx.env + .events() + .all() + .filter_by_contract(&ctx.contract_id) + .events(), + &[Refunded { + donor: ctx.donor_b.clone(), + amount: 160, + refunded_total: 160, + } + .to_xdr(&ctx.env, &ctx.contract_id)] + ); + } + + #[test] + fn auth_tree_contains_donor_and_arbiter_authorizations() { + let ctx = context(); + ctx.client().fund(&ctx.donor_a, &600); + let fund_auths = ctx.env.auths(); + assert!(fund_auths + .iter() + .any(|(address, _)| address == &ctx.donor_a)); + + ctx.client().approve_milestone(&0); + let approval_auths = ctx.env.auths(); + assert!(approval_auths.iter().any(|(address, invocation)| { + address == &ctx.arbiter + && invocation.function + == AuthorizedFunction::Contract(( + ctx.contract_id.clone(), + Symbol::new(&ctx.env, "approve_milestone"), + (&0_u32,).into_val(&ctx.env), + )) + })); + } + + #[test] + fn invalid_initialization_constraints_are_rejected() { + let env = Env::default(); + env.ledger().set_sequence_number(NOW); + let arbiter = Address::generate(&env); + let beneficiary = Address::generate(&env); + let token = Address::generate(&env); + let contract_id = env.register(ScholarshipEscrow, ()); + let client = ScholarshipEscrowClient::new(&env, &contract_id); + + env.mock_all_auths(); + assert_eq!( + client.try_init( + &arbiter, + &beneficiary, + &token, + &soroban_sdk::Vec::new(&env), + &EXPIRY, + ), + Err(Ok(Error::EmptyMilestones)) + ); + assert_eq!( + client.try_init( + &arbiter, + &beneficiary, + &token, + &soroban_sdk::vec![ + &env, + Milestone { + amount: 0, + released: false, + } + ], + &EXPIRY, + ), + Err(Ok(Error::InvalidMilestoneAmount)) + ); + assert_eq!( + client.try_init( + &arbiter, + &beneficiary, + &token, + &soroban_sdk::vec![ + &env, + Milestone { + amount: 1, + released: true, + } + ], + &EXPIRY, + ), + Err(Ok(Error::InvalidMilestoneState)) + ); + assert_eq!( + client.try_init( + &arbiter, + &beneficiary, + &token, + &soroban_sdk::vec![ + &env, + Milestone { + amount: 1, + released: false, + } + ], + &NOW, + ), + Err(Ok(Error::InvalidExpiry)) + ); + } + + #[test] + fn initialization_cannot_run_twice() { + let ctx = context(); + let milestones = soroban_sdk::vec![ + &ctx.env, + Milestone { + amount: 1_000, + released: false, + } + ]; + + assert_eq!( + ctx.client().try_init( + &ctx.arbiter, + &ctx.beneficiary, + &ctx.token, + &milestones, + &(EXPIRY + 1), + ), + Err(Ok(Error::AlreadyInitialized)) + ); + } +} diff --git a/docs/MAINNET.md b/docs/MAINNET.md new file mode 100644 index 00000000..dd48da04 --- /dev/null +++ b/docs/MAINNET.md @@ -0,0 +1,187 @@ +# Switching DeenBridge to Stellar Mainnet (USDC) + +DeenBridge runs on **Stellar testnet** by default (`STELLAR_NETWORK=testnet`). +This document is the complete checklist for moving the payment stack to +**mainnet** (the Stellar public network, `STELLAR_NETWORK=public` or +`mainnet`). Following it end-to-end means the switch requires **no code +reading** — only environment changes, wallet/trustline setup, and a smoke +test. + +> ⚠️ **Config is validated at boot.** The backend validates the Stellar +> configuration at startup (see `src/config/stellar.js`). A wrong or +> incomplete configuration **fails fast** with an error naming the exact +> problem instead of failing later on the first Horizon call. If you see +> `❌ Stellar configuration error` in the logs at boot, fix the named +> variable and restart — do not ship it. + +--- + +## 1. Understand what "network" controls + +Everything network-dependent resolves from a single source of truth +(`src/config/stellar.js`): + +| Setting | testnet | mainnet (`mainnet` / `public`) | +|---------|---------|-------------------------------| +| Network passphrase | `Test SDF Network ; September 2015` | `Public Global Stellar Network ; September 2015` | +| Default Horizon URL | `https://horizon-testnet.stellar.org` | `https://horizon.stellar.org` | +| USDC issuer (Circle) | `GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5` | `GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN` | +| EURC issuer | `GB3Q6QDZYTHWT7E5PVS3W7FUT5GVAFC5KSZFFLPU25GO7VTC3NM2ZTVO` | `GDHU6WRG4IEQXM5NZ4BMPKOXHW76MZM4Y2IEMFDVXBSDP6SJY4ITNPP2` | + +`STELLAR_NETWORK` accepts `testnet`, `mainnet`, or `public` (`public` is the +SDF name for the production network and is treated as `mainnet`). The USDC +issuer and default Horizon URL are derived from the network — you cannot +accidentally combine a mainnet flag with a testnet issuer or Horizon URL; the +boot-time validation rejects it. + +--- + +## 2. Backend environment variables + +Change these in the backend deployment (`.env` / Render / Vercel env): + +```dotenv +# The one switch that matters +STELLAR_NETWORK=mainnet # or "public" — both mean mainnet + +# Optional: explicit Horizon endpoints (comma-separated, for redundancy). +# Leave UNSET to use the network default (https://horizon.stellar.org). +# Never point a mainnet deployment at the testnet Horizon URL — boot will fail. +# HORIZON_URLS=https://horizon.stellar.org,https://horizon-fr.stellar.org + +# Horizon client tuning (optional, same defaults as testnet) +# HORIZON_TIMEOUT_MS=10000 +# HORIZON_MAX_RETRIES=3 +# HORIZON_CB_THRESHOLD=5 +# HORIZON_CB_COOLDOWN_MS=30000 +``` + +Also confirm the platform-level keys are set for mainnet (they are +network-agnostic, but they move real money now): + +```dotenv +# Platform fee wallet (receives the platform share of a fee-split purchase) +PLATFORM_WALLET_PUBLIC_KEY=G... +# Donation fund destination +DONATION_WALLET_PUBLIC_KEY=G... +# SEP-10 auth keypair public key (published in stellar.toml) +SIGNING_KEY=G... +``` + +### What NOT to change + +- `PLATFORM_FEE_PERCENT`, `PLATFORM_COLLECT_ENABLED` — unchanged. +- The **secret keys** of user wallets are never stored on the backend + (non-custodial). Users hold their own funds. + +--- + +## 3. Frontend environment variables + +The frontend must run on the **same network** or signatures will be rejected +(wrong network passphrase). In the `dnb-frontend` deployment set: + +```dotenv +NEXT_PUBLIC_STELLAR_NETWORK=mainnet +``` + +This must match the backend's `STELLAR_NETWORK` **exactly**. A mismatch +(backend on mainnet, frontend on testnet) produces signatures that fail with +`op_bad_auth` / bad network passphrase on submit. + +--- + +## 4. Creator trustlines (critical) + +Creators receive USDC **directly to their own wallets** (direct settlement) +or the platform wallet receives it (platform-collect mode). For a creator to +be able to receive USDC on mainnet, their wallet **must have a USDC +trustline to the mainnet Circle issuer**: + +``` +USDC issuer (mainnet): GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN +``` + +Notes: + +- A **trustline added on testnet does not carry over** to mainnet — trustlines + are per-account and per-network. Every creator must add the mainnet USDC + trustline even if they already had one on testnet. +- Freighter / xBull / Albedo have a "manage assets" / "add asset" flow; pasting + the issuer above adds the trustline. This costs a small one-time XLM reserve + (the account must also hold a bit of XLM for fees and the reserve). +- A purchase to a creator **without** the USDC trustline fails on-chain + (`op_no_trust`). The preflight check (`POST /api/stellar/payment/preflight`) + surfaces this before the wallet is asked to sign, and the initialize + endpoint can return `{ fallback: "claimable_balance" }` so the buyer can + still complete via a claimable balance instead of dead-ending. +- If the platform is in **platform-collect** mode, the platform wallet itself + must have the mainnet USDC trustline. + +### How to verify a trustline + +```bash +# Replace G... with the creator's public key +curl "https://horizon.stellar.org/accounts/G..." | jq '.balances[] | select(.asset_code=="USDC")' +``` + +An entry with `asset_code: "USDC"` and `asset_issuer` equal to the mainnet +Circle issuer confirms the trustline exists. If the array is empty of USDC, +the creator needs to add it. + +--- + +## 5. Smoke-test checklist (first mainnet transaction) + +Run these in order, on the **mainnet deployment**, after the env changes are +live. Do not proceed past a failed step. + +1. **Boot check** — start the backend. Confirm it logs + `✅ Environment variables validated successfully` and **no** + `❌ Stellar configuration error`. A bad `STELLAR_NETWORK` value or a + mainnet/testnet Horizon or issuer mismatch aborts startup with the exact + variable named. + ```bash + curl -s http://localhost:5000/health # -> {"success":true,"message":"pong"} + ``` +2. **Network sanity** — confirm the app resolves mainnet: + ```bash + curl -s http://localhost:5000/.well-known/stellar.toml | grep -i network + ``` + and, from the code, `NETWORK`/`networkPassphrase` resolve to + `Public Global Stellar Network ; September 2015`. +3. **Creator trustline** — pick a test creator, confirm their mainnet USDC + trustline via the Horizon query above. If missing, have them add it. +4. **Buyer setup** — a test buyer connects a **mainnet** wallet with a small + amount of USDC (≥ item price) and enough XLM for fees/reserve. +5. **Preflight** — call `POST /api/stellar/payment/preflight` for a paid + course. Expect `success: true` with no `destination_no_trustline` reason. +6. **Initialize** — `POST /api/stellar/payment/initialize` returns unsigned + XDR + `expectedHash`. Confirm the returned `networkPassphrase` is the + **public** passphrase. +7. **Sign & submit** — the wallet signs the XDR on mainnet; + `POST /api/stellar/payment/submit` returns `"Payment successful!"` with a + mainnet `stellar.expert` explorer URL. +8. **On-chain verify** — open the explorer link. Confirm a USDC payment to the + creator (or platform) and that the buyer now owns the item + (`GET /api/stellar/payment/transactions` shows it `confirmed`). +9. **Creator received USDC** — confirm the creator's mainnet USDC balance + increased by the expected amount (minus platform fee if enabled). + +### Rollback + +To go back to testnet, revert `STELLAR_NETWORK=testnet` (backend) and +`NEXT_PUBLIC_STELLAR_NETWORK=testnet` (frontend) and redeploy. Both sides must +change together. Testnet and mainnet data (transactions, balances) are +completely separate — records created on mainnet are not visible on testnet +and vice versa. + +--- + +## 6. Reference + +- Stellar docs: [Networks](https://developers.stellar.org/docs/learn/encyclopedia/network-configuration), + [Claimable balances](https://developers.stellar.org/docs/learn/encyclopedia/transactions-specialized/claimable-balances) +- Circle USDC: [USDC on Stellar](https://www.circle.com/en/usdc/stellar) +- Config source of truth: `src/config/stellar.js`, asset registry: + `src/config/assets.js` diff --git a/docs/authorization-matrix.md b/docs/authorization-matrix.md new file mode 100644 index 00000000..e6e6c572 --- /dev/null +++ b/docs/authorization-matrix.md @@ -0,0 +1,62 @@ +# Authorization Matrix — Resource Ownership + +This document describes the centralized resource-ownership authorization layer +(`src/middlewares/authorize.js`) that guards every mutating endpoint for books, +courses, spaces, and reviews. + +## How it works + +- `protect` authenticates the request and sets `req.user` (full User doc). +- `authorizeOwnership({ model, ownerField, resourceType })` loads the target + resource, then allows the request only if the caller is the **owner** or an + **admin**. On success it attaches the loaded doc as `req.resource`. +- `authorizeReviewOwnership({ model })` loads the parent item (Book/Course) and + the target review subdocument, applying the same owner-or-admin rule to + `review.user`. It supports both the id-scoped route (`/:id/reviews/:reviewId`) + and the self-scoped route (`/:id/reviews`, which targets the caller's own + review). +- Every denial writes an audit row (`authz.ownership.denied`, + `status: "failure"`) via the fire-and-forget audit service, then returns + `403` through the global error handler. + +## Owner fields + +| Resource | Model | Owner field | +| -------- | -------- | ------------------ | +| Book | `Book` | `author` | +| Course | `Course` | `createdBy` | +| Space | `Space` | `host` | +| Review | subdoc | `reviews[].user` | + +## Expected status codes + +Legend: **owner** = the resource owner; **non-owner educator/mentor** = an +authenticated mentor who does not own the resource; **student** = an +authenticated non-owner student; **admin** = any admin. + +| Resource / Action | Owner | Non-owner educator/mentor | Student (non-owner) | Admin | Non-existent id | +| ------------------------------------- | ----- | ------------------------- | ------------------- | ----- | --------------- | +| Book — `DELETE /:id` | 2xx | 403 | 403 | 2xx | 404 | +| Course — `PUT /:id` | 2xx | 403 | 403 | 2xx | 404 | +| Space — `PUT /update/:id` | 2xx | 403 | 403 | 2xx | 404 | +| Space — `DELETE /:id` | 2xx | 403 | 403 | 2xx | 404 | +| Book review — `PUT/PATCH /:id/reviews[/:reviewId]` | 2xx | 403 | 403 | 2xx | 404 | +| Book review — `DELETE /:id/reviews[/:reviewId]` | 2xx | 403 | 403 | 2xx | 404 | +| Course review — `PUT/PATCH /:id/reviews[/:reviewId]` | 2xx | 403 | 403 | 2xx | 404 | +| Course review — `DELETE /:id/reviews[/:reviewId]` | 2xx | 403 | 403 | 2xx | 404 | + +Notes on review status codes: + +- On the **self-scoped** review routes (no `:reviewId`), a non-owner has no + review of their own to act on, so the guard returns **404** ("Review not + found") rather than 403 — there is no target subdocument to deny. +- On the **id-scoped** review routes (`/:reviewId`), acting on another user's + review returns **403**; an unknown `:reviewId` returns **404**. + +## Out of scope / notes + +- **Review CREATE** (`POST /:id/reviews`) is **purchase-gated** (entitlement / + verified-purchase check in `verifyItemPurchase`), not ownership-gated. It is + intentionally NOT wrapped by the ownership layer. +- There is **no book-update route** and **no course-delete route** in the + current API surface, so those cells do not exist yet. diff --git a/docs/connection-pool-metrics.md b/docs/connection-pool-metrics.md new file mode 100644 index 00000000..2e4d4c3e --- /dev/null +++ b/docs/connection-pool-metrics.md @@ -0,0 +1,153 @@ +# MongoDB Connection Pool Metrics + +The API exposes MongoDB connection-pool statistics in **Prometheus text +exposition format** so pool health can be scraped and dashboarded. + +- **Endpoint:** `GET /metrics/database` +- **Content-Type:** `text/plain; version=0.0.4; charset=utf-8` +- **Collector:** `mongo/monitoring/poolMetrics.js` +- **Route:** `src/routes/metrics/database.js` (mounted in `app.js`) + +The collector subscribes to the MongoDB driver's Connection Monitoring & +Pooling (CMAP) events on the live `MongoClient` (wired up in +`src/config/db.js` after `mongoose.connect`). It has no import-time side +effects and degrades gracefully: if the database is not yet connected the +endpoint still returns valid, zeroed metrics. + +## Exposed metrics + +| Metric | Type | Meaning | +| --- | --- | --- | +| `mongodb_pool_connections_open` | gauge | Open physical connections (`created − closed`). | +| `mongodb_pool_connections_in_use` | gauge | Connections currently checked out. | +| `mongodb_pool_connections_available` | gauge | Idle connections available to borrow. | +| `mongodb_pool_wait_queue_size` | gauge | Pending checkout requests (wait-queue depth). | +| `mongodb_pool_max_size` | gauge | Configured `maxPoolSize`. | +| `mongodb_pool_min_size` | gauge | Configured `minPoolSize`. | +| `mongodb_connection_ready_state` | gauge | Mongoose `readyState` (0–3), with a `state` label. | +| `mongodb_pool_connections_created_total` | counter | Physical connections created. | +| `mongodb_pool_connections_ready_total` | counter | Connections that finished their handshake. | +| `mongodb_pool_connections_closed_total` | counter | Physical connections closed. | +| `mongodb_pool_checkouts_started_total` | counter | Checkout attempts started. | +| `mongodb_pool_checkouts_total` | counter | Successful checkouts. | +| `mongodb_pool_checkins_total` | counter | Connections returned to the pool. | +| `mongodb_pool_checkout_failures_total` | counter | Failed checkout attempts. | +| `mongodb_pool_errors_total` | counter | Pool + connection errors observed. | +| `mongodb_pool_pools_created_total` | counter | Pools created (one per topology member). | +| `mongodb_pool_pools_cleared_total` | counter | Pool clear events. | + +Every series carries a constant `pool="mongodb"` label. + +### Sample output + +```text +# HELP mongodb_pool_connections_open Current number of open connections in the MongoDB pool. +# TYPE mongodb_pool_connections_open gauge +mongodb_pool_connections_open{pool="mongodb"} 5 +# HELP mongodb_pool_connections_in_use Connections currently checked out (in use) from the pool. +# TYPE mongodb_pool_connections_in_use gauge +mongodb_pool_connections_in_use{pool="mongodb"} 2 +# HELP mongodb_connection_ready_state Mongoose connection readyState (0=disconnected,1=connected,2=connecting,3=disconnecting). +# TYPE mongodb_connection_ready_state gauge +mongodb_connection_ready_state{pool="mongodb",state="connected"} 1 +``` + +## Prometheus scrape configuration + +If `METRICS_TOKEN` is set, protect the endpoint the same way as `/metrics` +by sending `Authorization: Bearer ` (see `authorization` below). + +```yaml +# prometheus.yml +scrape_configs: + - job_name: dnb-backend-db-pool + metrics_path: /metrics/database + scheme: http + static_configs: + - targets: ["dnb-backend:5000"] + labels: + service: dnb-backend + # Uncomment if METRICS_TOKEN is configured: + # authorization: + # type: Bearer + # credentials: "" +``` + +## Grafana + +### Panel: pool saturation (time series) + +```json +{ + "title": "MongoDB Pool Saturation", + "type": "timeseries", + "targets": [ + { "expr": "mongodb_pool_connections_in_use{service=\"dnb-backend\"}", "legendFormat": "in use" }, + { "expr": "mongodb_pool_connections_available{service=\"dnb-backend\"}", "legendFormat": "available" }, + { "expr": "mongodb_pool_max_size{service=\"dnb-backend\"}", "legendFormat": "max" } + ], + "fieldConfig": { "defaults": { "unit": "short", "min": 0 } } +} +``` + +### Panel: checkout wait queue (stat) + +```json +{ + "title": "DB Pool Wait Queue", + "type": "stat", + "targets": [ + { "expr": "mongodb_pool_wait_queue_size{service=\"dnb-backend\"}", "legendFormat": "pending" } + ], + "options": { "colorMode": "value" }, + "fieldConfig": { + "defaults": { + "thresholds": { "steps": [ + { "color": "green", "value": null }, + { "color": "orange", "value": 1 }, + { "color": "red", "value": 5 } + ] } + } + } +} +``` + +### Panel: connection error rate (time series) + +```json +{ + "title": "DB Pool Errors (rate)", + "type": "timeseries", + "targets": [ + { "expr": "rate(mongodb_pool_errors_total{service=\"dnb-backend\"}[5m])", "legendFormat": "errors/s" }, + { "expr": "rate(mongodb_pool_checkout_failures_total{service=\"dnb-backend\"}[5m])", "legendFormat": "checkout failures/s" } + ], + "fieldConfig": { "defaults": { "unit": "ops", "min": 0 } } +} +``` + +### Suggested alerts + +```yaml +groups: + - name: dnb-backend-db-pool + rules: + - alert: MongoPoolSaturated + expr: mongodb_pool_connections_in_use / mongodb_pool_max_size > 0.9 + for: 5m + labels: { severity: warning } + annotations: + summary: "MongoDB pool >90% utilised" + - alert: MongoPoolWaitQueueBacklog + expr: mongodb_pool_wait_queue_size > 0 + for: 2m + labels: { severity: warning } + annotations: + summary: "Requests are waiting for a DB connection" + - alert: MongoDisconnected + expr: mongodb_connection_ready_state != 1 + for: 1m + labels: { severity: critical } + annotations: + summary: "Mongoose connection is not in the connected state" +``` diff --git a/docs/fee-sponsorship.md b/docs/fee-sponsorship.md new file mode 100644 index 00000000..4a202f3f --- /dev/null +++ b/docs/fee-sponsorship.md @@ -0,0 +1,133 @@ +# Fee-Bump Sponsorship — Platform-Paid Network Fees + +This document describes the optional **fee-bump sponsorship** flow (issue #30): +the platform can pay a user's Stellar network fee so a user who holds USDC but +almost no XLM can still buy a book, buy a course, or donate. + +## The problem it solves + +Stellar network fees are paid in XLM. A newly onboarded user typically holds +USDC but little or no XLM, so their otherwise-valid payment fails at submission +for lack of XLM. Fee sponsorship removes that onboarding wall **without touching +custody**: the platform wraps the user-signed transaction in a +[fee-bump transaction](https://developers.stellar.org/docs/learn/encyclopedia/transactions-specialized/fee-bump-transactions) +signed by a dedicated *fee-source* account. The user still signs — and only ever +signs — their own payment operations; the sponsor key signs **only** the +fee-bump wrapper and can never move user funds. + +## Trust model & guard rails + +Because the server signs on behalf of the platform, the flow is reject-by-default: + +1. **Structural whitelist** (`validateInnerTransaction`). The user's inner + transaction must match, operation-for-operation, the pending `Transaction` + row the server already built at initialize: + - **source** equals `buyerWallet`; + - **exact operation set** — the operation count equals the expected count + (1 for a direct payment/donation, 2 for a fee split) and every operation is + a `payment` in the settlement asset. This is enforced by **allow-list**: + only `payment` is permitted, so `changeTrust`, `setOptions`, `manageData`, + `accountMerge`, `createAccount`, `pathPaymentStrict*`, a second unexpected + `payment`, or any operation type not yet invented all fail; + - **destinations, amounts, asset** match the row exactly (creator + platform + split from `platformFee`; donations against `DONATION_WALLET_PUBLIC_KEY`), + compared in stroops; + - **memo** equals the row's memo. +2. **Spend caps** (`SponsorshipSpend` + `checkSpendCaps`), enforced *before* + wrapping: + - per-transaction fee ceiling (`FEE_SPONSOR_MAX_FEE_STROOPS`); + - per-UTC-day total spend (`FEE_SPONSOR_DAILY_CAP_STROOPS`); + - per-user per-UTC-day sponsored-transaction count + (`FEE_SPONSOR_PER_USER_DAILY_LIMIT`). +3. **Sponsor float pre-check** — refuses (non-fatally) if the sponsor account + cannot cover the declared max fee, so an underfunded float never causes a + Stellar submit failure that would mark the user's transaction `failed`. + +## Fee-bump fee semantics + +The fee-bump fee is priced per operation **including** the wrapper — the total +fee is `baseFeePerOp × (innerOps + 1)` (verified against the installed +`@stellar/stellar-sdk` and asserted in tests). The service declares the highest +per-op fee the per-transaction ceiling allows, so the sponsor tolerates fee +surges up to the cap while Horizon still only charges the true network fee. The +declared total is always clamped to `FEE_SPONSOR_MAX_FEE_STROOPS`. + +Two hashes are recorded for a sponsored row: Horizon returns the **fee-bump +(outer) hash** (`feeBumpTxHash`), while the **inner-transaction hash** +(`stellarTxHash`) is what matches the `expectedHash` stored at initialize and +what the on-chain payment verification (`verifyPaymentOperations`) runs against. + +## API + +Sponsorship is opt-in per submit — there is **no new endpoint**. Add +`requestSponsorship: true` to an existing submit request: + +- `POST /api/stellar/payment/submit` — `{ transactionId, signedXdr, requestSponsorship: true }` +- `POST /api/stellar/donation/submit` — `{ donationId, signedXdr, requestSponsorship: true }` + +When sponsorship is applied, the confirmed response carries `sponsored: true`, +`feeBumpTxHash`, and `sponsorFeeCharged` (the real `fee_charged` from Horizon). + +### Failure semantics + +Sponsorship-specific failures **never** mark the user's `Transaction` `failed`. +They return a distinct non-fatal status with `retryUnsponsored: true`, leaving +the row `pending` so the client can retry without sponsorship (the user pays +their own fee): + +| Reason (`sponsorship.reason`) | Status | Meaning | +|-------------------------------|:------:|---------| +| `whitelist_rejected` | 422 | Inner transaction did not match the row | +| `daily_cap_exceeded` | 429 | Per-day total spend cap would be exceeded | +| `per_user_daily_limit` | 429 | Per-user daily sponsored count reached | +| `fee_ceiling_too_low` | 503 | Per-tx fee ceiling too low to fee-bump | +| `sponsor_underfunded` | 503 | Sponsor float cannot cover the fee | +| `sponsor_misconfigured` | 503 | Secret missing/invalid at request time | + +Only a genuine on-network submission failure follows the existing failed-path. + +With `FEE_SPONSOR_ENABLED=false` (the default), the flag is ignored entirely and +both submit paths are byte-for-byte the original unsponsored flow — sending +`requestSponsorship: true` behaves exactly as if the flag were absent. + +### Ops status endpoint + +`GET /api/stellar/payment/sponsorship/status` (admin-only) returns whether +sponsorship is enabled, the sponsor account's **public key** (never the secret) +and live XLM float, the configured caps, and today's spend, so the float can be +topped up before it runs dry. + +## Configuration + +All variables are optional; with the master switch off, a boot with none of them +set is unchanged. When `FEE_SPONSOR_ENABLED=true`, a missing or invalid +`FEE_SPONSOR_SECRET` is a **hard boot failure** (fail fast with a clear message). + +| Variable | Default | Description | +|----------|---------|-------------| +| `FEE_SPONSOR_ENABLED` | `false` | Master switch | +| `FEE_SPONSOR_SECRET` | — | Dedicated `S…` fee-source secret. **MUST NOT** be the donation or platform receiving wallet. Never logged, never returned over HTTP. | +| `FEE_SPONSOR_MAX_FEE_STROOPS` | `1000000` | Per-transaction fee ceiling (0.1 XLM) | +| `FEE_SPONSOR_DAILY_CAP_STROOPS` | `100000000` | Total fee spend per UTC day (10 XLM) | +| `FEE_SPONSOR_PER_USER_DAILY_LIMIT` | `10` | Max sponsored transactions per user per UTC day | + +The sponsor account should be a dedicated, low-balance account topped up only +with the XLM float it needs for fees — never the donation or platform receiving +wallet. + +## Observability + +Every sponsorship decision is logged (approved / rejected + reason; the secret is +never logged) and counted in Prometheus via `fee_sponsorships_approved_total` +and `fee_sponsorships_rejected_total{reason}`. + +## Tests + +- `test/feeSponsorService.test.js` — structural whitelist adversarial matrix, + fee-bump fee correctness (asserted against the SDK), inner-transaction-untouched + proof, spend caps, secret handling, and boot-config validation. +- `test/feeSponsorSubmit.test.js` — controller wiring for both the payment and + donation submit paths: flag-off regression (byte-for-byte unchanged), flag-on + sponsorship, and cap/whitelist rejections that never mark the row `failed`. + +Run: `node --experimental-vm-modules node_modules/jest/bin/jest.js --runInBand --forceExit test/feeSponsorService.test.js test/feeSponsorSubmit.test.js` diff --git a/docs/full-text-search.md b/docs/full-text-search.md new file mode 100644 index 00000000..43194402 --- /dev/null +++ b/docs/full-text-search.md @@ -0,0 +1,291 @@ +# Full-Text Search Configuration Guide + +This document explains how to configure MongoDB text indexes and use the full-text search abstraction provided by the `mongo/utils/textSearch.js` utility and the `mongo/mixins/Searchable.js` mixin. + +## Overview + +The full-text search abstraction provides: + +1. **Score-based ranking** — Results are ordered by MongoDB's computed text-match score (`$meta: "textScore"`), not just returned in natural/insertion order. + +2. **Composable filters** — Additional filter criteria (e.g. price range, category) can be combined with text search without breaking correct score-based ordering. + +3. **Offset-based pagination** — Supports `page`/`limit` pagination alongside score-based sorting. + +4. **Reusable interface** — The `Searchable` mixin gives every model the same `.search()` method, eliminating duplicated query-building logic. + +## Text Index Configuration + +### Basic Text Index + +To make a model searchable, you must define a text index on the fields you want to search. Add this to your Mongoose schema definition: + +```javascript +// Simple text index on a single field +bookSchema.index({ title: "text" }); + +// Compound text index across multiple fields +bookSchema.index({ title: "text", description: "text", category: "text" }); +``` + +### Weighted Text Index + +You can assign weights to fields so that matches in more important fields rank higher: + +```javascript +// Title matches are 5x more important than description or category matches +bookSchema.index( + { title: "text", description: "text", category: "text" }, + { weights: { title: 5 } } +); +``` + +**Current models with weighted text indexes:** +- `Book`: `{ title: 5, description: 1, category: 1 }` +- `Course`: `{ title: 5, description: 1, category: 1 }` + +### Language Options + +MongoDB supports stemming and stop-words for many languages. You can configure these per index: + +```javascript +// English (default) +userSchema.index( + { name: "text", bio: "text", interests: "text" }, + { default_language: "english" } +); + +// Disable language processing (treat all tokens as literals) +userSchema.index( + { name: "text", bio: "text" }, + { default_language: "none" } +); + +// Use a specific language override field +userSchema.index( + { name: "text", bio: "text" }, + { default_language: "english", language_override: "lang" } +); +``` + +**Current models with language options:** +- `User`: `{ default_language: "none", language_override: "lang" }` + +### Partial Index (Performance Optimization) + +If you only need to search a subset of documents, use a partial index to reduce index size: + +```javascript +// Only index active books +bookSchema.index( + { title: "text", description: "text" }, + { partialFilterExpression: { isActive: true } } +); + +// Only index books that have content +bookSchema.index( + { title: "text", description: "text" }, + { partialFilterExpression: { fileUrl: { $exists: true } } } +); +``` + +### Wildcard Text Index (MongoDB 4.2+) + +To search across all string fields: + +```javascript +// Search all string fields (use with caution — may impact performance) +bookSchema.index({ "$**": "text" }); +``` + +## Using the Text Search Utility + +### Basic Search + +```javascript +import { textSearch } from "../mongo/utils/textSearch.js"; +import Book from "../src/models/Book.js"; + +const results = await textSearch({ + model: Book, + term: "react patterns", + page: 1, + limit: 10, +}); + +// results.documents — Array of matching books with `score` field +// results.total — Total matching documents +// results.page — Current page number +// results.pages — Total pages +``` + +### Search with Filters + +```javascript +const results = await textSearch({ + model: Book, + term: "node.js", + filters: { + price: { $gte: 0, $lte: 50 }, + category: "Programming", + }, + page: 1, + limit: 20, +}); +``` + +### Custom Projection + +```javascript +const results = await textSearch({ + model: Book, + term: "design patterns", + projection: { title: 1, price: 1, rating: 1 }, + page: 1, + limit: 10, +}); +``` + +### Custom Sort + +```javascript +// Sort by price ascending (score still available in returned documents) +const results = await textSearch({ + model: Book, + term: "javascript", + sort: { price: 1 }, + page: 1, + limit: 10, +}); +``` + +## Using the Searchable Mixin + +The `Searchable` mixin provides a convenient `.search()` static method on your model: + +```javascript +import { applySearchable } from "../mongo/mixins/Searchable.js"; + +const bookSchema = new mongoose.Schema({ + title: String, + description: String, + category: String, + price: Number, +}); + +// Define the text index +bookSchema.index({ title: "text", description: "text", category: "text" }, { weights: { title: 5 } }); + +// Apply the mixin (must be called after defining the schema, before compiling the model) +applySearchable(bookSchema, { + defaultFields: ["title", "description", "category", "price"], + defaultFilters: { isActive: true }, +}); + +const Book = mongoose.model("Book", bookSchema); + +// Now you can use .search() directly on the model +const results = await Book.search({ + term: "react patterns", + filters: { price: { $gte: 0 } }, + page: 1, + limit: 10, +}); +``` + +### Mixin Options + +| Option | Type | Description | +|--------|------|-------------| +| `defaultFields` | `string[]` | Fields to project by default when no explicit projection is provided | +| `defaultFilters` | `Object` | Filters always applied (merged with caller-supplied filters) | + +### Mixin Helper Methods + +When you apply the mixin, these static methods are also available: + +- `Model._buildSearchFilter(term, filters)` — Build a text-search filter without executing +- `Model._buildSearchProjection(extraProjection)` — Build a projection with score field +- `Model._buildSearchSort(customSort)` — Build a sort specification + +## Accessing the Text Score + +When using either the utility or the mixin, each document in the results includes a `score` field with the text-match relevance score: + +```javascript +const results = await textSearch({ model: Book, term: "react" }); + +results.documents.forEach((doc) => { + console.log(`${doc.title}: score ${doc.score}`); +}); +``` + +The score is: +- Higher for documents that match more terms +- Higher for documents where matched terms appear in weighted fields (e.g. `title`) +- Based on term frequency and inverse document frequency (TF-IDF) + +## Pagination Notes + +### Offset Pagination (page/limit) + +The default pagination is offset-based (`page`/`limit`), which is suitable for most search UIs where users navigate to specific page numbers. + +**Important:** When paginating with score-based sorting, MongoDB guarantees consistent results within a single query. However, if documents are inserted or deleted between page loads, results may shift slightly. This is acceptable for search use cases. + +### Cursor-Based Pagination + +For large datasets or infinite scroll, consider using cursor-based pagination from `mongo/utils/cursorPagination.js`. The text search utilities build filters that can be composed with cursor pagination: + +```javascript +import { buildTextFilter, buildTextProjection } from "../mongo/utils/textSearch.js"; +import { paginate } from "../mongo/utils/cursorPagination.js"; + +const filter = buildTextFilter("react", { price: { $gte: 0 } }); +const projection = buildTextProjection({ title: 1 }); + +// Use paginate with the text search filter +const results = await paginate({ + executor: ({ filter: cursorFilter, sort, limit }) => + Book.find({ $and: [filter, cursorFilter] }, projection) + .sort(sort) + .limit(limit) + .lean(), + sortField: "score", + sortOrder: -1, + limit: 20, +}); +``` + +**Note:** Score-based cursor pagination is complex because the score is computed by MongoDB, not stored on the document. The example above uses a hybrid approach. For most applications, offset pagination is sufficient. + +## Current Models with Text Indexes + +| Model | Fields | Weights | Language | +|-------|--------|---------|----------| +| Book | `title`, `description`, `category` | `title: 5` | default (english) | +| Course | `title`, `description`, `category` | `title: 5` | default (english) | +| User | `name`, `bio`, `interests` | none | `none` (language_override: `lang`) | + +## Testing Text Indexes + +When testing with MongoDB Memory Server, you must call `syncIndexes()` after connecting to ensure text indexes are created: + +```javascript +beforeEach(async () => { + await Book.deleteMany({}); + await Book.syncIndexes(); // Ensures text index exists +}); +``` + +## Common Pitfalls + +1. **Only one text index per collection:** MongoDB only allows one text index per collection. If you need to search different field combinations, use a single compound text index. + +2. **Text index + regular index conflict:** You cannot have both a text index and a regular index on the same field combination. Remove redundant regular indexes. + +3. **Case sensitivity:** Text search is case-insensitive by default. Don't add case-insensitive regex filters alongside text search. + +4. **Short search terms:** MongoDB requires at least 3 characters for `$text` search. The existing `searchService.js` falls back to regex for shorter terms. + +5. **Performance:** Text indexes can be large. Use partial indexes and field projections to minimize memory usage. diff --git a/docs/idempotency.md b/docs/idempotency.md new file mode 100644 index 00000000..e2529e12 --- /dev/null +++ b/docs/idempotency.md @@ -0,0 +1,56 @@ +# Request-Level Idempotency + +DeenBridge Backend provides header-driven request idempotency on all mutating payment, donation, refund, and payout endpoints. This prevents retried HTTP requests (due to flaky mobile connectivity, proxy retries, or double-tapped UI buttons) from creating duplicate transactions, submitting duplicate Stellar on-chain payments, or double-crediting educators. + +## Header + +Clients pass the `Idempotency-Key` HTTP header with a unique identifier (e.g. UUID v4): + +```http +POST /api/stellar/payment/initialize HTTP/1.1 +Host: api.deenbridge.app +Authorization: Bearer +Idempotency-Key: 9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d +Content-Type: application/json + +{ + "itemType": "book", + "itemId": "65b82e80cbf0776967aab8fe", + "buyerWallet": "GBKENR..." +} +``` + +## Behavior & Lifecycle + +1. **First Use (`in_progress`)**: + An `IdempotencyKey` record is atomically inserted with `status: "in_progress"` scoped to `{ key, userId, endpoint }`. A Mongo unique compound index guarantees atomicity under concurrent requests. +2. **Captured Response (`completed`)**: + Upon handler completion, the HTTP status code and JSON response body are captured and persisted with `status: "completed"`. +3. **Replay**: + Subsequent requests with the same `Idempotency-Key`, matching body, and same user immediately receive the stored status code and response body without re-executing controller logic or database/on-chain mutations. +4. **Expiration (TTL)**: + Idempotency keys automatically expire after 24 hours via MongoDB TTL index. + +## Error Responses & Concurrency + +| Scenario | HTTP Status | Response Description | +|----------|-------------|----------------------| +| **In-Flight Concurrency** | `409 Conflict` | A second request with the same idempotency key is already `in_progress`. Clients should back off and retry. | +| **Payload Mismatch** | `422 Unprocessable Entity` | The idempotency key was reused with a different request body fingerprint. | +| **Server Failure (5xx)** | — | Idempotency keys are purged on 5xx errors so clients can safely retry after a backend failure. | + +## Supported Endpoints & Policy + +Idempotency key protection is enabled on all mutating payment endpoints (`required: false` policy for backward compatibility): + +- `POST /api/stellar/payment/initialize` +- `POST /api/stellar/payment/submit` +- `POST /api/stellar/payment/transactions/:id/refund-request` +- `POST /api/stellar/payment/refunds/:refundId/build` +- `POST /api/stellar/payment/refunds/:refundId/submit` +- `POST /api/stellar/payment/refunds/:refundId/reject` +- `POST /api/stellar/payment/refunds/:refundId/dispute` +- `POST /api/stellar/donation/initialize` +- `POST /api/stellar/donation/submit` +- `POST /api/payouts/build` +- `POST /api/payouts/:batchId/submit` diff --git a/docs/service-to-service-auth.md b/docs/service-to-service-auth.md new file mode 100644 index 00000000..7770a74f --- /dev/null +++ b/docs/service-to-service-auth.md @@ -0,0 +1,149 @@ +# Service-to-Service (S2S) Authentication + +The backend authenticates the AI service (**dnb-ai**) with **signed, scoped, +rotatable keys** — not a single shared static secret. Each request is signed +with an HMAC-SHA256 signature over a canonical string, using a key selected by +its `kid`. This gives replay protection, constant-time verification, per-key +scopes, and **zero-downtime key rotation** via overlapping active `kid`s. + +- Middleware: `src/middlewares/serviceAuth.js` (`requireServiceAuth({ scope })`) +- Key store: `src/config/serviceKeys.js` (parses `AI_SERVICE_KEYS`) +- Guarded route (reference): `GET /api/internal/ai/whoami` + (scope `ai:read-content`) — reflects the authenticated service identity. + +Denied attempts are recorded to the audit log as `service_auth.denied` +(`status: "failure"`). + +## Headers + +Every S2S request MUST send all four headers: + +| Header | Meaning | +| ------------------ | ------------------------------------------------------------- | +| `X-Service-Id` | Logical caller id, e.g. `dnb-ai` | +| `X-Service-Key-Id` | The key id (`kid`) selecting which secret to sign with | +| `X-Timestamp` | Unix time in **seconds** at signing (string) | +| `X-Signature` | Lowercase hex HMAC-SHA256 of the canonical string | + +## Canonical signing string + +The signature is computed over this exact string — four fields joined by a +single `\n` (LF), with **no trailing newline**: + +``` +METHOD \n PATH \n TIMESTAMP \n sha256hex(rawBody || "") +``` + +- `METHOD` — HTTP method, uppercased (`GET`, `POST`, …). +- `PATH` — the request path exactly as sent, **including any query string** + (Express `req.originalUrl`, e.g. `/api/internal/ai/whoami`). +- `TIMESTAMP` — the same value sent in `X-Timestamp` (Unix seconds). +- `sha256hex(rawBody || "")` — lowercase hex SHA-256 of the **raw request body + bytes**; for a bodyless `GET` this is the SHA-256 of the empty string + (`e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`). + +Then: + +``` +signature = HMAC_SHA256(key.secret, canonicalString) // lowercase hex +``` + +### Reference client (Node.js) + +```js +import crypto from "crypto"; + +function signRequest({ method, path, secret, kid, serviceId, body }) { + const timestamp = Math.floor(Date.now() / 1000).toString(); + const bodyHash = crypto.createHash("sha256").update(body || "").digest("hex"); + const canonical = [method.toUpperCase(), path, timestamp, bodyHash].join("\n"); + const signature = crypto.createHmac("sha256", secret).update(canonical).digest("hex"); + return { + "X-Service-Id": serviceId, + "X-Service-Key-Id": kid, + "X-Timestamp": timestamp, + "X-Signature": signature, + }; +} +``` + +> **Body canonicalization caveat:** the digest is over the exact bytes the +> client transmits. Sign the *serialized* body you actually send (do not +> re-serialize on the server side). For JSON, sign the exact string passed to +> the HTTP client. + +## Replay protection + +Requests whose `X-Timestamp` differs from the server clock by more than +**±300 seconds** (`REPLAY_WINDOW_SECONDS`) — in either direction — are rejected +with `401`. Keep client and server clocks in sync (NTP). + +## Verification / response matrix + +| Condition | Result | +| ---------------------------------------------------- | ------ | +| Valid signature, permitted scope | `2xx` | +| Missing any of the four headers | `401` | +| Timestamp outside the ±300s window | `401` | +| Unknown `kid`, or a retired (`active: false`) `kid` | `401` | +| Signature mismatch (bad/forged) | `401` | +| Valid signature but key lacks the route's scope | `403` | + +All secret/signature comparisons use `crypto.timingSafeEqual` +(length-guarded — a length mismatch is a plain non-match, never a throw). + +## Scopes + +Scopes are per-key and asserted per-route. Current scopes: + +| Scope | Grants | +| ----------------- | --------------------------------------------------- | +| `ai:read-content` | Read content the AI service needs (e.g. `whoami`) | +| `ai:write-answers`| Reserved for AI write-back endpoints (future) | + +A key only passes a route when its `scopes` array `includes` that route's +required scope. + +## Key configuration (`AI_SERVICE_KEYS`) + +Keys are provisioned via the `AI_SERVICE_KEYS` env var — a JSON array: + +```json +[ + { + "kid": "k1", + "secret": "a-long-random-hmac-secret", + "scopes": ["ai:read-content"], + "active": true + } +] +``` + +- **Required in production** — boot fails fast (`validateEnv.js`) if it is + missing. Optional in development/test. +- Multiple entries may be `active` at once (this is what enables rotation). +- `active: false` retires a key without removing it. +- Malformed JSON or bad entries are skipped safely (empty key set → every S2S + request is rejected `401`; the process never crashes on parse). + +## Key-rotation runbook (zero downtime) + +Because multiple `kid`s can be active simultaneously, rotation never has a +window where valid callers are rejected: + +1. **Add** a new key with a fresh `kid` (e.g. `k2`) alongside the current one, + both `"active": true`: + ```json + [ + {"kid":"k1","secret":"OLD","scopes":["ai:read-content"],"active":true}, + {"kid":"k2","secret":"NEW","scopes":["ai:read-content"],"active":true} + ] + ``` +2. **Deploy** the backend with both keys active. Now `k1` and `k2` are both + accepted. +3. **Switch dnb-ai** to sign with `k2` (its `X-Service-Key-Id`). Verify traffic + is flowing under `k2`. +4. **Retire** `k1` by setting `"active": false` (or removing it) and redeploy. + `k1`-signed requests now get `401`; `k2` continues uninterrupted. + +At no point is there a gap where a correctly-signed request is rejected. diff --git a/docs/soroban-escrow-design.md b/docs/soroban-escrow-design.md new file mode 100644 index 00000000..930dc02e --- /dev/null +++ b/docs/soroban-escrow-design.md @@ -0,0 +1,171 @@ +# Scholarship Escrow Design + +## Scope + +This document defines the Stage 1 Soroban contract for a scholarship escrow. The +contract holds a Stellar Asset Contract (SAC) representation of the scholarship +asset. It does not custody private keys, create wallets, or perform classic +Stellar payments. The later JavaScript and API stages will build unsigned +Soroban invocation transactions around this contract. + +All monetary values are integer `i128` values in the token's smallest unit. +For the USDC integration that unit is a stroop-like seven-decimal unit, matching +the existing JavaScript `toStroops` discipline. The contract never parses +decimal strings and never uses floating point arithmetic. + +## Roles + +| Role | Responsibility | +| --- | --- | +| Donor | Any address that funds the escrow. A donor authorizes each `fund` call and can claim its recorded share after expiry. | +| Beneficiary | The fixed address receiving an approved milestone amount. | +| Arbiter | The fixed platform maintainer address that authorizes milestone releases. The arbiter is trusted to approve only completed work. | +| SAC token | The fixed Stellar Asset Contract address that receives deposits and sends releases and refunds. | + +The arbiter is a deliberate v1 trust assumption. A malicious or compromised +arbiter can release funded milestones early, although it cannot change the +beneficiary, token, milestone amounts, or expiry after initialization. A future +version should replace the single arbiter with a threshold authorization policy, +such as two of three independent maintainers or a Soroban multisignature +account, and should publish the policy in the escrow state. + +## State Machine + +```text +Uninitialized + | init(arbiter, beneficiary, token, milestones, expiry) + v +Active + | fund(donor, amount) | approve_milestone(index) + | v + | Active with released milestones + | + | ledger sequence reaches expiry + v +Expired + | refund(donor) + v +Refundable claims settled +``` + +Initialization is one time only and requires authorization from the supplied +arbiter. The milestone vector is copied into contract storage and cannot be +changed. Every amount must be positive and the sum of all milestones must fit +in `i128`. + +While active: + +- Funding requires donor authorization, moves SAC tokens from the donor to the + contract, records the donor's cumulative contribution, and rejects funding + that would exceed the fixed milestone total. +- Approval requires arbiter authorization, marks one unreleased milestone, and + transfers exactly that milestone amount from the contract to the beneficiary. +- Funding and approvals are rejected once the expiry ledger is reached. +- A milestone cannot be approved twice and cannot be approved until the escrow + has enough unreleased tokens to pay its exact amount. + +After expiry, the escrow is frozen. The first valid refund snapshots the +unreleased balance. Each donor can claim once, and its claim is based on the +ratio of its contribution to total funding. Integer division rounds down for +ordinary claims; the final unclaimed donor receives the remaining snapshot +balance, including any rounding remainder. This keeps all available tokens +claimable without introducing floating point arithmetic. A donor must +authorize its own refund. + +## Storage and Events + +The contract stores: + +- fixed role and token addresses, expiry, milestone total, and accounting totals; +- the immutable milestone vector; +- the list of donor addresses; +- each donor's cumulative contribution, refund amount, and claim status. + +The `Initialized`, `Funded`, `MilestoneApproved`, and `Refunded` events expose +every state-changing operation. Donor, milestone index, and beneficiary-facing +amounts are included so an indexer can reconstruct the state without trusting +the application database. + +## Invariants + +The contract maintains these invariants atomically: + +1. `funded_total <= milestone_total`. +2. `released_total + refunded_total <= funded_total`. +3. Each milestone is either unreleased or released exactly once. +4. A released milestone's transfer amount equals its immutable amount. +5. `refund_pool`, once created, equals the unreleased balance at expiry. +6. A donor's refund claim can be executed at most once and is authorized by that + donor. +7. The contract's accounted SAC balance equals the funded amount less released + and refunded amounts, assuming the token contract itself is correct. + +## Threat Analysis + +### Unauthorized release or refund + +`approve_milestone` calls `require_auth` on the stored arbiter. `fund` and +`refund` call `require_auth` on the supplied donor. The supplied donor is not a +database identity; Soroban authorization is the security boundary. + +### Reinitialization and parameter mutation + +Initialization checks for existing state. There are no setters for the arbiter, +beneficiary, token, expiry, or milestone vector, so later calls cannot replace +the payout destination or release schedule. + +### Over-release and accounting drift + +The contract checks the milestone release flag and available balance before +calling the SAC. It updates accounting only in the same transaction as the +token transfer, so a failed transfer rolls back the state change. All arithmetic +uses checked `i128` operations. + +### Funding after expiry + +The expiry check runs before donor authorization and token transfer. This +freezes the funding population before the refund pool is calculated and avoids +late donors changing existing pro-rata shares. + +### Token mismatch or malicious token contract + +The contract accepts one token address at initialization and uses it for every +transfer. It cannot prove that an arbitrary address is the intended USDC SAC; +deployment and Stage 2 configuration must therefore pin the network, SAC +address, issuer, and asset code. A future version may verify a known SAC +registry or store the expected asset metadata alongside the address. + +Anyone can transfer the configured SAC token directly to the contract without +calling `fund`. Such tokens are not donor contributions and are intentionally +excluded from release and refund accounting. Integrations must invoke `fund` +rather than treating the raw contract token balance as funded scholarship +value. + +### Arbiter compromise + +The single arbiter can release a milestone without an off-chain progress +agreement. This is the principal v1 trust tradeoff. The beneficiary and donors +can observe the events and balances, but cannot veto a release. Threshold +arbiter authorization is the planned mitigation. + +### Refund rounding + +Pro-rata claims use integer division. The final unclaimed donor receives the +remaining snapshot balance, so rounding dust is not trapped in the contract. +Claims are still order-sensitive by at most the integer remainder; Stage 2 +should present the snapshot and claim status clearly to donors. + +### Denial of service + +Milestones are fixed and refunds are per donor. The donor list grows with the +number of distinct funders, so deployment should set practical funding and +resource limits. A future version can use a separate claim registry or Merkle +distribution if scholarship escrows need very large donor sets. + +## Stage Boundaries + +This Stage 1 change intentionally stops at the design and contract foundation. +Stage 2 must review this state machine before adding the Soroban RPC service, +SAC deployment, and wallet signing walkthrough. Stage 3 can then add API +endpoints, transaction persistence, and live state reconciliation without +changing the contract's trust model. diff --git a/docs/transaction-lifecycle.md b/docs/transaction-lifecycle.md new file mode 100644 index 00000000..4afff53f --- /dev/null +++ b/docs/transaction-lifecycle.md @@ -0,0 +1,86 @@ +# Transaction Expiry Lifecycle and TTL Invariant + +This document describes the `expiresAt` / TTL behavior of the `Transaction` +collection and the invariant that protects confirmed purchases and donations +from being deleted. + +## The problem this invariant solves + +`Transaction` rows are created when a buyer starts a checkout. To garbage-collect +abandoned checkouts, the collection used a blanket TTL index on `expiresAt` +(`expireAfterSeconds: 0`) with a schema default of `now + 30 minutes` applied to +**every** row — including rows that later became `confirmed`. A confirmed on-chain +purchase or donation was therefore permanently deleted ~30 minutes after it was +created: the proof that the buyer paid and the educator earned silently vanished. + +## The invariant + +> **`expiresAt` is only ever set on `pending` transactions. Every terminal state +> MUST clear it, the schema enforces this on save, and the TTL index is scoped +> strictly to `status: "pending"` so the reaper cannot match anything else.** + +### Status → `expiresAt` mapping + +| Status | `expiresAt` | Rationale | +|-------------|------------------|--------------------------------------------------------------------| +| `pending` | `Date` (now + 30m) | Abandoned checkout awaiting wallet signature / submission. Eligible for TTL reaping. | +| `submitted` | retained | In-flight on the Stellar network. Transient, non-terminal. | +| `retrying` | retained | In-flight async on-chain verification. Transient, non-terminal. | +| `confirmed` | unset | Settled on-chain — item access granted / donation recorded. **Must never be reaped.** | +| `failed` | unset | Permanent failure. Kept for audit and reconciliation. | +| `expired` | unset | Cancelled by the user or explicitly timed out. Kept for audit. | +| `refunded` | unset | Refund executed on-chain. Kept for audit. | +| `disputed` | unset | Under administrator review. Kept for audit. | + +## Defense in depth + +The guarantee is enforced at three independent layers, so no single future code +path can regress confirmed rows back into the reaper's window: + +1. **Partial TTL index (structural).** `src/models/Transaction.js` declares + `transactionSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0, partialFilterExpression: { status: "pending" } })`. + MongoDB's TTL monitor only considers documents that match the partial filter, + so a non-`pending` document is never a deletion candidate even if it somehow + still carries an `expiresAt`. + +2. **Conditional schema default.** The `expiresAt` default only produces a + timestamp when the document's status is `pending` (or unset at creation). + Records created directly in a terminal state — e.g. worker-created confirmed + donations/purchases from the reconciliation service — are never born with an + expiry. + +3. **Pre-save hook (runtime).** A `pre("save")` middleware clears `expiresAt` + whenever a document is saved in a terminal status (`confirmed`, `failed`, + `expired`, `refunded`, `disputed`). Even if a controller forgets to unset it + explicitly, the model enforces the invariant. + +On top of the schema layers, every current transition to a terminal state also +unsets `expiresAt` explicitly for clarity: + +- `submitPayment` / `submitDonation` — validation failure, Stellar error, + verification failure, and confirmation paths. +- `cancelTransaction` — `$unset: { expiresAt: 1 }` alongside `status: "expired"`. +- `submitRefund` / `escalateDispute` — `$unset: { expiresAt: 1 }` alongside the + terminal status update. +- `promoteTransaction` (reconciliation) and the `verifyPaymentOnChain` job — + cleared before saving the confirmed/failed row. + +## Migrations + +Databases created before this invariant may still hold confirmed rows with a +30-minute `expiresAt` and the old blanket TTL index. Run the idempotent +migration to rescue those rows and swap the index: + +```bash +node src/migrations/fixTtlTransactionExpiry.js +``` + +It (1) `$unset`s `expiresAt` on all non-`pending` rows and (2) drops the blanket +`{ expiresAt: 1 }` index and recreates it with the `partialFilterExpression`. +Running it again is a no-op. + +## Out of scope + +The `Session` and `Refund` collections have their own TTL indexes on +`expiresAt`; those are intentional and correct (revoked/abandoned sessions and +expired refund windows should be reaped) and are not affected by this invariant. diff --git a/docs/webhooks.md b/docs/webhooks.md new file mode 100644 index 00000000..ecaf623b --- /dev/null +++ b/docs/webhooks.md @@ -0,0 +1,139 @@ +# Outbound Signed Webhooks (issue #45) + +DeenBridge emits signed HTTP callbacks so external consumers (the dnb-ai +service, educator tooling, analytics) can react to payment and enrollment +lifecycle events instead of polling the REST API. + +## Event catalog + +| Event | Emitted when | +| --- | --- | +| `payment.initialized` | A pending purchase transaction is created (`initializePayment`) | +| `payment.confirmed` | A payment is confirmed on-chain (`submitPayment`) | +| `payment.failed` | A payment fails validation / submission / verification (`submitPayment`) | +| `payment.expired` | A pending transaction is cancelled/expired (`cancelTransaction`) | +| `course.enrolled` | A user enrolls in a (free) course (`enrollInCourse`) | +| `wallet.connected` | A user connects a Stellar wallet (`connectWallet`) | +| `wallet.disconnected` | A user disconnects a wallet (`disconnectWallet`) | +| `ping` | Manually, via the management API, for integration testing | + +Emission is **fire-and-forget** and happens **only after the Mongo transaction +commits** — a rolled-back payment emits nothing, and a webhook problem can +never block or fail the originating HTTP request. + +## Payload envelope + +```json +{ + "eventId": "b2f1c0e6-...", // unique per event; use for idempotency + "type": "payment.confirmed", + "createdAt": "2025-01-01T12:00:00.000Z", + "apiVersion": "2025-01-01", + "data": { "transactionId": "...", "amount": "10.00", "stellarTxHash": "..." } +} +``` + +`data` is restricted to an explicit allowlist (ids, wallet public keys, +amounts, currency/network, tx hash, item references). **It never contains +emails, password hashes, secrets, or full user documents.** + +## Signature scheme + +Each delivery carries these headers: + +| Header | Value | +| --- | --- | +| `X-DeenBridge-Event` | the event type, e.g. `payment.confirmed` | +| `X-DeenBridge-Event-Id` | the `eventId` (idempotency key) | +| `X-DeenBridge-Timestamp` | unix seconds when the request was signed | +| `X-DeenBridge-Signature` | `v1=` | + +The signed string is `` `${timestamp}.${rawBody}` `` where `rawBody` is the +**exact** bytes of the request body. The server serializes the body once, signs +those bytes, and sends the same buffer — so a verifier must run the HMAC over +the raw received body, not a re-serialized copy (JSON key order can differ). + +### Consumer verification (copy-paste Node snippet) + +```js +import crypto from "crypto"; + +// `rawBody` must be the raw request body string/buffer, NOT JSON.parse'd back. +export function verifyDeenBridgeWebhook(req, rawBody, secret) { + const timestamp = req.headers["x-deenbridge-timestamp"]; + const header = req.headers["x-deenbridge-signature"] || ""; + const [version, provided] = header.split("="); + if (version !== "v1" || !provided) return false; + + // Reject stale deliveries (replay protection): 5 minutes. + const skew = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)); + if (!Number.isFinite(skew) || skew > 300) return false; + + const expected = crypto + .createHmac("sha256", secret) + .update(`${timestamp}.${rawBody}`) + .digest("hex"); + + const a = Buffer.from(expected, "hex"); + const b = Buffer.from(provided, "hex"); + return a.length === b.length && crypto.timingSafeEqual(a, b); +} +``` + +Always compare in constant time (`crypto.timingSafeEqual`) and reject stale +timestamps. Respond `2xx` to acknowledge; any other status (or a timeout) +triggers a retry. + +## Delivery, retries, and dead-letter + +A background worker (`services/webhooks/deliveryWorker.js`, enabled with +`WEBHOOK_WORKER_ENABLED=true`) claims due deliveries one at a time with an +atomic `findOneAndUpdate` so multiple loops/instances never double-send the +same row. Each POST has a ~10s timeout and does **not** follow redirects. + +- `2xx` → `delivered`. +- Otherwise the attempt is recorded and the delivery is retried on an + exponential backoff-with-jitter schedule: **1m, 5m, 30m, 2h, 12h**. +- After `WEBHOOK_MAX_ATTEMPTS` (default 6) it becomes `dead`. +- Dead deliveries can be requeued via + `POST /api/webhooks/:id/deliveries/:deliveryId/redeliver`. +- After `WEBHOOK_AUTO_DISABLE_THRESHOLD` (default 5) consecutive dead + deliveries the endpoint is auto-disabled (`isActive:false`, `disabledReason`) + and a warning is logged. Re-enable it via `PATCH /api/webhooks/:id`. + +All scheduling state lives in the `WebhookDelivery` document, so the loop can +later be swapped onto the durable job queue (issue #32) without a schema change. + +## Secret storage + +Signing secrets are generated server-side, returned to the caller **exactly +once** (at creation and on rotation), and stored **encrypted at rest** +(AES-256-GCM, key derived from `WEBHOOK_SECRET_ENCRYPTION_KEY`). Encryption +(not hashing) is required because the worker must recover the plaintext to +compute the HMAC on every attempt. The encrypted column is `select:false` and +stripped from every API response; no read endpoint ever returns the secret. + +## SSRF protection + +Endpoint URLs are validated at **registration** and again at **delivery**: +`https` is required outside development, and loopback / RFC-1918 / +link-local / other non-routable targets are rejected (literal IPs always; DNS +is additionally resolved in production). Residual limitation: DNS rebinding +between resolution and connect (TOCTOU) is not fully closed — that would +require pinning the resolved IP onto the connecting socket. + +## Management API + +All routes require an authenticated **admin** (`protect` + `authorizeRoles("admin")`). + +| Method & path | Purpose | +| --- | --- | +| `POST /api/webhooks` | Register an endpoint (returns `secret` once) | +| `GET /api/webhooks` | List your endpoints (no secret) | +| `GET /api/webhooks/:id` | Get one endpoint | +| `PATCH /api/webhooks/:id` | Update url/events/description/isActive | +| `DELETE /api/webhooks/:id` | Delete an endpoint | +| `POST /api/webhooks/:id/rotate-secret` | Rotate the secret (returns new `secret` once) | +| `GET /api/webhooks/:id/deliveries` | Paginated, `?status=` filterable delivery history | +| `POST /api/webhooks/:id/deliveries/:deliveryId/redeliver` | Requeue a delivery | +| `POST /api/webhooks/:id/ping` | Emit a signed `ping` event to the endpoint | diff --git a/mongo/base/BaseRepository.js b/mongo/base/BaseRepository.js new file mode 100644 index 00000000..440b416b --- /dev/null +++ b/mongo/base/BaseRepository.js @@ -0,0 +1,735 @@ +/** + * @module mongo/base/BaseRepository + * @description Abstract base repository encapsulating common Mongoose CRUD + * operations, offset- and cursor-based pagination, and standardized, + * typed error handling. Model-specific repositories should extend this class + * instead of talking to Mongoose models directly. + */ + +import mongoose from "mongoose"; +import logger from "../../src/config/logger.js"; + +/** + * Default number of documents returned by pagination methods. + * @constant {number} + */ +const DEFAULT_LIMIT = 20; + +/** + * Upper bound applied to any caller-supplied `limit` so a single request + * can never page the entire collection into memory. + * @constant {number} + */ +const MAX_LIMIT = 100; + +/** + * Queries slower than this many milliseconds are logged as warnings. + * @constant {number} + */ +const SLOW_OP_THRESHOLD_MS = 250; + +/* -------------------------------------------------------------------------- */ +/* Error types */ +/* -------------------------------------------------------------------------- */ + +/** + * Base class for all errors thrown by {@link BaseRepository}. Lets callers + * branch on a stable machine-readable `code` instead of parsing messages. + * All repository errors are operational (expected, recoverable) unless the + * underlying cause is unexpected infrastructure failure. + */ +export class RepositoryError extends Error { + /** + * @param {string} code - Stable error code (e.g. `"NOT_FOUND"`). + * @param {string} message - Human-readable description. + * @param {object} [options] + * @param {number} [options.statusCode=500] - HTTP status callers should map to. + * @param {Error} [options.cause] - Original error, preserved for debugging. + * @param {*} [options.details] - Extra structured context (e.g. invalid fields). + */ + constructor(code, message, { statusCode = 500, cause, details } = {}) { + super(message); + this.name = new.target.name; + this.code = code; + this.statusCode = statusCode; + this.isOperational = true; + if (cause) this.cause = cause; + if (details !== undefined) this.details = details; + Error.captureStackTrace(this, this.constructor); + } +} + +/** Thrown when a document matching an id/filter does not exist. Maps to HTTP 404. */ +export class DocumentNotFoundError extends RepositoryError { + /** @param {string} message @param {object} [options] */ + constructor(message, options) { + super("NOT_FOUND", message, { statusCode: 404, ...options }); + } +} + +/** Thrown for malformed input: empty filters, bad ids/cursors, unsupported options. Maps to HTTP 400. */ +export class RepositoryValidationError extends RepositoryError { + /** @param {string} message @param {object} [options] */ + constructor(message, options) { + super("VALIDATION_FAILED", message, { statusCode: 400, ...options }); + } +} + +/** Thrown when a write violates a unique index (Mongo duplicate key `11000`). Maps to HTTP 409. */ +export class DuplicateKeyError extends RepositoryError { + /** @param {string} message @param {object} [options] */ + constructor(message, options) { + super("DUPLICATE_KEY", message, { statusCode: 409, ...options }); + } +} + +/* -------------------------------------------------------------------------- */ +/* Helpers */ +/* -------------------------------------------------------------------------- */ + +/** + * Clamp a caller-supplied limit into `[1, MAX_LIMIT]`. + * @param {*} value - Raw limit value (may be undefined / string). + * @returns {{limit: number, clamped: boolean}} Resolved limit and whether it was adjusted. + */ +function resolveLimit(value) { + let limit = parseInt(value ?? DEFAULT_LIMIT, 10); + const clamped = + Number.isNaN(limit) || limit < 1 || limit > MAX_LIMIT; + if (Number.isNaN(limit) || limit < 1) limit = DEFAULT_LIMIT; + if (limit > MAX_LIMIT) limit = MAX_LIMIT; + return { limit, clamped }; +} + +/** + * Determine whether a plain-object filter is effectively empty. + * `$and`/`$or` operators count as non-empty. + * @param {*} filter + * @returns {boolean} + */ +function isEmptyFilter(filter) { + if (!filter || typeof filter !== "object") return true; + return Object.keys(filter).length === 0; +} + +/** + * Build a Mongoose query projection option from a `select` param. + * Accepts `"name email"` or `{ name: 1 }`. + * @param {string|object|undefined} select + * @returns {object|undefined} + */ +function selectOption(select) { + if (!select) return undefined; + return typeof select === "string" ? { [select]: 1 } : select; +} + +/* -------------------------------------------------------------------------- */ +/* BaseRepository */ +/* -------------------------------------------------------------------------- */ + +/** + * Abstract data-access base class for a single Mongoose model. + * + * Subclasses only need to pass their model to the constructor; every common + * read/write concern (CRUD, pagination, sessions, error normalization, + * slow-query logging) is inherited. Soft deletes are opt-in per call via + * `{ soft: true }` and are only available for models that declare a + * soft-delete field (`isDeleted` or `deletedAt`) in their schema — otherwise + * `delete()` performs a hard delete. + * + * Every method accepts an optional `session` (mongoose.ClientSession) so + * repositories compose cleanly inside `model.db.transaction()`. + * + * @example + * // mongo/repositories/CategoryRepository.js + * import BaseRepository from "../base/BaseRepository.js"; + * import Category from "../../src/models/Category.js"; + * + * export class CategoryRepository extends BaseRepository { + * constructor() { + * super(Category); + * } + * + * /** @returns {Promise} active categories ordered for menus *\/ + * async findActiveForMenu(options = {}) { + * return this.findMany( + * { isActive: true }, + * { sort: { order: 1, name: 1 }, select: "name slug icon", lean: true, ...options } + * ); + * } + * } + * + * // usage inside a service: + * const categoryRepo = new CategoryRepository(); + * const created = await categoryRepo.create({ name: "Zakat", slug: "zakat" }); + * const page = await categoryRepo.paginate({ isActive: true }, { page: 2, limit: 10 }); + * const feed = await categoryRepo.paginateCursor({}, { cursor: "NjZh...", limit: 25 }); + */ +export default class BaseRepository { + /** + * @param {import("mongoose").Model} model - Compiled Mongoose model this repository operates on. + * @throws {RepositoryValidationError} If `model` is missing or not a Mongoose model. + */ + constructor(model) { + if (!model || !(model.prototype instanceof mongoose.Model)) { + throw new RepositoryValidationError( + `${this.constructor.name}: a compiled mongoose model is required` + ); + } + + /** + * The wrapped Mongoose model. + * @type {import("mongoose").Model} + * @protected + */ + this.model = model; + + /** + * Scoped pino logger; every entry is tagged with the concrete class name. + * @type {import("pino").Logger} + * @protected + */ + this.logger = logger.child({ repository: this.constructor.name }); + + /** + * Whether this model's schema supports soft deletes. + * @type {"isDeleted"|"deletedAt"|null} + * @protected + */ + this.softDeleteField = this.model.schema.path("deletedAt") + ? "deletedAt" + : this.model.schema.path("isDeleted") + ? "isDeleted" + : null; + } + + /* ---------------------------------------------------------------------- */ + /* Writes */ + /* ---------------------------------------------------------------------- */ + + /** + * Create and persist a new document. + * + * @param {object} data - Attributes for the new document. + * @param {object} [options] + * @param {import("mongoose").ClientSession} [options.session] - Transaction session. + * @param {boolean} [options.validateBeforeSave=true] - Run schema validators. + * @returns {Promise} The saved document. + * @throws {DuplicateKeyError} If a unique index is violated (Mongo code 11000). + * @throws {RepositoryValidationError} If schema validation fails. + * @example + * const user = await userRepo.create({ name: "Aisha", email: "a@example.com" }); + */ + async create(data, options = {}) { + const { session, validateBeforeSave = true } = options; + return this._run("create", async () => { + try { + return await this.model.create([data], { + session, + validateBeforeSave, + }).then(([doc]) => doc); + } catch (err) { + throw this._normalizeError(err, "create"); + } + }); + } + + /** + * Update one document by id **or** arbitrary filter. + * + * Passing a bare ObjectId/string targets `_id`; passing an object is used as + * the filter directly (an empty filter throws rather than updating blindly). + * + * @param {string|import("mongoose").Types.ObjectId|object} idOrFilter - Document id or filter object. + * @param {object} data - Update payload (`$set` is applied implicitly). + * @param {object} [options] + * @param {boolean} [options.new=true] - Return the updated document. + * @param {boolean} [options.runValidators=true] - Validate update payload against schema. + * @param {string|object} [options.select] - Projection of the returned document. + * @param {(Array|object|string)} [options.populate] - Paths to populate. + * @param {boolean} [options.upsert=false] - Insert when no document matches. + * @param {import("mongoose").ClientSession} [options.session] - Transaction session. + * @returns {Promise} Updated document, or `null` with upsert disabled and nothing matched. + * @throws {DocumentNotFoundError} When `throwIfNotFound` semantics requested and nothing matched. + * @throws {RepositoryValidationError} On empty filter or failed validation. + * @throws {DuplicateKeyError} On unique-index violation. + * @example + * await pledgeRepo.update(pledgeId, { status: "paid" }, { session }); + */ + async update(idOrFilter, data, options = {}) { + const { + new: returnNew = true, + runValidators = true, + upsert = false, + select, + populate, + session, + } = options; + + return this._run("update", async () => { + const filter = this._resolveFilter(idOrFilter); + try { + const doc = await this.model + .findOneAndUpdate(filter, { $set: data }, { new: returnNew, runValidators, upsert, session }) + .select(selectOption(select)) + .populate(populate); + if (!doc && !upsert) { + this.logger.warn({ filter: this._safe(filter) }, "update matched no documents"); + } + return doc; + } catch (err) { + throw this._normalizeError(err, "update"); + } + }); + } + + /** + * Delete one document by id **or** filter. + * + * Hard delete is the default. Pass `{ soft: true }` to flag the document + * instead — supported only when the schema declares an `isDeleted` or + * `deletedAt` path (see {@link BaseRepository#softDeleteField}). Extend + * subclasses for richer retention rules (archival, cascades). + * + * @param {string|import("mongoose").Types.ObjectId|object} idOrFilter - Document id or filter object. + * @param {object} [options] + * @param {boolean} [options.soft=false] - Soft delete instead of removing. + * @param {import("mongoose").ClientSession} [options.session] - Transaction session. + * @returns {Promise<{acknowledged: boolean, deletedCount: number, softDeleted: boolean}>} Outcome summary. + * @throws {DocumentNotFoundError} When nothing matched. + * @throws {RepositoryValidationError} On empty filter, or `soft: true` for a model without a soft-delete field. + * @example + * await notificationRepo.delete(notificationId); // hard delete + * await auditLogRepo.delete(auditId, { soft: true }); // flags isDeleted/deletedAt if present + */ + async delete(idOrFilter, options = {}) { + const { soft = false, session } = options; + + return this._run("delete", async () => { + const filter = this._resolveFilter(idOrFilter); + + if (soft) { + if (!this.softDeleteField) { + throw new RepositoryValidationError( + `${this.model.modelName} does not declare an isDeleted/deletedAt field; soft delete unsupported` + ); + } + const now = new Date(); + const patch = + this.softDeleteField === "isDeleted" + ? { $set: { isDeleted: true } } + : { $set: { deletedAt: now } }; + const res = await this.model.updateOne(filter, patch, { session }); + if (res.matchedCount === 0) { + throw new DocumentNotFoundError(`${this.model.modelName} not found`, { details: { filter: this._safe(filter) } }); + } + return { acknowledged: res.acknowledged, deletedCount: res.modifiedCount, softDeleted: true }; + } + + const doc = await this.model.findOneAndDelete(filter, { session }); + if (!doc) { + throw new DocumentNotFoundError(`${this.model.modelName} not found`, { details: { filter: this._safe(filter) } }); + } + return { acknowledged: true, deletedCount: 1, softDeleted: false }; + }); + } + + /* ---------------------------------------------------------------------- */ + /* Reads */ + /* ---------------------------------------------------------------------- */ + + /** + * Fetch a document by its primary key. + * + * @param {string|import("mongoose").Types.ObjectId|null} id - Value castable to ObjectId. + * @param {object} [options] + * @param {string|object} [options.select] - Projection ("name email" or { name: 1 }). + * @param {(Array|object|string)} [options.populate] - Paths to populate. + * @param {boolean} [options.lean=false] - Return a plain JS object instead of a hydrated document. + * @param {import("mongoose").ClientSession} [options.session] - Transaction session. + * @returns {Promise} The document, or `null` when absent/malformed id. + * @example + * const course = await courseRepo.findById(id, { populate: "educator", lean: true }); + * if (!course) throw new APIError("Course not found", 404); + */ + async findById(id, options = {}) { + const { select, populate, lean = false, session } = options; + + return this._run("findById", async () => { + if (!id || !mongoose.Types.ObjectId.isValid(String(id))) return null; + return this.model + .findById(id) + .select(selectOption(select)) + .populate(populate) + .lean(lean) + .session(session ?? null); + }); + } + + /** + * Fetch a single document matching an arbitrary filter. + * + * @param {object} filter - Mongo filter; must be non-empty. + * @param {object} [options] - Same shape as {@link BaseRepository#findById}. + * @param {string|object} [options.select] + * @param {(Array|object|string)} [options.populate] + * @param {boolean} [options.lean=false] + * @param {import("mongoose").ClientSession} [options.session] + * @returns {Promise} Matching document or `null`. + * @throws {RepositoryValidationError} When `filter` is empty (would match an arbitrary document). + * @example + * const user = await userRepo.findOne({ email }, { lean: true }); + */ + async findOne(filter, options = {}) { + const { select, populate, lean = false, session } = options; + + return this._run("findOne", async () => { + if (isEmptyFilter(filter)) { + throw new RepositoryValidationError("findOne requires a non-empty filter"); + } + try { + return await this.model + .findOne(filter) + .select(selectOption(select)) + .populate(populate) + .lean(lean) + .session(session ?? null); + } catch (err) { + throw this._normalizeError(err, "findOne"); + } + }); + } + + /** + * Fetch many documents matching a filter, with optional sorting, paging and projection. + * + * @param {object} [filter={}] - Mongo filter; `{}` returns all documents. + * @param {object} [options] + * @param {object|string} [options.sort] - e.g. `{ createdAt: -1 }`. + * @param {number} [options.limit] - Max docs to return. + * @param {number} [options.skip] - Docs to skip (offset paging). + * @param {string|object} [options.select] + * @param {(Array|object|string)} [options.populate] + * @param {boolean} [options.lean=false] + * @param {import("mongoose").ClientSession} [options.session] + * @returns {Promise>} Matching documents (possibly empty). + * @example + * const recent = await transactionRepo.findMany({ status: "pending" }, { sort: { createdAt: -1 }, limit: 50, lean: true }); + */ + async findMany(filter = {}, options = {}) { + const { sort, limit, skip, select, populate, lean = false, session } = options; + + return this._run("findMany", async () => { + try { + let query = this.model.find(filter).lean(lean).session(session ?? null); + if (sort) query = query.sort(sort); + if (Number.isFinite(limit)) query = query.limit(limit); + if (Number.isFinite(skip)) query = query.skip(skip); + if (select) query = query.select(typeof select === "string" ? select : selectOption(select)); + if (populate) query = query.populate(populate); + return await query; + } catch (err) { + throw this._normalizeError(err, "findMany"); + } + }); + } + + /** + * Count documents matching a filter. + * + * @param {object} [filter={}] - Mongo filter. + * @param {object} [options] + * @param {number} [options.limit] - Cap the counted documents (countDocuments arg). + * @param {import("mongoose").ClientSession} [options.session] - Transaction session. + * @returns {Promise} Number of matching documents. + */ + async count(filter = {}, options = {}) { + const { limit, session } = options; + return this._run("count", () => + this.model.countDocuments(filter, { limit, session }) + ); + } + + /* ---------------------------------------------------------------------- */ + /* Pagination */ + /* ---------------------------------------------------------------------- */ + + /** + * Offset-based pagination for classic numbered UIs. + * + * Limits are clamped to `[1, 100]`. Sorting defaults to `_id` descending + * (stable even without a `createdAt` index). Deep offsets scan skipped + * documents server-side — prefer {@link BaseRepository#paginateCursor} + * for unbounded feeds. + * + * @param {object} [filter={}] - Mongo filter. + * @param {object} [options] + * @param {number} [options.page=1] - 1-based page number. + * @param {number} [options.offset] - Explicit skip; overrides computed `page` offset when provided. + * @param {number} [options.limit=20] - Page size (clamped to 100). + * @param {string} [options.sortBy="_id"] - Field to sort by. + * @param {("asc"|"desc")} [options.order="desc"] - Sort direction. + * @param {string|object} [options.select] + * @param {(Array|object|string)} [options.populate] + * @param {boolean} [options.lean=false] + * @param {import("mongoose").ClientSession} [options.session] + * @returns {Promise<{data: Array, total: number, page: number, limit: number, totalPages: number, offset: number, hasNextPage: boolean, hasPrevPage: boolean}>} Page plus metadata. + * @throws {RepositoryValidationError} If `sortBy` resolves to an empty field name. + * @example + * const { data, total, hasNextPage } = await userRepo.paginate( + * { isActive: true }, + * { page: req.query.page, limit: req.query.limit, sortBy: "createdAt" } + * ); + */ + async paginate(filter = {}, options = {}) { + const { + page: rawPage = 1, + offset: explicitOffset, + sortBy = "_id", + order = "desc", + select, + populate, + lean = false, + session, + } = options; + + return this._run("paginate", async () => { + const { limit, clamped } = resolveLimit(options.limit); + let page = parseInt(rawPage, 10); + if (Number.isNaN(page) || page < 1) page = 1; + if (clamped || Number.isNaN(parseInt(options.limit, 10))) { + this.logger.debug({ requestedLimit: options.limit, resolvedLimit: limit }, "pagination limit clamped"); + } + + const offset = Number.isFinite(explicitOffset) ? Math.max(0, explicitOffset) : (page - 1) * limit; + const direction = String(order).toLowerCase() === "asc" ? 1 : -1; + const sortField = String(sortBy).trim(); + if (!sortField) { + throw new RepositoryValidationError("sortBy cannot be empty"); + } + + const [data, total] = await Promise.all([ + this.findMany(filter, { sort: { [sortField]: direction }, limit, skip: offset, select, populate, lean, session }), + this.count(filter, { session }), + ]); + + const totalPages = Math.ceil(total / limit); + return { + data, + total, + page, + limit, + totalPages, + offset, + hasNextPage: page < totalPages, + hasPrevPage: page > 1, + }; + }); + } + + /** + * Cursor-based pagination keyed on a unique `_id` boundary — stable under + * concurrent inserts/deletes, O(limit) cost at any depth. Requires reading + * rows in `_id` order (ascending or descending), which matches how + * ObjectIds encode creation time. + * + * @param {object} [filter={}] - Mongo filter combined internally with the cursor condition. + * @param {object} [options] + * @param {string} [options.cursor] - Opaque cursor from a previous response's `nextCursor` (`null`/omitted starts from the beginning). + * @param {number} [options.limit=20] - Page size (clamped to 100). + * @param {("asc"|"desc")} [options.order="desc"] - Direction of `_id` traversal ("newest-first" by default). + * @param {string|object} [options.select] + * @param {(Array|object|string)} [options.populate] + * @param {boolean} [options.lean=false] + * @param {import("mongoose").ClientSession} [options.session] + * @returns {Promise<{data: Array, nextCursor: string|null, hasMore: boolean, limit: number}>} Page plus continuation token. + * @throws {RepositoryValidationError} If `cursor` is not a valid encoded ObjectId. + * @example + * // GET /api/v1/feed?cursor=NjZhMm...&limit=25 + * const page = await reelRepo.paginateCursor({ spaceId }, { cursor: req.query.cursor, limit: 25 }); + * res.json({ items: page.data, nextCursor: page.nextCursor }); + */ + async paginateCursor(filter = {}, options = {}) { + const { + cursor, + order = "desc", + select, + populate, + lean = false, + session, + } = options; + + return this._run("paginateCursor", async () => { + const { limit, clamped } = resolveLimit(options.limit); + if (clamped) { + this.logger.debug({ requestedLimit: options.limit, resolvedLimit: limit }, "cursor pagination limit clamped"); + } + const direction = String(order).toLowerCase() === "asc" ? 1 : -1; + + const effectiveFilter = { ...filter }; + if (cursor != null && cursor !== "") { + const boundary = this._decodeCursor(cursor); + effectiveFilter._id = { ...(filter._id ?? {}), [direction === 1 ? "$gt" : "$lt"]: boundary }; + } + + const data = await this.findMany(effectiveFilter, { + sort: { _id: direction }, + limit: limit + 1, // fetch one extra to detect hasMore without a second query + select, + populate, + lean, + session, + }); + + const hasMore = data.length > limit; + if (hasMore) data.length = limit; + const last = data.at(-1); + + return { + data, + nextCursor: hasMore && last ? this._encodeCursor(last._id) : null, + hasMore, + limit, + }; + }); + } + + /* ---------------------------------------------------------------------- */ + /* Internals */ + /* ---------------------------------------------------------------------- */ + + /** + * Normalize an id-or-filter argument into a Mongo filter, validating ids. + * @protected + * @param {string|import("mongoose").Types.ObjectId|object} idOrFilter + * @returns {object} Mongo filter. + * @throws {RepositoryValidationError} For empty filters or non-castable ids. + */ + _resolveFilter(idOrFilter) { + if (idOrFilter instanceof mongoose.Types.ObjectId || typeof idOrFilter === "string") { + if (!mongoose.Types.ObjectId.isValid(idOrFilter)) { + throw new RepositoryValidationError(`Invalid ObjectId: "${idOrFilter}"`); + } + return { _id: new mongoose.Types.ObjectId(idOrFilter) }; + } + if (isEmptyFilter(idOrFilter)) { + throw new RepositoryValidationError( + "Refusing to target every document: provide an id or a non-empty filter" + ); + } + return idOrFilter; + } + + /** + * Map driver/schema errors to typed repository errors; unknown errors pass + * through untouched so original stack traces survive. Also logs. + * @protected + * @param {Error} err - Error raised by Mongoose/MongoDB. + * @param {string} op - Operation name for log context. + * @returns {RepositoryError|Error} Normalized error ready to be thrown. + */ + _normalizeError(err, op) { + if (err instanceof RepositoryError) return err; + + // Mongo duplicate key (unique index violation) + if (err?.code === 11000) { + this.logger.warn({ op, keyValue: err.keyValue }, "duplicate key"); + return new DuplicateKeyError( + `Duplicate value for ${Object.keys(err.keyValue ?? {}).join(", ") || "unique field"}`, + { cause: err, details: err.keyValue } + ); + } + + // Bad ObjectId / bad value type for a path + if (err?.name === "CastError") { + this.logger.warn({ op, path: err.path, value: err.value }, "cast error"); + return new RepositoryValidationError(`Invalid value for ${err.path}: ${err.value}`, { cause: err }); + } + + // Schema validation failure + if (err?.name === "ValidationError") { + const details = Object.fromEntries( + Object.values(err.errors ?? {}).map((e) => [e.path, e.message]) + ); + this.logger.warn({ op, details }, "validation failed"); + return new RepositoryValidationError("Document validation failed", { cause: err, details }); + } + + // Unknown: log and rethrow original so nothing is swallowed silently. + this.logger.error({ err, op }, "unexpected repository error"); + return err; + } + + /** + * Time an operation, emit debug logs on success, warn above the slow-op + * threshold, then return/propagate the result. Errors are re-thrown — + * never swallowed. + * @protected + * @template T + * @param {string} op - Operation name for logs. + * @param {() => Promise} fn - Operation body. + * @returns {Promise} + */ + async _run(op, fn) { + const startedAt = Date.now(); + try { + const result = await fn(); + const durationMs = Date.now() - startedAt; + if (durationMs >= SLOW_OP_THRESHOLD_MS) { + this.logger.warn({ op, durationMs }, "slow repository operation"); + } else { + this.logger.debug({ op, durationMs }, "repository operation complete"); + } + return result; + } catch (err) { + this.logger.error( + { err: err instanceof Error ? err : new Error(String(err)), op, durationMs: Date.now() - startedAt }, + "repository operation failed" + ); + throw err; + } + } + + /** + * Encode a raw `_id` as an opaque base64url cursor. + * @protected + * @param {import("mongoose").Types.ObjectId|string} id + * @returns {string} URL-safe cursor token. + */ + _encodeCursor(id) { + return Buffer.from(String(id), "utf8").toString("base64url"); + } + + /** + * Decode an opaque cursor back into an ObjectId boundary. + * @protected + * @param {string} cursor + * @returns {import("mongoose").Types.ObjectId} + * @throws {RepositoryValidationError} If decoding or casting fails. + */ + _decodeCursor(cursor) { + try { + const decoded = Buffer.from(cursor, "base64url").toString("utf8"); + if (!mongoose.Types.ObjectId.isValid(decoded)) throw new Error("not an ObjectId"); + return new mongoose.Types.ObjectId(decoded); + } catch { + throw new RepositoryValidationError("Malformed pagination cursor"); + } + } + + /** + * Redact a filter for logging (drop operator objects which may embed PII). + * @protected + * @param {object} filter + * @returns {object} Log-safe shallow summary of the filter keys. + */ + _safe(filter) { + if (!filter || typeof filter !== "object") return {}; + return Object.fromEntries( + Object.entries(filter).map(([k, v]) => [ + k, + v && typeof v === "object" ? "[complex]" : "[value]", + ]) + ); + } +} diff --git a/mongo/base/README.md b/mongo/base/README.md new file mode 100644 index 00000000..8b0896fe --- /dev/null +++ b/mongo/base/README.md @@ -0,0 +1,5 @@ +# Base classes for the /mongo repository layer live here. + +`BaseRepository` (CRUD, offset & cursor pagination, standardized errors) is +added by its own refactor — see Deen-Bridge/dnb-backend#168 — and will be +re-exported from `mongo/index.js` as part of the `base` namespace. diff --git a/mongo/config/readPreference.js b/mongo/config/readPreference.js new file mode 100644 index 00000000..7cfb36c8 --- /dev/null +++ b/mongo/config/readPreference.js @@ -0,0 +1,206 @@ +/** + * @module mongo/config/readPreference + * Read-preference configuration for MongoDB replica sets. + * ------------------------------------------------------------------------- + * Centralises the set of valid Mongoose/MongoDB read-preference modes and + * provides a small router that maps a logical *query type* (read vs write) + * onto a concrete read preference. This lets callers route traffic across a + * replica set — sending writes and read-your-write reads to the primary while + * off-loading eventually-consistent reads to secondaries. + * + * This module is intentionally side-effect free: importing it neither opens a + * connection nor mutates global Mongoose state. Consumers opt in by calling + * {@link getReadPreference} (or reading {@link READ_PREFERENCE}) and passing + * the result to a query/connection. + * + * @example + * import { READ_PREFERENCE, getReadPreference } from "../mongo/config/readPreference.js"; + * + * // Explicit mode + * Book.find().read(READ_PREFERENCE.SECONDARY_PREFERRED); + * + * // Query-type routing (write → primary, read → secondaryPreferred) + * Book.find().read(getReadPreference("read")); + */ + +/** + * The MongoDB read-preference modes supported by Mongoose. + * + * Values are the wire-level mode strings understood by both the MongoDB driver + * and Mongoose's `Query.prototype.read()` / connection `readPreference` option. + * + * - `PRIMARY` — read only from the replica-set primary (default). + * - `PRIMARY_PREFERRED` — primary if available, otherwise a secondary. + * - `SECONDARY` — read only from secondaries. + * - `SECONDARY_PREFERRED` — secondaries if available, otherwise the primary. + * - `NEAREST` — the member with the lowest network latency. + * + * @readonly + * @enum {string} + */ +export const READ_PREFERENCE = Object.freeze({ + PRIMARY: "primary", + PRIMARY_PREFERRED: "primaryPreferred", + SECONDARY: "secondary", + SECONDARY_PREFERRED: "secondaryPreferred", + NEAREST: "nearest", +}); + +/** + * Immutable list of every valid read-preference mode string. + * + * Useful for validation (e.g. checking an env-var override against the set of + * modes the driver accepts). + * + * @readonly + * @type {ReadonlyArray} + */ +export const READ_PREFERENCE_MODES = Object.freeze( + Object.values(READ_PREFERENCE) +); + +/** + * Default read preference applied when a query type is unknown or unspecified. + * + * Defaults to `primary` for the safest, strongly-consistent behaviour, but can + * be overridden with the `MONGO_READ_PREFERENCE` environment variable. An + * invalid override is ignored in favour of `primary`. + * + * @readonly + * @type {string} + */ +export const DEFAULT_READ_PREFERENCE = READ_PREFERENCE_MODES.includes( + process.env.MONGO_READ_PREFERENCE +) + ? process.env.MONGO_READ_PREFERENCE + : READ_PREFERENCE.PRIMARY; + +/** + * Read preference used for read-heavy query types when replica reads are + * enabled. Sourced from `MONGO_READ_REPLICA_PREFERENCE` and falls back to + * `secondaryPreferred`, which keeps availability if no secondary is reachable. + * + * @readonly + * @type {string} + */ +export const READ_QUERY_PREFERENCE = READ_PREFERENCE_MODES.includes( + process.env.MONGO_READ_REPLICA_PREFERENCE +) + ? process.env.MONGO_READ_REPLICA_PREFERENCE + : READ_PREFERENCE.SECONDARY_PREFERRED; + +/** + * Query types recognised by {@link getReadPreference}. + * + * Both a coarse read/write split and the common Mongoose operation names are + * accepted so callers can pass an operation name directly. + * + * @readonly + * @enum {string} + */ +export const QUERY_TYPE = Object.freeze({ + READ: "read", + WRITE: "write", +}); + +/** + * Mongoose/Mongo operations that mutate data and therefore must target the + * primary. Anything not listed here is treated as a read. + * + * @readonly + * @type {ReadonlyArray} + */ +const WRITE_OPERATIONS = Object.freeze([ + "write", + "insert", + "insertone", + "insertmany", + "create", + "save", + "update", + "updateone", + "updatemany", + "replaceone", + "delete", + "deleteone", + "deletemany", + "remove", + "findoneandupdate", + "findoneanddelete", + "findoneandreplace", + "findbyidandupdate", + "findbyidanddelete", + "bulkwrite", + "aggregate", // may contain $out/$merge write stages — route to primary to be safe +]); + +/** + * Resolve a MongoDB read preference for a given logical query type. + * + * Writes (and write-like operations) always resolve to `primary` to guarantee + * they hit the replica-set primary. Reads resolve to {@link READ_QUERY_PREFERENCE} + * (default `secondaryPreferred`) so they can be served by secondaries. An + * unrecognised or empty query type falls back to {@link DEFAULT_READ_PREFERENCE}. + * + * @param {string} [queryType="read"] - A {@link QUERY_TYPE} value (`"read"` / + * `"write"`) or a Mongoose operation name such as `"find"`, `"updateOne"`, + * `"insertMany"`. Matching is case-insensitive. + * @returns {string} One of the {@link READ_PREFERENCE} mode strings. + * + * @example + * getReadPreference("write"); // → "primary" + * getReadPreference("updateOne"); // → "primary" + * getReadPreference("read"); // → "secondaryPreferred" + * getReadPreference("find"); // → "secondaryPreferred" + */ +export function getReadPreference(queryType = QUERY_TYPE.READ) { + if (typeof queryType !== "string" || queryType.trim() === "") { + return DEFAULT_READ_PREFERENCE; + } + + const normalized = queryType.trim().toLowerCase(); + + if (WRITE_OPERATIONS.includes(normalized)) { + return READ_PREFERENCE.PRIMARY; + } + + if (normalized === QUERY_TYPE.READ) { + return READ_QUERY_PREFERENCE; + } + + if (normalized === QUERY_TYPE.WRITE) { + return READ_PREFERENCE.PRIMARY; + } + + // Any other (read-style) operation name — e.g. find/findOne/count/distinct — + // is safe to serve from a secondary. + return READ_QUERY_PREFERENCE; +} + +/** + * Type guard that reports whether a value is a valid read-preference mode. + * + * @param {unknown} mode - Candidate read-preference string. + * @returns {boolean} `true` if `mode` is one of {@link READ_PREFERENCE_MODES}. + * + * @example + * isValidReadPreference("nearest"); // → true + * isValidReadPreference("fastest"); // → false + */ +export function isValidReadPreference(mode) { + return typeof mode === "string" && READ_PREFERENCE_MODES.includes(mode); +} + +/** + * Default export mirrors the named exports for callers that prefer a namespace + * import: `import readPreference from "../mongo/config/readPreference.js"`. + */ +export default { + READ_PREFERENCE, + READ_PREFERENCE_MODES, + DEFAULT_READ_PREFERENCE, + READ_QUERY_PREFERENCE, + QUERY_TYPE, + getReadPreference, + isValidReadPreference, +}; diff --git a/mongo/connection/replicaSet.js b/mongo/connection/replicaSet.js new file mode 100644 index 00000000..1b50f85a --- /dev/null +++ b/mongo/connection/replicaSet.js @@ -0,0 +1,232 @@ +/** + * @module mongo/connection/replicaSet + * Replica-set connection helpers for MongoDB / Mongoose. + * ------------------------------------------------------------------------- + * Builds Mongoose connection options wired for a replica set with a chosen + * read preference, and attaches connection-event listeners that provide basic + * failover monitoring via structured logging (connected / disconnected / + * reconnected / error). + * + * This module is intentionally side-effect free at import time: it neither + * connects nor mutates global Mongoose state until one of its functions is + * explicitly invoked. This keeps server boot and `node --check` safe and lets + * the existing connection code in `src/config/db.js` remain untouched — callers + * opt in to replica-set routing rather than having it forced on them. + * + * @example + * import mongoose from "mongoose"; + * import { connectReplicaSet } from "../mongo/connection/replicaSet.js"; + * + * await connectReplicaSet(mongoose, { + * uri: process.env.MONGO_URI, + * replicaSet: "rs0", + * readPreference: "secondaryPreferred", + * }); + */ + +import { getReadPreference, isValidReadPreference, READ_PREFERENCE } from "../config/readPreference.js"; + +/** + * Minimal logger shape used by this module. + * @typedef {Object} ReplicaSetLogger + * @property {(...args: any[]) => void} info + * @property {(...args: any[]) => void} warn + * @property {(...args: any[]) => void} error + */ + +/** + * Resolve a usable logger. Prefers the shared pino logger at + * `src/config/logger.js`; if it cannot be loaded (e.g. in a trimmed test + * environment) falls back to the global `console`. Loading is lazy and guarded + * so that importing this module never throws. + * + * @param {ReplicaSetLogger} [override] - Explicit logger to use instead. + * @returns {Promise} A logger with info/warn/error methods. + */ +async function resolveLogger(override) { + if (override && typeof override.info === "function") { + return override; + } + try { + const mod = await import("../../src/config/logger.js"); + return mod.default || console; + } catch { + return console; + } +} + +/** + * Build a Mongoose connection options object configured for a replica set. + * + * Merges sensible replica-set defaults (connection pool sizing, timeouts, + * majority writes with retryable writes/reads) with the requested read + * preference and any caller overrides. Pure function — it performs no I/O and + * has no side effects. + * + * Environment variables consulted for defaults: + * - `MONGO_REPLICA_SET` — replica-set name (`replicaSet`). + * - `MONGO_READ_PREFERENCE` — default read preference mode. + * - `MONGO_MAX_POOL_SIZE` — max pool size (default 10). + * - `MONGO_MIN_POOL_SIZE` — min pool size (default 5). + * + * @param {Object} [config={}] - Connection configuration. + * @param {string} [config.replicaSet] - Replica-set name. + * @param {string} [config.readPreference] - A valid read-preference mode + * (see {@link module:mongo/config/readPreference.READ_PREFERENCE}). Invalid + * values are ignored in favour of the default. + * @param {number} [config.maxPoolSize] - Maximum connection-pool size. + * @param {number} [config.minPoolSize] - Minimum connection-pool size. + * @param {number} [config.serverSelectionTimeoutMS=5000] - Server-selection timeout. + * @param {number} [config.socketTimeoutMS=45000] - Socket timeout. + * @param {Object} [config.overrides] - Extra Mongoose options merged last, + * taking precedence over every computed value. + * @returns {import("mongoose").ConnectOptions} Mongoose connection options. + * + * @example + * const opts = buildReplicaSetOptions({ replicaSet: "rs0", readPreference: "nearest" }); + */ +export function buildReplicaSetOptions(config = {}) { + const requestedPreference = config.readPreference ?? process.env.MONGO_READ_PREFERENCE; + const readPreference = isValidReadPreference(requestedPreference) + ? requestedPreference + : getReadPreference("read"); + + const replicaSet = config.replicaSet ?? process.env.MONGO_REPLICA_SET; + + const options = { + maxPoolSize: + config.maxPoolSize ?? parseInt(process.env.MONGO_MAX_POOL_SIZE || "10", 10), + minPoolSize: + config.minPoolSize ?? parseInt(process.env.MONGO_MIN_POOL_SIZE || "5", 10), + serverSelectionTimeoutMS: config.serverSelectionTimeoutMS ?? 5000, + socketTimeoutMS: config.socketTimeoutMS ?? 45000, + retryWrites: true, + retryReads: true, + w: "majority", + readPreference, + ...(replicaSet ? { replicaSet } : {}), + ...(config.overrides || {}), + }; + + return options; +} + +/** + * Attach replica-set monitoring/failover listeners to a Mongoose connection. + * + * Registers handlers for the `connected`, `disconnected`, `reconnected` and + * `error` events and logs each transition. These provide lightweight failover + * observability — a `disconnected`/`reconnected` pair typically corresponds to + * a primary step-down and re-election within the replica set. + * + * Idempotent: a guard flag on the connection prevents duplicate registration if + * called more than once for the same connection. + * + * @param {import("mongoose").Connection} connection - The Mongoose connection + * (e.g. `mongoose.connection`) to instrument. + * @param {Object} [options={}] - Options. + * @param {ReplicaSetLogger} [options.logger] - Logger override; defaults to the + * shared pino logger with a `console` fallback. + * @returns {Promise} The same connection, for chaining. + * + * @example + * await attachConnectionListeners(mongoose.connection); + */ +export async function attachConnectionListeners(connection, options = {}) { + if (!connection || typeof connection.on !== "function") { + throw new TypeError("attachConnectionListeners: a Mongoose connection is required"); + } + + // Guard against double registration on the same connection object. + if (connection.__replicaSetListenersAttached) { + return connection; + } + Object.defineProperty(connection, "__replicaSetListenersAttached", { + value: true, + enumerable: false, + configurable: true, + writable: true, + }); + + const logger = await resolveLogger(options.logger); + + connection.on("connected", () => { + logger.info( + { host: connection.host, name: connection.name }, + "MongoDB replica set connected" + ); + }); + + connection.on("reconnected", () => { + logger.info("MongoDB replica set reconnected (failover recovered)"); + }); + + connection.on("disconnected", () => { + logger.warn("MongoDB replica set disconnected (possible primary step-down / failover)"); + }); + + connection.on("error", (err) => { + logger.error(err, "MongoDB replica set connection error"); + }); + + return connection; +} + +/** + * Connect Mongoose to a replica set with read-preference routing and attach + * monitoring/failover listeners. + * + * Convenience wrapper combining {@link buildReplicaSetOptions} and + * {@link attachConnectionListeners}. It does nothing until called, so importing + * this module remains side-effect free. + * + * @param {import("mongoose")} mongoose - The Mongoose instance to connect with. + * @param {Object} [config={}] - Configuration. + * @param {string} [config.uri] - Connection string; defaults to `process.env.MONGO_URI`. + * @param {string} [config.replicaSet] - Replica-set name. + * @param {string} [config.readPreference] - Read-preference mode. + * @param {number} [config.maxPoolSize] - Max pool size. + * @param {number} [config.minPoolSize] - Min pool size. + * @param {number} [config.serverSelectionTimeoutMS] - Server-selection timeout. + * @param {number} [config.socketTimeoutMS] - Socket timeout. + * @param {Object} [config.overrides] - Extra Mongoose options. + * @param {ReplicaSetLogger} [config.logger] - Logger override. + * @returns {Promise} The active connection. + * @throws {Error} If no URI is provided (and `MONGO_URI` is unset) or the + * underlying `mongoose.connect` rejects. + * + * @example + * import mongoose from "mongoose"; + * await connectReplicaSet(mongoose, { readPreference: "secondaryPreferred" }); + */ +export async function connectReplicaSet(mongoose, config = {}) { + if (!mongoose || typeof mongoose.connect !== "function") { + throw new TypeError("connectReplicaSet: a Mongoose instance is required"); + } + + const uri = config.uri ?? process.env.MONGO_URI; + if (!uri) { + throw new Error( + "connectReplicaSet: no connection URI provided (set MONGO_URI or pass config.uri)" + ); + } + + const options = buildReplicaSetOptions(config); + + // Attach listeners before connecting so the initial `connected` event is caught. + await attachConnectionListeners(mongoose.connection, { logger: config.logger }); + + await mongoose.connect(uri, options); + return mongoose.connection; +} + +/** + * Default export mirrors the named exports for callers that prefer a namespace + * import: `import replicaSet from "../mongo/connection/replicaSet.js"`. + */ +export default { + READ_PREFERENCE, + buildReplicaSetOptions, + attachConnectionListeners, + connectReplicaSet, +}; diff --git a/mongo/index.js b/mongo/index.js new file mode 100644 index 00000000..c51174e4 --- /dev/null +++ b/mongo/index.js @@ -0,0 +1,70 @@ +/** + * @module mongo + * Entry point for the MongoDB data-access layer. + * ------------------------------------------------------------------------- + * This module is the public surface of the `/mongo` repository layer — the + * home of data-access code that is being separated from route handlers and + * services so persistence logic stays consistent and testable. + * + * Structure + * --------- + * ``` + * mongo/ + * ├── index.js ← you are here: re-exports everything below + * ├── base/ ← shared repository base classes + * │ └── BaseRepository.js + * └── repositories/ ← model-specific repositories + * ├── BookRepository.js + * ├── NotificationRepository.js + * └── ReelRepository.js + * └── NotificationRepository.js + * └── EducatorBalanceRepository.js + * ``` + * + * Intended usage: + * + * ```js + * import { base } from "../mongo/index.js"; + * import Reel from "../../src/models/Reel.js"; + * + * class CourseRepository extends base.BaseRepository { + * constructor() { + * super(Reel); + * import BaseRepository from "../mongo/base/BaseRepository.js"; + * + * class CourseRepository extends BaseRepository { + * constructor() { + * super(Course); + * } + * // thin, course-specific query helpers only + * } + * ``` + * + * Conventions for anything added under `/mongo`: + * - Repositories never call `res`/express — they return data or throw. + * - Errors are typed (see `base.BaseRepository`) rather than generic. + * - Every exported function/class carries complete JSDoc. + */ + +import BaseRepository from "./base/BaseRepository.js"; + +/** + * Namespace for shared repository base classes. + * + * @type {{ BaseRepository: typeof BaseRepository }} + */ +export const base = Object.freeze({ BaseRepository }); + +/** + * Model-specific repositories. + */ +export { default as BookRepository } from "./repositories/BookRepository.js"; +export { default as NotificationRepository } from "./repositories/NotificationRepository.js"; +export { default as ReelRepository } from "./repositories/ReelRepository.js"; +export { default as EducatorBalanceRepository } from "./repositories/EducatorBalanceRepository.js"; + +/** + * Default export mirrors the named exports for callers that prefer + * `import mongo from "../mongo/index.js"`. + */ +export default { base }; diff --git a/mongo/mixins/Searchable.js b/mongo/mixins/Searchable.js new file mode 100644 index 00000000..2d81380d --- /dev/null +++ b/mongo/mixins/Searchable.js @@ -0,0 +1,140 @@ +/** + * @module mongo/mixins/Searchable + * Mixin that adds a consistent full-text search interface to Mongoose models. + * ------------------------------------------------------------------------- + * This mixin can be applied to any Mongoose schema that has a text index + * defined. It provides a `.search()` static method (or instance method on + * the model) that delegates to the {@link module:mongo/utils/textSearch} + * utility, giving every model the same search interface without duplicating + * query-building logic. + * + * Usage + * ----- + * ```js + * import { applySearchable } from "../mixins/Searchable.js"; + * + * const bookSchema = new mongoose.Schema({ ... }); + * bookSchema.index({ title: "text", description: "text" }); + * applySearchable(bookSchema); + * + * const Book = mongoose.model("Book", bookSchema); + * const results = await Book.search({ term: "react", page: 1, limit: 10 }); + * ``` + * + * Conventions for anything added under `/mongo`: + * - Repositories never call `res`/express — they return data or throw. + * - Every exported function/class carries complete JSDoc. + */ + +import { textSearch, buildTextFilter, buildTextProjection, buildTextSort } from "../utils/textSearch.js"; + +/** + * @typedef {import("../utils/textSearch.js").TextSearchOptions} SearchOptions + */ + +/** + * @typedef {import("../utils/textSearch.js").TextSearchResult} SearchResult + */ + +/** + * Apply the Searchable mixin to a Mongoose schema. + * + * After calling this function, the schema's model will have a `.search()` + * static method and a `._buildSearchQuery()` helper method available. + * + * @param {import("mongoose").Schema} schema The Mongoose schema to enhance. + * @param {Object} [config={}] Optional configuration. + * @param {string[]} [config.defaultFields] Fields to project by default + * when no explicit projection is provided. If omitted, all fields are + * returned (no projection applied). + * @param {Object} [config.defaultFilters] Default filters always applied + * (e.g. `{ isActive: true }`). Merged with caller-supplied filters. + * @returns {void} + * @throws {TypeError} If `schema` is not a Mongoose schema instance. + */ +export function applySearchable(schema, config = {}) { + if (!schema || typeof schema.static !== "function") { + throw new TypeError("applySearchable: schema must be a Mongoose Schema instance"); + } + + const { defaultFields, defaultFilters } = config; + + /** + * Execute a full-text search against this model. + * + * This is the primary search interface for any model that has the + * Searchable mixin applied. It delegates to the shared + * {@link module:mongo/utils/textSearch.textSearch} utility. + * + * @param {SearchOptions & { term: string }} options Search parameters. + * @returns {Promise} The search results. + * @example + * const { documents, total, page, pages } = await Book.search({ + * term: "react patterns", + * filters: { price: { $gte: 0 } }, + * page: 1, + * limit: 10, + * }); + */ + schema.static("search", async function searchable(options = {}) { + const { filters: callerFilters, projection, ...rest } = options; + + // Merge default filters with caller-supplied filters + const mergedFilters = { ...defaultFilters, ...callerFilters }; + + // Use default fields if no explicit projection is provided + const effectiveProjection = + projection !== undefined ? projection : defaultFields ? Object.fromEntries(defaultFields.map((f) => [f, 1])) : {}; + + return textSearch({ + model: this, + filters: mergedFilters, + projection: effectiveProjection, + ...rest, + }); + }); + + /** + * Build a text-search filter without executing the query. + * + * Useful for composing search filters into larger queries or for testing. + * + * @param {string} term The search string. + * @param {Object} [filters] Additional filter criteria. + * @returns {Object} A MongoDB filter object. + */ + schema.static("_buildSearchFilter", function _buildSearchFilter(term, filters = {}) { + const merged = { ...defaultFilters, ...filters }; + return buildTextFilter(term, merged); + }); + + /** + * Build a text-search projection without executing the query. + * + * @param {Object} [extraProjection] Additional fields to include. + * @returns {Object} A Mongoose projection object. + */ + schema.static("_buildSearchProjection", function _buildSearchProjection(extraProjection = {}) { + return buildTextProjection( + defaultFields + ? { ...Object.fromEntries(defaultFields.map((f) => [f, 1])), ...extraProjection } + : extraProjection + ); + }); + + /** + * Build a text-search sort specification without executing the query. + * + * @param {Object} [customSort] Caller-supplied sort override. + * @returns {Object} A Mongoose sort specification. + */ + schema.static("_buildSearchSort", function _buildSearchSort(customSort) { + return buildTextSort(customSort); + }); +} + +/** + * Default export mirrors the named export for callers that prefer a namespace + * import: `import searchable from "../mixins/Searchable.js"`. + */ +export default { applySearchable }; diff --git a/mongo/mixins/__tests__/Searchable.test.js b/mongo/mixins/__tests__/Searchable.test.js new file mode 100644 index 00000000..7360889f --- /dev/null +++ b/mongo/mixins/__tests__/Searchable.test.js @@ -0,0 +1,286 @@ +import mongoose from "mongoose"; +import { MongoMemoryServer } from "mongodb-memory-server"; +import { applySearchable } from "../Searchable.js"; + +let mongoServer; + +// Two different schemas to prove the mixin is genuinely reusable +const bookSchema = new mongoose.Schema({ + title: { type: String, required: true }, + description: { type: String, required: true }, + category: String, + price: { type: Number, default: 0 }, + isActive: { type: Boolean, default: true }, +}); + +bookSchema.index({ title: "text", description: "text", category: "text" }, { weights: { title: 5 } }); + +const userSchema = new mongoose.Schema({ + name: { type: String, required: true }, + bio: { type: String }, + interests: [String], + role: { type: String, default: "student" }, +}); + +userSchema.index({ name: "text", bio: "text", interests: "text" }, { default_language: "none" }); + +let BookModel; +let UserModel; + +beforeAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + + // Apply mixin before compiling models + applySearchable(bookSchema, { + defaultFields: ["title", "description", "category", "price"], + defaultFilters: { isActive: true }, + }); + + applySearchable(userSchema, { + defaultFields: ["name", "bio", "interests"], + }); + + BookModel = mongoose.model("TestBook", bookSchema); + UserModel = mongoose.model("TestUser", userSchema); + + await BookModel.syncIndexes(); + await UserModel.syncIndexes(); +}, 60000); + +afterAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (mongoServer) { + await mongoServer.stop(); + } +}); + +beforeEach(async () => { + await BookModel.deleteMany({}); + await UserModel.deleteMany({}); + + await BookModel.create([ + { title: "React Fundamentals", description: "Learn React from scratch", category: "Programming", price: 100 }, + { title: "Advanced React Patterns", description: "Deep dive into React design patterns", category: "Programming", price: 150 }, + { title: "Node.js Basics", description: "Introduction to Node.js and Express", category: "Programming", price: 80 }, + { title: "Cooking 101", description: "Learn how to cook basic meals", category: "Cooking", price: 0 }, + { title: "Advanced Cooking Techniques", description: "Master advanced culinary skills", category: "Cooking", price: 50 }, + // Inactive book — should be filtered by default + { title: "Inactive React Book", description: "This book is inactive", category: "Programming", price: 30, isActive: false }, + ]); + + await UserModel.create([ + { name: "John Doe", bio: "React expert and tutor", interests: ["React", "JavaScript"], role: "mentor" }, + { name: "Jane Smith", bio: "Cooking master", interests: ["Cooking"], role: "mentor" }, + { name: "Bob Student", bio: "Learning React", interests: ["React"], role: "student" }, + ]); +}); + +describe("applySearchable", () => { + it("throws when schema is not a Mongoose schema", () => { + expect(() => applySearchable({})).toThrow(TypeError); + expect(() => applySearchable(null)).toThrow(TypeError); + }); + + it("adds .search() static method to schema", () => { + expect(typeof BookModel.search).toBe("function"); + expect(typeof UserModel.search).toBe("function"); + }); + + it("adds helper static methods to schema", () => { + expect(typeof BookModel._buildSearchFilter).toBe("function"); + expect(typeof BookModel._buildSearchProjection).toBe("function"); + expect(typeof BookModel._buildSearchSort).toBe("function"); + }); +}); + +describe("Model.search()", () => { + it("returns matching results with score", async () => { + const results = await BookModel.search({ term: "react" }); + + expect(results.documents.length).toBe(2); // Inactive book filtered out + expect(results.total).toBe(2); + expect(results.page).toBe(1); + + results.documents.forEach((doc) => { + expect(doc.score).toBeDefined(); + expect(typeof doc.score).toBe("number"); + }); + }); + + it("applies default filters", async () => { + // Without mixin, inactive book would appear + const results = await BookModel.search({ term: "react" }); + const titles = results.documents.map((d) => d.title); + expect(titles).not.toContain("Inactive React Book"); + }); + + it("combines default filters with caller filters", async () => { + const results = await BookModel.search({ + term: "react", + filters: { price: { $gte: 150 } }, + }); + + expect(results.documents.length).toBe(1); + expect(results.documents[0].title).toBe("Advanced React Patterns"); + }); + + it("supports pagination", async () => { + const results = await BookModel.search({ term: "react", page: 1, limit: 1 }); + expect(results.documents.length).toBe(1); + expect(results.limit).toBe(1); + expect(results.total).toBe(2); + expect(results.pages).toBe(2); + }); + + it("returns default fields when configured", async () => { + const results = await BookModel.search({ term: "react" }); + const doc = results.documents[0]; + + // Default fields should be included + expect(doc.title).toBeDefined(); + expect(doc.description).toBeDefined(); + expect(doc.category).toBeDefined(); + expect(doc.price).toBeDefined(); + expect(doc.score).toBeDefined(); + + // Fields not in defaultFields should not be included (unless _id) + expect(doc.isActive).toBeUndefined(); + }); + + it("overrides default fields with explicit projection", async () => { + const results = await BookModel.search({ + term: "react", + projection: { title: 1, price: 1 }, + }); + + const doc = results.documents[0]; + expect(doc.title).toBeDefined(); + expect(doc.price).toBeDefined(); + expect(doc.score).toBeDefined(); + expect(doc.description).toBeUndefined(); // Not in explicit projection + }); + + it("overrides default filters with empty filters", async () => { + // When caller passes empty filters, default filters still apply + const results = await BookModel.search({ term: "react", filters: {} }); + const titles = results.documents.map((d) => d.title); + expect(titles).not.toContain("Inactive React Book"); + }); + + it("works with models without default filters", async () => { + const results = await UserModel.search({ term: "react" }); + expect(results.documents.length).toBe(2); // John Doe and Bob Student + const names = results.documents.map((d) => d.name); + expect(names).toContain("John Doe"); + expect(names).toContain("Bob Student"); + }); + + it("returns default fields for models without defaultFields config", async () => { + const results = await UserModel.search({ term: "react" }); + const doc = results.documents[0]; + + // All fields should be present when no defaultFields configured + expect(doc.name).toBeDefined(); + expect(doc.bio).toBeDefined(); + expect(doc.interests).toBeDefined(); + expect(doc.score).toBeDefined(); + }); + + it("returns empty results for non-matching term", async () => { + const results = await BookModel.search({ term: "xyznonexistent" }); + expect(results.documents).toEqual([]); + expect(results.total).toBe(0); + expect(results.pages).toBe(0); + }); + + it("searches across multiple fields", async () => { + // Search by category (should match "Programming" in category field) + const results = await BookModel.search({ term: "programming" }); + expect(results.documents.length).toBe(3); // React Fundamentals, Advanced React, Node.js + }); + + it("supports custom sort", async () => { + const results = await BookModel.search({ + term: "react", + sort: { price: 1 }, + }); + + expect(results.documents.length).toBe(2); + expect(results.documents[0].price).toBeLessThanOrEqual(results.documents[1].price); + }); +}); + +describe("Model._buildSearchFilter()", () => { + it("builds a text filter with term", () => { + const filter = UserModel._buildSearchFilter("react"); + expect(filter).toEqual({ $text: { $search: "react" } }); + }); + + it("includes default filters", () => { + const filter = BookModel._buildSearchFilter("react", { price: { $gte: 100 } }); + expect(filter).toEqual({ + $and: [ + { $text: { $search: "react" } }, + { isActive: true, price: { $gte: 100 } }, + ], + }); + }); + + it("works for models without default filters", () => { + const filter = UserModel._buildSearchFilter("react"); + expect(filter).toEqual({ $text: { $search: "react" } }); + }); +}); + +describe("Model._buildSearchProjection()", () => { + it("includes default fields and score", () => { + const projection = BookModel._buildSearchProjection(); + expect(projection).toEqual({ + title: 1, + description: 1, + category: 1, + price: 1, + score: { $meta: "textScore" }, + }); + }); + + it("merges with extra projection", () => { + const projection = BookModel._buildSearchProjection({ rating: 1 }); + expect(projection).toEqual({ + title: 1, + description: 1, + category: 1, + price: 1, + rating: 1, + score: { $meta: "textScore" }, + }); + }); + + it("returns only score when no defaultFields", () => { + const projection = UserModel._buildSearchProjection(); + expect(projection).toEqual({ + name: 1, + bio: 1, + interests: 1, + score: { $meta: "textScore" }, + }); + }); +}); + +describe("Model._buildSearchSort()", () => { + it("returns default relevance sort", () => { + const sort = BookModel._buildSearchSort(); + expect(sort).toEqual({ score: { $meta: "textScore" } }); + }); + + it("returns custom sort when provided", () => { + const sort = BookModel._buildSearchSort({ price: -1 }); + expect(sort).toEqual({ price: -1 }); + }); +}); diff --git a/mongo/monitoring/__tests__/poolMetrics.test.js b/mongo/monitoring/__tests__/poolMetrics.test.js new file mode 100644 index 00000000..a34f3fda --- /dev/null +++ b/mongo/monitoring/__tests__/poolMetrics.test.js @@ -0,0 +1,119 @@ +import { EventEmitter } from "node:events"; +import { PoolMetricsCollector } from "../poolMetrics.js"; + +/** + * Builds a fake Mongoose connection whose `getClient()` returns an + * EventEmitter standing in for the MongoDB driver's MongoClient. This lets us + * drive CMAP pool events synthetically, with no live database. + */ +function makeFakeConnection({ readyState = 1, maxPoolSize = 10, minPoolSize = 5 } = {}) { + const client = new EventEmitter(); + client.options = { maxPoolSize, minPoolSize }; + + const connection = new EventEmitter(); + connection.readyState = readyState; + connection.getClient = () => client; + + return { connection, client }; +} + +describe("PoolMetricsCollector", () => { + test("importing/constructing has no side effects and renders zeroed metrics", () => { + const collector = new PoolMetricsCollector(); + expect(collector.isAttached()).toBe(false); + + const out = collector.render(); + // Prometheus format sanity: HELP/TYPE headers and a labelled sample line. + expect(out).toMatch(/# HELP mongodb_pool_connections_open /); + expect(out).toMatch(/# TYPE mongodb_pool_connections_open gauge/); + expect(out).toMatch(/mongodb_pool_connections_open\{pool="mongodb"\} 0/); + expect(out.endsWith("\n")).toBe(true); + }); + + test("attach is idempotent and returns false without a client", () => { + const collector = new PoolMetricsCollector(); + expect(collector.attach(null)).toBe(false); + expect(collector.attach({})).toBe(false); // no getClient() + + const { connection, client } = makeFakeConnection(); + expect(collector.attach(connection)).toBe(true); + expect(collector.isAttached()).toBe(true); + + // Re-attaching to the same client should not double-register listeners. + const before = client.listenerCount("connectionCreated"); + expect(collector.attach(connection)).toBe(true); + expect(client.listenerCount("connectionCreated")).toBe(before); + }); + + test("counters and derived gauges track synthetic CMAP events", () => { + const collector = new PoolMetricsCollector(); + const { connection, client } = makeFakeConnection({ readyState: 1 }); + collector.attach(connection); + + // Simulate a pool coming up with three connections. + client.emit("connectionPoolCreated"); + client.emit("connectionPoolReady"); + for (let i = 0; i < 3; i += 1) { + client.emit("connectionCreated"); + client.emit("connectionReady"); + } + + // Two operations borrow connections; one is returned. + client.emit("connectionCheckOutStarted"); + client.emit("connectionCheckedOut"); + client.emit("connectionCheckOutStarted"); + client.emit("connectionCheckedOut"); + client.emit("connectionCheckedIn"); + + // One checkout fails (counts as an error too). + client.emit("connectionCheckOutStarted"); + client.emit("connectionCheckOutFailed"); + + // One connection closed; one connection-level error. + client.emit("connectionClosed"); + connection.emit("error", new Error("socket reset")); + + const snap = collector.snapshot(); + expect(snap.open).toBe(2); // 3 created − 1 closed + expect(snap.inUse).toBe(1); // 2 checked out − 1 checked in + expect(snap.available).toBe(1); // 2 open − 1 in use + expect(snap.pending).toBe(0); // 3 started − 2 out − 1 failed + expect(snap.maxPoolSize).toBe(10); + expect(snap.minPoolSize).toBe(5); + expect(snap.readyState).toBe(1); + expect(snap.readyStateName).toBe("connected"); + + expect(collector.counters.connectionsCreated).toBe(3); + expect(collector.counters.connectionsClosed).toBe(1); + expect(collector.counters.checkOutsFailed).toBe(1); + // checkout failure + connection error → 2 total errors. + expect(collector.counters.connectionErrors).toBe(2); + }); + + test("render emits Prometheus lines reflecting the collected state", () => { + const collector = new PoolMetricsCollector(); + const { connection, client } = makeFakeConnection({ readyState: 1 }); + collector.attach(connection); + + client.emit("connectionCreated"); + client.emit("connectionCreated"); + client.emit("connectionCheckOutStarted"); + client.emit("connectionCheckedOut"); + + const out = collector.render(); + + expect(out).toContain('mongodb_pool_connections_created_total{pool="mongodb"} 2'); + expect(out).toContain('mongodb_pool_connections_open{pool="mongodb"} 2'); + expect(out).toContain('mongodb_pool_connections_in_use{pool="mongodb"} 1'); + expect(out).toContain('mongodb_pool_max_size{pool="mongodb"} 10'); + expect(out).toContain( + 'mongodb_connection_ready_state{pool="mongodb",state="connected"} 1' + ); + + // Every metric must carry both a HELP and a TYPE header. + const helpCount = (out.match(/# HELP /g) || []).length; + const typeCount = (out.match(/# TYPE /g) || []).length; + expect(helpCount).toBe(typeCount); + expect(helpCount).toBeGreaterThanOrEqual(15); + }); +}); diff --git a/mongo/monitoring/poolMetrics.js b/mongo/monitoring/poolMetrics.js new file mode 100644 index 00000000..78a148f4 --- /dev/null +++ b/mongo/monitoring/poolMetrics.js @@ -0,0 +1,356 @@ +/** + * @module mongo/monitoring/poolMetrics + * MongoDB connection-pool metrics collector. + * ------------------------------------------------------------------------- + * Subscribes to the MongoDB driver's Connection Monitoring & Pooling (CMAP) + * events — emitted on the underlying `MongoClient` — and maintains a small set + * of counters and gauges describing the health of the Mongoose connection + * pool. A `render()` method serialises the current state as Prometheus text + * exposition format (v0.0.4) so it can be scraped without any external + * metrics library. + * + * Design constraints + * ------------------ + * - **No import-time side effects.** Importing this module only constructs a + * singleton with zeroed counters; it never touches the network or requires + * a live database. Listeners are attached only when `attach(connection)` is + * called at runtime (see `src/config/db.js`, after `mongoose.connect`). + * - **Degrades gracefully.** If the client is not yet connected, `attach` + * is a no-op that returns `false`; `render()` still emits valid (zeroed) + * metrics so the scrape endpoint never fails. + * - **Zero dependencies.** The Prometheus text format is hand-rolled using + * only stdlib; the collector needs nothing beyond `mongoose` (already a + * project dependency) for reading `readyState`. + * + * CMAP events consumed (per the MongoDB driver specification): + * - `connectionPoolCreated` — a pool was created for a server + * - `connectionPoolReady` — a pool finished initialising + * - `connectionPoolCleared` — a pool was cleared (e.g. on error) + * - `connectionPoolClosed` — a pool was torn down + * - `connectionCreated` — a physical connection was opened + * - `connectionReady` — a connection finished its handshake + * - `connectionClosed` — a connection was closed + * - `connectionCheckOutStarted` — a checkout (borrow) request began + * - `connectionCheckOutFailed` — a checkout request failed (pool error) + * - `connectionCheckedOut` — a connection was borrowed by an operation + * - `connectionCheckedIn` — a connection was returned to the pool + * + * From these the collector derives the "in-use / available / pending" gauges + * that operators care about, plus totals for created/closed/errors. + */ + +import mongoose from "mongoose"; + +const READY_STATE_NAMES = { + 0: "disconnected", + 1: "connected", + 2: "connecting", + 3: "disconnecting", + 99: "uninitialized", +}; + +/** + * Collector for MongoDB connection-pool metrics. + * + * A single shared instance is exported as the module default. The class is + * exported too so tests can construct isolated instances and feed synthetic + * pool events without a live database. + */ +export class PoolMetricsCollector { + constructor() { + /** @type {import("mongoose").Connection | null} */ + this._connection = null; + /** @type {import("mongodb").MongoClient | null} */ + this._client = null; + this._attached = false; + + // Cumulative counters — monotonically increasing. + this.counters = { + poolsCreated: 0, + poolsReady: 0, + poolsCleared: 0, + poolsClosed: 0, + connectionsCreated: 0, + connectionsReady: 0, + connectionsClosed: 0, + checkOutsStarted: 0, + checkOutsFailed: 0, + checkedOut: 0, + checkedIn: 0, + connectionErrors: 0, + }; + } + + /** @returns {boolean} whether pool-event listeners are currently attached */ + isAttached() { + return this._attached; + } + + /** + * Attach CMAP event listeners to a Mongoose connection's underlying client. + * Safe to call repeatedly and safe to call before a connection exists — in + * either case it will not throw. + * + * @param {import("mongoose").Connection} connection A Mongoose connection. + * @returns {boolean} true when listeners are (or were already) attached. + */ + attach(connection) { + if (!connection) return false; + + let client; + try { + client = typeof connection.getClient === "function" ? connection.getClient() : null; + } catch { + // getClient() throws when the connection has never been established. + client = null; + } + if (!client || typeof client.on !== "function") return false; + + // Already wired to this exact client — nothing to do. + if (this._attached && this._client === client) { + this._connection = connection; + return true; + } + + this._connection = connection; + this._client = client; + + client.on("connectionPoolCreated", () => { + this.counters.poolsCreated += 1; + }); + client.on("connectionPoolReady", () => { + this.counters.poolsReady += 1; + }); + client.on("connectionPoolCleared", () => { + this.counters.poolsCleared += 1; + }); + client.on("connectionPoolClosed", () => { + this.counters.poolsClosed += 1; + }); + client.on("connectionCreated", () => { + this.counters.connectionsCreated += 1; + }); + client.on("connectionReady", () => { + this.counters.connectionsReady += 1; + }); + client.on("connectionClosed", () => { + this.counters.connectionsClosed += 1; + }); + client.on("connectionCheckOutStarted", () => { + this.counters.checkOutsStarted += 1; + }); + client.on("connectionCheckOutFailed", () => { + this.counters.checkOutsFailed += 1; + this.counters.connectionErrors += 1; + }); + client.on("connectionCheckedOut", () => { + this.counters.checkedOut += 1; + }); + client.on("connectionCheckedIn", () => { + this.counters.checkedIn += 1; + }); + + // Connection-level errors (auth failure, socket errors, etc.). + if (typeof connection.on === "function") { + connection.on("error", () => { + this.counters.connectionErrors += 1; + }); + } + + this._attached = true; + return true; + } + + /** + * Compute the live pool state derived from the accumulated counters plus the + * current connection `readyState` and configured pool sizes. + * + * @returns {{ + * readyState: number, + * readyStateName: string, + * maxPoolSize: number|null, + * minPoolSize: number|null, + * open: number, + * inUse: number, + * available: number, + * pending: number + * }} + */ + snapshot() { + const c = this.counters; + + // Currently open (physical) connections = created − closed. + const open = Math.max(0, c.connectionsCreated - c.connectionsClosed); + // In-use connections = checked out − checked in. + const inUse = Math.max(0, c.checkedOut - c.checkedIn); + // Available = open connections not currently borrowed. + const available = Math.max(0, open - inUse); + // Pending = checkout requests started but not yet satisfied or failed. + // Approximates the wait-queue depth. + const pending = Math.max( + 0, + c.checkOutsStarted - c.checkedOut - c.checkOutsFailed + ); + + const connection = this._connection || mongoose.connection; + const readyState = + connection && typeof connection.readyState === "number" + ? connection.readyState + : 0; + + let maxPoolSize = null; + let minPoolSize = null; + const options = this._client && this._client.options; + if (options) { + if (typeof options.maxPoolSize === "number") maxPoolSize = options.maxPoolSize; + if (typeof options.minPoolSize === "number") minPoolSize = options.minPoolSize; + } + + return { + readyState, + readyStateName: READY_STATE_NAMES[readyState] || "unknown", + maxPoolSize, + minPoolSize, + open, + inUse, + available, + pending, + }; + } + + /** + * Serialise the current metrics as Prometheus text exposition format. + * Each metric carries a constant `{pool="mongodb"}` label so the output is a + * valid `metric_name{labels} value` series. + * + * @returns {string} Prometheus-formatted metrics text (newline-terminated). + */ + render() { + const s = this.snapshot(); + const c = this.counters; + const L = 'pool="mongodb"'; + const lines = []; + + const metric = (name, type, help, samples) => { + lines.push(`# HELP ${name} ${help}`); + lines.push(`# TYPE ${name} ${type}`); + for (const [labels, value] of samples) { + lines.push(`${name}{${labels}} ${value}`); + } + }; + + metric( + "mongodb_pool_connections_open", + "gauge", + "Current number of open connections in the MongoDB pool.", + [[L, s.open]] + ); + metric( + "mongodb_pool_connections_in_use", + "gauge", + "Connections currently checked out (in use) from the pool.", + [[L, s.inUse]] + ); + metric( + "mongodb_pool_connections_available", + "gauge", + "Open connections currently available (idle) in the pool.", + [[L, s.available]] + ); + metric( + "mongodb_pool_wait_queue_size", + "gauge", + "Pending checkout requests waiting for an available connection.", + [[L, s.pending]] + ); + metric( + "mongodb_pool_max_size", + "gauge", + "Configured maximum pool size (maxPoolSize).", + [[L, s.maxPoolSize == null ? 0 : s.maxPoolSize]] + ); + metric( + "mongodb_pool_min_size", + "gauge", + "Configured minimum pool size (minPoolSize).", + [[L, s.minPoolSize == null ? 0 : s.minPoolSize]] + ); + metric( + "mongodb_connection_ready_state", + "gauge", + "Mongoose connection readyState (0=disconnected,1=connected,2=connecting,3=disconnecting).", + [[`${L},state="${s.readyStateName}"`, s.readyState]] + ); + + metric( + "mongodb_pool_connections_created_total", + "counter", + "Total physical connections created since process start.", + [[L, c.connectionsCreated]] + ); + metric( + "mongodb_pool_connections_ready_total", + "counter", + "Total connections that completed their handshake and became ready.", + [[L, c.connectionsReady]] + ); + metric( + "mongodb_pool_connections_closed_total", + "counter", + "Total physical connections closed since process start.", + [[L, c.connectionsClosed]] + ); + metric( + "mongodb_pool_checkouts_started_total", + "counter", + "Total connection checkout (borrow) attempts started.", + [[L, c.checkOutsStarted]] + ); + metric( + "mongodb_pool_checkouts_total", + "counter", + "Total successful connection checkouts.", + [[L, c.checkedOut]] + ); + metric( + "mongodb_pool_checkins_total", + "counter", + "Total connections returned (checked in) to the pool.", + [[L, c.checkedIn]] + ); + metric( + "mongodb_pool_checkout_failures_total", + "counter", + "Total connection checkout attempts that failed.", + [[L, c.checkOutsFailed]] + ); + metric( + "mongodb_pool_errors_total", + "counter", + "Total connection-pool and connection errors observed.", + [[L, c.connectionErrors]] + ); + metric( + "mongodb_pool_pools_created_total", + "counter", + "Total connection pools created (one per server/topology member).", + [[L, c.poolsCreated]] + ); + metric( + "mongodb_pool_pools_cleared_total", + "counter", + "Total times a connection pool was cleared.", + [[L, c.poolsCleared]] + ); + + return lines.join("\n") + "\n"; + } +} + +/** + * Shared singleton used by the metrics route and the DB bootstrap. + * @type {PoolMetricsCollector} + */ +const poolMetrics = new PoolMetricsCollector(); + +export default poolMetrics; diff --git a/mongo/repositories/BookRepository.js b/mongo/repositories/BookRepository.js new file mode 100644 index 00000000..451207ec --- /dev/null +++ b/mongo/repositories/BookRepository.js @@ -0,0 +1,456 @@ +/** + * @module mongo/repositories/BookRepository + * Data-access layer for the {@link Book} model. + * ------------------------------------------------------------------------- + * `BookRepository` concentrates every Book-specific persistence query in one + * place so route handlers and services stop talking to the Mongoose model + * directly. It follows the conventions declared in `mongo/index.js`: + * + * - Repositories never touch `res`/express — they return data or throw. + * - Every exported class/method carries complete JSDoc. + * + * Self-containment + * ---------------- + * The shared `BaseRepository` (Deen-Bridge/dnb-backend#168) is not available + * yet, so this repository is intentionally standalone: it imports the `Book` + * model directly and implements its helpers on top of it. The class is shaped + * so that, once #168 lands, it can `extend BaseRepository` with minimal churn + * (a `this.model` handle, thin query helpers, typed throwing). + * + * Field notes (derived from the real schemas, nothing invented) + * ------------------------------------------------------------- + * - Availability: the Book schema has no dedicated `available`/`inStock` + * flag, so availability is derived from a book having downloadable content + * (`fileUrl` present), with optional price constraints layered on top. + * - Tags: the Book schema has no `tags` array; its taxonomy fields are the + * free-text `category` string and the `categoryRef` reference. Tag-style + * filtering therefore matches against `category`. + * - Downloads/reads: the schema tracks consumption via the `readCount` + * counter (there is no separate `downloadCount`), so read/download + * tracking increments `readCount`. + * - Purchases: a completed purchase is recorded in the `Transaction` + * collection (`itemType: "book"`, `status: "confirmed"`) and mirrored on + * `User.purchasedBooks`; the Book document itself holds no buyer list. + * + * @example + * import BookRepository from "../mongo/repositories/BookRepository.js"; + * + * const books = await BookRepository.findAvailable({ freeOnly: true, limit: 20 }); + */ + +import Book from "../../src/models/Book.js"; +import Transaction from "../../src/models/Transaction.js"; + +/** + * @typedef {Object} QueryOptions + * @property {number} [limit] Maximum number of documents to return. + * @property {number} [skip] Number of documents to skip (offset). + * @property {number} [page] 1-based page number; combined with + * `limit` to compute `skip` when `skip` + * is not supplied explicitly. + * @property {Object|string} [sort] Mongoose sort specification. + * @property {string|string[]|Object|Object[]} [populate] + * Path(s) to populate. + * @property {string|Object} [select] Projection / field selection. + * @property {boolean} [lean=false] Return plain objects instead of + * hydrated Mongoose documents. + */ + +/** + * Repository exposing Book-specific query helpers on top of the Mongoose + * `Book` model. + * + * Instances are cheap and stateless; a shared default instance is exported so + * callers can `import BookRepository from ".../BookRepository.js"` and use it + * immediately, while still allowing `new BookRepository(model)` for tests that + * need to inject a mock model. + */ +export class BookRepository { + /** + * @param {import("mongoose").Model} [model=Book] The Mongoose model this + * repository operates on. Defaults to the real `Book` model; accepting it + * as a parameter keeps the class testable and mirrors the shape a future + * `BaseRepository` subclass will take. + */ + constructor(model = Book) { + /** + * The Mongoose model backing this repository. + * @type {import("mongoose").Model} + */ + this.model = model; + } + + /** + * Apply shared {@link QueryOptions} (sort, pagination, projection, + * population, lean) to an existing Mongoose query. + * + * @private + * @param {import("mongoose").Query} query The query to decorate. + * @param {QueryOptions} [options={}] Options to apply. + * @returns {import("mongoose").Query} The same query, decorated. + */ + _applyOptions(query, options = {}) { + const { limit, skip, page, sort, populate, select, lean } = options; + + if (sort) query.sort(sort); + if (select) query.select(select); + + let effectiveSkip = skip; + if (effectiveSkip == null && page != null && limit != null) { + effectiveSkip = (Math.max(1, page) - 1) * limit; + } + if (effectiveSkip != null) query.skip(effectiveSkip); + if (limit != null) query.limit(limit); + + if (populate) { + const paths = Array.isArray(populate) ? populate : [populate]; + for (const path of paths) query.populate(path); + } + + if (lean) query.lean(); + + return query; + } + + /** + * Fetch a single book by its identifier. + * + * @param {import("mongoose").Types.ObjectId|string} id Book id. + * @param {QueryOptions} [options={}] Query options. + * @returns {Promise} The book, or `null` if not found. + * @throws {Error} If `id` is missing. + */ + async findById(id, options = {}) { + if (!id) throw new Error("BookRepository.findById: `id` is required"); + return this._applyOptions(this.model.findById(id), options).exec(); + } + + /** + * Find all books written by a given author. + * + * @param {import("mongoose").Types.ObjectId|string} authorId The author's + * User id (matched against `Book.author`). + * @param {QueryOptions} [options={}] Query options. + * @returns {Promise} Matching books (newest first by default). + * @throws {Error} If `authorId` is missing. + */ + async findByAuthor(authorId, options = {}) { + if (!authorId) { + throw new Error("BookRepository.findByAuthor: `authorId` is required"); + } + const query = this.model.find({ author: authorId }); + return this._applyOptions(query, { sort: { createdAt: -1 }, ...options }).exec(); + } + + /** + * Find "available" books. + * + * The Book schema has no explicit availability flag, so a book is considered + * available when it has downloadable content (`fileUrl` present). Optional + * price constraints narrow the result further. + * + * @param {QueryOptions & { + * freeOnly?: boolean, + * maxPrice?: number, + * minPrice?: number, + * }} [options={}] + * `freeOnly` restricts to books priced at 0; `maxPrice`/`minPrice` bound + * the `price` field. Remaining keys are treated as {@link QueryOptions}. + * @returns {Promise} Available books. + */ + async findAvailable(options = {}) { + const { freeOnly, maxPrice, minPrice, ...queryOptions } = options; + + const filter = { fileUrl: { $exists: true, $nin: [null, ""] } }; + + if (freeOnly) { + filter.price = 0; + } else if (maxPrice != null || minPrice != null) { + filter.price = {}; + if (minPrice != null) filter.price.$gte = minPrice; + if (maxPrice != null) filter.price.$lte = maxPrice; + } + + const query = this.model.find(filter); + return this._applyOptions(query, { sort: { createdAt: -1 }, ...queryOptions }).exec(); + } + + /** + * Search books by free text. + * + * By default this uses the schema's compound text index + * (`title`, `description`, `category`) via `$text` and sorts by relevance. + * Set `useRegex` to fall back to a case-insensitive regex across the same + * fields (useful for partial-token / prefix matching that `$text` cannot do). + * + * @param {string} term The search term. + * @param {QueryOptions & { useRegex?: boolean }} [options={}] + * `useRegex` switches from `$text` to regex matching. + * @returns {Promise} Matching books. + * @throws {Error} If `term` is empty. + */ + async searchBooks(term, options = {}) { + if (!term || !String(term).trim()) { + throw new Error("BookRepository.searchBooks: `term` is required"); + } + const { useRegex, ...queryOptions } = options; + const trimmed = String(term).trim(); + + if (useRegex) { + const rx = new RegExp(this._escapeRegex(trimmed), "i"); + const query = this.model.find({ + $or: [{ title: rx }, { description: rx }, { category: rx }], + }); + return this._applyOptions(query, queryOptions).exec(); + } + + const query = this.model.find( + { $text: { $search: trimmed } }, + { score: { $meta: "textScore" } } + ); + // Default to relevance ordering unless the caller overrides `sort`. + const merged = { sort: { score: { $meta: "textScore" } }, ...queryOptions }; + return this._applyOptions(query, merged).exec(); + } + + /** + * Escape user-supplied text for safe use inside a `RegExp`. + * + * @private + * @param {string} value Raw input. + * @returns {string} Regex-safe string. + */ + _escapeRegex(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + } + + /** + * Find books in a category. + * + * Accepts either the free-text `category` string (matched case-insensitively) + * or a `categoryRef` ObjectId. When `value` looks like a 24-char hex id it is + * matched against `categoryRef`; otherwise it is matched against `category`. + * + * @param {string|import("mongoose").Types.ObjectId} value Category name or + * category reference id. + * @param {QueryOptions} [options={}] Query options. + * @returns {Promise} Matching books. + * @throws {Error} If `value` is missing. + */ + async findByCategory(value, options = {}) { + if (!value) { + throw new Error("BookRepository.findByCategory: `value` is required"); + } + const str = String(value); + const filter = /^[a-fA-F0-9]{24}$/.test(str) + ? { categoryRef: value } + : { category: new RegExp(`^${this._escapeRegex(str)}$`, "i") }; + + return this._applyOptions(this.model.find(filter), options).exec(); + } + + /** + * Filter books by tags. + * + * The Book schema has no dedicated `tags` array; its closest taxonomy field + * is `category`. This method therefore matches books whose `category` is one + * of the supplied tags (case-insensitive), letting callers filter by a set + * of topical labels. + * + * @param {string|string[]} tags One or more tag/category labels. + * @param {QueryOptions} [options={}] Query options. + * @returns {Promise} Matching books. + * @throws {Error} If no tags are supplied. + */ + async findByTags(tags, options = {}) { + const list = (Array.isArray(tags) ? tags : [tags]).filter( + (t) => t != null && String(t).trim() !== "" + ); + if (list.length === 0) { + throw new Error("BookRepository.findByTags: at least one tag is required"); + } + const patterns = list.map( + (t) => new RegExp(`^${this._escapeRegex(String(t).trim())}$`, "i") + ); + const query = this.model.find({ category: { $in: patterns } }); + return this._applyOptions(query, options).exec(); + } + + /** + * Flexible multi-criterion filter combining the common Book dimensions: + * author, category/categoryRef, tags (→ `category`), price range and a + * minimum rating. Any omitted criterion is simply not applied. + * + * @param {Object} [criteria={}] + * @param {import("mongoose").Types.ObjectId|string} [criteria.author] + * @param {string|import("mongoose").Types.ObjectId} [criteria.category] + * Free-text category name (or a `categoryRef` id when 24-char hex). + * @param {string[]} [criteria.tags] Tag labels matched against `category`. + * @param {number} [criteria.minPrice] + * @param {number} [criteria.maxPrice] + * @param {number} [criteria.minRating] Minimum `rating` (0–5). + * @param {QueryOptions} [options={}] Query options. + * @returns {Promise} Matching books. + */ + async filter(criteria = {}, options = {}) { + const { author, category, tags, minPrice, maxPrice, minRating } = criteria; + const filter = {}; + + if (author) filter.author = author; + + if (category) { + const str = String(category); + if (/^[a-fA-F0-9]{24}$/.test(str)) { + filter.categoryRef = category; + } else { + filter.category = new RegExp(`^${this._escapeRegex(str)}$`, "i"); + } + } + + if (tags != null) { + const list = (Array.isArray(tags) ? tags : [tags]).filter( + (t) => t != null && String(t).trim() !== "" + ); + if (list.length > 0) { + filter.category = { + $in: list.map( + (t) => new RegExp(`^${this._escapeRegex(String(t).trim())}$`, "i") + ), + }; + } + } + + if (minPrice != null || maxPrice != null) { + filter.price = {}; + if (minPrice != null) filter.price.$gte = minPrice; + if (maxPrice != null) filter.price.$lte = maxPrice; + } + + if (minRating != null) filter.rating = { $gte: minRating }; + + return this._applyOptions(this.model.find(filter), options).exec(); + } + + /** + * Find the books a user has purchased. + * + * Purchases are recorded in the `Transaction` collection, not on the Book + * document, so this resolves the buyer's confirmed book transactions and + * returns the corresponding Book documents. + * + * @param {import("mongoose").Types.ObjectId|string} userId The buyer's id. + * @param {QueryOptions & { includePending?: boolean }} [options={}] + * By default only `confirmed` purchases count; set `includePending` to also + * include in-flight (`pending`/`submitted`/`retrying`) transactions. + * @returns {Promise} The purchased books (may be fewer than the + * number of transactions if books were since deleted). + * @throws {Error} If `userId` is missing. + */ + async findPurchasedByUser(userId, options = {}) { + if (!userId) { + throw new Error("BookRepository.findPurchasedByUser: `userId` is required"); + } + const { includePending, ...queryOptions } = options; + + const statusFilter = includePending + ? { $in: ["confirmed", "submitted", "retrying", "pending"] } + : "confirmed"; + + const txns = await Transaction.find({ + buyer: userId, + itemType: "book", + itemTypeModel: "Book", + status: statusFilter, + }) + .select("itemId") + .lean() + .exec(); + + const bookIds = [...new Set(txns.map((t) => String(t.itemId)))]; + if (bookIds.length === 0) return []; + + return this.findByPurchaseHistory(bookIds, queryOptions); + } + + /** + * Resolve a set of purchased book ids to Book documents. + * + * Complements {@link findPurchasedByUser}: given the `bookId`s from a user's + * `purchasedBooks` history (or any id list), return the live Book documents. + * + * @param {Array} bookIds + * @param {QueryOptions} [options={}] Query options. + * @returns {Promise} Matching books. + * @throws {Error} If `bookIds` is not an array. + */ + async findByPurchaseHistory(bookIds, options = {}) { + if (!Array.isArray(bookIds)) { + throw new Error( + "BookRepository.findByPurchaseHistory: `bookIds` must be an array" + ); + } + if (bookIds.length === 0) return []; + const query = this.model.find({ _id: { $in: bookIds } }); + return this._applyOptions(query, options).exec(); + } + + /** + * Atomically increment a book's read/download counter. + * + * The Book schema tracks consumption via `readCount` (there is no separate + * `downloadCount`), so each read or download bumps `readCount`. + * + * @param {import("mongoose").Types.ObjectId|string} bookId Book id. + * @param {number} [amount=1] Amount to increment by (may be negative to + * correct an over-count). + * @returns {Promise} The updated book, or `null` if not found. + * @throws {Error} If `bookId` is missing or `amount` is not a number. + */ + async incrementReadCount(bookId, amount = 1) { + if (!bookId) { + throw new Error("BookRepository.incrementReadCount: `bookId` is required"); + } + if (typeof amount !== "number" || Number.isNaN(amount)) { + throw new Error( + "BookRepository.incrementReadCount: `amount` must be a number" + ); + } + return this.model + .findByIdAndUpdate( + bookId, + { $inc: { readCount: amount }, $set: { updatedAt: new Date() } }, + { new: true } + ) + .exec(); + } + + /** + * Return the most-read (most-downloaded) books, ordered by `readCount`. + * + * @param {QueryOptions} [options={}] Query options (`limit` recommended). + * @returns {Promise} Books ordered by descending `readCount`. + */ + async findMostRead(options = {}) { + const query = this.model.find({}); + return this._applyOptions(query, { sort: { readCount: -1 }, ...options }).exec(); + } + + /** + * Count books matching an arbitrary filter. + * + * @param {Object} [filter={}] A Mongoose filter object. + * @returns {Promise} The matching document count. + */ + async count(filter = {}) { + return this.model.countDocuments(filter).exec(); + } +} + +/** + * Default shared instance bound to the real `Book` model, mirroring the + * ergonomics the other `/mongo` exports aim for. + * @type {BookRepository} + */ +const bookRepository = new BookRepository(); + +export default bookRepository; diff --git a/mongo/repositories/EducatorBalanceRepository.js b/mongo/repositories/EducatorBalanceRepository.js new file mode 100644 index 00000000..927e78e9 --- /dev/null +++ b/mongo/repositories/EducatorBalanceRepository.js @@ -0,0 +1,526 @@ +/** + * @module mongo/repositories/EducatorBalanceRepository + * Data-access layer for the {@link EducatorBalance} model. + * ------------------------------------------------------------------------- + * `EducatorBalanceRepository` extends `BaseRepository` to inherit generic CRUD, + * pagination, and typed error handling, then adds educator-balance-specific + * helpers: balance queries, atomic balance mutations, transaction history, + * and reconciliation. + * + * Field notes (derived from the real EducatorBalance schema): + * - `owedStroops`: String — earnings from `platform_collect` settlements, + * awaiting payout. This is the withdrawable balance. + * - `settledStroops`: String — earnings from `direct` settlements, already + * in the educator's Stellar wallet. + * - `lastPayoutAt`: Date — when the most recent payout was processed. + * + * Transaction history lives in the separate `LedgerEntry` collection (not + * embedded on EducatorBalance). Reconciliation sums LedgerEntry records and + * compares against the stored balance fields. + * + * All balance mutations use MongoDB atomic operators (`$inc`, `$expr` with + * aggregation pipeline updates) to prevent lost updates under concurrency. + * + * @example + * import EducatorBalanceRepository from "../mongo/repositories/EducatorBalanceRepository.js"; + * + * const balance = await EducatorBalanceRepository.findByEducator(educatorId); + * const available = await EducatorBalanceRepository.getAvailableBalance(educatorId); + * const deduction = await EducatorBalanceRepository.deductOwedBalance(educatorId, amountStroops); + */ + +import BaseRepository, { + RepositoryValidationError, +} from "../base/BaseRepository.js"; +import EducatorBalance from "../../src/models/EducatorBalance.js"; +import LedgerEntry from "../../src/models/LedgerEntry.js"; + +/** + * Repository exposing EducatorBalance-specific helpers on top of + * {@link BaseRepository}. + * + * Instances are cheap and stateless; a shared default instance is exported so + * callers can `import EducatorBalanceRepository from ".../EducatorBalanceRepository.js"` + * and use it immediately, while still allowing `new EducatorBalanceRepository(model)` + * for tests that need to inject a mock model. + */ +export class EducatorBalanceRepository extends BaseRepository { + /** + * @param {import("mongoose").Model} [model=EducatorBalance] The Mongoose model + * this repository operates on. Defaults to the real `EducatorBalance` model; + * accepting it as a parameter keeps the class testable. + */ + constructor(model = EducatorBalance) { + super(model); + } + + /* ---------------------------------------------------------------------- */ + /* Queries */ + /* ---------------------------------------------------------------------- */ + + /** + * Fetch the balance record for a specific educator. + * + * @param {import("mongoose").Types.ObjectId|string} educatorId Educator user id. + * @param {object} [options={}] Query options. + * @param {boolean} [options.lean=true] Return a plain JS object. + * @param {(Array|object|string)} [options.populate] Paths to populate. + * @param {import("mongoose").ClientSession} [options.session] Transaction session. + * @returns {Promise} The balance document, or `null` if none exists. + * @throws {RepositoryValidationError} If `educatorId` is missing. + */ + async findByEducator(educatorId, options = {}) { + if (!educatorId) { + throw new RepositoryValidationError( + "EducatorBalanceRepository.findByEducator: `educatorId` is required" + ); + } + + const { lean = true, populate, session, ...rest } = options; + + return this.findOne( + { educator: educatorId }, + { lean, populate, session, ...rest } + ); + } + + /** + * Return the withdrawable/available balance for an educator. + * + * "Available" means the amount the platform currently owes the educator + * and that can be disbursed via a payout batch. This corresponds to the + * `owedStroops` field on the EducatorBalance document. + * + * @param {import("mongoose").Types.ObjectId|string} educatorId Educator user id. + * @param {object} [options={}] + * @param {import("mongoose").ClientSession} [options.session] Transaction session. + * @returns {Promise<{stroops: string, amount: string}>} Available balance in + * stroops (BigInt string) and USDC decimal string. + * @throws {RepositoryValidationError} If `educatorId` is missing. + */ + async getAvailableBalance(educatorId, options = {}) { + if (!educatorId) { + throw new RepositoryValidationError( + "EducatorBalanceRepository.getAvailableBalance: `educatorId` is required" + ); + } + + const { session } = options; + const balance = await this.findByEducator(educatorId, { session }); + const stroops = BigInt(balance?.owedStroops || "0"); + + return { + stroops: stroops.toString(), + amount: this._stroopsToAmount(stroops), + }; + } + + /** + * Return the amount currently pending payout (owed but not yet disbursed). + * + * In the current schema, `owedStroops` represents earnings that have been + * credited from `platform_collect` sales but have not yet been paid out + * via a payout batch. This is the "pending" amount. + * + * @param {import("mongoose").Types.ObjectId|string} educatorId Educator user id. + * @param {object} [options={}] + * @param {import("mongoose").ClientSession} [options.session] Transaction session. + * @returns {Promise<{stroops: string, amount: string}>} Pending amount in + * stroops (BigInt string) and USDC decimal string. + * @throws {RepositoryValidationError} If `educatorId` is missing. + */ + async getPendingAmount(educatorId, options = {}) { + if (!educatorId) { + throw new RepositoryValidationError( + "EducatorBalanceRepository.getPendingAmount: `educatorId` is required" + ); + } + + const { session } = options; + const balance = await this.findByEducator(educatorId, { session }); + const stroops = BigInt(balance?.owedStroops || "0"); + + return { + stroops: stroops.toString(), + amount: this._stroopsToAmount(stroops), + }; + } + + /* ---------------------------------------------------------------------- */ + /* Transaction History */ + /* ---------------------------------------------------------------------- */ + + /** + * Fetch the educator's ledger/transaction history with pagination. + * + * Transaction history lives in the separate `LedgerEntry` collection. + * Results are scoped to the educator and sorted newest-first by default. + * + * @param {import("mongoose").Types.ObjectId|string} educatorId Educator user id. + * @param {object} [options={}] + * @param {number} [options.page=1] 1-based page number. + * @param {number} [options.limit=20] Page size (clamped to 100). + * @param {string} [options.sortBy="createdAt"] Field to sort by. + * @param {("asc"|"desc")} [options.order="desc"] Sort direction. + * @param {string} [options.type] Filter by entry type ("sale" or "payout"). + * @param {Date|string} [options.from] Start date (inclusive). + * @param {Date|string} [options.to] End date (inclusive). + * @param {import("mongoose").ClientSession} [options.session] Transaction session. + * @returns {Promise<{data: object[], total: number, page: number, limit: number, totalPages: number, hasNextPage: boolean, hasPrevPage: boolean}>} + * @throws {RepositoryValidationError} If `educatorId` is missing. + */ + async getTransactionHistory(educatorId, options = {}) { + if (!educatorId) { + throw new RepositoryValidationError( + "EducatorBalanceRepository.getTransactionHistory: `educatorId` is required" + ); + } + + const { + page = 1, + limit = 20, + sortBy = "createdAt", + order = "desc", + type, + from, + to, + session, + } = options; + + const filter = { educator: educatorId }; + + if (type) { + if (!["sale", "payout"].includes(type)) { + throw new RepositoryValidationError( + "EducatorBalanceRepository.getTransactionHistory: `type` must be 'sale' or 'payout'" + ); + } + filter.type = type; + } + + if (from || to) { + filter.createdAt = {}; + if (from) filter.createdAt.$gte = new Date(from); + if (to) filter.createdAt.$lte = new Date(to); + } + + const direction = String(order).toLowerCase() === "asc" ? 1 : -1; + const effectiveLimit = Math.min(Math.max(1, parseInt(limit, 10) || 20), 100); + const effectivePage = Math.max(1, parseInt(page, 10) || 1); + const skip = (effectivePage - 1) * effectiveLimit; + + const [data, total] = await Promise.all([ + LedgerEntry.find(filter) + .sort({ [sortBy]: direction }) + .skip(skip) + .limit(effectiveLimit) + .lean(true) + .session(session ?? null), + LedgerEntry.countDocuments(filter).session(session ?? null), + ]); + + const totalPages = Math.ceil(total / effectiveLimit); + + return { + data, + total, + page: effectivePage, + limit: effectiveLimit, + totalPages, + hasNextPage: effectivePage < totalPages, + hasPrevPage: effectivePage > 1, + }; + } + + /* ---------------------------------------------------------------------- */ + /* Reconciliation */ + /* ---------------------------------------------------------------------- */ + + /** + * Verify that the stored EducatorBalance is consistent with its underlying + * LedgerEntry transaction history. + * + * Reconciliation logic: + * - `sale` + `platform_collect` → add `amountStroops` to computed owed + * - `sale` + `direct` → add `amountStroops` to computed settled + * - `payout` → subtract `amountStroops` from computed owed, add to computed settled + * + * @param {import("mongoose").Types.ObjectId|string} educatorId Educator user id. + * @param {object} [options={}] + * @param {import("mongoose").ClientSession} [options.session] Transaction session. + * @returns {Promise<{isConsistent: boolean, storedOwed: string, computedOwed: string, storedSettled: string, computedSettled: string, discrepancies: string[]}>} + * @throws {RepositoryValidationError} If `educatorId` is missing. + */ + async reconcileBalance(educatorId, options = {}) { + if (!educatorId) { + throw new RepositoryValidationError( + "EducatorBalanceRepository.reconcileBalance: `educatorId` is required" + ); + } + + const { session } = options; + + const [balance, entries] = await Promise.all([ + this.findByEducator(educatorId, { session }), + LedgerEntry.find({ educator: educatorId }) + .sort({ createdAt: 1 }) + .lean(true) + .session(session ?? null), + ]); + + let computedOwed = 0n; + let computedSettled = 0n; + + for (const entry of entries) { + const amt = BigInt(entry.amountStroops || "0"); + + if (entry.type === "sale") { + if (entry.settlement === "platform_collect") { + computedOwed += amt; + } else { + computedSettled += amt; + } + } else if (entry.type === "payout") { + computedOwed -= amt; + computedSettled += amt; + } + } + + const storedOwed = BigInt(balance?.owedStroops || "0"); + const storedSettled = BigInt(balance?.settledStroops || "0"); + + const discrepancies = []; + if (storedOwed !== computedOwed) { + discrepancies.push( + `owedStroops mismatch: stored=${storedOwed.toString()}, computed=${computedOwed.toString()}` + ); + } + if (storedSettled !== computedSettled) { + discrepancies.push( + `settledStroops mismatch: stored=${storedSettled.toString()}, computed=${computedSettled.toString()}` + ); + } + + return { + isConsistent: discrepancies.length === 0, + storedOwed: storedOwed.toString(), + computedOwed: computedOwed.toString(), + storedSettled: storedSettled.toString(), + computedSettled: computedSettled.toString(), + discrepancies, + }; + } + + /* ---------------------------------------------------------------------- */ + /* Atomic Balance Mutations */ + /* ---------------------------------------------------------------------- */ + + /** + * Atomically deduct from an educator's owed balance. + * + * Uses a MongoDB aggregation pipeline update with an `$expr` filter so the + * balance check and deduction happen in a single atomic operation. If the + * available balance is insufficient, the update matches nothing and returns + * `null` — preventing any race condition from pushing the balance negative. + * + * @param {import("mongoose").Types.ObjectId|string} educatorId Educator user id. + * @param {string|bigint} amountStroops Amount to deduct (in stroops). + * @param {object} [options={}] + * @param {import("mongoose").ClientSession} [options.session] Transaction session. + * @returns {Promise} The updated balance document, or `null` if + * the educator has insufficient owed balance. + * @throws {RepositoryValidationError} If `educatorId` or `amountStroops` is missing/invalid. + */ + async deductOwedBalance(educatorId, amountStroops, options = {}) { + if (!educatorId) { + throw new RepositoryValidationError( + "EducatorBalanceRepository.deductOwedBalance: `educatorId` is required" + ); + } + + const amount = BigInt(amountStroops); + if (amount <= 0n) { + throw new RepositoryValidationError( + "EducatorBalanceRepository.deductOwedBalance: `amountStroops` must be a positive integer" + ); + } + + const { session } = options; + const amountNum = Number(amount); + + const result = await this.model.findOneAndUpdate( + { + educator: educatorId, + $expr: { $gte: [{ $toLong: "$owedStroops" }, amountNum] }, + }, + [ + { + $set: { + owedStroops: { + $toString: { + $subtract: [{ $toLong: "$owedStroops" }, amountNum], + }, + }, + lastPayoutAt: new Date(), + }, + }, + ], + { new: true, session: session ?? null } + ); + + return result; + } + + /** + * Atomically credit an educator's owed balance. + * + * Uses an aggregation pipeline update with `$add` on `$toLong` to atomically + * increment the string-stored stroops value. Creates the balance record if + * it does not exist (upsert). + * + * @param {import("mongoose").Types.ObjectId|string} educatorId Educator user id. + * @param {string|bigint} amountStroops Amount to credit (in stroops). + * @param {object} [options={}] + * @param {import("mongoose").ClientSession} [options.session] Transaction session. + * @returns {Promise} The updated (or created) balance document. + * @throws {RepositoryValidationError} If `educatorId` or `amountStroops` is missing/invalid. + */ + async creditOwedBalance(educatorId, amountStroops, options = {}) { + if (!educatorId) { + throw new RepositoryValidationError( + "EducatorBalanceRepository.creditOwedBalance: `educatorId` is required" + ); + } + + const amount = BigInt(amountStroops); + if (amount <= 0n) { + throw new RepositoryValidationError( + "EducatorBalanceRepository.creditOwedBalance: `amountStroops` must be a positive integer" + ); + } + + const { session } = options; + const amountNum = Number(amount); + + const result = await this.model.findOneAndUpdate( + { educator: educatorId }, + [ + { + $set: { + owedStroops: { + $toString: { + $add: [{ $toLong: { $ifNull: ["$owedStroops", "0"] } }, amountNum], + }, + }, + }, + }, + ], + { upsert: true, new: true, session: session ?? null } + ); + + return result; + } + + /** + * Atomically move an amount from owed to settled balance. + * + * The filter checks that `owedStroops >= amount` using `$expr`, and the + * aggregation pipeline update subtracts from owed and adds to settled — all + * in a single atomic MongoDB operation. + * + * @param {import("mongoose").Types.ObjectId|string} educatorId Educator user id. + * @param {string|bigint} amountStroops Amount to move (in stroops). + * @param {object} [options={}] + * @param {import("mongoose").ClientSession} [options.session] Transaction session. + * @returns {Promise} The updated balance document, or `null` if + * the educator has insufficient owed balance. + * @throws {RepositoryValidationError} If `educatorId` or `amountStroops` is missing/invalid. + */ + async settleOwedToSettled(educatorId, amountStroops, options = {}) { + if (!educatorId) { + throw new RepositoryValidationError( + "EducatorBalanceRepository.settleOwedToSettled: `educatorId` is required" + ); + } + + const amount = BigInt(amountStroops); + if (amount <= 0n) { + throw new RepositoryValidationError( + "EducatorBalanceRepository.settleOwedToSettled: `amountStroops` must be a positive integer" + ); + } + + const { session } = options; + const amountNum = Number(amount); + + const result = await this.model.findOneAndUpdate( + { + educator: educatorId, + $expr: { $gte: [{ $toLong: "$owedStroops" }, amountNum] }, + }, + [ + { + $set: { + owedStroops: { + $toString: { + $subtract: [{ $toLong: "$owedStroops" }, amountNum], + }, + }, + settledStroops: { + $toString: { + $add: [ + { $toLong: { $ifNull: ["$settledStroops", "0"] } }, + amountNum, + ], + }, + }, + lastPayoutAt: new Date(), + }, + }, + ], + { new: true, session: session ?? null } + ); + + return result; + } + + /* ---------------------------------------------------------------------- */ + /* Helpers */ + /* ---------------------------------------------------------------------- */ + + /** + * Convert a stroops (BigInt) amount to a USDC decimal string. + * 1 USDC = 100,000,000 stroops (8 decimal places). + * + * @private + * @param {bigint} stroops + * @returns {string} USDC decimal string. + */ + _stroopsToAmount(stroops) { + const str = stroops.toString(); + if (str === "0") return "0"; + + const negative = str.startsWith("-"); + const abs = negative ? str.slice(1) : str; + + if (abs.length <= 8) { + const padded = abs.padStart(8, "0"); + const result = `0.${padded}`.replace(/0+$/, "").replace(/\.$/, ".0"); + return negative ? `-${result}` : result; + } + + const intPart = abs.slice(0, abs.length - 8); + const decPart = abs.slice(abs.length - 8).replace(/0+$/, ""); + const result = decPart ? `${intPart}.${decPart}` : intPart; + return negative ? `-${result}` : result; + } +} + +/** + * Default shared instance bound to the real `EducatorBalance` model, mirroring + * the ergonomics the other `/mongo` exports aim for. + * @type {EducatorBalanceRepository} + */ +const educatorBalanceRepository = new EducatorBalanceRepository(); + +export default educatorBalanceRepository; diff --git a/mongo/repositories/NotificationRepository.js b/mongo/repositories/NotificationRepository.js new file mode 100644 index 00000000..82764d64 --- /dev/null +++ b/mongo/repositories/NotificationRepository.js @@ -0,0 +1,419 @@ +/** + * @module mongo/repositories/NotificationRepository + * Data-access layer for the {@link Notification} model. + * ------------------------------------------------------------------------- + * `NotificationRepository` extends `BaseRepository` to inherit generic CRUD, + * offset/cursor pagination, and typed error handling, then adds + * notification-specific query helpers: user scoping, unread filtering, + * mark-as-read mutations, bulk operations, and type/priority filtering. + * + * @example + * import NotificationRepository from "../mongo/repositories/NotificationRepository.js"; + * + * const unread = await NotificationRepository.findUnread(userId, { limit: 25 }); + * await NotificationRepository.markAsRead(notificationId); + * await NotificationRepository.markAllAsRead(userId); + */ + +import BaseRepository, { RepositoryValidationError } from "../base/BaseRepository.js"; +import Notification from "../../src/models/Notification.js"; + +/** + * Allowed values for {@link NotificationRepository#type | type} field. + * Mirrors the `type` enum defined on the Notification schema. + * @type {ReadonlyArray} + */ +export const NOTIFICATION_TYPES = Object.freeze([ + "follow", + "unfollow", + "new_course", + "new_book", + "course_like", + "book_like", + "course_comment", + "book_comment", + "system", + "welcome", + "recommendation", + "pledge_due", +]); + +/** + * Allowed values for the `priority` field. + * Mirrors the `priority` enum defined on the Notification schema. + * @type {ReadonlyArray} + */ +export const NOTIFICATION_PRIORITIES = Object.freeze([ + "low", + "medium", + "high", + "urgent", +]); + +/** + * Repository exposing Notification-specific query helpers on top of + * {@link BaseRepository}. + * + * Instances are cheap and stateless; a shared default instance is exported so + * callers can `import NotificationRepository from ".../NotificationRepository.js"` + * and use it immediately, while still allowing `new NotificationRepository(model)` + * for tests that need to inject a mock model. + */ +export class NotificationRepository extends BaseRepository { + /** + * @param {import("mongoose").Model} [model=Notification] The Mongoose model + * this repository operates on. Defaults to the real `Notification` model; + * accepting it as a parameter keeps the class testable. + */ + constructor(model = Notification) { + super(model); + } + + /* ---------------------------------------------------------------------- */ + /* Queries */ + /* ---------------------------------------------------------------------- */ + + /** + * Fetch notifications belonging to a specific user (as recipient). + * + * Deleted notifications (`isDeleted: true`) are excluded by default. + * + * @param {import("mongoose").Types.ObjectId|string} userId Recipient user id. + * @param {object} [options={}] Query options forwarded to + * {@link BaseRepository#paginate} or {@link BaseRepository#findMany}. + * @param {boolean} [options.paginate=false] When `true`, returns offset-paginated + * results via {@link BaseRepository#paginate}. Otherwise returns a plain array. + * @param {object} [options.filter] Additional filter criteria merged into the + * base `{ recipient, isDeleted: false }` query (e.g. `{ type: "follow" }`). + * @returns {Promise} Matching + * notifications, newest first by default. + * @throws {RepositoryValidationError} If `userId` is missing. + */ + async findByUser(userId, options = {}) { + if (!userId) { + throw new RepositoryValidationError( + "NotificationRepository.findByUser: `userId` is required" + ); + } + + const { paginate: usePaginate, filter: extraFilter, ...rest } = options; + const filter = { + recipient: userId, + isDeleted: false, + ...extraFilter, + }; + + const defaults = { sort: { createdAt: -1 }, ...rest }; + + if (usePaginate) { + return this.paginate(filter, defaults); + } + + return this.findMany(filter, defaults); + } + + /** + * Fetch unread notifications for a user. + * + * Uses the `isRead: false` field from the Notification schema. + * + * @param {import("mongoose").Types.ObjectId|string} userId Recipient user id. + * @param {object} [options={}] Query options (same shape as + * {@link NotificationRepository#findByUser}). + * @returns {Promise} Unread + * notifications, newest first by default. + * @throws {RepositoryValidationError} If `userId` is missing. + */ + async findUnread(userId, options = {}) { + if (!userId) { + throw new RepositoryValidationError( + "NotificationRepository.findUnread: `userId` is required" + ); + } + + return this.findByUser(userId, { + ...options, + filter: { isRead: false, ...options.filter }, + }); + } + + /** + * Query notifications filtered by type. + * + * @param {string} type Notification type (must be a valid enum value). + * @param {object} [options={}] Query options. Supports an optional `userId` + * to scope results to a single recipient. + * @returns {Promise} Matching notifications. + * @throws {RepositoryValidationError} If `type` is missing or invalid. + */ + async findByType(type, options = {}) { + if (!type) { + throw new RepositoryValidationError( + "NotificationRepository.findByType: `type` is required" + ); + } + + if (!NOTIFICATION_TYPES.includes(type)) { + throw new RepositoryValidationError( + `NotificationRepository.findByType: invalid type "${type}". Must be one of: ${NOTIFICATION_TYPES.join(", ")}` + ); + } + + const { userId, ...rest } = options; + + if (userId) { + return this.findByUser(userId, { + ...rest, + filter: { type, ...rest.filter }, + }); + } + + const filter = { type, isDeleted: false, ...rest.filter }; + return this.findMany(filter, { sort: { createdAt: -1 }, ...rest }); + } + + /** + * Query notifications filtered by priority. + * + * @param {string} priority Notification priority (must be a valid enum value). + * @param {object} [options={}] Query options. Supports an optional `userId` + * to scope results to a single recipient. + * @returns {Promise} Matching notifications. + * @throws {RepositoryValidationError} If `priority` is missing or invalid. + */ + async findByPriority(priority, options = {}) { + if (!priority) { + throw new RepositoryValidationError( + "NotificationRepository.findByPriority: `priority` is required" + ); + } + + if (!NOTIFICATION_PRIORITIES.includes(priority)) { + throw new RepositoryValidationError( + `NotificationRepository.findByPriority: invalid priority "${priority}". Must be one of: ${NOTIFICATION_PRIORITIES.join(", ")}` + ); + } + + const { userId, ...rest } = options; + + if (userId) { + return this.findByUser(userId, { + ...rest, + filter: { priority, ...rest.filter }, + }); + } + + const filter = { priority, isDeleted: false, ...rest.filter }; + return this.findMany(filter, { sort: { createdAt: -1 }, ...rest }); + } + + /* ---------------------------------------------------------------------- */ + /* Mutations */ + /* ---------------------------------------------------------------------- */ + + /** + * Mark a single notification as read. + * + * @param {import("mongoose").Types.ObjectId|string} notificationId + * Notification id. + * @param {object} [options={}] + * @param {import("mongoose").ClientSession} [options.session] Transaction + * session. + * @returns {Promise} The updated notification, or `null` if not + * found. + * @throws {RepositoryValidationError} If `notificationId` is missing. + */ + async markAsRead(notificationId, options = {}) { + if (!notificationId) { + throw new RepositoryValidationError( + "NotificationRepository.markAsRead: `notificationId` is required" + ); + } + + return this.update(notificationId, { isRead: true }, options); + } + + /** + * Mark all unread notifications for a user as read. + * + * Uses `updateMany` under the hood so the operation is a single atomic write. + * + * @param {import("mongoose").Types.ObjectId|string} userId Recipient user id. + * @param {object} [options={}] + * @param {import("mongoose").ClientSession} [options.session] Transaction + * session. + * @returns {Promise<{acknowledged: boolean, modifiedCount: number}>} Outcome + * summary with the number of notifications flipped. + * @throws {RepositoryValidationError} If `userId` is missing. + */ + async markAllAsRead(userId, options = {}) { + if (!userId) { + throw new RepositoryValidationError( + "NotificationRepository.markAllAsRead: `userId` is required" + ); + } + + const { session } = options; + + const result = await this.model + .updateMany( + { recipient: userId, isRead: false, isDeleted: false }, + { $set: { isRead: true } }, + { session } + ) + .exec(); + + return { acknowledged: result.acknowledged, modifiedCount: result.modifiedCount }; + } + + /** + * Mark multiple specific notifications as read by their ids. + * + * Only notifications belonging to the given user are updated (ownership guard). + * + * @param {import("mongoose").Types.ObjectId|string} userId Owner of the + * notifications (ownership check). + * @param {Array} notificationIds + * Notification ids to mark as read. + * @param {object} [options={}] + * @param {import("mongoose").ClientSession} [options.session] Transaction + * session. + * @returns {Promise<{acknowledged: boolean, modifiedCount: number}>} Outcome + * summary. + * @throws {RepositoryValidationError} If `userId` or `notificationIds` is + * missing/empty. + */ + async markManyAsRead(userId, notificationIds, options = {}) { + if (!userId) { + throw new RepositoryValidationError( + "NotificationRepository.markManyAsRead: `userId` is required" + ); + } + + if (!Array.isArray(notificationIds) || notificationIds.length === 0) { + throw new RepositoryValidationError( + "NotificationRepository.markManyAsRead: `notificationIds` must be a non-empty array" + ); + } + + const { session } = options; + + const result = await this.model + .updateMany( + { + _id: { $in: notificationIds }, + recipient: userId, + isRead: false, + isDeleted: false, + }, + { $set: { isRead: true } }, + { session } + ) + .exec(); + + return { acknowledged: result.acknowledged, modifiedCount: result.modifiedCount }; + } + + /* ---------------------------------------------------------------------- */ + /* Cleanup / bulk operations */ + /* ---------------------------------------------------------------------- */ + + /** + * Permanently delete notifications older than a given date. + * + * This is a hard delete (not soft) and is intended for TTL-style cleanup of + * stale notifications. Note: the Notification schema does not declare a + * MongoDB TTL index, so cleanup is performed manually via this method or + * an external scheduler. + * + * @param {Date|string} olderThan Cutoff date — notifications with + * `createdAt` before this date are deleted. + * @param {object} [options={}] + * @param {object} [options.filter] Extra filter criteria merged in (e.g. + * `{ recipient: someUserId }` to scope the cleanup). + * @param {import("mongoose").ClientSession} [options.session] Transaction + * session. + * @returns {Promise<{acknowledged: boolean, deletedCount: number}>} Outcome + * summary. + * @throws {RepositoryValidationError} If `olderThan` is missing. + */ + async deleteOlderThan(olderThan, options = {}) { + if (!olderThan) { + throw new RepositoryValidationError( + "NotificationRepository.deleteOlderThan: `olderThan` date is required" + ); + } + + const { filter: extraFilter, session } = options; + const cutoff = new Date(olderThan); + + if (Number.isNaN(cutoff.getTime())) { + throw new RepositoryValidationError( + "NotificationRepository.deleteOlderThan: `olderThan` must be a valid date" + ); + } + + const filter = { + createdAt: { $lt: cutoff }, + ...extraFilter, + }; + + const result = await this.model.deleteMany(filter, { session }).exec(); + + return { acknowledged: result.acknowledged, deletedCount: result.deletedCount }; + } + + /** + * Soft-delete notifications older than a given date. + * + * Flags `isDeleted: true` instead of removing documents, which is appropriate + * when the model supports soft deletes and you want a recoverable archival + * strategy. + * + * @param {Date|string} olderThan Cutoff date. + * @param {object} [options={}] + * @param {object} [options.filter] Extra filter criteria merged in. + * @param {import("mongoose").ClientSession} [options.session] Transaction + * session. + * @returns {Promise<{acknowledged: boolean, modifiedCount: number}>} Outcome + * summary. + * @throws {RepositoryValidationError} If `olderThan` is missing. + */ + async softDeleteOlderThan(olderThan, options = {}) { + if (!olderThan) { + throw new RepositoryValidationError( + "NotificationRepository.softDeleteOlderThan: `olderThan` date is required" + ); + } + + const { filter: extraFilter, session } = options; + const cutoff = new Date(olderThan); + + if (Number.isNaN(cutoff.getTime())) { + throw new RepositoryValidationError( + "NotificationRepository.softDeleteOlderThan: `olderThan` must be a valid date" + ); + } + + const filter = { + createdAt: { $lt: cutoff }, + isDeleted: false, + ...extraFilter, + }; + + const result = await this.model + .updateMany(filter, { $set: { isDeleted: true } }, { session }) + .exec(); + + return { acknowledged: result.acknowledged, modifiedCount: result.modifiedCount }; + } +} + +/** + * Default shared instance bound to the real `Notification` model, mirroring + * the ergonomics the other `/mongo` exports aim for. + * @type {NotificationRepository} + */ +const notificationRepository = new NotificationRepository(); + +export default notificationRepository; diff --git a/mongo/repositories/ReelRepository.js b/mongo/repositories/ReelRepository.js new file mode 100644 index 00000000..27fc0526 --- /dev/null +++ b/mongo/repositories/ReelRepository.js @@ -0,0 +1,613 @@ +/** + * @module mongo/repositories/ReelRepository + * Data-access layer for the {@link Reel} model. + * ------------------------------------------------------------------------- + * `ReelRepository` extends `BaseRepository` to inherit generic CRUD, + * offset/cursor pagination, and typed error handling, then adds + * reel-specific query helpers: creator scoping, trending queries, + * engagement tracking, and filtering/sorting options. + * + * Field notes (derived from the real Reel schema): + * - Creator: `createdBy` (ObjectId ref to User) + * - Engagement: `likes` (array of User refs), `loves` (array of User refs), + * `comments` (array of comment subdocs), `shareCount` (Number), + * `viewCount` (Number) + * - Space: The Reel schema does not currently have a `spaceId` field. + * `findBySpace` is implemented as a placeholder for future schema changes. + * + * @example + * import ReelRepository from "../mongo/repositories/ReelRepository.js"; + * + * const creatorReels = await ReelRepository.findByCreator(userId, { limit: 10 }); + * const trending = await ReelRepository.findTrending({ limit: 20 }); + * await ReelRepository.incrementViewCount(reelId); + */ + +import BaseRepository, { + RepositoryValidationError, +} from "../base/BaseRepository.js"; +import Reel from "../../src/models/Reel.js"; + +/** + * Repository exposing Reel-specific query helpers on top of + * {@link BaseRepository}. + * + * Instances are cheap and stateless; a shared default instance is exported so + * callers can `import ReelRepository from ".../ReelRepository.js"` and use it + * immediately, while still allowing `new ReelRepository(model)` for tests that + * need to inject a mock model. + */ +export class ReelRepository extends BaseRepository { + /** + * @param {import("mongoose").Model} [model=Reel] The Mongoose model + * this repository operates on. Defaults to the real `Reel` model; + * accepting it as a parameter keeps the class testable. + */ + constructor(model = Reel) { + super(model); + } + + /* ---------------------------------------------------------------------- */ + /* Queries */ + /* ---------------------------------------------------------------------- */ + + /** + * Fetch reels created by a specific user. + * + * @param {import("mongoose").Types.ObjectId|string} creatorId Creator user id. + * @param {object} [options={}] Query options forwarded to + * {@link BaseRepository#paginate} or {@link BaseRepository#findMany}. + * @param {boolean} [options.paginate=false] When `true`, returns offset-paginated + * results via {@link BaseRepository#paginate}. Otherwise returns a plain array. + * @param {object} [options.filter] Additional filter criteria merged into the + * base `{ createdBy: creatorId }` query. + * @returns {Promise} Matching + * reels, newest first by default. + * @throws {RepositoryValidationError} If `creatorId` is missing. + */ + async findByCreator(creatorId, options = {}) { + if (!creatorId) { + throw new RepositoryValidationError( + "ReelRepository.findByCreator: `creatorId` is required" + ); + } + + const { paginate: usePaginate, filter: extraFilter, ...rest } = options; + const filter = { + createdBy: creatorId, + ...extraFilter, + }; + + const defaults = { sort: { createdAt: -1 }, ...rest }; + + if (usePaginate) { + return this.paginate(filter, defaults); + } + + return this.findMany(filter, defaults); + } + + /** + * Fetch reels belonging to a specific space. + * + * Note: The Reel schema does not currently have a `spaceId` field. + * This method is implemented as a placeholder for future schema changes. + * When a `spaceId` field is added to the Reel schema, this method will + * work without modification. + * + * @param {import("mongoose").Types.ObjectId|string} spaceId Space id. + * @param {object} [options={}] Query options (same shape as + * {@link ReelRepository#findByCreator}). + * @returns {Promise} Matching + * reels (currently returns empty array until schema is updated). + * @throws {RepositoryValidationError} If `spaceId` is missing. + */ + async findBySpace(spaceId, options = {}) { + if (!spaceId) { + throw new RepositoryValidationError( + "ReelRepository.findBySpace: `spaceId` is required" + ); + } + + const { paginate: usePaginate, filter: extraFilter, ...rest } = options; + + // Check if the model has a spaceId field + if (!this.model.schema.path("spaceId")) { + // Schema doesn't have spaceId yet - return empty results + const defaults = { sort: { createdAt: -1 }, ...rest }; + if (usePaginate) { + return { + data: [], + total: 0, + page: 1, + limit: defaults.limit || 20, + totalPages: 0, + offset: 0, + hasNextPage: false, + hasPrevPage: false, + }; + } + return []; + } + + const filter = { + spaceId: spaceId, + ...extraFilter, + }; + + const defaults = { sort: { createdAt: -1 }, ...rest }; + + if (usePaginate) { + return this.paginate(filter, defaults); + } + + return this.findMany(filter, defaults); + } + + /** + * Fetch trending reels ranked by engagement metrics. + * + * Engagement is calculated as: viewCount + (likes.length * 2) + + * (loves.length * 3) + (comments.length * 4). Reels are sorted by + * this engagement score in descending order. + * + * @param {object} [options={}] Query options. + * @param {number} [options.limit=20] Maximum number of reels to return. + * @param {number} [options.days=7] Number of days to look back for trending. + * @param {object} [options.filter] Additional filter criteria. + * @param {string|object} [options.select] Projection. + * @param {(Array|object|string)} [options.populate] Paths to populate. + * @param {boolean} [options.lean=false] Return plain objects. + * @returns {Promise} Trending reels ordered by engagement. + */ + async findTrending(options = {}) { + const { + limit = 20, + days = 7, + filter: extraFilter, + select, + populate, + lean = false, + ...rest + } = options; + + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - days); + + const filter = { + createdAt: { $gte: cutoffDate }, + ...extraFilter, + }; + + // Use aggregation to calculate engagement score and sort by it + const pipeline = [ + { $match: filter }, + { + $addFields: { + engagementScore: { + $add: [ + { $ifNull: ["$viewCount", 0] }, + { $multiply: [{ $size: { $ifNull: ["$likes", []] } }, 2] }, + { $multiply: [{ $size: { $ifNull: ["$loves", []] } }, 3] }, + { $multiply: [{ $size: { $ifNull: ["$comments", []] } }, 4] }, + ], + }, + }, + }, + { $sort: { engagementScore: -1, createdAt: -1 } }, + { $limit: limit }, + ]; + + if (select) { + pipeline.push({ $project: typeof select === "string" ? { [select]: 1 } : select }); + } + + let result = await this.model.aggregate(pipeline).exec(); + + if (lean) { + result = result.map((doc) => ({ ...doc })); + } + + if (populate) { + result = await this.model.populate(result, populate); + } + + return result; + } + + /* ---------------------------------------------------------------------- */ + /* Engagement Tracking */ + /* ---------------------------------------------------------------------- */ + + /** + * Atomically increment a reel's view count. + * + * @param {import("mongoose").Types.ObjectId|string} reelId Reel id. + * @param {number} [amount=1] Amount to increment by. + * @param {object} [options={}] + * @param {import("mongoose").ClientSession} [options.session] Transaction session. + * @returns {Promise} The updated reel, or `null` if not found. + * @throws {RepositoryValidationError} If `reelId` is missing or `amount` is not a number. + */ + async incrementViewCount(reelId, amount = 1, options = {}) { + if (!reelId) { + throw new RepositoryValidationError( + "ReelRepository.incrementViewCount: `reelId` is required" + ); + } + if (typeof amount !== "number" || Number.isNaN(amount)) { + throw new RepositoryValidationError( + "ReelRepository.incrementViewCount: `amount` must be a number" + ); + } + + const { session } = options; + + return this.model + .findByIdAndUpdate( + reelId, + { $inc: { viewCount: amount } }, + { new: true, session } + ) + .exec(); + } + + /** + * Add a user to a reel's likes array (if not already present) and + * remove them from loves (toggle behavior). + * + * Uses `$addToSet` for atomic add and `$pull` for atomic remove, + * ensuring race-safe operations. + * + * @param {import("mongoose").Types.ObjectId|string} reelId Reel id. + * @param {import("mongoose").Types.ObjectId|string} userId User id to like. + * @param {object} [options={}] + * @param {import("mongoose").ClientSession} [options.session] Transaction session. + * @returns {Promise} The updated reel, or `null` if not found. + * @throws {RepositoryValidationError} If `reelId` or `userId` is missing. + */ + async addLike(reelId, userId, options = {}) { + if (!reelId) { + throw new RepositoryValidationError( + "ReelRepository.addLike: `reelId` is required" + ); + } + if (!userId) { + throw new RepositoryValidationError( + "ReelRepository.addLike: `userId` is required" + ); + } + + const { session } = options; + + return this.model + .findByIdAndUpdate( + reelId, + { + $addToSet: { likes: userId }, + $pull: { loves: userId }, + }, + { new: true, session } + ) + .exec(); + } + + /** + * Remove a user from a reel's likes array. + * + * @param {import("mongoose").Types.ObjectId|string} reelId Reel id. + * @param {import("mongoose").Types.ObjectId|string} userId User id to remove. + * @param {object} [options={}] + * @param {import("mongoose").ClientSession} [options.session] Transaction session. + * @returns {Promise} The updated reel, or `null` if not found. + * @throws {RepositoryValidationError} If `reelId` or `userId` is missing. + */ + async removeLike(reelId, userId, options = {}) { + if (!reelId) { + throw new RepositoryValidationError( + "ReelRepository.removeLike: `reelId` is required" + ); + } + if (!userId) { + throw new RepositoryValidationError( + "ReelRepository.removeLike: `userId` is required" + ); + } + + const { session } = options; + + return this.model + .findByIdAndUpdate( + reelId, + { $pull: { likes: userId } }, + { new: true, session } + ) + .exec(); + } + + /** + * Add a user to a reel's loves array (if not already present) and + * remove them from likes (toggle behavior). + * + * @param {import("mongoose").Types.ObjectId|string} reelId Reel id. + * @param {import("mongoose").Types.ObjectId|string} userId User id to love. + * @param {object} [options={}] + * @param {import("mongoose").ClientSession} [options.session] Transaction session. + * @returns {Promise} The updated reel, or `null` if not found. + * @throws {RepositoryValidationError} If `reelId` or `userId` is missing. + */ + async addLove(reelId, userId, options = {}) { + if (!reelId) { + throw new RepositoryValidationError( + "ReelRepository.addLove: `reelId` is required" + ); + } + if (!userId) { + throw new RepositoryValidationError( + "ReelRepository.addLove: `userId` is required" + ); + } + + const { session } = options; + + return this.model + .findByIdAndUpdate( + reelId, + { + $addToSet: { loves: userId }, + $pull: { likes: userId }, + }, + { new: true, session } + ) + .exec(); + } + + /** + * Remove a user from a reel's loves array. + * + * @param {import("mongoose").Types.ObjectId|string} reelId Reel id. + * @param {import("mongoose").Types.ObjectId|string} userId User id to remove. + * @param {object} [options={}] + * @param {import("mongoose").ClientSession} [options.session] Transaction session. + * @returns {Promise} The updated reel, or `null` if not found. + * @throws {RepositoryValidationError} If `reelId` or `userId` is missing. + */ + async removeLove(reelId, userId, options = {}) { + if (!reelId) { + throw new RepositoryValidationError( + "ReelRepository.removeLove: `reelId` is required" + ); + } + if (!userId) { + throw new RepositoryValidationError( + "ReelRepository.removeLove: `userId` is required" + ); + } + + const { session } = options; + + return this.model + .findByIdAndUpdate( + reelId, + { $pull: { loves: userId } }, + { new: true, session } + ) + .exec(); + } + + /** + * Add a comment to a reel. + * + * @param {import("mongoose").Types.ObjectId|string} reelId Reel id. + * @param {object} comment Comment data (must include `user` and `text`). + * @param {import("mongoose").Types.ObjectId|string} comment.user User id of commenter. + * @param {string} comment.text Comment text. + * @param {object} [options={}] + * @param {import("mongoose").ClientSession} [options.session] Transaction session. + * @returns {Promise} The updated reel, or `null` if not found. + * @throws {RepositoryValidationError} If required fields are missing. + */ + async addComment(reelId, comment, options = {}) { + if (!reelId) { + throw new RepositoryValidationError( + "ReelRepository.addComment: `reelId` is required" + ); + } + if (!comment || !comment.user || !comment.text) { + throw new RepositoryValidationError( + "ReelRepository.addComment: `comment.user` and `comment.text` are required" + ); + } + + const { session } = options; + + const commentDoc = { + user: comment.user, + text: comment.text, + createdAt: new Date(), + }; + + return this.model + .findByIdAndUpdate( + reelId, + { $push: { comments: commentDoc } }, + { new: true, session } + ) + .exec(); + } + + /** + * Remove a comment from a reel. + * + * @param {import("mongoose").Types.ObjectId|string} reelId Reel id. + * @param {import("mongoose").Types.ObjectId|string} commentId Comment id to remove. + * @param {object} [options={}] + * @param {import("mongoose").ClientSession} [options.session] Transaction session. + * @returns {Promise} The updated reel, or `null` if not found. + * @throws {RepositoryValidationError} If `reelId` or `commentId` is missing. + */ + async removeComment(reelId, commentId, options = {}) { + if (!reelId) { + throw new RepositoryValidationError( + "ReelRepository.removeComment: `reelId` is required" + ); + } + if (!commentId) { + throw new RepositoryValidationError( + "ReelRepository.removeComment: `commentId` is required" + ); + } + + const { session } = options; + + return this.model + .findByIdAndUpdate( + reelId, + { $pull: { comments: { _id: commentId } } }, + { new: true, session } + ) + .exec(); + } + + /** + * Atomically increment a reel's share count. + * + * @param {import("mongoose").Types.ObjectId|string} reelId Reel id. + * @param {number} [amount=1] Amount to increment by. + * @param {object} [options={}] + * @param {import("mongoose").ClientSession} [options.session] Transaction session. + * @returns {Promise} The updated reel, or `null` if not found. + * @throws {RepositoryValidationError} If `reelId` is missing or `amount` is not a number. + */ + async incrementShareCount(reelId, amount = 1, options = {}) { + if (!reelId) { + throw new RepositoryValidationError( + "ReelRepository.incrementShareCount: `reelId` is required" + ); + } + if (typeof amount !== "number" || Number.isNaN(amount)) { + throw new RepositoryValidationError( + "ReelRepository.incrementShareCount: `amount` must be a number" + ); + } + + const { session } = options; + + return this.model + .findByIdAndUpdate( + reelId, + { $inc: { shareCount: amount } }, + { new: true, session } + ) + .exec(); + } + + /* ---------------------------------------------------------------------- */ + /* Filtering and Sorting */ + /* ---------------------------------------------------------------------- */ + + /** + * Flexible multi-criterion filter combining common Reel dimensions. + * + * Any omitted criterion is simply not applied. + * + * @param {object} [criteria={}] + * @param {import("mongoose").Types.ObjectId|string} [criteria.creator] Creator user id. + * @param {string} [criteria.category] Category string. + * @param {string[]} [criteria.tags] Tag labels. + * @param {string} [criteria.search] Free text search on description. + * @param {number} [criteria.minViews] Minimum view count. + * @param {number} [criteria.minLikes] Minimum like count. + * @param {number} [criteria.minComments] Minimum comment count. + * @param {object} [options={}] Query options. + * @param {string} [options.sortBy="createdAt"] Field to sort by. + * @param {("asc"|"desc")} [options.order="desc"] Sort direction. + * @param {number} [options.limit] Maximum results. + * @param {number} [options.page] Page number for pagination. + * @param {boolean} [options.paginate=false] Use pagination. + * @returns {Promise} Matching reels. + */ + async filter(criteria = {}, options = {}) { + const { + creator, + category, + tags, + search, + minViews, + minLikes, + minComments, + } = criteria; + + const { + sortBy = "createdAt", + order = "desc", + paginate: usePaginate, + ...rest + } = options; + + const filter = {}; + + if (creator) filter.createdBy = creator; + if (category) filter.category = category; + + if (tags != null) { + const list = (Array.isArray(tags) ? tags : [tags]).filter( + (t) => t != null && String(t).trim() !== "" + ); + if (list.length > 0) { + filter.tags = { $in: list }; + } + } + + if (search) { + filter.description = { $regex: search, $options: "i" }; + } + + // Note: minViews, minLikes, minComments require aggregation + // For now, we'll do a basic find and filter in memory if needed + // A production implementation would use aggregation pipeline + if (minViews != null || minLikes != null || minComments != null) { + // Use aggregation for complex filtering + const pipeline = [{ $match: filter }]; + + if (minViews != null) { + pipeline.push({ $match: { viewCount: { $gte: minViews } } }); + } + if (minLikes != null) { + pipeline.push({ + $match: { $expr: { $gte: [{ $size: { $ifNull: ["$likes", []] } }, minLikes] } }, + }); + } + if (minComments != null) { + pipeline.push({ + $match: { $expr: { $gte: [{ $size: { $ifNull: ["$comments", []] } }, minComments] } }, + }); + } + + const direction = String(order).toLowerCase() === "asc" ? 1 : -1; + pipeline.push({ $sort: { [sortBy]: direction } }); + + if (rest.limit) pipeline.push({ $limit: rest.limit }); + + return this.model.aggregate(pipeline).exec(); + } + + const direction = String(order).toLowerCase() === "asc" ? 1 : -1; + const sort = { [sortBy]: direction }; + + if (usePaginate) { + return this.paginate(filter, { sort, ...rest }); + } + + return this.findMany(filter, { sort, ...rest }); + } +} + +/** + * Default shared instance bound to the real `Reel` model, mirroring + * the ergonomics the other `/mongo` exports aim for. + * @type {ReelRepository} + */ +const reelRepository = new ReelRepository(); + +export default reelRepository; diff --git a/mongo/repositories/SpaceRepository.js b/mongo/repositories/SpaceRepository.js new file mode 100644 index 00000000..ad7778b4 --- /dev/null +++ b/mongo/repositories/SpaceRepository.js @@ -0,0 +1,484 @@ +/** + * @module mongo/repositories/SpaceRepository + * Data-access layer for the {@link Space} model (#175). + * ------------------------------------------------------------------------- + * `SpaceRepository` centralizes every Space-specific persistence query so + * route handlers and services stop talking to the Mongoose model directly. + * + * Conventions: + * - Repositories never touch `res`/express - they return data or throw. + * - Every exported method carries complete JSDoc. + * + * Index recommendations (for optimal query performance): + * - { host: 1, status: 1 } - findByOwner with status filtering + * - { enrolledUsers: 1, status: 1 } - findByMember queries + * - { price: 1, status: 1 } - public/free space queries + * - { eventDate: 1 } - upcoming/past space queries + * - { title: "text", description: "text", category: "text" } - text search + * + * @example + * import SpaceRepository from "../mongo/repositories/SpaceRepository.js"; + * + * const spaces = await SpaceRepository.findByOwner(userId, { status: "live" }); + * const publicSpaces = await SpaceRepository.findPublic({ limit: 20 }); + */ + +import Space from "../../src/models/Space.js"; + +/** + * @typedef {Object} QueryOptions + * @property {number} [limit] Maximum number of documents to return. + * @property {number} [skip] Number of documents to skip (offset). + * @property {number} [page] 1-based page number; combined with + * `limit` to compute `skip` when `skip` + * is not supplied explicitly. + * @property {Object|string} [sort] Mongoose sort specification. + * @property {string|string[]|Object|Object[]} [populate] + * Path(s) to populate. + * @property {string|Object} [select] Projection / field selection. + * @property {boolean} [lean=false] Return plain objects instead of + * hydrated Mongoose documents. + */ + +/** + * Repository exposing Space-specific query helpers on top of the Mongoose + * `Space` model. + */ +export class SpaceRepository { + /** + * @param {import("mongoose").Model} [model=Space] The Mongoose model this + * repository operates on. + */ + constructor(model = Space) { + /** + * The Mongoose model backing this repository. + * @type {import("mongoose").Model} + */ + this.model = model; + } + + /** + * Apply shared {@link QueryOptions} to an existing Mongoose query. + * + * @private + * @param {import("mongoose").Query} query The query to decorate. + * @param {QueryOptions} [options={}] Options to apply. + * @returns {import("mongoose").Query} The same query, decorated. + */ + _applyOptions(query, options = {}) { + const { limit, skip, page, sort, populate, select, lean } = options; + + if (sort) query.sort(sort); + if (select) query.select(select); + + let effectiveSkip = skip; + if (effectiveSkip == null && page != null && limit != null) { + effectiveSkip = (Math.max(1, page) - 1) * limit; + } + if (effectiveSkip != null) query.skip(effectiveSkip); + if (limit != null) query.limit(limit); + + if (populate) { + const paths = Array.isArray(populate) ? populate : [populate]; + for (const path of paths) query.populate(path); + } + + if (lean) query.lean(); + + return query; + } + + /** + * Escape user-supplied text for safe use inside a `RegExp`. + * + * @private + * @param {string} value Raw input. + * @returns {string} Regex-safe string. + */ + _escapeRegex(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + } + + /** + * Fetch a single space by its identifier. + * + * @param {import("mongoose").Types.ObjectId|string} id Space id. + * @param {QueryOptions} [options={}] Query options. + * @returns {Promise} The space, or `null` if not found. + * @throws {Error} If `id` is missing. + */ + async findById(id, options = {}) { + if (!id) throw new Error("SpaceRepository.findById: `id` is required"); + return this._applyOptions(this.model.findById(id), options).exec(); + } + + /** + * Find all spaces hosted (owned) by a given user. + * + * @param {import("mongoose").Types.ObjectId|string} ownerId The host's User id. + * @param {QueryOptions & { status?: string }} [options={}] Query options. + * `status` filters by space status ("live", "upcoming", "ended"). + * @returns {Promise} Matching spaces (newest first by default). + * @throws {Error} If `ownerId` is missing. + */ + async findByOwner(ownerId, options = {}) { + if (!ownerId) { + throw new Error("SpaceRepository.findByOwner: `ownerId` is required"); + } + const { status, ...queryOptions } = options; + const filter = { host: ownerId }; + if (status) filter.status = status; + + const query = this.model.find(filter); + return this._applyOptions(query, { sort: { createdAt: -1 }, ...queryOptions }).exec(); + } + + /** + * Find all spaces where a user is enrolled as a member. + * + * @param {import("mongoose").Types.ObjectId|string} memberId The member's User id. + * @param {QueryOptions & { status?: string }} [options={}] Query options. + * @returns {Promise} Matching spaces. + * @throws {Error} If `memberId` is missing. + */ + async findByMember(memberId, options = {}) { + if (!memberId) { + throw new Error("SpaceRepository.findByMember: `memberId` is required"); + } + const { status, ...queryOptions } = options; + const filter = { enrolledUsers: memberId }; + if (status) filter.status = status; + + const query = this.model.find(filter); + return this._applyOptions(query, { sort: { eventDate: 1 }, ...queryOptions }).exec(); + } + + /** + * Find public spaces (free spaces available to anyone). + * + * Public spaces are defined as spaces with price = 0 and upcoming or live status. + * + * @param {QueryOptions & { status?: string }} [options={}] Query options. + * @returns {Promise} Matching public spaces. + */ + async findPublic(options = {}) { + const { status, ...queryOptions } = options; + const filter = { price: 0 }; + if (status) { + filter.status = status; + } else { + filter.status = { $in: ["live", "upcoming"] }; + } + + const query = this.model.find(filter); + return this._applyOptions(query, { sort: { eventDate: 1 }, ...queryOptions }).exec(); + } + + /** + * Find upcoming spaces (not yet started). + * + * @param {QueryOptions} [options={}] Query options. + * @returns {Promise} Upcoming spaces sorted by event date. + */ + async findUpcoming(options = {}) { + const filter = { + status: "upcoming", + eventDate: { $gte: new Date() }, + }; + const query = this.model.find(filter); + return this._applyOptions(query, { sort: { eventDate: 1 }, ...options }).exec(); + } + + /** + * Find live spaces (currently in session). + * + * @param {QueryOptions} [options={}] Query options. + * @returns {Promise} Live spaces. + */ + async findLive(options = {}) { + const filter = { status: "live" }; + const query = this.model.find(filter); + return this._applyOptions(query, { sort: { eventDate: -1 }, ...options }).exec(); + } + + /** + * Find spaces by category. + * + * @param {string} category Category name (case-insensitive match). + * @param {QueryOptions & { status?: string }} [options={}] Query options. + * @returns {Promise} Matching spaces. + * @throws {Error} If `category` is missing. + */ + async findByCategory(category, options = {}) { + if (!category) { + throw new Error("SpaceRepository.findByCategory: `category` is required"); + } + const { status, ...queryOptions } = options; + const filter = { + category: new RegExp(`^${this._escapeRegex(category)}$`, "i"), + }; + if (status) filter.status = status; + + const query = this.model.find(filter); + return this._applyOptions(query, { sort: { eventDate: 1 }, ...queryOptions }).exec(); + } + + /** + * Search spaces by free text. + * + * Uses the schema's compound text index (`title`, `description`, `category`) + * and sorts by relevance. + * + * @param {string} term The search term. + * @param {QueryOptions & { status?: string }} [options={}] Query options. + * @returns {Promise} Matching spaces. + * @throws {Error} If `term` is empty. + */ + async search(term, options = {}) { + if (!term || !String(term).trim()) { + throw new Error("SpaceRepository.search: `term` is required"); + } + const { status, ...queryOptions } = options; + const trimmed = String(term).trim(); + + const filter = { $text: { $search: trimmed } }; + if (status) filter.status = status; + + const query = this.model.find(filter, { score: { $meta: "textScore" } }); + return this._applyOptions( + query, + { sort: { score: { $meta: "textScore" } }, ...queryOptions } + ).exec(); + } + + /** + * Advanced filtering with multiple criteria. + * + * @param {Object} [criteria={}] + * @param {import("mongoose").Types.ObjectId|string} [criteria.host] Host user id. + * @param {string} [criteria.category] Category (case-insensitive). + * @param {string|string[]} [criteria.status] Status or statuses to include. + * @param {number} [criteria.minPrice] Minimum price. + * @param {number} [criteria.maxPrice] Maximum price (0 for free only). + * @param {Date} [criteria.fromDate] Events on or after this date. + * @param {Date} [criteria.toDate] Events on or before this date. + * @param {QueryOptions} [options={}] Query options. + * @returns {Promise} Matching spaces. + */ + async filter(criteria = {}, options = {}) { + const { host, category, status, minPrice, maxPrice, fromDate, toDate } = criteria; + const filter = {}; + + if (host) filter.host = host; + + if (category) { + filter.category = new RegExp(`^${this._escapeRegex(category)}$`, "i"); + } + + if (status) { + filter.status = Array.isArray(status) ? { $in: status } : status; + } + + if (minPrice != null || maxPrice != null) { + filter.price = {}; + if (minPrice != null) filter.price.$gte = minPrice; + if (maxPrice != null) filter.price.$lte = maxPrice; + } + + if (fromDate || toDate) { + filter.eventDate = {}; + if (fromDate) filter.eventDate.$gte = fromDate; + if (toDate) filter.eventDate.$lte = toDate; + } + + const query = this.model.find(filter); + return this._applyOptions(query, { sort: { eventDate: 1 }, ...options }).exec(); + } + + // ------------------------------------------------------------------------- + // Member management queries + // ------------------------------------------------------------------------- + + /** + * Get the list of enrolled members for a space. + * + * @param {import("mongoose").Types.ObjectId|string} spaceId Space id. + * @param {QueryOptions} [options={}] Query options for populating members. + * @returns {Promise} Enrolled user objects (when populated) or ids. + * @throws {Error} If `spaceId` is missing. + */ + async getMembers(spaceId, options = {}) { + if (!spaceId) { + throw new Error("SpaceRepository.getMembers: `spaceId` is required"); + } + const space = await this.model + .findById(spaceId) + .populate({ + path: "enrolledUsers", + select: options.select || "name email avatar", + }) + .lean() + .exec(); + + return space?.enrolledUsers || []; + } + + /** + * Get the waitlist for a space. + * + * @param {import("mongoose").Types.ObjectId|string} spaceId Space id. + * @param {QueryOptions} [options={}] Query options for populating members. + * @returns {Promise} Waitlist user objects (when populated) or ids. + * @throws {Error} If `spaceId` is missing. + */ + async getWaitlist(spaceId, options = {}) { + if (!spaceId) { + throw new Error("SpaceRepository.getWaitlist: `spaceId` is required"); + } + const space = await this.model + .findById(spaceId) + .populate({ + path: "waitList", + select: options.select || "name email avatar", + }) + .lean() + .exec(); + + return space?.waitList || []; + } + + /** + * Check if a user is enrolled in a space. + * + * @param {import("mongoose").Types.ObjectId|string} spaceId Space id. + * @param {import("mongoose").Types.ObjectId|string} userId User id. + * @returns {Promise} True if the user is enrolled. + */ + async isMember(spaceId, userId) { + if (!spaceId || !userId) { + throw new Error("SpaceRepository.isMember: `spaceId` and `userId` are required"); + } + const count = await this.model + .countDocuments({ _id: spaceId, enrolledUsers: userId }) + .exec(); + return count > 0; + } + + /** + * Check if a user is on the waitlist for a space. + * + * @param {import("mongoose").Types.ObjectId|string} spaceId Space id. + * @param {import("mongoose").Types.ObjectId|string} userId User id. + * @returns {Promise} True if the user is on the waitlist. + */ + async isOnWaitlist(spaceId, userId) { + if (!spaceId || !userId) { + throw new Error("SpaceRepository.isOnWaitlist: `spaceId` and `userId` are required"); + } + const count = await this.model + .countDocuments({ _id: spaceId, waitList: userId }) + .exec(); + return count > 0; + } + + /** + * Add a user to the enrolled members list. + * + * @param {import("mongoose").Types.ObjectId|string} spaceId Space id. + * @param {import("mongoose").Types.ObjectId|string} userId User id. + * @returns {Promise} Updated space or null if not found. + */ + async addMember(spaceId, userId) { + return this.model + .findByIdAndUpdate( + spaceId, + { $addToSet: { enrolledUsers: userId } }, + { new: true } + ) + .exec(); + } + + /** + * Remove a user from the enrolled members list. + * + * @param {import("mongoose").Types.ObjectId|string} spaceId Space id. + * @param {import("mongoose").Types.ObjectId|string} userId User id. + * @returns {Promise} Updated space or null if not found. + */ + async removeMember(spaceId, userId) { + return this.model + .findByIdAndUpdate( + spaceId, + { $pull: { enrolledUsers: userId } }, + { new: true } + ) + .exec(); + } + + /** + * Add a user to the waitlist. + * + * @param {import("mongoose").Types.ObjectId|string} spaceId Space id. + * @param {import("mongoose").Types.ObjectId|string} userId User id. + * @returns {Promise} Updated space or null if not found. + */ + async addToWaitlist(spaceId, userId) { + return this.model + .findByIdAndUpdate( + spaceId, + { $addToSet: { waitList: userId } }, + { new: true } + ) + .exec(); + } + + /** + * Remove a user from the waitlist. + * + * @param {import("mongoose").Types.ObjectId|string} spaceId Space id. + * @param {import("mongoose").Types.ObjectId|string} userId User id. + * @returns {Promise} Updated space or null if not found. + */ + async removeFromWaitlist(spaceId, userId) { + return this.model + .findByIdAndUpdate( + spaceId, + { $pull: { waitList: userId } }, + { new: true } + ) + .exec(); + } + + /** + * Get member count for a space. + * + * @param {import("mongoose").Types.ObjectId|string} spaceId Space id. + * @returns {Promise} Number of enrolled members. + */ + async getMemberCount(spaceId) { + const space = await this.model + .findById(spaceId) + .select("enrolledUsers") + .lean() + .exec(); + return space?.enrolledUsers?.length || 0; + } + + /** + * Count spaces matching an arbitrary filter. + * + * @param {Object} [filter={}] A Mongoose filter object. + * @returns {Promise} The matching document count. + */ + async count(filter = {}) { + return this.model.countDocuments(filter).exec(); + } +} + +/** + * Default shared instance bound to the real `Space` model. + * @type {SpaceRepository} + */ +const spaceRepository = new SpaceRepository(); + +export default spaceRepository; diff --git a/mongo/repositories/TransactionRepository.js b/mongo/repositories/TransactionRepository.js new file mode 100644 index 00000000..13629de8 --- /dev/null +++ b/mongo/repositories/TransactionRepository.js @@ -0,0 +1,555 @@ +/** + * @module mongo/repositories/TransactionRepository + * Data-access layer for the {@link Transaction} model (#170). + * ------------------------------------------------------------------------- + * `TransactionRepository` centralizes every Transaction-specific persistence + * query so route handlers and services stop talking to the Mongoose model + * directly. + * + * Conventions: + * - Repositories never touch `res`/express - they return data or throw. + * - Every exported method carries complete JSDoc. + * + * Index recommendations (for optimal query performance): + * - { buyer: 1, status: 1 } - findByUser with status filter + * - { creator: 1, status: 1 } - findByCreator queries + * - { status: 1, createdAt: -1 } - findByStatus with date sort + * - { type: 1, status: 1, createdAt: -1 } - donation stats + * - { createdAt: 1 } - date range queries + * - { confirmedAt: 1 } - settlement/payout queries + * + * @example + * import TransactionRepository from "../mongo/repositories/TransactionRepository.js"; + * + * const txns = await TransactionRepository.findByUser(userId, { status: "confirmed" }); + * const stats = await TransactionRepository.getVolumeStats({ from: startDate, to: endDate }); + */ + +import Transaction from "../../src/models/Transaction.js"; + +/** + * @typedef {Object} QueryOptions + * @property {number} [limit] Maximum number of documents to return. + * @property {number} [skip] Number of documents to skip (offset). + * @property {number} [page] 1-based page number; combined with + * `limit` to compute `skip` when `skip` + * is not supplied explicitly. + * @property {Object|string} [sort] Mongoose sort specification. + * @property {string|string[]|Object|Object[]} [populate] + * Path(s) to populate. + * @property {string|Object} [select] Projection / field selection. + * @property {boolean} [lean=false] Return plain objects instead of + * hydrated Mongoose documents. + */ + +/** + * Repository exposing Transaction-specific query helpers on top of the + * Mongoose `Transaction` model. + */ +export class TransactionRepository { + /** + * @param {import("mongoose").Model} [model=Transaction] The Mongoose model + * this repository operates on. + */ + constructor(model = Transaction) { + /** + * The Mongoose model backing this repository. + * @type {import("mongoose").Model} + */ + this.model = model; + } + + /** + * Apply shared {@link QueryOptions} to an existing Mongoose query. + * + * @private + * @param {import("mongoose").Query} query The query to decorate. + * @param {QueryOptions} [options={}] Options to apply. + * @returns {import("mongoose").Query} The same query, decorated. + */ + _applyOptions(query, options = {}) { + const { limit, skip, page, sort, populate, select, lean } = options; + + if (sort) query.sort(sort); + if (select) query.select(select); + + let effectiveSkip = skip; + if (effectiveSkip == null && page != null && limit != null) { + effectiveSkip = (Math.max(1, page) - 1) * limit; + } + if (effectiveSkip != null) query.skip(effectiveSkip); + if (limit != null) query.limit(limit); + + if (populate) { + const paths = Array.isArray(populate) ? populate : [populate]; + for (const path of paths) query.populate(path); + } + + if (lean) query.lean(); + + return query; + } + + /** + * Fetch a single transaction by its identifier. + * + * @param {import("mongoose").Types.ObjectId|string} id Transaction id. + * @param {QueryOptions} [options={}] Query options. + * @returns {Promise} The transaction, or `null` if not found. + * @throws {Error} If `id` is missing. + */ + async findById(id, options = {}) { + if (!id) throw new Error("TransactionRepository.findById: `id` is required"); + return this._applyOptions(this.model.findById(id), options).exec(); + } + + /** + * Find a transaction by its Stellar transaction hash. + * + * @param {string} hash Stellar transaction hash. + * @param {QueryOptions} [options={}] Query options. + * @returns {Promise} The transaction, or `null` if not found. + * @throws {Error} If `hash` is missing. + */ + async findByHash(hash, options = {}) { + if (!hash) { + throw new Error("TransactionRepository.findByHash: `hash` is required"); + } + const query = this.model.findOne({ stellarTxHash: hash }); + return this._applyOptions(query, options).exec(); + } + + /** + * Find all transactions for a given user (as buyer). + * + * @param {import("mongoose").Types.ObjectId|string} userId The buyer's User id. + * @param {QueryOptions & { status?: string|string[], type?: string }} [options={}] + * `status` filters by transaction status, `type` filters by "purchase"/"donation". + * @returns {Promise} Matching transactions (newest first by default). + * @throws {Error} If `userId` is missing. + */ + async findByUser(userId, options = {}) { + if (!userId) { + throw new Error("TransactionRepository.findByUser: `userId` is required"); + } + const { status, type, ...queryOptions } = options; + const filter = { buyer: userId }; + if (status) { + filter.status = Array.isArray(status) ? { $in: status } : status; + } + if (type) filter.type = type; + + const query = this.model.find(filter); + return this._applyOptions(query, { sort: { createdAt: -1 }, ...queryOptions }).exec(); + } + + /** + * Find all transactions for a given creator (as seller/recipient). + * + * @param {import("mongoose").Types.ObjectId|string} creatorId The creator's User id. + * @param {QueryOptions & { status?: string|string[], type?: string }} [options={}] + * @returns {Promise} Matching transactions. + * @throws {Error} If `creatorId` is missing. + */ + async findByCreator(creatorId, options = {}) { + if (!creatorId) { + throw new Error("TransactionRepository.findByCreator: `creatorId` is required"); + } + const { status, type, ...queryOptions } = options; + const filter = { creator: creatorId }; + if (status) { + filter.status = Array.isArray(status) ? { $in: status } : status; + } + if (type) filter.type = type; + + const query = this.model.find(filter); + return this._applyOptions(query, { sort: { createdAt: -1 }, ...queryOptions }).exec(); + } + + /** + * Find transactions by status. + * + * @param {string|string[]} status Status or statuses to filter by. + * @param {QueryOptions & { type?: string }} [options={}] Query options. + * @returns {Promise} Matching transactions. + * @throws {Error} If `status` is missing. + */ + async findByStatus(status, options = {}) { + if (!status) { + throw new Error("TransactionRepository.findByStatus: `status` is required"); + } + const { type, ...queryOptions } = options; + const filter = { + status: Array.isArray(status) ? { $in: status } : status, + }; + if (type) filter.type = type; + + const query = this.model.find(filter); + return this._applyOptions(query, { sort: { createdAt: -1 }, ...queryOptions }).exec(); + } + + /** + * Find transactions within a date range. + * + * @param {Date} from Start date (inclusive). + * @param {Date} to End date (inclusive). + * @param {QueryOptions & { status?: string|string[], type?: string, dateField?: string }} [options={}] + * `dateField` specifies which date field to filter on (default: "createdAt"). + * @returns {Promise} Matching transactions. + * @throws {Error} If date range is invalid. + */ + async findByDateRange(from, to, options = {}) { + if (!from || !to) { + throw new Error("TransactionRepository.findByDateRange: `from` and `to` are required"); + } + const { status, type, dateField = "createdAt", ...queryOptions } = options; + const filter = { + [dateField]: { $gte: from, $lte: to }, + }; + if (status) { + filter.status = Array.isArray(status) ? { $in: status } : status; + } + if (type) filter.type = type; + + const query = this.model.find(filter); + return this._applyOptions(query, { sort: { [dateField]: -1 }, ...queryOptions }).exec(); + } + + /** + * Find transactions for a specific item (book or course). + * + * @param {string} itemType "book" or "course". + * @param {import("mongoose").Types.ObjectId|string} itemId The item's id. + * @param {QueryOptions & { status?: string|string[] }} [options={}] + * @returns {Promise} Matching transactions. + */ + async findByItem(itemType, itemId, options = {}) { + if (!itemType || !itemId) { + throw new Error("TransactionRepository.findByItem: `itemType` and `itemId` are required"); + } + const { status, ...queryOptions } = options; + const filter = { itemType, itemId }; + if (status) { + filter.status = Array.isArray(status) ? { $in: status } : status; + } + + const query = this.model.find(filter); + return this._applyOptions(query, { sort: { createdAt: -1 }, ...queryOptions }).exec(); + } + + /** + * Find pending transactions that need processing/retry. + * + * @param {QueryOptions} [options={}] Query options. + * @returns {Promise} Pending/retrying transactions. + */ + async findPending(options = {}) { + const filter = { status: { $in: ["pending", "submitted", "retrying"] } }; + const query = this.model.find(filter); + return this._applyOptions(query, { sort: { createdAt: 1 }, ...options }).exec(); + } + + /** + * Find failed transactions for review. + * + * @param {QueryOptions & { since?: Date }} [options={}] Query options. + * `since` limits to failures after a specific date. + * @returns {Promise} Failed transactions. + */ + async findFailed(options = {}) { + const { since, ...queryOptions } = options; + const filter = { status: "failed" }; + if (since) filter.createdAt = { $gte: since }; + + const query = this.model.find(filter); + return this._applyOptions(query, { sort: { createdAt: -1 }, ...queryOptions }).exec(); + } + + // ------------------------------------------------------------------------- + // Aggregation and statistics methods + // ------------------------------------------------------------------------- + + /** + * Get transaction volume statistics. + * + * @param {Object} [options={}] + * @param {Date} [options.from] Start date. + * @param {Date} [options.to] End date. + * @param {string} [options.type] Filter by transaction type. + * @param {string} [options.currency] Filter by currency. + * @param {string|string[]} [options.status=["confirmed"]] Status filter. + * @returns {Promise} Volume statistics. + */ + async getVolumeStats(options = {}) { + const { + from, + to, + type, + currency, + status = ["confirmed"], + } = options; + + const match = { + status: Array.isArray(status) ? { $in: status } : status, + }; + if (from || to) { + match.createdAt = {}; + if (from) match.createdAt.$gte = from; + if (to) match.createdAt.$lte = to; + } + if (type) match.type = type; + if (currency) match.currency = currency; + + const result = await this.model.aggregate([ + { $match: match }, + { + $group: { + _id: null, + totalCount: { $sum: 1 }, + totalVolume: { $sum: { $toDouble: "$amount" } }, + avgAmount: { $avg: { $toDouble: "$amount" } }, + minAmount: { $min: { $toDouble: "$amount" } }, + maxAmount: { $max: { $toDouble: "$amount" } }, + }, + }, + ]); + + return result[0] || { + totalCount: 0, + totalVolume: 0, + avgAmount: 0, + minAmount: 0, + maxAmount: 0, + }; + } + + /** + * Get volume breakdown by currency. + * + * @param {Object} [options={}] + * @param {Date} [options.from] Start date. + * @param {Date} [options.to] End date. + * @param {string|string[]} [options.status=["confirmed"]] Status filter. + * @returns {Promise} Per-currency volume breakdown. + */ + async getVolumeByCurrency(options = {}) { + const { from, to, status = ["confirmed"] } = options; + + const match = { + status: Array.isArray(status) ? { $in: status } : status, + }; + if (from || to) { + match.createdAt = {}; + if (from) match.createdAt.$gte = from; + if (to) match.createdAt.$lte = to; + } + + return this.model.aggregate([ + { $match: match }, + { + $group: { + _id: "$currency", + count: { $sum: 1 }, + volume: { $sum: { $toDouble: "$amount" } }, + }, + }, + { $sort: { volume: -1 } }, + ]); + } + + /** + * Get volume breakdown by transaction type. + * + * @param {Object} [options={}] + * @param {Date} [options.from] Start date. + * @param {Date} [options.to] End date. + * @param {string|string[]} [options.status=["confirmed"]] Status filter. + * @returns {Promise} Per-type volume breakdown. + */ + async getVolumeByType(options = {}) { + const { from, to, status = ["confirmed"] } = options; + + const match = { + status: Array.isArray(status) ? { $in: status } : status, + }; + if (from || to) { + match.createdAt = {}; + if (from) match.createdAt.$gte = from; + if (to) match.createdAt.$lte = to; + } + + return this.model.aggregate([ + { $match: match }, + { + $group: { + _id: "$type", + count: { $sum: 1 }, + volume: { $sum: { $toDouble: "$amount" } }, + }, + }, + { $sort: { volume: -1 } }, + ]); + } + + /** + * Get daily transaction volume time series. + * + * @param {Object} options + * @param {Date} options.from Start date. + * @param {Date} options.to End date. + * @param {string} [options.type] Filter by transaction type. + * @param {string|string[]} [options.status=["confirmed"]] Status filter. + * @returns {Promise} Daily volume series. + */ + async getDailyVolume(options = {}) { + const { from, to, type, status = ["confirmed"] } = options; + + if (!from || !to) { + throw new Error("TransactionRepository.getDailyVolume: `from` and `to` are required"); + } + + const match = { + createdAt: { $gte: from, $lte: to }, + status: Array.isArray(status) ? { $in: status } : status, + }; + if (type) match.type = type; + + return this.model.aggregate([ + { $match: match }, + { + $group: { + _id: { + year: { $year: "$createdAt" }, + month: { $month: "$createdAt" }, + day: { $dayOfMonth: "$createdAt" }, + }, + count: { $sum: 1 }, + volume: { $sum: { $toDouble: "$amount" } }, + }, + }, + { + $project: { + _id: 0, + date: { + $dateFromParts: { + year: "$_id.year", + month: "$_id.month", + day: "$_id.day", + }, + }, + count: 1, + volume: 1, + }, + }, + { $sort: { date: 1 } }, + ]); + } + + /** + * Get transaction count breakdown by status. + * + * @param {Object} [options={}] + * @param {Date} [options.from] Start date. + * @param {Date} [options.to] End date. + * @returns {Promise} Per-status count breakdown. + */ + async getCountByStatus(options = {}) { + const { from, to } = options; + const match = {}; + if (from || to) { + match.createdAt = {}; + if (from) match.createdAt.$gte = from; + if (to) match.createdAt.$lte = to; + } + + const pipeline = []; + if (Object.keys(match).length > 0) { + pipeline.push({ $match: match }); + } + pipeline.push( + { $group: { _id: "$status", count: { $sum: 1 } } }, + { $sort: { count: -1 } } + ); + + return this.model.aggregate(pipeline); + } + + /** + * Get earnings summary for a creator. + * + * @param {import("mongoose").Types.ObjectId|string} creatorId Creator's User id. + * @param {Object} [options={}] + * @param {Date} [options.from] Start date. + * @param {Date} [options.to] End date. + * @returns {Promise} Earnings summary with total and breakdown. + */ + async getCreatorEarnings(creatorId, options = {}) { + if (!creatorId) { + throw new Error("TransactionRepository.getCreatorEarnings: `creatorId` is required"); + } + const { from, to } = options; + + const match = { + creator: creatorId, + status: "confirmed", + }; + if (from || to) { + match.confirmedAt = {}; + if (from) match.confirmedAt.$gte = from; + if (to) match.confirmedAt.$lte = to; + } + + const result = await this.model.aggregate([ + { $match: match }, + { + $group: { + _id: "$itemType", + count: { $sum: 1 }, + gross: { $sum: { $toDouble: "$amount" } }, + net: { + $sum: { + $cond: [ + { $ifNull: ["$platformFee.creatorAmount", false] }, + { $toDouble: "$platformFee.creatorAmount" }, + { $toDouble: "$amount" }, + ], + }, + }, + }, + }, + ]); + + const totals = result.reduce( + (acc, r) => { + acc.totalCount += r.count; + acc.totalGross += r.gross; + acc.totalNet += r.net; + return acc; + }, + { totalCount: 0, totalGross: 0, totalNet: 0 } + ); + + return { + ...totals, + byItemType: result, + }; + } + + /** + * Count transactions matching an arbitrary filter. + * + * @param {Object} [filter={}] A Mongoose filter object. + * @returns {Promise} The matching document count. + */ + async count(filter = {}) { + return this.model.countDocuments(filter).exec(); + } +} + +/** + * Default shared instance bound to the real `Transaction` model. + * @type {TransactionRepository} + */ +const transactionRepository = new TransactionRepository(); + +export default transactionRepository; diff --git a/mongo/repositories/__tests__/EducatorBalanceRepository.test.js b/mongo/repositories/__tests__/EducatorBalanceRepository.test.js new file mode 100644 index 00000000..22f8816e --- /dev/null +++ b/mongo/repositories/__tests__/EducatorBalanceRepository.test.js @@ -0,0 +1,617 @@ +import mongoose from "mongoose"; +import { MongoMemoryServer } from "mongodb-memory-server"; +import EducatorBalance from "../../../src/models/EducatorBalance.js"; +import LedgerEntry from "../../../src/models/LedgerEntry.js"; +import { EducatorBalanceRepository } from "../EducatorBalanceRepository.js"; + +describe("EducatorBalanceRepository", () => { + let mongoServer; + let repo; + + const educatorA = new mongoose.Types.ObjectId(); + const educatorB = new mongoose.Types.ObjectId(); + + beforeAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + repo = new EducatorBalanceRepository(EducatorBalance); + }, 30000); + + afterAll(async () => { + await mongoose.disconnect(); + if (mongoServer) await mongoServer.stop(); + }); + + beforeEach(async () => { + await EducatorBalance.deleteMany({}); + await LedgerEntry.deleteMany({}); + }); + + /* -------------------------------------------------------------------- */ + /* Helpers */ + /* -------------------------------------------------------------------- */ + + const createBalance = (overrides = {}) => + EducatorBalance.create({ + educator: educatorA, + owedStroops: "0", + settledStroops: "0", + ...overrides, + }); + + const createLedgerEntry = (overrides = {}) => + LedgerEntry.create({ + educator: educatorA, + type: "sale", + txRef: `tx_${new mongoose.Types.ObjectId()}`, + amount: "10", + amountStroops: "1000000000", + settlement: "platform_collect", + ...overrides, + }); + + /* -------------------------------------------------------------------- */ + /* Construction */ + /* -------------------------------------------------------------------- */ + + describe("constructor", () => { + it("creates an instance extending BaseRepository", () => { + expect(repo).toBeInstanceOf(EducatorBalanceRepository); + expect(repo.model).toBe(EducatorBalance); + }); + + it("accepts a custom model for testing", () => { + const custom = new EducatorBalanceRepository(EducatorBalance); + expect(custom.model).toBe(EducatorBalance); + }); + }); + + /* -------------------------------------------------------------------- */ + /* findByEducator */ + /* -------------------------------------------------------------------- */ + + describe("findByEducator", () => { + it("returns the balance record for an educator", async () => { + await createBalance({ educator: educatorA, owedStroops: "500000000" }); + + const result = await repo.findByEducator(educatorA); + expect(result).toBeDefined(); + expect(result.owedStroops).toBe("500000000"); + }); + + it("returns null when no balance exists", async () => { + const result = await repo.findByEducator(new mongoose.Types.ObjectId()); + expect(result).toBeNull(); + }); + + it("excludes other educators' balances", async () => { + await createBalance({ educator: educatorA, owedStroops: "100" }); + await createBalance({ educator: educatorB, owedStroops: "200" }); + + const result = await repo.findByEducator(educatorA); + expect(result.owedStroops).toBe("100"); + }); + + it("throws when educatorId is missing", async () => { + await expect(repo.findByEducator(null)).rejects.toThrow("educatorId"); + await expect(repo.findByEducator(undefined)).rejects.toThrow("educatorId"); + await expect(repo.findByEducator("")).rejects.toThrow("educatorId"); + }); + }); + + /* -------------------------------------------------------------------- */ + /* getAvailableBalance */ + /* -------------------------------------------------------------------- */ + + describe("getAvailableBalance", () => { + it("returns the owedStroops as the available balance", async () => { + await createBalance({ educator: educatorA, owedStroops: "1500000000" }); + + const result = await repo.getAvailableBalance(educatorA); + expect(result.stroops).toBe("1500000000"); + expect(result.amount).toBe("15"); + }); + + it("returns '0' when no balance exists", async () => { + const result = await repo.getAvailableBalance(new mongoose.Types.ObjectId()); + expect(result.stroops).toBe("0"); + expect(result.amount).toBe("0"); + }); + + it("ignores settledStroops", async () => { + await createBalance({ + educator: educatorA, + owedStroops: "100000000", + settledStroops: "500000000", + }); + + const result = await repo.getAvailableBalance(educatorA); + expect(result.stroops).toBe("100000000"); + expect(result.amount).toBe("1"); + }); + + it("throws when educatorId is missing", async () => { + await expect(repo.getAvailableBalance(null)).rejects.toThrow("educatorId"); + }); + }); + + /* -------------------------------------------------------------------- */ + /* getPendingAmount */ + /* -------------------------------------------------------------------- */ + + describe("getPendingAmount", () => { + it("returns the owedStroops as the pending amount", async () => { + await createBalance({ educator: educatorA, owedStroops: "300000000" }); + + const result = await repo.getPendingAmount(educatorA); + expect(result.stroops).toBe("300000000"); + expect(result.amount).toBe("3"); + }); + + it("returns '0' when no balance exists", async () => { + const result = await repo.getPendingAmount(new mongoose.Types.ObjectId()); + expect(result.stroops).toBe("0"); + }); + + it("throws when educatorId is missing", async () => { + await expect(repo.getPendingAmount(null)).rejects.toThrow("educatorId"); + }); + }); + + /* -------------------------------------------------------------------- */ + /* getTransactionHistory */ + /* -------------------------------------------------------------------- */ + + describe("getTransactionHistory", () => { + beforeEach(async () => { + await createLedgerEntry({ + educator: educatorA, + type: "sale", + amount: "10", + amountStroops: "1000000000", + settlement: "platform_collect", + createdAt: new Date("2026-01-01"), + }); + await createLedgerEntry({ + educator: educatorA, + type: "sale", + amount: "5", + amountStroops: "500000000", + settlement: "direct", + createdAt: new Date("2026-01-02"), + }); + await createLedgerEntry({ + educator: educatorA, + type: "payout", + amount: "10", + amountStroops: "1000000000", + txRef: "batch_001", + createdAt: new Date("2026-01-03"), + }); + await createLedgerEntry({ + educator: educatorB, + type: "sale", + amount: "50", + amountStroops: "5000000000", + settlement: "platform_collect", + createdAt: new Date("2026-01-01"), + }); + }); + + it("returns ledger entries for the specified educator only", async () => { + const result = await repo.getTransactionHistory(educatorA); + expect(result.data).toHaveLength(3); + for (const entry of result.data) { + expect(entry.educator.toString()).toBe(educatorA.toString()); + } + }); + + it("excludes other educators' entries", async () => { + const result = await repo.getTransactionHistory(educatorA); + const hasEducatorB = result.data.some( + (e) => e.educator.toString() === educatorB.toString() + ); + expect(hasEducatorB).toBe(false); + }); + + it("paginates correctly", async () => { + const page1 = await repo.getTransactionHistory(educatorA, { + page: 1, + limit: 2, + }); + expect(page1.data).toHaveLength(2); + expect(page1.total).toBe(3); + expect(page1.totalPages).toBe(2); + expect(page1.hasNextPage).toBe(true); + expect(page1.hasPrevPage).toBe(false); + + const page2 = await repo.getTransactionHistory(educatorA, { + page: 2, + limit: 2, + }); + expect(page2.data).toHaveLength(1); + expect(page2.hasNextPage).toBe(false); + expect(page2.hasPrevPage).toBe(true); + }); + + it("filters by type", async () => { + const sales = await repo.getTransactionHistory(educatorA, { + type: "sale", + }); + expect(sales.data).toHaveLength(2); + for (const entry of sales.data) { + expect(entry.type).toBe("sale"); + } + + const payouts = await repo.getTransactionHistory(educatorA, { + type: "payout", + }); + expect(payouts.data).toHaveLength(1); + expect(payouts.data[0].type).toBe("payout"); + }); + + it("filters by date range", async () => { + const result = await repo.getTransactionHistory(educatorA, { + from: "2026-01-02", + to: "2026-01-03", + }); + expect(result.data).toHaveLength(2); + }); + + it("throws for invalid type", async () => { + await expect( + repo.getTransactionHistory(educatorA, { type: "invalid" }) + ).rejects.toThrow("type"); + }); + + it("throws when educatorId is missing", async () => { + await expect(repo.getTransactionHistory(null)).rejects.toThrow("educatorId"); + }); + }); + + /* -------------------------------------------------------------------- */ + /* reconcileBalance */ + /* -------------------------------------------------------------------- */ + + describe("reconcileBalance", () => { + it("returns isConsistent when LedgerEntry history matches stored balance", async () => { + await createBalance({ + educator: educatorA, + owedStroops: "500000000", + settledStroops: "0", + }); + + await createLedgerEntry({ + educator: educatorA, + type: "sale", + amount: "5", + amountStroops: "500000000", + settlement: "platform_collect", + }); + + const result = await repo.reconcileBalance(educatorA); + expect(result.isConsistent).toBe(true); + expect(result.storedOwed).toBe("500000000"); + expect(result.computedOwed).toBe("500000000"); + expect(result.discrepancies).toHaveLength(0); + }); + + it("detects an induced mismatch in owedStroops", async () => { + await createBalance({ + educator: educatorA, + owedStroops: "99999", + settledStroops: "0", + }); + + await createLedgerEntry({ + educator: educatorA, + type: "sale", + amount: "5", + amountStroops: "500000000", + settlement: "platform_collect", + }); + + const result = await repo.reconcileBalance(educatorA); + expect(result.isConsistent).toBe(false); + expect(result.storedOwed).toBe("99999"); + expect(result.computedOwed).toBe("500000000"); + expect(result.discrepancies.length).toBeGreaterThan(0); + expect(result.discrepancies[0]).toContain("owedStroops mismatch"); + }); + + it("detects an induced mismatch in settledStroops", async () => { + await createBalance({ + educator: educatorA, + owedStroops: "0", + settledStroops: "11111", + }); + + await createLedgerEntry({ + educator: educatorA, + type: "sale", + amount: "10", + amountStroops: "1000000000", + settlement: "direct", + }); + + const result = await repo.reconcileBalance(educatorA); + expect(result.isConsistent).toBe(false); + expect(result.storedSettled).toBe("11111"); + expect(result.computedSettled).toBe("1000000000"); + }); + + it("correctly reconciles mixed sale and payout entries", async () => { + await createBalance({ + educator: educatorA, + owedStroops: "0", + settledStroops: "1500000000", + }); + + await createLedgerEntry({ + educator: educatorA, + type: "sale", + amount: "10", + amountStroops: "1000000000", + settlement: "platform_collect", + }); + await createLedgerEntry({ + educator: educatorA, + type: "sale", + amount: "5", + amountStroops: "500000000", + settlement: "direct", + }); + await createLedgerEntry({ + educator: educatorA, + type: "payout", + amount: "10", + amountStroops: "1000000000", + txRef: "batch_001", + }); + + const result = await repo.reconcileBalance(educatorA); + expect(result.isConsistent).toBe(true); + expect(result.computedOwed).toBe("0"); + expect(result.computedSettled).toBe("1500000000"); + }); + + it("returns consistent result when no entries exist", async () => { + await createBalance({ + educator: educatorA, + owedStroops: "0", + settledStroops: "0", + }); + + const result = await repo.reconcileBalance(educatorA); + expect(result.isConsistent).toBe(true); + expect(result.computedOwed).toBe("0"); + expect(result.computedSettled).toBe("0"); + }); + + it("throws when educatorId is missing", async () => { + await expect(repo.reconcileBalance(null)).rejects.toThrow("educatorId"); + }); + }); + + /* -------------------------------------------------------------------- */ + /* deductOwedBalance — CRITICAL ATOMICITY TEST */ + /* -------------------------------------------------------------------- */ + + describe("deductOwedBalance", () => { + it("deducts the specified amount atomically", async () => { + await createBalance({ educator: educatorA, owedStroops: "1000000000" }); + + const result = await repo.deductOwedBalance(educatorA, "300000000"); + expect(result).toBeDefined(); + expect(result.owedStroops).toBe("700000000"); + }); + + it("returns null when balance is insufficient", async () => { + await createBalance({ educator: educatorA, owedStroops: "100000000" }); + + const result = await repo.deductOwedBalance(educatorA, "200000000"); + expect(result).toBeNull(); + + const balance = await EducatorBalance.findOne({ educator: educatorA }); + expect(balance.owedStroops).toBe("100000000"); + }); + + it("returns null for non-existent educator", async () => { + const result = await repo.deductOwedBalance( + new mongoose.Types.ObjectId(), + "100000000" + ); + expect(result).toBeNull(); + }); + + it("deducts exactly the requested amount (no rounding)", async () => { + await createBalance({ educator: educatorA, owedStroops: "123456789" }); + + const result = await repo.deductOwedBalance(educatorA, "1"); + expect(result.owedStroops).toBe("123456788"); + }); + + it("allows deducting the full balance", async () => { + await createBalance({ educator: educatorA, owedStroops: "500000000" }); + + const result = await repo.deductOwedBalance(educatorA, "500000000"); + expect(result.owedStroops).toBe("0"); + }); + + /** + * CRITICAL TEST: Two concurrent withdrawal deductions against a balance + * that can only cover ONE of them. Exactly one must succeed and the other + * must be rejected. This proves the $expr filter prevents lost updates + * and negative balances. + */ + it("handles concurrent withdrawal deductions — only one succeeds", async () => { + await createBalance({ educator: educatorA, owedStroops: "100000000" }); + + const deductionA = repo.deductOwedBalance(educatorA, "80000000"); + const deductionB = repo.deductOwedBalance(educatorA, "80000000"); + + const results = await Promise.all([deductionA, deductionB]); + + const successes = results.filter((r) => r !== null); + const failures = results.filter((r) => r === null); + + expect(successes).toHaveLength(1); + expect(failures).toHaveLength(1); + + const finalBalance = await EducatorBalance.findOne({ educator: educatorA }); + const finalOwed = BigInt(finalBalance.owedStroops); + expect(finalOwed >= 0n).toBe(true); + expect(finalOwed.toString()).toBe("20000000"); + }); + + it("throws when educatorId is missing", async () => { + await expect(repo.deductOwedBalance(null, "100")).rejects.toThrow( + "educatorId" + ); + }); + + it("throws when amountStroops is not positive", async () => { + await createBalance({ educator: educatorA, owedStroops: "100" }); + await expect(repo.deductOwedBalance(educatorA, "0")).rejects.toThrow( + "positive" + ); + await expect(repo.deductOwedBalance(educatorA, "-100")).rejects.toThrow( + "positive" + ); + }); + }); + + /* -------------------------------------------------------------------- */ + /* creditOwedBalance */ + /* -------------------------------------------------------------------- */ + + describe("creditOwedBalance", () => { + it("credits the specified amount atomically", async () => { + await createBalance({ educator: educatorA, owedStroops: "100000000" }); + + const result = await repo.creditOwedBalance(educatorA, "500000000"); + expect(result.owedStroops).toBe("600000000"); + }); + + it("creates a new balance record if none exists (upsert)", async () => { + const result = await repo.creditOwedBalance(educatorA, "200000000"); + expect(result).toBeDefined(); + expect(result.owedStroops).toBe("200000000"); + expect(result.educator.toString()).toBe(educatorA.toString()); + }); + + it("accumulates multiple credits", async () => { + await repo.creditOwedBalance(educatorA, "100000000"); + await repo.creditOwedBalance(educatorA, "200000000"); + const result = await repo.creditOwedBalance(educatorA, "50000000"); + + expect(result.owedStroops).toBe("350000000"); + }); + + it("throws when educatorId is missing", async () => { + await expect(repo.creditOwedBalance(null, "100")).rejects.toThrow( + "educatorId" + ); + }); + + it("throws when amountStroops is not positive", async () => { + await expect(repo.creditOwedBalance(educatorA, "0")).rejects.toThrow( + "positive" + ); + }); + }); + + /* -------------------------------------------------------------------- */ + /* settleOwedToSettled */ + /* -------------------------------------------------------------------- */ + + describe("settleOwedToSettled", () => { + it("moves amount from owed to settled atomically", async () => { + await createBalance({ + educator: educatorA, + owedStroops: "500000000", + settledStroops: "100000000", + }); + + const result = await repo.settleOwedToSettled(educatorA, "300000000"); + expect(result.owedStroops).toBe("200000000"); + expect(result.settledStroops).toBe("400000000"); + }); + + it("returns null when owed balance is insufficient", async () => { + await createBalance({ + educator: educatorA, + owedStroops: "100000000", + settledStroops: "0", + }); + + const result = await repo.settleOwedToSettled(educatorA, "200000000"); + expect(result).toBeNull(); + + const balance = await EducatorBalance.findOne({ educator: educatorA }); + expect(balance.owedStroops).toBe("100000000"); + expect(balance.settledStroops).toBe("0"); + }); + + it("handles concurrent settle operations safely", async () => { + await createBalance({ + educator: educatorA, + owedStroops: "100000000", + settledStroops: "0", + }); + + const settleA = repo.settleOwedToSettled(educatorA, "80000000"); + const settleB = repo.settleOwedToSettled(educatorA, "80000000"); + + const results = await Promise.all([settleA, settleB]); + const successes = results.filter((r) => r !== null); + const failures = results.filter((r) => r === null); + + expect(successes).toHaveLength(1); + expect(failures).toHaveLength(1); + + const finalBalance = await EducatorBalance.findOne({ educator: educatorA }); + expect(BigInt(finalBalance.owedStroops) >= 0n).toBe(true); + expect(BigInt(finalBalance.settledStroops).toString()).toBe("80000000"); + }); + + it("throws when educatorId is missing", async () => { + await expect(repo.settleOwedToSettled(null, "100")).rejects.toThrow( + "educatorId" + ); + }); + + it("throws when amountStroops is not positive", async () => { + await expect(repo.settleOwedToSettled(educatorA, "0")).rejects.toThrow( + "positive" + ); + }); + }); + + /* -------------------------------------------------------------------- */ + /* _stroopsToAmount (private helper) */ + /* -------------------------------------------------------------------- */ + + describe("_stroopsToAmount", () => { + it("converts stroops to USDC decimal string", () => { + expect(repo._stroopsToAmount(0n)).toBe("0"); + expect(repo._stroopsToAmount(1n)).toBe("0.00000001"); + expect(repo._stroopsToAmount(100000000n)).toBe("1"); + expect(repo._stroopsToAmount(150000000n)).toBe("1.5"); + expect(repo._stroopsToAmount(1050000000n)).toBe("10.5"); + expect(repo._stroopsToAmount(1000000000n)).toBe("10"); + }); + + it("handles large values", () => { + expect(repo._stroopsToAmount(100000000000n)).toBe("1000"); + expect(repo._stroopsToAmount(1234567890123n)).toBe("12345.67890123"); + }); + + it("trims trailing zeros in decimal part", () => { + expect(repo._stroopsToAmount(100000010n)).toBe("1.0000001"); + }); + }); +}); diff --git a/mongo/repositories/__tests__/NotificationRepository.test.js b/mongo/repositories/__tests__/NotificationRepository.test.js new file mode 100644 index 00000000..d2edce11 --- /dev/null +++ b/mongo/repositories/__tests__/NotificationRepository.test.js @@ -0,0 +1,604 @@ +import mongoose from "mongoose"; +import { MongoMemoryServer } from "mongodb-memory-server"; +import Notification from "../../../src/models/Notification.js"; +import { + NotificationRepository, + NOTIFICATION_TYPES, + NOTIFICATION_PRIORITIES, +} from "../NotificationRepository.js"; + +describe("NotificationRepository", () => { + let mongoServer; + let repo; + + const userId = new mongoose.Types.ObjectId(); + const senderId = new mongoose.Types.ObjectId(); + + beforeAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + repo = new NotificationRepository(Notification); + }, 30000); + + afterAll(async () => { + await mongoose.disconnect(); + if (mongoServer) await mongoServer.stop(); + }); + + beforeEach(async () => { + await Notification.deleteMany({}); + }); + + /* -------------------------------------------------------------------- */ + /* Helpers */ + /* -------------------------------------------------------------------- */ + + const createNotification = (overrides = {}) => + Notification.create({ + recipient: userId, + sender: senderId, + type: "follow", + title: "New follower", + message: "Someone followed you", + priority: "medium", + ...overrides, + }); + + const createBulkNotifications = (count, overrides = {}) => + Notification.insertMany( + Array.from({ length: count }, (_, i) => ({ + recipient: userId, + sender: senderId, + type: NOTIFICATION_TYPES[i % NOTIFICATION_TYPES.length], + title: `Notification ${i + 1}`, + message: `Message ${i + 1}`, + priority: NOTIFICATION_PRIORITIES[i % NOTIFICATION_PRIORITIES.length], + isRead: i % 3 === 0, + ...overrides, + })) + ); + + /* -------------------------------------------------------------------- */ + /* Construction */ + /* -------------------------------------------------------------------- */ + + describe("constructor", () => { + it("creates an instance extending BaseRepository", () => { + expect(repo).toBeInstanceOf(NotificationRepository); + expect(repo.model).toBe(Notification); + }); + + it("accepts a custom model for testing", () => { + const custom = new NotificationRepository(Notification); + expect(custom.model).toBe(Notification); + }); + }); + + /* -------------------------------------------------------------------- */ + /* findByUser */ + /* -------------------------------------------------------------------- */ + + describe("findByUser", () => { + it("returns only notifications for the specified user", async () => { + const otherUser = new mongoose.Types.ObjectId(); + + await createBulkNotifications(3); + await createBulkNotifications(2, { recipient: otherUser }); + + const results = await repo.findByUser(userId); + expect(results).toHaveLength(3); + for (const doc of results) { + expect(doc.recipient.toString()).toBe(userId.toString()); + } + }); + + it("excludes soft-deleted notifications", async () => { + await createNotification({ isDeleted: true }); + await createNotification({ isDeleted: false }); + + const results = await repo.findByUser(userId); + expect(results).toHaveLength(1); + }); + + it("sorts newest first by default", async () => { + const old = await createNotification({ title: "Old" }); + await new Promise((r) => setTimeout(r, 10)); + const newer = await createNotification({ title: "Newer" }); + + const results = await repo.findByUser(userId); + expect(results[0].title).toBe("Newer"); + expect(results[1].title).toBe("Old"); + }); + + it("supports additional filter criteria", async () => { + await createNotification({ type: "follow" }); + await createNotification({ type: "system" }); + + const results = await repo.findByUser(userId, { + filter: { type: "follow" }, + }); + expect(results).toHaveLength(1); + expect(results[0].type).toBe("follow"); + }); + + it("supports limit option", async () => { + await createBulkNotifications(5); + + const results = await repo.findByUser(userId, { limit: 2 }); + expect(results).toHaveLength(2); + }); + + it("supports paginated mode", async () => { + await createBulkNotifications(5); + + const page1 = await repo.findByUser(userId, { + paginate: true, + limit: 2, + page: 1, + }); + expect(page1.data).toHaveLength(2); + expect(page1.total).toBe(5); + expect(page1.totalPages).toBe(3); + expect(page1.hasNextPage).toBe(true); + expect(page1.hasPrevPage).toBe(false); + + const page2 = await repo.findByUser(userId, { + paginate: true, + limit: 2, + page: 2, + }); + expect(page2.data).toHaveLength(2); + expect(page2.hasPrevPage).toBe(true); + }); + + it("throws when userId is missing", async () => { + await expect(repo.findByUser(null)).rejects.toThrow("userId"); + await expect(repo.findByUser(undefined)).rejects.toThrow("userId"); + await expect(repo.findByUser("")).rejects.toThrow("userId"); + }); + }); + + /* -------------------------------------------------------------------- */ + /* findUnread */ + /* -------------------------------------------------------------------- */ + + describe("findUnread", () => { + it("returns only unread notifications", async () => { + await createNotification({ isRead: false }); + await createNotification({ isRead: false }); + await createNotification({ isRead: true }); + + const results = await repo.findUnread(userId); + expect(results).toHaveLength(2); + for (const doc of results) { + expect(doc.isRead).toBe(false); + } + }); + + it("excludes soft-deleted notifications", async () => { + await createNotification({ isRead: false, isDeleted: true }); + await createNotification({ isRead: false, isDeleted: false }); + + const results = await repo.findUnread(userId); + expect(results).toHaveLength(1); + }); + + it("combines with additional filter criteria", async () => { + await createNotification({ type: "follow", isRead: false }); + await createNotification({ type: "system", isRead: false }); + await createNotification({ type: "follow", isRead: true }); + + const results = await repo.findUnread(userId, { + filter: { type: "follow" }, + }); + expect(results).toHaveLength(1); + expect(results[0].type).toBe("follow"); + }); + + it("supports paginated mode", async () => { + await createBulkNotifications(5, { isRead: false }); + + const page = await repo.findUnread(userId, { paginate: true, limit: 3 }); + expect(page.data).toHaveLength(3); + expect(page.total).toBe(5); + }); + + it("throws when userId is missing", async () => { + await expect(repo.findUnread(null)).rejects.toThrow("userId"); + }); + }); + + /* -------------------------------------------------------------------- */ + /* findByType */ + /* -------------------------------------------------------------------- */ + + describe("findByType", () => { + it("returns only notifications of the specified type", async () => { + await createNotification({ type: "follow" }); + await createNotification({ type: "follow" }); + await createNotification({ type: "system" }); + + const results = await repo.findByType("follow"); + expect(results).toHaveLength(2); + for (const doc of results) { + expect(doc.type).toBe("follow"); + } + }); + + it("scopes to a specific user when userId is provided", async () => { + const otherUser = new mongoose.Types.ObjectId(); + await createNotification({ type: "follow", recipient: userId }); + await createNotification({ type: "follow", recipient: otherUser }); + + const results = await repo.findByType("follow", { userId }); + expect(results).toHaveLength(1); + expect(results[0].recipient.toString()).toBe(userId.toString()); + }); + + it("throws when type is missing", async () => { + await expect(repo.findByType(null)).rejects.toThrow("type"); + }); + + it("throws for invalid type", async () => { + await expect(repo.findByType("invalid_type")).rejects.toThrow("invalid type"); + }); + }); + + /* -------------------------------------------------------------------- */ + /* findByPriority */ + /* -------------------------------------------------------------------- */ + + describe("findByPriority", () => { + it("returns only notifications of the specified priority", async () => { + await createNotification({ priority: "urgent" }); + await createNotification({ priority: "urgent" }); + await createNotification({ priority: "low" }); + + const results = await repo.findByPriority("urgent"); + expect(results).toHaveLength(2); + for (const doc of results) { + expect(doc.priority).toBe("urgent"); + } + }); + + it("scopes to a specific user when userId is provided", async () => { + const otherUser = new mongoose.Types.ObjectId(); + await createNotification({ priority: "urgent", recipient: userId }); + await createNotification({ priority: "urgent", recipient: otherUser }); + + const results = await repo.findByPriority("urgent", { userId }); + expect(results).toHaveLength(1); + expect(results[0].recipient.toString()).toBe(userId.toString()); + }); + + it("throws when priority is missing", async () => { + await expect(repo.findByPriority(null)).rejects.toThrow("priority"); + }); + + it("throws for invalid priority", async () => { + await expect(repo.findByPriority("extreme")).rejects.toThrow("invalid priority"); + }); + }); + + /* -------------------------------------------------------------------- */ + /* markAsRead */ + /* -------------------------------------------------------------------- */ + + describe("markAsRead", () => { + it("marks a single notification as read", async () => { + const notif = await createNotification({ isRead: false }); + const updated = await repo.markAsRead(notif._id); + + expect(updated.isRead).toBe(true); + }); + + it("does not affect other notifications", async () => { + const notif1 = await createNotification({ isRead: false }); + const notif2 = await createNotification({ isRead: false }); + + await repo.markAsRead(notif1._id); + + const check = await Notification.findById(notif2._id); + expect(check.isRead).toBe(false); + }); + + it("returns null for non-existent notification", async () => { + const fakeId = new mongoose.Types.ObjectId(); + const result = await repo.markAsRead(fakeId); + expect(result).toBeNull(); + }); + + it("throws when notificationId is missing", async () => { + await expect(repo.markAsRead(null)).rejects.toThrow("notificationId"); + }); + }); + + /* -------------------------------------------------------------------- */ + /* markAllAsRead */ + /* -------------------------------------------------------------------- */ + + describe("markAllAsRead", () => { + it("marks all unread notifications for a user as read", async () => { + await createNotification({ isRead: false }); + await createNotification({ isRead: false }); + await createNotification({ isRead: true }); + + const result = await repo.markAllAsRead(userId); + expect(result.acknowledged).toBe(true); + expect(result.modifiedCount).toBe(2); + + const remaining = await Notification.find({ recipient: userId, isRead: false }); + expect(remaining).toHaveLength(0); + }); + + it("does not affect other users' notifications", async () => { + const otherUser = new mongoose.Types.ObjectId(); + await createNotification({ isRead: false, recipient: userId }); + await createNotification({ isRead: false, recipient: otherUser }); + + await repo.markAllAsRead(userId); + + const otherUserNotif = await Notification.findOne({ recipient: otherUser }); + expect(otherUserNotif.isRead).toBe(false); + }); + + it("does not affect soft-deleted notifications", async () => { + await createNotification({ isRead: false, isDeleted: true }); + + const result = await repo.markAllAsRead(userId); + expect(result.modifiedCount).toBe(0); + }); + + it("returns zero modifiedCount when no unread exist", async () => { + await createNotification({ isRead: true }); + + const result = await repo.markAllAsRead(userId); + expect(result.modifiedCount).toBe(0); + }); + + it("throws when userId is missing", async () => { + await expect(repo.markAllAsRead(null)).rejects.toThrow("userId"); + }); + }); + + /* -------------------------------------------------------------------- */ + /* markManyAsRead */ + /* -------------------------------------------------------------------- */ + + describe("markManyAsRead", () => { + it("marks multiple specific notifications as read", async () => { + const n1 = await createNotification({ isRead: false }); + const n2 = await createNotification({ isRead: false }); + await createNotification({ isRead: false }); + + const result = await repo.markManyAsRead(userId, [n1._id, n2._id]); + expect(result.modifiedCount).toBe(2); + + const check1 = await Notification.findById(n1._id); + const check2 = await Notification.findById(n2._id); + expect(check1.isRead).toBe(true); + expect(check2.isRead).toBe(true); + }); + + it("does not update notifications belonging to another user", async () => { + const otherUser = new mongoose.Types.ObjectId(); + const n1 = await createNotification({ isRead: false }); + const n2 = await createNotification({ isRead: false, recipient: otherUser }); + + const result = await repo.markManyAsRead(userId, [n1._id, n2._id]); + expect(result.modifiedCount).toBe(1); + + const check = await Notification.findById(n2._id); + expect(check.isRead).toBe(false); + }); + + it("does not update already-read notifications", async () => { + const n1 = await createNotification({ isRead: false }); + const n2 = await createNotification({ isRead: true }); + + const result = await repo.markManyAsRead(userId, [n1._id, n2._id]); + expect(result.modifiedCount).toBe(1); + }); + + it("throws when userId is missing", async () => { + await expect(repo.markManyAsRead(null, [])).rejects.toThrow("userId"); + }); + + it("throws when notificationIds is empty", async () => { + await expect(repo.markManyAsRead(userId, [])).rejects.toThrow( + "notificationIds" + ); + }); + + it("throws when notificationIds is not an array", async () => { + await expect(repo.markManyAsRead(userId, "not-an-array")).rejects.toThrow( + "notificationIds" + ); + }); + }); + + /* -------------------------------------------------------------------- */ + /* deleteOlderThan */ + /* -------------------------------------------------------------------- */ + + describe("deleteOlderThan", () => { + it("permanently deletes notifications older than the cutoff", async () => { + const oldDate = new Date("2020-01-01"); + const newDate = new Date("2025-01-01"); + + await Notification.create({ + recipient: userId, + sender: senderId, + type: "system", + title: "Old", + message: "Old message", + createdAt: oldDate, + }); + await Notification.create({ + recipient: userId, + sender: senderId, + type: "system", + title: "New", + message: "New message", + createdAt: newDate, + }); + + const result = await repo.deleteOlderThan(new Date("2023-01-01")); + expect(result.deletedCount).toBe(1); + + const remaining = await Notification.find({ recipient: userId }); + expect(remaining).toHaveLength(1); + expect(remaining[0].title).toBe("New"); + }); + + it("supports extra filter criteria", async () => { + const oldDate = new Date("2020-01-01"); + await Notification.create({ + recipient: userId, + sender: senderId, + type: "follow", + title: "Old follow", + message: "...", + createdAt: oldDate, + }); + await Notification.create({ + recipient: userId, + sender: senderId, + type: "system", + title: "Old system", + message: "...", + createdAt: oldDate, + }); + + const result = await repo.deleteOlderThan(new Date("2023-01-01"), { + filter: { type: "follow" }, + }); + expect(result.deletedCount).toBe(1); + + const remaining = await Notification.find({ recipient: userId }); + expect(remaining).toHaveLength(1); + expect(remaining[0].type).toBe("system"); + }); + + it("throws when olderThan is missing", async () => { + await expect(repo.deleteOlderThan(null)).rejects.toThrow("olderThan"); + }); + + it("throws for invalid date", async () => { + await expect(repo.deleteOlderThan("not-a-date")).rejects.toThrow("valid date"); + }); + }); + + /* -------------------------------------------------------------------- */ + /* softDeleteOlderThan */ + /* -------------------------------------------------------------------- */ + + describe("softDeleteOlderThan", () => { + it("flags old notifications as deleted instead of removing them", async () => { + const old = await Notification.create({ + recipient: userId, + sender: senderId, + type: "system", + title: "Old", + message: "...", + createdAt: new Date("2020-01-01"), + }); + await Notification.create({ + recipient: userId, + sender: senderId, + type: "system", + title: "New", + message: "...", + createdAt: new Date("2025-01-01"), + }); + + const result = await repo.softDeleteOlderThan(new Date("2023-01-01")); + expect(result.modifiedCount).toBe(1); + + const check = await Notification.findById(old._id); + expect(check.isDeleted).toBe(true); + + const remaining = await repo.findByUser(userId); + expect(remaining).toHaveLength(1); + expect(remaining[0].title).toBe("New"); + }); + + it("does not soft-delete already-deleted notifications", async () => { + await Notification.create({ + recipient: userId, + sender: senderId, + type: "system", + title: "Already deleted", + message: "...", + isDeleted: true, + createdAt: new Date("2020-01-01"), + }); + + const result = await repo.softDeleteOlderThan(new Date("2023-01-01")); + expect(result.modifiedCount).toBe(0); + }); + + it("throws when olderThan is missing", async () => { + await expect(repo.softDeleteOlderThan(null)).rejects.toThrow("olderThan"); + }); + + it("throws for invalid date", async () => { + await expect(repo.softDeleteOlderThan("bad")).rejects.toThrow("valid date"); + }); + }); + + /* -------------------------------------------------------------------- */ + /* Filtering combinations */ + /* -------------------------------------------------------------------- */ + + describe("combined filtering", () => { + it("filters by type + unread + user simultaneously", async () => { + await createNotification({ type: "follow", isRead: false }); + await createNotification({ type: "follow", isRead: true }); + await createNotification({ type: "system", isRead: false }); + + const results = await repo.findUnread(userId, { + filter: { type: "follow" }, + }); + expect(results).toHaveLength(1); + expect(results[0].type).toBe("follow"); + expect(results[0].isRead).toBe(false); + }); + + it("filters by priority + type + user simultaneously", async () => { + await createNotification({ type: "follow", priority: "urgent" }); + await createNotification({ type: "follow", priority: "low" }); + await createNotification({ type: "system", priority: "urgent" }); + + const results = await repo.findByUser(userId, { + filter: { type: "follow", priority: "urgent" }, + }); + expect(results).toHaveLength(1); + expect(results[0].type).toBe("follow"); + expect(results[0].priority).toBe("urgent"); + }); + }); + + /* -------------------------------------------------------------------- */ + /* Exports and constants */ + /* -------------------------------------------------------------------- */ + + describe("exports", () => { + it("exports NOTIFICATION_TYPES as a frozen array", () => { + expect(Array.isArray(NOTIFICATION_TYPES)).toBe(true); + expect(Object.isFrozen(NOTIFICATION_TYPES)).toBe(true); + expect(NOTIFICATION_TYPES).toContain("follow"); + expect(NOTIFICATION_TYPES).toContain("system"); + }); + + it("exports NOTIFICATION_PRIORITIES as a frozen array", () => { + expect(Array.isArray(NOTIFICATION_PRIORITIES)).toBe(true); + expect(Object.isFrozen(NOTIFICATION_PRIORITIES)).toBe(true); + expect(NOTIFICATION_PRIORITIES).toContain("low"); + expect(NOTIFICATION_PRIORITIES).toContain("urgent"); + }); + }); +}); diff --git a/mongo/repositories/__tests__/ReelRepository.test.js b/mongo/repositories/__tests__/ReelRepository.test.js new file mode 100644 index 00000000..7e0bcff2 --- /dev/null +++ b/mongo/repositories/__tests__/ReelRepository.test.js @@ -0,0 +1,680 @@ +import mongoose from "mongoose"; +import { MongoMemoryServer } from "mongodb-memory-server"; +import Reel from "../../../src/models/Reel.js"; +import { ReelRepository } from "../ReelRepository.js"; + +describe("ReelRepository", () => { + let mongoServer; + let repo; + + const userId = new mongoose.Types.ObjectId(); + const otherUserId = new mongoose.Types.ObjectId(); + + beforeAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + repo = new ReelRepository(Reel); + }, 30000); + + afterAll(async () => { + await mongoose.disconnect(); + if (mongoServer) await mongoServer.stop(); + }); + + beforeEach(async () => { + await Reel.deleteMany({}); + }); + + /* -------------------------------------------------------------------- */ + /* Helpers */ + /* -------------------------------------------------------------------- */ + + const createReel = (overrides = {}) => + Reel.create({ + description: "Test reel description", + category: "education", + tags: ["tutorial", "coding"], + video: "https://example.com/video.mp4", + createdBy: userId, + viewCount: 0, + shareCount: 0, + likes: [], + loves: [], + comments: [], + ...overrides, + }); + + const createBulkReels = (count, overrides = {}) => + Reel.insertMany( + Array.from({ length: count }, (_, i) => ({ + description: `Reel ${i + 1}`, + category: i % 2 === 0 ? "education" : "entertainment", + tags: [`tag${i}`], + video: `https://example.com/video${i}.mp4`, + createdBy: i % 3 === 0 ? otherUserId : userId, + viewCount: i * 10, + shareCount: i, + likes: i % 2 === 0 ? [otherUserId] : [], + loves: i % 3 === 0 ? [userId] : [], + comments: i % 4 === 0 ? [{ user: userId, text: `Comment ${i}` }] : [], + ...overrides, + })) + ); + + /* -------------------------------------------------------------------- */ + /* Construction */ + /* -------------------------------------------------------------------- */ + + describe("constructor", () => { + it("creates an instance extending BaseRepository", () => { + expect(repo).toBeInstanceOf(ReelRepository); + expect(repo.model).toBe(Reel); + }); + + it("accepts a custom model for testing", () => { + const custom = new ReelRepository(Reel); + expect(custom.model).toBe(Reel); + }); + }); + + /* -------------------------------------------------------------------- */ + /* findByCreator */ + /* -------------------------------------------------------------------- */ + + describe("findByCreator", () => { + it("returns only reels created by the specified user", async () => { + await createBulkReels(5); + + const results = await repo.findByCreator(userId); + expect(results.length).toBeGreaterThan(0); + for (const doc of results) { + expect(doc.createdBy.toString()).toBe(userId.toString()); + } + }); + + it("excludes reels from other creators", async () => { + await createReel({ createdBy: userId }); + await createReel({ createdBy: otherUserId }); + + const results = await repo.findByCreator(userId); + expect(results).toHaveLength(1); + }); + + it("sorts newest first by default", async () => { + const old = await createReel({ description: "Old" }); + await new Promise((r) => setTimeout(r, 10)); + const newer = await createReel({ description: "Newer" }); + + const results = await repo.findByCreator(userId); + expect(results[0].description).toBe("Newer"); + expect(results[1].description).toBe("Old"); + }); + + it("supports additional filter criteria", async () => { + await createReel({ createdBy: userId, category: "education" }); + await createReel({ createdBy: userId, category: "entertainment" }); + + const results = await repo.findByCreator(userId, { + filter: { category: "education" }, + }); + expect(results).toHaveLength(1); + expect(results[0].category).toBe("education"); + }); + + it("supports limit option", async () => { + await createBulkReels(5); + + const results = await repo.findByCreator(userId, { limit: 2 }); + expect(results).toHaveLength(2); + }); + + it("supports paginated mode", async () => { + await createBulkReels(5); + + const page1 = await repo.findByCreator(userId, { + paginate: true, + limit: 2, + page: 1, + }); + expect(page1.data).toHaveLength(2); + expect(page1.total).toBeGreaterThan(0); + expect(page1.totalPages).toBeGreaterThan(0); + expect(page1.hasNextPage).toBeDefined(); + expect(page1.hasPrevPage).toBeDefined(); + }); + + it("throws when creatorId is missing", async () => { + await expect(repo.findByCreator(null)).rejects.toThrow("creatorId"); + await expect(repo.findByCreator(undefined)).rejects.toThrow("creatorId"); + await expect(repo.findByCreator("")).rejects.toThrow("creatorId"); + }); + }); + + /* -------------------------------------------------------------------- */ + /* findBySpace */ + /* -------------------------------------------------------------------- */ + + describe("findBySpace", () => { + it("returns empty array when schema has no spaceId field", async () => { + await createBulkReels(3); + + const results = await repo.findBySpace(new mongoose.Types.ObjectId()); + expect(results).toEqual([]); + }); + + it("supports paginated mode with empty results", async () => { + const page = await repo.findBySpace(new mongoose.Types.ObjectId(), { + paginate: true, + limit: 10, + }); + expect(page.data).toEqual([]); + expect(page.total).toBe(0); + expect(page.totalPages).toBe(0); + expect(page.hasNextPage).toBe(false); + expect(page.hasPrevPage).toBe(false); + }); + + it("throws when spaceId is missing", async () => { + await expect(repo.findBySpace(null)).rejects.toThrow("spaceId"); + }); + }); + + /* -------------------------------------------------------------------- */ + /* findTrending */ + /* -------------------------------------------------------------------- */ + + describe("findTrending", () => { + it("returns reels sorted by engagement score", async () => { + const highEngagement = await createReel({ + description: "High engagement", + viewCount: 1000, + likes: [userId, otherUserId], + loves: [userId], + comments: [{ user: userId, text: "Great!" }], + }); + + const lowEngagement = await createReel({ + description: "Low engagement", + viewCount: 10, + likes: [], + loves: [], + comments: [], + }); + + const results = await repo.findTrending({ limit: 10 }); + expect(results).toHaveLength(2); + expect(results[0].description).toBe("High engagement"); + expect(results[1].description).toBe("Low engagement"); + }); + + it("respects the limit parameter", async () => { + await createBulkReels(5); + + const results = await repo.findTrending({ limit: 2 }); + expect(results).toHaveLength(2); + }); + + it("respects the days parameter", async () => { + // Create old reel + const oldReel = await Reel.create({ + description: "Old reel", + video: "https://example.com/old.mp4", + createdBy: userId, + viewCount: 10000, + createdAt: new Date("2020-01-01"), + }); + + // Create new reel + const newReel = await createReel({ + description: "New reel", + viewCount: 10, + }); + + const results = await repo.findTrending({ days: 30 }); + expect(results.some((r) => r.description === "Old reel")).toBe(false); + }); + + it("supports additional filter criteria", async () => { + await createReel({ + description: "Education reel", + category: "education", + viewCount: 100, + }); + await createReel({ + description: "Entertainment reel", + category: "entertainment", + viewCount: 200, + }); + + const results = await repo.findTrending({ + filter: { category: "education" }, + }); + expect(results).toHaveLength(1); + expect(results[0].category).toBe("education"); + }); + }); + + /* -------------------------------------------------------------------- */ + /* incrementViewCount */ + /* -------------------------------------------------------------------- */ + + describe("incrementViewCount", () => { + it("atomically increments view count", async () => { + const reel = await createReel({ viewCount: 10 }); + + const updated = await repo.incrementViewCount(reel._id); + expect(updated.viewCount).toBe(11); + }); + + it("increments by specified amount", async () => { + const reel = await createReel({ viewCount: 10 }); + + const updated = await repo.incrementViewCount(reel._id, 5); + expect(updated.viewCount).toBe(15); + }); + + it("handles concurrent increments correctly", async () => { + const reel = await createReel({ viewCount: 0 }); + + // Fire 10 concurrent increments + const increments = Array.from({ length: 10 }, () => + repo.incrementViewCount(reel._id, 1) + ); + await Promise.all(increments); + + const updated = await Reel.findById(reel._id); + expect(updated.viewCount).toBe(10); + }); + + it("returns null for non-existent reel", async () => { + const fakeId = new mongoose.Types.ObjectId(); + const result = await repo.incrementViewCount(fakeId); + expect(result).toBeNull(); + }); + + it("throws when reelId is missing", async () => { + await expect(repo.incrementViewCount(null)).rejects.toThrow("reelId"); + }); + + it("throws when amount is not a number", async () => { + const reel = await createReel(); + await expect(repo.incrementViewCount(reel._id, "not-a-number")).rejects.toThrow( + "amount" + ); + }); + }); + + /* -------------------------------------------------------------------- */ + /* addLike */ + /* -------------------------------------------------------------------- */ + + describe("addLike", () => { + it("adds a user to the likes array", async () => { + const reel = await createReel(); + + const updated = await repo.addLike(reel._id, userId); + expect(updated.likes.map((id) => id.toString())).toContain( + userId.toString() + ); + }); + + it("removes user from loves when adding like", async () => { + const reel = await createReel({ loves: [userId] }); + + const updated = await repo.addLike(reel._id, userId); + expect(updated.likes.map((id) => id.toString())).toContain( + userId.toString() + ); + expect(updated.loves.map((id) => id.toString())).not.toContain( + userId.toString() + ); + }); + + it("does not duplicate likes", async () => { + const reel = await createReel({ likes: [userId] }); + + const updated = await repo.addLike(reel._id, userId); + const likeCount = updated.likes.filter( + (id) => id.toString() === userId.toString() + ).length; + expect(likeCount).toBe(1); + }); + + it("throws when reelId is missing", async () => { + await expect(repo.addLike(null, userId)).rejects.toThrow("reelId"); + }); + + it("throws when userId is missing", async () => { + const reel = await createReel(); + await expect(repo.addLike(reel._id, null)).rejects.toThrow("userId"); + }); + }); + + /* -------------------------------------------------------------------- */ + /* removeLike */ + /* -------------------------------------------------------------------- */ + + describe("removeLike", () => { + it("removes a user from the likes array", async () => { + const reel = await createReel({ likes: [userId] }); + + const updated = await repo.removeLike(reel._id, userId); + expect(updated.likes.map((id) => id.toString())).not.toContain( + userId.toString() + ); + }); + + it("throws when reelId is missing", async () => { + await expect(repo.removeLike(null, userId)).rejects.toThrow("reelId"); + }); + + it("throws when userId is missing", async () => { + const reel = await createReel(); + await expect(repo.removeLike(reel._id, null)).rejects.toThrow("userId"); + }); + }); + + /* -------------------------------------------------------------------- */ + /* addLove */ + /* -------------------------------------------------------------------- */ + + describe("addLove", () => { + it("adds a user to the loves array", async () => { + const reel = await createReel(); + + const updated = await repo.addLove(reel._id, userId); + expect(updated.loves.map((id) => id.toString())).toContain( + userId.toString() + ); + }); + + it("removes user from likes when adding love", async () => { + const reel = await createReel({ likes: [userId] }); + + const updated = await repo.addLove(reel._id, userId); + expect(updated.loves.map((id) => id.toString())).toContain( + userId.toString() + ); + expect(updated.likes.map((id) => id.toString())).not.toContain( + userId.toString() + ); + }); + + it("does not duplicate loves", async () => { + const reel = await createReel({ loves: [userId] }); + + const updated = await repo.addLove(reel._id, userId); + const loveCount = updated.loves.filter( + (id) => id.toString() === userId.toString() + ).length; + expect(loveCount).toBe(1); + }); + + it("throws when reelId is missing", async () => { + await expect(repo.addLove(null, userId)).rejects.toThrow("reelId"); + }); + + it("throws when userId is missing", async () => { + const reel = await createReel(); + await expect(repo.addLove(reel._id, null)).rejects.toThrow("userId"); + }); + }); + + /* -------------------------------------------------------------------- */ + /* removeLove */ + /* -------------------------------------------------------------------- */ + + describe("removeLove", () => { + it("removes a user from the loves array", async () => { + const reel = await createReel({ loves: [userId] }); + + const updated = await repo.removeLove(reel._id, userId); + expect(updated.loves.map((id) => id.toString())).not.toContain( + userId.toString() + ); + }); + + it("throws when reelId is missing", async () => { + await expect(repo.removeLove(null, userId)).rejects.toThrow("reelId"); + }); + + it("throws when userId is missing", async () => { + const reel = await createReel(); + await expect(repo.removeLove(reel._id, null)).rejects.toThrow("userId"); + }); + }); + + /* -------------------------------------------------------------------- */ + /* addComment */ + /* -------------------------------------------------------------------- */ + + describe("addComment", () => { + it("adds a comment to the reel", async () => { + const reel = await createReel(); + + const updated = await repo.addComment(reel._id, { + user: userId, + text: "Great reel!", + }); + expect(updated.comments).toHaveLength(1); + expect(updated.comments[0].text).toBe("Great reel!"); + expect(updated.comments[0].user.toString()).toBe(userId.toString()); + }); + + it("throws when reelId is missing", async () => { + await expect( + repo.addComment(null, { user: userId, text: "test" }) + ).rejects.toThrow("reelId"); + }); + + it("throws when comment.user is missing", async () => { + const reel = await createReel(); + await expect( + repo.addComment(reel._id, { text: "test" }) + ).rejects.toThrow("comment.user"); + }); + + it("throws when comment.text is missing", async () => { + const reel = await createReel(); + await expect( + repo.addComment(reel._id, { user: userId }) + ).rejects.toThrow("comment.text"); + }); + }); + + /* -------------------------------------------------------------------- */ + /* removeComment */ + /* -------------------------------------------------------------------- */ + + describe("removeComment", () => { + it("removes a comment from the reel", async () => { + const reel = await createReel({ + comments: [{ user: userId, text: "To be removed" }], + }); + const commentId = reel.comments[0]._id; + + const updated = await repo.removeComment(reel._id, commentId); + expect(updated.comments).toHaveLength(0); + }); + + it("throws when reelId is missing", async () => { + const fakeCommentId = new mongoose.Types.ObjectId(); + await expect(repo.removeComment(null, fakeCommentId)).rejects.toThrow( + "reelId" + ); + }); + + it("throws when commentId is missing", async () => { + const reel = await createReel(); + await expect(repo.removeComment(reel._id, null)).rejects.toThrow( + "commentId" + ); + }); + }); + + /* -------------------------------------------------------------------- */ + /* incrementShareCount */ + /* -------------------------------------------------------------------- */ + + describe("incrementShareCount", () => { + it("atomically increments share count", async () => { + const reel = await createReel({ shareCount: 5 }); + + const updated = await repo.incrementShareCount(reel._id); + expect(updated.shareCount).toBe(6); + }); + + it("increments by specified amount", async () => { + const reel = await createReel({ shareCount: 5 }); + + const updated = await repo.incrementShareCount(reel._id, 3); + expect(updated.shareCount).toBe(8); + }); + + it("returns null for non-existent reel", async () => { + const fakeId = new mongoose.Types.ObjectId(); + const result = await repo.incrementShareCount(fakeId); + expect(result).toBeNull(); + }); + + it("throws when reelId is missing", async () => { + await expect(repo.incrementShareCount(null)).rejects.toThrow("reelId"); + }); + + it("throws when amount is not a number", async () => { + const reel = await createReel(); + await expect(repo.incrementShareCount(reel._id, "not-a-number")).rejects.toThrow( + "amount" + ); + }); + }); + + /* -------------------------------------------------------------------- */ + /* filter */ + /* -------------------------------------------------------------------- */ + + describe("filter", () => { + it("filters by creator", async () => { + await createReel({ createdBy: userId, description: "My reel" }); + await createReel({ createdBy: otherUserId, description: "Other reel" }); + + const results = await repo.filter({ creator: userId }); + expect(results).toHaveLength(1); + expect(results[0].createdBy.toString()).toBe(userId.toString()); + }); + + it("filters by category", async () => { + await createReel({ category: "education" }); + await createReel({ category: "entertainment" }); + + const results = await repo.filter({ category: "education" }); + expect(results).toHaveLength(1); + expect(results[0].category).toBe("education"); + }); + + it("filters by tags", async () => { + await createReel({ tags: ["javascript", "tutorial"] }); + await createReel({ tags: ["cooking", "recipe"] }); + + const results = await repo.filter({ tags: ["javascript"] }); + expect(results).toHaveLength(1); + expect(results[0].tags).toContain("javascript"); + }); + + it("filters by search text", async () => { + await createReel({ description: "Learn JavaScript basics" }); + await createReel({ description: "Cooking tutorial" }); + + const results = await repo.filter({ search: "JavaScript" }); + expect(results).toHaveLength(1); + expect(results[0].description).toContain("JavaScript"); + }); + + it("supports sort options", async () => { + await createReel({ description: "Old", createdAt: new Date("2020-01-01") }); + await createReel({ description: "New", createdAt: new Date("2025-01-01") }); + + const results = await repo.filter({}, { sortBy: "createdAt", order: "asc" }); + expect(results[0].description).toBe("Old"); + expect(results[1].description).toBe("New"); + }); + + it("supports pagination", async () => { + await createBulkReels(5); + + const page = await repo.filter({}, { paginate: true, limit: 2, page: 1 }); + expect(page.data).toHaveLength(2); + expect(page.total).toBeGreaterThan(0); + }); + + it("combines multiple filters", async () => { + await createReel({ + createdBy: userId, + category: "education", + tags: ["javascript"], + }); + await createReel({ + createdBy: userId, + category: "entertainment", + tags: ["cooking"], + }); + await createReel({ + createdBy: otherUserId, + category: "education", + tags: ["javascript"], + }); + + const results = await repo.filter({ + creator: userId, + category: "education", + }); + expect(results).toHaveLength(1); + expect(results[0].tags).toContain("javascript"); + }); + }); + + /* -------------------------------------------------------------------- */ + /* Inherited BaseRepository methods */ + /* -------------------------------------------------------------------- */ + + describe("inherited BaseRepository methods", () => { + it("findById returns a reel by id", async () => { + const reel = await createReel(); + const found = await repo.findById(reel._id); + expect(found).toBeDefined(); + expect(found._id.toString()).toBe(reel._id.toString()); + }); + + it("findOne returns a single reel matching filter", async () => { + await createReel({ description: "Unique reel" }); + const found = await repo.findOne({ description: "Unique reel" }); + expect(found).toBeDefined(); + expect(found.description).toBe("Unique reel"); + }); + + it("findMany returns multiple reels", async () => { + await createBulkReels(3); + const results = await repo.findMany({}); + expect(results).toHaveLength(3); + }); + + it("count returns correct count", async () => { + await createBulkReels(5); + const count = await repo.count({}); + expect(count).toBe(5); + }); + + it("paginate returns paginated results", async () => { + await createBulkReels(10); + const page = await repo.paginate({}, { page: 1, limit: 3 }); + expect(page.data).toHaveLength(3); + expect(page.total).toBe(10); + expect(page.totalPages).toBe(4); + expect(page.hasNextPage).toBe(true); + expect(page.hasPrevPage).toBe(false); + }); + }); +}); diff --git a/mongo/utils/QueryBuilder.js b/mongo/utils/QueryBuilder.js new file mode 100644 index 00000000..a61bdc03 --- /dev/null +++ b/mongo/utils/QueryBuilder.js @@ -0,0 +1,437 @@ +/** + * @module mongo/utils/QueryBuilder + * Fluent query builder utility for Mongoose queries (#180). + * ------------------------------------------------------------------------- + * `QueryBuilder` provides a chainable, fluent interface for constructing + * complex MongoDB queries. It reduces boilerplate and improves code + * readability when building queries with multiple conditions, projections, + * sorting, pagination, and population. + * + * The builder wraps a Mongoose Query object and exposes chainable methods + * that mirror common query operations. Call `exec()` or `lean().exec()` to + * execute the query and retrieve results. + * + * Conventions: + * - Repositories never touch `res`/express - they return data or throw. + * - Every exported method carries complete JSDoc. + * - The builder is immutable-ish: each method returns `this` for chaining, + * but mutates the underlying query. + * + * @example + * import QueryBuilder from "../mongo/utils/QueryBuilder.js"; + * import User from "../models/User.js"; + * + * const users = await new QueryBuilder(User) + * .where({ status: "active", role: "educator" }) + * .select("name email createdAt") + * .sort({ createdAt: -1 }) + * .limit(20) + * .skip(40) + * .populate("courses") + * .lean() + * .exec(); + */ + +/** + * @typedef {Object} WhereCondition + * @description A MongoDB filter object or a key-value pair for equality matching. + */ + +/** + * Fluent query builder for Mongoose models. + * + * Wraps a Mongoose model or query and provides chainable methods for + * constructing complex queries in a readable, declarative style. + */ +export class QueryBuilder { + /** + * Create a new QueryBuilder instance. + * + * @param {import("mongoose").Model|import("mongoose").Query} modelOrQuery + * Either a Mongoose Model (starts a new `find()` query) or an existing + * Query object to wrap and extend. + * @throws {TypeError} If `modelOrQuery` is not a valid Model or Query. + */ + constructor(modelOrQuery) { + if (!modelOrQuery) { + throw new TypeError("QueryBuilder: a Model or Query is required"); + } + + // If it's a Model, start with a find() query + if (typeof modelOrQuery.find === "function" && typeof modelOrQuery.schema !== "undefined") { + /** @type {import("mongoose").Query} */ + this._query = modelOrQuery.find(); + /** @type {import("mongoose").Model} */ + this._model = modelOrQuery; + } else if (typeof modelOrQuery.exec === "function") { + // It's already a Query + this._query = modelOrQuery; + this._model = modelOrQuery.model; + } else { + throw new TypeError("QueryBuilder: expected a Mongoose Model or Query"); + } + + /** @type {boolean} */ + this._isLean = false; + } + + /** + * Add filter conditions to the query. + * + * Accepts either a MongoDB filter object or a field name and value for + * simple equality matching. Multiple calls are merged (AND'd together). + * + * @param {string|Object} fieldOrConditions Field name or filter object. + * @param {*} [value] Value for equality match when first arg is a string. + * @returns {this} The builder instance for chaining. + * + * @example + * builder.where({ status: "active" }); + * builder.where("role", "educator"); + * builder.where({ age: { $gte: 18 } }); + */ + where(fieldOrConditions, value) { + if (typeof fieldOrConditions === "string") { + this._query.where(fieldOrConditions).equals(value); + } else if (fieldOrConditions && typeof fieldOrConditions === "object") { + this._query.where(fieldOrConditions); + } + return this; + } + + /** + * Add an equality condition. + * + * @param {string} field Field name to match. + * @param {*} value Value to match against. + * @returns {this} The builder instance for chaining. + */ + equals(field, value) { + this._query.where(field).equals(value); + return this; + } + + /** + * Add a greater-than condition. + * + * @param {string} field Field name. + * @param {*} value Threshold value. + * @returns {this} The builder instance for chaining. + */ + gt(field, value) { + this._query.where(field).gt(value); + return this; + } + + /** + * Add a greater-than-or-equal condition. + * + * @param {string} field Field name. + * @param {*} value Threshold value. + * @returns {this} The builder instance for chaining. + */ + gte(field, value) { + this._query.where(field).gte(value); + return this; + } + + /** + * Add a less-than condition. + * + * @param {string} field Field name. + * @param {*} value Threshold value. + * @returns {this} The builder instance for chaining. + */ + lt(field, value) { + this._query.where(field).lt(value); + return this; + } + + /** + * Add a less-than-or-equal condition. + * + * @param {string} field Field name. + * @param {*} value Threshold value. + * @returns {this} The builder instance for chaining. + */ + lte(field, value) { + this._query.where(field).lte(value); + return this; + } + + /** + * Add an $in condition (value in array). + * + * @param {string} field Field name. + * @param {Array} values Array of possible values. + * @returns {this} The builder instance for chaining. + */ + in(field, values) { + this._query.where(field).in(values); + return this; + } + + /** + * Add a $nin condition (value not in array). + * + * @param {string} field Field name. + * @param {Array} values Array of excluded values. + * @returns {this} The builder instance for chaining. + */ + nin(field, values) { + this._query.where(field).nin(values); + return this; + } + + /** + * Add a regex match condition. + * + * @param {string} field Field name. + * @param {RegExp|string} pattern Regex pattern or string. + * @param {string} [flags] Regex flags (when pattern is a string). + * @returns {this} The builder instance for chaining. + */ + regex(field, pattern, flags) { + const rx = pattern instanceof RegExp ? pattern : new RegExp(pattern, flags); + this._query.where(field).regex(rx); + return this; + } + + /** + * Add an exists condition. + * + * @param {string} field Field name. + * @param {boolean} [exists=true] Whether the field should exist. + * @returns {this} The builder instance for chaining. + */ + exists(field, exists = true) { + this._query.where(field).exists(exists); + return this; + } + + /** + * Set field projection (select which fields to include/exclude). + * + * @param {string|string[]|Object} fields Field specification. + * - String: space-separated field names (prefix with `-` to exclude). + * - Array: field names to include. + * - Object: `{ field: 1 }` for include, `{ field: 0 }` for exclude. + * @returns {this} The builder instance for chaining. + * + * @example + * builder.select("name email -password"); + * builder.select(["name", "email"]); + * builder.select({ name: 1, email: 1 }); + */ + select(fields) { + this._query.select(fields); + return this; + } + + /** + * Set sort order. + * + * @param {string|Object} spec Sort specification. + * - String: space-separated fields (prefix with `-` for descending). + * - Object: `{ field: 1 }` for ascending, `{ field: -1 }` for descending. + * @returns {this} The builder instance for chaining. + * + * @example + * builder.sort("-createdAt name"); + * builder.sort({ createdAt: -1, name: 1 }); + */ + sort(spec) { + this._query.sort(spec); + return this; + } + + /** + * Limit the number of results. + * + * @param {number} count Maximum number of documents to return. + * @returns {this} The builder instance for chaining. + */ + limit(count) { + if (typeof count === "number" && count >= 0) { + this._query.limit(count); + } + return this; + } + + /** + * Skip a number of documents (offset pagination). + * + * @param {number} count Number of documents to skip. + * @returns {this} The builder instance for chaining. + */ + skip(count) { + if (typeof count === "number" && count >= 0) { + this._query.skip(count); + } + return this; + } + + /** + * Apply page-based pagination. + * + * Convenience method that computes `skip` from a 1-based page number. + * + * @param {number} page 1-based page number. + * @param {number} perPage Number of documents per page. + * @returns {this} The builder instance for chaining. + * + * @example + * builder.paginate(3, 20); // Page 3, 20 items per page + */ + paginate(page, perPage) { + const pageNum = Math.max(1, Math.floor(page) || 1); + const limit = Math.max(1, Math.floor(perPage) || 20); + this._query.skip((pageNum - 1) * limit).limit(limit); + return this; + } + + /** + * Populate referenced documents. + * + * @param {string|Object|Array} paths Path(s) to populate. + * @returns {this} The builder instance for chaining. + * + * @example + * builder.populate("author"); + * builder.populate({ path: "author", select: "name email" }); + * builder.populate(["author", "category"]); + */ + populate(paths) { + if (!paths) return this; + + const pathList = Array.isArray(paths) ? paths : [paths]; + for (const path of pathList) { + this._query.populate(path); + } + return this; + } + + /** + * Return plain JavaScript objects instead of Mongoose documents. + * + * @param {boolean} [enabled=true] Whether to enable lean mode. + * @returns {this} The builder instance for chaining. + */ + lean(enabled = true) { + this._isLean = enabled; + this._query.lean(enabled); + return this; + } + + /** + * Execute the query and return results. + * + * @returns {Promise} The query results. + */ + async exec() { + return this._query.exec(); + } + + /** + * Execute and return a single document. + * + * Modifies the underlying query to `findOne()`. + * + * @returns {Promise} The first matching document or null. + */ + async one() { + // Clone the conditions and apply to a findOne query + const conditions = this._query.getFilter(); + const options = this._query.getOptions(); + + let query = this._model.findOne(conditions); + + if (options.sort) query = query.sort(options.sort); + if (options.projection) query = query.select(options.projection); + if (this._isLean) query = query.lean(); + + // Apply populations + const populatedPaths = this._query.getPopulatedPaths(); + for (const path of populatedPaths || []) { + query = query.populate(path); + } + + return query.exec(); + } + + /** + * Count documents matching the current filter. + * + * @returns {Promise} The count of matching documents. + */ + async count() { + const conditions = this._query.getFilter(); + return this._model.countDocuments(conditions).exec(); + } + + /** + * Check if any documents match the current filter. + * + * @returns {Promise} True if at least one document matches. + */ + async exists() { + const conditions = this._query.getFilter(); + const doc = await this._model.findOne(conditions).select("_id").lean().exec(); + return doc !== null; + } + + /** + * Get distinct values for a field. + * + * @param {string} field Field name. + * @returns {Promise} Array of distinct values. + */ + async distinct(field) { + const conditions = this._query.getFilter(); + return this._model.distinct(field, conditions).exec(); + } + + /** + * Get the underlying Mongoose Query object. + * + * Useful for advanced operations not covered by the builder. + * + * @returns {import("mongoose").Query} The wrapped query. + */ + getQuery() { + return this._query; + } + + /** + * Clone the builder for branching queries. + * + * @returns {QueryBuilder} A new builder with the same state. + */ + clone() { + const cloned = new QueryBuilder(this._query.clone()); + cloned._isLean = this._isLean; + return cloned; + } +} + +/** + * Factory function to create a QueryBuilder from a model. + * + * @param {import("mongoose").Model} model The Mongoose model. + * @returns {QueryBuilder} A new QueryBuilder instance. + * + * @example + * import { query } from "../mongo/utils/QueryBuilder.js"; + * import User from "../models/User.js"; + * + * const users = await query(User) + * .where({ status: "active" }) + * .sort("-createdAt") + * .limit(10) + * .exec(); + */ +export function query(model) { + return new QueryBuilder(model); +} + +export default QueryBuilder; diff --git a/mongo/utils/__tests__/QueryBuilder.test.js b/mongo/utils/__tests__/QueryBuilder.test.js new file mode 100644 index 00000000..f7322ba3 --- /dev/null +++ b/mongo/utils/__tests__/QueryBuilder.test.js @@ -0,0 +1,320 @@ +/** + * @jest-environment node + * + * Tests for the QueryBuilder utility (#180). + */ + +import QueryBuilder, { query } from "../QueryBuilder.js"; + +// Mock Mongoose Model and Query +const createMockQuery = () => { + const chainable = { + where: jest.fn().mockReturnThis(), + equals: jest.fn().mockReturnThis(), + gt: jest.fn().mockReturnThis(), + gte: jest.fn().mockReturnThis(), + lt: jest.fn().mockReturnThis(), + lte: jest.fn().mockReturnThis(), + in: jest.fn().mockReturnThis(), + nin: jest.fn().mockReturnThis(), + regex: jest.fn().mockReturnThis(), + exists: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + sort: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + populate: jest.fn().mockReturnThis(), + lean: jest.fn().mockReturnThis(), + exec: jest.fn().mockResolvedValue([{ _id: "1", name: "Test" }]), + getFilter: jest.fn().mockReturnValue({}), + getOptions: jest.fn().mockReturnValue({}), + getPopulatedPaths: jest.fn().mockReturnValue([]), + clone: jest.fn(), + model: null, + }; + chainable.clone.mockReturnValue({ ...chainable, clone: jest.fn() }); + return chainable; +}; + +const createMockModel = (mockQuery) => ({ + find: jest.fn().mockReturnValue(mockQuery), + findOne: jest.fn().mockReturnValue(mockQuery), + countDocuments: jest.fn().mockReturnValue({ exec: jest.fn().mockResolvedValue(42) }), + distinct: jest.fn().mockReturnValue({ exec: jest.fn().mockResolvedValue(["a", "b"]) }), + schema: {}, +}); + +describe("QueryBuilder", () => { + let mockQuery; + let mockModel; + + beforeEach(() => { + mockQuery = createMockQuery(); + mockModel = createMockModel(mockQuery); + mockQuery.model = mockModel; + }); + + describe("constructor", () => { + it("accepts a Mongoose Model", () => { + const builder = new QueryBuilder(mockModel); + expect(mockModel.find).toHaveBeenCalled(); + }); + + it("accepts an existing Query", () => { + const builder = new QueryBuilder(mockQuery); + expect(builder.getQuery()).toBe(mockQuery); + }); + + it("throws on invalid input", () => { + expect(() => new QueryBuilder(null)).toThrow(TypeError); + expect(() => new QueryBuilder({})).toThrow(TypeError); + }); + }); + + describe("where()", () => { + it("accepts object conditions", () => { + const builder = new QueryBuilder(mockModel); + builder.where({ status: "active" }); + expect(mockQuery.where).toHaveBeenCalledWith({ status: "active" }); + }); + + it("accepts field and value", () => { + const builder = new QueryBuilder(mockModel); + builder.where("role", "educator"); + expect(mockQuery.where).toHaveBeenCalledWith("role"); + expect(mockQuery.equals).toHaveBeenCalledWith("educator"); + }); + + it("returns this for chaining", () => { + const builder = new QueryBuilder(mockModel); + expect(builder.where({ a: 1 })).toBe(builder); + }); + }); + + describe("comparison methods", () => { + it("gt() adds greater-than condition", () => { + const builder = new QueryBuilder(mockModel); + builder.gt("age", 18); + expect(mockQuery.where).toHaveBeenCalledWith("age"); + expect(mockQuery.gt).toHaveBeenCalledWith(18); + }); + + it("gte() adds greater-than-or-equal condition", () => { + const builder = new QueryBuilder(mockModel); + builder.gte("age", 21); + expect(mockQuery.gte).toHaveBeenCalledWith(21); + }); + + it("lt() adds less-than condition", () => { + const builder = new QueryBuilder(mockModel); + builder.lt("price", 100); + expect(mockQuery.lt).toHaveBeenCalledWith(100); + }); + + it("lte() adds less-than-or-equal condition", () => { + const builder = new QueryBuilder(mockModel); + builder.lte("price", 50); + expect(mockQuery.lte).toHaveBeenCalledWith(50); + }); + }); + + describe("in() and nin()", () => { + it("in() adds $in condition", () => { + const builder = new QueryBuilder(mockModel); + builder.in("status", ["active", "pending"]); + expect(mockQuery.in).toHaveBeenCalledWith(["active", "pending"]); + }); + + it("nin() adds $nin condition", () => { + const builder = new QueryBuilder(mockModel); + builder.nin("role", ["banned", "suspended"]); + expect(mockQuery.nin).toHaveBeenCalledWith(["banned", "suspended"]); + }); + }); + + describe("regex()", () => { + it("accepts RegExp", () => { + const builder = new QueryBuilder(mockModel); + const rx = /test/i; + builder.regex("name", rx); + expect(mockQuery.regex).toHaveBeenCalledWith(rx); + }); + + it("accepts string pattern with flags", () => { + const builder = new QueryBuilder(mockModel); + builder.regex("name", "test", "i"); + expect(mockQuery.regex).toHaveBeenCalledWith(expect.any(RegExp)); + }); + }); + + describe("select()", () => { + it("accepts string projection", () => { + const builder = new QueryBuilder(mockModel); + builder.select("name email -password"); + expect(mockQuery.select).toHaveBeenCalledWith("name email -password"); + }); + + it("accepts object projection", () => { + const builder = new QueryBuilder(mockModel); + builder.select({ name: 1, email: 1 }); + expect(mockQuery.select).toHaveBeenCalledWith({ name: 1, email: 1 }); + }); + }); + + describe("sort()", () => { + it("accepts string sort", () => { + const builder = new QueryBuilder(mockModel); + builder.sort("-createdAt name"); + expect(mockQuery.sort).toHaveBeenCalledWith("-createdAt name"); + }); + + it("accepts object sort", () => { + const builder = new QueryBuilder(mockModel); + builder.sort({ createdAt: -1 }); + expect(mockQuery.sort).toHaveBeenCalledWith({ createdAt: -1 }); + }); + }); + + describe("limit() and skip()", () => { + it("limit() sets result limit", () => { + const builder = new QueryBuilder(mockModel); + builder.limit(20); + expect(mockQuery.limit).toHaveBeenCalledWith(20); + }); + + it("skip() sets offset", () => { + const builder = new QueryBuilder(mockModel); + builder.skip(40); + expect(mockQuery.skip).toHaveBeenCalledWith(40); + }); + + it("ignores negative values", () => { + const builder = new QueryBuilder(mockModel); + builder.limit(-5); + builder.skip(-10); + expect(mockQuery.limit).not.toHaveBeenCalled(); + expect(mockQuery.skip).not.toHaveBeenCalled(); + }); + }); + + describe("paginate()", () => { + it("computes skip and limit from page number", () => { + const builder = new QueryBuilder(mockModel); + builder.paginate(3, 20); + expect(mockQuery.skip).toHaveBeenCalledWith(40); // (3-1) * 20 + expect(mockQuery.limit).toHaveBeenCalledWith(20); + }); + + it("handles page 1", () => { + const builder = new QueryBuilder(mockModel); + builder.paginate(1, 10); + expect(mockQuery.skip).toHaveBeenCalledWith(0); + expect(mockQuery.limit).toHaveBeenCalledWith(10); + }); + }); + + describe("populate()", () => { + it("accepts string path", () => { + const builder = new QueryBuilder(mockModel); + builder.populate("author"); + expect(mockQuery.populate).toHaveBeenCalledWith("author"); + }); + + it("accepts array of paths", () => { + const builder = new QueryBuilder(mockModel); + builder.populate(["author", "category"]); + expect(mockQuery.populate).toHaveBeenCalledTimes(2); + }); + + it("accepts object config", () => { + const builder = new QueryBuilder(mockModel); + const config = { path: "author", select: "name" }; + builder.populate(config); + expect(mockQuery.populate).toHaveBeenCalledWith(config); + }); + }); + + describe("lean()", () => { + it("enables lean mode by default", () => { + const builder = new QueryBuilder(mockModel); + builder.lean(); + expect(mockQuery.lean).toHaveBeenCalledWith(true); + }); + + it("can disable lean mode", () => { + const builder = new QueryBuilder(mockModel); + builder.lean(false); + expect(mockQuery.lean).toHaveBeenCalledWith(false); + }); + }); + + describe("exec()", () => { + it("executes the query", async () => { + const builder = new QueryBuilder(mockModel); + const result = await builder.exec(); + expect(mockQuery.exec).toHaveBeenCalled(); + expect(result).toEqual([{ _id: "1", name: "Test" }]); + }); + }); + + describe("count()", () => { + it("returns document count", async () => { + const builder = new QueryBuilder(mockModel); + const count = await builder.count(); + expect(mockModel.countDocuments).toHaveBeenCalled(); + expect(count).toBe(42); + }); + }); + + describe("distinct()", () => { + it("returns distinct values", async () => { + const builder = new QueryBuilder(mockModel); + const values = await builder.distinct("category"); + expect(mockModel.distinct).toHaveBeenCalledWith("category", {}); + expect(values).toEqual(["a", "b"]); + }); + }); + + describe("method chaining", () => { + it("supports fluent interface", async () => { + const builder = new QueryBuilder(mockModel); + + const result = await builder + .where({ status: "active" }) + .where("role", "educator") + .gt("age", 18) + .select("name email") + .sort("-createdAt") + .limit(20) + .skip(40) + .populate("courses") + .lean() + .exec(); + + expect(mockQuery.where).toHaveBeenCalled(); + expect(mockQuery.select).toHaveBeenCalled(); + expect(mockQuery.sort).toHaveBeenCalled(); + expect(mockQuery.limit).toHaveBeenCalled(); + expect(mockQuery.skip).toHaveBeenCalled(); + expect(mockQuery.populate).toHaveBeenCalled(); + expect(mockQuery.lean).toHaveBeenCalled(); + expect(mockQuery.exec).toHaveBeenCalled(); + }); + }); + + describe("clone()", () => { + it("creates a copy of the builder", () => { + const builder = new QueryBuilder(mockModel); + const cloned = builder.clone(); + expect(cloned).not.toBe(builder); + expect(cloned).toBeInstanceOf(QueryBuilder); + }); + }); + + describe("query() factory function", () => { + it("creates a QueryBuilder from a model", () => { + const builder = query(mockModel); + expect(builder).toBeInstanceOf(QueryBuilder); + }); + }); +}); diff --git a/mongo/utils/__tests__/aggregation.test.js b/mongo/utils/__tests__/aggregation.test.js new file mode 100644 index 00000000..cc2ee388 --- /dev/null +++ b/mongo/utils/__tests__/aggregation.test.js @@ -0,0 +1,410 @@ +import mongoose from "mongoose"; +import { MongoMemoryServer } from "mongodb-memory-server"; +import aggregation, { + groupBy, + sumBy, + countBy, + averageBy, + matchStage, + sortStage, + limitStage, + skipStage, + paginate, + dateGroup, + timeSeries, + buildPipeline, +} from "../aggregation.js"; + +let mongoServer; + +// Test schema: orders with an amount, a category and a creation date. +const testSchema = new mongoose.Schema({ + amount: { type: Number, required: true }, + category: { type: String, required: true }, + createdAt: { type: Date, required: true }, +}); + +let TestModel; + +// Deterministic seed data. Dates chosen so daily/weekly/monthly buckets are +// unambiguous (see per-test comments for the expected ISO week numbers). +const SEED = [ + { amount: 100, category: "A", createdAt: new Date("2026-01-01T10:00:00Z") }, // Thu, 2026-W01, Jan + { amount: 200, category: "A", createdAt: new Date("2026-01-02T10:00:00Z") }, // Fri, 2026-W01, Jan + { amount: 50, category: "B", createdAt: new Date("2026-01-08T10:00:00Z") }, // Thu, 2026-W02, Jan + { amount: 150, category: "B", createdAt: new Date("2026-02-15T10:00:00Z") }, // Sun, 2026-W07, Feb + { amount: 300, category: "A", createdAt: new Date("2026-02-20T10:00:00Z") }, // Fri, 2026-W08, Feb +]; + +/** Turn `$group` output into a `{ [_id]: doc }` map for order-independent asserts. */ +function byId(rows) { + return rows.reduce((acc, row) => { + acc[row._id] = row; + return acc; + }, {}); +} + +beforeAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + TestModel = mongoose.model("TestAggregation", testSchema); +}, 60000); + +afterAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (mongoServer) { + await mongoServer.stop(); + } +}); + +beforeEach(async () => { + await TestModel.deleteMany({}); + await TestModel.create(SEED); +}); + +describe("groupBy", () => { + it("groups by a single field with custom accumulators", async () => { + const stage = groupBy("category", { total: { $sum: "$amount" }, n: { $sum: 1 } }); + expect(stage).toEqual({ + $group: { _id: "$category", total: { $sum: "$amount" }, n: { $sum: 1 } }, + }); + + const rows = byId(await TestModel.aggregate([stage])); + expect(rows.A).toMatchObject({ total: 600, n: 3 }); + expect(rows.B).toMatchObject({ total: 200, n: 2 }); + }); + + it("groups over the whole collection when field is null", async () => { + const stage = groupBy(null, { total: { $sum: "$amount" } }); + expect(stage).toEqual({ $group: { _id: null, total: { $sum: "$amount" } } }); + + const rows = await TestModel.aggregate([stage]); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ _id: null, total: 800 }); + }); + + it("accepts a field already prefixed with $", () => { + expect(groupBy("$category", { n: { $sum: 1 } })).toEqual({ + $group: { _id: "$category", n: { $sum: 1 } }, + }); + }); + + it("throws when accumulators is missing or empty", () => { + expect(() => groupBy("category")).toThrow(TypeError); + expect(() => groupBy("category", {})).toThrow(TypeError); + }); +}); + +describe("sumBy", () => { + it("sums grouped by a field", async () => { + const stage = sumBy("amount", "category"); + expect(stage).toEqual({ $group: { _id: "$category", total: { $sum: "$amount" } } }); + + const rows = byId(await TestModel.aggregate([stage])); + expect(rows.A.total).toBe(600); + expect(rows.B.total).toBe(200); + }); + + it("sums the whole collection when groupField is null", async () => { + const rows = await TestModel.aggregate([sumBy("amount")]); + expect(rows).toHaveLength(1); + expect(rows[0].total).toBe(800); + }); + + it("supports a custom output name", () => { + expect(sumBy("amount", null, "revenue")).toEqual({ + $group: { _id: null, revenue: { $sum: "$amount" } }, + }); + }); + + it("throws on an invalid field", () => { + expect(() => sumBy("")).toThrow(TypeError); + expect(() => sumBy(null)).toThrow(TypeError); + }); +}); + +describe("countBy", () => { + it("counts documents per distinct value", async () => { + const stage = countBy("category"); + expect(stage).toEqual({ $group: { _id: "$category", count: { $sum: 1 } } }); + + const rows = byId(await TestModel.aggregate([stage])); + expect(rows.A.count).toBe(3); + expect(rows.B.count).toBe(2); + }); + + it("supports a custom output name", () => { + expect(countBy("category", "occurrences")).toEqual({ + $group: { _id: "$category", occurrences: { $sum: 1 } }, + }); + }); + + it("throws on an invalid field", () => { + expect(() => countBy("")).toThrow(TypeError); + }); +}); + +describe("averageBy", () => { + it("averages grouped by a field", async () => { + const stage = averageBy("amount", "category"); + expect(stage).toEqual({ $group: { _id: "$category", average: { $avg: "$amount" } } }); + + const rows = byId(await TestModel.aggregate([stage])); + expect(rows.A.average).toBe(200); // (100+200+300)/3 + expect(rows.B.average).toBe(100); // (50+150)/2 + }); + + it("averages the whole collection when groupField is null", async () => { + const rows = await TestModel.aggregate([averageBy("amount")]); + expect(rows).toHaveLength(1); + expect(rows[0].average).toBe(160); // 800/5 + }); + + it("throws on an invalid field", () => { + expect(() => averageBy(undefined)).toThrow(TypeError); + }); +}); + +describe("thin stage builders", () => { + it("matchStage wraps a filter", () => { + expect(matchStage({ category: "A" })).toEqual({ $match: { category: "A" } }); + }); + + it("matchStage throws on a non-object", () => { + expect(() => matchStage(null)).toThrow(TypeError); + expect(() => matchStage([])).toThrow(TypeError); + }); + + it("sortStage wraps a spec", () => { + expect(sortStage({ total: -1 })).toEqual({ $sort: { total: -1 } }); + }); + + it("sortStage throws on an empty spec", () => { + expect(() => sortStage({})).toThrow(TypeError); + }); + + it("limitStage validates a positive integer", () => { + expect(limitStage(5)).toEqual({ $limit: 5 }); + expect(() => limitStage(0)).toThrow(TypeError); + expect(() => limitStage(-1)).toThrow(TypeError); + expect(() => limitStage(2.5)).toThrow(TypeError); + }); + + it("skipStage validates a non-negative integer", () => { + expect(skipStage(0)).toEqual({ $skip: 0 }); + expect(skipStage(10)).toEqual({ $skip: 10 }); + expect(() => skipStage(-1)).toThrow(TypeError); + }); + + it("paginate builds a skip/limit pair", () => { + expect(paginate(1, 10)).toEqual([{ $skip: 0 }, { $limit: 10 }]); + expect(paginate(3, 20)).toEqual([{ $skip: 40 }, { $limit: 20 }]); + }); + + it("paginate throws on a bad page", () => { + expect(() => paginate(0, 10)).toThrow(TypeError); + }); +}); + +describe("dateGroup", () => { + it("builds the daily bucket key", () => { + expect(dateGroup("createdAt", "daily")).toEqual({ + $dateToString: { format: "%Y-%m-%d", date: "$createdAt", timezone: "UTC" }, + }); + }); + + it("builds the weekly bucket key", () => { + expect(dateGroup("createdAt", "weekly")).toEqual({ + $dateToString: { format: "%G-W%V", date: "$createdAt", timezone: "UTC" }, + }); + }); + + it("builds the monthly bucket key", () => { + expect(dateGroup("createdAt", "monthly")).toEqual({ + $dateToString: { format: "%Y-%m", date: "$createdAt", timezone: "UTC" }, + }); + }); + + it("passes through a custom timezone", () => { + expect(dateGroup("createdAt", "daily", "America/New_York")).toEqual({ + $dateToString: { format: "%Y-%m-%d", date: "$createdAt", timezone: "America/New_York" }, + }); + }); + + it("throws on an unsupported granularity", () => { + expect(() => dateGroup("createdAt", "yearly")).toThrow(TypeError); + expect(() => dateGroup("createdAt")).toThrow(TypeError); + }); + + it("throws on an invalid field or timezone", () => { + expect(() => dateGroup("", "daily")).toThrow(TypeError); + expect(() => dateGroup("createdAt", "daily", "")).toThrow(TypeError); + }); +}); + +describe("timeSeries", () => { + it("buckets daily and sorts ascending", async () => { + const pipeline = timeSeries("createdAt", { granularity: "daily", valueField: "amount", op: "sum" }); + const rows = await TestModel.aggregate(pipeline); + + expect(rows.map((r) => r._id)).toEqual([ + "2026-01-01", + "2026-01-02", + "2026-01-08", + "2026-02-15", + "2026-02-20", + ]); + expect(rows.map((r) => r.value)).toEqual([100, 200, 50, 150, 300]); + }); + + it("buckets weekly (ISO week) and sums", async () => { + const pipeline = timeSeries("createdAt", { granularity: "weekly", valueField: "amount", op: "sum" }); + const rows = byId(await TestModel.aggregate(pipeline)); + + expect(rows["2026-W01"].value).toBe(300); // 100 + 200 + expect(rows["2026-W02"].value).toBe(50); + expect(rows["2026-W07"].value).toBe(150); + expect(rows["2026-W08"].value).toBe(300); + }); + + it("buckets monthly and sums", async () => { + const pipeline = timeSeries("createdAt", { granularity: "monthly", valueField: "amount", op: "sum" }); + const rows = await TestModel.aggregate(pipeline); + + expect(rows.map((r) => r._id)).toEqual(["2026-01", "2026-02"]); // sorted ascending + expect(byId(rows)["2026-01"].value).toBe(350); // 100 + 200 + 50 + expect(byId(rows)["2026-02"].value).toBe(450); // 150 + 300 + }); + + it("counts documents per bucket with op=count", async () => { + const pipeline = timeSeries("createdAt", { granularity: "monthly", op: "count" }); + const rows = byId(await TestModel.aggregate(pipeline)); + + expect(rows["2026-01"].value).toBe(3); + expect(rows["2026-02"].value).toBe(2); + }); + + it("averages per bucket with op=avg", async () => { + const pipeline = timeSeries("createdAt", { granularity: "monthly", valueField: "amount", op: "avg" }); + const rows = byId(await TestModel.aggregate(pipeline)); + + expect(rows["2026-01"].value).toBeCloseTo(350 / 3); + expect(rows["2026-02"].value).toBe(225); + }); + + it("honours the timezone when bucketing across a day boundary", async () => { + await TestModel.deleteMany({}); + // 02:00 UTC on Mar 1 is still Feb 28 in New York (UTC-5, pre-DST). + await TestModel.create({ amount: 10, category: "C", createdAt: new Date("2026-03-01T02:00:00Z") }); + + const utc = await TestModel.aggregate( + timeSeries("createdAt", { granularity: "daily", valueField: "amount", op: "sum" }) + ); + expect(utc[0]._id).toBe("2026-03-01"); + + const ny = await TestModel.aggregate( + timeSeries("createdAt", { granularity: "daily", valueField: "amount", op: "sum", timezone: "America/New_York" }) + ); + expect(ny[0]._id).toBe("2026-02-28"); + }); + + it("throws on a missing valueField for a non-count op", () => { + expect(() => timeSeries("createdAt", { granularity: "daily", op: "sum" })).toThrow(TypeError); + }); + + it("throws on an unsupported op", () => { + expect(() => + timeSeries("createdAt", { granularity: "daily", valueField: "amount", op: "median" }) + ).toThrow(TypeError); + }); + + it("throws on an unsupported granularity", () => { + expect(() => + timeSeries("createdAt", { granularity: "hourly", valueField: "amount" }) + ).toThrow(TypeError); + }); +}); + +describe("buildPipeline", () => { + it("composes stages in canonical order", () => { + const pipeline = buildPipeline({ + match: { category: "A" }, + group: sumBy("amount", "category"), + sort: { total: -1 }, + skip: 0, + limit: 5, + }); + + expect(pipeline).toEqual([ + { $match: { category: "A" } }, + { $group: { _id: "$category", total: { $sum: "$amount" } } }, + { $sort: { total: -1 } }, + { $skip: 0 }, + { $limit: 5 }, + ]); + }); + + it("omits absent and empty parts", () => { + expect(buildPipeline({})).toEqual([]); + expect(buildPipeline({ match: {}, sort: {} })).toEqual([]); + expect(buildPipeline({ limit: 3 })).toEqual([{ $limit: 3 }]); + }); + + it("accepts a bare group body and wraps it in $group", () => { + const pipeline = buildPipeline({ group: { _id: "$category", total: { $sum: "$amount" } } }); + expect(pipeline).toEqual([{ $group: { _id: "$category", total: { $sum: "$amount" } } }]); + }); + + it("inlines an array of group stages", () => { + const pipeline = buildPipeline({ + group: timeSeries("createdAt", { granularity: "monthly", op: "count" }), + }); + expect(pipeline).toEqual([ + { $group: { _id: dateGroup("createdAt", "monthly"), value: { $sum: 1 } } }, + { $sort: { _id: 1 } }, + ]); + }); + + it("runs end-to-end against the model", async () => { + const pipeline = buildPipeline({ + group: sumBy("amount", "category"), + sort: { total: -1 }, + limit: 1, + }); + const rows = await TestModel.aggregate(pipeline); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ _id: "A", total: 600 }); // highest total first + }); + + it("throws on a malformed part", () => { + expect(() => buildPipeline({ match: [] })).toThrow(TypeError); + expect(() => buildPipeline({ sort: 5 })).toThrow(TypeError); + expect(() => buildPipeline({ limit: -1 })).toThrow(TypeError); + }); +}); + +describe("aggregation (default export)", () => { + it("exposes all named helpers", () => { + for (const name of [ + "groupBy", + "sumBy", + "countBy", + "averageBy", + "matchStage", + "sortStage", + "limitStage", + "skipStage", + "paginate", + "dateGroup", + "timeSeries", + "buildPipeline", + ]) { + expect(typeof aggregation[name]).toBe("function"); + } + }); +}); diff --git a/mongo/utils/__tests__/cursorPagination.test.js b/mongo/utils/__tests__/cursorPagination.test.js new file mode 100644 index 00000000..a830255f --- /dev/null +++ b/mongo/utils/__tests__/cursorPagination.test.js @@ -0,0 +1,274 @@ +import { jest } from "@jest/globals"; +import cursorPagination, { + encodeCursor, + decodeCursor, + buildCursorFromDoc, + buildCursorFilter, + buildSort, + paginate, +} from "../cursorPagination.js"; + +/** + * A tiny in-memory "collection" that mimics the slice of MongoDB behaviour the + * paginate helper relies on: apply a filter, sort, and limit. It is only rich + * enough to exercise the pure helpers — no database or network is involved. + */ +const makeExecutor = (docs) => ({ filter, sort, limit }) => { + const matches = docs.filter((doc) => matchesFilter(doc, filter)); + const [[field, order]] = Object.entries(sort); + const sorted = matches.sort((a, b) => compare(a, b, field, order)); + return sorted.slice(0, limit); +}; + +const compare = (a, b, field, order) => { + if (a[field] < b[field]) return -1 * order; + if (a[field] > b[field]) return 1 * order; + // Deterministic _id tiebreaker, mirroring buildSort. + if (a._id < b._id) return -1 * order; + if (a._id > b._id) return 1 * order; + return 0; +}; + +const matchesFilter = (doc, filter) => { + if (!filter || Object.keys(filter).length === 0) return true; + if (filter.$and) return filter.$and.every((f) => matchesFilter(doc, f)); + if (filter.$or) return filter.$or.some((f) => matchesFilter(doc, f)); + return Object.entries(filter).every(([key, cond]) => { + const value = doc[key]; + if (cond && typeof cond === "object" && !Array.isArray(cond)) { + return Object.entries(cond).every(([op, operand]) => { + if (op === "$gt") return value > operand; + if (op === "$lt") return value < operand; + if (op === "$gte") return value >= operand; + if (op === "$lte") return value <= operand; + return false; + }); + } + return value === cond; + }); +}; + +describe("encodeCursor / decodeCursor", () => { + it("round-trips a simple _id payload", () => { + const payload = { id: "abc123" }; + const cursor = encodeCursor(payload); + expect(typeof cursor).toBe("string"); + expect(decodeCursor(cursor)).toEqual(payload); + }); + + it("round-trips a compound {field, _id} payload", () => { + const payload = { v: "2020-01-01T00:00:00.000Z", id: "xyz" }; + expect(decodeCursor(encodeCursor(payload))).toEqual(payload); + }); + + it("produces URL-safe output with no padding", () => { + const cursor = encodeCursor({ v: "a=b/c+d?", id: "??????" }); + expect(cursor).not.toMatch(/[+/=]/); + }); + + it("rejects an empty or non-string cursor on decode", () => { + expect(() => decodeCursor("")).toThrow(TypeError); + expect(() => decodeCursor(null)).toThrow(TypeError); + }); + + it("rejects a null payload on encode", () => { + expect(() => encodeCursor(null)).toThrow(TypeError); + }); +}); + +describe("buildCursorFromDoc", () => { + it("encodes only the id when sorting by _id", () => { + const cursor = buildCursorFromDoc({ _id: "42", name: "z" }); + expect(decodeCursor(cursor)).toEqual({ id: "42" }); + }); + + it("encodes {field value, _id} when sorting by a non-_id field", () => { + const cursor = buildCursorFromDoc({ _id: "42", createdAt: "2021" }, "createdAt"); + expect(decodeCursor(cursor)).toEqual({ v: "2021", id: "42" }); + }); + + it("normalises Date values to ISO strings", () => { + const when = new Date("2022-06-15T12:00:00.000Z"); + const cursor = buildCursorFromDoc({ _id: "1", createdAt: when }, "createdAt"); + expect(decodeCursor(cursor)).toEqual({ v: when.toISOString(), id: "1" }); + }); + + it("throws when the document has no _id", () => { + expect(() => buildCursorFromDoc({ name: "x" })).toThrow(TypeError); + }); +}); + +describe("buildSort", () => { + it("sorts by _id only when that is the field", () => { + expect(buildSort("_id", 1, "forward")).toEqual({ _id: 1 }); + }); + + it("adds an _id tiebreaker for non-_id fields", () => { + expect(buildSort("createdAt", 1, "forward")).toEqual({ createdAt: 1, _id: 1 }); + }); + + it("reverses the sort for backward pagination", () => { + expect(buildSort("createdAt", 1, "backward")).toEqual({ createdAt: -1, _id: -1 }); + expect(buildSort("_id", -1, "backward")).toEqual({ _id: 1 }); + }); +}); + +describe("buildCursorFilter", () => { + it("uses $gt for ascending forward on _id", () => { + const filter = buildCursorFilter({ cursor: { id: "5" }, sortField: "_id", sortOrder: 1 }); + expect(filter).toEqual({ _id: { $gt: "5" } }); + }); + + it("uses $lt for ascending backward on _id", () => { + const filter = buildCursorFilter({ + cursor: { id: "5" }, + sortField: "_id", + sortOrder: 1, + direction: "backward", + }); + expect(filter).toEqual({ _id: { $lt: "5" } }); + }); + + it("inverts the operator for a descending sort", () => { + const filter = buildCursorFilter({ cursor: { id: "5" }, sortField: "_id", sortOrder: -1 }); + expect(filter).toEqual({ _id: { $lt: "5" } }); + }); + + it("builds a compound $or predicate for a non-_id field", () => { + const filter = buildCursorFilter({ + cursor: { v: "2021", id: "5" }, + sortField: "createdAt", + sortOrder: 1, + direction: "forward", + }); + expect(filter).toEqual({ + $or: [{ createdAt: { $gt: "2021" } }, { createdAt: "2021", _id: { $gt: "5" } }], + }); + }); + + it("throws without a decoded cursor", () => { + expect(() => buildCursorFilter({ cursor: null })).toThrow(TypeError); + }); +}); + +describe("paginate (forward and backward)", () => { + const docs = [ + { _id: "1", name: "a" }, + { _id: "2", name: "b" }, + { _id: "3", name: "c" }, + { _id: "4", name: "d" }, + { _id: "5", name: "e" }, + ]; + + it("validates its arguments", async () => { + await expect(paginate({})).rejects.toThrow(TypeError); + await expect(paginate({ executor: () => [], limit: 0 })).rejects.toThrow(TypeError); + await expect( + paginate({ executor: () => [], after: "a", before: "b" }) + ).rejects.toThrow(TypeError); + }); + + it("returns the first page and reports a next page", async () => { + const result = await paginate({ executor: makeExecutor(docs), limit: 2 }); + expect(result.nodes.map((d) => d._id)).toEqual(["1", "2"]); + expect(result.pageInfo.hasNextPage).toBe(true); + expect(result.pageInfo.hasPreviousPage).toBe(false); + expect(result.edges).toHaveLength(2); + expect(result.pageInfo.startCursor).toBe(result.edges[0].cursor); + expect(result.pageInfo.endCursor).toBe(result.edges[1].cursor); + }); + + it("walks forward across every page with `after`", async () => { + const executor = makeExecutor(docs); + const first = await paginate({ executor, limit: 2 }); + const second = await paginate({ executor, limit: 2, after: first.pageInfo.endCursor }); + expect(second.nodes.map((d) => d._id)).toEqual(["3", "4"]); + expect(second.pageInfo.hasNextPage).toBe(true); + expect(second.pageInfo.hasPreviousPage).toBe(true); + + const third = await paginate({ executor, limit: 2, after: second.pageInfo.endCursor }); + expect(third.nodes.map((d) => d._id)).toEqual(["5"]); + expect(third.pageInfo.hasNextPage).toBe(false); + }); + + it("walks backward with `before`, restoring ascending order", async () => { + const executor = makeExecutor(docs); + // Jump forward to the last page, then page backward from its first cursor. + const first = await paginate({ executor, limit: 2 }); + const second = await paginate({ executor, limit: 2, after: first.pageInfo.endCursor }); + const back = await paginate({ + executor, + limit: 2, + before: second.pageInfo.startCursor, + }); + expect(back.nodes.map((d) => d._id)).toEqual(["1", "2"]); + expect(back.pageInfo.hasPreviousPage).toBe(false); + expect(back.pageInfo.hasNextPage).toBe(true); + }); + + it("supports a descending sort on a non-_id field", async () => { + const executor = makeExecutor(docs); + const result = await paginate({ + executor, + sortField: "name", + sortOrder: -1, + limit: 2, + }); + expect(result.nodes.map((d) => d._id)).toEqual(["5", "4"]); + const next = await paginate({ + executor, + sortField: "name", + sortOrder: -1, + limit: 2, + after: result.pageInfo.endCursor, + }); + expect(next.nodes.map((d) => d._id)).toEqual(["3", "2"]); + }); + + it("intersects the cursor predicate with a base filter", async () => { + const mixed = [ + { _id: "1", kind: "x" }, + { _id: "2", kind: "y" }, + { _id: "3", kind: "x" }, + { _id: "4", kind: "x" }, + ]; + const result = await paginate({ + executor: makeExecutor(mixed), + limit: 10, + baseFilter: { kind: "x" }, + }); + expect(result.nodes.map((d) => d._id)).toEqual(["1", "3", "4"]); + }); + + it("handles an empty result set", async () => { + const result = await paginate({ executor: makeExecutor([]), limit: 5 }); + expect(result.nodes).toEqual([]); + expect(result.pageInfo).toEqual({ + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + endCursor: null, + }); + }); + + it("awaits an async executor", async () => { + const executor = jest.fn(async (query) => makeExecutor(docs)(query)); + const result = await paginate({ executor, limit: 2 }); + expect(executor).toHaveBeenCalledTimes(1); + expect(executor.mock.calls[0][0].limit).toBe(3); // limit + 1 over-fetch + expect(result.nodes.map((d) => d._id)).toEqual(["1", "2"]); + }); +}); + +describe("default export", () => { + it("exposes every named helper", () => { + expect(cursorPagination).toEqual({ + encodeCursor, + decodeCursor, + buildCursorFromDoc, + buildCursorFilter, + buildSort, + paginate, + }); + }); +}); diff --git a/mongo/utils/__tests__/textSearch.test.js b/mongo/utils/__tests__/textSearch.test.js new file mode 100644 index 00000000..24b4841c --- /dev/null +++ b/mongo/utils/__tests__/textSearch.test.js @@ -0,0 +1,280 @@ +import mongoose from "mongoose"; +import { MongoMemoryServer } from "mongodb-memory-server"; +import textSearch, { + buildTextFilter, + buildTextProjection, + buildTextSort, + textSearch as textSearchFn, +} from "../textSearch.js"; + +let mongoServer; + +// Test schema with text index +const testSchema = new mongoose.Schema({ + title: { type: String, required: true }, + description: { type: String, required: true }, + category: String, + price: { type: Number, default: 0 }, + isActive: { type: Boolean, default: true }, +}); + +testSchema.index({ title: "text", description: "text", category: "text" }, { weights: { title: 5 } }); + +let TestModel; + +beforeAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + TestModel = mongoose.model("TestTextSearch", testSchema); + await TestModel.syncIndexes(); +}, 60000); + +afterAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (mongoServer) { + await mongoServer.stop(); + } +}); + +beforeEach(async () => { + await TestModel.deleteMany({}); + await TestModel.create([ + { title: "React Fundamentals", description: "Learn React from scratch", category: "Programming", price: 100 }, + { title: "Advanced React Patterns", description: "Deep dive into React design patterns", category: "Programming", price: 150 }, + { title: "Node.js Basics", description: "Introduction to Node.js and Express", category: "Programming", price: 80 }, + { title: "Cooking 101", description: "Learn how to cook basic meals", category: "Cooking", price: 0 }, + { title: "Advanced Cooking Techniques", description: "Master advanced culinary skills", category: "Cooking", price: 50 }, + ]); +}); + +describe("buildTextFilter", () => { + it("returns empty object when term is empty", () => { + const filter = buildTextFilter(""); + expect(filter).toEqual({}); + }); + + it("returns empty object when term is null/undefined", () => { + expect(buildTextFilter(null)).toEqual({}); + expect(buildTextFilter(undefined)).toEqual({}); + }); + + it("builds a $text filter for a valid term", () => { + const filter = buildTextFilter("react"); + expect(filter).toEqual({ $text: { $search: "react" } }); + }); + + it("trims whitespace from the term", () => { + const filter = buildTextFilter(" react "); + expect(filter).toEqual({ $text: { $search: "react" } }); + }); + + it("combines text filter with additional filters via $and", () => { + const filter = buildTextFilter("react", { price: { $gte: 100 } }); + expect(filter).toEqual({ + $and: [ + { $text: { $search: "react" } }, + { price: { $gte: 100 } }, + ], + }); + }); + + it("returns only filters when term is empty", () => { + const filter = buildTextFilter("", { category: "Programming" }); + expect(filter).toEqual({ category: "Programming" }); + }); + + it("ignores undefined/null/empty filter values", () => { + const filter = buildTextFilter("react", { + category: "Programming", + price: undefined, + isActive: null, + tags: "", + }); + expect(filter).toEqual({ + $and: [ + { $text: { $search: "react" } }, + { category: "Programming" }, + ], + }); + }); +}); + +describe("buildTextProjection", () => { + it("returns only score when no extra fields provided", () => { + const projection = buildTextProjection(); + expect(projection).toEqual({ score: { $meta: "textScore" } }); + }); + + it("merges extra fields with score", () => { + const projection = buildTextProjection({ title: 1, price: 1 }); + expect(projection).toEqual({ + title: 1, + price: 1, + score: { $meta: "textScore" }, + }); + }); + + it("score field is always present", () => { + const projection = buildTextProjection({ _id: 0, title: 1 }); + expect(projection.score).toEqual({ $meta: "textScore" }); + }); +}); + +describe("buildTextSort", () => { + it("returns default relevance sort when no custom sort provided", () => { + expect(buildTextSort()).toEqual({ score: { $meta: "textScore" } }); + expect(buildTextSort(null)).toEqual({ score: { $meta: "textScore" } }); + expect(buildTextSort({})).toEqual({ score: { $meta: "textScore" } }); + }); + + it("returns custom sort when provided", () => { + const sort = { price: 1 }; + expect(buildTextSort(sort)).toEqual(sort); + }); +}); + +describe("textSearch (function)", () => { + it("throws when model is missing", async () => { + await expect(textSearchFn({ term: "react" })).rejects.toThrow(TypeError); + }); + + it("throws when term is missing", async () => { + await expect(textSearchFn({ model: TestModel })).rejects.toThrow(TypeError); + await expect(textSearchFn({ model: TestModel, term: "" })).rejects.toThrow(TypeError); + await expect(textSearchFn({ model: TestModel, term: " " })).rejects.toThrow(TypeError); + }); + + it("returns matching results ordered by text score", async () => { + const results = await textSearchFn({ model: TestModel, term: "react" }); + + expect(results.documents.length).toBeGreaterThan(0); + expect(results.total).toBe(2); + expect(results.page).toBe(1); + expect(results.limit).toBe(10); + expect(results.pages).toBe(1); + + // Results should have score field + results.documents.forEach((doc) => { + expect(doc.score).toBeDefined(); + expect(typeof doc.score).toBe("number"); + }); + + // "React Fundamentals" should rank higher (exact match in title) than + // "Advanced React Patterns" which also has "React" in title but with extra words + const titles = results.documents.map((d) => d.title); + expect(titles).toContain("React Fundamentals"); + expect(titles).toContain("Advanced React Patterns"); + }); + + it("searches across multiple fields", async () => { + const results = await textSearchFn({ model: TestModel, term: "cooking" }); + expect(results.documents.length).toBe(2); + expect(results.total).toBe(2); + }); + + it("combines text search with filters", async () => { + const results = await textSearchFn({ + model: TestModel, + term: "react", + filters: { price: { $gte: 150 } }, + }); + + expect(results.documents.length).toBe(1); + expect(results.documents[0].title).toBe("Advanced React Patterns"); + expect(results.documents[0].price).toBe(150); + }); + + it("supports pagination", async () => { + // Create more documents to test pagination + await TestModel.create([ + { title: "React Testing Guide", description: "Testing React applications comprehensively", category: "Programming", price: 90 }, + { title: "React Native Development", description: "Mobile development with React Native framework", category: "Programming", price: 120 }, + { title: "React Hooks Deep Dive", description: "Understanding React hooks in depth and patterns", category: "Programming", price: 70 }, + { title: "React State Management", description: "Managing state in React applications with Redux", category: "Programming", price: 110 }, + ]); + + const page1 = await textSearchFn({ model: TestModel, term: "react", page: 1, limit: 2 }); + expect(page1.documents.length).toBe(2); + expect(page1.page).toBe(1); + expect(page1.limit).toBe(2); + expect(page1.total).toBeGreaterThanOrEqual(5); + expect(page1.pages).toBeGreaterThanOrEqual(3); + + // Each page has correct count + const allPageIds = new Set(page1.documents.map((d) => d._id.toString())); + expect(allPageIds.size).toBe(2); + + // Verify score is present and valid + page1.documents.forEach((doc) => { + expect(doc.score).toBeDefined(); + expect(typeof doc.score).toBe("number"); + expect(doc.score).toBeGreaterThan(0); + }); + }); + + it("caps limit at 100", async () => { + const results = await textSearchFn({ model: TestModel, term: "react", limit: 200 }); + expect(results.limit).toBe(100); + }); + + it("enforces minimum page of 1", async () => { + const results = await textSearchFn({ model: TestModel, term: "react", page: 0 }); + expect(results.page).toBe(1); + }); + + it("returns empty results for non-matching term", async () => { + const results = await textSearchFn({ model: TestModel, term: "xyznonexistent" }); + expect(results.documents).toEqual([]); + expect(results.total).toBe(0); + expect(results.pages).toBe(0); + }); + + it("returns all documents with lean by default", async () => { + const results = await textSearchFn({ model: TestModel, term: "react", limit: 100 }); + // Lean returns plain objects, not Mongoose documents + results.documents.forEach((doc) => { + expect(doc).not.toHaveProperty("$isNew"); + expect(typeof doc.save).toBe("undefined"); + }); + }); + + it("supports custom projection", async () => { + const results = await textSearchFn({ + model: TestModel, + term: "react", + projection: { title: 1, price: 1 }, + }); + + expect(results.documents.length).toBeGreaterThan(0); + const doc = results.documents[0]; + expect(doc.title).toBeDefined(); + expect(doc.price).toBeDefined(); + expect(doc.score).toBeDefined(); // Score always included + expect(doc.description).toBeUndefined(); // Not projected + }); + + it("supports custom sort", async () => { + const results = await textSearchFn({ + model: TestModel, + term: "react", + sort: { price: 1 }, // Sort by price ascending + }); + + expect(results.documents.length).toBe(2); + expect(results.documents[0].price).toBeLessThanOrEqual(results.documents[1].price); + }); +}); + +describe("textSearch (default export)", () => { + it("has all methods", () => { + expect(typeof textSearch.buildTextFilter).toBe("function"); + expect(typeof textSearch.buildTextProjection).toBe("function"); + expect(typeof textSearch.buildTextSort).toBe("function"); + expect(typeof textSearch.textSearch).toBe("function"); + }); +}); diff --git a/mongo/utils/aggregation.js b/mongo/utils/aggregation.js new file mode 100644 index 00000000..b6009150 --- /dev/null +++ b/mongo/utils/aggregation.js @@ -0,0 +1,445 @@ +/** + * @module mongo/utils/aggregation + * Reusable MongoDB aggregation-pipeline builder helpers. + * ------------------------------------------------------------------------- + * These are pure, dependency-free helpers that a repository (for example a + * `ReportRepository` or a metrics/analytics service) can compose to build + * aggregation pipelines without duplicating stage-construction logic. + * + * Every function here **builds** plain JavaScript objects/arrays describing + * aggregation stages or whole pipelines. Nothing in this module touches + * express, connects to a database, or executes a query — a caller runs the + * result with `Model.aggregate(pipeline)`. This keeps the builders trivially + * unit-testable and reusable across models. + * + * Why a separate utility? + * ----------------------- + * - **Consistency.** Every group/sum/count/average and every date bucket in + * the application is constructed the same way. + * - **Composability.** Thin, single-purpose stage builders (`matchStage`, + * `sortStage`, …) can be assembled by hand or through `buildPipeline`, + * which orders and omits stages for you. + * - **Correctness.** Date bucketing is centralised so daily/weekly/monthly + * grouping (and timezone handling) behaves identically everywhere. + * + * Date-bucketing approach + * ----------------------- + * Date buckets are built with `$dateToString` (not `$dateTrunc`). Each bucket + * key is therefore a **string** whose lexical order matches chronological + * order, so an ascending `$sort` on the bucket key yields a correct time + * series. The formats used are: + * - `daily` → `"%Y-%m-%d"` e.g. `"2026-08-24"` + * - `weekly` → `"%G-W%V"` e.g. `"2026-W34"` (ISO-8601 week + year) + * - `monthly` → `"%Y-%m"` e.g. `"2026-08"` + * A `timezone` (IANA name or fixed offset, default `"UTC"`) selects the local + * calendar used to compute the bucket. + * + * Conventions for anything added under `/mongo`: + * - Builders never call `res`/express — they return data or throw. + * - Every exported function/class carries complete JSDoc. + */ + +/** + * @typedef {("daily"|"weekly"|"monthly")} Granularity + * A supported date-bucketing granularity. + */ + +/** + * @typedef {("sum"|"avg"|"min"|"max"|"count")} TimeSeriesOp + * The aggregation operation applied to each time-series bucket. + */ + +/** + * @typedef {Object} TimeSeriesOptions + * @property {Granularity} granularity Bucket size: `daily`, `weekly` + * or `monthly`. + * @property {string} [valueField] The numeric field to aggregate. + * Required for every `op` except `"count"` (which counts documents). + * @property {TimeSeriesOp} [op="sum"] How to aggregate each bucket. + * @property {string} [timezone="UTC"] IANA timezone (or fixed offset) + * used to compute the calendar bucket. + */ + +/** The `$dateToString` format string for each supported granularity. */ +const GRANULARITY_FORMATS = Object.freeze({ + daily: "%Y-%m-%d", + weekly: "%G-W%V", + monthly: "%Y-%m", +}); + +/** Map of {@link TimeSeriesOp} → MongoDB accumulator operator. */ +const OP_OPERATORS = Object.freeze({ + sum: "$sum", + avg: "$avg", + min: "$min", + max: "$max", +}); + +/** + * Normalise a field name into an aggregation field reference (a `"$field"` + * path). Values that already start with `$` are returned untouched so callers + * may pass either `"amount"` or `"$amount"`. + * + * @param {string} field The field name. + * @returns {string} A field-path reference usable inside aggregation operators. + * @throws {TypeError} If `field` is not a non-empty string. + */ +function fieldRef(field) { + if (typeof field !== "string" || field.trim() === "") { + throw new TypeError("field must be a non-empty string"); + } + const trimmed = field.trim(); + return trimmed.startsWith("$") ? trimmed : `$${trimmed}`; +} + +/** + * Assert a value is a plain object (and not null / an array). + * + * @param {*} value The value to check. + * @param {string} label Used in the thrown error message. + * @returns {Object} The validated object. + * @throws {TypeError} If `value` is not a plain object. + */ +function assertObject(value, label) { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new TypeError(`${label} must be a plain object`); + } + return value; +} + +/** + * Build a `$group` stage with an explicit set of accumulator expressions. + * + * Grouping is done by `field` (a single field reference) or, when `field` is + * `null`/`undefined`, by the whole collection (`_id: null`). + * + * @param {?string} field Field to group by, or `null` for the + * whole collection. + * @param {Object} accumulators Map of output field → accumulator + * expression, e.g. `{ total: { $sum: "$amount" }, n: { $sum: 1 } }`. + * @returns {Object} A `$group` stage. + * @throws {TypeError} If `accumulators` is not a non-empty plain object, or + * `field` is provided but not a string. + * @example + * groupBy("category", { total: { $sum: "$amount" } }); + * // → { $group: { _id: "$category", total: { $sum: "$amount" } } } + * @example + * groupBy(null, { total: { $sum: "$amount" } }); + * // → { $group: { _id: null, total: { $sum: "$amount" } } } + */ +export function groupBy(field, accumulators) { + assertObject(accumulators, "accumulators"); + if (Object.keys(accumulators).length === 0) { + throw new TypeError("accumulators must have at least one entry"); + } + const id = field === null || field === undefined ? null : fieldRef(field); + return { $group: { _id: id, ...accumulators } }; +} + +/** + * Build a `$group` stage that sums `field`, optionally grouped by `groupField`. + * + * @param {string} field The numeric field to sum. + * @param {?string} [groupField=null] Field to group by, or `null` for the + * whole-collection total. + * @param {string} [as="total"] Name of the output sum field. + * @returns {Object} A `$group` stage. + * @throws {TypeError} If `field` is not a non-empty string. + * @example + * sumBy("amount", "category"); + * // → { $group: { _id: "$category", total: { $sum: "$amount" } } } + */ +export function sumBy(field, groupField = null, as = "total") { + const ref = fieldRef(field); + return groupBy(groupField, { [as]: { $sum: ref } }); +} + +/** + * Build a `$group` stage that counts how many documents fall into each distinct + * value of `field`. + * + * @param {string} field The field whose values are counted. + * @param {string} [as="count"] Name of the output count field. + * @returns {Object} A `$group` stage. + * @throws {TypeError} If `field` is not a non-empty string. + * @example + * countBy("category"); + * // → { $group: { _id: "$category", count: { $sum: 1 } } } + */ +export function countBy(field, as = "count") { + const ref = fieldRef(field); + return groupBy(ref, { [as]: { $sum: 1 } }); +} + +/** + * Build a `$group` stage that averages `field`, optionally grouped by + * `groupField`. + * + * @param {string} field The numeric field to average. + * @param {?string} [groupField=null] Field to group by, or `null` for the + * whole-collection average. + * @param {string} [as="average"] Name of the output average field. + * @returns {Object} A `$group` stage. + * @throws {TypeError} If `field` is not a non-empty string. + * @example + * averageBy("amount", "category"); + * // → { $group: { _id: "$category", average: { $avg: "$amount" } } } + */ +export function averageBy(field, groupField = null, as = "average") { + const ref = fieldRef(field); + return groupBy(groupField, { [as]: { $avg: ref } }); +} + +/** + * Build a `$match` stage from a filter object. + * + * @param {Object} filters A MongoDB query document. + * @returns {Object} A `$match` stage. + * @throws {TypeError} If `filters` is not a plain object. + * @example + * matchStage({ status: "paid", amount: { $gte: 10 } }); + * // → { $match: { status: "paid", amount: { $gte: 10 } } } + */ +export function matchStage(filters) { + assertObject(filters, "filters"); + return { $match: filters }; +} + +/** + * Build a `$sort` stage from a sort specification. + * + * @param {Object} spec A sort spec, e.g. `{ createdAt: -1 }`. + * @returns {Object} A `$sort` stage. + * @throws {TypeError} If `spec` is not a non-empty plain object. + * @example + * sortStage({ total: -1 }); + * // → { $sort: { total: -1 } } + */ +export function sortStage(spec) { + assertObject(spec, "spec"); + if (Object.keys(spec).length === 0) { + throw new TypeError("spec must have at least one sort key"); + } + return { $sort: spec }; +} + +/** + * Build a `$limit` stage. + * + * @param {number} n A positive integer row cap. + * @returns {Object} A `$limit` stage. + * @throws {TypeError} If `n` is not a positive integer. + * @example + * limitStage(10); // → { $limit: 10 } + */ +export function limitStage(n) { + if (!Number.isInteger(n) || n <= 0) { + throw new TypeError("limit must be a positive integer"); + } + return { $limit: n }; +} + +/** + * Build a `$skip` stage. + * + * @param {number} n A non-negative integer offset. + * @returns {Object} A `$skip` stage. + * @throws {TypeError} If `n` is not a non-negative integer. + * @example + * skipStage(20); // → { $skip: 20 } + */ +export function skipStage(n) { + if (!Number.isInteger(n) || n < 0) { + throw new TypeError("skip must be a non-negative integer"); + } + return { $skip: n }; +} + +/** + * Build an offset-pagination stage pair (`$skip` then `$limit`) for a 1-based + * page number. + * + * @param {number} [page=1] 1-based page number. + * @param {number} [limit=20] Page size (positive integer). + * @returns {Object[]} A two-stage array: `[ { $skip }, { $limit } ]`. + * @throws {TypeError} If `page` is not a positive integer or `limit` is invalid. + * @example + * paginate(2, 10); // → [ { $skip: 10 }, { $limit: 10 } ] + */ +export function paginate(page = 1, limit = 20) { + if (!Number.isInteger(page) || page <= 0) { + throw new TypeError("page must be a positive integer"); + } + const limitPart = limitStage(limit); + return [skipStage((page - 1) * limit), limitPart]; +} + +/** + * Build the `$group` **key expression** that buckets a date field by the given + * granularity. This returns the value you would place at `_id` inside a + * `$group` stage (not a full stage). + * + * See the module header for the exact `$dateToString` formats and rationale. + * + * @param {string} dateField The date field to bucket. + * @param {Granularity} granularity `daily`, `weekly` or `monthly`. + * @param {string} [timezone="UTC"] IANA timezone (or fixed offset). + * @returns {Object} A `$dateToString` expression usable as a `$group._id`. + * @throws {TypeError} If `dateField` is invalid or `granularity` is unsupported. + * @example + * dateGroup("createdAt", "monthly"); + * // → { $dateToString: { format: "%Y-%m", date: "$createdAt", timezone: "UTC" } } + */ +export function dateGroup(dateField, granularity, timezone = "UTC") { + const ref = fieldRef(dateField); + const format = GRANULARITY_FORMATS[granularity]; + if (!format) { + throw new TypeError( + `granularity must be one of ${Object.keys(GRANULARITY_FORMATS).join(", ")}; got "${granularity}"` + ); + } + if (typeof timezone !== "string" || timezone.trim() === "") { + throw new TypeError("timezone must be a non-empty string"); + } + return { $dateToString: { format, date: ref, timezone } }; +} + +/** + * Build a full time-series pipeline: bucket documents by a date field, apply an + * aggregation to each bucket, then sort the buckets ascending (chronological). + * + * The output documents have the shape `{ _id: , value: }`, + * ordered from earliest bucket to latest. + * + * @param {string} dateField The date field to bucket by. + * @param {TimeSeriesOptions} options Bucketing/aggregation options. + * @returns {Object[]} A ready-to-run aggregation pipeline. + * @throws {TypeError} If inputs are invalid (bad granularity/op, or a missing + * `valueField` for a non-count op). + * @example + * timeSeries("createdAt", { granularity: "daily", valueField: "amount", op: "sum" }); + * // → [ + * // { $group: { _id: { $dateToString: {...} }, value: { $sum: "$amount" } } }, + * // { $sort: { _id: 1 } }, + * // ] + * @example + * timeSeries("createdAt", { granularity: "monthly", op: "count" }); + * // counts documents per month + */ +export function timeSeries(dateField, options) { + assertObject(options, "options"); + const { granularity, valueField, op = "sum", timezone = "UTC" } = options; + + const key = dateGroup(dateField, granularity, timezone); + + let accumulator; + if (op === "count") { + accumulator = { $sum: 1 }; + } else { + const operator = OP_OPERATORS[op]; + if (!operator) { + throw new TypeError( + `op must be one of ${[...Object.keys(OP_OPERATORS), "count"].join(", ")}; got "${op}"` + ); + } + if (typeof valueField !== "string" || valueField.trim() === "") { + throw new TypeError(`op "${op}" requires a valueField`); + } + accumulator = { [operator]: fieldRef(valueField) }; + } + + return [ + { $group: { _id: key, value: accumulator } }, + { $sort: { _id: 1 } }, + ]; +} + +/** + * @typedef {Object} BuildPipelineSpec + * @property {Object} [match] Filter for a leading `$match` stage. + * @property {(Object|Object[])} [group] A `$group` stage (or its inner + * document, e.g. `{ _id, total }`), or an array of stages to inline. + * @property {Object} [sort] Sort spec for a `$sort` stage. + * @property {number} [skip] Non-negative `$skip` offset. + * @property {number} [limit] Positive `$limit` cap. + */ + +/** + * Compose a pipeline from named parts, inserting stages in the canonical order + * `$match → $group → $sort → $skip → $limit` and omitting any part that is + * absent/empty. + * + * The `group` part is flexible: pass a full `{ $group: {...} }` stage (as + * returned by {@link groupBy}/{@link sumBy}/…), the bare inner group document + * `{ _id, ... }`, or an array of stages to inline verbatim. + * + * @param {BuildPipelineSpec} [spec={}] The pipeline parts. + * @returns {Object[]} The composed aggregation pipeline. + * @throws {TypeError} If any provided part is malformed. + * @example + * buildPipeline({ + * match: { status: "paid" }, + * group: sumBy("amount", "category"), + * sort: { total: -1 }, + * limit: 5, + * }); + * // → [ { $match }, { $group }, { $sort }, { $limit } ] + */ +export function buildPipeline({ match, group, sort, skip, limit } = {}) { + const pipeline = []; + + if (match !== undefined && match !== null) { + assertObject(match, "match"); + if (Object.keys(match).length > 0) { + pipeline.push(matchStage(match)); + } + } + + if (group !== undefined && group !== null) { + if (Array.isArray(group)) { + pipeline.push(...group); + } else { + assertObject(group, "group"); + if (Object.keys(group).length > 0) { + // Accept either a full `{ $group: {...} }` stage or a bare group body. + pipeline.push("$group" in group ? group : { $group: group }); + } + } + } + + if (sort !== undefined && sort !== null) { + assertObject(sort, "sort"); + if (Object.keys(sort).length > 0) { + pipeline.push(sortStage(sort)); + } + } + + if (skip !== undefined && skip !== null) { + pipeline.push(skipStage(skip)); + } + + if (limit !== undefined && limit !== null) { + pipeline.push(limitStage(limit)); + } + + return pipeline; +} + +/** + * Default export mirrors the named exports for callers that prefer a namespace + * import: `import aggregation from "../utils/aggregation.js"`. + */ +export default { + groupBy, + sumBy, + countBy, + averageBy, + matchStage, + sortStage, + limitStage, + skipStage, + paginate, + dateGroup, + timeSeries, + buildPipeline, +}; diff --git a/mongo/utils/cursorPagination.js b/mongo/utils/cursorPagination.js new file mode 100644 index 00000000..099ab0e2 --- /dev/null +++ b/mongo/utils/cursorPagination.js @@ -0,0 +1,347 @@ +/** + * @module mongo/utils/cursorPagination + * Cursor-based ("keyset") pagination utilities for MongoDB. + * ------------------------------------------------------------------------- + * These are pure, dependency-free helpers that a repository (for example the + * forthcoming `base.BaseRepository`) can compose to paginate a collection + * without ever leaking `res`/express into the data layer. + * + * Why cursor pagination instead of `skip`/`limit` (offset) pagination? + * ------------------------------------------------------------------- + * - **Performance.** Offset pagination (`.skip(N).limit(M)`) forces the + * server to walk and discard the first `N` documents on every page, so + * cost grows linearly with the page number — page 10 000 scans a million + * rows to return a handful. Cursor pagination instead seeks straight to + * the boundary with an indexed range predicate (`{ field: { $gt: x } }`), + * so a page costs the same whether it is the first or the millionth. + * - **Stability.** With `skip`, inserts/deletes that happen between page + * loads shift every subsequent offset, causing rows to be repeated or + * skipped. A cursor is anchored to a concrete document, so concurrent + * writes never corrupt the traversal. + * + * The trade-off is that cursors only support sequential ("next"/"previous") + * navigation — you cannot jump directly to an arbitrary page number. For + * feeds, infinite scroll and large data sets that is exactly the right shape. + * + * A cursor here encodes a **stable, unique sort key**: either the document + * `_id` on its own, or a `{ , _id }` pair when sorting by a non-unique + * field (the `_id` acts as a deterministic tiebreaker so no two documents + * ever share a cursor). + * + * Conventions for anything added under `/mongo`: + * - Repositories never call `res`/express — they return data or throw. + * - Every exported function/class carries complete JSDoc. + */ + +/** + * @typedef {Object} CursorPayload + * The decoded contents of a cursor. `v` is omitted when paginating by `_id` + * alone (in that case `id` is the only sort key). + * @property {*} id The document `_id`, used as the unique tiebreaker. + * @property {*} [v] The value of the primary sort `field` for the + * document the cursor points at. + */ + +/** + * @typedef {Object} PageInfo + * @property {boolean} hasNextPage Whether more documents exist after this page. + * @property {boolean} hasPreviousPage Whether more documents exist before this page. + * @property {?string} startCursor Cursor for the first edge, or `null` when empty. + * @property {?string} endCursor Cursor for the last edge, or `null` when empty. + */ + +/** + * @typedef {Object} Edge + * @property {*} node A single document returned by the executor. + * @property {string} cursor The opaque cursor pointing at `node`. + */ + +/** + * @typedef {Object} PaginationResult + * @property {Edge[]} edges The page of documents, each paired with its cursor. + * @property {Object[]} nodes Convenience array of just the documents, in page order. + * @property {PageInfo} pageInfo Navigation metadata for building the next/prev query. + */ + +/** + * Base64url-encode an arbitrary JSON-serialisable payload into an opaque, + * URL-safe cursor string. + * + * The output uses the URL-safe base64 alphabet (`-`/`_`) with padding + * stripped, so it can be dropped into a query string without escaping. + * + * @param {CursorPayload} payload The stable sort key to encode. + * @returns {string} An opaque, URL-safe cursor. + * @throws {TypeError} If `payload` cannot be JSON-serialised. + */ +export function encodeCursor(payload) { + if (payload === undefined || payload === null) { + throw new TypeError("encodeCursor: payload is required"); + } + let json; + try { + json = JSON.stringify(payload); + } catch (err) { + throw new TypeError(`encodeCursor: payload is not serialisable: ${err.message}`); + } + return Buffer.from(json, "utf8").toString("base64url"); +} + +/** + * Decode a cursor produced by {@link encodeCursor} back into its payload. + * + * @param {string} cursor The opaque cursor to decode. + * @returns {CursorPayload} The decoded sort key. + * @throws {TypeError} If `cursor` is not a string. + * @throws {Error} If `cursor` is malformed or does not contain valid JSON. + */ +export function decodeCursor(cursor) { + if (typeof cursor !== "string" || cursor.length === 0) { + throw new TypeError("decodeCursor: cursor must be a non-empty string"); + } + let json; + try { + json = Buffer.from(cursor, "base64url").toString("utf8"); + } catch (err) { + throw new Error(`decodeCursor: cursor is not valid base64url: ${err.message}`); + } + try { + return JSON.parse(json); + } catch (err) { + throw new Error(`decodeCursor: cursor does not contain valid JSON: ${err.message}`); + } +} + +/** + * Build the opaque cursor that points at a given document, based on the field + * the query is sorted by. + * + * When `sortField` is `_id` the cursor stores only the id; otherwise it stores + * both the field value and the `_id` tiebreaker. + * + * @param {Object} doc The source document (must expose `_id`). + * @param {string} [sortField="_id"] The primary sort field name. + * @returns {string} The opaque cursor for `doc`. + * @throws {TypeError} If `doc` has no `_id`. + */ +export function buildCursorFromDoc(doc, sortField = "_id") { + if (!doc || doc._id === undefined || doc._id === null) { + throw new TypeError("buildCursorFromDoc: document must have an _id"); + } + const id = normaliseValue(doc._id); + if (sortField === "_id") { + return encodeCursor({ id }); + } + return encodeCursor({ v: normaliseValue(doc[sortField]), id }); +} + +/** + * Build the MongoDB filter fragment that seeks to the documents on one side of + * a cursor, honouring both the sort field/order and the direction of travel. + * + * The returned object is meant to be merged (via `$and` or spread into an + * existing `$and` list) with any base filter the repository already applies. + * + * Semantics, using an ascending sort on `field` with `_id` as the tiebreaker: + * - forward: `{ $or: [ { field: { $gt: v } }, { field: v, _id: { $gt: id } } ] }` + * - backward: `{ $or: [ { field: { $lt: v } }, { field: v, _id: { $lt: id } } ] }` + * For a descending sort the `$gt`/`$lt` operators are inverted. When + * `sortField` is `_id` the filter collapses to a single `{ _id: { : id } }`. + * + * @param {Object} params + * @param {CursorPayload} params.cursor The decoded cursor to seek from. + * @param {string} [params.sortField="_id"] The primary sort field name. + * @param {(1|-1)} [params.sortOrder=1] Ascending (`1`) or descending (`-1`). + * @param {("forward"|"backward")} [params.direction="forward"] Direction of travel. + * @returns {Object} A MongoDB filter fragment. + */ +export function buildCursorFilter({ + cursor, + sortField = "_id", + sortOrder = 1, + direction = "forward", +} = {}) { + if (!cursor || cursor.id === undefined) { + throw new TypeError("buildCursorFilter: a decoded cursor with an id is required"); + } + // Ascending + forward → strictly greater; every flip of order or direction + // toggles the comparison operator. + const ascending = sortOrder === 1; + const forward = direction === "forward"; + const useGreaterThan = ascending === forward; + const op = useGreaterThan ? "$gt" : "$lt"; + + if (sortField === "_id") { + return { _id: { [op]: cursor.id } }; + } + + return { + $or: [ + { [sortField]: { [op]: cursor.v } }, + { [sortField]: cursor.v, _id: { [op]: cursor.id } }, + ], + }; +} + +/** + * Build the `sort` specification handed to MongoDB for a page. + * + * For backward pagination the sort is reversed so the database can return the + * `limit` documents *nearest* to the cursor; {@link paginate} then flips those + * documents back into the caller's requested order. + * + * @param {string} [sortField="_id"] The primary sort field name. + * @param {(1|-1)} [sortOrder=1] Ascending (`1`) or descending (`-1`). + * @param {("forward"|"backward")} [direction="forward"] Direction of travel. + * @returns {Object} A MongoDB sort specification (always `_id`-tiebroken). + */ +export function buildSort(sortField = "_id", sortOrder = 1, direction = "forward") { + const effectiveOrder = direction === "backward" ? -sortOrder : sortOrder; + if (sortField === "_id") { + return { _id: effectiveOrder }; + } + return { [sortField]: effectiveOrder, _id: effectiveOrder }; +} + +/** + * Paginate a collection with a cursor, in either direction. + * + * This helper is storage-agnostic: instead of talking to Mongo directly it + * calls the injected `executor`, which lets it stay a pure function that a + * repository wires to a real `Model.find(...)`. The executor receives the + * computed `filter`, `sort` and a `limit` (already `+1`, so the helper can + * detect whether a further page exists) and must return the matching + * documents in `sort` order. + * + * Provide exactly one of `after` (walk forwards) or `before` (walk backwards); + * omit both to fetch the first page. Regardless of direction the returned + * `edges`/`nodes` are always in the caller's requested `sortOrder`. + * + * @param {Object} options + * @param {(query: {filter: Object, sort: Object, limit: number}) => (Object[]|Promise)} options.executor + * Runs the query and resolves to up to `limit` documents in `sort` order. + * @param {string} [options.sortField="_id"] The primary sort field name. + * @param {(1|-1)} [options.sortOrder=1] Ascending (`1`) or descending (`-1`). + * @param {number} [options.limit=20] Page size (must be a positive integer). + * @param {?string} [options.after] Cursor to paginate forwards from. + * @param {?string} [options.before] Cursor to paginate backwards from. + * @param {Object} [options.baseFilter={}] An existing filter to intersect with. + * @returns {Promise} The page, its cursors and navigation flags. + * @throws {TypeError} If `executor` is not a function, `limit` is not a positive + * integer, or both `after` and `before` are supplied. + */ +export async function paginate({ + executor, + sortField = "_id", + sortOrder = 1, + limit = 20, + after = null, + before = null, + baseFilter = {}, +} = {}) { + if (typeof executor !== "function") { + throw new TypeError("paginate: executor function is required"); + } + if (!Number.isInteger(limit) || limit <= 0) { + throw new TypeError("paginate: limit must be a positive integer"); + } + if (after && before) { + throw new TypeError("paginate: provide either `after` or `before`, not both"); + } + + const direction = before ? "backward" : "forward"; + const activeCursor = before || after; + + // Compose the base filter with the cursor seek predicate (if any). + let filter = baseFilter; + if (activeCursor) { + const decoded = decodeCursor(activeCursor); + const seek = buildCursorFilter({ cursor: decoded, sortField, sortOrder, direction }); + filter = mergeFilter(baseFilter, seek); + } + + const sort = buildSort(sortField, sortOrder, direction); + + // Over-fetch by one to learn whether another page exists in the direction + // of travel without issuing a second count query. + const fetched = await executor({ filter, sort, limit: limit + 1 }); + const rows = Array.isArray(fetched) ? fetched : []; + + const hasExtra = rows.length > limit; + const page = hasExtra ? rows.slice(0, limit) : rows.slice(); + + // Backward pages come back nearest-first (reversed); restore caller order. + const ordered = direction === "backward" ? page.reverse() : page; + + const edges = ordered.map((node) => ({ + node, + cursor: buildCursorFromDoc(node, sortField), + })); + + return { + edges, + nodes: edges.map((edge) => edge.node), + pageInfo: { + hasNextPage: direction === "forward" ? hasExtra : Boolean(activeCursor), + hasPreviousPage: direction === "backward" ? hasExtra : Boolean(after), + startCursor: edges.length ? edges[0].cursor : null, + endCursor: edges.length ? edges[edges.length - 1].cursor : null, + }, + }; +} + +/** + * Normalise a value pulled off a document so it survives a JSON round-trip + * inside a cursor. BSON types such as `ObjectId` and `Date` expose a stable + * string form; primitives are returned untouched. + * + * @param {*} value The raw value from the document. + * @returns {*} A JSON-safe representation of `value`. + */ +function normaliseValue(value) { + if (value === null || value === undefined) { + return value; + } + if (value instanceof Date) { + return value.toISOString(); + } + // ObjectId and similar BSON types implement a meaningful toString/toHexString. + if (typeof value === "object") { + if (typeof value.toHexString === "function") { + return value.toHexString(); + } + if (typeof value.toString === "function" && value.toString !== Object.prototype.toString) { + return value.toString(); + } + } + return value; +} + +/** + * Intersect a base filter with a cursor seek predicate without either one + * clobbering the other's keys (they may both constrain the same field). + * + * @param {Object} baseFilter The repository's existing filter. + * @param {Object} seekFilter The cursor predicate from {@link buildCursorFilter}. + * @returns {Object} A single MongoDB filter equivalent to `base AND seek`. + */ +function mergeFilter(baseFilter, seekFilter) { + const base = baseFilter && typeof baseFilter === "object" ? baseFilter : {}; + if (Object.keys(base).length === 0) { + return seekFilter; + } + return { $and: [base, seekFilter] }; +} + +/** + * Default export mirrors the named exports for callers that prefer a namespace + * import: `import cursorPagination from "../utils/cursorPagination.js"`. + */ +export default { + encodeCursor, + decodeCursor, + buildCursorFromDoc, + buildCursorFilter, + buildSort, + paginate, +}; diff --git a/mongo/utils/healthCheck.js b/mongo/utils/healthCheck.js new file mode 100644 index 00000000..cdbc0ff8 --- /dev/null +++ b/mongo/utils/healthCheck.js @@ -0,0 +1,62 @@ +import mongoose from "mongoose"; + +/** + * Checks MongoDB database health by inspecting connection state and issuing a ping command. + * Returns health status, response time, and connection details. + * + * @param {Object} options + * @param {number} [options.timeoutMs=3000] Ping timeout in milliseconds + * @returns {Promise<{healthy: boolean, status: string, responseTimeMs: number, connection: Object, error?: string}>} + */ +export async function checkDatabaseHealth({ timeoutMs = 3000 } = {}) { + const startTime = Date.now(); + const readyState = mongoose.connection ? mongoose.connection.readyState : 0; + const stateNames = { + 0: "disconnected", + 1: "connected", + 2: "connecting", + 3: "disconnecting", + }; + + const connectionDetails = { + readyState: stateNames[readyState] || "unknown", + host: mongoose.connection?.host || null, + port: mongoose.connection?.port || null, + name: mongoose.connection?.name || null, + }; + + if (readyState !== 1 || !mongoose.connection?.db) { + return { + healthy: false, + status: "unhealthy", + responseTimeMs: Date.now() - startTime, + connection: connectionDetails, + error: `MongoDB is not connected (state: ${stateNames[readyState] || readyState})`, + }; + } + + try { + const pingPromise = mongoose.connection.db.admin().ping(); + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error("Database ping timeout")), timeoutMs) + ); + + await Promise.race([pingPromise, timeoutPromise]); + const responseTimeMs = Date.now() - startTime; + + return { + healthy: true, + status: "healthy", + responseTimeMs, + connection: connectionDetails, + }; + } catch (error) { + return { + healthy: false, + status: "unhealthy", + responseTimeMs: Date.now() - startTime, + connection: connectionDetails, + error: error.message || "Database ping failed", + }; + } +} diff --git a/mongo/utils/textSearch.js b/mongo/utils/textSearch.js new file mode 100644 index 00000000..422c2282 --- /dev/null +++ b/mongo/utils/textSearch.js @@ -0,0 +1,198 @@ +/** + * @module mongo/utils/textSearch + * Reusable full-text search utilities for MongoDB via Mongoose. + * ------------------------------------------------------------------------- + * These are pure, dependency-free helpers that a repository (for example + * `BookRepository`) can compose to perform `$text` searches against a + * Mongoose model without duplicating query-building logic. + * + * Why a separate utility? + * ----------------------- + * - **Consistency.** Every text search in the application goes through the + * same query construction, projection, sort and pagination logic. + * - **Score-based ranking.** Results are always ordered by MongoDB's + * computed `$meta: "textScore"`, not natural/insertion order. + * - **Composability.** Additional filter criteria can be combined with + * text search, and offset-based pagination works correctly alongside + * score-based sorting. + * + * Conventions for anything added under `/mongo`: + * - Repositories never call `res`/express — they return data or throw. + * - Every exported function/class carries complete JSDoc. + */ + +/** + * @typedef {Object} TextSearchOptions + * @property {string} term The search string passed to + * MongoDB's `$text.$search`. + * @property {Object} [filters={}] Additional filter criteria + * (e.g. `{ price: { $gte: 10 }, category: "Programming" }`) combined with + * the `$text` query via `$and`. + * @property {Object} [projection={}] Extra fields to project + * alongside the text score. The `score` field is added automatically. + * @property {Object} [sort] Custom sort specification. + * Defaults to `{ score: { $meta: "textScore" } }`. + * @property {number} [page=1] 1-based page number. + * @property {number} [limit=10] Documents per page (capped + * at 100). + * @property {boolean} [lean=true] Return plain objects instead + * of hydrated Mongoose documents. + * @property {Object} [mongooseOptions={}] Additional options forwarded + * to the Mongoose query (e.g. `{ populate: "author" }`). + */ + +/** + * @typedef {Object} TextSearchResult + * @property {Object[]} documents The page of matching documents, + * each carrying a `score` field with the text-match relevance score. + * @property {number} total Total number of documents + * matching the combined query (text + filters). + * @property {number} page The current 1-based page number. + * @property {number} limit The page size used. + * @property {number} pages Total number of pages. + */ + +/** + * Build a MongoDB `$text` search query combined with optional filter criteria. + * + * The returned filter object is safe to pass to `Model.find(filter, projection)`. + * When `term` is non-empty, a `$text: { $search: term }` clause is included; + * when `filters` is non-empty, it is intersected with the text query via `$and`. + * + * @param {string} term The search string. + * @param {Object} [filters={}] Additional filter criteria. + * @returns {Object} A MongoDB filter object. + */ +export function buildTextFilter(term, filters = {}) { + const textClause = term && String(term).trim() + ? { $text: { $search: String(term).trim() } } + : {}; + + const filterKeys = Object.keys(filters).filter( + (k) => filters[k] !== undefined && filters[k] !== null && filters[k] !== "" + ); + + if (filterKeys.length === 0) { + return textClause; + } + + const filterPart = {}; + for (const key of filterKeys) { + filterPart[key] = filters[key]; + } + + if (Object.keys(textClause).length === 0) { + return filterPart; + } + + return { $and: [textClause, filterPart] }; +} + +/** + * Build the projection object that includes the text-score meta field. + * + * When a `$text` search is active, MongoDB requires the projection to include + * `{ score: { $meta: "textScore" } }` for the score to be accessible on the + * returned documents. This helper merges the caller's custom projection with + * the score field. + * + * @param {Object} [extraProjection={}] Additional fields to include + * (e.g. `{ title: 1, description: 1 }`). + * @returns {Object} A Mongoose projection object. + */ +export function buildTextProjection(extraProjection = {}) { + return { ...extraProjection, score: { $meta: "textScore" } }; +} + +/** + * Build the sort specification for a text search. + * + * Defaults to relevance-based sorting via `{ score: { $meta: "textScore" } }`. + * Callers may override this (e.g. to sort by `{ price: 1 }`) but should be + * aware that non-score sorts will not reflect text relevance. + * + * @param {Object} [customSort] Caller-supplied sort. When `null` or + * `undefined` the default relevance sort is used. + * @returns {Object} A Mongoose sort specification. + */ +export function buildTextSort(customSort) { + if (customSort && typeof customSort === "object" && Object.keys(customSort).length > 0) { + return customSort; + } + return { score: { $meta: "textScore" } }; +} + +/** + * Execute a full-text search against a Mongoose model and return a page of + * results with score-based ranking. + * + * This is the primary entry-point for callers that want a single-function + * text-search-and-paginate workflow. For finer control, use the individual + * `buildTextFilter`, `buildTextProjection`, and `buildTextSort` helpers. + * + * @param {Object} params + * @param {import("mongoose").Model} params.model The Mongoose model to + * search against (must have a text index defined). + * @param {string} params.term The search string. + * @param {Object} [params.filters={}] Additional filter criteria. + * @param {Object} [params.projection={}] Extra fields to project. + * @param {Object} [params.sort] Custom sort specification. + * @param {number} [params.page=1] 1-based page number. + * @param {number} [params.limit=10] Documents per page. + * @param {boolean} [params.lean=true] Return plain objects. + * @returns {Promise} The search results with pagination + * metadata. + * @throws {TypeError} If `model` is not provided or `term` is missing. + */ +export async function textSearch({ + model, + term, + filters = {}, + projection = {}, + sort, + page = 1, + limit = 10, + lean = true, +} = {}) { + if (!model) { + throw new TypeError("textSearch: model is required"); + } + if (!term || !String(term).trim()) { + throw new TypeError("textSearch: term is required"); + } + + const validPage = Math.max(1, Number(page) || 1); + const validLimit = Math.min(100, Math.max(1, Number(limit) || 10)); + const skip = (validPage - 1) * validLimit; + + const filter = buildTextFilter(term, filters); + const textProjection = buildTextProjection(projection); + const sortSpec = buildTextSort(sort); + + const query = model.find(filter, textProjection).sort(sortSpec).skip(skip).limit(validLimit); + if (lean) query.lean(); + + const [documents, total] = await Promise.all([ + query.exec(), + model.countDocuments(filter).exec(), + ]); + + return { + documents, + total, + page: validPage, + limit: validLimit, + pages: Math.ceil(total / validLimit), + }; +} + +/** + * Default export mirrors the named exports for callers that prefer a namespace + * import: `import textSearch from "../utils/textSearch.js"`. + */ +export default { + buildTextFilter, + buildTextProjection, + buildTextSort, + textSearch, +}; diff --git a/openapi.yaml b/openapi.yaml index 834a5f09..8897be9a 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -82,6 +82,13 @@ components: scheme: bearer description: Static token from the JOBS_DASHBOARD_TOKEN environment variable. parameters: + IdempotencyKeyHeader: + name: Idempotency-Key + in: header + required: false + description: Unique key for request idempotency protection. Prevents duplicate charges and transactions on retried requests. + schema: + type: string Page: name: page in: query @@ -128,6 +135,11 @@ components: content: application/json: schema: { $ref: "#/components/schemas/Error" } + UnprocessableEntity: + description: Request payload mismatch for idempotency key + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } TooManyRequests: description: Rate limit exceeded. /api is rate limited globally and /api/auth more strictly. content: @@ -438,7 +450,7 @@ paths: /health: get: tags: [Meta] - summary: Liveness probe used by CI and the host + summary: Readiness probe for critical dependencies security: [] responses: "200": @@ -449,8 +461,57 @@ paths: type: object properties: success: { type: boolean } - message: { type: string, examples: [pong] } - timestamp: { type: string, format: date-time } + message: { type: string } + data: + type: object + properties: + status: { type: string, enum: [healthy] } + timestamp: { type: string, format: date-time } + uptime: { type: number } + environment: { type: string } + dependencies: + type: object + properties: + mongodb: + type: object + properties: + status: { type: string, enum: [up, down] } + state: { type: string } + redis: + type: object + properties: + status: { type: string, enum: [up, down] } + "503": + description: One or more critical dependencies are unavailable + content: + application/json: + schema: + type: object + properties: + success: { type: boolean, examples: [false] } + message: { type: string } + data: + type: object + properties: + status: { type: string, enum: [unhealthy] } + timestamp: { type: string, format: date-time } + uptime: { type: number } + environment: { type: string } + dependencies: + type: object + /ping: + get: + tags: [Meta] + summary: Dependency free liveness probe + security: [] + responses: + "200": + description: The process is alive + content: + text/plain: + schema: + type: string + example: pong /metrics: get: tags: [Meta] @@ -1671,6 +1732,12 @@ paths: post: tags: [Payments] summary: Submit a signed transaction and verify it on chain + description: > + Submits the buyer-signed transaction and verifies it on chain. When + `requestSponsorship: true` and fee sponsorship is enabled, the platform + pays the network fee via a fee-bump wrapper (see docs/fee-sponsorship.md). + Sponsorship-specific failures return a distinct non-fatal 4xx/503 with + `retryUnsponsored: true` and never mark the transaction failed. requestBody: required: true content: @@ -1680,12 +1747,17 @@ paths: properties: signedXdr: { type: string } transactionId: { $ref: "#/components/schemas/ObjectId" } + requestSponsorship: + type: boolean + description: Opt in to platform-paid network fees (fee-bump). Ignored when sponsorship is disabled. required: [signedXdr] responses: "200": { $ref: "#/components/responses/Ok" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/BadRequest" } + "429": { $ref: "#/components/responses/BadRequest" } /api/stellar/payment/transactions: get: tags: [Payments] @@ -1879,6 +1951,18 @@ paths: "200": { $ref: "#/components/responses/Ok" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } + /api/stellar/payment/sponsorship/status: + get: + tags: [Payments] + summary: Fee-bump sponsorship status (admin) + description: > + Admin-only. Returns whether fee sponsorship is enabled, the sponsor + account public key (never the secret) and live XLM float, the configured + caps, and today's spend. See docs/fee-sponsorship.md. + responses: + "200": { $ref: "#/components/responses/Ok" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } /api/stellar/donation/stats: get: tags: [Donations] @@ -1909,6 +1993,10 @@ paths: post: tags: [Donations] summary: Submit a signed donation transaction + description: > + Submits the donor-signed transaction and verifies it on chain. Supports + the same optional `requestSponsorship: true` fee-bump flow as the payment + submit endpoint (see docs/fee-sponsorship.md). requestBody: required: true content: @@ -1917,11 +2005,17 @@ paths: type: object properties: signedXdr: { type: string } + donationId: { $ref: "#/components/schemas/ObjectId" } + requestSponsorship: + type: boolean + description: Opt in to platform-paid network fees (fee-bump). Ignored when sponsorship is disabled. required: [signedXdr] responses: "200": { $ref: "#/components/responses/Ok" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } + "422": { $ref: "#/components/responses/BadRequest" } + "429": { $ref: "#/components/responses/BadRequest" } /api/payouts/me/balance: get: tags: [Payouts] @@ -2052,4 +2146,4 @@ paths: type: array items: { type: object } "401": { $ref: "#/components/responses/Unauthorized" } - "404": { $ref: "#/components/responses/NotFound" } \ No newline at end of file + "404": { $ref: "#/components/responses/NotFound" } diff --git a/package-lock.json b/package-lock.json index b48efac5..b38222d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,11 +31,13 @@ "morgan": "^1.10.0", "multer": "^1.4.5-lts.2", "multer-storage-cloudinary": "^4.0.0", - "nodemailer": "^9.0.3", + "otplib": "^13.4.1", + "pdfkit": "^0.20.1", "pino": "^10.3.1", "pino-http": "^11.0.0", "pino-pretty": "^13.1.3", "prom-client": "^15.1.3", + "qrcode": "^1.5.4", "redis": "^4.7.1", "safe-stable-stringify": "^2.5.0", "socket.io": "^4.8.1", @@ -1053,6 +1055,18 @@ "sparse-bitfield": "^3.0.3" } }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/ed25519": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.1.0.tgz", @@ -1083,6 +1097,62 @@ "node": ">=8.0.0" } }, + "node_modules/@otplib/core": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@otplib/core/-/core-13.4.1.tgz", + "integrity": "sha512-KIXgK1hNtWJEBMTastbe1bpmuais+3f+ATeO8TkMs2rNkfGO1FbQy8+/UWVEu3TR/iTJerU0idkPudaPmLP2BA==", + "license": "MIT" + }, + "node_modules/@otplib/hotp": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@otplib/hotp/-/hotp-13.4.1.tgz", + "integrity": "sha512-g9q04SwpG5ZtMnVkUcgcoAlwCH4YLROZN1qhyBwgkBzqYYVSYhpP6gSGaxGHwePLt1c+e6NqDlgIZN+e1/XPuA==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.1", + "@otplib/uri": "13.4.1" + } + }, + "node_modules/@otplib/plugin-base32-scure": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-base32-scure/-/plugin-base32-scure-13.4.1.tgz", + "integrity": "sha512-Fs/r5qisC05SRhT6xWXaypB6PVC0vgWf6zztmi0J5RnQ09OJiPDWCJFH6cDm6ANsrdvB9di7X+Jb7L13BoEbUA==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.1", + "@scure/base": "^2.2.0" + } + }, + "node_modules/@otplib/plugin-crypto-noble": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-crypto-noble/-/plugin-crypto-noble-13.4.1.tgz", + "integrity": "sha512-PJfVW8/1hdS6CfxLheKPZSLTwDq4TijZbN4yRjxlv0ODdzmxpM+wGwWr1JXMdy0xJPxLziydQD5gdVqrR4/gAg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.2.0", + "@otplib/core": "13.4.1" + } + }, + "node_modules/@otplib/totp": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@otplib/totp/-/totp-13.4.1.tgz", + "integrity": "sha512-QOkBVPrf6AM4qZaReZPSk9/I8ATVdZpIISJz115MqeVtcrbcr5llPZ0J7804tpnjnp1vCRkI5Qjd47HhgVteBQ==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.1", + "@otplib/hotp": "13.4.1", + "@otplib/uri": "13.4.1" + } + }, + "node_modules/@otplib/uri": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/@otplib/uri/-/uri-13.4.1.tgz", + "integrity": "sha512-xaIm7bvICMhoB2rZIR5luiaMdssWR5nY5nXnR1fdezUgZuEO58D6zrGzLp7pQuBmlpmL0HagnscDQFoskp9yiA==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.1" + } + }, "node_modules/@paralleldrive/cuid2": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", @@ -1177,6 +1247,15 @@ "@redis/client": "^1.0.0" } }, + "node_modules/@scure/base": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.3.0.tgz", + "integrity": "sha512-NsG6Y03tY6R5BUis4FdVtHVkur0U6FOzskgs9ZXNl78CUc9fkZ78HmENUle1nSOkCasDmbubmWD9qwB7mm4PZA==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.12", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", @@ -1268,6 +1347,15 @@ "proxy-from-env": "^2.1.0" } }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@tokenizer/inflate": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", @@ -1561,7 +1649,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -2119,6 +2206,15 @@ "node": ">=8" } }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.1.2" + } + }, "node_modules/browserslist": { "version": "4.28.6", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", @@ -2272,7 +2368,6 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -2411,6 +2506,15 @@ "node": ">=12" } }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/cloudinary": { "version": "1.41.3", "resolved": "https://registry.npmjs.org/cloudinary/-/cloudinary-1.41.3.tgz", @@ -2479,7 +2583,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -2492,7 +2595,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/color-string": { @@ -2825,6 +2927,15 @@ "ms": "2.0.0" } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/dedent": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -2904,6 +3015,12 @@ "wrappy": "1" } }, + "node_modules/dfa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", + "license": "MIT" + }, "node_modules/diff-sequences": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", @@ -2914,6 +3031,12 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -3431,6 +3554,12 @@ "integrity": "sha512-eVAiWVNPSEGIzDl5yPuLrx8fNMogScXvD9xp1Kzd41FjRIz2I3sSIcxsFeM5EzFfHAfobdvs8ZySffUopljvIA==", "license": "MIT" }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, "node_modules/fast-fifo": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", @@ -3476,6 +3605,12 @@ "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", "license": "MIT" }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/file-stream-rotator": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/file-stream-rotator/-/file-stream-rotator-0.6.1.tgz", @@ -3582,7 +3717,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, "license": "MIT", "dependencies": { "locate-path": "^5.0.0", @@ -3618,6 +3752,23 @@ } } }, + "node_modules/fontkit": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.12", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "dfa": "^1.2.0", + "fast-deep-equal": "^3.1.3", + "restructure": "^3.0.0", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.4.0", + "unicode-trie": "^2.0.0" + } + }, "node_modules/form-data": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", @@ -5199,6 +5350,25 @@ "node": ">=6" } }, + "node_modules/linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "license": "MIT", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/linebreak/node_modules/base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -5210,7 +5380,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, "license": "MIT", "dependencies": { "p-locate": "^4.1.0" @@ -6055,15 +6224,6 @@ "node": ">=18" } }, - "node_modules/nodemailer": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz", - "integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==", - "license": "MIT-0", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/nodemon": { "version": "3.1.14", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", @@ -6325,6 +6485,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/otplib": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/otplib/-/otplib-13.4.1.tgz", + "integrity": "sha512-o5CxfDw6bh7hoDv0NUUIcc0RqzJ9ipfUrzeKheKJ+vs4rXZnDlA9n4a/7R1cDjpmLjKLix4BgNVRmoDkm5rLSQ==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.1", + "@otplib/hotp": "13.4.1", + "@otplib/plugin-base32-scure": "13.4.1", + "@otplib/plugin-crypto-noble": "13.4.1", + "@otplib/totp": "13.4.1", + "@otplib/uri": "13.4.1" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -6345,7 +6519,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, "license": "MIT", "dependencies": { "p-limit": "^2.2.0" @@ -6358,7 +6531,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, "license": "MIT", "dependencies": { "p-try": "^2.0.0" @@ -6374,12 +6546,17 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, + "node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -6412,7 +6589,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6454,6 +6630,32 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pdfkit": { + "version": "0.20.1", + "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.20.1.tgz", + "integrity": "sha512-1rRXK6x5o8I/3dBrBzXfxibpHpkfCnIA7EBAES7pEpGFc/65inMLlA8SalGWpJfal7BGekxeLf6A30IOpQpc5Q==", + "license": "MIT", + "dependencies": { + "@noble/ciphers": "^1.3.0", + "@noble/hashes": "^1.8.0", + "fflate": "^0.8.3", + "fontkit": "^2.0.4", + "linebreak": "^1.1.0", + "png-js": "^2.0.0" + } + }, + "node_modules/pdfkit/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -6589,6 +6791,23 @@ "node": ">=8" } }, + "node_modules/png-js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/png-js/-/png-js-2.0.0.tgz", + "integrity": "sha512-GdzJuUMc6ZSpxFJWVxtOH1bzYHym+TOnveqUjb+VJIbZWbZzyiRGFiKhbiielfpYbgMlhHVhsJ0FTazfuRFkMA==", + "dependencies": { + "fflate": "^0.8.2" + } + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -6742,6 +6961,89 @@ "teleport": ">=0.2.0" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", @@ -6863,12 +7165,17 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -6924,6 +7231,12 @@ "node": ">=10" } }, + "node_modules/restructure": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==", + "license": "MIT" + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -7874,6 +8187,12 @@ "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", "license": "MIT" }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -7966,7 +8285,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/type-detect": { @@ -8054,6 +8372,26 @@ "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "license": "MIT" }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -8181,6 +8519,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/wide-align": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", diff --git a/package.json b/package.json index 428306d6..1bfc51bd 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "start": "node --import dotenv/config server.js", "dev": "nodemon --import dotenv/config server.js", "seed": "node src/scripts/seedDatabase.js", + "seed:categories": "node src/scripts/seedCategories.js", + "migrate:categories": "node src/scripts/migrateCategories.js", "migrate:review-stats": "node src/migrations/backfillReviewStats.js", "test-redis": "node test-redis.js", "payouts:audit": "node src/scripts/auditPayouts.js", @@ -39,10 +41,13 @@ "morgan": "^1.10.0", "multer": "^1.4.5-lts.2", "multer-storage-cloudinary": "^4.0.0", + "otplib": "^13.4.1", + "pdfkit": "^0.20.1", "pino": "^10.3.1", "pino-http": "^11.0.0", "pino-pretty": "^13.1.3", "prom-client": "^15.1.3", + "qrcode": "^1.5.4", "redis": "^4.7.1", "safe-stable-stringify": "^2.5.0", "socket.io": "^4.8.1", diff --git a/routes/health/database.js b/routes/health/database.js new file mode 100644 index 00000000..ab5c1f00 --- /dev/null +++ b/routes/health/database.js @@ -0,0 +1 @@ +export { default } from "../../src/routes/health/database.js"; diff --git a/routes/metrics/database.js b/routes/metrics/database.js new file mode 100644 index 00000000..fb449fe8 --- /dev/null +++ b/routes/metrics/database.js @@ -0,0 +1,5 @@ +// Thin re-export shim so the route is reachable at both `routes/metrics/...` +// and `src/routes/metrics/...`, mirroring the `routes/health/database.js` +// convention already used in this repo. The real implementation lives under +// `src/routes/metrics/database.js`. +export { default } from "../../src/routes/metrics/database.js"; diff --git a/server.js b/server.js index 4921eca2..804b889f 100644 --- a/server.js +++ b/server.js @@ -1,9 +1,22 @@ -import app from "./app.js"; +import dotenv from "dotenv"; import logger from "./src/config/logger.js"; +import connectDB from "./src/config/db.js"; +import validateEnv from "./src/config/validateEnv.js"; import { initRedis, closeRedis } from "./src/config/redis.js"; import { startJobs, stopJobs } from "./src/jobs/queue.js"; +import { + handleUncaughtException, + handleUnhandledRejection, +} from "./src/middlewares/errorHandler.js"; import "./src/jobs/handlers.js"; +dotenv.config(); +handleUncaughtException(); +validateEnv(); +await connectDB(); +handleUnhandledRejection(); + +const { default: app } = await import("./app.js"); const PORT = process.env.PORT || 5000; // Initialize Redis @@ -35,6 +48,31 @@ if (process.env.INGESTION_WORKER_ENABLED === "true") { ); } +// Start outbound webhook delivery worker if enabled +let stopWebhookWorker; +if (process.env.WEBHOOK_WORKER_ENABLED === "true") { + import("./src/services/webhooks/deliveryWorker.js").then( + ({ startDeliveryWorker, stopDeliveryWorker: stopFn }) => { + stopWebhookWorker = stopFn; + startDeliveryWorker().catch((err) => + logger.error(err, "Webhook delivery worker startup failed") + ); + } + ); +} + +let stopPledgeScheduler; +if (process.env.PLEDGE_SCHEDULER_ENABLED === "true") { + import("./src/workers/pledgeScheduler.js").then( + ({ startPledgeScheduler, stopPledgeScheduler: stopFn }) => { + stopPledgeScheduler = stopFn; + startPledgeScheduler().catch((err) => + logger.error(err, "Pledge scheduler startup failed") + ); + } + ); +} + // Graceful shutdown const gracefulShutdown = async (signal) => { logger.info(`${signal} received. Starting graceful shutdown...`); @@ -48,6 +86,14 @@ const gracefulShutdown = async (signal) => { await stopIngestionWorker(); } + if (stopWebhookWorker) { + await stopWebhookWorker(); + } + + if (stopPledgeScheduler) { + await stopPledgeScheduler(); + } + // Close Redis connection await closeRedis(); diff --git a/services/emails/sendMail.js b/services/emails/sendMail.js index d068415e..0df55995 100644 --- a/services/emails/sendMail.js +++ b/services/emails/sendMail.js @@ -76,6 +76,12 @@ const primaryButton = (href, label) => ` `; +// Test-only in-memory outbox. Populated ONLY when the email transport is +// unavailable AND NODE_ENV === "test" (the CI/unit scenario) so tests can +// assert on rendered bodies — including OTP codes and verification links — +// without those secrets ever reaching a log stream. Never logged. +export const testOutbox = []; + const sendMail = async ({ to, subject, @@ -85,25 +91,33 @@ const sendMail = async ({ cc, bcc, attachments, + template = "generic", }) => { const apiKey = process.env.SENDLIB_API_KEY || ""; const apiUrl = SENDLIB_API_URL(); const from = getFrom(); - // Security: never log the html body — it contains OTP codes / verification - // tokens. Log recipient + subject only. + // Security: never log the html/text body — it contains OTP codes and + // verification tokens. Log only non-sensitive metadata (recipient, subject, + // template id) and route it through structured fields so pino redaction + // applies to any object it serializes. if (!apiKey || !apiUrl) { logger.warn( + { template }, "SENDLIB_API_KEY / SENDLIB_API_URL not set — email not sent" ); - // Test env only (synthetic data): expose the body so tests can assert - // OTP/verification behavior. NEVER logged in dev/prod — bodies carry secrets. if (NODE_ENV() === "test") { - logger.info(`[EMAIL LOG] To: ${to} | Subject: ${subject} | Body: ${html}`); - } else { - logger.info(`[EMAIL SKIPPED] To: ${to} | Subject: ${subject}`); + // Test hook: expose the rendered message so tests can inspect + // OTP/token behavior. This is an in-memory array, not a log. + const rendered = { template, to, subject, html, text }; + testOutbox.push(rendered); + return rendered; } - return; + logger.info( + { template, to, subject }, + "[EMAIL SKIPPED] email not sent (transport unconfigured)" + ); + return null; } if (!from) { logger.warn( @@ -134,13 +148,13 @@ const sendMail = async ({ ); } const messageId = payload.messageId || payload.id; - logger.info(`Email sent to ${to}${messageId ? `: ${messageId}` : ""}`); + logger.info({ template, to, messageId }, "Email sent"); return payload; } catch (error) { - logger.error("Failed to send email:", error.message); + logger.error({ template, to, err: error.message }, "Failed to send email"); if (NODE_ENV() === "development") { // Subject only — never log the body (contains OTP/token). - logger.info(`[DEV FALLBACK] To: ${to} | Subject: ${subject}`); + logger.info({ template, to, subject }, "[DEV FALLBACK] email not sent"); return; } throw error; @@ -149,7 +163,7 @@ const sendMail = async ({ export const sendOtpEmail = async (otp, email) => { if (!email || !otp) throw new Error("Email and OTP are required"); - logger.info(`Sending OTP email to: ${email}`); + logger.info({ template: "otp", to: email }, "Sending OTP email"); const content = `

Password Reset

@@ -164,20 +178,21 @@ export const sendOtpEmail = async (otp, email) => {

`; - await sendMail({ + return sendMail({ to: email, subject: "Your Password Reset Code — DeenBridge", html: emailShell({ content, preheader: "Use this code to reset your DeenBridge password.", }), + template: "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}`); + logger.info({ template: "receipt", to: receipt.email }, "Sending receipt email"); const content = `

Payment Receipt

@@ -200,20 +215,21 @@ export const sendReceiptEmail = async (receipt) => {

`; - await sendMail({ + return sendMail({ to: receipt.email, subject: "Payment Receipt — DeenBridge", html: emailShell({ content, preheader: "Thank you for your contribution to DeenBridge.", }), + template: "receipt", }); }; export const sendVerificationEmail = async (email, token) => { if (!email || !token) throw new Error("Email and token are required"); const link = `${FRONTEND_URL()}/verify-email?token=${token}`; - logger.info(`Sending verification email to: ${email}`); + logger.info({ template: "verification", to: email }, "Sending verification email"); const content = `

Welcome to DeenBridge!

@@ -233,13 +249,14 @@ export const sendVerificationEmail = async (email, token) => {

`; - await sendMail({ + return sendMail({ to: email, subject: "Verify your email — DeenBridge", html: emailShell({ content, preheader: "Click to verify your email and activate your DeenBridge account.", }), + template: "verification", }); }; diff --git a/src/config/db.js b/src/config/db.js index 499fb3e6..25ab2f10 100644 --- a/src/config/db.js +++ b/src/config/db.js @@ -1,5 +1,6 @@ import mongoose from "mongoose"; import logger from "./logger.js"; +import poolMetrics from "../../mongo/monitoring/poolMetrics.js"; const SLOW_QUERY_MS = parseInt(process.env.SLOW_QUERY_MS || "200", 10); @@ -62,6 +63,14 @@ const connectDB = async () => { logger.info(`Database: ${mongoose.connection.name}`); logger.info(`Host: ${mongoose.connection.host}`); + // Instrument the live connection pool (CMAP events). Best-effort: never + // let metrics wiring break a successful DB connection. + try { + poolMetrics.attach(mongoose.connection); + } catch (metricsErr) { + logger.warn(metricsErr, "Failed to attach pool metrics collector"); + } + mongoose.connection.on("error", (err) => { logger.error(err, "MongoDB connection error"); }); diff --git a/src/config/metrics.js b/src/config/metrics.js index 789022ba..1a4ca4bc 100644 --- a/src/config/metrics.js +++ b/src/config/metrics.js @@ -49,6 +49,22 @@ const paymentsFailed = new promClient.Counter({ registers: [registry], }); +// Fee-bump sponsorship (#30): one increment per sponsorship decision so the +// approve/reject ratio and rejection reasons are observable in Prometheus. +const sponsorshipsApproved = new promClient.Counter({ + name: "fee_sponsorships_approved_total", + help: "Total number of transactions approved for platform fee sponsorship", + labelNames: ["type"], + registers: [registry], +}); + +const sponsorshipsRejected = new promClient.Counter({ + name: "fee_sponsorships_rejected_total", + help: "Total number of sponsorship requests rejected before submission", + labelNames: ["type", "reason"], + registers: [registry], +}); + function observeHttpDuration(method, route, statusCode, durationMs) { httpRequestDuration.observe( { method, route: route || "unknown", status_code: String(statusCode) }, @@ -91,6 +107,8 @@ export { paymentsSubmitted, paymentsConfirmed, paymentsFailed, + sponsorshipsApproved, + sponsorshipsRejected, observeHttpDuration, observeHorizonDuration, metricsMiddleware, diff --git a/src/config/serviceKeys.js b/src/config/serviceKeys.js new file mode 100644 index 00000000..b4056c71 --- /dev/null +++ b/src/config/serviceKeys.js @@ -0,0 +1,106 @@ +// config/serviceKeys.js +// +// Service-to-service (S2S) key store for the AI service (dnb-ai). +// +// Keys are provisioned via the AI_SERVICE_KEYS environment variable, a JSON +// array of key objects: +// +// [ +// { "kid": "k1", "secret": "long-random-hmac-secret", +// "scopes": ["ai:read-content"], "active": true } +// ] +// +// Multiple entries may be active at once, keyed by `kid`, so a new key can be +// introduced and the old one retired WITHOUT downtime (see the rotation +// runbook in docs/service-to-service-auth.md). Each key carries its own +// allowed `scopes`; the requireServiceAuth middleware asserts the route scope. +// +// The parse is memoized against the raw env string so repeated lookups are +// cheap, yet a test (or a hot-reloaded deploy) can mutate process.env and pick +// up the new key set on the next call. Parsing is resilient: a missing or +// malformed value yields an empty Map and NEVER throws at import time. +import logger from "./logger.js"; + +let cachedRaw; +let cachedMap = new Map(); + +/** + * Parse the AI_SERVICE_KEYS env value into a Map. + * Invalid entries are skipped (and logged) rather than aborting the whole set. + * + * @param {string|undefined} raw + * @returns {Map} + */ +function parseServiceKeys(raw) { + const map = new Map(); + if (!raw || typeof raw !== "string" || raw.trim() === "") { + return map; + } + + let parsed; + try { + parsed = JSON.parse(raw); + } catch (_err) { + logger.warn("⚠️ AI_SERVICE_KEYS is not valid JSON — no service keys loaded."); + return map; + } + + if (!Array.isArray(parsed)) { + logger.warn("⚠️ AI_SERVICE_KEYS must be a JSON array — no service keys loaded."); + return map; + } + + for (const entry of parsed) { + if (!entry || typeof entry !== "object") continue; + const { kid, secret } = entry; + if (typeof kid !== "string" || kid === "" || typeof secret !== "string" || secret === "") { + logger.warn("⚠️ Skipping AI_SERVICE_KEYS entry missing a string kid/secret."); + continue; + } + const scopes = Array.isArray(entry.scopes) + ? entry.scopes.filter((s) => typeof s === "string") + : []; + // Default to active unless explicitly disabled (active:false retires a kid). + const active = entry.active !== false; + map.set(kid, { secret, scopes, active }); + } + + return map; +} + +/** + * Return the current service-key Map, re-parsing only when the underlying env + * value has changed since the last call. + * + * @returns {Map} + */ +export function getServiceKeys() { + const raw = process.env.AI_SERVICE_KEYS; + if (raw !== cachedRaw) { + cachedRaw = raw; + cachedMap = parseServiceKeys(raw); + } + return cachedMap; +} + +/** + * Look up a single key by its `kid`. Returns undefined for unknown ids. + * + * @param {string} kid + * @returns {{secret: string, scopes: string[], active: boolean}|undefined} + */ +export function getServiceKey(kid) { + if (typeof kid !== "string" || kid === "") return undefined; + return getServiceKeys().get(kid); +} + +/** + * Force the next getServiceKeys()/getServiceKey() call to re-parse from env. + * Primarily a test hook for rotating keys mid-suite. + */ +export function resetServiceKeys() { + cachedRaw = undefined; + cachedMap = new Map(); +} + +export default { getServiceKeys, getServiceKey, resetServiceKeys }; diff --git a/src/config/stellar.js b/src/config/stellar.js new file mode 100644 index 00000000..b78a203a --- /dev/null +++ b/src/config/stellar.js @@ -0,0 +1,177 @@ +// config/stellar.js +// +// Single source of truth for the Stellar network configuration. Everything +// that depends on which network the app is running against (network +// passphrase, Horizon URL, USDC issuer, default asset) resolves through this +// module instead of being derived ad-hoc in each service. +// +// Network values accepted: +// - "testnet" -> testnet (default when unset, for back-compat) +// - "mainnet" | "public" -> mainnet ("public" is the Stellar SDK / SDF +// name for the production network) +// +// Startup validation (validateStellarConfig) makes a misconfigured deployment +// fail at boot with a message naming the exact problem, rather than failing +// at request time on the first Horizon call. + +import * as StellarSdk from "@stellar/stellar-sdk"; +import { getAssetConfig, getDefaultAssetCode } from "./assets.js"; + +/** Normalized network names used as registry keys / DB enum values. */ +export const STELLAR_NETWORK_ALIASES = Object.freeze({ + testnet: "testnet", + mainnet: "mainnet", + public: "mainnet", +}); + +/** Canonical Horizon endpoints per network (used when HORIZON_URLS is unset). */ +export const HORIZON_DEFAULTS = Object.freeze({ + testnet: "https://horizon-testnet.stellar.org", + mainnet: "https://horizon.stellar.org", +}); + +/** + * Canonical USDC issuers per network (Circle). Documented here as the + * reference; the asset registry (assets.js) must agree with it — + * validateStellarConfig() cross-checks the registry against these constants + * so a mainnet flag paired with a testnet issuer can never silently boot. + */ +export const USDC_ISSUERS = Object.freeze({ + testnet: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + mainnet: "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", +}); + +/** + * Resolve and normalize the configured Stellar network. + * @param {string} [raw] - raw STELLAR_NETWORK value (defaults to process.env) + * @returns {"testnet"|"mainnet"} + * @throws {Error} naming the exact problem when the value is not recognized + */ +export const resolveStellarNetwork = (raw = process.env.STELLAR_NETWORK) => { + const value = String(raw ?? "").trim().toLowerCase(); + if (!value) { + // Back-compat: unset means testnet, matching the historical default. + return "testnet"; + } + const network = STELLAR_NETWORK_ALIASES[value]; + if (!network) { + throw new Error( + `Invalid STELLAR_NETWORK "${raw}": expected "testnet", "mainnet", or "public". ` + + "DeenBridge defaults to testnet; switch to mainnet/public only when the whole " + + "stack (Horizon, USDC issuer, frontend NEXT_PUBLIC_STELLAR_NETWORK) is ready " + + "for mainnet — see docs/MAINNET.md." + ); + } + return network; +}; + +/** + * Resolve the full Stellar configuration from the environment. + * @returns {{ + * network: "testnet"|"mainnet", + * networkPassphrase: string, + * horizonUrls: string[], + * primaryHorizonUrl: string, + * usdcIssuer: string, + * defaultAssetCode: string, + * }} + */ +export const resolveStellarConfig = () => { + const network = resolveStellarNetwork(); + const rawUrls = + process.env.HORIZON_URLS || HORIZON_DEFAULTS[network]; + const horizonUrls = rawUrls + .split(",") + .map((url) => url.trim()) + .filter(Boolean); + const usdc = getAssetConfig("USDC", network); + + return { + network, + networkPassphrase: + network === "mainnet" + ? StellarSdk.Networks.PUBLIC + : StellarSdk.Networks.TESTNET, + horizonUrls, + primaryHorizonUrl: horizonUrls[0] || HORIZON_DEFAULTS[network], + usdcIssuer: usdc.issuer, + defaultAssetCode: getDefaultAssetCode(network), + }; +}; + +/** + * Fail-fast startup validation of the Stellar configuration. + * + * Checks: + * 1. STELLAR_NETWORK resolves to testnet/mainnet (public alias allowed). + * 2. Every configured Horizon URL is a valid http(s) URL, and none points + * at the OTHER network's canonical Horizon (a mainnet flag paired with + * the testnet Horizon URL — or vice versa — would silently operate on + * the wrong chain). + * 3. The resolved USDC issuer is a valid Stellar public key and matches the + * canonical issuer for the selected network (a mainnet flag with a + * testnet issuer must not boot). + * + * Custom / mirror Horizon URLs are fine — only cross-network mismatches are + * rejected. + * + * @returns {{ valid: boolean, problems: string[] }} + */ +export const validateStellarConfig = () => { + const problems = []; + + let network; + try { + network = resolveStellarNetwork(); + } catch (error) { + return { valid: false, problems: [error.message] }; + } + + const config = resolveStellarConfig(); + + const otherNetwork = network === "mainnet" ? "testnet" : "mainnet"; + + for (const url of config.horizonUrls) { + let parsed; + try { + parsed = new URL(url); + } catch { + problems.push( + `Invalid HORIZON_URLS entry "${url}": not a valid URL. ` + + `Expected http(s) endpoints, comma-separated (e.g. ${HORIZON_DEFAULTS[network]}).` + ); + continue; + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + problems.push( + `Invalid HORIZON_URLS entry "${url}": must be an http(s) URL.` + ); + } + if (url === HORIZON_DEFAULTS[otherNetwork]) { + problems.push( + `HORIZON_URLS entry "${url}" is the canonical ${otherNetwork} endpoint, ` + + `but STELLAR_NETWORK is "${network}". Set HORIZON_URLS to ${HORIZON_DEFAULTS[network]} ` + + `(or unset it to use the network default) — see docs/MAINNET.md.` + ); + } + } + + const expectedIssuer = USDC_ISSUERS[network]; + if (config.usdcIssuer !== expectedIssuer) { + problems.push( + `USDC issuer mismatch on ${network}: registry resolves ${config.usdcIssuer}, ` + + `expected ${expectedIssuer}. Fix src/config/assets.js or the environment — ` + + "a mainnet flag with a testnet issuer (or vice versa) must never boot." + ); + } else { + try { + StellarSdk.Keypair.fromPublicKey(config.usdcIssuer); + } catch { + problems.push( + `USDC issuer "${config.usdcIssuer}" is not a valid Stellar public key.` + ); + } + } + + return { valid: problems.length === 0, problems }; +}; diff --git a/src/config/stellarConfig.test.js b/src/config/stellarConfig.test.js new file mode 100644 index 00000000..685bcbc5 --- /dev/null +++ b/src/config/stellarConfig.test.js @@ -0,0 +1,179 @@ +import { jest } from "@jest/globals"; + +// Mock the asset registry so the issuer-mismatch validation path can be +// exercised (the real registry always agrees with the canonical constants, +// which is exactly what the cross-check is for). +const testnetIssuer = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; +const mainnetIssuer = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; +const getAssetConfig = jest.fn((code, network) => + code === "USDC" + ? { + code: "USDC", + issuer: network === "mainnet" ? mainnetIssuer : testnetIssuer, + isDefault: true, + } + : null +); + +jest.unstable_mockModule("./assets.js", () => ({ + getAssetConfig, + getDefaultAssetCode: jest.fn(() => "USDC"), +})); + +const { + resolveStellarNetwork, + resolveStellarConfig, + validateStellarConfig, + USDC_ISSUERS, +} = await import("./stellar.js"); + +describe("stellar config resolution", () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.STELLAR_NETWORK; + delete process.env.HORIZON_URLS; + getAssetConfig.mockClear(); + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it("defaults to testnet when STELLAR_NETWORK is unset", () => { + expect(resolveStellarNetwork()).toBe("testnet"); + const config = resolveStellarConfig(); + expect(config.network).toBe("testnet"); + expect(config.networkPassphrase).toBe("Test SDF Network ; September 2015"); + expect(config.primaryHorizonUrl).toBe("https://horizon-testnet.stellar.org"); + expect(config.usdcIssuer).toBe(USDC_ISSUERS.testnet); + expect(config.defaultAssetCode).toBe("USDC"); + }); + + it("resolves the full mainnet config when STELLAR_NETWORK=mainnet", () => { + process.env.STELLAR_NETWORK = "mainnet"; + const config = resolveStellarConfig(); + expect(config.network).toBe("mainnet"); + expect(config.networkPassphrase).toBe( + "Public Global Stellar Network ; September 2015" + ); + expect(config.primaryHorizonUrl).toBe("https://horizon.stellar.org"); + expect(config.usdcIssuer).toBe(USDC_ISSUERS.mainnet); + expect(config.usdcIssuer).not.toBe(USDC_ISSUERS.testnet); + }); + + it("treats 'public' as an alias for mainnet", () => { + process.env.STELLAR_NETWORK = "public"; + const config = resolveStellarConfig(); + expect(config.network).toBe("mainnet"); + expect(config.networkPassphrase).toBe( + "Public Global Stellar Network ; September 2015" + ); + expect(config.primaryHorizonUrl).toBe("https://horizon.stellar.org"); + }); + + it("accepts explicit HORIZON_URLS and trims/parses them", () => { + process.env.HORIZON_URLS = + " https://custom.stellar.org , https://mirror.stellar.org "; + const config = resolveStellarConfig(); + expect(config.horizonUrls).toEqual([ + "https://custom.stellar.org", + "https://mirror.stellar.org", + ]); + expect(config.primaryHorizonUrl).toBe("https://custom.stellar.org"); + }); + + it("throws a descriptive error for an unknown network", () => { + process.env.STELLAR_NETWORK = "devnet"; + expect(() => resolveStellarNetwork()).toThrow( + /Invalid STELLAR_NETWORK "devnet"/ + ); + expect(() => resolveStellarConfig()).toThrow( + /Invalid STELLAR_NETWORK "devnet"/ + ); + }); + + it("accepts case-insensitive network values", () => { + process.env.STELLAR_NETWORK = "MAINNET"; + expect(resolveStellarNetwork()).toBe("mainnet"); + }); +}); + +describe("validateStellarConfig (fail-fast startup validation)", () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.STELLAR_NETWORK; + delete process.env.HORIZON_URLS; + getAssetConfig.mockClear(); + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it("passes for a clean testnet configuration", () => { + const result = validateStellarConfig(); + expect(result.valid).toBe(true); + expect(result.problems).toEqual([]); + }); + + it("passes for a clean mainnet configuration", () => { + process.env.STELLAR_NETWORK = "mainnet"; + const result = validateStellarConfig(); + expect(result.valid).toBe(true); + expect(result.problems).toEqual([]); + }); + + it("reports an invalid STELLAR_NETWORK value", () => { + process.env.STELLAR_NETWORK = "devnet"; + const result = validateStellarConfig(); + expect(result.valid).toBe(false); + expect(result.problems.join(" ")).toContain("Invalid STELLAR_NETWORK"); + }); + + it("rejects a mainnet flag pointing at the testnet Horizon URL", () => { + process.env.STELLAR_NETWORK = "mainnet"; + process.env.HORIZON_URLS = "https://horizon-testnet.stellar.org"; + const result = validateStellarConfig(); + expect(result.valid).toBe(false); + expect(result.problems.join(" ")).toContain("testnet"); + expect(result.problems.join(" ")).toContain("horizon-testnet.stellar.org"); + }); + + it("rejects a testnet flag pointing at the mainnet Horizon URL", () => { + process.env.HORIZON_URLS = "https://horizon.stellar.org"; + const result = validateStellarConfig(); + expect(result.valid).toBe(false); + expect(result.problems.join(" ")).toContain("mainnet"); + }); + + it("rejects a non-URL HORIZON_URLS entry", () => { + process.env.HORIZON_URLS = "not-a-url,https://horizon-testnet.stellar.org"; + const result = validateStellarConfig(); + expect(result.valid).toBe(false); + expect(result.problems.join(" ")).toContain("not-a-url"); + }); + + it("allows custom (non-canonical) Horizon URLs on the right network", () => { + process.env.HORIZON_URLS = "https://custom.stellar.org"; + const result = validateStellarConfig(); + expect(result.valid).toBe(true); + }); + + it("rejects a mainnet config whose resolved USDC issuer is the testnet issuer", () => { + process.env.STELLAR_NETWORK = "mainnet"; + // Simulate a corrupted/mismatched registry: mainnet network but testnet issuer. + getAssetConfig.mockImplementation((code) => + code === "USDC" + ? { code: "USDC", issuer: testnetIssuer, isDefault: true } + : null + ); + const result = validateStellarConfig(); + expect(result.valid).toBe(false); + expect(result.problems.join(" ")).toContain("USDC issuer mismatch"); + expect(result.problems.join(" ")).toContain(testnetIssuer); + }); +}); diff --git a/src/config/validateEnv.js b/src/config/validateEnv.js index 8b7fa1ae..17b915da 100644 --- a/src/config/validateEnv.js +++ b/src/config/validateEnv.js @@ -1,4 +1,6 @@ import logger from "./logger.js"; +import { validateStellarConfig, resolveStellarConfig } from "./stellar.js"; +import { validateFeeSponsorBootConfig } from "../services/stellar/feeSponsorService.js"; /** * Validate required environment variables @@ -53,26 +55,69 @@ const optionalEnvVars = [ "SIGNING_KEY", "INGESTION_WORKER_ENABLED", "INGESTION_POLL_INTERVAL_MS", + // Outbound webhooks (issue #45). WEBHOOK_SECRET_ENCRYPTION_KEY is + // security-critical (fail-fast in production, see below); the rest are + // tunables with sensible defaults. + "WEBHOOK_SECRET_ENCRYPTION_KEY", + "WEBHOOK_WORKER_ENABLED", + "WEBHOOK_POLL_INTERVAL_MS", + "WEBHOOK_MAX_ATTEMPTS", + "WEBHOOK_AUTO_DISABLE_THRESHOLD", + "WEBHOOK_BACKOFF_JITTER_MS", + "WEBHOOK_HTTP_TIMEOUT_MS", + "WEBHOOK_API_VERSION", + // Service-to-service auth keys for the AI service (dnb-ai). Required in + // production (fail-fast below); optional in development/test. + "AI_SERVICE_KEYS", + // Fee-bump sponsorship (#30). All optional — a boot with none of these set + // (the default) is unchanged. When FEE_SPONSOR_ENABLED=true, the secret is + // validated below and a bad/missing secret fails fast. + "FEE_SPONSOR_ENABLED", + "FEE_SPONSOR_SECRET", + "FEE_SPONSOR_MAX_FEE_STROOPS", + "FEE_SPONSOR_DAILY_CAP_STROOPS", + "FEE_SPONSOR_PER_USER_DAILY_LIMIT", ]; export const validateEnv = () => { + // Test mode: provide defaults for development of tests + if (process.env.NODE_ENV === "test") { + process.env.JWT_SECRET = process.env.JWT_SECRET || "test-secret-key-at-least-32-characters-long"; + process.env.MONGO_URI = process.env.MONGO_URI || "mongodb://test-db:27017/dnb-test"; + process.env.PORT = process.env.PORT || "5000"; + } + // Default values for TTLs if not provided process.env.ACCESS_TOKEN_TTL = process.env.ACCESS_TOKEN_TTL || "15m"; process.env.REFRESH_TOKEN_TTL = process.env.REFRESH_TOKEN_TTL || "30d"; - // Default values for Horizon resilient client if not provided - const network = process.env.STELLAR_NETWORK || "testnet"; + // Default values for Horizon resilient client if not provided. The + // network-aware default comes from the single source of truth + // (config/stellar.js), so the env var always reflects what the app will + // actually use after boot. if (!process.env.HORIZON_URLS) { - process.env.HORIZON_URLS = - network === "mainnet" - ? "https://horizon.stellar.org" - : "https://horizon-testnet.stellar.org"; + process.env.HORIZON_URLS = resolveStellarConfig().horizonUrls.join(","); } process.env.HORIZON_TIMEOUT_MS = process.env.HORIZON_TIMEOUT_MS || "10000"; process.env.HORIZON_MAX_RETRIES = process.env.HORIZON_MAX_RETRIES || "3"; process.env.HORIZON_CB_THRESHOLD = process.env.HORIZON_CB_THRESHOLD || "5"; process.env.HORIZON_CB_COOLDOWN_MS = process.env.HORIZON_CB_COOLDOWN_MS || "30000"; + // Fail fast on a misconfigured Stellar setup (bad STELLAR_NETWORK value, + // mainnet flag with testnet Horizon/issuer, etc.) instead of failing at + // request time on the first Horizon call. + const stellarValidation = validateStellarConfig(); + if (!stellarValidation.valid) { + for (const problem of stellarValidation.problems) { + logger.error(`❌ Stellar configuration error: ${problem}`); + } + logger.error( + "Stellar configuration is invalid — fix the issues above before starting. " + + "See docs/MAINNET.md for the mainnet checklist." + ); + process.exit(1); + } + const missing = []; requiredEnvVars.forEach((envVar) => { @@ -81,6 +126,23 @@ export const validateEnv = () => { } }); + // Service-to-service auth keys are REQUIRED in production so a misconfigured + // deploy fails fast rather than leaving the AI channel open/unauthenticated. + // In development/test they stay optional. + if (process.env.NODE_ENV === "production" && !process.env.AI_SERVICE_KEYS) { + missing.push("AI_SERVICE_KEYS"); + } + + // Webhook signing secrets are stored encrypted at rest; the encryption key + // is security-critical, so a production deploy must set it explicitly rather + // than fall back to the built-in dev key. + if ( + process.env.NODE_ENV === "production" && + !process.env.WEBHOOK_SECRET_ENCRYPTION_KEY + ) { + missing.push("WEBHOOK_SECRET_ENCRYPTION_KEY"); + } + if (missing.length > 0) { logger.error( `❌ Missing required environment variables: ${missing.join(", ")}` @@ -91,6 +153,15 @@ export const validateEnv = () => { process.exit(1); } + // Fee-bump sponsorship (#30): when the master switch is on, a missing or + // invalid sponsor secret is a hard boot failure so a misconfigured deploy + // never silently disables sponsorship or ships an unusable key. + const feeSponsor = validateFeeSponsorBootConfig(); + if (!feeSponsor.ok) { + logger.error(`❌ Fee sponsorship misconfigured: ${feeSponsor.message}`); + process.exit(1); + } + // Check JWT_SECRET strength if (process.env.JWT_SECRET && process.env.JWT_SECRET.length < 32) { logger.warn( diff --git a/src/controllers/admin/educatorVerificationAdminController.js b/src/controllers/admin/educatorVerificationAdminController.js new file mode 100644 index 00000000..480dc325 --- /dev/null +++ b/src/controllers/admin/educatorVerificationAdminController.js @@ -0,0 +1,247 @@ +import cloudinary from "../../utils/cloudinary.js"; +import mongoose from "mongoose"; +import { catchAsync, APIError } from "../../middlewares/errorHandler.js"; +import EducatorVerification, { + VERIFICATION_STATUS, +} from "../../models/EducatorVerification.js"; +import User from "../../models/User.js"; +import { AUDIT_ACTIONS } from "../../models/AuditLog.js"; +import { recordAudit } from "../../services/audit/auditService.js"; + +const SIGNED_URL_TTL_SECONDS = 600; + +const buildSignedUrl = (publicId) => { + const config = cloudinary.config(); + if (!config.cloud_name || !config.api_key || !config.api_secret) { + return null; + } + try { + return cloudinary.url(publicId, { + sign_url: true, + secure: true, + expires_at: Math.floor(Date.now() / 1000) + SIGNED_URL_TTL_SECONDS, + }); + } catch (_) { + return null; + } +}; + +const serializeDocumentsWithSignedUrls = (docs) => + docs.map((d) => ({ + type: d.type, + originalFileName: d.originalFileName, + uploadedAt: d.uploadedAt, + signedUrl: buildSignedUrl(d.cloudinaryPublicId), + })); + +export const listApplications = catchAsync(async (req, res) => { + const { + status, + page = "1", + limit = "20", + } = req.query; + + const filter = {}; + if (status) { + const valid = Object.values(VERIFICATION_STATUS); + if (!valid.includes(status)) { + throw new APIError(`Invalid status. Must be one of: ${valid.join(", ")}`, 400); + } + filter.status = status; + } + + const pageNum = Math.max(1, parseInt(page, 10) || 1); + const limitNum = Math.min(100, Math.max(1, parseInt(limit, 10) || 20)); + const skip = (pageNum - 1) * limitNum; + + const [applications, total] = await Promise.all([ + EducatorVerification.find(filter) + .sort({ submittedAt: -1, createdAt: -1 }) + .skip(skip) + .limit(limitNum) + .populate("applicant", "name email role verifiedEducator") + .populate("reviewedBy", "name email") + .lean(), + EducatorVerification.countDocuments(filter), + ]); + + const serialized = applications.map((a) => ({ + ...a, + documents: serializeDocumentsWithSignedUrls(a.documents || []), + })); + + res.status(200).json({ + success: true, + applications: serialized, + pagination: { + page: pageNum, + limit: limitNum, + total, + pages: Math.ceil(total / limitNum), + }, + }); +}); + +export const getApplicationById = catchAsync(async (req, res) => { + const { id } = req.params; + + if (!mongoose.Types.ObjectId.isValid(id)) { + throw new APIError("Invalid application id", 400); + } + + const application = await EducatorVerification.findById(id) + .populate("applicant", "name email role verifiedEducator") + .populate("reviewedBy", "name email") + .lean(); + + if (!application) { + throw new APIError("Application not found", 404); + } + + const serialized = { + ...application, + documents: serializeDocumentsWithSignedUrls(application.documents || []), + }; + + res.status(200).json({ + success: true, + application: serialized, + }); +}); + +export const getAdminDocumentSignedUrl = catchAsync(async (req, res) => { + const { id, documentIndex } = req.params; + const idx = parseInt(documentIndex, 10); + + if (!mongoose.Types.ObjectId.isValid(id)) { + throw new APIError("Invalid application id", 400); + } + if (isNaN(idx) || idx < 0) { + throw new APIError("Invalid document index", 400); + } + + const verification = await EducatorVerification.findById(id); + if (!verification) { + throw new APIError("Application not found", 404); + } + if (idx >= verification.documents.length) { + throw new APIError("Document not found", 404); + } + + const doc = verification.documents[idx]; + const signedUrl = buildSignedUrl(doc.cloudinaryPublicId); + + if (!signedUrl) { + throw new APIError("Unable to generate signed URL at this time", 503); + } + + res.status(200).json({ + success: true, + data: { + signedUrl, + expiresInSeconds: SIGNED_URL_TTL_SECONDS, + }, + }); +}); + +const performReview = async (req, res, targetStatus, auditAction) => { + const { id } = req.params; + const { reviewNotes } = req.body || {}; + const reviewerId = req.user._id; + + if (!mongoose.Types.ObjectId.isValid(id)) { + throw new APIError("Invalid application id", 400); + } + + const verification = await EducatorVerification.findById(id); + if (!verification) { + throw new APIError("Application not found", 404); + } + + const previousStatus = verification.status; + + if (!verification.canTransitionTo(targetStatus)) { + throw new APIError( + `Cannot transition from '${previousStatus}' to '${targetStatus}'`, + 409 + ); + } + + const applicantId = verification.applicant; + + const session = await mongoose.startSession(); + session.startTransaction(); + + try { + verification.status = targetStatus; + verification.reviewedBy = reviewerId; + verification.reviewNotes = reviewNotes || null; + verification.reviewedAt = new Date(); + await verification.save({ session }); + + if (targetStatus === VERIFICATION_STATUS.APPROVED) { + await User.updateOne( + { _id: applicantId }, + { $set: { verifiedEducator: true } }, + { session } + ); + } + + await session.commitTransaction(); + session.endSession(); + } catch (err) { + await session.abortTransaction(); + session.endSession(); + throw err; + } + + await recordAudit({ + action: auditAction, + actor: reviewerId, + req, + targetType: "EducatorVerification", + targetId: verification._id.toString(), + status: "success", + metadata: { + verificationId: verification._id.toString(), + previousStatus, + newStatus: targetStatus, + reviewedBy: reviewerId.toString(), + reviewNotes: reviewNotes || null, + educatorId: applicantId.toString(), + }, + }); + + res.status(200).json({ + success: true, + message: + targetStatus === VERIFICATION_STATUS.APPROVED + ? "Application approved — educator now verified" + : "Application rejected", + application: { + _id: verification._id, + status: verification.status, + reviewedBy: verification.reviewedBy, + reviewNotes: verification.reviewNotes, + reviewedAt: verification.reviewedAt, + }, + }); +}; + +export const approveApplication = catchAsync(async (req, res) => { + return performReview( + req, + res, + VERIFICATION_STATUS.APPROVED, + AUDIT_ACTIONS.EDUCATOR_VERIFY_APPROVE + ); +}); + +export const rejectApplication = catchAsync(async (req, res) => { + return performReview( + req, + res, + VERIFICATION_STATUS.REJECTED, + AUDIT_ACTIONS.EDUCATOR_VERIFY_REJECT + ); +}); diff --git a/src/controllers/analytics/analyticsController.js b/src/controllers/analytics/analyticsController.js index 378f7b74..6454b4a5 100644 --- a/src/controllers/analytics/analyticsController.js +++ b/src/controllers/analytics/analyticsController.js @@ -2,6 +2,8 @@ import mongoose from "mongoose"; import logger from "../../config/logger.js"; import Course from "../../models/Course.js"; import CourseProgress from "../../models/CourseProgress.js"; +import certificateService from "../../services/certificate.service.js"; +import badgeService from "../../services/badge.service.js"; const toObjectId = (value) => { if (!value) return null; diff --git a/src/controllers/analytics/courseAnalyticsController.js b/src/controllers/analytics/courseAnalyticsController.js new file mode 100644 index 00000000..1458e7f3 --- /dev/null +++ b/src/controllers/analytics/courseAnalyticsController.js @@ -0,0 +1,107 @@ +// controllers/analytics/courseAnalyticsController.js +// +// Creator-facing HTTP handlers for course analytics. Ownership of a single +// course is enforced upstream by the authorizeOwnership middleware (which loads +// the course as req.resource); the overview handler is implicitly scoped to the +// authenticated creator. All handlers are read-only. + +import mongoose from "mongoose"; +import { catchAsync, APIError } from "../../middlewares/errorHandler.js"; +import { + getCourseAnalytics, + getCreatorOverview, +} from "../../services/analytics/courseAnalyticsService.js"; +import { analyticsToCsv } from "../../utils/analyticsCalculator.js"; + +/** + * Extract and lightly validate the optional startDate/endDate query params. + * + * @param {import("express").Request} req + * @returns {{startDate?: string, endDate?: string}} + */ +const readDateRange = (req) => { + const { startDate, endDate } = req.query; + return { startDate, endDate }; +}; + +/** + * GET /api/courses/analytics/:courseId + * + * Full analytics for a single course. Requires the caller to own the course + * (enforced by authorizeOwnership, which attaches req.resource). + */ +export const getCourseAnalyticsHandler = catchAsync(async (req, res, next) => { + const { courseId } = req.params; + if (!mongoose.Types.ObjectId.isValid(courseId)) { + return next(new APIError("Invalid course id", 400)); + } + + const analytics = await getCourseAnalytics(courseId, { + ...readDateRange(req), + courseDoc: req.resource, // provided by authorizeOwnership + }); + + if (!analytics) { + return next(new APIError("Course not found", 404)); + } + + res.status(200).json({ success: true, analytics }); +}); + +/** + * GET /api/courses/analytics/:courseId/export?format=csv + * + * Export a single course's analytics. Defaults to CSV; `format=json` returns + * the raw payload. (PDF is intentionally out of scope for this endpoint.) + */ +export const exportCourseAnalyticsHandler = catchAsync(async (req, res, next) => { + const { courseId } = req.params; + if (!mongoose.Types.ObjectId.isValid(courseId)) { + return next(new APIError("Invalid course id", 400)); + } + + const format = String(req.query.format || "csv").toLowerCase(); + + const analytics = await getCourseAnalytics(courseId, { + ...readDateRange(req), + courseDoc: req.resource, + }); + + if (!analytics) { + return next(new APIError("Course not found", 404)); + } + + if (format === "json") { + return res.status(200).json({ success: true, analytics }); + } + + if (format !== "csv") { + return next( + new APIError("Unsupported export format. Use 'csv' or 'json'.", 400) + ); + } + + const csv = analyticsToCsv(analytics); + const filename = `course-${analytics.courseId}-analytics.csv`; + + res.setHeader("Content-Type", "text/csv; charset=utf-8"); + res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); + res.status(200).send(csv); +}); + +/** + * GET /api/courses/analytics/overview + * + * Portfolio-level analytics across every course owned by the authenticated + * creator, with an aggregate roll-up. + */ +export const getCreatorOverviewHandler = catchAsync(async (req, res) => { + const overview = await getCreatorOverview(req.user._id, readDateRange(req)); + res.status(200).json({ success: true, overview }); +}); + +export default { + getCourseAnalyticsHandler, + exportCourseAnalyticsHandler, + getCreatorOverviewHandler, +}; diff --git a/src/controllers/authController.js b/src/controllers/authController.js index 3aa4b37a..5c4d7edf 100644 --- a/src/controllers/authController.js +++ b/src/controllers/authController.js @@ -1,5 +1,5 @@ // controllers/authController.js -import bcrypt from "bcrypt"; +import bcrypt from "bcryptjs"; import jwt from "jsonwebtoken"; import crypto from "crypto"; import User from "../models/User.js"; @@ -12,21 +12,51 @@ import { recordAudit } from "../services/audit/auditService.js"; import { AUDIT_ACTIONS } from "../models/AuditLog.js"; import { generateOtp, hashOtp, verifyOtp } from "../utils/otp.js"; import { firstPasswordIssue } from "../utils/passwordPolicy.js"; +import { isPasswordBreached } from "../utils/hibp.js"; import { catchAsync, APIError } from "../middlewares/errorHandler.js"; +import qrcode from "qrcode"; +import { + encryptSecret, + decryptSecret, + generateRecoveryCodes, + verifyAndConsumeRecoveryCode, + generateBase32Secret, + verifyTOTPCode, + generateOtpauthUrl, +} from "../utils/twoFactorCrypto.js"; + +// Runtime bootstrap validates this before serving traffic. Resolving it when a +// token operation occurs keeps importing the Express app free of process-wide +// configuration side effects for unit and integration tests. +const getJwtSecret = () => { + const secret = process.env.JWT_SECRET; + if (!secret) { + throw new Error("JWT_SECRET is required to sign or verify auth tokens"); + } + return secret; +}; -const JWT_SECRET = process.env.JWT_SECRET || "deenbridge-temp-secret-key-2024"; - -// Log JWT configuration on startup and warn if using fallback -if (!process.env.JWT_SECRET) { - logger.warn( - "⚠️ WARNING: JWT_SECRET not found in .env! Using fallback (INSECURE for production)" +// ── Progressive login lockout (issue #89) ─────────────────────────────────── +// After LOGIN_MAX_FAILED_ATTEMPTS consecutive failures, the account is locked +// for an escalating duration. Each new failure while unlocked (re)extends the +// lock with exponential backoff, capped at LOGIN_LOCKOUT_MAX_MS. +const LOGIN_MAX_FAILED_ATTEMPTS = + parseInt(process.env.LOGIN_MAX_ATTEMPTS, 10) || 5; +const LOGIN_LOCKOUT_BASE_MS = + parseInt(process.env.LOGIN_LOCKOUT_BASE_MS, 10) || 60 * 1000; // 1 min +const LOGIN_LOCKOUT_MAX_MS = + parseInt(process.env.LOGIN_LOCKOUT_MAX_MS, 10) || 24 * 60 * 60 * 1000; // 24 h +const LOGIN_LOCKOUT_MULTIPLIER = 2; + +/** Escalating backoff: base * 2^(failures - threshold), capped at the max. */ +const lockoutDurationMs = (failedAttempts) => + Math.min( + LOGIN_LOCKOUT_MAX_MS, + LOGIN_LOCKOUT_BASE_MS * + LOGIN_LOCKOUT_MULTIPLIER ** + Math.max(0, failedAttempts - LOGIN_MAX_FAILED_ATTEMPTS) ); -} else { - logger.info( - `✅ JWT_SECRET loaded from .env (length: ${process.env.JWT_SECRET.length})` - ); -} // Helper: parse duration string to ms (e.g. 15m, 30d) export const parseDurationToMs = (duration) => { @@ -81,7 +111,8 @@ export const shapeAuthUser = (user) => ({ }); // Helper: generate new session + refresh token + cookie + access token -export const createSessionAndTokens = async (user, req, res) => { +export const createSessionAndTokens = async (user, req, res, options = {}) => { + const is2FAVerified = options.is2FAVerified === true; const rawRefreshToken = crypto.randomBytes(32).toString("hex"); const refreshTokenHash = crypto.createHash("sha256").update(rawRefreshToken).digest("hex"); @@ -100,12 +131,13 @@ export const createSessionAndTokens = async (user, req, res) => { label: getDeviceLabel(req.headers["user-agent"]), }, expiresAt, + is2FAVerified, }); const accessTokenTtl = process.env.ACCESS_TOKEN_TTL || "15m"; const accessToken = jwt.sign( - { userId: user._id, role: user.role, sessionId: session._id }, - JWT_SECRET, + { userId: user._id, role: user.role, sessionId: session._id, is2FAVerified }, + getJwtSecret(), { expiresIn: accessTokenTtl } ); @@ -144,6 +176,29 @@ export const registerUser = catchAsync(async (req, res, next) => { return next(new APIError(passwordIssue, 400)); } + // Reject passwords that appear in real-world breach dumps (HIBP range API, + // SHA-1 prefix only — the password is never transmitted). Fails open on a + // HIBP outage so signups don't break. + const breached = await isPasswordBreached(password); + if (breached) { + logger.warn(`❌ Registration failed - breached password: ${email}`); + recordAudit({ + action: AUDIT_ACTIONS.AUTH_REGISTER_FAILURE, + actor: null, + req, + targetType: "User", + targetId: email, + status: "failure", + metadata: { email, reason: "breached_password" }, + }); + return next( + new APIError( + "This password has appeared in a known data breach. Please choose a different one.", + 400 + ) + ); + } + // Check if user already exists const existing = await User.findOne({ email }); if (existing) { @@ -366,9 +421,62 @@ export const loginUser = catchAsync(async (req, res, next) => { return next(new APIError("Invalid credentials", 401)); } + // Per-account lockout: reject BEFORE running bcrypt while the account is + // locked. Respond with the SAME generic "Invalid credentials" used for a + // nonexistent account so a locked account is indistinguishable from one that + // does not exist (no enumeration). The lock itself is recorded in the audit + // log for operators. + const isLocked = user.lockUntil && new Date(user.lockUntil) > new Date(); + if (isLocked) { + logger.warn(`🔒 Login blocked - account locked: ${email}`); + recordAudit({ + action: AUDIT_ACTIONS.AUTH_ACCOUNT_LOCKED, + actor: user._id, + req, + targetType: "User", + targetId: user._id.toString(), + status: "failure", + metadata: { email, reason: "account_locked" }, + }); + return next(new APIError("Invalid credentials", 401)); + } + // Verify password const isPasswordCorrect = await bcrypt.compare(password, user.password); if (!isPasswordCorrect) { + // Atomic increment — the DB is the single source of truth for the counter, + // so concurrent login attempts cannot race a read-then-write snapshot. + const updated = await User.findByIdAndUpdate( + user._id, + { $inc: { failedLoginAttempts: 1 } }, + { new: true } + ); + const failedAttempts = updated?.failedLoginAttempts ?? 1; + user.failedLoginAttempts = failedAttempts; + + if (failedAttempts >= LOGIN_MAX_FAILED_ATTEMPTS) { + const lockUntil = new Date( + Date.now() + lockoutDurationMs(failedAttempts) + ); + await User.updateOne( + { _id: user._id }, + { $set: { lockUntil } } + ); + user.lockUntil = lockUntil; + logger.warn( + `🔒 Account locked after ${failedAttempts} failed attempts: ${email}` + ); + recordAudit({ + action: AUDIT_ACTIONS.AUTH_ACCOUNT_LOCKED, + actor: user._id, + req, + targetType: "User", + targetId: user._id.toString(), + status: "failure", + metadata: { email, reason: "account_locked" }, + }); + } + logger.warn(`❌ Login failed - Incorrect password: ${email}`); recordAudit({ action: AUDIT_ACTIONS.AUTH_LOGIN_FAILURE, @@ -382,23 +490,59 @@ export const loginUser = catchAsync(async (req, res, next) => { return next(new APIError("Invalid credentials", 401)); } - // Auto-promote whitelisted admin emails (self-healing for existing accounts) + // Auto-promote whitelisted admin emails (self-healing for existing accounts, only if 2FA is enabled) const ADMIN_EMAILS = (process.env.ADMIN_EMAILS || "") .split(",") .map((e) => e.trim().toLowerCase()) .filter(Boolean); if (ADMIN_EMAILS.includes(user.email.toLowerCase()) && user.role !== "admin") { - user.role = "admin"; - await user.save({ validateBeforeSave: false }); - logger.info(`👑 Promoted ${user.email} to admin (whitelisted account)`); + if (user.twoFactor?.enabled) { + user.role = "admin"; + await user.save({ validateBeforeSave: false }); + logger.info(`👑 Promoted ${user.email} to admin (whitelisted account with 2FA)`); + } else { + logger.warn(`⚠️ Whitelisted admin account ${user.email} not promoted because 2FA is not enabled`); + } + } + + // If 2FA is enabled for this user, issue a short-lived MFA challenge token instead of session tokens + if (user.twoFactor?.enabled) { + const mfaToken = jwt.sign( + { userId: user._id, type: "mfa_challenge" }, + getJwtSecret(), + { expiresIn: "5m" } + ); + + logger.info(`🔐 2FA step-up challenge issued for: ${email}`); + + recordAudit({ + action: AUDIT_ACTIONS.AUTH_2FA_LOGIN_CHALLENGE, + actor: user._id, + req, + targetType: "User", + targetId: user._id.toString(), + status: "success", + metadata: { email, mfaRequired: true }, + }); + + return res.status(200).json({ + success: true, + mfaRequired: true, + mfaToken, + message: "Two-factor authentication required", + }); } - // Update last login + // Update last login and clear any lockout state (success is the reset). user.lastLogin = new Date(); + if (user.failedLoginAttempts || user.lockUntil) { + user.failedLoginAttempts = 0; + user.lockUntil = null; + } await user.save({ validateBeforeSave: false }); - // Generate session and tokens + // Generate session and tokens (for users without 2FA) const { accessToken, refreshToken } = await createSessionAndTokens(user, req, res); logger.info(`✅ Login successful: ${email} (ID: ${user._id})`); @@ -536,6 +680,19 @@ export const resetPassword = async (req, res) => { return res.status(400).json({ success: false, message: "Invalid or expired OTP" }); } + // A reset must not be a way around a breached-password rejection either. + // Run only AFTER the caller proves account ownership via the OTP, so an + // unauthenticated attacker cannot trigger HIBP lookups for arbitrary emails. + const breached = await isPasswordBreached(newPassword); + if (breached) { + logger.warn(`❌ Password reset failed - breached password: ${email}`); + return res.status(400).json({ + success: false, + message: + "This password has appeared in a known data breach. Please choose a different one.", + }); + } + // Hash new password using cost factor 12 (aligned with registerUser) const hashedPassword = await bcrypt.hash(newPassword, 12); user.password = hashedPassword; @@ -599,6 +756,7 @@ export const refreshSession = catchAsync(async (req, res, next) => { } // Perform rotation + const is2FAVerified = session.is2FAVerified === true; const newRawToken = crypto.randomBytes(32).toString("hex"); const newHash = crypto.createHash("sha256").update(newRawToken).digest("hex"); @@ -616,6 +774,7 @@ export const refreshSession = catchAsync(async (req, res, next) => { label: getDeviceLabel(req.headers["user-agent"]), }, expiresAt: newExpiresAt, + is2FAVerified, }); session.revokedAt = new Date(); @@ -625,8 +784,8 @@ export const refreshSession = catchAsync(async (req, res, next) => { const accessTokenTtl = process.env.ACCESS_TOKEN_TTL || "15m"; const accessToken = jwt.sign( - { userId: session.user._id, role: session.user.role, sessionId: newSession._id }, - JWT_SECRET, + { userId: session.user._id, role: session.user.role, sessionId: newSession._id, is2FAVerified }, + getJwtSecret(), { expiresIn: accessTokenTtl } ); @@ -828,3 +987,282 @@ export const changePassword = catchAsync(async (req, res, next) => { "Password changed successfully. Other devices have been signed out.", }); }); + +// ── TOTP 2FA Controllers ─────────────────────────────────────────────────── + +/** + * Enrollment endpoint (POST /api/auth/2fa/setup, protect) + * Generates secret, stores encrypted pendingSecret, returns otpauth URI & QR code. + */ +export const setup2FA = catchAsync(async (req, res, next) => { + const user = await User.findById(req.user._id).select("+twoFactor.secret +twoFactor.pendingSecret"); + if (!user) { + return next(new APIError("User not found", 404)); + } + + if (user.twoFactor?.enabled) { + return next(new APIError("Two-factor authentication is already enabled", 400)); + } + + const secret = generateBase32Secret(); + const encryptedSecret = encryptSecret(secret); + + if (!user.twoFactor) { + user.twoFactor = {}; + } + user.twoFactor.pendingSecret = encryptedSecret; + await user.save({ validateBeforeSave: false }); + + const otpauthUrl = generateOtpauthUrl(user.email, secret); + const qrCode = await qrcode.toDataURL(otpauthUrl); + + recordAudit({ + action: AUDIT_ACTIONS.AUTH_2FA_SETUP_INITIATED, + actor: user._id, + req, + targetType: "User", + targetId: user._id.toString(), + status: "success", + metadata: { email: user.email }, + }); + + res.status(200).json({ + success: true, + secret, + otpauthUrl, + qrCode, + message: "2FA setup initiated. Scan QR code or enter secret into your authenticator app, then confirm with a 2FA code.", + }); +}); + +/** + * Confirm/Verify endpoint (POST /api/auth/2fa/verify) + * Supports: + * 1. Setup confirmation (with protect authorization header & code) + * 2. Login step-up completion (with mfaToken & code / recoveryCode) + */ +export const verify2FA = catchAsync(async (req, res, next) => { + const { code, recoveryCode, mfaToken } = req.body; + + // Branch A: Login step-up verification + if (mfaToken) { + let decoded; + try { + decoded = jwt.verify(mfaToken, getJwtSecret()); + } catch (err) { + return next(new APIError("Invalid or expired 2FA challenge token", 401)); + } + + if (decoded.type !== "mfa_challenge" || !decoded.userId) { + return next(new APIError("Invalid 2FA challenge token", 401)); + } + + const user = await User.findById(decoded.userId).select("+twoFactor.secret +twoFactor.recoveryCodes"); + if (!user || !user.twoFactor?.enabled) { + return next(new APIError("Two-factor authentication is not enabled for this user", 400)); + } + + let isValid = false; + let isRecovery = false; + + if (code) { + const decryptedSecret = decryptSecret(user.twoFactor.secret); + isValid = verifyTOTPCode(code.toString(), decryptedSecret); + } + + if (!isValid && recoveryCode) { + isValid = await verifyAndConsumeRecoveryCode(user, recoveryCode); + if (isValid) isRecovery = true; + } + + // Fallback: if recovery code was passed in the code field + if (!isValid && code && !recoveryCode) { + isValid = await verifyAndConsumeRecoveryCode(user, code); + if (isValid) isRecovery = true; + } + + if (!isValid) { + recordAudit({ + action: AUDIT_ACTIONS.AUTH_2FA_LOGIN_FAILURE, + actor: user._id, + req, + targetType: "User", + targetId: user._id.toString(), + status: "failure", + metadata: { email: user.email, reason: "invalid_2fa_code" }, + }); + return next(new APIError("Invalid 2FA code or recovery code", 401)); + } + + // Auto-promote whitelisted admin emails now that 2FA is verified & enabled + const ADMIN_EMAILS = (process.env.ADMIN_EMAILS || "") + .split(",") + .map((e) => e.trim().toLowerCase()) + .filter(Boolean); + + if (ADMIN_EMAILS.includes(user.email.toLowerCase()) && user.role !== "admin") { + user.role = "admin"; + logger.info(`👑 Promoted ${user.email} to admin (whitelisted account with 2FA)`); + } + + user.lastLogin = new Date(); + await user.save({ validateBeforeSave: false }); + + if (isRecovery) { + recordAudit({ + action: AUDIT_ACTIONS.AUTH_2FA_RECOVERY_USED, + actor: user._id, + req, + targetType: "User", + targetId: user._id.toString(), + status: "success", + metadata: { email: user.email, recoveryCodeUsed: true }, + }); + } + + const { accessToken, refreshToken } = await createSessionAndTokens(user, req, res, { is2FAVerified: true }); + + recordAudit({ + action: AUDIT_ACTIONS.AUTH_2FA_LOGIN_SUCCESS, + actor: user._id, + req, + targetType: "User", + targetId: user._id.toString(), + status: "success", + metadata: { email: user.email, role: user.role }, + }); + + return res.status(200).json({ + success: true, + message: "Login successful", + accessToken, + refreshToken, + token: accessToken, + user: shapeAuthUser(user), + }); + } + + // Branch B: Setup confirmation + if (!req.user) { + return next(new APIError("Authentication required (provide authorization header or mfaToken)", 401)); + } + + if (!code) { + return next(new APIError("Please provide a 2FA code", 400)); + } + + const user = await User.findById(req.user._id).select("+twoFactor.pendingSecret +twoFactor.secret +twoFactor.recoveryCodes"); + if (!user || !user.twoFactor?.pendingSecret) { + return next(new APIError("No 2FA setup in progress. Please call setup first.", 400)); + } + + const decryptedSecret = decryptSecret(user.twoFactor.pendingSecret); + const isValid = verifyTOTPCode(code.toString(), decryptedSecret); + + if (!isValid) { + recordAudit({ + action: AUDIT_ACTIONS.AUTH_2FA_ENABLE_FAILURE, + actor: user._id, + req, + targetType: "User", + targetId: user._id.toString(), + status: "failure", + metadata: { email: user.email, reason: "invalid_confirmation_code" }, + }); + return next(new APIError("Invalid 2FA verification code", 401)); + } + + // Generate single-use recovery codes + const { plainCodes, hashedCodes } = await generateRecoveryCodes(10); + + user.twoFactor.secret = user.twoFactor.pendingSecret; + user.twoFactor.pendingSecret = undefined; + user.twoFactor.enabled = true; + user.twoFactor.recoveryCodes = hashedCodes; + user.twoFactor.enrolledAt = new Date(); + await user.save({ validateBeforeSave: false }); + + recordAudit({ + action: AUDIT_ACTIONS.AUTH_2FA_ENABLE_SUCCESS, + actor: user._id, + req, + targetType: "User", + targetId: user._id.toString(), + status: "success", + metadata: { email: user.email, twoFactorEnabled: true }, + }); + + return res.status(200).json({ + success: true, + message: "Two-factor authentication enabled successfully", + recoveryCodes: plainCodes, + }); +}); + +/** + * Disable 2FA endpoint (POST /api/auth/2fa/disable, protect) + * Requires a valid code or recovery code. + */ +export const disable2FA = catchAsync(async (req, res, next) => { + const { code, recoveryCode } = req.body; + + if (!code && !recoveryCode) { + return next(new APIError("Please provide a 2FA code or recovery code to disable 2FA", 400)); + } + + const user = await User.findById(req.user._id).select("+twoFactor.secret +twoFactor.recoveryCodes"); + if (!user || !user.twoFactor?.enabled) { + return next(new APIError("Two-factor authentication is not enabled", 400)); + } + + let isValid = false; + + if (code) { + const decryptedSecret = decryptSecret(user.twoFactor.secret); + isValid = verifyTOTPCode(code.toString(), decryptedSecret); + } + + if (!isValid && recoveryCode) { + isValid = await verifyAndConsumeRecoveryCode(user, recoveryCode); + } + + if (!isValid && code && !recoveryCode) { + isValid = await verifyAndConsumeRecoveryCode(user, code); + } + + if (!isValid) { + recordAudit({ + action: AUDIT_ACTIONS.AUTH_2FA_DISABLE_FAILURE, + actor: user._id, + req, + targetType: "User", + targetId: user._id.toString(), + status: "failure", + metadata: { email: user.email, reason: "invalid_code" }, + }); + return next(new APIError("Invalid 2FA code or recovery code", 401)); + } + + user.twoFactor.enabled = false; + user.twoFactor.secret = undefined; + user.twoFactor.pendingSecret = undefined; + user.twoFactor.recoveryCodes = []; + user.twoFactor.enrolledAt = undefined; + + await user.save({ validateBeforeSave: false }); + + recordAudit({ + action: AUDIT_ACTIONS.AUTH_2FA_DISABLE_SUCCESS, + actor: user._id, + req, + targetType: "User", + targetId: user._id.toString(), + status: "success", + metadata: { email: user.email, twoFactorEnabled: false }, + }); + + res.status(200).json({ + success: true, + message: "Two-factor authentication disabled successfully", + }); +}); diff --git a/src/controllers/badge.controller.js b/src/controllers/badge.controller.js new file mode 100644 index 00000000..2f19a3ce --- /dev/null +++ b/src/controllers/badge.controller.js @@ -0,0 +1,57 @@ +import badgeService from "../services/badge.service.js"; + +export const getUserBadgesController = async (req, res) => { + try { + const userId = req.params.userId || req.user?._id; + if (!userId) { + return res.status(400).json({ + success: false, + message: "User ID is required", + }); + } + + const userBadges = await badgeService.getUserBadges(userId); + res.status(200).json({ + success: true, + count: userBadges.length, + data: userBadges, + }); + } catch (error) { + res.status(500).json({ + success: false, + message: error.message, + }); + } +}; + +export const getAllBadgesController = async (req, res) => { + try { + const badges = await badgeService.getAllBadges(); + res.status(200).json({ + success: true, + count: badges.length, + data: badges, + }); + } catch (error) { + res.status(500).json({ + success: false, + message: error.message, + }); + } +}; + +export const checkBadgesController = async (req, res) => { + try { + const newlyAwarded = await badgeService.checkAndAwardBadges(req.user._id); + res.status(200).json({ + success: true, + newlyAwardedCount: newlyAwarded.length, + data: newlyAwarded, + }); + } catch (error) { + res.status(500).json({ + success: false, + message: error.message, + }); + } +}; diff --git a/src/controllers/badge.controller.ts b/src/controllers/badge.controller.ts new file mode 100644 index 00000000..2f19a3ce --- /dev/null +++ b/src/controllers/badge.controller.ts @@ -0,0 +1,57 @@ +import badgeService from "../services/badge.service.js"; + +export const getUserBadgesController = async (req, res) => { + try { + const userId = req.params.userId || req.user?._id; + if (!userId) { + return res.status(400).json({ + success: false, + message: "User ID is required", + }); + } + + const userBadges = await badgeService.getUserBadges(userId); + res.status(200).json({ + success: true, + count: userBadges.length, + data: userBadges, + }); + } catch (error) { + res.status(500).json({ + success: false, + message: error.message, + }); + } +}; + +export const getAllBadgesController = async (req, res) => { + try { + const badges = await badgeService.getAllBadges(); + res.status(200).json({ + success: true, + count: badges.length, + data: badges, + }); + } catch (error) { + res.status(500).json({ + success: false, + message: error.message, + }); + } +}; + +export const checkBadgesController = async (req, res) => { + try { + const newlyAwarded = await badgeService.checkAndAwardBadges(req.user._id); + res.status(200).json({ + success: true, + newlyAwardedCount: newlyAwarded.length, + data: newlyAwarded, + }); + } catch (error) { + res.status(500).json({ + success: false, + message: error.message, + }); + } +}; diff --git a/src/controllers/books/bookController.js b/src/controllers/books/bookController.js index 6ee3f7bd..9d7ffb6a 100644 --- a/src/controllers/books/bookController.js +++ b/src/controllers/books/bookController.js @@ -1,10 +1,12 @@ import axios from "axios"; +import mongoose from "mongoose"; import Book from "../../models/Book.js"; import User from "../../models/User.js"; import cloudinary from "../../utils/cloudinary.js"; import logger from "../../config/logger.js"; import { validateMagicBytes } from "../../utils/fileValidation.js"; import { createNewBookNotification } from "../notificationController.js"; +import { APIError, catchAsync } from "../../middlewares/errorHandler.js"; //cretae a book export const createBook = async (req, res) => { @@ -124,26 +126,34 @@ export const getBooksByAuthor = async (req, res) => { }; // delete book by id -export const deleteBook = async (req, res) => { - try { - const book = await Book.findById(req.params.id); - if (!book) { - return res.status(404).json({ success: false, message: "Book not found" }); - } +export const deleteBook = catchAsync(async (req, res, next) => { + const { id } = req.params; - if (req.user.role !== "admin" && book.author.toString() !== req.user._id.toString()) { - return res.status(403).json({ - success: false, - message: "Not authorized to delete this book", - }); - } + if (!mongoose.Types.ObjectId.isValid(id)) { + return next(new APIError("Invalid book id", 400)); + } - await Book.findByIdAndDelete(req.params.id); - res.json({ success: true, message: "Book deleted" }); - } catch (error) { - res.status(500).json({ success: false, message: error.message }); + const book = req.resource || (await Book.findById(id)); + if (!book) { + return next(new APIError("Book not found", 404)); } -}; + + const isOwner = + req.user?._id && book.author?.toString() === req.user._id.toString(); + const isAdmin = req.user?.role === "admin"; + if (!isOwner && !isAdmin) { + return next( + new APIError("You are not authorized to delete this book", 403) + ); + } + + await Book.findByIdAndDelete(book._id); + res.status(200).json({ + success: true, + message: "Book deleted", + data: null, + }); +}); // review books diff --git a/src/controllers/books/readingProgressController.js b/src/controllers/books/readingProgressController.js new file mode 100644 index 00000000..bc702ff4 --- /dev/null +++ b/src/controllers/books/readingProgressController.js @@ -0,0 +1,61 @@ +import readingProgressService from "../../services/reading-progress.service.js"; + +/** + * PUT /api/books/:bookId/progress + * Create or update the reader's progress for a book (upsert per user + book). + */ +export const updateReadingProgress = async (req, res) => { + try { + const { bookId } = req.params; + const userId = req.user._id; + const { page, totalPages, percentage, lastPosition, device } = req.body; + + const progress = await readingProgressService.upsertProgress({ + userId, + bookId, + page, + totalPages, + percentage, + lastPosition, + device, + }); + + res.status(200).json({ success: true, progress }); + } catch (error) { + const status = error.message === "Book not found" ? 404 : 400; + res.status(status).json({ success: false, message: error.message }); + } +}; + +/** + * GET /api/books/:bookId/progress + * Resume: return the last stored position for this user + book. + */ +export const getReadingProgress = async (req, res) => { + try { + const { bookId } = req.params; + const userId = req.user._id; + + const progress = await readingProgressService.getProgress({ userId, bookId }); + + res.status(200).json({ success: true, progress: progress || null }); + } catch (error) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +/** + * GET /api/books/library/progress + * The user's reading library with a progress percentage per book. + */ +export const getReadingLibrary = async (req, res) => { + try { + const userId = req.user._id; + + const library = await readingProgressService.getLibraryWithProgress({ userId }); + + res.status(200).json({ success: true, library }); + } catch (error) { + res.status(400).json({ success: false, message: error.message }); + } +}; diff --git a/src/controllers/categoryController.js b/src/controllers/categoryController.js new file mode 100644 index 00000000..01fa4b5d --- /dev/null +++ b/src/controllers/categoryController.js @@ -0,0 +1,118 @@ +import mongoose from "mongoose"; +import Category from "../models/Category.js"; +import Course from "../models/Course.js"; +import { deleteCachePattern } from "../utils/cache.js"; +import { slugifyCategory, uniqueCategorySlug } from "../services/categoryService.js"; + +const categoryProjection = { + name: 1, + slug: 1, + description: 1, + icon: 1, + image: 1, + parent: 1, + order: 1, +}; + +export const listCategories = async (_req, res) => { + const categories = await Category.aggregate([ + { $match: { isActive: true } }, + { + $lookup: { + from: "courses", + localField: "_id", + foreignField: "categoryRef", + as: "courses", + }, + }, + { + $addFields: { + courseCount: { $size: "$courses" }, + enrollmentCount: { + $sum: { + $map: { input: "$courses", as: "course", in: { $size: { $ifNull: ["$$course.enrolledUsers", []] } } }, + }, + }, + freeCount: { + $size: { $filter: { input: "$courses", as: "course", cond: { $eq: ["$$course.price", 0] } } }, + }, + paidCount: { + $size: { $filter: { input: "$courses", as: "course", cond: { $gt: ["$$course.price", 0] } } }, + }, + minPrice: { $cond: [{ $gt: [{ $size: "$courses" }, 0] }, { $min: "$courses.price" }, null] }, + maxPrice: { $cond: [{ $gt: [{ $size: "$courses" }, 0] }, { $max: "$courses.price" }, null] }, + }, + }, + { $project: { ...categoryProjection, courseCount: 1, enrollmentCount: 1, freeCount: 1, paidCount: 1, minPrice: 1, maxPrice: 1 } }, + { $sort: { order: 1, name: 1 } }, + ]); + res.json({ success: true, categories }); +}; + +export const getCategory = async (req, res) => { + const category = await Category.findOne({ slug: slugifyCategory(req.params.slug), isActive: true }) + .select(categoryProjection) + .lean(); + if (!category) return res.status(404).json({ success: false, message: "Category not found" }); + + const page = Math.max(1, Number(req.query.page) || 1); + const limit = Math.min(100, Math.max(1, Number(req.query.limit) || 20)); + const sorts = { newest: { createdAt: -1 }, popular: { enrolledUsers: -1 }, price: { price: 1 } }; + const sort = sorts[req.query.sort] || sorts.newest; + const filter = { categoryRef: category._id }; + const [courses, total] = await Promise.all([ + Course.find(filter).sort(sort).skip((page - 1) * limit).limit(limit).populate("createdBy", "name avatar"), + Course.countDocuments(filter), + ]); + res.json({ success: true, category, courses, pagination: { page, limit, total, pages: Math.ceil(total / limit) } }); +}; + +export const createCategory = async (req, res) => { + const { name, description, icon, image, parent, order, isActive } = req.body; + if (!name?.trim()) return res.status(400).json({ success: false, message: "Category name is required" }); + const duplicate = await Category.exists({ name: new RegExp(`^${name.trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "i") }); + if (duplicate) return res.status(409).json({ success: false, message: "Category name already exists" }); + if (parent) { + const parentCategory = await Category.findById(parent); + if (!parentCategory || parentCategory.parent) return res.status(400).json({ success: false, message: "Parent must be a top-level category" }); + } + const category = await Category.create({ name: name.trim(), slug: await uniqueCategorySlug(name), description, icon, image, parent: parent || null, order, isActive }); + await deleteCachePattern("categories:*"); + res.status(201).json({ success: true, category }); +}; + +export const updateCategory = async (req, res) => { + if (!mongoose.Types.ObjectId.isValid(req.params.id)) return res.status(400).json({ success: false, message: "Invalid category id" }); + const category = await Category.findById(req.params.id); + if (!category) return res.status(404).json({ success: false, message: "Category not found" }); + const allowed = ["description", "icon", "image", "order", "isActive"]; + for (const field of allowed) if (req.body[field] !== undefined) category[field] = req.body[field]; + if (req.body.name && req.body.name.trim() !== category.name) { + category.name = req.body.name.trim(); + category.slug = await uniqueCategorySlug(category.name, category._id); + } + if (req.body.parent !== undefined) { + if (req.body.parent) { + const parent = await Category.findById(req.body.parent); + if (!parent || parent.parent || parent._id.equals(category._id)) return res.status(400).json({ success: false, message: "Invalid parent category" }); + } + category.parent = req.body.parent || null; + } + await category.save(); + await Promise.all([deleteCachePattern("categories:*"), deleteCachePattern("courses:*")]); + res.json({ success: true, category }); +}; + +export const deleteCategory = async (req, res) => { + const category = await Category.findById(req.params.id); + if (!category) return res.status(404).json({ success: false, message: "Category not found" }); + const courseCount = await Course.countDocuments({ categoryRef: category._id }); + if (courseCount > 0) { + category.isActive = false; + await category.save(); + } else { + await category.deleteOne(); + } + await Promise.all([deleteCachePattern("categories:*"), deleteCachePattern("courses:*")]); + res.json({ success: true, softDeleted: courseCount > 0 }); +}; diff --git a/src/controllers/certificate.controller.js b/src/controllers/certificate.controller.js new file mode 100644 index 00000000..40c4b96a --- /dev/null +++ b/src/controllers/certificate.controller.js @@ -0,0 +1,78 @@ +import certificateService from "../services/certificate.service.js"; + +export const generateCertificateController = async (req, res) => { + try { + const { courseId } = req.body; + const userId = req.user._id; + + if (!courseId) { + return res.status(400).json({ + success: false, + message: "courseId is required", + }); + } + + const certificate = await certificateService.generateCertificate({ userId, courseId }); + res.status(201).json({ + success: true, + data: certificate, + }); + } catch (error) { + res.status(400).json({ + success: false, + message: error.message, + }); + } +}; + +export const getCertificateByIdController = async (req, res) => { + try { + const certificate = await certificateService.getCertificateById(req.params.id); + res.status(200).json({ + success: true, + data: certificate, + }); + } catch (error) { + res.status(404).json({ + success: false, + message: error.message, + }); + } +}; + +export const getUserCertificatesController = async (req, res) => { + try { + const userId = req.params.userId || req.user._id; + const certificates = await certificateService.getUserCertificates(userId); + res.status(200).json({ + success: true, + count: certificates.length, + data: certificates, + }); + } catch (error) { + res.status(500).json({ + success: false, + message: error.message, + }); + } +}; + +export const downloadCertificateController = async (req, res) => { + try { + const certificate = await certificateService.getCertificateById(req.params.id); + const pdfBuffer = await certificateService.generatePDFBuffer(certificate); + + res.setHeader("Content-Type", "application/pdf"); + res.setHeader( + "Content-Disposition", + `attachment; filename="${certificate.certificateId}.pdf"` + ); + res.setHeader("Content-Length", pdfBuffer.length); + res.send(pdfBuffer); + } catch (error) { + res.status(404).json({ + success: false, + message: error.message, + }); + } +}; diff --git a/src/controllers/certificate.controller.ts b/src/controllers/certificate.controller.ts new file mode 100644 index 00000000..40c4b96a --- /dev/null +++ b/src/controllers/certificate.controller.ts @@ -0,0 +1,78 @@ +import certificateService from "../services/certificate.service.js"; + +export const generateCertificateController = async (req, res) => { + try { + const { courseId } = req.body; + const userId = req.user._id; + + if (!courseId) { + return res.status(400).json({ + success: false, + message: "courseId is required", + }); + } + + const certificate = await certificateService.generateCertificate({ userId, courseId }); + res.status(201).json({ + success: true, + data: certificate, + }); + } catch (error) { + res.status(400).json({ + success: false, + message: error.message, + }); + } +}; + +export const getCertificateByIdController = async (req, res) => { + try { + const certificate = await certificateService.getCertificateById(req.params.id); + res.status(200).json({ + success: true, + data: certificate, + }); + } catch (error) { + res.status(404).json({ + success: false, + message: error.message, + }); + } +}; + +export const getUserCertificatesController = async (req, res) => { + try { + const userId = req.params.userId || req.user._id; + const certificates = await certificateService.getUserCertificates(userId); + res.status(200).json({ + success: true, + count: certificates.length, + data: certificates, + }); + } catch (error) { + res.status(500).json({ + success: false, + message: error.message, + }); + } +}; + +export const downloadCertificateController = async (req, res) => { + try { + const certificate = await certificateService.getCertificateById(req.params.id); + const pdfBuffer = await certificateService.generatePDFBuffer(certificate); + + res.setHeader("Content-Type", "application/pdf"); + res.setHeader( + "Content-Disposition", + `attachment; filename="${certificate.certificateId}.pdf"` + ); + res.setHeader("Content-Length", pdfBuffer.length); + res.send(pdfBuffer); + } catch (error) { + res.status(404).json({ + success: false, + message: error.message, + }); + } +}; diff --git a/src/controllers/course-bundle.controller.js b/src/controllers/course-bundle.controller.js new file mode 100644 index 00000000..4033f0d2 --- /dev/null +++ b/src/controllers/course-bundle.controller.js @@ -0,0 +1,129 @@ +import courseBundleService from "../services/course-bundle.service.js"; + +export const createBundle = async (req, res) => { + try { + const { title, description, courses, price, currency } = req.body; + const createdBy = req.user._id; + + if (!title || !description || !courses || price === undefined) { + return res.status(400).json({ + success: false, + message: "Title, description, courses array, and price are required", + }); + } + + const bundle = await courseBundleService.createBundle({ + title, + description, + courses, + price: Number(price), + currency, + createdBy, + }); + + res.status(201).json({ + success: true, + data: bundle, + }); + } catch (error) { + res.status(400).json({ + success: false, + message: error.message, + }); + } +}; + +export const getBundles = async (req, res) => { + try { + const bundles = await courseBundleService.getBundles(req.query); + res.status(200).json({ + success: true, + count: bundles.length, + data: bundles, + }); + } catch (error) { + res.status(500).json({ + success: false, + message: error.message, + }); + } +}; + +export const getBundleById = async (req, res) => { + try { + const bundle = await courseBundleService.getBundleById(req.params.id); + res.status(200).json({ + success: true, + data: bundle, + }); + } catch (error) { + res.status(404).json({ + success: false, + message: error.message, + }); + } +}; + +export const getBundlesByCourse = async (req, res) => { + try { + const bundles = await courseBundleService.getBundlesByCourse(req.params.courseId); + res.status(200).json({ + success: true, + count: bundles.length, + data: bundles, + }); + } catch (error) { + res.status(500).json({ + success: false, + message: error.message, + }); + } +}; + +export const updateBundle = async (req, res) => { + try { + const bundle = await courseBundleService.updateBundle( + req.params.id, + req.body, + req.user._id, + req.user.role + ); + res.status(200).json({ + success: true, + data: bundle, + }); + } catch (error) { + res.status(400).json({ + success: false, + message: error.message, + }); + } +}; + +export const deleteBundle = async (req, res) => { + try { + const result = await courseBundleService.deleteBundle( + req.params.id, + req.user._id, + req.user.role + ); + res.status(200).json(result); + } catch (error) { + res.status(400).json({ + success: false, + message: error.message, + }); + } +}; + +export const purchaseBundle = async (req, res) => { + try { + const result = await courseBundleService.purchaseBundle(req.params.id, req.user._id); + res.status(200).json(result); + } catch (error) { + res.status(400).json({ + success: false, + message: error.message, + }); + } +}; diff --git a/src/controllers/course-bundle.controller.ts b/src/controllers/course-bundle.controller.ts new file mode 100644 index 00000000..4033f0d2 --- /dev/null +++ b/src/controllers/course-bundle.controller.ts @@ -0,0 +1,129 @@ +import courseBundleService from "../services/course-bundle.service.js"; + +export const createBundle = async (req, res) => { + try { + const { title, description, courses, price, currency } = req.body; + const createdBy = req.user._id; + + if (!title || !description || !courses || price === undefined) { + return res.status(400).json({ + success: false, + message: "Title, description, courses array, and price are required", + }); + } + + const bundle = await courseBundleService.createBundle({ + title, + description, + courses, + price: Number(price), + currency, + createdBy, + }); + + res.status(201).json({ + success: true, + data: bundle, + }); + } catch (error) { + res.status(400).json({ + success: false, + message: error.message, + }); + } +}; + +export const getBundles = async (req, res) => { + try { + const bundles = await courseBundleService.getBundles(req.query); + res.status(200).json({ + success: true, + count: bundles.length, + data: bundles, + }); + } catch (error) { + res.status(500).json({ + success: false, + message: error.message, + }); + } +}; + +export const getBundleById = async (req, res) => { + try { + const bundle = await courseBundleService.getBundleById(req.params.id); + res.status(200).json({ + success: true, + data: bundle, + }); + } catch (error) { + res.status(404).json({ + success: false, + message: error.message, + }); + } +}; + +export const getBundlesByCourse = async (req, res) => { + try { + const bundles = await courseBundleService.getBundlesByCourse(req.params.courseId); + res.status(200).json({ + success: true, + count: bundles.length, + data: bundles, + }); + } catch (error) { + res.status(500).json({ + success: false, + message: error.message, + }); + } +}; + +export const updateBundle = async (req, res) => { + try { + const bundle = await courseBundleService.updateBundle( + req.params.id, + req.body, + req.user._id, + req.user.role + ); + res.status(200).json({ + success: true, + data: bundle, + }); + } catch (error) { + res.status(400).json({ + success: false, + message: error.message, + }); + } +}; + +export const deleteBundle = async (req, res) => { + try { + const result = await courseBundleService.deleteBundle( + req.params.id, + req.user._id, + req.user.role + ); + res.status(200).json(result); + } catch (error) { + res.status(400).json({ + success: false, + message: error.message, + }); + } +}; + +export const purchaseBundle = async (req, res) => { + try { + const result = await courseBundleService.purchaseBundle(req.params.id, req.user._id); + res.status(200).json(result); + } catch (error) { + res.status(400).json({ + success: false, + message: error.message, + }); + } +}; diff --git a/src/controllers/courses/courseController.js b/src/controllers/courses/courseController.js index 65780cc8..2d926065 100644 --- a/src/controllers/courses/courseController.js +++ b/src/controllers/courses/courseController.js @@ -1,9 +1,39 @@ import Course from "../../models/Course.js"; +import CourseProgress from "../../models/CourseProgress.js"; import mongoose from "mongoose"; import logger from "../../config/logger.js"; import { catchAsync, APIError } from "../../middlewares/errorHandler.js"; import { getCacheOrSet, CACHE_TTL, CACHE_KEYS } from "../../utils/cache.js"; import { createNewCourseNotification } from "../notificationController.js"; +import { emitEvent, EVENT_TYPES } from "../../services/webhooks/webhookService.js"; +import { + categoryTaxonomyExists, + categoryValidationError, + resolveActiveCategory, +} from "../../services/categoryService.js"; + +/** + * Normalize + defensively validate an optional prerequisites array of course + * ObjectIds. Returns { ids } on success or { error } (string) on failure. + * `selfId` (optional) guards against a course listing itself. + */ +const normalizePrerequisites = (prerequisites, selfId) => { + if (prerequisites === undefined) return { ids: undefined }; + if (!Array.isArray(prerequisites)) { + return { error: "prerequisites must be an array" }; + } + const ids = []; + for (const p of prerequisites) { + if (!mongoose.Types.ObjectId.isValid(p)) { + return { error: "Each prerequisite must be a valid Mongo ObjectId" }; + } + if (selfId && String(p) === String(selfId)) { + return { error: "A course cannot list itself as a prerequisite" }; + } + ids.push(p); + } + return { ids }; +}; /** * Create a new course @@ -11,7 +41,8 @@ import { createNewCourseNotification } from "../notificationController.js"; * Backend receives URLs instead of file buffers */ export const createCourse = catchAsync(async (req, res, next) => { - const { title, description, category, price, thumbnail, video } = req.body; + const { title, description, category, price, thumbnail, video, prerequisites } = + req.body; logger.info(`Creating course: ${title} by user: ${req.user._id}`); @@ -22,15 +53,27 @@ export const createCourse = catchAsync(async (req, res, next) => { ); } + const { ids: prerequisiteIds, error: prerequisiteError } = + normalizePrerequisites(prerequisites); + if (prerequisiteError) { + return next(new APIError(prerequisiteError, 400)); + } + const categoryDoc = await resolveActiveCategory(category); + if (!categoryDoc && (await categoryTaxonomyExists())) { + return next(new APIError(await categoryValidationError(), 400)); + } + // Create course with URLs from frontend const course = await Course.create({ title, description, - category, + category: categoryDoc?.name || category, + categoryRef: categoryDoc?._id, price: price || 0, createdBy: req.user._id, thumbnail: thumbnail || null, // URL from frontend video: video || null, // URL from frontend + ...(prerequisiteIds !== undefined && { prerequisites: prerequisiteIds }), }); logger.info(`✅ Course created successfully: ${course._id} - ${title}`); @@ -48,9 +91,15 @@ 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 filter = {}; + if (req.query.category) { + const categoryDoc = await resolveActiveCategory(req.query.category); + if (!categoryDoc) return res.status(404).json({ success: false, message: "Category not found" }); + filter.categoryRef = categoryDoc._id; + } + const courses = await Course.find(filter).populate( "createdBy", "name email avatar" ); @@ -63,12 +112,20 @@ export const getCourses = async (_req, res) => { // 📘 Get a single course export const getCourseById = async (req, res) => { try { - const course = await Course.findById(req.params.id).populate("createdBy", "name avatar bio"); + const course = await Course.findById(req.params.id) + .populate("createdBy", "name avatar bio") + .populate("prerequisites", "title thumbnail"); if (!course) return res .status(404) .json({ success: false, message: "Course not found" }); + // Track a course view for creator analytics (fire-and-forget so a failed + // metric write never blocks or fails the detail response). + Course.updateOne({ _id: course._id }, { $inc: { views: 1 } }).catch((err) => + logger.error("Failed to increment course view count:", err) + ); + res.status(200).json({ success: true, course }); } catch (error) { res.status(500).json({ success: false, message: error.message }); @@ -129,6 +186,36 @@ export const enrollInCourse = async (req, res) => { .json({ success: false, message: "Already enrolled" }); } + // Prerequisite gate: the learner must have COMPLETED every prerequisite + // course (a CourseProgress doc with completedAt set, or percentComplete>=100) + // before they can enroll in this (advanced) course. + if (Array.isArray(course.prerequisites) && course.prerequisites.length > 0) { + const completed = await CourseProgress.find({ + user: req.user._id, + course: { $in: course.prerequisites }, + $or: [ + { completedAt: { $ne: null } }, + { percentComplete: { $gte: 100 } }, + ], + }).select("course"); + + const completedIds = new Set(completed.map((p) => p.course.toString())); + const missingIds = course.prerequisites.filter( + (p) => !completedIds.has(p.toString()) + ); + + if (missingIds.length > 0) { + const missingCourses = await Course.find({ + _id: { $in: missingIds }, + }).select("title"); + const titles = missingCourses.map((c) => c.title).join(", "); + return res.status(400).json({ + success: false, + message: `Complete these prerequisites first: ${titles}`, + }); + } + } + // Add user to course's enrolledUsers course.enrolledUsers.push(req.user._id); await course.save(); @@ -149,6 +236,12 @@ export const enrollInCourse = async (req, res) => { } } + await emitEvent(EVENT_TYPES.COURSE_ENROLLED, { + courseId: course._id.toString(), + itemTitle: course.title, + userId: req.user._id.toString(), + }); + res .status(200) .json({ @@ -164,34 +257,44 @@ export const enrollInCourse = async (req, res) => { // 📝 Edit/Update a course export const updateCourse = catchAsync(async (req, res, next) => { - const { title, description, category, price, thumbnail, video } = req.body; + const { title, description, category, price, thumbnail, video, prerequisites } = + req.body; const courseId = req.params.id; logger.info(`Updating course: ${courseId}`); - const course = await Course.findById(courseId); + // Ownership is enforced by authorizeOwnership middleware (req.resource). + const course = req.resource || (await Course.findById(courseId)); if (!course) { return next(new APIError("Course not found", 404)); } - // Check if user is the creator or admin (authorization) - if (req.user.role !== "admin" && course.createdBy.toString() !== req.user._id.toString()) { - logger.warn(`Unauthorized course update attempt by user: ${req.user._id}`); - return next( - new APIError("You are not authorized to update this course", 403) - ); + const { ids: prerequisiteIds, error: prerequisiteError } = + normalizePrerequisites(prerequisites, courseId); + if (prerequisiteError) { + return next(new APIError(prerequisiteError, 400)); } // Update fields (URLs from frontend) course.title = title || course.title; course.description = description || course.description; - course.category = category || course.category; + if (category) { + const categoryDoc = await resolveActiveCategory(category); + if (!categoryDoc && (await categoryTaxonomyExists())) { + return next(new APIError(await categoryValidationError(), 400)); + } + course.category = categoryDoc?.name || category; + course.categoryRef = categoryDoc?._id; + } course.price = price !== undefined ? price : course.price; // Update media URLs if provided if (thumbnail) course.thumbnail = thumbnail; if (video) course.video = video; + // Replace prerequisites only when explicitly provided (keeps update PATCH-like). + if (prerequisiteIds !== undefined) course.prerequisites = prerequisiteIds; + await course.save(); logger.info(`✅ Course updated successfully: ${courseId}`); diff --git a/src/controllers/educatorVerificationController.js b/src/controllers/educatorVerificationController.js new file mode 100644 index 00000000..acc8f16c --- /dev/null +++ b/src/controllers/educatorVerificationController.js @@ -0,0 +1,241 @@ +import cloudinary from "../utils/cloudinary.js"; +import { catchAsync, APIError } from "../middlewares/errorHandler.js"; +import EducatorVerification, { + VERIFICATION_STATUS, +} from "../models/EducatorVerification.js"; +import User from "../models/User.js"; +import { AUDIT_ACTIONS } from "../models/AuditLog.js"; +import { recordAudit } from "../services/audit/auditService.js"; + +const SIGNED_URL_TTL_SECONDS = 600; + +const buildSignedUrl = (publicId) => { + const config = cloudinary.config(); + if (!config.cloud_name || !config.api_key || !config.api_secret) { + return null; + } + try { + return cloudinary.url(publicId, { + sign_url: true, + secure: true, + expires_at: Math.floor(Date.now() / 1000) + SIGNED_URL_TTL_SECONDS, + }); + } catch (_) { + return null; + } +}; + +const serializeDocuments = (docs, includeSignedUrl = false) => + docs.map((d) => { + const obj = { + type: d.type, + originalFileName: d.originalFileName, + uploadedAt: d.uploadedAt, + }; + if (includeSignedUrl) { + obj.signedUrl = buildSignedUrl(d.cloudinaryPublicId); + } + return obj; + }); + +export const getMyApplication = catchAsync(async (req, res) => { + const applicantId = req.user._id; + + const verification = await EducatorVerification.findOne({ + applicant: applicantId, + }) + .sort({ createdAt: -1 }) + .lean(); + + if (!verification) { + return res.status(200).json({ + success: true, + application: null, + }); + } + + res.status(200).json({ + success: true, + application: { + ...verification, + documents: serializeDocuments(verification.documents, true), + }, + }); +}); + +export const getDocumentSignedUrl = catchAsync(async (req, res) => { + const { documentIndex } = req.params; + const applicantId = req.user._id; + const idx = parseInt(documentIndex, 10); + + if (isNaN(idx) || idx < 0) { + throw new APIError("Invalid document index", 400); + } + + const verification = await EducatorVerification.findOne({ + applicant: applicantId, + }).sort({ createdAt: -1 }); + + if (!verification) { + throw new APIError("No verification application found", 404); + } + + if (idx >= verification.documents.length) { + throw new APIError("Document not found", 404); + } + + const doc = verification.documents[idx]; + const signedUrl = buildSignedUrl(doc.cloudinaryPublicId); + + if (!signedUrl) { + throw new APIError("Unable to generate signed URL at this time", 503); + } + + res.status(200).json({ + success: true, + data: { + signedUrl, + expiresInSeconds: SIGNED_URL_TTL_SECONDS, + }, + }); +}); + +export const submitApplication = catchAsync(async (req, res) => { + const applicantId = req.user._id; + const { documents, personalStatement } = req.body || {}; + + if (!Array.isArray(documents) || documents.length === 0) { + throw new APIError( + "At least one credential document is required to submit", + 400 + ); + } + + for (const d of documents) { + if (!d.type || !d.cloudinaryPublicId || !d.originalFileName) { + throw new APIError( + "Each document must include type, cloudinaryPublicId, and originalFileName", + 400 + ); + } + } + + let verification = await EducatorVerification.findOne({ + applicant: applicantId, + status: { $in: [VERIFICATION_STATUS.DRAFT, VERIFICATION_STATUS.REJECTED] }, + }); + + let isResubmit = false; + let previousStatus = null; + + if (verification) { + if (verification.status === VERIFICATION_STATUS.REJECTED) { + isResubmit = true; + previousStatus = verification.status; + if (!verification.canTransitionTo(VERIFICATION_STATUS.PENDING)) { + throw new APIError("Cannot resubmit this application", 409); + } + verification.status = VERIFICATION_STATUS.PENDING; + verification.reviewedBy = null; + verification.reviewNotes = null; + verification.reviewedAt = null; + } else { + previousStatus = verification.status; + if (!verification.canTransitionTo(VERIFICATION_STATUS.PENDING)) { + throw new APIError("Cannot submit application from current state", 409); + } + verification.status = VERIFICATION_STATUS.PENDING; + } + verification.documents = documents; + verification.personalStatement = personalStatement || null; + verification.submittedAt = new Date(); + } else { + const existingPendingOrApproved = await EducatorVerification.findOne({ + applicant: applicantId, + status: { + $in: [VERIFICATION_STATUS.PENDING, VERIFICATION_STATUS.APPROVED], + }, + }); + if (existingPendingOrApproved) { + throw new APIError( + "An application is already pending or approved; cannot create a new one", + 409 + ); + } + + verification = new EducatorVerification({ + applicant: applicantId, + status: VERIFICATION_STATUS.PENDING, + documents, + personalStatement: personalStatement || null, + submittedAt: new Date(), + }); + previousStatus = VERIFICATION_STATUS.DRAFT; + } + + await verification.save(); + + const auditAction = isResubmit + ? AUDIT_ACTIONS.EDUCATOR_VERIFY_RESUBMIT + : AUDIT_ACTIONS.EDUCATOR_VERIFY_SUBMIT; + + await recordAudit({ + action: auditAction, + actor: applicantId, + req, + targetType: "EducatorVerification", + targetId: verification._id.toString(), + status: "success", + metadata: { + verificationId: verification._id.toString(), + previousStatus, + newStatus: VERIFICATION_STATUS.PENDING, + documentCount: documents.length, + }, + }); + + res.status(201).json({ + success: true, + message: isResubmit + ? "Application resubmitted for review" + : "Application submitted for review", + application: { + _id: verification._id, + status: verification.status, + submittedAt: verification.submittedAt, + documents: serializeDocuments(verification.documents, false), + }, + }); +}); + +export const generateUploadSignature = catchAsync(async (req, res) => { + const timestamp = Math.round(new Date().getTime() / 1000); + const config = cloudinary.config(); + + if (!config.api_secret) { + throw new APIError("Upload signing unavailable at this time", 503); + } + + const folder = "educator-verification"; + const signature = cloudinary.utils.api_sign_request( + { + timestamp, + folder, + type: "authenticated", + }, + config.api_secret + ); + + res.status(200).json({ + success: true, + message: "Signature generated successfully", + data: { + timestamp, + signature, + cloudName: config.cloud_name, + apiKey: config.api_key, + folder, + uploadType: "authenticated", + }, + }); +}); diff --git a/src/controllers/healthController.js b/src/controllers/healthController.js new file mode 100644 index 00000000..e7662b58 --- /dev/null +++ b/src/controllers/healthController.js @@ -0,0 +1,49 @@ +import mongoose from "mongoose"; +import { isRedisReady } from "../config/redis.js"; + +const mongoStates = { + 0: "disconnected", + 1: "connected", + 2: "connecting", + 3: "disconnecting", +}; + +export const createHealthHandler = ({ + getMongoReadyState = () => mongoose.connection.readyState, + getRedisReady = isRedisReady, + getUptime = () => process.uptime(), + getEnvironment = () => process.env.NODE_ENV || "unknown", +} = {}) => { + return (_req, res) => { + const mongoReadyState = getMongoReadyState(); + const mongoReady = mongoReadyState === 1; + const redisReady = Boolean(getRedisReady()); + const healthy = mongoReady && redisReady; + + return res.status(healthy ? 200 : 503).json({ + success: healthy, + message: healthy + ? "All critical dependencies are ready" + : "One or more critical dependencies are unavailable", + data: { + status: healthy ? "healthy" : "unhealthy", + timestamp: new Date().toISOString(), + uptime: getUptime(), + environment: getEnvironment(), + dependencies: { + mongodb: { + status: mongoReady ? "up" : "down", + state: mongoStates[mongoReadyState] || "unknown", + }, + redis: { + status: redisReady ? "up" : "down", + }, + }, + }, + }); + }; +}; + +export const healthCheck = createHealthHandler(); + +export const ping = (_req, res) => res.status(200).send("pong"); diff --git a/src/controllers/highlight.controller.js b/src/controllers/highlight.controller.js new file mode 100644 index 00000000..2f93b22d --- /dev/null +++ b/src/controllers/highlight.controller.js @@ -0,0 +1,168 @@ +import highlightService from "../services/highlight.service.js"; + +export const createHighlight = async (req, res) => { + try { + const { bookId } = req.params; + const { text, color, pageNumber, passage, cfiRange } = req.body; + const userId = req.user._id; + + if (!text || text.trim() === "") { + return res.status(400).json({ success: false, message: "Highlight text is required" }); + } + + const highlight = await highlightService.createHighlight({ + userId, + bookId, + text, + color, + pageNumber, + passage, + cfiRange, + }); + + res.status(201).json({ success: true, highlight }); + } catch (error) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const getHighlights = async (req, res) => { + try { + const { bookId } = req.params; + const userId = req.user._id; + + const highlights = await highlightService.getHighlights({ userId, bookId }); + res.status(200).json({ success: true, highlights }); + } catch (error) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const deleteHighlight = async (req, res) => { + try { + const { id } = req.params; + const userId = req.user._id; + + const highlight = await highlightService.deleteHighlight({ userId, highlightId: id }); + res.status(200).json({ success: true, message: "Highlight deleted", highlight }); + } catch (error) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const createNote = async (req, res) => { + try { + const { bookId } = req.params; + const { highlightId, content, pageNumber, passage } = req.body; + const userId = req.user._id; + + if (!content || content.trim() === "") { + return res.status(400).json({ success: false, message: "Note content is required" }); + } + + const note = await highlightService.createNote({ + userId, + bookId, + highlightId, + content, + pageNumber, + passage, + }); + + res.status(201).json({ success: true, note }); + } catch (error) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const getNotes = async (req, res) => { + try { + const { bookId } = req.params; + const userId = req.user._id; + + const notes = await highlightService.getNotes({ userId, bookId }); + res.status(200).json({ success: true, notes }); + } catch (error) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const deleteNote = async (req, res) => { + try { + const { id } = req.params; + const userId = req.user._id; + + const note = await highlightService.deleteNote({ userId, noteId: id }); + res.status(200).json({ success: true, message: "Note deleted", note }); + } catch (error) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const getHighlightsAndNotes = async (req, res) => { + try { + const { bookId } = req.params; + const userId = req.user._id; + + const result = await highlightService.getHighlightsAndNotes({ userId, bookId }); + res.status(200).json({ success: true, ...result }); + } catch (error) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const searchHighlightsAndNotes = async (req, res) => { + try { + const { bookId } = req.params; + const query = req.query.q || req.query.query || ""; + const userId = req.user._id; + + const results = await highlightService.searchHighlightsAndNotes({ + userId, + bookId, + query, + }); + + res.status(200).json({ success: true, results }); + } catch (error) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const exportHighlights = async (req, res) => { + try { + const { bookId } = req.params; + const format = req.query.format || "text"; + const userId = req.user._id; + + const exportData = await highlightService.exportHighlights({ + userId, + bookId, + format, + }); + + if (format === "pdf") { + res.setHeader("Content-Type", exportData.mimeType); + res.setHeader("Content-Disposition", `attachment; filename="${exportData.filename}"`); + return res.status(200).send(exportData.content); + } + + res.setHeader("Content-Type", exportData.mimeType); + res.setHeader("Content-Disposition", `attachment; filename="${exportData.filename}"`); + return res.status(200).send(exportData.content); + } catch (error) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export default { + createHighlight, + getHighlights, + deleteHighlight, + createNote, + getNotes, + deleteNote, + getHighlightsAndNotes, + searchHighlightsAndNotes, + exportHighlights, +}; diff --git a/src/controllers/highlight.controller.ts b/src/controllers/highlight.controller.ts new file mode 100644 index 00000000..13401b87 --- /dev/null +++ b/src/controllers/highlight.controller.ts @@ -0,0 +1,169 @@ +import { Request, Response } from "express"; +import highlightService from "../services/highlight.service.ts"; + +export const createHighlight = async (req: Request, res: Response) => { + try { + const { bookId } = req.params; + const { text, color, pageNumber, passage, cfiRange } = req.body; + const userId = (req as any).user._id; + + if (!text || text.trim() === "") { + return res.status(400).json({ success: false, message: "Highlight text is required" }); + } + + const highlight = await highlightService.createHighlight({ + userId, + bookId, + text, + color, + pageNumber, + passage, + cfiRange, + }); + + res.status(201).json({ success: true, highlight }); + } catch (error: any) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const getHighlights = async (req: Request, res: Response) => { + try { + const { bookId } = req.params; + const userId = (req as any).user._id; + + const highlights = await highlightService.getHighlights({ userId, bookId }); + res.status(200).json({ success: true, highlights }); + } catch (error: any) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const deleteHighlight = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const userId = (req as any).user._id; + + const highlight = await highlightService.deleteHighlight({ userId, highlightId: id }); + res.status(200).json({ success: true, message: "Highlight deleted", highlight }); + } catch (error: any) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const createNote = async (req: Request, res: Response) => { + try { + const { bookId } = req.params; + const { highlightId, content, pageNumber, passage } = req.body; + const userId = (req as any).user._id; + + if (!content || content.trim() === "") { + return res.status(400).json({ success: false, message: "Note content is required" }); + } + + const note = await highlightService.createNote({ + userId, + bookId, + highlightId, + content, + pageNumber, + passage, + }); + + res.status(201).json({ success: true, note }); + } catch (error: any) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const getNotes = async (req: Request, res: Response) => { + try { + const { bookId } = req.params; + const userId = (req as any).user._id; + + const notes = await highlightService.getNotes({ userId, bookId }); + res.status(200).json({ success: true, notes }); + } catch (error: any) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const deleteNote = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const userId = (req as any).user._id; + + const note = await highlightService.deleteNote({ userId, noteId: id }); + res.status(200).json({ success: true, message: "Note deleted", note }); + } catch (error: any) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const getHighlightsAndNotes = async (req: Request, res: Response) => { + try { + const { bookId } = req.params; + const userId = (req as any).user._id; + + const result = await highlightService.getHighlightsAndNotes({ userId, bookId }); + res.status(200).json({ success: true, ...result }); + } catch (error: any) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const searchHighlightsAndNotes = async (req: Request, res: Response) => { + try { + const { bookId } = req.params; + const query = (req.query.q || req.query.query || "") as string; + const userId = (req as any).user._id; + + const results = await highlightService.searchHighlightsAndNotes({ + userId, + bookId, + query, + }); + + res.status(200).json({ success: true, results }); + } catch (error: any) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const exportHighlights = async (req: Request, res: Response) => { + try { + const { bookId } = req.params; + const format = (req.query.format as string) || "text"; + const userId = (req as any).user._id; + + const exportData = await highlightService.exportHighlights({ + userId, + bookId, + format, + }); + + if (format === "pdf") { + res.setHeader("Content-Type", exportData.mimeType); + res.setHeader("Content-Disposition", `attachment; filename="${exportData.filename}"`); + return res.status(200).send(exportData.content); + } + + res.setHeader("Content-Type", exportData.mimeType); + res.setHeader("Content-Disposition", `attachment; filename="${exportData.filename}"`); + return res.status(200).send(exportData.content); + } catch (error: any) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export default { + createHighlight, + getHighlights, + deleteHighlight, + createNote, + getNotes, + deleteNote, + getHighlightsAndNotes, + searchHighlightsAndNotes, + exportHighlights, +}; diff --git a/src/controllers/messaging.controller.ts b/src/controllers/messaging.controller.ts new file mode 100644 index 00000000..674be8df --- /dev/null +++ b/src/controllers/messaging.controller.ts @@ -0,0 +1,95 @@ +import { Request, Response } from "express"; +import messagingService from "../services/messaging.service.ts"; + +export const getOrCreateConversation = async (req: Request, res: Response) => { + try { + const { userId } = req.params; + const currentUserId = (req as any).user._id; + + const conversation = await messagingService.getOrCreateConversation( + currentUserId.toString(), + userId + ); + + res.status(200).json({ success: true, conversation }); + } catch (error: any) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const getConversations = async (req: Request, res: Response) => { + try { + const userId = (req as any).user._id; + + const conversations = await messagingService.getConversations( + userId.toString() + ); + + res.status(200).json({ success: true, conversations }); + } catch (error: any) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const getMessages = async (req: Request, res: Response) => { + try { + const { conversationId } = req.params; + const userId = (req as any).user._id; + const page = parseInt(req.query.page as string) || 1; + const limit = parseInt(req.query.limit as string) || 30; + + const result = await messagingService.getMessages({ + conversationId, + userId: userId.toString(), + page, + limit, + }); + + res.status(200).json({ success: true, ...result }); + } catch (error: any) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const sendMessage = async (req: Request, res: Response) => { + try { + const { conversationId } = req.params; + const { text, image } = req.body; + const userId = (req as any).user._id; + + const message = await messagingService.sendMessage({ + conversationId, + senderId: userId.toString(), + text, + image, + }); + + res.status(201).json({ success: true, message }); + } catch (error: any) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const markAsRead = async (req: Request, res: Response) => { + try { + const { conversationId } = req.params; + const userId = (req as any).user._id; + + const result = await messagingService.markAsRead({ + conversationId, + userId: userId.toString(), + }); + + res.status(200).json({ success: true, ...result }); + } catch (error: any) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export default { + getOrCreateConversation, + getConversations, + getMessages, + sendMessage, + markAsRead, +}; diff --git a/src/controllers/moderation.controller.js b/src/controllers/moderation.controller.js new file mode 100644 index 00000000..66dbaecc --- /dev/null +++ b/src/controllers/moderation.controller.js @@ -0,0 +1,96 @@ +import moderationService from "../services/moderation.service.js"; + +export const flagReel = async (req, res) => { + try { + const { reelId } = req.params; + const { reason, details } = req.body; + const reporterId = req.user._id; + + if (!reason || reason.trim() === "") { + return res.status(400).json({ success: false, message: "Reason for flagging is required" }); + } + + const flag = await moderationService.flagReel({ + reelId, + reporterId, + reason, + details, + }); + + res.status(201).json({ success: true, flag, message: "Reel flagged for moderation" }); + } catch (error) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const getModerationQueue = async (req, res) => { + try { + const status = req.query.status; + const page = parseInt(req.query.page, 10) || 1; + const limit = parseInt(req.query.limit, 10) || 20; + + const result = await moderationService.getModerationQueue({ + status, + page, + limit, + }); + + res.status(200).json({ success: true, ...result }); + } catch (error) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const processModerationAction = async (req, res) => { + try { + const { flagId } = req.params; + const { reelId, action, notes } = req.body; + const adminId = req.user._id; + + if (!action || !["approve", "reject", "remove"].includes(action)) { + return res.status(400).json({ + success: false, + message: "Valid action ('approve', 'reject', 'remove') is required", + }); + } + + const result = await moderationService.processModerationAction({ + flagId, + reelId, + adminId, + action, + notes, + }); + + res.status(200).json({ success: true, ...result }); + } catch (error) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const getModerationHistory = async (req, res) => { + try { + const reelId = req.query.reelId; + const adminId = req.query.adminId; + const page = parseInt(req.query.page, 10) || 1; + const limit = parseInt(req.query.limit, 10) || 20; + + const result = await moderationService.getModerationHistory({ + reelId, + adminId, + page, + limit, + }); + + res.status(200).json({ success: true, ...result }); + } catch (error) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export default { + flagReel, + getModerationQueue, + processModerationAction, + getModerationHistory, +}; diff --git a/src/controllers/moderation.controller.ts b/src/controllers/moderation.controller.ts new file mode 100644 index 00000000..72ffac15 --- /dev/null +++ b/src/controllers/moderation.controller.ts @@ -0,0 +1,97 @@ +import { Request, Response } from "express"; +import moderationService from "../services/moderation.service.ts"; + +export const flagReel = async (req: Request, res: Response) => { + try { + const { reelId } = req.params; + const { reason, details } = req.body; + const reporterId = (req as any).user._id; + + if (!reason || reason.trim() === "") { + return res.status(400).json({ success: false, message: "Reason for flagging is required" }); + } + + const flag = await moderationService.flagReel({ + reelId, + reporterId, + reason, + details, + }); + + res.status(201).json({ success: true, flag, message: "Reel flagged for moderation" }); + } catch (error: any) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const getModerationQueue = async (req: Request, res: Response) => { + try { + const status = req.query.status as string; + const page = parseInt(req.query.page as string, 10) || 1; + const limit = parseInt(req.query.limit as string, 10) || 20; + + const result = await moderationService.getModerationQueue({ + status, + page, + limit, + }); + + res.status(200).json({ success: true, ...result }); + } catch (error: any) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const processModerationAction = async (req: Request, res: Response) => { + try { + const { flagId } = req.params; + const { reelId, action, notes } = req.body; + const adminId = (req as any).user._id; + + if (!action || !["approve", "reject", "remove"].includes(action)) { + return res.status(400).json({ + success: false, + message: "Valid action ('approve', 'reject', 'remove') is required", + }); + } + + const result = await moderationService.processModerationAction({ + flagId, + reelId, + adminId, + action, + notes, + }); + + res.status(200).json({ success: true, ...result }); + } catch (error: any) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const getModerationHistory = async (req: Request, res: Response) => { + try { + const reelId = req.query.reelId as string; + const adminId = req.query.adminId as string; + const page = parseInt(req.query.page as string, 10) || 1; + const limit = parseInt(req.query.limit as string, 10) || 20; + + const result = await moderationService.getModerationHistory({ + reelId, + adminId, + page, + limit, + }); + + res.status(200).json({ success: true, ...result }); + } catch (error: any) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export default { + flagReel, + getModerationQueue, + processModerationAction, + getModerationHistory, +}; diff --git a/src/controllers/reading-group.controller.js b/src/controllers/reading-group.controller.js new file mode 100644 index 00000000..f92a580c --- /dev/null +++ b/src/controllers/reading-group.controller.js @@ -0,0 +1,186 @@ +import readingGroupService from "../services/reading-group.service.js"; + +export const createGroup = async (req, res) => { + try { + const { name, description, bookId, privacy, chaptersPerWeek, readingSchedule } = req.body; + const creatorId = req.user._id; + + if (!name || name.trim() === "") { + return res.status(400).json({ success: false, message: "Group name is required" }); + } + if (!bookId) { + return res.status(400).json({ success: false, message: "bookId is required" }); + } + + const group = await readingGroupService.createGroup({ + name, + description, + bookId, + creatorId, + privacy, + chaptersPerWeek, + readingSchedule, + }); + + res.status(201).json({ success: true, group }); + } catch (error) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const getGroups = async (req, res) => { + try { + const bookId = req.query.bookId; + const privacy = req.query.privacy; + const search = req.query.search || req.query.q; + const page = parseInt(req.query.page, 10) || 1; + const limit = parseInt(req.query.limit, 10) || 20; + + const result = await readingGroupService.getGroups({ + bookId, + privacy, + search, + page, + limit, + }); + + res.status(200).json({ success: true, ...result }); + } catch (error) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const getGroupDetails = async (req, res) => { + try { + const { id } = req.params; + const userId = req.user?._id; + + const details = await readingGroupService.getGroupDetails(id, userId); + res.status(200).json({ success: true, ...details }); + } catch (error) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const joinGroup = async (req, res) => { + try { + const { id } = req.params; + const userId = req.user._id; + + const membership = await readingGroupService.joinGroup(id, userId); + res.status(200).json({ success: true, membership }); + } catch (error) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const inviteMember = async (req, res) => { + try { + const { id } = req.params; + const { targetUserId } = req.body; + const adminId = req.user._id; + + if (!targetUserId) { + return res.status(400).json({ success: false, message: "targetUserId is required" }); + } + + const membership = await readingGroupService.inviteMember(id, adminId, targetUserId); + res.status(200).json({ success: true, membership, message: "Invitation sent" }); + } catch (error) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const updateSchedule = async (req, res) => { + try { + const { id } = req.params; + const { readingSchedule, chaptersPerWeek } = req.body; + const adminId = req.user._id; + + const group = await readingGroupService.updateSchedule(id, adminId, readingSchedule, chaptersPerWeek); + res.status(200).json({ success: true, group }); + } catch (error) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const addDiscussionPost = async (req, res) => { + try { + const { id } = req.params; + const { chapter, content } = req.body; + const userId = req.user._id; + + if (chapter === undefined || chapter === null) { + return res.status(400).json({ success: false, message: "Chapter is required" }); + } + if (!content || content.trim() === "") { + return res.status(400).json({ success: false, message: "Content is required" }); + } + + const discussions = await readingGroupService.addDiscussionPost({ + groupId: id, + chapter: Number(chapter), + userId, + content, + }); + + res.status(201).json({ success: true, discussions }); + } catch (error) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const getDiscussions = async (req, res) => { + try { + const { id } = req.params; + const chapter = req.query.chapter ? parseInt(req.query.chapter, 10) : undefined; + + const discussions = await readingGroupService.getDiscussions(id, chapter); + res.status(200).json({ success: true, discussions }); + } catch (error) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const updateMemberProgress = async (req, res) => { + try { + const { id } = req.params; + const { currentChapter, currentProgressPercent } = req.body; + const userId = req.user._id; + + const member = await readingGroupService.updateMemberProgress({ + groupId: id, + userId, + currentChapter, + currentProgressPercent, + }); + + res.status(200).json({ success: true, member }); + } catch (error) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const getMemberProgressDashboard = async (req, res) => { + try { + const { id } = req.params; + + const dashboard = await readingGroupService.getMemberProgressDashboard(id); + res.status(200).json({ success: true, ...dashboard }); + } catch (error) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export default { + createGroup, + getGroups, + getGroupDetails, + joinGroup, + inviteMember, + updateSchedule, + addDiscussionPost, + getDiscussions, + updateMemberProgress, + getMemberProgressDashboard, +}; diff --git a/src/controllers/reading-group.controller.ts b/src/controllers/reading-group.controller.ts new file mode 100644 index 00000000..76491100 --- /dev/null +++ b/src/controllers/reading-group.controller.ts @@ -0,0 +1,187 @@ +import { Request, Response } from "express"; +import readingGroupService from "../services/reading-group.service.ts"; + +export const createGroup = async (req: Request, res: Response) => { + try { + const { name, description, bookId, privacy, chaptersPerWeek, readingSchedule } = req.body; + const creatorId = (req as any).user._id; + + if (!name || name.trim() === "") { + return res.status(400).json({ success: false, message: "Group name is required" }); + } + if (!bookId) { + return res.status(400).json({ success: false, message: "bookId is required" }); + } + + const group = await readingGroupService.createGroup({ + name, + description, + bookId, + creatorId, + privacy, + chaptersPerWeek, + readingSchedule, + }); + + res.status(201).json({ success: true, group }); + } catch (error: any) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const getGroups = async (req: Request, res: Response) => { + try { + const bookId = req.query.bookId as string; + const privacy = req.query.privacy as string; + const search = (req.query.search || req.query.q) as string; + const page = parseInt(req.query.page as string, 10) || 1; + const limit = parseInt(req.query.limit as string, 10) || 20; + + const result = await readingGroupService.getGroups({ + bookId, + privacy, + search, + page, + limit, + }); + + res.status(200).json({ success: true, ...result }); + } catch (error: any) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const getGroupDetails = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const userId = (req as any).user?._id; + + const details = await readingGroupService.getGroupDetails(id, userId); + res.status(200).json({ success: true, ...details }); + } catch (error: any) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const joinGroup = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const userId = (req as any).user._id; + + const membership = await readingGroupService.joinGroup(id, userId); + res.status(200).json({ success: true, membership }); + } catch (error: any) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const inviteMember = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const { targetUserId } = req.body; + const adminId = (req as any).user._id; + + if (!targetUserId) { + return res.status(400).json({ success: false, message: "targetUserId is required" }); + } + + const membership = await readingGroupService.inviteMember(id, adminId, targetUserId); + res.status(200).json({ success: true, membership, message: "Invitation sent" }); + } catch (error: any) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const updateSchedule = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const { readingSchedule, chaptersPerWeek } = req.body; + const adminId = (req as any).user._id; + + const group = await readingGroupService.updateSchedule(id, adminId, readingSchedule, chaptersPerWeek); + res.status(200).json({ success: true, group }); + } catch (error: any) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const addDiscussionPost = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const { chapter, content } = req.body; + const userId = (req as any).user._id; + + if (chapter === undefined || chapter === null) { + return res.status(400).json({ success: false, message: "Chapter is required" }); + } + if (!content || content.trim() === "") { + return res.status(400).json({ success: false, message: "Content is required" }); + } + + const discussions = await readingGroupService.addDiscussionPost({ + groupId: id, + chapter: Number(chapter), + userId, + content, + }); + + res.status(201).json({ success: true, discussions }); + } catch (error: any) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const getDiscussions = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const chapter = req.query.chapter ? parseInt(req.query.chapter as string, 10) : undefined; + + const discussions = await readingGroupService.getDiscussions(id, chapter); + res.status(200).json({ success: true, discussions }); + } catch (error: any) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const updateMemberProgress = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const { currentChapter, currentProgressPercent } = req.body; + const userId = (req as any).user._id; + + const member = await readingGroupService.updateMemberProgress({ + groupId: id, + userId, + currentChapter, + currentProgressPercent, + }); + + res.status(200).json({ success: true, member }); + } catch (error: any) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const getMemberProgressDashboard = async (req: Request, res: Response) => { + try { + const { id } = req.params; + + const dashboard = await readingGroupService.getMemberProgressDashboard(id); + res.status(200).json({ success: true, ...dashboard }); + } catch (error: any) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export default { + createGroup, + getGroups, + getGroupDetails, + joinGroup, + inviteMember, + updateSchedule, + addDiscussionPost, + getDiscussions, + updateMemberProgress, + getMemberProgressDashboard, +}; diff --git a/src/controllers/reelController.js b/src/controllers/reelController.js index bb9ab31f..25984a8f 100644 --- a/src/controllers/reelController.js +++ b/src/controllers/reelController.js @@ -2,6 +2,10 @@ import mongoose from "mongoose"; import Reel from "../models/Reel.js"; import cloudinary from "../utils/cloudinary.js"; import logger from "../config/logger.js"; +import { + createReelDerivative, + listReelDerivatives, +} from "../services/reelDuetService.js"; const uploadBufferToCloudinary = (buffer, options) => new Promise((resolve, reject) => { @@ -48,12 +52,18 @@ const formatReelResponse = (reel, viewerId) => { duration: reel.duration, createdAt: reel.createdAt, updatedAt: reel.updatedAt, + originalReelId: reel.originalReelId || null, + duetType: reel.duetType || null, + stitchClip: reel.stitchClip || null, + composition: reel.composition || null, stats: { likes: likeSet.size, loves: loveSet.size, comments: reel.comments?.length || 0, shares: reel.shareCount || 0, views: reel.viewCount || 0, + duets: reel.duetCount || 0, + stitches: reel.stitchCount || 0, }, viewerState: viewerKey ? { @@ -425,3 +435,98 @@ export const registerReelView = async (req, res) => { res.status(500).json({ success: false, message: error.message }); } }; + +// ====================== +// DUET / STITCH +// ====================== + +// Create a duet/stitch response video linked to the original reel (:id). +// A `duet` is displayed side-by-side with the original; a `stitch` prepends a +// clip of the original before the response plays. +export const createReelDuet = async (req, res) => { + try { + const { id } = req.params; + const { description, category, tags, type, stitchStart, stitchEnd } = + req.body; + const userId = req.user?._id; + + if (!req.file?.buffer) { + return res + .status(400) + .json({ success: false, message: "Video file is required" }); + } + + const uploadResult = await uploadBufferToCloudinary(req.file.buffer, { + resource_type: "video", + folder: "dnb/reels/duets", + }); + + const clip = + type === "stitch" + ? { start: Number(stitchStart), end: Number(stitchEnd) } + : undefined; + + const derivative = await createReelDerivative({ + originalReelId: id, + type, + userId, + description, + category, + tags: normalizeTags(tags), + video: uploadResult.secure_url, + videoPublicId: uploadResult.public_id, + thumbnail: uploadResult.thumbnail_url || uploadResult.secure_url, + duration: uploadResult.duration, + clip, + }); + + const populatedReel = await derivative.populate("createdBy", "name avatar"); + + res.status(201).json({ + success: true, + reel: formatReelResponse(populatedReel.toObject(), userId), + }); + } catch (error) { + logger.error("Error creating reel duet/stitch:", error); + res + .status(error.statusCode || 500) + .json({ success: false, message: error.message }); + } +}; + +// Browse all duets/stitches for a given reel (:id). Optional ?type=duet|stitch. +export const getReelDerivatives = async (req, res) => { + try { + const { id } = req.params; + const { type } = req.query; + const viewerId = req.user?._id; + + const original = await Reel.findById(id).select("_id duetCount stitchCount"); + if (!original) { + return res + .status(404) + .json({ success: false, message: "Reel not found" }); + } + + const { items, page, limit, total, hasMore } = await listReelDerivatives( + id, + { page: req.query.page, limit: req.query.limit, type } + ); + + res.status(200).json({ + success: true, + page, + limit, + total, + hasMore, + counts: { + duets: original.duetCount || 0, + stitches: original.stitchCount || 0, + }, + reels: items.map((reel) => formatReelResponse(reel, viewerId)), + }); + } catch (error) { + logger.error("Error fetching reel duets/stitches:", error); + res.status(500).json({ success: false, message: error.message }); + } +}; diff --git a/src/controllers/reviewController.js b/src/controllers/reviewController.js index 87678533..f8d4fe15 100644 --- a/src/controllers/reviewController.js +++ b/src/controllers/reviewController.js @@ -172,9 +172,7 @@ export const updateReviewHandler = (Model, itemType) => return next(new APIError("Review not found", 404)); } - if (review.user.toString() !== req.user._id.toString()) { - return next(new APIError("Not authorized to update this review", 403)); - } + // Ownership is enforced by authorizeReviewOwnership middleware. if (comment !== undefined) { if (typeof comment !== "string" || comment.trim() === "") { @@ -229,13 +227,7 @@ export const deleteReviewHandler = (Model, itemType) => return next(new APIError("Review not found", 404)); } - const review = item.reviews[reviewIndex]; - - const isOwner = review.user.toString() === req.user._id.toString(); - const isAdmin = req.user.role === "admin"; - if (!isOwner && !isAdmin) { - return next(new APIError("Not authorized to delete this review", 403)); - } + // Ownership is enforced by authorizeReviewOwnership middleware. item.reviews.splice(reviewIndex, 1); await recomputeReviewStats(item); diff --git a/src/controllers/space-poll.controller.js b/src/controllers/space-poll.controller.js new file mode 100644 index 00000000..a79df448 --- /dev/null +++ b/src/controllers/space-poll.controller.js @@ -0,0 +1,110 @@ +import spacePollService from "../services/space-poll.service.js"; + +export const createPoll = async (req, res) => { + try { + const { spaceId } = req.params; + const { question, options } = req.body; + const hostId = req.user._id; + + const poll = await spacePollService.createPoll({ + spaceId, + hostId, + question, + options, + }); + + res.status(201).json({ success: true, poll }); + } catch (error) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const getSpacePolls = async (req, res) => { + try { + const { spaceId } = req.params; + const userId = req.user?._id; + + const polls = await spacePollService.getSpacePolls(spaceId, userId); + res.status(200).json({ success: true, polls }); + } catch (error) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const getPollResults = async (req, res) => { + try { + const { pollId } = req.params; + const userId = req.user?._id; + + const poll = await spacePollService.getPollResults(pollId, userId); + res.status(200).json({ success: true, poll }); + } catch (error) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const voteInPoll = async (req, res) => { + try { + const { pollId } = req.params; + const { optionIndex } = req.body; + const userId = req.user._id; + + if (optionIndex === undefined || optionIndex === null) { + return res.status(400).json({ success: false, message: "optionIndex is required" }); + } + + const updatedPoll = await spacePollService.voteInPoll({ + pollId, + userId, + optionIndex: Number(optionIndex), + }); + + res.status(200).json({ success: true, poll: updatedPoll }); + } catch (error) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const closePoll = async (req, res) => { + try { + const { pollId } = req.params; + const hostId = req.user._id; + + const closedPoll = await spacePollService.closePoll({ + pollId, + hostId, + }); + + res.status(200).json({ success: true, poll: closedPoll }); + } catch (error) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const exportPollResults = async (req, res) => { + try { + const { pollId } = req.params; + const format = req.query.format; + + const exportData = await spacePollService.exportPollResults(pollId); + + if (format === "csv") { + res.setHeader("Content-Type", "text/csv"); + res.setHeader("Content-Disposition", `attachment; filename="poll-${pollId}-results.csv"`); + return res.status(200).send(exportData.csvData); + } + + res.status(200).json({ success: true, export: exportData }); + } catch (error) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export default { + createPoll, + getSpacePolls, + getPollResults, + voteInPoll, + closePoll, + exportPollResults, +}; diff --git a/src/controllers/space-poll.controller.ts b/src/controllers/space-poll.controller.ts new file mode 100644 index 00000000..28d4585b --- /dev/null +++ b/src/controllers/space-poll.controller.ts @@ -0,0 +1,111 @@ +import { Request, Response } from "express"; +import spacePollService from "../services/space-poll.service.ts"; + +export const createPoll = async (req: Request, res: Response) => { + try { + const { spaceId } = req.params; + const { question, options } = req.body; + const hostId = (req as any).user._id; + + const poll = await spacePollService.createPoll({ + spaceId, + hostId, + question, + options, + }); + + res.status(201).json({ success: true, poll }); + } catch (error: any) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const getSpacePolls = async (req: Request, res: Response) => { + try { + const { spaceId } = req.params; + const userId = (req as any).user?._id; + + const polls = await spacePollService.getSpacePolls(spaceId, userId); + res.status(200).json({ success: true, polls }); + } catch (error: any) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const getPollResults = async (req: Request, res: Response) => { + try { + const { pollId } = req.params; + const userId = (req as any).user?._id; + + const poll = await spacePollService.getPollResults(pollId, userId); + res.status(200).json({ success: true, poll }); + } catch (error: any) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const voteInPoll = async (req: Request, res: Response) => { + try { + const { pollId } = req.params; + const { optionIndex } = req.body; + const userId = (req as any).user._id; + + if (optionIndex === undefined || optionIndex === null) { + return res.status(400).json({ success: false, message: "optionIndex is required" }); + } + + const updatedPoll = await spacePollService.voteInPoll({ + pollId, + userId, + optionIndex: Number(optionIndex), + }); + + res.status(200).json({ success: true, poll: updatedPoll }); + } catch (error: any) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const closePoll = async (req: Request, res: Response) => { + try { + const { pollId } = req.params; + const hostId = (req as any).user._id; + + const closedPoll = await spacePollService.closePoll({ + pollId, + hostId, + }); + + res.status(200).json({ success: true, poll: closedPoll }); + } catch (error: any) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export const exportPollResults = async (req: Request, res: Response) => { + try { + const { pollId } = req.params; + const format = req.query.format as string; + + const exportData = await spacePollService.exportPollResults(pollId); + + if (format === "csv") { + res.setHeader("Content-Type", "text/csv"); + res.setHeader("Content-Disposition", `attachment; filename="poll-${pollId}-results.csv"`); + return res.status(200).send(exportData.csvData); + } + + res.status(200).json({ success: true, export: exportData }); + } catch (error: any) { + res.status(400).json({ success: false, message: error.message }); + } +}; + +export default { + createPoll, + getSpacePolls, + getPollResults, + voteInPoll, + closePoll, + exportPollResults, +}; diff --git a/src/controllers/spaceController.js b/src/controllers/spaceController.js index 147cb6fd..aea36797 100644 --- a/src/controllers/spaceController.js +++ b/src/controllers/spaceController.js @@ -90,18 +90,12 @@ export const updateSpace = async (req, res) => { if (req.body[key] !== undefined) updates[key] = req.body[key]; } - const existingSpace = await Space.findById(id); + // Ownership is enforced by authorizeOwnership middleware (req.resource). + const existingSpace = req.resource || (await Space.findById(id)); if (!existingSpace) { return res.status(404).json({ success: false, message: "Space not found" }); } - if (req.user.role !== "admin" && existingSpace.host.toString() !== req.user._id.toString()) { - return res.status(403).json({ - success: false, - message: "Not authorized to update this space", - }); - } - const space = await Space.findByIdAndUpdate(id, updates, { new: true, }).populate("host", "name email avatar"); @@ -158,18 +152,12 @@ export const getSpacesByHost = async (req, res) => { export const deleteSpace = async (req, res) => { try { const { id } = req.params; - const space = await Space.findById(id); + // Ownership is enforced by authorizeOwnership middleware (req.resource). + const space = req.resource || (await Space.findById(id)); if (!space) { return res.status(404).json({ success: false, message: "Space not found" }); } - if (req.user.role !== "admin" && space.host.toString() !== req.user._id.toString()) { - return res.status(403).json({ - success: false, - message: "Not authorized to delete this space", - }); - } - await Space.findByIdAndDelete(id); res.status(200).json({ success: true, message: "Space deleted" }); } catch (error) { diff --git a/src/controllers/stellar/analyticsController.js b/src/controllers/stellar/analyticsController.js new file mode 100644 index 00000000..cc389d18 --- /dev/null +++ b/src/controllers/stellar/analyticsController.js @@ -0,0 +1,114 @@ +// controllers/stellar/analyticsController.js +import { + getPaymentAnalytics, + getSummaryAnalytics, + getTimeSeriesAnalytics, + DEFAULT_PERIOD, +} from "../../services/stellar/analyticsService.js"; +import logger from "../../config/logger.js"; + +/** + * Analytics controllers for the payment dashboard. + * + * Each handler translates validated query parameters into service filters and + * returns aggregated statistics. Validation of the query shape is performed by + * the route's express-validator chain before these run. + * + * @module controllers/stellar/analyticsController + */ + +/** + * Pull whitelisted analytics filters off the request query. + * @param {import("express").Request} req Express request. + * @returns {import("../../services/stellar/analyticsService.js").AnalyticsFilters} + */ +const extractFilters = (req) => { + const { status, type, currency, buyerId, creatorId, startDate, endDate } = + req.query; + return { status, type, currency, buyerId, creatorId, startDate, endDate }; +}; + +/** + * GET /api/stellar/analytics + * Combined per-asset summary plus a time series bucketed by `period`. + * @param {import("express").Request} req Express request. + * @param {import("express").Response} res Express response. + */ +export const getAnalyticsOverview = async (req, res) => { + try { + const period = req.query.period || DEFAULT_PERIOD; + const filters = extractFilters(req); + + const data = await getPaymentAnalytics({ period, ...filters }); + + res.status(200).json({ + success: true, + ...data, + }); + } catch (error) { + logger.error("Get analytics overview error:", error); + res.status(500).json({ + success: false, + message: "Failed to fetch payment analytics", + }); + } +}; + +/** + * GET /api/stellar/analytics/summary + * Per-asset totals (volume, count, average) with no time bucketing. + * @param {import("express").Request} req Express request. + * @param {import("express").Response} res Express response. + */ +export const getAnalyticsSummary = async (req, res) => { + try { + const filters = extractFilters(req); + const summary = await getSummaryAnalytics(filters); + + res.status(200).json({ + success: true, + filters, + summary, + }); + } catch (error) { + logger.error("Get analytics summary error:", error); + res.status(500).json({ + success: false, + message: "Failed to fetch payment analytics summary", + }); + } +}; + +/** + * GET /api/stellar/analytics/timeseries + * Statistics bucketed by `period` (day|week|month|year) and asset. + * @param {import("express").Request} req Express request. + * @param {import("express").Response} res Express response. + */ +export const getAnalyticsTimeSeries = async (req, res) => { + try { + const period = req.query.period || DEFAULT_PERIOD; + const filters = extractFilters(req); + + const series = await getTimeSeriesAnalytics({ period, ...filters }); + + res.status(200).json({ + success: true, + period, + filters, + series, + }); + } catch (error) { + logger.error("Get analytics time series error:", error); + res.status(500).json({ + success: false, + message: "Failed to fetch payment analytics time series", + }); + } +}; + +export default { + getAnalyticsOverview, + getAnalyticsSummary, + getAnalyticsTimeSeries, +}; diff --git a/src/controllers/stellar/donationController.js b/src/controllers/stellar/donationController.js index 50b05be2..08ccfb7e 100644 --- a/src/controllers/stellar/donationController.js +++ b/src/controllers/stellar/donationController.js @@ -2,16 +2,21 @@ import mongoose from "mongoose"; import Transaction from "../../models/Transaction.js"; import { - isValidPublicKey, getAccountBalance, - buildPaymentTransaction, - buildSep7Uri, submitTransaction, verifyPaymentOperations, + validateSignedPaymentXdr, getExplorerUrl, - NETWORK, DONATION_WALLET_PUBLIC_KEY, } from "../../services/stellar/stellarService.js"; +import { createDonationIntent } from "../../services/stellar/donationIntentService.js"; +import { markPledgeTransactionPaid } from "../../services/pledgeService.js"; +import { + isFeeSponsorEnabled, + prepareSponsoredSubmission, + recordSponsorshipSpend, + SponsorshipError, +} from "../../services/stellar/feeSponsorService.js"; import logger from "../../config/logger.js"; import { enqueue } from "../../jobs/queue.js"; import { @@ -19,10 +24,10 @@ import { paymentsSubmitted, paymentsConfirmed, paymentsFailed, + sponsorshipsApproved, + sponsorshipsRejected, } from "../../config/metrics.js"; -const DONATION_MEMO = "DNB-SADAQAH"; - /** * Initialize a sadaqah donation - creates pending record and returns XDR to sign * POST /api/stellar/donation/initialize @@ -34,69 +39,8 @@ export const initializeDonation = async (req, res) => { try { const donorId = req.user._id; const { amount, publicKey } = req.body; - - // Donation wallet must be configured on the server - if (!DONATION_WALLET_PUBLIC_KEY) { - await session.abortTransaction(); - return res.status(503).json({ - success: false, - message: "Donations are not available right now. Please try again later.", - }); - } - - // Validate donor public key - if (!publicKey || !isValidPublicKey(publicKey)) { - await session.abortTransaction(); - return res.status(400).json({ - success: false, - message: "Invalid Stellar public key", - }); - } - - // Validate amount (positive, max 7 decimal places) - const parsedAmount = Number(amount); - if ( - !amount || - !Number.isFinite(parsedAmount) || - parsedAmount <= 0 || - !/^\d+(\.\d{1,7})?$/.test(amount.toString()) - ) { - await session.abortTransaction(); - return res.status(400).json({ - success: false, - message: - "Invalid amount. Must be a positive number with at most 7 decimal places", - }); - } - - // Build the donation payment transaction (donor -> donation fund) - const paymentTx = await buildPaymentTransaction({ - sourcePublicKey: publicKey, - destinationPublicKey: DONATION_WALLET_PUBLIC_KEY, - amount: amount.toString(), - memo: DONATION_MEMO, - }); - - // SEP-7 URI so wallets can deep-link the same payment - const sep7Uri = buildSep7Uri({ - destination: DONATION_WALLET_PUBLIC_KEY, - amount: amount.toString(), - memo: DONATION_MEMO, - }); - - // Create pending donation record - const donation = new Transaction({ - type: "donation", - buyer: donorId, - buyerWallet: publicKey, - creatorWallet: DONATION_WALLET_PUBLIC_KEY, - amount: amount.toString(), - network: NETWORK, - status: "pending", - stellarTxHash: paymentTx.hash, // Temporary hash, will be replaced with actual - }); - - await donation.save({ session }); + const { transaction: donation, transactionXdr, sep7Uri, networkPassphrase } = + await createDonationIntent({ donorId, publicKey, amount, session }); await session.commitTransaction(); paymentsInitialized.inc({ type: "donation" }); @@ -105,16 +49,16 @@ export const initializeDonation = async (req, res) => { res.status(200).json({ success: true, donationId: donation._id, - transactionXdr: paymentTx.xdr, + transactionXdr, sep7Uri, - networkPassphrase: paymentTx.networkPassphrase, + networkPassphrase, }); } catch (error) { await session.abortTransaction(); logger.error("Initialize donation error:", error); - res.status(500).json({ + res.status(error.statusCode || 500).json({ success: false, - message: "Failed to initialize donation", + message: error.statusCode ? error.message : "Failed to initialize donation", error: process.env.NODE_ENV === "development" ? error.message : undefined, }); @@ -132,7 +76,7 @@ export const submitDonation = async (req, res) => { session.startTransaction(); try { - const { donationId, signedXdr } = req.body; + const { donationId, signedXdr, requestSponsorship } = req.body; const donorId = req.user._id; if (!donationId || !signedXdr) { @@ -159,7 +103,82 @@ export const submitDonation = async (req, res) => { }); } - // Update status to submitted + // Build expected payments array for validation + const expectedPayments = [ + { + destination: donation.creatorWallet, + amount: donation.amount, + }, + ]; + + // Fee-bump sponsorship (#30): opt-in and only when the master switch is on. + // Skipped entirely with the flag off — the donation submit path below is + // then byte-for-byte the original unsponsored flow. + const wantSponsor = requestSponsorship === true && isFeeSponsorEnabled(); + let submissionXdr = signedXdr; + let sponsorship = null; + + if (wantSponsor) { + try { + sponsorship = await prepareSponsoredSubmission({ + signedXdr, + transactionRow: donation, + userId: donorId, + session, + }); + submissionXdr = sponsorship.feeBumpXdr; + } catch (sponsorError) { + if (sponsorError instanceof SponsorshipError) { + // Sponsorship-specific failure: leave the donation pending so the + // client can retry unsponsored; never mark it failed. + await session.abortTransaction(); + sponsorshipsRejected.inc({ + type: "donation", + reason: sponsorError.code, + }); + logger.info( + `Sponsorship rejected for donation ${donationId}: ${sponsorError.code}` + ); + return res.status(sponsorError.httpStatus).json({ + success: false, + message: "Fee sponsorship was not applied; retry without sponsorship", + sponsorship: { approved: false, reason: sponsorError.code }, + retryUnsponsored: true, + }); + } + throw sponsorError; + } + sponsorshipsApproved.inc({ type: "donation" }); + logger.info(`Sponsorship approved for donation ${donationId}`); + } else { + // Validate signed XDR contents (memo, payments, optional source) + try { + validateSignedPaymentXdr( + signedXdr, + expectedPayments, + donation.memo, + donation.buyerWallet, + true + ); + } catch (validationError) { + donation.status = "failed"; + donation.expiresAt = undefined; + donation.failureReason = `validation_failed: ${validationError.message}`; + await donation.save({ session }); + await session.commitTransaction(); + paymentsFailed.inc({ type: "donation", reason: "validation_failed" }); + + logger.error(`Donation ${donationId} validation failed:`, validationError.message); + + return res.status(400).json({ + success: false, + message: "Signed transaction does not match expected payment details", + error: validationError.message, + }); + } + } + + // Update status to submitted after validation donation.status = "submitted"; donation.submittedAt = new Date(); await donation.save({ session }); @@ -168,9 +187,10 @@ export const submitDonation = async (req, res) => { // Submit to Stellar network let result; try { - result = await submitTransaction(signedXdr); + result = await submitTransaction(submissionXdr); } catch (stellarError) { donation.status = "failed"; + donation.expiresAt = undefined; donation.failureReason = stellarError.message; await donation.save({ session }); await session.commitTransaction(); @@ -185,16 +205,42 @@ export const submitDonation = async (req, res) => { }); } + // Sponsored submits: the fee-bump has landed, so account the spend (with + // the real fee_charged) and stamp the sponsorship fields. Verification and + // the stored hash use the inner-transaction hash (which matches + // `expectedHash`); the fee-bump (outer) hash is kept alongside. + if (sponsorship) { + donation.sponsored = true; + donation.feeBumpTxHash = sponsorship.outerHash; + donation.sponsorFeeCharged = + result.feeCharged != null + ? String(result.feeCharged) + : String(sponsorship.maxFeeStroops); + try { + await recordSponsorshipSpend({ + userId: donorId, + feeStroops: + result.feeCharged != null + ? Number(result.feeCharged) + : sponsorship.maxFeeStroops, + session, + }); + } catch (spendErr) { + logger.error( + `Failed to record sponsorship spend for donation ${donationId}:`, + spendErr + ); + } + } + + const settledHash = sponsorship ? sponsorship.innerHash : result.hash; + // Verify on-chain that the donation actually paid the fund (amount, destination, asset) - const verification = await verifyPaymentOperations(result.hash, [ - { - destination: donation.creatorWallet, - amount: donation.amount, - }, - ]); + // (expectedPayments already defined above for pre-submission validation) + const verification = await verifyPaymentOperations(settledHash, expectedPayments); if (!verification.verified) { - donation.stellarTxHash = result.hash; + donation.stellarTxHash = settledHash; if (verification.transient) { donation.status = "retrying"; donation.failureReason = verification.reason; @@ -205,7 +251,7 @@ export const submitDonation = async (req, res) => { { attempts: 5, backoffMs: 1000, - idempotencyKey: `verify:${result.hash}`, + idempotencyKey: `verify:${settledHash}`, session, } ); @@ -214,11 +260,13 @@ export const submitDonation = async (req, res) => { success: true, message: "Donation submitted; confirmation is in progress", donationId: donation._id, - txHash: result.hash, + txHash: settledHash, status: "retrying", + ...(sponsorship && { sponsored: true }), }); } donation.status = "failed"; + donation.expiresAt = undefined; donation.failureReason = `On-chain verification failed: ${verification.reason}`; await donation.save({ session }); await session.commitTransaction(); @@ -236,10 +284,11 @@ export const submitDonation = async (req, res) => { } // Mark confirmed - donation.stellarTxHash = result.hash; + donation.stellarTxHash = settledHash; donation.stellarLedger = result.ledger; donation.status = "confirmed"; donation.confirmedAt = new Date(); + donation.expiresAt = undefined; // terminal state — never TTL-reapable await donation.save({ session }); await enqueue( "generateReceipt", @@ -247,22 +296,30 @@ export const submitDonation = async (req, res) => { { attempts: 5, backoffMs: 1000, - idempotencyKey: `receipt:${result.hash}`, + idempotencyKey: `receipt:${settledHash}`, session, } ); await session.commitTransaction(); paymentsConfirmed.inc({ type: "donation" }); + markPledgeTransactionPaid(donation, donation.confirmedAt).catch((error) => + logger.error({ donationId, error: error.message }, "Failed to update pledge statistics") + ); logger.info( - `Donation successful: ${donationId}, Stellar TX: ${result.hash}` + `Donation successful: ${donationId}, Stellar TX: ${settledHash}${sponsorship ? " (sponsored)" : ""}` ); res.status(200).json({ success: true, message: "JazakAllah khair! Your sadaqah has been received.", - txHash: result.hash, - explorerUrl: getExplorerUrl(result.hash), + txHash: settledHash, + explorerUrl: getExplorerUrl(settledHash), + ...(sponsorship && { + sponsored: true, + feeBumpTxHash: sponsorship.outerHash, + sponsorFeeCharged: donation.sponsorFeeCharged, + }), }); } catch (error) { await session.abortTransaction(); diff --git a/src/controllers/stellar/giftController.js b/src/controllers/stellar/giftController.js new file mode 100644 index 00000000..06cac1e7 --- /dev/null +++ b/src/controllers/stellar/giftController.js @@ -0,0 +1,639 @@ +// controllers/stellar/giftController.js +// +// Gift-a-course/book flow built on Stellar claimable balances. The sender +// funds a claimable balance the recipient claims whenever they're ready +// (trustline-free), with a reclaim-after-expiry predicate. Access to the item +// is granted to the RECIPIENT on claim — never the payer. All signing is +// client-side; the server only builds unsigned XDR, records the real +// claimable-balance id, verifies on-chain, and grants access. +import User from "../../models/User.js"; +import Book from "../../models/Book.js"; +import Course from "../../models/Course.js"; +import GiftClaim from "../../models/GiftClaim.js"; +import { + buildCreateClaimableBalanceTx, + buildClaimTx, + resolveBalanceId, + getClaimableBalance, + validateSignedGiftXdr, + giftExpiryFromNow, +} from "../../services/stellar/claimableBalanceService.js"; +import { + submitTransaction, + verifyTransaction, + NETWORK, + getExplorerUrl, +} from "../../services/stellar/stellarService.js"; +import { grantItemAccess } from "../../services/stellar/reconciliationService.js"; +import logger from "../../config/logger.js"; + +// Gift memos are tagged DNB-GIFT- so they are +// never mistaken for a purchase (DNB-(BOOK|COURSE)-...) or donation memo by +// the reconciliation worker. +const buildGiftMemo = (itemId) => `DNB-GIFT-${String(itemId).slice(-8)}`; + +const isBeforeExpiry = (gift, now = Date.now()) => + now < gift.expiresAt.getTime(); + +/** + * Initialize a gift: validate the recipient, build an unsigned + * create_claimable_balance XDR, and persist a pending GiftClaim. + * POST /api/stellar/gifts/initialize + */ +export const initializeGift = async (req, res) => { + try { + const senderId = req.user._id; + const { itemType, itemId, recipientUserId } = req.body; + + if (!["book", "course"].includes(itemType)) { + return res.status(400).json({ + success: false, + message: "Invalid item type. Must be 'book' or 'course'", + }); + } + + const sender = await User.findById(senderId); + if (!sender?.stellarWallet?.publicKey) { + return res.status(400).json({ + success: false, + message: "Please connect your Stellar wallet first", + }); + } + + const Model = itemType === "book" ? Book : Course; + const populateField = itemType === "book" ? "author" : "createdBy"; + const item = await Model.findById(itemId).populate( + populateField, + "stellarWallet name" + ); + if (!item) { + return res.status(404).json({ + success: false, + message: `${itemType} not found`, + }); + } + const creator = itemType === "book" ? item.author : item.createdBy; + + if (!item.price || item.price === 0) { + return res.status(400).json({ + success: false, + message: "This item is free, no gift needed", + }); + } + + if (recipientUserId === senderId.toString()) { + return res.status(400).json({ + success: false, + message: "You cannot gift an item to yourself", + }); + } + + const recipient = await User.findById(recipientUserId); + if (!recipient) { + return res.status(404).json({ + success: false, + message: "Recipient not found", + }); + } + if (!recipient?.stellarWallet?.publicKey) { + return res.status(400).json({ + success: false, + message: "Recipient has not connected their Stellar wallet yet", + }); + } + + // Already-owned guard, checked against the RECIPIENT (mirrors + // initializePayment's guard in the purchase flow). + const purchasedArray = + itemType === "book" ? recipient.purchasedBooks : recipient.purchasedCourses; + const idField = itemType === "book" ? "bookId" : "courseId"; + const alreadyOwns = purchasedArray?.some( + (p) => p[idField]?.toString() === itemId + ); + if (alreadyOwns) { + return res.status(400).json({ + success: false, + message: `Recipient already owns this ${itemType}`, + }); + } + + // Duplicate-pending guard (mirrors initializePayment). + const existingGift = await GiftClaim.findOne({ + sender: senderId, + recipient: recipientUserId, + itemType, + itemId, + status: { $in: ["pending_signature", "open"] }, + }); + if (existingGift) { + return res.status(400).json({ + success: false, + message: "You already have a pending gift for this recipient and item", + giftId: existingGift._id, + }); + } + + const expiresAt = giftExpiryFromNow(); + const paymentTx = await buildCreateClaimableBalanceTx({ + sourcePublicKey: sender.stellarWallet.publicKey, + claimantPublicKey: recipient.stellarWallet.publicKey, + amount: item.price.toString(), + expiresAt, + memo: buildGiftMemo(itemId), + }); + + const gift = new GiftClaim({ + sender: senderId, + recipient: recipient._id, + recipientWallet: recipient.stellarWallet.publicKey, + creator: creator?._id, + itemType, + itemId, + itemTypeModel: itemType === "book" ? "Book" : "Course", + itemTitle: item.title, + amount: item.price.toString(), + assetCode: "USDC", + status: "pending_signature", + expiresAt, + createTxHash: paymentTx.hash, + network: NETWORK, + }); + await gift.save(); + + logger.info( + `Gift initialized: ${gift._id} from ${senderId} to ${recipient._id} for ${itemType} ${itemId}` + ); + + res.status(200).json({ + success: true, + giftId: gift._id, + payment: { + xdr: paymentTx.xdr, + networkPassphrase: paymentTx.networkPassphrase, + expectedHash: paymentTx.hash, + }, + expiresAt: gift.expiresAt, + item: { + title: item.title, + price: item.price, + type: itemType, + }, + recipient: { + name: recipient.name, + wallet: recipient.stellarWallet.publicKey, + }, + }); + } catch (error) { + logger.error("Initialize gift error:", error); + res.status(500).json({ + success: false, + message: "Failed to initialize gift", + error: + process.env.NODE_ENV === "development" ? error.message : undefined, + }); + } +}; + +/** + * Submit the signed create_claimable_balance XDR, resolve the real balance + * id, and mark the gift open. + * POST /api/stellar/gifts/submit + */ +export const submitGift = async (req, res) => { + try { + const senderId = req.user._id; + const { giftId, signedXdr } = req.body; + + if (!giftId || !signedXdr) { + return res.status(400).json({ + success: false, + message: "Gift ID and signed XDR are required", + }); + } + + const gift = await GiftClaim.findOne({ + _id: giftId, + sender: senderId, + status: "pending_signature", + }); + if (!gift) { + return res.status(404).json({ + success: false, + message: "Gift not found or already processed", + }); + } + + const sender = await User.findById(senderId); + if (!sender?.stellarWallet?.publicKey) { + return res.status(400).json({ + success: false, + message: "Please connect your Stellar wallet first", + }); + } + + // Verify the signed XDR BEFORE any DB write or access grant — a tampered + // XDR (wrong asset/amount/claimants) is rejected outright. + try { + validateSignedGiftXdr(signedXdr, { + assetCode: gift.assetCode, + amount: gift.amount, + recipientWallet: gift.recipientWallet, + senderWallet: sender.stellarWallet.publicKey, + expiresAt: gift.expiresAt, + }); + } catch (validationError) { + return res.status(400).json({ + success: false, + message: "Signed transaction does not match expected gift details", + error: validationError.message, + }); + } + + let result; + try { + result = await submitTransaction(signedXdr); + } catch (stellarError) { + return res.status(400).json({ + success: false, + message: "Transaction failed on Stellar network", + error: stellarError.message, + }); + } + + // Resolve the REAL claimable-balance id — NOT the tx hash. + const balanceId = await resolveBalanceId(result.hash, { + amount: gift.amount, + claimantPublicKey: gift.recipientWallet, + }); + if (!balanceId) { + // Leave the gift pending_signature so the client can retry — the + // create tx is already on-chain, and a retry simply re-resolves the id. + return res.status(502).json({ + success: false, + message: "Could not resolve the claimable balance id yet; please retry", + createTxHash: result.hash, + }); + } + + gift.createTxHash = result.hash; + gift.balanceId = balanceId; + gift.status = "open"; + await gift.save(); + + logger.info( + `Gift submitted: ${gift._id}, balance ${balanceId} (tx ${result.hash})` + ); + + res.status(200).json({ + success: true, + giftId: gift._id, + balanceId, + createTxHash: result.hash, + explorerUrl: getExplorerUrl(result.hash), + }); + } catch (error) { + logger.error("Submit gift error:", error); + res.status(500).json({ + success: false, + message: "Failed to submit gift", + error: + process.env.NODE_ENV === "development" ? error.message : undefined, + }); + } +}; + +/** + * List gifts sent and received by the current user. + * GET /api/stellar/gifts + */ +export const listGifts = async (req, res) => { + try { + const userId = req.user._id; + + // Lazy expiry transition: open gifts past their expiry flip to "expired" + // (the document is never deleted — the sender still needs it to reclaim). + await GiftClaim.updateMany( + { + $or: [{ sender: userId }, { recipient: userId }], + status: "open", + expiresAt: { $lte: new Date() }, + }, + { $set: { status: "expired" } } + ); + + const gifts = await GiftClaim.find({ + $or: [{ sender: userId }, { recipient: userId }], + }) + .sort({ createdAt: -1 }) + .populate("sender", "name") + .populate("recipient", "name"); + + res.status(200).json({ success: true, gifts }); + } catch (error) { + logger.error("List gifts error:", error); + res.status(500).json({ + success: false, + message: "Failed to fetch gifts", + }); + } +}; + +/** + * Get a single gift with live Horizon status of the underlying balance. + * GET /api/stellar/gifts/:id + */ +export const getGift = async (req, res) => { + try { + const userId = req.user._id; + const { id } = req.params; + + const gift = await GiftClaim.findOne({ + _id: id, + $or: [{ sender: userId }, { recipient: userId }], + }) + .populate("sender", "name") + .populate("recipient", "name"); + + if (!gift) { + return res.status(404).json({ + success: false, + message: "Gift not found", + }); + } + + // Lazy expiry transition (see listGifts). + if (gift.status === "open" && !isBeforeExpiry(gift)) { + gift.status = "expired"; + await gift.save(); + } + + let live = null; + if (gift.balanceId) { + const balance = await getClaimableBalance(gift.balanceId); + live = balance.exists + ? { + state: balance.record.state, + sponsor: balance.record.sponsor, + lastModifiedLedger: balance.record.last_modified_ledger, + } + : { state: "not_found" }; + } + + res.status(200).json({ + success: true, + gift: { ...gift.toObject(), live }, + }); + } catch (error) { + logger.error("Get gift error:", error); + res.status(500).json({ + success: false, + message: "Failed to fetch gift", + }); + } +}; + +/** + * Build an unsigned claim (or reclaim) XDR for a gift. + * Recipient-only before expiry; sender-only after expiry (reclaim). + * POST /api/stellar/gifts/:id/claim/initialize + */ +export const claimInitialize = async (req, res) => { + try { + const userId = req.user._id; + const { id } = req.params; + + const gift = await GiftClaim.findOne({ + _id: id, + $or: [{ sender: userId }, { recipient: userId }], + }); + if (!gift) { + return res.status(404).json({ + success: false, + message: "Gift not found", + }); + } + + if (gift.status === "pending_signature") { + return res.status(400).json({ + success: false, + message: "Gift has not been submitted yet", + }); + } + if (gift.status === "claimed" || gift.status === "reclaimed") { + return res.status(400).json({ + success: false, + message: "Gift has already been claimed", + }); + } + + const user = await User.findById(userId); + if (!user?.stellarWallet?.publicKey) { + return res.status(400).json({ + success: false, + message: "Please connect your Stellar wallet first", + }); + } + + const isRecipient = gift.recipient.toString() === userId.toString(); + const isSender = gift.sender.toString() === userId.toString(); + if (!isRecipient && !isSender) { + return res.status(403).json({ + success: false, + message: "You are not a party to this gift", + }); + } + + const beforeExpiry = isBeforeExpiry(gift); + // Lazy expiry transition before the authorization decision. + if (gift.status === "open" && !beforeExpiry) { + gift.status = "expired"; + await gift.save(); + } + + if (beforeExpiry) { + if (!isRecipient) { + return res.status(403).json({ + success: false, + message: "Only the recipient can claim this gift before it expires", + }); + } + } else if (!isSender) { + return res.status(403).json({ + success: false, + message: "This gift has expired; only the sender can reclaim it", + }); + } + + const claim = await buildClaimTx({ + claimantPublicKey: user.stellarWallet.publicKey, + balanceId: gift.balanceId, + }); + + res.status(200).json({ + success: true, + giftId: gift._id, + action: beforeExpiry ? "claim" : "reclaim", + claim: { + xdr: claim.xdr, + networkPassphrase: claim.networkPassphrase, + expectedHash: claim.hash, + includesChangeTrust: claim.includesChangeTrust, + }, + }); + } catch (error) { + logger.error("Claim initialize error:", error); + res.status(500).json({ + success: false, + message: "Failed to build claim transaction", + error: + process.env.NODE_ENV === "development" ? error.message : undefined, + }); + } +}; + +/** + * Submit a signed claim (or reclaim) XDR, verify it on-chain, and — for a + * recipient claim — grant item access to the RECIPIENT. + * POST /api/stellar/gifts/:id/claim/submit + */ +export const claimSubmit = async (req, res) => { + try { + const userId = req.user._id; + const { id } = req.params; + const { signedXdr } = req.body; + + if (!signedXdr) { + return res.status(400).json({ + success: false, + message: "Signed XDR is required", + }); + } + + const gift = await GiftClaim.findOne({ + _id: id, + $or: [{ sender: userId }, { recipient: userId }], + }); + if (!gift) { + return res.status(404).json({ + success: false, + message: "Gift not found", + }); + } + + if (gift.status === "pending_signature") { + return res.status(400).json({ + success: false, + message: "Gift has not been submitted yet", + }); + } + if (gift.status === "claimed" || gift.status === "reclaimed") { + return res.status(400).json({ + success: false, + message: "Gift has already been claimed", + }); + } + + const user = await User.findById(userId); + if (!user?.stellarWallet?.publicKey) { + return res.status(400).json({ + success: false, + message: "Please connect your Stellar wallet first", + }); + } + + const isRecipient = gift.recipient.toString() === userId.toString(); + const isSender = gift.sender.toString() === userId.toString(); + if (!isRecipient && !isSender) { + return res.status(403).json({ + success: false, + message: "You are not a party to this gift", + }); + } + + const beforeExpiry = isBeforeExpiry(gift); + if (gift.status === "open" && !beforeExpiry) { + gift.status = "expired"; + await gift.save(); + } + + if (beforeExpiry && !isRecipient) { + return res.status(403).json({ + success: false, + message: "Only the recipient can claim this gift before it expires", + }); + } + if (!beforeExpiry && !isSender) { + return res.status(403).json({ + success: false, + message: "This gift has expired; only the sender can reclaim it", + }); + } + + let result; + try { + result = await submitTransaction(signedXdr); + } catch (stellarError) { + return res.status(400).json({ + success: false, + message: "Transaction failed on Stellar network", + error: stellarError.message, + }); + } + + // Verify on-chain that the claim_claimable_balance op actually succeeded. + const verification = await verifyTransaction(result.hash); + if (!verification.exists || !verification.successful) { + return res.status(400).json({ + success: false, + message: "Claim transaction did not succeed on the Stellar network", + }); + } + const claimOp = (verification.operations || []).find( + (op) => op.type === "claim_claimable_balance" + ); + if (!claimOp) { + return res.status(400).json({ + success: false, + message: + "Claim transaction did not contain a claim_claimable_balance operation", + }); + } + + if (beforeExpiry) { + // Recipient claim → grant access to the RECIPIENT, never the sender. + // This deliberately inverts the buyer-centric purchase flow: the payer + // (sender) funded the balance, but the beneficiary (recipient) is the + // one who gains course/book access. + await grantItemAccess({ + buyerId: gift.recipient, + itemType: gift.itemType, + itemId: gift.itemId, + }); + gift.status = "claimed"; + } else { + gift.status = "reclaimed"; + } + gift.claimTxHash = result.hash; + await gift.save(); + + logger.info( + `Gift ${gift._id} ${beforeExpiry ? "claimed" : "reclaimed"} by ${userId} (tx ${result.hash})` + ); + + res.status(200).json({ + success: true, + giftId: gift._id, + action: beforeExpiry ? "claimed" : "reclaimed", + claimTxHash: result.hash, + explorerUrl: getExplorerUrl(result.hash), + }); + } catch (error) { + logger.error("Claim submit error:", error); + res.status(500).json({ + success: false, + message: "Failed to submit claim", + error: + process.env.NODE_ENV === "development" ? error.message : undefined, + }); + } +}; diff --git a/src/controllers/stellar/onrampController.js b/src/controllers/stellar/onrampController.js new file mode 100644 index 00000000..89dd0cc4 --- /dev/null +++ b/src/controllers/stellar/onrampController.js @@ -0,0 +1,220 @@ +// controllers/stellar/onrampController.js +import OnrampTransaction from "../../models/OnrampTransaction.js"; +import { + buildWidgetUrl, + isOnrampConfigured, + verifyWebhookSignature, + mapProviderStatus, +} from "../../services/stellar/onrampService.js"; +import logger from "../../config/logger.js"; + +/** + * Create a fiat on-ramp widget session for the authenticated user. + * + * Persists a `created` OnrampTransaction linked to the user, then returns a + * signed MoonPay widget URL with the user's wallet address pre-filled. The + * record id is passed to MoonPay as `externalTransactionId` so later webhooks + * can be matched back to the originating record. + * + * POST /api/stellar/onramp/session + * + * @param {import("express").Request} req Authenticated request (`req.user`). + * @param {import("express").Response} res + * @returns {Promise} + */ +export const createOnrampSession = async (req, res) => { + try { + if (!isOnrampConfigured()) { + return res.status(503).json({ + success: false, + message: "Fiat on-ramp is not configured", + }); + } + + const userId = req.user._id; + const { walletAddress, cryptoCurrency, fiatCurrency, fiatAmount, redirectUrl } = + req.body; + + if (!walletAddress) { + return res.status(400).json({ + success: false, + message: "walletAddress is required", + }); + } + + // Create the tracking record first so its id can be embedded in the widget + // URL as the provider's externalTransactionId. + const record = new OnrampTransaction({ + user: userId, + walletAddress, + provider: "moonpay", + status: "created", + cryptoCurrency: cryptoCurrency ? cryptoCurrency.toLowerCase() : undefined, + fiatCurrency: fiatCurrency ? fiatCurrency.toLowerCase() : undefined, + fiatAmount: fiatAmount !== undefined ? String(fiatAmount) : undefined, + }); + + let widget; + try { + widget = buildWidgetUrl({ + walletAddress, + cryptoCurrency, + baseCurrencyCode: fiatCurrency, + baseCurrencyAmount: fiatAmount, + externalTransactionId: record._id.toString(), + email: req.user.email, + redirectUrl, + }); + } catch (buildError) { + // Invalid wallet / unconfigured — surface without persisting the record. + return res.status(buildError.statusCode || 400).json({ + success: false, + message: buildError.message, + }); + } + + record.cryptoCurrency = widget.cryptoCurrency; + await record.save(); + + logger.info(`On-ramp session created: ${record._id} for user ${userId}`); + + res.status(201).json({ + success: true, + onrampId: record._id, + provider: "moonpay", + widgetUrl: widget.url, + status: record.status, + }); + } catch (error) { + logger.error("Create on-ramp session error:", error); + res.status(500).json({ + success: false, + message: "Failed to create on-ramp session", + error: process.env.NODE_ENV === "development" ? error.message : undefined, + }); + } +}; + +/** + * List the authenticated user's on-ramp transactions, most recent first. + * + * GET /api/stellar/onramp/transactions + * + * @param {import("express").Request} req Authenticated request (`req.user`). + * @param {import("express").Response} res + * @returns {Promise} + */ +export const getOnrampTransactions = async (req, res) => { + try { + const userId = req.user._id; + const transactions = await OnrampTransaction.find({ user: userId }) + .sort({ createdAt: -1 }) + .limit(50) + .select( + "provider status providerStatus cryptoCurrency fiatCurrency fiatAmount cryptoAmount cryptoTransactionHash walletAddress createdAt completedAt" + ); + + res.status(200).json({ + success: true, + count: transactions.length, + transactions, + }); + } catch (error) { + logger.error("Get on-ramp transactions error:", error); + res.status(500).json({ + success: false, + message: "Failed to fetch on-ramp transactions", + }); + } +}; + +/** + * Handle inbound MoonPay webhooks for on-ramp status updates. + * + * Public endpoint: authenticity is established by verifying the provider HMAC + * signature over the raw request body (`req.rawBody`, captured globally in + * app.js). The matching OnrampTransaction is located by `externalTransactionId` + * (our record id) or the provider transaction id, then updated in place. + * + * POST /api/stellar/onramp/webhook + * + * @param {import("express").Request} req Public request; `req.rawBody` is the + * exact bytes of the body used for signature verification. + * @param {import("express").Response} res + * @returns {Promise} + */ +export const handleWebhook = async (req, res) => { + try { + const signatureHeader = + req.get("Moonpay-Signature-V2") || req.get("moonpay-signature-v2"); + + const valid = verifyWebhookSignature(req.rawBody, signatureHeader); + if (!valid) { + logger.warn("Rejected on-ramp webhook: invalid signature"); + return res.status(401).json({ + success: false, + message: "Invalid webhook signature", + }); + } + + const data = req.body?.data || {}; + const providerStatus = data.status; + const externalTransactionId = data.externalTransactionId; + const providerTransactionId = data.id; + + // Locate the originating record: prefer our own id, fall back to the + // provider id for events that omit externalTransactionId. + let record = null; + if (externalTransactionId) { + record = await OnrampTransaction.findById(externalTransactionId).catch( + () => null + ); + } + if (!record && providerTransactionId) { + record = await OnrampTransaction.findOne({ providerTransactionId }); + } + + if (!record) { + // Acknowledge to stop provider retries; nothing to update on our side. + logger.warn( + `On-ramp webhook for unknown transaction (external=${externalTransactionId}, provider=${providerTransactionId})` + ); + return res.status(200).json({ success: true, matched: false }); + } + + if (providerTransactionId) record.providerTransactionId = providerTransactionId; + if (providerStatus) { + record.providerStatus = providerStatus; + record.status = mapProviderStatus(providerStatus); + } + if (data.cryptoTransactionId) { + record.cryptoTransactionHash = data.cryptoTransactionId; + } + if (data.quoteCurrencyAmount !== undefined && data.quoteCurrencyAmount !== null) { + record.cryptoAmount = String(data.quoteCurrencyAmount); + } + if (data.baseCurrencyAmount !== undefined && data.baseCurrencyAmount !== null) { + record.fiatAmount = String(data.baseCurrencyAmount); + } + if (data.failureReason) { + record.failureReason = data.failureReason; + } + if (record.status === "completed" && !record.completedAt) { + record.completedAt = new Date(); + } + + await record.save(); + + logger.info( + `On-ramp webhook applied: ${record._id} -> ${record.status} (${providerStatus})` + ); + + res.status(200).json({ success: true, matched: true }); + } catch (error) { + logger.error("On-ramp webhook error:", error); + res.status(500).json({ + success: false, + message: "Failed to process on-ramp webhook", + }); + } +}; diff --git a/src/controllers/stellar/paymentController.js b/src/controllers/stellar/paymentController.js index b6911842..377f0ad8 100644 --- a/src/controllers/stellar/paymentController.js +++ b/src/controllers/stellar/paymentController.js @@ -10,18 +10,28 @@ import { buildSep7Uri, calculateFeeSplit, preflightPayment, + PREFLIGHT_REASON_CODES, submitTransaction, verifyTransaction, verifyPaymentOperations, + validateSignedPaymentXdr, findPaymentPaths, applySlippage, NETWORK, + networkPassphrase, getExplorerUrl, USDC, PLATFORM_WALLET_PUBLIC_KEY, } from "../../services/stellar/stellarService.js"; import { getAssetConfig, isAssetSupported, getSupportedCodes } from "../../config/assets.js"; import * as StellarSdk from "@stellar/stellar-sdk"; +import { + isFeeSponsorEnabled, + prepareSponsoredSubmission, + recordSponsorshipSpend, + getSponsorshipStatus, + SponsorshipError, +} from "../../services/stellar/feeSponsorService.js"; import { recordSaleEarnings } from "../../services/payoutService.js"; import { grantItemAccess } from "../../services/stellar/reconciliationService.js"; import { enqueue } from "../../jobs/queue.js"; @@ -31,9 +41,12 @@ import { paymentsSubmitted, paymentsConfirmed, paymentsFailed, + sponsorshipsApproved, + sponsorshipsRejected, } from "../../config/metrics.js"; import { recordAudit } from "../../services/audit/auditService.js"; import { AUDIT_ACTIONS } from "../../models/AuditLog.js"; +import { emitEvent, EVENT_TYPES } from "../../services/webhooks/webhookService.js"; /** * Resolve the item, its creator, and the settlement destination wallet for a @@ -58,6 +71,9 @@ const resolvePaymentDestination = async ({ itemType, itemId, session }) => { error: { status: 400, message: "Creator has not connected their Stellar wallet yet", + // The buyer can still complete the purchase through the + // claimable-balance (gift) path instead of dead-ending. + fallback: "claimable_balance", }, }; } @@ -251,6 +267,7 @@ export const getPaymentPreflight = async (req, res) => { return res.status(resolved.error.status).json({ success: false, message: resolved.error.message, + ...(resolved.error.fallback && { fallback: resolved.error.fallback }), }); } @@ -284,9 +301,17 @@ export const getPaymentPreflight = async (req, res) => { assetCode, }); + // A destination without the asset trustline is exactly the case the + // claimable-balance (gift) path fixes — surface it so the frontend can + // route the buyer there instead of dead-ending on op_no_trust. + const hasNoTrustline = preflight.reasons?.some( + (r) => r.code === PREFLIGHT_REASON_CODES.DESTINATION_NO_TRUSTLINE + ); + res.status(200).json({ success: true, preflight, + ...(hasNoTrustline && { fallback: "claimable_balance" }), }); } catch (error) { logger.error("Payment preflight error:", error); @@ -342,6 +367,7 @@ export const initializePayment = async (req, res) => { return res.status(resolved.error.status).json({ success: false, message: resolved.error.message, + ...(resolved.error.fallback && { fallback: resolved.error.fallback }), }); } @@ -387,11 +413,26 @@ export const initializePayment = async (req, res) => { }).session(session); if (existingTx) { + // Idempotent initialize: a double-click or client retry while a + // pending checkout already exists must not pile up duplicate pending + // records. Return the existing record (with its original unsigned XDR + // when one was persisted) so the frontend can resume the same + // checkout; stale pending records are reaped by the TTL index on + // `expiresAt` (pending-only partial index). await session.abortTransaction(); - return res.status(400).json({ - success: false, - message: "You have a pending transaction for this item", + return res.status(200).json({ + success: true, + alreadyPending: true, transactionId: existingTx._id, + message: + "You already have a pending transaction for this item; returning it", + payment: existingTx.unsignedXdr + ? { + xdr: existingTx.unsignedXdr, + networkPassphrase, + expectedHash: existingTx.expectedHash, + } + : null, }); } @@ -499,7 +540,9 @@ export const initializePayment = async (req, res) => { network: NETWORK, status: "pending", settlement: settlementMode, - stellarTxHash: paymentTx.hash, + expectedHash: paymentTx.hash, + unsignedXdr: paymentTx.xdr, + memo, ...(sendAssetInput && { sendAsset: sendAssetInput, sendMax, @@ -539,6 +582,21 @@ export const initializePayment = async (req, res) => { }, }); + // Fire-and-forget: emit AFTER the txn has committed (never inside it). + await emitEvent(EVENT_TYPES.PAYMENT_INITIALIZED, { + transactionId: transaction._id.toString(), + itemType, + itemId: itemId.toString(), + itemTitle: item.title, + amount: transaction.amount, + currency: transaction.currency, + network: transaction.network, + settlement: settlementMode, + buyerId: buyerId.toString(), + creatorId: creator._id.toString(), + status: "pending", + }); + res.status(200).json({ success: true, transactionId: transaction._id, @@ -585,7 +643,7 @@ export const submitPayment = async (req, res) => { session.startTransaction(); try { - const { transactionId, signedXdr } = req.body; + const { transactionId, signedXdr, requestSponsorship } = req.body; const buyerId = req.user._id; if (!transactionId || !signedXdr) { @@ -596,6 +654,54 @@ export const submitPayment = async (req, res) => { }); } + // Derive the deterministic on-chain hash of the submitted transaction. + // A replayed submission of the same signed XDR produces the same hash, + // which is what makes submit naturally idempotent per transaction hash. + // Parse failures are deliberately ignored here — the XDR is validated in + // full below (validateSignedPaymentXdr), which keeps the error shape for + // malformed XDRs unchanged. + let signedTx = null; + try { + signedTx = StellarSdk.TransactionBuilder.fromXDR( + signedXdr, + networkPassphrase + ); + } catch { + // fall through — validation below reports the malformed XDR + } + const signedTxHash = signedTx?.hash().toString("hex") || null; + + // Idempotent submit: if this exact on-chain transaction hash was already + // processed (access granted, earnings recorded, receipt queued), return + // the original success response instead of re-processing — a double-click + // or a client retry after a timeout must never grant access twice. The + // unique index on stellarTxHash is the database-level backstop for the + // concurrent case (handled below on E11000). + if (signedTxHash) { + const alreadyProcessed = await Transaction.findOne({ + buyer: buyerId, + stellarTxHash: signedTxHash, + status: "confirmed", + }).session(session); + + if (alreadyProcessed?.status === "confirmed") { + await session.commitTransaction(); + return res.status(200).json({ + success: true, + replay: true, + message: "Payment already processed", + transaction: { + id: alreadyProcessed._id, + hash: alreadyProcessed.stellarTxHash, + ledger: alreadyProcessed.stellarLedger, + itemTitle: alreadyProcessed.itemTitle, + amount: alreadyProcessed.amount, + explorerUrl: getExplorerUrl(alreadyProcessed.stellarTxHash), + }, + }); + } + } + const transaction = await Transaction.findOne({ _id: transactionId, buyer: buyerId, @@ -610,6 +716,108 @@ export const submitPayment = async (req, res) => { }); } + // Build expected payments to validate XDR BEFORE submit + const expectedPayments = transaction.platformFee?.platformAmount + ? [ + { + destination: transaction.creatorWallet, + amount: transaction.platformFee.creatorAmount, + }, + { + destination: transaction.platformFee.platformWallet, + amount: transaction.platformFee.platformAmount, + }, + ] + : [ + { + destination: transaction.creatorWallet, + amount: transaction.amount, + }, + ]; + + // Fee-bump sponsorship (#30): only when the client opts in AND the master + // switch is on. With the flag off this is skipped entirely and the flow + // below is byte-for-byte the original unsponsored path. + const wantSponsor = requestSponsorship === true && isFeeSponsorEnabled(); + let submissionXdr = signedXdr; + let sponsorship = null; + + if (wantSponsor) { + try { + // Structural whitelist + spend caps + fee-bump wrapping. This is a + // strict superset of validateSignedPaymentXdr, so it is not run again + // for the sponsored path. + sponsorship = await prepareSponsoredSubmission({ + signedXdr, + transactionRow: transaction, + userId: buyerId, + session, + }); + submissionXdr = sponsorship.feeBumpXdr; + } catch (sponsorError) { + if (sponsorError instanceof SponsorshipError) { + // Sponsorship-specific failure: DO NOT mark the row failed. Leave it + // pending so the client can retry unsponsored (user pays the fee). + await session.abortTransaction(); + sponsorshipsRejected.inc({ + type: "purchase", + reason: sponsorError.code, + }); + logger.info( + `Sponsorship rejected for transaction ${transactionId}: ${sponsorError.code}` + ); + return res.status(sponsorError.httpStatus).json({ + success: false, + message: "Fee sponsorship was not applied; retry without sponsorship", + sponsorship: { approved: false, reason: sponsorError.code }, + retryUnsponsored: true, + }); + } + throw sponsorError; + } + sponsorshipsApproved.inc({ type: "purchase" }); + logger.info(`Sponsorship approved for transaction ${transactionId}`); + } else { + // Validate signed XDR contents (memo, payments, optional source) + try { + validateSignedPaymentXdr( + signedXdr, + expectedPayments, + transaction.memo, + transaction.buyerWallet, + true + ); + } catch (validationError) { + transaction.status = "failed"; + transaction.expiresAt = undefined; + transaction.failureReason = `validation_failed: ${validationError.message}`; + await transaction.save({ session }); + await session.commitTransaction(); + paymentsFailed.inc({ type: "purchase", reason: "validation_failed" }); + + logger.error(`Transaction ${transactionId} validation failed:`, validationError.message); + + await emitEvent(EVENT_TYPES.PAYMENT_FAILED, { + transactionId: transaction._id.toString(), + itemType: transaction.itemType, + itemId: transaction.itemId?.toString(), + amount: transaction.amount, + currency: transaction.currency, + network: transaction.network, + buyerId: buyerId.toString(), + status: "failed", + failureReason: `validation_failed: ${validationError.message}`, + }); + + return res.status(400).json({ + success: false, + message: "Signed transaction does not match expected payment details", + error: validationError.message, + }); + } + } + + // Update status to submitted after validation transaction.status = "submitted"; transaction.submittedAt = new Date(); await transaction.save({ session }); @@ -617,9 +825,10 @@ export const submitPayment = async (req, res) => { let result; try { - result = await submitTransaction(signedXdr); + result = await submitTransaction(submissionXdr); } catch (stellarError) { transaction.status = "failed"; + transaction.expiresAt = undefined; transaction.failureReason = stellarError.message; await transaction.save({ session }); await session.commitTransaction(); @@ -627,6 +836,18 @@ export const submitPayment = async (req, res) => { logger.error(`Transaction ${transactionId} failed:`, stellarError); + await emitEvent(EVENT_TYPES.PAYMENT_FAILED, { + transactionId: transaction._id.toString(), + itemType: transaction.itemType, + itemId: transaction.itemId?.toString(), + amount: transaction.amount, + currency: transaction.currency, + network: transaction.network, + buyerId: buyerId.toString(), + status: "failed", + failureReason: stellarError.message, + }); + return res.status(400).json({ success: false, message: "Transaction failed on Stellar network", @@ -634,32 +855,52 @@ export const submitPayment = async (req, res) => { }); } - const expectedPayments = transaction.platformFee?.platformAmount - ? [ - { - destination: transaction.creatorWallet, - amount: transaction.platformFee.creatorAmount, - }, - { - destination: transaction.platformFee.platformWallet, - amount: transaction.platformFee.platformAmount, - }, - ] - : [ - { - destination: transaction.creatorWallet, - amount: transaction.amount, - }, - ]; + // Sponsored submits: the platform's fee-bump has landed, so account the + // spend (with the real fee_charged) and stamp the sponsorship fields. The + // inner-transaction hash is what the payment operations verify against and + // what matches `expectedHash`; the fee-bump (outer) hash is kept alongside. + if (sponsorship) { + transaction.sponsored = true; + transaction.feeBumpTxHash = sponsorship.outerHash; + transaction.sponsorFeeCharged = + result.feeCharged != null + ? String(result.feeCharged) + : String(sponsorship.maxFeeStroops); + try { + await recordSponsorshipSpend({ + userId: buyerId, + feeStroops: + result.feeCharged != null + ? Number(result.feeCharged) + : sponsorship.maxFeeStroops, + session, + }); + } catch (spendErr) { + // Accounting must never sink an on-chain-successful payment; a sweep + // can reconcile spend later from the sponsored rows. + logger.error( + `Failed to record sponsorship spend for transaction ${transactionId}:`, + spendErr + ); + } + } + + // The hash the payment operations settle under: the inner tx for a + // sponsored submit, otherwise the submitted tx itself. + const settledHash = sponsorship ? sponsorship.innerHash : result.hash; + + // Verify on-chain that the creator (and platform, when a fee was applied) + // actually received the expected USDC amounts + // (expectedPayments already defined above for pre-submission validation) const verification = await verifyPaymentOperations( - result.hash, + settledHash, expectedPayments, transaction.currency || "USDC" ); if (!verification.verified) { - transaction.stellarTxHash = result.hash; + transaction.stellarTxHash = settledHash; if (verification.transient) { transaction.status = "retrying"; transaction.failureReason = verification.reason; @@ -671,7 +912,7 @@ export const submitPayment = async (req, res) => { { attempts: 5, backoffMs: 1000, - idempotencyKey: `verify:${result.hash}`, + idempotencyKey: `verify:${settledHash}`, session, } ); @@ -689,11 +930,13 @@ export const submitPayment = async (req, res) => { success: true, message: "Payment submitted; confirmation is in progress", transactionId: transaction._id, - txHash: result.hash, + txHash: settledHash, status: "retrying", + ...(sponsorship && { sponsored: true }), }); } transaction.status = "failed"; + transaction.expiresAt = undefined; transaction.failureReason = `On-chain verification failed: ${verification.reason}`; await transaction.save({ session }); await session.commitTransaction(); @@ -712,11 +955,24 @@ export const submitPayment = async (req, res) => { status: "failure", metadata: { transactionId, - stellarTxHash: result.hash, + stellarTxHash: settledHash, failureReason: `On-chain verification failed: ${verification.reason}`, }, }); + await emitEvent(EVENT_TYPES.PAYMENT_FAILED, { + transactionId: transaction._id.toString(), + itemType: transaction.itemType, + itemId: transaction.itemId?.toString(), + amount: transaction.amount, + currency: transaction.currency, + network: transaction.network, + stellarTxHash: settledHash, + buyerId: buyerId.toString(), + status: "failed", + failureReason: `On-chain verification failed: ${verification.reason}`, + }); + return res.status(400).json({ success: false, message: "Payment could not be verified on the Stellar network", @@ -724,11 +980,47 @@ export const submitPayment = async (req, res) => { }); } - transaction.stellarTxHash = result.hash; + transaction.stellarTxHash = settledHash; transaction.stellarLedger = result.ledger; transaction.status = "confirmed"; transaction.confirmedAt = new Date(); - await transaction.save({ session }); + transaction.expiresAt = undefined; // terminal state — never TTL-reapable + try { + await transaction.save({ session }); + } catch (saveError) { + // Idempotency backstop at the database layer: the unique index on + // stellarTxHash means a concurrent request already confirmed this exact + // on-chain hash. Roll back this session's writes (including the + // "submitted" status) and return the original success response. + if (saveError?.code === 11000) { + const concurrentConfirmed = await Transaction.findOne({ + buyer: buyerId, + stellarTxHash: result.hash, + status: "confirmed", + }).session(session); + + if (concurrentConfirmed) { + await session.abortTransaction(); + logger.info( + `Transaction ${transactionId} already confirmed by a concurrent request (${result.hash}); returning existing result` + ); + return res.status(200).json({ + success: true, + replay: true, + message: "Payment already processed", + transaction: { + id: concurrentConfirmed._id, + hash: concurrentConfirmed.stellarTxHash, + ledger: concurrentConfirmed.stellarLedger, + itemTitle: concurrentConfirmed.itemTitle, + amount: concurrentConfirmed.amount, + explorerUrl: getExplorerUrl(concurrentConfirmed.stellarTxHash), + }, + }); + } + } + throw saveError; + } paymentsConfirmed.inc({ type: "purchase" }); await recordSaleEarnings(transaction, { session }); @@ -747,7 +1039,7 @@ export const submitPayment = async (req, res) => { { attempts: 5, backoffMs: 1000, - idempotencyKey: `receipt:${result.hash}`, + idempotencyKey: `receipt:${settledHash}`, session, } ); @@ -763,7 +1055,7 @@ export const submitPayment = async (req, res) => { await session.commitTransaction(); logger.info( - `Payment successful: ${transactionId}, Stellar TX: ${result.hash}` + `Payment successful: ${transactionId}, Stellar TX: ${settledHash}${sponsorship ? " (sponsored)" : ""}` ); recordAudit({ @@ -775,25 +1067,49 @@ export const submitPayment = async (req, res) => { status: "success", metadata: { transactionId, - stellarTxHash: result.hash, + stellarTxHash: settledHash, stellarLedger: result.ledger, amount: transaction.amount, itemType: transaction.itemType, - itemId: transaction.itemId.toString(), + itemId: transaction.itemId?.toString(), settlementMode: transaction.settlement, + sponsored: !!sponsorship, }, }); + // Fire-and-forget: emit AFTER the txn commit above. + await emitEvent(EVENT_TYPES.PAYMENT_CONFIRMED, { + transactionId: transaction._id.toString(), + itemType: transaction.itemType, + itemId: transaction.itemId?.toString(), + itemTitle: transaction.itemTitle, + amount: transaction.amount, + currency: transaction.currency, + network: transaction.network, + settlement: transaction.settlement, + stellarTxHash: settledHash, + stellarLedger: result.ledger, + buyerId: buyerId.toString(), + creatorId: transaction.creator?.toString(), + status: "confirmed", + sponsored: !!sponsorship, + }); + res.status(200).json({ success: true, message: "Payment successful!", transaction: { id: transaction._id, - hash: result.hash, + hash: settledHash, ledger: result.ledger, itemTitle: transaction.itemTitle, amount: transaction.amount, - explorerUrl: getExplorerUrl(result.hash), + explorerUrl: getExplorerUrl(settledHash), + ...(sponsorship && { + sponsored: true, + feeBumpTxHash: sponsorship.outerHash, + sponsorFeeCharged: transaction.sponsorFeeCharged, + }), }, }); } catch (error) { @@ -929,8 +1245,13 @@ export const cancelTransaction = async (req, res) => { status: "pending", }, { - status: "expired", - failureReason: "Cancelled by user", + $set: { + status: "expired", + failureReason: "Cancelled by user", + }, + $unset: { + expiresAt: 1, + }, }, { new: true } ); @@ -960,6 +1281,18 @@ export const cancelTransaction = async (req, res) => { }, }); + await emitEvent(EVENT_TYPES.PAYMENT_EXPIRED, { + transactionId: transaction._id.toString(), + itemType: transaction.itemType, + itemId: transaction.itemId?.toString(), + amount: transaction.amount, + currency: transaction.currency, + network: transaction.network, + buyerId: userId.toString(), + status: "expired", + failureReason: "Cancelled by user", + }); + res.status(200).json({ success: true, message: "Transaction cancelled", @@ -971,4 +1304,24 @@ export const cancelTransaction = async (req, res) => { message: "Failed to cancel transaction", }); } +}; + +/** + * Fee-bump sponsorship status (#30) — auth-protected ops view. Exposes whether + * sponsorship is enabled, the sponsor account's public key and live XLM float, + * the configured caps, and today's spend so the float can be topped up before + * it runs dry. The sponsor secret is never read here and never returned. + * GET /api/stellar/payment/sponsorship/status + */ +export const sponsorshipStatus = async (req, res) => { + try { + const status = await getSponsorshipStatus(); + res.status(200).json({ success: true, sponsorship: status }); + } catch (error) { + logger.error("Sponsorship status error:", error); + res.status(500).json({ + success: false, + message: "Failed to fetch sponsorship status", + }); + } }; \ No newline at end of file diff --git a/src/controllers/stellar/pledgeController.js b/src/controllers/stellar/pledgeController.js new file mode 100644 index 00000000..b86f8e17 --- /dev/null +++ b/src/controllers/stellar/pledgeController.js @@ -0,0 +1,145 @@ +import mongoose from "mongoose"; +import Pledge from "../../models/Pledge.js"; +import PledgeCycle from "../../models/PledgeCycle.js"; +import { isValidPublicKey } from "../../services/stellar/stellarService.js"; +import { + createDonationIntent, + validateDonationAmount, +} from "../../services/stellar/donationIntentService.js"; +import { firstDueAt } from "../../services/pledgeService.js"; +import { submitDonation } from "./donationController.js"; + +export const createPledge = async (req, res) => { + const { publicKey, amount, cadence, anchorDay, anchorDate, startAt } = req.body; + if (!isValidPublicKey(publicKey || "")) { + return res.status(400).json({ success: false, message: "Invalid Stellar public key" }); + } + if (!validateDonationAmount(amount)) { + return res.status(400).json({ success: false, message: "Invalid amount. Must be positive with at most 7 decimal places" }); + } + if (!["daily", "weekly", "monthly"].includes(cadence)) { + return res.status(400).json({ success: false, message: "Cadence must be daily, weekly, or monthly" }); + } + if (cadence === "weekly" && anchorDay !== undefined && (!Number.isInteger(anchorDay) || anchorDay < 0 || anchorDay > 6)) { + return res.status(400).json({ success: false, message: "anchorDay must be between 0 and 6" }); + } + if (cadence === "monthly" && anchorDate !== undefined && (!Number.isInteger(anchorDate) || anchorDate < 1 || anchorDate > 31)) { + return res.status(400).json({ success: false, message: "anchorDate must be between 1 and 31" }); + } + const effectiveStart = startAt ? new Date(startAt) : new Date(); + if (Number.isNaN(effectiveStart.getTime())) { + return res.status(400).json({ success: false, message: "Invalid startAt" }); + } + const pledge = await Pledge.create({ + user: req.user._id, + publicKey, + amount: amount.toString(), + cadence, + anchorDay: cadence === "weekly" ? (anchorDay ?? effectiveStart.getUTCDay()) : undefined, + anchorDate: cadence === "monthly" ? (anchorDate ?? effectiveStart.getUTCDate()) : undefined, + nextDueAt: firstDueAt({ cadence, anchorDay, anchorDate, startAt: effectiveStart }), + }); + res.status(201).json({ success: true, pledge }); +}; + +export const listPledges = async (req, res) => { + const pledges = await Pledge.find({ user: req.user._id }).sort({ createdAt: -1 }); + res.json({ success: true, pledges }); +}; + +export const getPledgeStats = async (req, res) => { + const pledges = await Pledge.find({ user: req.user._id }).lean(); + const totals = pledges.reduce( + (stats, pledge) => { + stats.totalPaidStroops = (BigInt(stats.totalPaidStroops) + BigInt(pledge.totalPaidStroops || "0")).toString(); + stats.longestStreak = Math.max(stats.longestStreak, pledge.longestStreak || 0); + stats.active += pledge.status === "active" ? 1 : 0; + return stats; + }, + { totalPaidStroops: "0", longestStreak: 0, active: 0 } + ); + res.json({ success: true, ...totals, pledges }); +}; + +export const updatePledgeStatus = async (req, res) => { + const { status } = req.body; + if (!["active", "paused", "cancelled"].includes(status)) { + return res.status(400).json({ success: false, message: "Invalid pledge status" }); + } + const pledge = await Pledge.findOne({ _id: req.params.id, user: req.user._id }); + if (!pledge) return res.status(404).json({ success: false, message: "Pledge not found" }); + if (pledge.status === "cancelled" && status !== "cancelled") { + return res.status(409).json({ success: false, message: "Cancelled pledges cannot be resumed" }); + } + pledge.status = status; + await pledge.save(); + res.json({ success: true, pledge }); +}; + +export const listPledgeCycles = async (req, res) => { + const pledge = await Pledge.findOne({ _id: req.params.id, user: req.user._id }); + if (!pledge) return res.status(404).json({ success: false, message: "Pledge not found" }); + const cycles = await PledgeCycle.find({ pledge: pledge._id }).sort({ dueAt: -1 }).populate("transaction"); + res.json({ success: true, cycles }); +}; + +export const initializePledgeCycle = async (req, res) => { + const session = await mongoose.startSession(); + session.startTransaction(); + try { + const cycle = await PledgeCycle.findById(req.params.cycleId).session(session); + if (!cycle || !["due", "notified"].includes(cycle.status)) { + await session.abortTransaction(); + return res.status(404).json({ success: false, message: "Payable pledge cycle not found" }); + } + const pledge = await Pledge.findOne({ _id: cycle.pledge, user: req.user._id, status: { $ne: "cancelled" } }).session(session); + if (!pledge) { + await session.abortTransaction(); + return res.status(404).json({ success: false, message: "Pledge not found" }); + } + if (cycle.windowEndsAt <= new Date()) { + cycle.status = "lapsed"; + pledge.consecutivePaid = 0; + await Promise.all([cycle.save({ session }), pledge.save({ session })]); + await session.commitTransaction(); + return res.status(410).json({ success: false, message: "Pledge cycle payment window has ended" }); + } + if (cycle.transaction) { + const transaction = await cycle.populate("transaction"); + await session.abortTransaction(); + return res.status(409).json({ success: false, message: "Pledge cycle already initialized", donationId: transaction.transaction?._id }); + } + const intent = await createDonationIntent({ + donorId: req.user._id, + publicKey: pledge.publicKey, + amount: pledge.amount, + session, + memo: "DNB-PLEDGE", + }); + cycle.transaction = intent.transaction._id; + await cycle.save({ session }); + await session.commitTransaction(); + res.json({ + success: true, + cycleId: cycle._id, + donationId: intent.transaction._id, + transactionXdr: intent.transactionXdr, + sep7Uri: intent.sep7Uri, + networkPassphrase: intent.networkPassphrase, + }); + } catch (error) { + await session.abortTransaction(); + res.status(error.statusCode || 500).json({ success: false, message: error.message }); + } finally { + session.endSession(); + } +}; + +export const submitPledgeCycle = async (req, res) => { + const cycle = await PledgeCycle.findById(req.params.cycleId).populate("pledge"); + if (!cycle || !cycle.pledge || cycle.pledge.user.toString() !== req.user._id.toString() || !cycle.transaction) { + return res.status(404).json({ success: false, message: "Initialized pledge cycle not found" }); + } + req.body.donationId = cycle.transaction.toString(); + return submitDonation(req, res); +}; diff --git a/src/controllers/stellar/refundController.js b/src/controllers/stellar/refundController.js index e85bc9c6..88da522b 100644 --- a/src/controllers/stellar/refundController.js +++ b/src/controllers/stellar/refundController.js @@ -321,7 +321,10 @@ export const submitRefund = async (req, res) => { await Transaction.findByIdAndUpdate( refund.originalTransaction, - { status: "refunded", refund: refund._id } + { + $set: { status: "refunded", refund: refund._id }, + $unset: { expiresAt: 1 }, // terminal state — never TTL-reapable + } ); logger.info(`Refund confirmed and access revoked atomically for refund ${refund._id}`); @@ -432,7 +435,8 @@ export const escalateDispute = async (req, res) => { await refund.save(); await Transaction.findByIdAndUpdate(refund.originalTransaction, { - status: "disputed", + $set: { status: "disputed" }, + $unset: { expiresAt: 1 }, // terminal state — never TTL-reapable }); logger.info(`Refund ${refund._id} escalated to dispute by buyer ${buyerId}`); diff --git a/src/controllers/stellar/walletController.js b/src/controllers/stellar/walletController.js index 7d47c0f2..d2c772be 100644 --- a/src/controllers/stellar/walletController.js +++ b/src/controllers/stellar/walletController.js @@ -8,6 +8,7 @@ import { import logger from "../../config/logger.js"; import { recordAudit } from "../../services/audit/auditService.js"; import { AUDIT_ACTIONS } from "../../models/AuditLog.js"; +import { emitEvent, EVENT_TYPES } from "../../services/webhooks/webhookService.js"; /** * Connect Stellar wallet to user profile @@ -84,6 +85,12 @@ export const connectWallet = async (req, res) => { metadata: { publicKey, network: NETWORK }, }); + await emitEvent(EVENT_TYPES.WALLET_CONNECTED, { + userId: userId.toString(), + publicKey, + network: NETWORK, + }); + res.status(200).json({ success: true, message: "Wallet connected successfully", @@ -132,6 +139,11 @@ export const disconnectWallet = async (req, res) => { metadata: { previousPublicKey }, }); + await emitEvent(EVENT_TYPES.WALLET_DISCONNECTED, { + userId: userId.toString(), + publicKey: previousPublicKey, + }); + res.status(200).json({ success: true, message: "Wallet disconnected successfully", diff --git a/src/controllers/uploadController.js b/src/controllers/uploadController.js index 902fa0d3..a78d4ddd 100644 --- a/src/controllers/uploadController.js +++ b/src/controllers/uploadController.js @@ -19,8 +19,8 @@ export const generateSignature = async (req, res) => { data: { timestamp, signature, - cloudName: config.cloud_name, - apiKey: config.api_key, + cloudName: config.cloud_name || process.env.CLOUDINARY_CLOUD_NAME || "test_cloud", + apiKey: config.api_key || process.env.CLOUDINARY_API_KEY || "test_key", } }); } catch (error) { diff --git a/src/controllers/userController.js b/src/controllers/userController.js index f462e1aa..49c54400 100644 --- a/src/controllers/userController.js +++ b/src/controllers/userController.js @@ -6,6 +6,7 @@ import logger from "../config/logger.js"; import { validateMagicBytes } from "../utils/fileValidation.js"; import CourseProgress from "../models/CourseProgress.js"; import { createFollowNotification, createUnfollowNotification } from "./notificationController.js"; +import badgeService from "../services/badge.service.js"; const PUBLIC_FIELDS = "name avatar bio role interests gender age country language"; @@ -130,9 +131,18 @@ export const getUser = async (req, res) => { message: "User not found", }); } + + const userObj = typeof user.toObject === "function" ? user.toObject() : { ...user }; + try { + const badges = await badgeService.getUserBadges(user._id); + userObj.badges = badges; + } catch (_err) { + userObj.badges = []; + } + res.status(200).json({ success: true, - user, + user: userObj, }); } catch (error) { logger.error("Get user error:", error); diff --git a/src/controllers/webhookController.js b/src/controllers/webhookController.js new file mode 100644 index 00000000..c7d3f7d9 --- /dev/null +++ b/src/controllers/webhookController.js @@ -0,0 +1,308 @@ +// controllers/webhookController.js +// +// Management API for outbound webhook endpoints and their deliveries. All +// routes are admin-gated (see routes/webhookRoutes.js). The signing secret is +// returned ONLY in the create and rotate-secret responses; no read endpoint +// ever returns it. +import { catchAsync, APIError } from "../middlewares/errorHandler.js"; +import WebhookEndpoint from "../models/WebhookEndpoint.js"; +import WebhookDelivery from "../models/WebhookDelivery.js"; +import { validateWebhookUrl, assertDeliverableUrl } from "../services/webhooks/urlGuard.js"; +import { generateSecret, encryptSecret } from "../services/webhooks/webhookSecret.js"; +import { emitEventToEndpoint, EVENT_TYPES } from "../services/webhooks/webhookService.js"; +import { recordAudit } from "../services/audit/auditService.js"; +import { AUDIT_ACTIONS } from "../models/AuditLog.js"; +import logger from "../config/logger.js"; + +const DELIVERY_STATUSES = ["pending", "retrying", "delivered", "dead"]; + +// Validate a URL structurally, then (in production) resolve DNS to reject +// private targets. Throws APIError(400) on failure. +const validateUrlOrThrow = async (url) => { + try { + validateWebhookUrl(url); + } catch (err) { + throw new APIError(err.message, 400); + } + const guard = await assertDeliverableUrl(url); + if (!guard.ok) { + throw new APIError(`Webhook URL rejected: ${guard.reason}`, 400); + } +}; + +const normalizeEvents = (events) => { + if (events === undefined) return undefined; + if (!Array.isArray(events) || events.length === 0) { + throw new APIError("`events` must be a non-empty array of event types", 400); + } + const valid = new Set([...Object.values(EVENT_TYPES), "*"]); + for (const e of events) { + if (!valid.has(e)) { + throw new APIError(`Unknown event type: ${e}`, 400); + } + } + return events; +}; + +/** + * POST /api/webhooks + * Register a new endpoint. Returns the plaintext signing secret ONCE. + */ +export const createEndpoint = catchAsync(async (req, res) => { + const { url, events, description } = req.body; + + if (!url) throw new APIError("`url` is required", 400); + await validateUrlOrThrow(url); + const normalizedEvents = normalizeEvents(events) || ["*"]; + + const secret = generateSecret(); + const endpoint = await WebhookEndpoint.create({ + url, + secretEncrypted: encryptSecret(secret), + events: normalizedEvents, + description, + owner: req.user._id, + }); + + recordAudit({ + action: AUDIT_ACTIONS.WEBHOOK_ENDPOINT_CREATED, + actor: req.user._id, + req, + targetType: "WebhookEndpoint", + targetId: endpoint._id.toString(), + status: "success", + metadata: { endpointId: endpoint._id.toString(), url, events: normalizedEvents }, + }); + + logger.info({ endpointId: endpoint._id.toString() }, "webhook: endpoint created"); + + // Secret shown exactly once. endpoint.toJSON() strips secretEncrypted. + res.status(201).json({ + success: true, + message: "Webhook endpoint created. Store the secret now — it is shown only once.", + endpoint, + secret, + }); +}); + +/** + * GET /api/webhooks + * List the caller's endpoints (never includes the secret). + */ +export const listEndpoints = catchAsync(async (req, res) => { + const endpoints = await WebhookEndpoint.find({ owner: req.user._id }).sort({ + createdAt: -1, + }); + res.status(200).json({ success: true, endpoints }); +}); + +const findOwnedEndpoint = async (id, ownerId) => { + const endpoint = await WebhookEndpoint.findOne({ _id: id, owner: ownerId }); + if (!endpoint) throw new APIError("Webhook endpoint not found", 404); + return endpoint; +}; + +/** + * GET /api/webhooks/:id + */ +export const getEndpoint = catchAsync(async (req, res) => { + const endpoint = await findOwnedEndpoint(req.params.id, req.user._id); + res.status(200).json({ success: true, endpoint }); +}); + +/** + * PATCH /api/webhooks/:id + * Update url / events / description / isActive. Re-enabling clears the + * disabled markers. Never returns or rotates the secret. + */ +export const updateEndpoint = catchAsync(async (req, res) => { + const endpoint = await findOwnedEndpoint(req.params.id, req.user._id); + const { url, events, description, isActive } = req.body; + + if (url !== undefined) { + await validateUrlOrThrow(url); + endpoint.url = url; + } + if (events !== undefined) { + endpoint.events = normalizeEvents(events); + } + if (description !== undefined) { + endpoint.description = description; + } + if (isActive !== undefined) { + endpoint.isActive = Boolean(isActive); + if (isActive) { + // Re-enable: reset failure state so it isn't immediately re-disabled. + endpoint.consecutiveFailures = 0; + endpoint.disabledAt = undefined; + endpoint.disabledReason = undefined; + } + } + + await endpoint.save(); + + recordAudit({ + action: AUDIT_ACTIONS.WEBHOOK_ENDPOINT_UPDATED, + actor: req.user._id, + req, + targetType: "WebhookEndpoint", + targetId: endpoint._id.toString(), + status: "success", + metadata: { endpointId: endpoint._id.toString() }, + }); + + res.status(200).json({ success: true, endpoint }); +}); + +/** + * DELETE /api/webhooks/:id + */ +export const deleteEndpoint = catchAsync(async (req, res) => { + const endpoint = await findOwnedEndpoint(req.params.id, req.user._id); + await WebhookEndpoint.deleteOne({ _id: endpoint._id }); + + recordAudit({ + action: AUDIT_ACTIONS.WEBHOOK_ENDPOINT_DELETED, + actor: req.user._id, + req, + targetType: "WebhookEndpoint", + targetId: endpoint._id.toString(), + status: "success", + metadata: { endpointId: endpoint._id.toString() }, + }); + + res.status(200).json({ success: true, message: "Webhook endpoint deleted" }); +}); + +/** + * POST /api/webhooks/:id/rotate-secret + * Generate a new signing secret and return it ONCE. + */ +export const rotateSecret = catchAsync(async (req, res) => { + const endpoint = await findOwnedEndpoint(req.params.id, req.user._id); + const secret = generateSecret(); + endpoint.secretEncrypted = encryptSecret(secret); + await endpoint.save(); + + recordAudit({ + action: AUDIT_ACTIONS.WEBHOOK_SECRET_ROTATED, + actor: req.user._id, + req, + targetType: "WebhookEndpoint", + targetId: endpoint._id.toString(), + status: "success", + metadata: { endpointId: endpoint._id.toString() }, + }); + + res.status(200).json({ + success: true, + message: "Secret rotated. Store the new secret now — it is shown only once.", + secret, + }); +}); + +/** + * GET /api/webhooks/:id/deliveries?status=&page=&limit= + * Paginated, filterable delivery history for an endpoint. + */ +export const listDeliveries = catchAsync(async (req, res) => { + const endpoint = await findOwnedEndpoint(req.params.id, req.user._id); + + const page = Math.max(1, parseInt(req.query.page, 10) || 1); + const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 20)); + const query = { endpoint: endpoint._id }; + + if (req.query.status) { + if (!DELIVERY_STATUSES.includes(req.query.status)) { + throw new APIError(`Invalid status filter: ${req.query.status}`, 400); + } + query.status = req.query.status; + } + + const [deliveries, total] = await Promise.all([ + WebhookDelivery.find(query) + .sort({ createdAt: -1 }) + .skip((page - 1) * limit) + .limit(limit), + WebhookDelivery.countDocuments(query), + ]); + + res.status(200).json({ + success: true, + deliveries, + pagination: { page, limit, total, pages: Math.ceil(total / limit) }, + }); +}); + +/** + * POST /api/webhooks/:id/deliveries/:deliveryId/redeliver + * Requeue a delivery (typically a dead one) for immediate re-attempt. + */ +export const redeliver = catchAsync(async (req, res) => { + const endpoint = await findOwnedEndpoint(req.params.id, req.user._id); + const delivery = await WebhookDelivery.findOne({ + _id: req.params.deliveryId, + endpoint: endpoint._id, + }); + if (!delivery) throw new APIError("Delivery not found", 404); + + if (delivery.status === "delivered") { + throw new APIError("Delivery already succeeded; nothing to redeliver", 400); + } + + // Atomic status transition via $set — avoids full-document re-validation of + // the Mixed `payload` field (Mongoose's required check trips on re-save) and + // matches the worker's claim pattern. + const requeued = await WebhookDelivery.findByIdAndUpdate( + delivery._id, + { $set: { status: "pending", nextAttemptAt: new Date() } }, + { new: true } + ); + + recordAudit({ + action: AUDIT_ACTIONS.WEBHOOK_DELIVERY_REDELIVERED, + actor: req.user._id, + req, + targetType: "WebhookDelivery", + targetId: delivery._id.toString(), + status: "success", + metadata: { + endpointId: endpoint._id.toString(), + deliveryId: delivery._id.toString(), + eventType: delivery.eventType, + }, + }); + + res.status(200).json({ success: true, message: "Delivery requeued", delivery: requeued }); +}); + +/** + * POST /api/webhooks/:id/ping + * Emit a signed `ping` event to this endpoint for integration testing. + */ +export const pingEndpoint = catchAsync(async (req, res) => { + const endpoint = await findOwnedEndpoint(req.params.id, req.user._id); + + const { eventId, delivery } = await emitEventToEndpoint( + endpoint._id, + EVENT_TYPES.PING, + { message: "ping", type: "ping" } + ); + + recordAudit({ + action: AUDIT_ACTIONS.WEBHOOK_PING, + actor: req.user._id, + req, + targetType: "WebhookEndpoint", + targetId: endpoint._id.toString(), + status: delivery ? "success" : "failure", + metadata: { endpointId: endpoint._id.toString(), eventType: "ping" }, + }); + + res.status(202).json({ + success: true, + message: "Ping queued for delivery", + eventId, + deliveryId: delivery?._id, + }); +}); diff --git a/src/jobs/handlers.js b/src/jobs/handlers.js index c45d3fce..8b95234a 100644 --- a/src/jobs/handlers.js +++ b/src/jobs/handlers.js @@ -5,6 +5,7 @@ import { sendOtpEmail, sendReceiptEmail } from "../../services/emails/sendMail.j import { verifyPaymentOperations, getExplorerUrl } from "../services/stellar/stellarService.js"; import { recordSaleEarnings } from "../services/payoutService.js"; import { registerJob, enqueue } from "./queue.js"; +import { markPledgeTransactionPaid } from "../services/pledgeService.js"; const expectedPaymentsFor = (transaction) => transaction.type === "donation" @@ -48,6 +49,7 @@ registerJob("verifyPaymentOnChain", async ({ transactionId }, context) => { throw new Error(verification.reason); } transaction.status = "failed"; + transaction.expiresAt = undefined; // terminal state — never TTL-reapable transaction.failureReason = `On-chain verification failed: ${verification.reason}`; await transaction.save(); return; @@ -55,9 +57,14 @@ registerJob("verifyPaymentOnChain", async ({ transactionId }, context) => { transaction.status = "confirmed"; transaction.confirmedAt = new Date(); + transaction.expiresAt = undefined; // terminal state — never TTL-reapable transaction.failureReason = undefined; await transaction.save(); + if (transaction.type === "donation") { + await markPledgeTransactionPaid(transaction, transaction.confirmedAt); + } + if (transaction.type === "purchase") { await recordSaleEarnings(transaction); const purchase = { purchaseDate: transaction.confirmedAt }; diff --git a/src/middlewares/authMiddleware.js b/src/middlewares/authMiddleware.js index dc62e188..de69bd79 100644 --- a/src/middlewares/authMiddleware.js +++ b/src/middlewares/authMiddleware.js @@ -27,6 +27,7 @@ export const protect = async (req, res, next) => { } req.sessionId = decoded.sessionId; + req.is2FAVerified = decoded.is2FAVerified === true; next(); } catch (error) { @@ -49,10 +50,48 @@ export const authorizeRoles = (...roles) => { message: `Forbidden: Access requires one of the following roles: ${roles.join(", ")}`, }); } + + // Enforce 2FA for admin role + if (req.user.role === "admin") { + if (!req.user.twoFactor?.enabled) { + return res.status(403).json({ + success: false, + message: "Forbidden: Admin access requires TOTP two-factor authentication to be enabled.", + }); + } + if (!req.is2FAVerified) { + return res.status(403).json({ + success: false, + message: "Forbidden: Admin access requires a 2FA-verified session.", + }); + } + } + next(); }; }; +export const require2FA = (req, res, next) => { + if (!req.user) { + return res + .status(401) + .json({ success: false, message: "Not authenticated" }); + } + if (!req.user.twoFactor?.enabled) { + return res.status(403).json({ + success: false, + message: "Two-factor authentication is required to be enabled for this action.", + }); + } + if (!req.is2FAVerified) { + return res.status(403).json({ + success: false, + message: "This action requires a 2FA-verified session.", + }); + } + next(); +}; + export const requireVerified = (req, res, next) => { if (!req.user) { return res @@ -68,5 +107,24 @@ export const requireVerified = (req, res, next) => { next(); }; +export const requireVerifiedEducator = (req, res, next) => { + if (!req.user) { + return res + .status(401) + .json({ success: false, message: "Not authenticated" }); + } + if (req.user.role === "admin") { + return next(); + } + if (!req.user.verifiedEducator) { + return res.status(403).json({ + success: false, + message: + "Forbidden: You must be a verified educator to create content. Please submit a verification application via /api/educator-verification.", + }); + } + next(); +}; + export const restrictTo = (...roles) => authorizeRoles(...roles); export const authorize = (...roles) => authorizeRoles(...roles); diff --git a/src/middlewares/authorize.js b/src/middlewares/authorize.js new file mode 100644 index 00000000..f43992e0 --- /dev/null +++ b/src/middlewares/authorize.js @@ -0,0 +1,121 @@ +// middlewares/authorize.js +// +// Centralized resource-ownership authorization layer. +// +// These guards run after `protect` (which sets req.user) and enforce that the +// authenticated user either owns the target resource or is an admin before a +// mutating handler runs. Ownership denials are written to the audit log +// (fire-and-forget) and surfaced as a 403 via the global error handler. +import { APIError, catchAsync } from "./errorHandler.js"; +import { recordAudit } from "../services/audit/auditService.js"; +import { AUDIT_ACTIONS } from "../models/AuditLog.js"; + +/** + * Guard that enforces ownership of a top-level resource (Book, Course, Space). + * + * Loads the document by id, allows owners and admins, and denies everyone else + * with a 403 (auditing the denial). On success the loaded doc is attached as + * `req.resource` so the handler can reuse it. + * + * @param {object} opts + * @param {import("mongoose").Model} opts.model - Mongoose model to load from + * @param {string} opts.ownerField - Field holding the owner ObjectId + * @param {string} opts.resourceType - Human-readable type (e.g. "Book") + * @param {string} [opts.idParam] - req.params key for the id + */ +export const authorizeOwnership = ({ model, ownerField, resourceType, idParam = "id" }) => + catchAsync(async (req, _res, next) => { + const doc = await model.findById(req.params[idParam]); + if (!doc) { + return next(new APIError(`${resourceType} not found`, 404)); + } + + const isAdmin = req.user?.role === "admin"; + const isOwner = doc[ownerField]?.toString() === req.user._id.toString(); + + if (!isAdmin && !isOwner) { + recordAudit({ + action: AUDIT_ACTIONS.AUTHZ_OWNERSHIP_DENIED, + actor: req.user._id, + req, + targetType: resourceType, + targetId: String(doc._id), + status: "failure", + metadata: { reason: `not owner of ${resourceType}`, role: req.user.role }, + }); + return next( + new APIError(`You are not authorized to modify this ${resourceType.toLowerCase()}`, 403) + ); + } + + req.resource = doc; + next(); + }); + +/** + * Guard that enforces ownership of a review subdocument living on a parent + * item (Book or Course). Supports two shapes the review controllers accept: + * - id-scoped: /:id/reviews/:reviewId -> owner-check the named review + * - self-scoped: /:id/reviews -> operate on the caller's own review + * + * On success the parent doc is attached as `req.parentResource` and the target + * review as `req.review`. + * + * @param {object} opts + * @param {import("mongoose").Model} opts.model - Parent model (Book/Course) + * @param {string} [opts.resourceType] - Type label for audit/errors + * @param {string} [opts.idParam] - req.params key for the parent id + * @param {string} [opts.reviewParam] - req.params key for the review id + */ +export const authorizeReviewOwnership = ({ + model, + resourceType = "Review", + idParam = "id", + reviewParam = "reviewId", +}) => + catchAsync(async (req, _res, next) => { + const parent = await model.findById(req.params[idParam]); + if (!parent) { + return next(new APIError(`${resourceType} not found`, 404)); + } + + const reviewId = req.params[reviewParam]; + let review; + + if (reviewId) { + review = parent.reviews.id(reviewId); + if (!review) { + return next(new APIError("Review not found", 404)); + } + + const isAdmin = req.user?.role === "admin"; + const isOwner = review.user?.toString() === req.user._id.toString(); + + if (!isAdmin && !isOwner) { + recordAudit({ + action: AUDIT_ACTIONS.AUTHZ_OWNERSHIP_DENIED, + actor: req.user._id, + req, + targetType: resourceType, + targetId: String(review._id), + status: "failure", + metadata: { reason: `not owner of ${resourceType}`, role: req.user.role }, + }); + return next(new APIError("You are not authorized to modify this review", 403)); + } + } else { + // Self-scoped: operate on the caller's own review (owner by construction). + review = parent.reviews.find( + (r) => r.user?.toString() === req.user._id.toString() + ); + if (!review) { + return next(new APIError("Review not found", 404)); + } + } + + req.parentResource = parent; + req.review = review; + next(); + }); + +export default { authorizeOwnership, authorizeReviewOwnership }; diff --git a/src/middlewares/errorHandler.js b/src/middlewares/errorHandler.js index c92dd9ab..da31760f 100644 --- a/src/middlewares/errorHandler.js +++ b/src/middlewares/errorHandler.js @@ -1,11 +1,12 @@ import logger from "../config/logger.js"; export class APIError extends Error { - constructor(message, statusCode = 500, isOperational = true) { + constructor(message, statusCode = 500, isOperational = true, errors) { super(message); this.statusCode = statusCode; this.isOperational = isOperational; this.status = `${statusCode}`.startsWith("4") ? "fail" : "error"; + if (errors) this.errors = errors; Error.captureStackTrace(this, this.constructor); } } @@ -43,6 +44,8 @@ const sendErrorDev = (err, req, res) => { status: err.status, error: err, message: err.message, + data: null, + ...(err.errors && { errors: err.errors }), stack: err.stack, reqId: req?.id, }); @@ -58,6 +61,8 @@ const sendErrorProd = (err, req, res) => { success: false, status: err.status, message: err.message, + data: null, + ...(err.errors && { errors: err.errors }), reqId: req?.id, }); } else { diff --git a/src/middlewares/idempotency.js b/src/middlewares/idempotency.js new file mode 100644 index 00000000..31808330 --- /dev/null +++ b/src/middlewares/idempotency.js @@ -0,0 +1,133 @@ +import crypto from "crypto"; +import IdempotencyKey from "../models/IdempotencyKey.js"; +import { APIError, catchAsync } from "./errorHandler.js"; +import logger from "../config/logger.js"; + +/** + * Middleware to enforce request-level idempotency on mutating endpoints. + * + * @param {Object} options + * @param {boolean} [options.required=false] - Whether the Idempotency-Key header is mandatory for the route. + */ +export const idempotency = ({ required = false } = {}) => { + return catchAsync(async (req, res, next) => { + const rawKey = + req.headers["idempotency-key"] || req.headers["x-idempotency-key"]; + const idempotencyKey = Array.isArray(rawKey) ? rawKey[0] : rawKey; + + if (!idempotencyKey || !idempotencyKey.trim()) { + if (required) { + throw new APIError("Idempotency-Key header is required", 400); + } + return next(); + } + + const trimmedKey = idempotencyKey.trim(); + const userId = req.user?._id; + if (!userId) { + throw new APIError("Authentication required for idempotency protection", 401); + } + + const endpoint = (req.originalUrl || req.path || "").split("?")[0]; + const requestHash = crypto + .createHash("sha256") + .update(JSON.stringify(req.body || {})) + .digest("hex"); + + let record; + try { + record = await IdempotencyKey.create({ + key: trimmedKey, + userId, + endpoint, + requestHash, + status: "in_progress", + }); + } catch (err) { + if (err.code === 11000) { + const existing = await IdempotencyKey.findOne({ + key: trimmedKey, + userId, + endpoint, + }); + + if (!existing) { + throw err; + } + + if (existing.requestHash !== requestHash) { + throw new APIError("Idempotency key payload mismatch", 422); + } + + if (existing.status === "in_progress") { + throw new APIError( + "A request with this idempotency key is currently in progress", + 409 + ); + } + + if (existing.status === "completed") { + return res.status(existing.statusCode).json(existing.responseBody); + } + } + throw err; + } + + const originalJson = res.json.bind(res); + const originalSend = res.send.bind(res); + let captured = false; + + const captureResponse = (body) => { + if (captured) return; + captured = true; + const statusCode = res.statusCode || 200; + + if (statusCode >= 500) { + IdempotencyKey.deleteOne({ _id: record._id }).catch((e) => + logger.error({ err: e }, "Failed to delete idempotency key on server error") + ); + } else { + IdempotencyKey.updateOne( + { _id: record._id }, + { + $set: { + status: "completed", + statusCode, + responseBody: body, + }, + } + ).catch((e) => + logger.error({ err: e }, "Failed to update idempotency key to completed") + ); + } + }; + + res.json = function (body) { + captureResponse(body); + return originalJson(body); + }; + + res.send = function (body) { + if (!captured) { + let parsed = body; + if (typeof body === "string") { + try { + parsed = JSON.parse(body); + } catch (_) {} + } + captureResponse(parsed); + } + return originalSend(body); + }; + + res.on("close", () => { + if (!captured && !res.writableEnded) { + IdempotencyKey.deleteOne({ _id: record._id }).catch(() => {}); + } + }); + + next(); + }); +}; + +export default idempotency; diff --git a/src/middlewares/security.js b/src/middlewares/security.js index c8e18fb4..28eaf9fc 100644 --- a/src/middlewares/security.js +++ b/src/middlewares/security.js @@ -1,8 +1,9 @@ import helmet from "helmet"; -import rateLimit from "express-rate-limit"; +import rateLimit, { ipKeyGenerator } from "express-rate-limit"; import mongoSanitize from "express-mongo-sanitize"; import hpp from "hpp"; import logger from "../config/logger.js"; +import { verifyCaptcha } from "../utils/captcha.js"; /** * Helmet - Sets various HTTP headers for security @@ -43,8 +44,9 @@ export const apiLimiter = rateLimit({ * @param {number} defaultMax – default max requests in the window * @param {number} defaultWindow – default window in ms * @param {string} prefix – env var prefix (e.g. "RATE_LIMIT_AUTH") + * @param {object} [extra] – extra express-rate-limit options (e.g. keyGenerator) */ -function makeLimiter(defaultMax, defaultWindow, prefix) { +function makeLimiter(defaultMax, defaultWindow, prefix, extra = {}) { const max = parseInt(process.env[`${prefix}_MAX`], 10) || defaultMax; const windowMs = parseInt(process.env[`${prefix}_WINDOW_MS`], 10) || defaultWindow; @@ -62,9 +64,84 @@ function makeLimiter(defaultMax, defaultWindow, prefix) { message: "Too many requests, please try again later.", }); }, + ...extra, }); } +/** + * Normalize an email to its canonical lowercase form so rate-limit keys are + * stable regardless of case/whitespace the client sends. + */ +const normalizeEmail = (email = "") => + String(email || "").trim().toLowerCase(); + +/** + * Per-EMAIL throttle for signup and verification-resend (issue #89). + * Keyed on the normalized email address — NOT just the IP — so rotating IPs + * cannot defeat it. Unlike authLimiter, it does NOT skip in the test env, so + * the burst behavior is asserted by the test suite. Unlike authLimiter it + * counts successful requests too, since a signup/verification flood is the + * abuse being mitigated. + * + * Env overrides: RATE_LIMIT_EMAIL_AUTH_MAX, RATE_LIMIT_EMAIL_AUTH_WINDOW_MS, + * RATE_LIMIT_EMAIL_AUTH_DISABLE. + */ +export const emailAuthLimiter = makeLimiter( + 20, + 15 * 60 * 1000, + "RATE_LIMIT_EMAIL_AUTH", + { + keyGenerator: (req) => `email:${normalizeEmail(req.body?.email)}`, + }, +); + +/** + * Pluggable captcha gate (no-op when CAPTCHA_SECRET_KEY is unset). Wire onto + * /register and /resend-verification to add burst mitigation beyond the + * email limiter once a provider is configured. + */ +export const captchaGate = () => async (req, res, next) => { + const token = + req.body?.captchaToken || + req.body?.["g-recaptcha-response"] || + req.body?.["h-captcha-response"]; + const ok = await verifyCaptcha(token); + if (!ok) { + logger.warn(`Captcha verification failed for ${req.ip}`); + return res.status(400).json({ + success: false, + message: "Captcha verification failed. Please try again.", + data: null, + }); + } + next(); +}; + +/** + * Per-USER throttle for the Stellar payment endpoints (initialize/submit). + * Keyed on the authenticated user id (falling back to the IP when + * unauthenticated) so one account cannot hammer payment routes from many IPs + * and one IP cannot hammer many accounts. Stricter than the global + * generousLimiter because these routes mutate money state. Like + * emailAuthLimiter it does NOT skip in the test env, so the burst behavior is + * asserted by the test suite. + * + * Env overrides: RATE_LIMIT_PAYMENT_MAX, RATE_LIMIT_PAYMENT_WINDOW_MS, + * RATE_LIMIT_PAYMENT_DISABLE. + */ +export const paymentLimiter = makeLimiter( + 30, + 15 * 60 * 1000, + "RATE_LIMIT_PAYMENT", + { + // Per-user key when authenticated; ipKeyGenerator for the unauthenticated + // fallback so IPv6 subnets are bucketed correctly (express-rate-limit v8 + // validation requires the helper for any req.ip usage). + keyGenerator: (req) => + `payment:${req.user?._id?.toString() || ipKeyGenerator(req.ip)}`, + }, +); + /** * Moderate – for mutation endpoints (purchase, email, upload, payouts). * 100 requests per 15 minutes by default. @@ -123,6 +200,24 @@ export const refreshLimiter = rateLimit({ }, }); +/** + * Rate limiting specifically for 2FA verification routes + */ +export const twoFactorLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 5, // Limit each IP to 5 attempts + standardHeaders: true, + legacyHeaders: false, + skip: () => process.env.NODE_ENV === "test" && process.env.ENABLE_TEST_RATE_LIMIT !== "true", + handler: (req, res) => { + logger.warn(`2FA rate limit exceeded for IP: ${req.ip}`); + res.status(429).json({ + success: false, + message: "Too many 2FA verification attempts, please try again later.", + }); + }, +}); + /** * MongoDB Injection Protection * Custom implementation for Express 5 compatibility @@ -229,6 +324,8 @@ export default { generousLimiter, authLimiter, refreshLimiter, + emailAuthLimiter, + captchaGate, mongoSanitizeMiddleware, hppMiddleware, customSecurityHeaders, diff --git a/src/middlewares/serviceAuth.js b/src/middlewares/serviceAuth.js new file mode 100644 index 00000000..4c4c59e4 --- /dev/null +++ b/src/middlewares/serviceAuth.js @@ -0,0 +1,150 @@ +// middlewares/serviceAuth.js +// +// Service-to-service (S2S) authentication for the AI service (dnb-ai). +// +// Instead of a single static bearer token, callers sign each request with an +// HMAC-SHA256 signature over a canonical string, using a scoped key selected by +// `kid`. This gives us: replay protection (timestamp window), constant-time +// comparison (crypto.timingSafeEqual), per-key scopes, and zero-downtime key +// rotation via overlapping active `kid`s (see config/serviceKeys.js and +// docs/service-to-service-auth.md). +// +// ── Signing contract (the dnb-ai client MUST reproduce this exactly) ───────── +// Headers sent by the caller: +// X-Service-Id logical caller id (e.g. "dnb-ai") +// X-Service-Key-Id the key id (`kid`) selecting which secret to use +// X-Timestamp Unix time in SECONDS at signing (string) +// X-Signature lowercase hex HMAC-SHA256 of the canonical string +// +// Canonical string (LF-separated, no trailing newline): +// METHOD \n PATH \n TIMESTAMP \n sha256hex(rawBody || "") +// +// METHOD = HTTP method, uppercased (e.g. "GET", "POST") +// PATH = request path exactly as sent, including any query string +// (Express req.originalUrl — e.g. "/api/internal/ai/whoami") +// TIMESTAMP = the same value sent in X-Timestamp +// rawBody = the raw request body bytes ("" for bodyless GETs) +// +// signature = HMAC_SHA256(key.secret, canonicalString) in lowercase hex +import crypto from "crypto"; +import { APIError, catchAsync } from "./errorHandler.js"; +import { getServiceKey } from "../config/serviceKeys.js"; +import { recordAudit } from "../services/audit/auditService.js"; +import { AUDIT_ACTIONS } from "../models/AuditLog.js"; + +// Requests whose X-Timestamp is more than this many seconds away from the +// server clock (past OR future) are rejected as stale/replayed. +export const REPLAY_WINDOW_SECONDS = 300; + +/** sha256 hex of a buffer/string. */ +function sha256hex(input) { + return crypto.createHash("sha256").update(input ?? "").digest("hex"); +} + +/** + * Build the canonical string that is HMAC-signed. Exported so tests (and, by + * mirror, the dnb-ai client) can reproduce the exact byte sequence. + * + * @param {object} p + * @param {string} p.method HTTP method (any case; uppercased here) + * @param {string} p.path request path incl. query (req.originalUrl) + * @param {string|number} p.timestamp Unix seconds + * @param {Buffer|string} [p.rawBody] raw request body bytes + * @returns {string} + */ +export function buildCanonicalString({ method, path, timestamp, rawBody }) { + return [ + String(method).toUpperCase(), + path, + String(timestamp), + sha256hex(rawBody || ""), + ].join("\n"); +} + +/** Constant-time equality on two hex strings, safe on length mismatch. */ +function timingSafeEqualHex(a, b) { + const bufA = Buffer.from(String(a), "utf8"); + const bufB = Buffer.from(String(b), "utf8"); + // timingSafeEqual throws on unequal lengths, so guard first. Returning early + // on a length mismatch is safe: signatures are fixed-length hex, so an + // attacker learns nothing an equal-length compare wouldn't already leak. + if (bufA.length !== bufB.length) return false; + return crypto.timingSafeEqual(bufA, bufB); +} + +/** + * Guard a route with signed, scoped service-to-service auth. + * + * @param {object} opts + * @param {string} opts.scope the scope this route requires (e.g. "ai:read-content") + * @returns Express middleware + */ +export function requireServiceAuth({ scope } = {}) { + return catchAsync(async (req, _res, next) => { + const serviceId = req.headers["x-service-id"]; + const kid = req.headers["x-service-key-id"]; + const timestamp = req.headers["x-timestamp"]; + const signature = req.headers["x-signature"]; + + // Shared denial path: audit (fire-and-forget) then propagate an APIError. + const deny = (reason, statusCode) => { + recordAudit({ + action: AUDIT_ACTIONS.SERVICE_AUTH_DENIED, + actor: null, + req, + targetType: "Service", + targetId: serviceId || kid || "unknown", + status: "failure", + metadata: { reason, kid, scope }, + }); + return next(new APIError(reason, statusCode)); + }; + + // 1. All four headers are required. + if (!serviceId || !kid || !timestamp || !signature) { + return deny("Missing service authentication headers", 401); + } + + // 2. Reject stale / future timestamps (replay protection). + const ts = Number(timestamp); + if (!Number.isFinite(ts)) { + return deny("Invalid service authentication timestamp", 401); + } + const nowSeconds = Math.floor(Date.now() / 1000); + if (Math.abs(nowSeconds - ts) > REPLAY_WINDOW_SECONDS) { + return deny("Service authentication timestamp outside replay window", 401); + } + + // 3. Resolve the key by kid; unknown or retired (active:false) → 401. + const key = getServiceKey(kid); + if (!key || key.active !== true) { + return deny("Unknown or retired service key", 401); + } + + // 4. Recompute the signature and compare in constant time. + const canonical = buildCanonicalString({ + method: req.method, + path: req.originalUrl, + timestamp, + rawBody: req.rawBody, + }); + const expected = crypto + .createHmac("sha256", key.secret) + .update(canonical) + .digest("hex"); + if (!timingSafeEqualHex(signature, expected)) { + return deny("Invalid service signature", 401); + } + + // 5. Enforce scope (403 — authenticated but not permitted). + if (!scope || !Array.isArray(key.scopes) || !key.scopes.includes(scope)) { + return deny("Service key missing required scope", 403); + } + + // 6. Success — attach the authenticated service context. + req.service = { id: String(serviceId), kid, scopes: key.scopes }; + return next(); + }); +} + +export default requireServiceAuth; diff --git a/src/middlewares/validate.js b/src/middlewares/validate.js index efb19900..841768f9 100644 --- a/src/middlewares/validate.js +++ b/src/middlewares/validate.js @@ -10,11 +10,19 @@ export const validate = (req, res, next) => { const errors = validationResult(req); if (!errors.isEmpty()) { - const errorMessages = errors.array().map((err) => err.msg); - logger.warn(`Validation failed for ${req.originalUrl}:`, errorMessages); + const validationErrors = errors + .array({ onlyFirstError: true }) + .map((err) => ({ + field: err.path || err.param || "request", + message: err.msg, + })); + logger.warn( + `Validation failed for ${req.baseUrl}${req.path}:`, + validationErrors.map(({ field, message }) => `${field}: ${message}`) + ); return next( - new APIError(`Validation Error: ${errorMessages.join(", ")}`, 400) + new APIError("Validation failed", 400, true, validationErrors) ); } @@ -60,8 +68,13 @@ export const requireFields = (fields) => { logger.warn(`Missing required fields: ${missingFields.join(", ")}`); return next( new APIError( - `Missing required fields: ${missingFields.join(", ")}`, - 400 + "Validation failed", + 400, + true, + missingFields.map((field) => ({ + field, + message: `${field} is required`, + })) ) ); } diff --git a/src/migrations/fixTtlTransactionExpiry.js b/src/migrations/fixTtlTransactionExpiry.js new file mode 100644 index 00000000..507c89b9 --- /dev/null +++ b/src/migrations/fixTtlTransactionExpiry.js @@ -0,0 +1,82 @@ +import dotenv from "dotenv"; +import mongoose from "mongoose"; +import Transaction from "../models/Transaction.js"; +import logger from "../config/logger.js"; + +dotenv.config(); + +/** + * Migration: fixTtlTransactionExpiry + * + * The `Transaction` collection previously had a blanket TTL index on + * `expiresAt` ({ expireAfterSeconds: 0 }) and a schema default that stamped a + * 30-minute expiry on every row regardless of status. Because confirm paths + * never cleared `expiresAt`, confirmed purchases/donations were reaped ~30 + * minutes after creation — deleting the proof of payment and leaving orphaned + * earnings behind. + * + * This migration: + * 1. `$unset`s `expiresAt` on every existing non-`pending` transaction so + * the TTL reaper can never touch already-confirmed (or otherwise + * terminal) rows before the index swap completes. + * 2. Drops the blanket `{ expiresAt: 1 }` TTL index (if present) and + * recreates it as a partial index scoped strictly to + * `{ status: "pending" }`, so reaping is structurally impossible for + * non-pending rows even if a future code path forgets step 1. + * + * Idempotent: running it again is a no-op for data (no non-pending rows carry + * `expiresAt`) and for the index (the partial index already matches). + */ +export const fixTtlTransactionExpiry = async () => { + const collection = Transaction.collection; + + // 1. Rescue legacy terminal rows from the TTL reaper before touching indexes. + const updateResult = await Transaction.updateMany( + { + status: { $ne: "pending" }, + expiresAt: { $exists: true, $ne: null }, + }, + { $unset: { expiresAt: 1 } } + ); + + const modifiedCount = updateResult.modifiedCount ?? updateResult.nModified ?? 0; + logger.info(`Unset expiresAt on ${modifiedCount} non-pending transaction(s).`); + + // 2. Replace the blanket TTL index with the partial-filter version. A schema + // `.index()` edit does NOT alter an already-built index, so this must be + // done explicitly. + const indexes = await collection.indexes(); + const ttlIndex = indexes.find((idx) => idx.key && idx.key.expiresAt === 1); + + const hasPendingPartialFilter = + ttlIndex?.partialFilterExpression?.status === "pending"; + + if (ttlIndex && !hasPendingPartialFilter) { + logger.info(`Dropping blanket TTL index "${ttlIndex.name}"...`); + await collection.dropIndex(ttlIndex.name); + } + + // If the correct partial index already exists this is a no-op. + await collection.createIndex( + { expiresAt: 1 }, + { expireAfterSeconds: 0, partialFilterExpression: { status: "pending" } } + ); + logger.info("Ensured partial TTL index scoped to status: pending."); + + return { modifiedCount }; +}; + +// Standalone CLI execution +if (process.argv[1] && process.argv[1].endsWith("fixTtlTransactionExpiry.js")) { + if (!process.env.MONGO_URI) { + throw new Error("MONGO_URI must be set to run TTL transaction expiry migration"); + } + + try { + await mongoose.connect(process.env.MONGO_URI); + const result = await fixTtlTransactionExpiry(); + console.log(`Migration complete: ${result.modifiedCount} documents updated.`); + } finally { + await mongoose.disconnect(); + } +} diff --git a/src/models/AuditLog.js b/src/models/AuditLog.js index b7b9f133..7884f235 100644 --- a/src/models/AuditLog.js +++ b/src/models/AuditLog.js @@ -21,11 +21,23 @@ export const AUDIT_ACTIONS = Object.freeze({ AUTH_REGISTER_FAILURE: "auth.register.failure", AUTH_LOGIN_SUCCESS: "auth.login.success", AUTH_LOGIN_FAILURE: "auth.login.failure", + AUTH_ACCOUNT_LOCKED: "auth.account_locked", AUTH_LOGOUT: "auth.logout", AUTH_PASSWORD_RESET_REQUEST: "auth.password_reset.request", AUTH_PASSWORD_RESET_COMPLETE: "auth.password_reset.complete", AUTH_PASSWORD_CHANGE: "auth.password_change", + // 2FA + AUTH_2FA_SETUP_INITIATED: "auth.2fa.setup_initiated", + AUTH_2FA_ENABLE_SUCCESS: "auth.2fa.enable.success", + AUTH_2FA_ENABLE_FAILURE: "auth.2fa.enable.failure", + AUTH_2FA_LOGIN_CHALLENGE: "auth.2fa.login.challenge", + AUTH_2FA_LOGIN_SUCCESS: "auth.2fa.login.success", + AUTH_2FA_LOGIN_FAILURE: "auth.2fa.login.failure", + AUTH_2FA_DISABLE_SUCCESS: "auth.2fa.disable.success", + AUTH_2FA_DISABLE_FAILURE: "auth.2fa.disable.failure", + AUTH_2FA_RECOVERY_USED: "auth.2fa.recovery_used", + // Wallet WALLET_CONNECT_SUCCESS: "wallet.connect.success", WALLET_CONNECT_FAILURE: "wallet.connect.failure", @@ -41,10 +53,31 @@ export const AUDIT_ACTIONS = Object.freeze({ // Entitlements (access grants) ENTITLEMENT_GRANT: "entitlement.grant", + // Authorization + AUTHZ_OWNERSHIP_DENIED: "authz.ownership.denied", + + // Service-to-service auth (dnb-ai) + SERVICE_AUTH_DENIED: "service_auth.denied", + // Extension points — to be instrumented with issue #20 / #28 PAYOUT_BATCH_INITIATED: "payout.batch.initiated", PAYOUT_BATCH_CONFIRMED: "payout.batch.confirmed", ROLE_CHANGE: "role.change", + + // Educator verification pipeline (issue #92) + EDUCATOR_VERIFY_SUBMIT: "educator_verify.submit", + EDUCATOR_VERIFY_RESUBMIT: "educator_verify.resubmit", + EDUCATOR_VERIFY_APPROVE: "educator_verify.approve", + EDUCATOR_VERIFY_REJECT: "educator_verify.reject", + + // Outbound webhooks (issue #45) + WEBHOOK_ENDPOINT_CREATED: "webhook.endpoint.created", + WEBHOOK_ENDPOINT_UPDATED: "webhook.endpoint.updated", + WEBHOOK_ENDPOINT_DELETED: "webhook.endpoint.deleted", + WEBHOOK_ENDPOINT_DISABLED: "webhook.endpoint.disabled", + WEBHOOK_SECRET_ROTATED: "webhook.secret.rotated", + WEBHOOK_DELIVERY_REDELIVERED: "webhook.delivery.redelivered", + WEBHOOK_PING: "webhook.ping", }); const ACTION_VALUES = Object.values(AUDIT_ACTIONS); diff --git a/src/models/Book.js b/src/models/Book.js index 458aca70..2309cb7b 100644 --- a/src/models/Book.js +++ b/src/models/Book.js @@ -12,6 +12,7 @@ const bookSchema = new mongoose.Schema({ required: true, }, category: String, + categoryRef: { type: mongoose.Schema.Types.ObjectId, ref: "Category", index: true }, price: { type: Number, default: 0, diff --git a/src/models/Category.js b/src/models/Category.js new file mode 100644 index 00000000..1c40936f --- /dev/null +++ b/src/models/Category.js @@ -0,0 +1,19 @@ +import mongoose from "mongoose"; + +const categorySchema = new mongoose.Schema( + { + name: { type: String, required: true, trim: true, unique: true }, + slug: { type: String, required: true, trim: true, lowercase: true, unique: true, index: true }, + description: { type: String, default: "" }, + icon: { type: String, default: "" }, + image: { type: String, default: "" }, + parent: { type: mongoose.Schema.Types.ObjectId, ref: "Category", default: null }, + order: { type: Number, default: 0 }, + isActive: { type: Boolean, default: true, index: true }, + }, + { timestamps: true } +); + +categorySchema.index({ parent: 1, order: 1 }); + +export default mongoose.model("Category", categorySchema); diff --git a/src/models/Course.js b/src/models/Course.js index 57847a9f..eee7b6cd 100644 --- a/src/models/Course.js +++ b/src/models/Course.js @@ -16,6 +16,7 @@ const courseSchema = new mongoose.Schema( type: String, required: true, }, + categoryRef: { type: mongoose.Schema.Types.ObjectId, ref: "Category", index: true }, thumbnail: { type: String, // image URL }, @@ -31,6 +32,11 @@ const courseSchema = new mongoose.Schema( default: "USDC", enum: getSupportedCodes(), }, + views: { + type: Number, + default: 0, + min: 0, + }, rating: { type: Number, default: 0, @@ -73,6 +79,11 @@ const courseSchema = new mongoose.Schema( required: true, }, enrolledUsers: [{ type: mongoose.Schema.Types.ObjectId, ref: "User" }], + // Courses a learner must COMPLETE before they can enroll in this course. + prerequisites: { + type: [{ type: mongoose.Schema.Types.ObjectId, ref: "Course" }], + default: [], + }, sections: [ { title: String, @@ -95,4 +106,3 @@ const courseSchema = new mongoose.Schema( courseSchema.index({ title: "text", description: "text", category: "text" }, { weights: { title: 5 } }); courseSchema.index({ rating: -1 }); export default mongoose.model("Course", courseSchema); - diff --git a/src/models/EducatorVerification.js b/src/models/EducatorVerification.js new file mode 100644 index 00000000..c3cefabe --- /dev/null +++ b/src/models/EducatorVerification.js @@ -0,0 +1,138 @@ +import mongoose from "mongoose"; + +export const VERIFICATION_STATUS = Object.freeze({ + DRAFT: "draft", + PENDING: "pending", + APPROVED: "approved", + REJECTED: "rejected", +}); + +export const LEGAL_TRANSITIONS = Object.freeze({ + [VERIFICATION_STATUS.DRAFT]: [VERIFICATION_STATUS.PENDING], + [VERIFICATION_STATUS.PENDING]: [ + VERIFICATION_STATUS.APPROVED, + VERIFICATION_STATUS.REJECTED, + ], + [VERIFICATION_STATUS.APPROVED]: [], + [VERIFICATION_STATUS.REJECTED]: [VERIFICATION_STATUS.PENDING], +}); + +const STATUS_VALUES = Object.values(VERIFICATION_STATUS); + +const documentSchema = new mongoose.Schema( + { + type: { + type: String, + required: [true, "Document type is required"], + enum: [ + "government_id", + "teaching_certificate", + "degree", + "work_sample", + "other", + ], + }, + cloudinaryPublicId: { + type: String, + required: [true, "Document cloudinaryPublicId is required"], + }, + originalFileName: { + type: String, + required: [true, "Document originalFileName is required"], + }, + uploadedAt: { + type: Date, + default: Date.now, + }, + }, + { _id: false, versionKey: false } +); + +const educatorVerificationSchema = new mongoose.Schema( + { + applicant: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: [true, "Applicant is required"], + index: true, + }, + + status: { + type: String, + enum: STATUS_VALUES, + default: VERIFICATION_STATUS.DRAFT, + required: [true, "Status is required"], + index: true, + }, + + documents: { + type: [documentSchema], + default: [], + }, + + personalStatement: { + type: String, + maxlength: 2000, + default: null, + }, + + reviewedBy: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + default: null, + }, + + reviewNotes: { + type: String, + maxlength: 2000, + default: null, + }, + + submittedAt: { + type: Date, + default: null, + }, + + reviewedAt: { + type: Date, + default: null, + }, + }, + { timestamps: true, versionKey: false } +); + +educatorVerificationSchema.index( + { applicant: 1, status: 1 }, + { unique: true, partialFilterExpression: { status: { $in: ["draft", "pending"] } } } +); + +educatorVerificationSchema.statics.isValidTransition = function (from, to) { + const allowed = LEGAL_TRANSITIONS[from]; + return Array.isArray(allowed) && allowed.includes(to); +}; + +educatorVerificationSchema.methods.canTransitionTo = function (targetStatus) { + return this.constructor.isValidTransition(this.status, targetStatus); +}; + +educatorVerificationSchema.pre("save", function (next) { + if (!this.isModified("status")) return next(); + if (this.isNew) return next(); + + const prev = this.modifiedPaths().includes("status") + ? this.$locals.previousStatus + : null; + next(); +}); + +educatorVerificationSchema.pre("findOneAndUpdate", function (next) { + const update = this.getUpdate(); + const nextStatus = update?.$set?.status ?? update?.status; + if (!nextStatus) return next(); + next(); +}); + +export default mongoose.model( + "EducatorVerification", + educatorVerificationSchema +); diff --git a/src/models/GiftClaim.js b/src/models/GiftClaim.js new file mode 100644 index 00000000..5927ec57 --- /dev/null +++ b/src/models/GiftClaim.js @@ -0,0 +1,113 @@ +// models/GiftClaim.js +// +// A gift of a course/book paid via a Stellar claimable balance. The sender +// creates an on-ledger balance the recipient can claim whenever they're ready +// (trustline-free), with a reclaim-after-expiry predicate so funds are never +// stranded. Access to the item is granted to the RECIPIENT on claim, never the +// sender. +// +// NOTE: unlike Transaction.expiresAt, this schema deliberately has NO TTL +// index. A gift record must survive past its expiry so the sender can still +// fetch a reclaim XDR afterward — the expiry transition only flips `status` +// to "expired" and never deletes the document. +import mongoose from "mongoose"; +import { getSupportedCodes } from "../config/assets.js"; + +const giftClaimSchema = new mongoose.Schema( + { + // Who funded the balance (the payer). + sender: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + // Who receives the item (the beneficiary). Deliberately distinct from the + // sender — access is granted to this user on claim. + recipient: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + recipientWallet: { + type: String, + required: true, + }, + // The item's creator (for display only — the creator is not paid through + // the claimable-balance path; that stays out of scope). + creator: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + itemType: { + type: String, + enum: ["book", "course"], + required: true, + }, + itemId: { + type: mongoose.Schema.Types.ObjectId, + required: true, + refPath: "itemTypeModel", + }, + itemTypeModel: { + type: String, + enum: ["Book", "Course"], + required: true, + }, + itemTitle: { + type: String, + required: true, + }, + // Amount stored as string to preserve precision (USDC only). + amount: { + type: String, + required: true, + }, + assetCode: { + type: String, + default: "USDC", + enum: getSupportedCodes(), + }, + // The REAL claimable-balance id (hex-encoded XDR of ClaimableBalanceId), + // resolved from the create transaction's result — NOT the tx hash. + // Unique + sparse because it is only known after the create tx lands. + balanceId: { + type: String, + unique: true, + sparse: true, + }, + status: { + type: String, + enum: ["pending_signature", "open", "claimed", "reclaimed", "expired"], + default: "pending_signature", + index: true, + }, + // Predicate expiry for the balance. Sender reclaims after this instant. + // No TTL index — see comment at top of file. + expiresAt: { + type: Date, + required: true, + }, + createTxHash: { + type: String, + }, + claimTxHash: { + type: String, + }, + network: { + type: String, + enum: ["testnet", "mainnet"], + required: true, + }, + }, + { timestamps: true } +); + +giftClaimSchema.index({ sender: 1, status: 1 }); +giftClaimSchema.index({ recipient: 1, status: 1 }); +giftClaimSchema.index({ recipient: 1, itemType: 1, itemId: 1 }); + +export default mongoose.model("GiftClaim", giftClaimSchema); diff --git a/src/models/IdempotencyKey.js b/src/models/IdempotencyKey.js new file mode 100644 index 00000000..b2550ad6 --- /dev/null +++ b/src/models/IdempotencyKey.js @@ -0,0 +1,52 @@ +import mongoose from "mongoose"; + +const idempotencyKeySchema = new mongoose.Schema( + { + key: { + type: String, + required: true, + trim: true, + }, + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + endpoint: { + type: String, + required: true, + trim: true, + }, + requestHash: { + type: String, + required: true, + }, + statusCode: { + type: Number, + }, + responseBody: { + type: mongoose.Schema.Types.Mixed, + }, + status: { + type: String, + enum: ["in_progress", "completed"], + default: "in_progress", + index: true, + }, + createdAt: { + type: Date, + default: Date.now, + expires: 86400, // 24-hour TTL index + }, + }, + { timestamps: true } +); + +// Unique compound index on { key, userId, endpoint } for concurrency lock +idempotencyKeySchema.index( + { key: 1, userId: 1, endpoint: 1 }, + { unique: true } +); + +export default mongoose.model("IdempotencyKey", idempotencyKeySchema); diff --git a/src/models/Notification.js b/src/models/Notification.js index e76cd33f..f1bb95ca 100644 --- a/src/models/Notification.js +++ b/src/models/Notification.js @@ -26,6 +26,7 @@ const notificationSchema = new mongoose.Schema( "system", // System notification "welcome", // Welcome notification "recommendation", // New recommendation + "pledge_due", // Recurring sadaqah cycle ready to sign ], required: true, }, @@ -55,6 +56,8 @@ const notificationSchema = new mongoose.Schema( type: mongoose.Schema.Types.ObjectId, ref: "Reel", }, + pledgeId: { type: mongoose.Schema.Types.ObjectId, ref: "Pledge" }, + pledgeCycleId: { type: mongoose.Schema.Types.ObjectId, ref: "PledgeCycle" }, commentId: String, // Any other relevant data }, diff --git a/src/models/OnrampTransaction.js b/src/models/OnrampTransaction.js new file mode 100644 index 00000000..08782aa2 --- /dev/null +++ b/src/models/OnrampTransaction.js @@ -0,0 +1,103 @@ +// models/OnrampTransaction.js +import mongoose from "mongoose"; + +/** + * Internal on-ramp lifecycle statuses. + * + * These are provider-agnostic. Raw provider statuses (e.g. MoonPay's + * `waitingPayment`, `pending`, `completed`, `failed`) are normalized into this + * set by {@link module:services/stellar/onrampService.mapProviderStatus} and the + * original provider value is preserved in `providerStatus` for audit. + * + * @constant {string[]} + */ +export const ONRAMP_STATUSES = [ + "created", + "pending", + "completed", + "failed", +]; + +/** + * Tracks a fiat-to-crypto on-ramp purchase initiated through a third-party + * provider (MoonPay). Each record links a widget session to the user account + * that started it and is updated as provider webhooks report status changes. + * + * A dedicated collection is used (rather than the shared `Transaction` model) + * because on-ramp purchases are settled entirely by the provider and do not + * carry the buyer/creator/item shape of on-chain marketplace transactions. + */ +const onrampTransactionSchema = new mongoose.Schema( + { + // User account that initiated the on-ramp purchase. + user: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + // Stellar public key the purchased asset is delivered to (pre-filled in + // the widget URL). + walletAddress: { + type: String, + required: true, + index: true, + }, + // On-ramp provider used for this session. + provider: { + type: String, + enum: ["moonpay"], + default: "moonpay", + index: true, + }, + // Provider-side transaction identifier, populated from webhook payloads. + // Sparse because it is unknown until the first webhook arrives. + providerTransactionId: { + type: String, + sparse: true, + index: true, + }, + // Normalized lifecycle status (see ONRAMP_STATUSES). + status: { + type: String, + enum: ONRAMP_STATUSES, + default: "created", + index: true, + }, + // Raw provider status string, kept verbatim for audit/debugging. + providerStatus: { + type: String, + }, + // Crypto asset the user is buying (e.g. "usdc", "xlm"). + cryptoCurrency: { + type: String, + }, + // Fiat currency used to pay (e.g. "usd", "eur"). + fiatCurrency: { + type: String, + }, + // Fiat amount charged. Stored as a string to preserve precision. + fiatAmount: { + type: String, + }, + // Crypto amount delivered, once known. Stored as a string for precision. + cryptoAmount: { + type: String, + }, + // On-chain settlement hash reported by the provider when the crypto is sent. + cryptoTransactionHash: { + type: String, + }, + // Reason recorded when the provider reports a failed purchase. + failureReason: { + type: String, + }, + completedAt: Date, + }, + { timestamps: true } +); + +// Common lookup: a user's on-ramp history, most recent first. +onrampTransactionSchema.index({ user: 1, createdAt: -1 }); + +export default mongoose.model("OnrampTransaction", onrampTransactionSchema); diff --git a/src/models/Pledge.js b/src/models/Pledge.js new file mode 100644 index 00000000..4cfa7986 --- /dev/null +++ b/src/models/Pledge.js @@ -0,0 +1,23 @@ +import mongoose from "mongoose"; + +const pledgeSchema = new mongoose.Schema( + { + user: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true, index: true }, + publicKey: { type: String, required: true }, + amount: { type: String, required: true }, + cadence: { type: String, enum: ["daily", "weekly", "monthly"], required: true }, + anchorDay: { type: Number, min: 0, max: 6 }, + anchorDate: { type: Number, min: 1, max: 31 }, + status: { type: String, enum: ["active", "paused", "cancelled"], default: "active", index: true }, + nextDueAt: { type: Date, required: true, index: true }, + consecutivePaid: { type: Number, default: 0 }, + longestStreak: { type: Number, default: 0 }, + totalPaidStroops: { type: String, default: "0" }, + lastPaidAt: Date, + schedulerLockUntil: Date, + }, + { timestamps: true } +); + +pledgeSchema.index({ status: 1, nextDueAt: 1 }); +export default mongoose.model("Pledge", pledgeSchema); diff --git a/src/models/PledgeCycle.js b/src/models/PledgeCycle.js new file mode 100644 index 00000000..2e0ea655 --- /dev/null +++ b/src/models/PledgeCycle.js @@ -0,0 +1,15 @@ +import mongoose from "mongoose"; + +const pledgeCycleSchema = new mongoose.Schema( + { + pledge: { type: mongoose.Schema.Types.ObjectId, ref: "Pledge", required: true, index: true }, + dueAt: { type: Date, required: true }, + status: { type: String, enum: ["due", "notified", "paid", "skipped", "lapsed"], default: "due", index: true }, + transaction: { type: mongoose.Schema.Types.ObjectId, ref: "Transaction", default: null }, + windowEndsAt: { type: Date, required: true, index: true }, + }, + { timestamps: true } +); + +pledgeCycleSchema.index({ pledge: 1, dueAt: 1 }, { unique: true }); +export default mongoose.model("PledgeCycle", pledgeCycleSchema); diff --git a/src/models/ReadingProgress.js b/src/models/ReadingProgress.js new file mode 100644 index 00000000..f88f5a32 --- /dev/null +++ b/src/models/ReadingProgress.js @@ -0,0 +1,75 @@ +import mongoose from "mongoose"; + +/** + * ReadingProgress + * + * Stores a single reading-progress record per user + book combination so that + * progress can be resumed and synced across a user's devices. Mirrors the + * CourseProgress model style: ObjectId refs, a unique compound index and + * mongoose timestamps (createdAt / updatedAt). + */ +const readingProgressSchema = new mongoose.Schema( + { + user: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + book: { + type: mongoose.Schema.Types.ObjectId, + ref: "Book", + required: true, + index: true, + }, + // Current page the reader is on (0-based / 1-based is up to the client). + page: { + type: Number, + default: 0, + min: 0, + }, + // Total number of pages, when the client knows it. Used to derive a + // percentage on the fly if the client only reports the page. + totalPages: { + type: Number, + default: 0, + min: 0, + }, + // Completion percentage, 0-100. + percentage: { + type: Number, + default: 0, + min: 0, + max: 100, + }, + // Opaque resume token (e.g. an EPUB CFI or PDF locator) so the reader can + // resume from the exact last position, not just the page number. + lastPosition: { + type: String, + default: "", + }, + // Identifier of the device that last wrote progress. Lets a client ignore + // echoes of its own updates when syncing across devices. + device: { + type: String, + default: "", + }, + // Monotonic version bumped on every update. A client can poll the library + // endpoint and compare versions (or updatedAt) to detect changes made on + // another device without needing a live socket connection. + version: { + type: Number, + default: 0, + }, + completedAt: { + type: Date, + default: null, + }, + }, + { timestamps: true } +); + +// One progress record per user + book combination. +readingProgressSchema.index({ user: 1, book: 1 }, { unique: true }); + +export default mongoose.model("ReadingProgress", readingProgressSchema); diff --git a/src/models/Reel.js b/src/models/Reel.js index cb696516..faef2831 100644 --- a/src/models/Reel.js +++ b/src/models/Reel.js @@ -15,6 +15,7 @@ const commentSchema = new Schema( const reelSchema = new Schema( { + title: { type: String, trim: true }, description: { type: String, required: true, trim: true, maxlength: 2000 }, category: { type: String, trim: true }, tags: [{ type: String, trim: true }], @@ -22,11 +23,40 @@ const reelSchema = new Schema( videoPublicId: { type: String }, thumbnail: { type: String }, duration: { type: Number }, + status: { type: String, enum: ["active", "pending", "removed"], default: "active" }, + isRemoved: { type: Boolean, default: false }, createdBy: { type: Schema.Types.ObjectId, ref: "User", required: true, }, + // Duet / stitch linkage. A derivative reel is a response video linked to an + // original reel. `originalReelId` is null for a normal (top-level) reel. + originalReelId: { + type: Schema.Types.ObjectId, + ref: "Reel", + default: null, + }, + // `duet` = response shown side-by-side with the original. + // `stitch` = play a portion of the original, then the user's response. + duetType: { + type: String, + enum: ["duet", "stitch"], + default: null, + }, + // For a stitch, the [start, end] portion (in seconds) of the original that + // is prepended before the response video plays. + stitchClip: { + start: { type: Number, min: 0 }, + end: { type: Number, min: 0 }, + }, + // Compositing descriptor produced by utils/videoCompositor.js. Records the + // compositing intent/metadata; actual frame compositing is delegated to the + // media pipeline. + composition: { type: Schema.Types.Mixed, default: null }, + // Derivative counts surfaced on the original reel. + duetCount: { type: Number, default: 0 }, + stitchCount: { type: Number, default: 0 }, likes: [{ type: Schema.Types.ObjectId, ref: "User" }], loves: [{ type: Schema.Types.ObjectId, ref: "User" }], comments: [commentSchema], @@ -38,6 +68,8 @@ const reelSchema = new Schema( reelSchema.index({ createdAt: -1 }); reelSchema.index({ description: "text" }); +// Browse all duets/stitches for a given reel, newest first. +reelSchema.index({ originalReelId: 1, createdAt: -1 }); reelSchema.virtual("likeCount").get(function () { return this.likes?.length || 0; diff --git a/src/models/Session.js b/src/models/Session.js index 6f0b49da..cf69e76c 100644 --- a/src/models/Session.js +++ b/src/models/Session.js @@ -40,6 +40,10 @@ const sessionSchema = new mongoose.Schema( type: Date, default: Date.now, }, + is2FAVerified: { + type: Boolean, + default: false, + }, }, { timestamps: true } ); diff --git a/src/models/SponsorshipSpend.js b/src/models/SponsorshipSpend.js new file mode 100644 index 00000000..8ba9da69 --- /dev/null +++ b/src/models/SponsorshipSpend.js @@ -0,0 +1,43 @@ +// models/SponsorshipSpend.js +// +// Durable spend accounting for fee-bump sponsorship (#30). One document per +// UTC day tracks how much XLM (in stroops) the platform sponsor account has +// spent on network fees and how many sponsored transactions each user has +// been granted, so the per-day total cap and per-user daily count cap can be +// enforced across process restarts and horizontal replicas. +import mongoose from "mongoose"; + +const sponsorshipSpendSchema = new mongoose.Schema( + { + // UTC calendar day, formatted YYYY-MM-DD. Unique so `$inc` upserts race + // safely on a single row per day. + day: { + type: String, + required: true, + unique: true, + index: true, + }, + // Total XLM fees paid by the sponsor account today, in stroops. Daily caps + // are small (well under Number.MAX_SAFE_INTEGER), so a Number here keeps + // atomic `$inc` accounting simple without BigInt gymnastics. + totalStroops: { + type: Number, + default: 0, + }, + // Count of transactions sponsored today (across all users). + sponsoredCount: { + type: Number, + default: 0, + }, + // Per-user sponsored-transaction counts for today, keyed by user id string. + // Enforces FEE_SPONSOR_PER_USER_DAILY_LIMIT. + userCounts: { + type: Map, + of: Number, + default: () => new Map(), + }, + }, + { timestamps: true } +); + +export default mongoose.model("SponsorshipSpend", sponsorshipSpendSchema); diff --git a/src/models/Transaction.js b/src/models/Transaction.js index f8b52be2..23f9cb22 100644 --- a/src/models/Transaction.js +++ b/src/models/Transaction.js @@ -7,10 +7,24 @@ const transactionSchema = new mongoose.Schema( // Transaction identification stellarTxHash: { type: String, - required: true, + sparse: true, unique: true, index: true, }, + expectedHash: { + type: String, + index: true, + }, + // The unsigned XDR returned at initialize, persisted so a duplicate + // initialize for the same pending checkout can replay the exact same + // transaction to sign (idempotent initialize). Never used for + // verification — only for replay of the pending record. + unsignedXdr: { + type: String, + }, + memo: { + type: String, + }, stellarLedger: { type: Number, }, @@ -114,6 +128,25 @@ const transactionSchema = new mongoose.Schema( default: "direct", index: true, }, + // Fee-bump sponsorship (#30): set only when the platform paid this + // transaction's network fee via a fee-bump wrapper. Absent/false means the + // user paid their own fee (the default, unchanged flow). + sponsored: { + type: Boolean, + default: false, + }, + // Actual XLM fee (in stroops) the sponsor account paid, taken from the + // Horizon submit response `fee_charged`. Stored as a string to stay + // consistent with the precision-preserving `amount` field. + sponsorFeeCharged: { + type: String, + }, + // Horizon returns the fee-bump (outer) transaction hash; `stellarTxHash` + // continues to hold the inner-transaction hash (which matches + // `expectedHash` from initialize), so both are recorded for a sponsored row. + feeBumpTxHash: { + type: String, + }, // Status tracking status: { type: String, @@ -140,15 +173,43 @@ const transactionSchema = new mongoose.Schema( confirmedAt: Date, expiresAt: { type: Date, - default: () => new Date(Date.now() + 30 * 60 * 1000), // 30 minutes + // Only abandoned `pending` checkouts get a reaping deadline. Records + // created directly in a terminal state (e.g. worker-created confirmed + // donations/purchases) must never be born with an expiry. + default: function () { + return this.status === "pending" || !this.status + ? new Date(Date.now() + 30 * 60 * 1000) // 30 minutes + : undefined; + }, }, }, { timestamps: true } ); + +// Terminal statuses are permanent records (paid purchases, donations, refunds, +// disputes, failures) that must never be reaped by the TTL monitor. +const TERMINAL_STATUSES = ["confirmed", "failed", "expired", "refunded", "disputed"]; + +// TTL invariant: `expiresAt` is only meaningful for abandoned `pending` +// checkouts. Enforce it at the schema level so a future code path that forgets +// to clear `expiresAt` cannot regress confirmed/terminal rows back into the +// TTL reaper's window — defense in depth on top of the partial index below. +transactionSchema.pre("save", function (next) { + if (TERMINAL_STATUSES.includes(this.status)) { + this.expiresAt = undefined; + } + next(); +}); + // Indexes for efficient queries transactionSchema.index({ buyer: 1, status: 1 }); transactionSchema.index({ creator: 1, status: 1 }); transactionSchema.index({ itemType: 1, itemId: 1 }); transactionSchema.index({ type: 1, status: 1, createdAt: -1 }); // Donation stats -transactionSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); // TTL for expired pending +// TTL for expired pending checkouts only — a blanket index would also reap +// confirmed purchases/donations once their original 30-minute expiry passes. +transactionSchema.index( + { expiresAt: 1 }, + { expireAfterSeconds: 0, partialFilterExpression: { status: "pending" } } +); export default mongoose.model("Transaction", transactionSchema); \ No newline at end of file diff --git a/src/models/User.js b/src/models/User.js index e377b178..db15358b 100644 --- a/src/models/User.js +++ b/src/models/User.js @@ -53,16 +53,52 @@ const userSchema = new mongoose.Schema( type: Boolean, default: false, }, + verifiedEducator: { + type: Boolean, + default: false, + }, lastLogin: { type: Date, }, + // Progressive login lockout (issue #89): consecutive failures increment + // failedLoginAttempts; after the env-configurable threshold the account is + // temporarily locked until lockUntil. Reset to 0 / null on successful login. + failedLoginAttempts: { + type: Number, + default: 0, + }, + lockUntil: { + type: Date, + default: null, + }, resetTokenHash: { type: String, }, resetTokenExpiry: { type: Date, }, + twoFactor: { + enabled: { + type: Boolean, + default: false, + }, + secret: { + type: String, + select: false, + }, + pendingSecret: { + type: String, + select: false, + }, + recoveryCodes: { + type: [String], + select: false, + }, + enrolledAt: { + type: Date, + }, + }, // Follow system following: [ { diff --git a/src/models/WebhookDelivery.js b/src/models/WebhookDelivery.js new file mode 100644 index 00000000..e307e22b --- /dev/null +++ b/src/models/WebhookDelivery.js @@ -0,0 +1,79 @@ +// models/WebhookDelivery.js +// +// One row per (event, subscribed endpoint). ALL scheduling state lives in the +// document (status, attemptCount, nextAttemptAt) rather than in worker memory, +// so the delivery loop can later be swapped onto the durable job queue (issue +// #32) without a schema change. `nextAttemptAt` is indexed for the claim query. +import mongoose from "mongoose"; + +// Bound the stored attempt history and per-attempt error text so a flapping +// consumer can't grow a document unbounded. +export const MAX_STORED_ATTEMPTS = 20; +export const MAX_ERROR_LENGTH = 500; + +const attemptSchema = new mongoose.Schema( + { + at: { type: Date, default: Date.now }, + statusCode: Number, + error: String, + durationMs: Number, + }, + { _id: false } +); + +const webhookDeliverySchema = new mongoose.Schema( + { + endpoint: { + type: mongoose.Schema.Types.ObjectId, + ref: "WebhookEndpoint", + required: true, + index: true, + }, + // Stable per-event id used by consumers for idempotency. Shared across the + // fan-out of one event to multiple endpoints. + eventId: { + type: String, + required: true, + index: true, + }, + eventType: { + type: String, + required: true, + }, + // Frozen event envelope ({ eventId, type, createdAt, apiVersion, data }). + // Serialized ONCE at delivery time so the signed bytes match the sent body. + payload: { + type: mongoose.Schema.Types.Mixed, + required: true, + }, + attempts: { + type: [attemptSchema], + default: [], + }, + status: { + type: String, + enum: ["pending", "retrying", "delivered", "dead"], + default: "pending", + index: true, + }, + attemptCount: { + type: Number, + default: 0, + }, + // When the delivery becomes eligible for its next attempt. Indexed and + // used by the atomic claim query. + nextAttemptAt: { + type: Date, + default: Date.now, + index: true, + }, + deliveredAt: Date, + lastError: String, + }, + { timestamps: true } +); + +// Compound index backing the worker's claim query. +webhookDeliverySchema.index({ status: 1, nextAttemptAt: 1 }); + +export default mongoose.model("WebhookDelivery", webhookDeliverySchema); diff --git a/src/models/WebhookEndpoint.js b/src/models/WebhookEndpoint.js new file mode 100644 index 00000000..9a9b0a57 --- /dev/null +++ b/src/models/WebhookEndpoint.js @@ -0,0 +1,74 @@ +// models/WebhookEndpoint.js +// +// A registered outbound webhook subscription. The signing `secret` is stored +// ENCRYPTED at rest (AES-256-GCM via services/webhooks/webhookSecret.js) — the +// delivery worker must recover the plaintext to sign each request, so a +// one-way hash cannot be used. The encrypted field is `select:false` so it is +// never returned by an accidental find(); read endpoints strip it explicitly. +import mongoose from "mongoose"; + +const webhookEndpointSchema = new mongoose.Schema( + { + // Destination URL. Validated (https-only outside development, no private + // targets) by services/webhooks/urlGuard.js at registration and delivery. + url: { + type: String, + required: true, + trim: true, + }, + // AES-256-GCM ciphertext (`iv:authTag:ciphertext`, hex). Never selected by + // default; never returned by the API after creation/rotation. + secretEncrypted: { + type: String, + required: true, + select: false, + }, + // Subscribed event types. `["*"]` subscribes to everything. + events: { + type: [String], + default: ["*"], + }, + isActive: { + type: Boolean, + default: true, + index: true, + }, + description: { + type: String, + trim: true, + maxlength: 500, + }, + // Count of CONSECUTIVE dead deliveries. Reset to 0 on any successful + // delivery. When it reaches the auto-disable threshold the endpoint is + // deactivated by the delivery worker. + consecutiveFailures: { + type: Number, + default: 0, + }, + lastDeliveryAt: Date, + lastSuccessAt: Date, + disabledAt: Date, + disabledReason: String, + // The admin who registered the endpoint. + owner: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + }, + { timestamps: true } +); + +webhookEndpointSchema.index({ owner: 1, createdAt: -1 }); + +// Defense in depth: never leak the encrypted secret through toJSON/toObject, +// even if a caller forgot to `.select("-secretEncrypted")`. +const stripSecret = (_doc, ret) => { + delete ret.secretEncrypted; + return ret; +}; +webhookEndpointSchema.set("toJSON", { transform: stripSecret }); +webhookEndpointSchema.set("toObject", { transform: stripSecret }); + +export default mongoose.model("WebhookEndpoint", webhookEndpointSchema); diff --git a/src/models/badge.model.js b/src/models/badge.model.js new file mode 100644 index 00000000..4c41de18 --- /dev/null +++ b/src/models/badge.model.js @@ -0,0 +1,41 @@ +import mongoose from "mongoose"; + +const badgeSchema = new mongoose.Schema( + { + name: { + type: String, + required: true, + unique: true, + trim: true, + }, + slug: { + type: String, + required: true, + unique: true, + trim: true, + }, + description: { + type: String, + required: true, + }, + icon: { + type: String, + }, + category: { + type: String, + default: "milestone", + }, + criteriaType: { + type: String, + required: true, + enum: ["courses_completed", "category_completed", "custom"], + }, + threshold: { + type: Number, + default: 1, + }, + }, + { timestamps: true } +); + +export default mongoose.model("Badge", badgeSchema); diff --git a/src/models/badge.model.ts b/src/models/badge.model.ts new file mode 100644 index 00000000..4c41de18 --- /dev/null +++ b/src/models/badge.model.ts @@ -0,0 +1,41 @@ +import mongoose from "mongoose"; + +const badgeSchema = new mongoose.Schema( + { + name: { + type: String, + required: true, + unique: true, + trim: true, + }, + slug: { + type: String, + required: true, + unique: true, + trim: true, + }, + description: { + type: String, + required: true, + }, + icon: { + type: String, + }, + category: { + type: String, + default: "milestone", + }, + criteriaType: { + type: String, + required: true, + enum: ["courses_completed", "category_completed", "custom"], + }, + threshold: { + type: Number, + default: 1, + }, + }, + { timestamps: true } +); + +export default mongoose.model("Badge", badgeSchema); diff --git a/src/models/certificate.model.js b/src/models/certificate.model.js new file mode 100644 index 00000000..fa3aac2b --- /dev/null +++ b/src/models/certificate.model.js @@ -0,0 +1,53 @@ +import mongoose from "mongoose"; + +const certificateSchema = new mongoose.Schema( + { + certificateId: { + type: String, + required: true, + unique: true, + index: true, + }, + user: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + course: { + type: mongoose.Schema.Types.ObjectId, + ref: "Course", + required: true, + index: true, + }, + learnerName: { + type: String, + required: true, + }, + courseTitle: { + type: String, + required: true, + }, + completionDate: { + type: Date, + default: Date.now, + }, + instructorName: { + type: String, + default: "DeenBridge Instructor", + }, + instructorSignature: { + type: String, + default: "DeenBridge Academy", + }, + certificateUrl: { + type: String, + required: true, + }, + }, + { timestamps: true } +); + +certificateSchema.index({ user: 1, course: 1 }, { unique: true }); + +export default mongoose.model("Certificate", certificateSchema); diff --git a/src/models/certificate.model.ts b/src/models/certificate.model.ts new file mode 100644 index 00000000..fa3aac2b --- /dev/null +++ b/src/models/certificate.model.ts @@ -0,0 +1,53 @@ +import mongoose from "mongoose"; + +const certificateSchema = new mongoose.Schema( + { + certificateId: { + type: String, + required: true, + unique: true, + index: true, + }, + user: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + course: { + type: mongoose.Schema.Types.ObjectId, + ref: "Course", + required: true, + index: true, + }, + learnerName: { + type: String, + required: true, + }, + courseTitle: { + type: String, + required: true, + }, + completionDate: { + type: Date, + default: Date.now, + }, + instructorName: { + type: String, + default: "DeenBridge Instructor", + }, + instructorSignature: { + type: String, + default: "DeenBridge Academy", + }, + certificateUrl: { + type: String, + required: true, + }, + }, + { timestamps: true } +); + +certificateSchema.index({ user: 1, course: 1 }, { unique: true }); + +export default mongoose.model("Certificate", certificateSchema); diff --git a/src/models/content-flag.model.js b/src/models/content-flag.model.js new file mode 100644 index 00000000..26bb977a --- /dev/null +++ b/src/models/content-flag.model.js @@ -0,0 +1,40 @@ +import mongoose from "mongoose"; + +const contentFlagSchema = new mongoose.Schema( + { + reel: { + type: mongoose.Schema.Types.ObjectId, + ref: "Reel", + required: true, + index: true, + }, + reporter: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + index: true, + }, + reason: { + type: String, + required: true, + trim: true, + }, + details: { + type: String, + trim: true, + }, + status: { + type: String, + enum: ["pending", "approved", "rejected", "removed"], + default: "pending", + index: true, + }, + isAutoFlagged: { + type: Boolean, + default: false, + }, + flaggedKeywords: [{ type: String }], + }, + { timestamps: true } +); + +export default mongoose.model("ContentFlag", contentFlagSchema); diff --git a/src/models/content-flag.model.ts b/src/models/content-flag.model.ts new file mode 100644 index 00000000..26bb977a --- /dev/null +++ b/src/models/content-flag.model.ts @@ -0,0 +1,40 @@ +import mongoose from "mongoose"; + +const contentFlagSchema = new mongoose.Schema( + { + reel: { + type: mongoose.Schema.Types.ObjectId, + ref: "Reel", + required: true, + index: true, + }, + reporter: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + index: true, + }, + reason: { + type: String, + required: true, + trim: true, + }, + details: { + type: String, + trim: true, + }, + status: { + type: String, + enum: ["pending", "approved", "rejected", "removed"], + default: "pending", + index: true, + }, + isAutoFlagged: { + type: Boolean, + default: false, + }, + flaggedKeywords: [{ type: String }], + }, + { timestamps: true } +); + +export default mongoose.model("ContentFlag", contentFlagSchema); diff --git a/src/models/conversation.model.ts b/src/models/conversation.model.ts new file mode 100644 index 00000000..c84b119f --- /dev/null +++ b/src/models/conversation.model.ts @@ -0,0 +1,34 @@ +import mongoose from "mongoose"; + +const conversationSchema = new mongoose.Schema( + { + participants: { + type: [ + { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + }, + ], + required: true, + validate: [ + (val: any[]) => val && val.length === 2, + "A conversation must have exactly 2 participants", + ], + }, + lastMessage: { + type: mongoose.Schema.Types.ObjectId, + ref: "Message", + }, + lastMessageAt: { + type: Date, + default: Date.now, + index: true, + }, + }, + { timestamps: true } +); + +conversationSchema.index({ participants: 1 }); +conversationSchema.index({ lastMessageAt: -1 }); + +export default mongoose.model("Conversation", conversationSchema); diff --git a/src/models/course-bundle.model.js b/src/models/course-bundle.model.js new file mode 100644 index 00000000..d874329d --- /dev/null +++ b/src/models/course-bundle.model.js @@ -0,0 +1,55 @@ +import mongoose from "mongoose"; + +const courseBundleSchema = new mongoose.Schema( + { + title: { + type: String, + required: [true, "Title is required"], + trim: true, + }, + description: { + type: String, + required: [true, "Description is required"], + }, + courses: [ + { + type: mongoose.Schema.Types.ObjectId, + ref: "Course", + required: true, + }, + ], + price: { + type: Number, + required: [true, "Price is required"], + min: [0, "Price cannot be negative"], + }, + currency: { + type: String, + default: "USDC", + }, + originalPrice: { + type: Number, + default: 0, + }, + discountPercentage: { + type: Number, + default: 0, + }, + createdBy: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + }, + isActive: { + type: Boolean, + default: true, + }, + }, + { timestamps: true } +); + +courseBundleSchema.index({ createdBy: 1 }); +courseBundleSchema.index({ courses: 1 }); +courseBundleSchema.index({ title: "text", description: "text" }); + +export default mongoose.model("CourseBundle", courseBundleSchema); diff --git a/src/models/course-bundle.model.ts b/src/models/course-bundle.model.ts new file mode 100644 index 00000000..d874329d --- /dev/null +++ b/src/models/course-bundle.model.ts @@ -0,0 +1,55 @@ +import mongoose from "mongoose"; + +const courseBundleSchema = new mongoose.Schema( + { + title: { + type: String, + required: [true, "Title is required"], + trim: true, + }, + description: { + type: String, + required: [true, "Description is required"], + }, + courses: [ + { + type: mongoose.Schema.Types.ObjectId, + ref: "Course", + required: true, + }, + ], + price: { + type: Number, + required: [true, "Price is required"], + min: [0, "Price cannot be negative"], + }, + currency: { + type: String, + default: "USDC", + }, + originalPrice: { + type: Number, + default: 0, + }, + discountPercentage: { + type: Number, + default: 0, + }, + createdBy: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + }, + isActive: { + type: Boolean, + default: true, + }, + }, + { timestamps: true } +); + +courseBundleSchema.index({ createdBy: 1 }); +courseBundleSchema.index({ courses: 1 }); +courseBundleSchema.index({ title: "text", description: "text" }); + +export default mongoose.model("CourseBundle", courseBundleSchema); diff --git a/src/models/highlight.model.js b/src/models/highlight.model.js new file mode 100644 index 00000000..9943bf98 --- /dev/null +++ b/src/models/highlight.model.js @@ -0,0 +1,43 @@ +import mongoose from "mongoose"; + +const highlightSchema = new mongoose.Schema( + { + user: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + book: { + type: mongoose.Schema.Types.ObjectId, + ref: "Book", + required: true, + index: true, + }, + text: { + type: String, + required: true, + trim: true, + }, + color: { + type: String, + enum: ["yellow", "blue", "green", "pink", "purple", "orange"], + default: "yellow", + }, + pageNumber: { + type: Number, + }, + passage: { + type: String, + }, + cfiRange: { + type: String, + }, + }, + { timestamps: true } +); + +highlightSchema.index({ user: 1, book: 1 }); +highlightSchema.index({ text: "text", passage: "text" }); + +export default mongoose.model("Highlight", highlightSchema); diff --git a/src/models/highlight.model.ts b/src/models/highlight.model.ts new file mode 100644 index 00000000..9943bf98 --- /dev/null +++ b/src/models/highlight.model.ts @@ -0,0 +1,43 @@ +import mongoose from "mongoose"; + +const highlightSchema = new mongoose.Schema( + { + user: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + book: { + type: mongoose.Schema.Types.ObjectId, + ref: "Book", + required: true, + index: true, + }, + text: { + type: String, + required: true, + trim: true, + }, + color: { + type: String, + enum: ["yellow", "blue", "green", "pink", "purple", "orange"], + default: "yellow", + }, + pageNumber: { + type: Number, + }, + passage: { + type: String, + }, + cfiRange: { + type: String, + }, + }, + { timestamps: true } +); + +highlightSchema.index({ user: 1, book: 1 }); +highlightSchema.index({ text: "text", passage: "text" }); + +export default mongoose.model("Highlight", highlightSchema); diff --git a/src/models/message.model.ts b/src/models/message.model.ts new file mode 100644 index 00000000..6d448666 --- /dev/null +++ b/src/models/message.model.ts @@ -0,0 +1,39 @@ +import mongoose from "mongoose"; + +const messageSchema = new mongoose.Schema( + { + conversation: { + type: mongoose.Schema.Types.ObjectId, + ref: "Conversation", + required: true, + index: true, + }, + sender: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + }, + text: { + type: String, + trim: true, + default: "", + }, + image: { + type: String, + }, + readBy: { + type: [ + { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + }, + ], + default: [], + }, + }, + { timestamps: true } +); + +messageSchema.index({ conversation: 1, createdAt: -1 }); + +export default mongoose.model("Message", messageSchema); diff --git a/src/models/moderation-action.model.js b/src/models/moderation-action.model.js new file mode 100644 index 00000000..387cfa9b --- /dev/null +++ b/src/models/moderation-action.model.js @@ -0,0 +1,35 @@ +import mongoose from "mongoose"; + +const moderationActionSchema = new mongoose.Schema( + { + flag: { + type: mongoose.Schema.Types.ObjectId, + ref: "ContentFlag", + index: true, + }, + reel: { + type: mongoose.Schema.Types.ObjectId, + ref: "Reel", + required: true, + index: true, + }, + admin: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + action: { + type: String, + enum: ["approve", "reject", "remove"], + required: true, + }, + notes: { + type: String, + trim: true, + }, + }, + { timestamps: true } +); + +export default mongoose.model("ModerationAction", moderationActionSchema); diff --git a/src/models/moderation-action.model.ts b/src/models/moderation-action.model.ts new file mode 100644 index 00000000..387cfa9b --- /dev/null +++ b/src/models/moderation-action.model.ts @@ -0,0 +1,35 @@ +import mongoose from "mongoose"; + +const moderationActionSchema = new mongoose.Schema( + { + flag: { + type: mongoose.Schema.Types.ObjectId, + ref: "ContentFlag", + index: true, + }, + reel: { + type: mongoose.Schema.Types.ObjectId, + ref: "Reel", + required: true, + index: true, + }, + admin: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + action: { + type: String, + enum: ["approve", "reject", "remove"], + required: true, + }, + notes: { + type: String, + trim: true, + }, + }, + { timestamps: true } +); + +export default mongoose.model("ModerationAction", moderationActionSchema); diff --git a/src/models/note.model.js b/src/models/note.model.js new file mode 100644 index 00000000..1ea9f2ab --- /dev/null +++ b/src/models/note.model.js @@ -0,0 +1,40 @@ +import mongoose from "mongoose"; + +const noteSchema = new mongoose.Schema( + { + user: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + book: { + type: mongoose.Schema.Types.ObjectId, + ref: "Book", + required: true, + index: true, + }, + highlight: { + type: mongoose.Schema.Types.ObjectId, + ref: "Highlight", + index: true, + }, + content: { + type: String, + required: true, + trim: true, + }, + pageNumber: { + type: Number, + }, + passage: { + type: String, + }, + }, + { timestamps: true } +); + +noteSchema.index({ user: 1, book: 1 }); +noteSchema.index({ content: "text", passage: "text" }); + +export default mongoose.model("Note", noteSchema); diff --git a/src/models/note.model.ts b/src/models/note.model.ts new file mode 100644 index 00000000..1ea9f2ab --- /dev/null +++ b/src/models/note.model.ts @@ -0,0 +1,40 @@ +import mongoose from "mongoose"; + +const noteSchema = new mongoose.Schema( + { + user: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + book: { + type: mongoose.Schema.Types.ObjectId, + ref: "Book", + required: true, + index: true, + }, + highlight: { + type: mongoose.Schema.Types.ObjectId, + ref: "Highlight", + index: true, + }, + content: { + type: String, + required: true, + trim: true, + }, + pageNumber: { + type: Number, + }, + passage: { + type: String, + }, + }, + { timestamps: true } +); + +noteSchema.index({ user: 1, book: 1 }); +noteSchema.index({ content: "text", passage: "text" }); + +export default mongoose.model("Note", noteSchema); diff --git a/src/models/poll-vote.model.js b/src/models/poll-vote.model.js new file mode 100644 index 00000000..42b8be09 --- /dev/null +++ b/src/models/poll-vote.model.js @@ -0,0 +1,33 @@ +import mongoose from "mongoose"; + +const pollVoteSchema = new mongoose.Schema( + { + poll: { + type: mongoose.Schema.Types.ObjectId, + ref: "SpacePoll", + required: true, + index: true, + }, + space: { + type: mongoose.Schema.Types.ObjectId, + ref: "Space", + required: true, + index: true, + }, + user: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + optionIndex: { + type: Number, + required: true, + }, + }, + { timestamps: true } +); + +pollVoteSchema.index({ poll: 1, user: 1 }, { unique: true }); + +export default mongoose.model("PollVote", pollVoteSchema); diff --git a/src/models/poll-vote.model.ts b/src/models/poll-vote.model.ts new file mode 100644 index 00000000..42b8be09 --- /dev/null +++ b/src/models/poll-vote.model.ts @@ -0,0 +1,33 @@ +import mongoose from "mongoose"; + +const pollVoteSchema = new mongoose.Schema( + { + poll: { + type: mongoose.Schema.Types.ObjectId, + ref: "SpacePoll", + required: true, + index: true, + }, + space: { + type: mongoose.Schema.Types.ObjectId, + ref: "Space", + required: true, + index: true, + }, + user: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + optionIndex: { + type: Number, + required: true, + }, + }, + { timestamps: true } +); + +pollVoteSchema.index({ poll: 1, user: 1 }, { unique: true }); + +export default mongoose.model("PollVote", pollVoteSchema); diff --git a/src/models/reading-group-member.model.js b/src/models/reading-group-member.model.js new file mode 100644 index 00000000..47fb30e6 --- /dev/null +++ b/src/models/reading-group-member.model.js @@ -0,0 +1,45 @@ +import mongoose from "mongoose"; + +const readingGroupMemberSchema = new mongoose.Schema( + { + group: { + type: mongoose.Schema.Types.ObjectId, + ref: "ReadingGroup", + required: true, + index: true, + }, + user: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + role: { + type: String, + enum: ["admin", "member"], + default: "member", + }, + status: { + type: String, + enum: ["active", "invited", "pending"], + default: "active", + }, + currentChapter: { + type: Number, + default: 1, + }, + currentProgressPercent: { + type: Number, + default: 0, + }, + lastReadDate: { + type: Date, + default: Date.now, + }, + }, + { timestamps: true } +); + +readingGroupMemberSchema.index({ group: 1, user: 1 }, { unique: true }); + +export default mongoose.model("ReadingGroupMember", readingGroupMemberSchema); diff --git a/src/models/reading-group-member.model.ts b/src/models/reading-group-member.model.ts new file mode 100644 index 00000000..47fb30e6 --- /dev/null +++ b/src/models/reading-group-member.model.ts @@ -0,0 +1,45 @@ +import mongoose from "mongoose"; + +const readingGroupMemberSchema = new mongoose.Schema( + { + group: { + type: mongoose.Schema.Types.ObjectId, + ref: "ReadingGroup", + required: true, + index: true, + }, + user: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + role: { + type: String, + enum: ["admin", "member"], + default: "member", + }, + status: { + type: String, + enum: ["active", "invited", "pending"], + default: "active", + }, + currentChapter: { + type: Number, + default: 1, + }, + currentProgressPercent: { + type: Number, + default: 0, + }, + lastReadDate: { + type: Date, + default: Date.now, + }, + }, + { timestamps: true } +); + +readingGroupMemberSchema.index({ group: 1, user: 1 }, { unique: true }); + +export default mongoose.model("ReadingGroupMember", readingGroupMemberSchema); diff --git a/src/models/reading-group.model.js b/src/models/reading-group.model.js new file mode 100644 index 00000000..f2274dc0 --- /dev/null +++ b/src/models/reading-group.model.js @@ -0,0 +1,63 @@ +import mongoose from "mongoose"; + +const scheduleItemSchema = new mongoose.Schema( + { + chapter: { type: Number, required: true }, + title: { type: String, default: "" }, + targetPages: { type: String, default: "" }, + startDate: { type: Date }, + endDate: { type: Date }, + }, + { _id: false } +); + +const discussionPostSchema = new mongoose.Schema( + { + chapter: { type: Number, required: true }, + user: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true }, + content: { type: String, required: true, trim: true }, + createdAt: { type: Date, default: Date.now }, + } +); + +const readingGroupSchema = new mongoose.Schema( + { + name: { + type: String, + required: true, + trim: true, + }, + description: { + type: String, + trim: true, + }, + book: { + type: mongoose.Schema.Types.ObjectId, + ref: "Book", + required: true, + index: true, + }, + creator: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + privacy: { + type: String, + enum: ["public", "private"], + default: "public", + }, + chaptersPerWeek: { + type: Number, + default: 1, + }, + readingSchedule: [scheduleItemSchema], + discussions: [discussionPostSchema], + }, + { timestamps: true } +); + +readingGroupSchema.index({ name: "text", description: "text" }); + +export default mongoose.model("ReadingGroup", readingGroupSchema); diff --git a/src/models/reading-group.model.ts b/src/models/reading-group.model.ts new file mode 100644 index 00000000..f2274dc0 --- /dev/null +++ b/src/models/reading-group.model.ts @@ -0,0 +1,63 @@ +import mongoose from "mongoose"; + +const scheduleItemSchema = new mongoose.Schema( + { + chapter: { type: Number, required: true }, + title: { type: String, default: "" }, + targetPages: { type: String, default: "" }, + startDate: { type: Date }, + endDate: { type: Date }, + }, + { _id: false } +); + +const discussionPostSchema = new mongoose.Schema( + { + chapter: { type: Number, required: true }, + user: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true }, + content: { type: String, required: true, trim: true }, + createdAt: { type: Date, default: Date.now }, + } +); + +const readingGroupSchema = new mongoose.Schema( + { + name: { + type: String, + required: true, + trim: true, + }, + description: { + type: String, + trim: true, + }, + book: { + type: mongoose.Schema.Types.ObjectId, + ref: "Book", + required: true, + index: true, + }, + creator: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + privacy: { + type: String, + enum: ["public", "private"], + default: "public", + }, + chaptersPerWeek: { + type: Number, + default: 1, + }, + readingSchedule: [scheduleItemSchema], + discussions: [discussionPostSchema], + }, + { timestamps: true } +); + +readingGroupSchema.index({ name: "text", description: "text" }); + +export default mongoose.model("ReadingGroup", readingGroupSchema); diff --git a/src/models/space-poll.model.js b/src/models/space-poll.model.js new file mode 100644 index 00000000..5b950f89 --- /dev/null +++ b/src/models/space-poll.model.js @@ -0,0 +1,50 @@ +import mongoose from "mongoose"; + +const pollOptionSchema = new mongoose.Schema( + { + optionIndex: { type: Number, required: true }, + text: { type: String, required: true, trim: true }, + }, + { _id: false } +); + +const spacePollSchema = new mongoose.Schema( + { + space: { + type: mongoose.Schema.Types.ObjectId, + ref: "Space", + required: true, + index: true, + }, + creator: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + }, + question: { + type: String, + required: true, + trim: true, + }, + options: { + type: [pollOptionSchema], + required: true, + validate: [ + (opts) => opts && opts.length >= 2, + "Poll must have at least 2 options", + ], + }, + status: { + type: String, + enum: ["active", "closed"], + default: "active", + index: true, + }, + closedAt: { + type: Date, + }, + }, + { timestamps: true } +); + +export default mongoose.model("SpacePoll", spacePollSchema); diff --git a/src/models/space-poll.model.ts b/src/models/space-poll.model.ts new file mode 100644 index 00000000..be3878fa --- /dev/null +++ b/src/models/space-poll.model.ts @@ -0,0 +1,50 @@ +import mongoose from "mongoose"; + +const pollOptionSchema = new mongoose.Schema( + { + optionIndex: { type: Number, required: true }, + text: { type: String, required: true, trim: true }, + }, + { _id: false } +); + +const spacePollSchema = new mongoose.Schema( + { + space: { + type: mongoose.Schema.Types.ObjectId, + ref: "Space", + required: true, + index: true, + }, + creator: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + }, + question: { + type: String, + required: true, + trim: true, + }, + options: { + type: [pollOptionSchema], + required: true, + validate: [ + (opts: any[]) => opts && opts.length >= 2, + "Poll must have at least 2 options", + ], + }, + status: { + type: String, + enum: ["active", "closed"], + default: "active", + index: true, + }, + closedAt: { + type: Date, + }, + }, + { timestamps: true } +); + +export default mongoose.model("SpacePoll", spacePollSchema); diff --git a/src/models/user-badge.model.js b/src/models/user-badge.model.js new file mode 100644 index 00000000..3ca92997 --- /dev/null +++ b/src/models/user-badge.model.js @@ -0,0 +1,30 @@ +import mongoose from "mongoose"; + +const userBadgeSchema = new mongoose.Schema( + { + user: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + badge: { + type: mongoose.Schema.Types.ObjectId, + ref: "Badge", + required: true, + index: true, + }, + awardedAt: { + type: Date, + default: Date.now, + }, + metadata: { + type: mongoose.Schema.Types.Mixed, + }, + }, + { timestamps: true } +); + +userBadgeSchema.index({ user: 1, badge: 1 }, { unique: true }); + +export default mongoose.model("UserBadge", userBadgeSchema); diff --git a/src/models/user-badge.model.ts b/src/models/user-badge.model.ts new file mode 100644 index 00000000..3ca92997 --- /dev/null +++ b/src/models/user-badge.model.ts @@ -0,0 +1,30 @@ +import mongoose from "mongoose"; + +const userBadgeSchema = new mongoose.Schema( + { + user: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + badge: { + type: mongoose.Schema.Types.ObjectId, + ref: "Badge", + required: true, + index: true, + }, + awardedAt: { + type: Date, + default: Date.now, + }, + metadata: { + type: mongoose.Schema.Types.Mixed, + }, + }, + { timestamps: true } +); + +userBadgeSchema.index({ user: 1, badge: 1 }, { unique: true }); + +export default mongoose.model("UserBadge", userBadgeSchema); diff --git a/src/mongo/utils/healthCheck.js b/src/mongo/utils/healthCheck.js new file mode 100644 index 00000000..4f2debd2 --- /dev/null +++ b/src/mongo/utils/healthCheck.js @@ -0,0 +1,2 @@ +export * from "../../../mongo/utils/healthCheck.js"; +export { checkDatabaseHealth as default } from "../../../mongo/utils/healthCheck.js"; diff --git a/src/routes/admin/educatorVerificationAdminRoutes.js b/src/routes/admin/educatorVerificationAdminRoutes.js new file mode 100644 index 00000000..b696d152 --- /dev/null +++ b/src/routes/admin/educatorVerificationAdminRoutes.js @@ -0,0 +1,24 @@ +import express from "express"; +import { protect, authorizeRoles } from "../../middlewares/authMiddleware.js"; +import { + listApplications, + getApplicationById, + getAdminDocumentSignedUrl, + approveApplication, + rejectApplication, +} from "../../controllers/admin/educatorVerificationAdminController.js"; + +const router = express.Router(); + +router.use(protect, authorizeRoles("admin")); + +router.get("/", listApplications); +router.get("/:id", getApplicationById); +router.get( + "/:id/documents/:documentIndex/signed-url", + getAdminDocumentSignedUrl +); +router.post("/:id/approve", approveApplication); +router.post("/:id/reject", rejectApplication); + +export default router; diff --git a/src/routes/admin/moderationRoutes.js b/src/routes/admin/moderationRoutes.js new file mode 100644 index 00000000..3df96284 --- /dev/null +++ b/src/routes/admin/moderationRoutes.js @@ -0,0 +1,19 @@ +import express from "express"; +import { protect, authorizeRoles } from "../../middlewares/authMiddleware.js"; +import { + getModerationQueue, + processModerationAction, + getModerationHistory, +} from "../../controllers/moderation.controller.js"; + +const router = express.Router(); + +router.use(protect); +router.use(authorizeRoles("admin")); + +router.get("/queue", getModerationQueue); +router.post("/action", processModerationAction); +router.post("/:flagId/action", processModerationAction); +router.get("/history", getModerationHistory); + +export default router; diff --git a/src/routes/authRoutes.js b/src/routes/authRoutes.js index b5a952fb..ff69626c 100644 --- a/src/routes/authRoutes.js +++ b/src/routes/authRoutes.js @@ -13,23 +13,63 @@ import { changePassword, verifyEmail, resendVerification, + setup2FA, + verify2FA, + disable2FA, } from "../controllers/authController.js"; import { getStellarChallenge, verifyStellarChallenge, } from "../controllers/stellar/sep10Controller.js"; import { protect } from "../middlewares/authMiddleware.js"; -import { refreshLimiter } from "../middlewares/security.js"; +import { + refreshLimiter, + twoFactorLimiter, + emailAuthLimiter, + captchaGate, +} from "../middlewares/security.js"; +import { validate } from "../middlewares/validate.js"; +import { + registerValidation, + loginValidation, +} from "../validators/requestValidators.js"; const router = express.Router(); -// Public routes with auth rate limit -router.post("/register", registerUser); -router.post("/login", loginUser); +// Public routes with auth rate limit. +// /register and /resend-verification also carry a per-EMAIL limiter (survives +// IP rotation) plus a pluggable captcha gate (no-op when unconfigured) — +// see issue #89. +router.post( + "/register", + emailAuthLimiter, + captchaGate(), + registerValidation, + validate, + registerUser +); +router.post("/login", loginValidation, validate, loginUser); router.post("/request-password-reset", requestPasswordReset); router.post("/reset-password", resetPassword); router.get("/verify-email/:token", verifyEmail); -router.post("/resend-verification", resendVerification); +router.post( + "/resend-verification", + emailAuthLimiter, + captchaGate(), + resendVerification +); + +// 2FA Routes +router.post("/2fa/setup", protect, twoFactorLimiter, setup2FA); +router.post("/2fa/verify", twoFactorLimiter, (req, res, next) => { + // If authorization header is provided and no mfaToken, pass through protect middleware first + if (req.headers.authorization && !req.body.mfaToken) { + return protect(req, res, next); + } + next(); +}, verify2FA); +router.post("/2fa/login", twoFactorLimiter, verify2FA); +router.post("/2fa/disable", protect, twoFactorLimiter, disable2FA); // Stellar SEP-10 Web Authentication ("Sign in with Stellar"). Returns 503 when // the feature is unconfigured (SEP10_SIGNING_SECRET/domains unset). See #25. diff --git a/src/routes/badge.routes.js b/src/routes/badge.routes.js new file mode 100644 index 00000000..26592deb --- /dev/null +++ b/src/routes/badge.routes.js @@ -0,0 +1,19 @@ +import express from "express"; +import { protect } from "../middlewares/authMiddleware.js"; +import { + getUserBadgesController, + getAllBadgesController, + checkBadgesController, +} from "../controllers/badge.controller.js"; + +const router = express.Router(); + +// Public routes +router.get("/", getAllBadgesController); +router.get("/user/:userId", getUserBadgesController); + +// Protected routes +router.get("/my-badges", protect, getUserBadgesController); +router.post("/evaluate", protect, checkBadgesController); + +export default router; diff --git a/src/routes/badge.routes.ts b/src/routes/badge.routes.ts new file mode 100644 index 00000000..26592deb --- /dev/null +++ b/src/routes/badge.routes.ts @@ -0,0 +1,19 @@ +import express from "express"; +import { protect } from "../middlewares/authMiddleware.js"; +import { + getUserBadgesController, + getAllBadgesController, + checkBadgesController, +} from "../controllers/badge.controller.js"; + +const router = express.Router(); + +// Public routes +router.get("/", getAllBadgesController); +router.get("/user/:userId", getUserBadgesController); + +// Protected routes +router.get("/my-badges", protect, getUserBadgesController); +router.post("/evaluate", protect, checkBadgesController); + +export default router; diff --git a/src/routes/books/bookRoutes.js b/src/routes/books/bookRoutes.js index 56b3a833..b3d9b60d 100644 --- a/src/routes/books/bookRoutes.js +++ b/src/routes/books/bookRoutes.js @@ -19,7 +19,12 @@ import { checkIfBookBookmarked, removeBookBookmark, } from "../../controllers/books/bookmarkBookController.js"; -import { protect } from "../../middlewares/authMiddleware.js"; +import { protect, requireVerifiedEducator } from "../../middlewares/authMiddleware.js"; +import { + authorizeOwnership, + authorizeReviewOwnership, +} from "../../middlewares/authorize.js"; +import Book from "../../models/Book.js"; import { cacheMiddleware, invalidateCacheMiddleware, @@ -38,6 +43,7 @@ const booksByAuthorCacheKey = (req) => router.post( "/", protect, + requireVerifiedEducator, uploadBook.fields([ { name: "thumbnail", maxCount: 1 }, { name: "file", maxCount: 1 }, @@ -84,6 +90,7 @@ router.get( router.delete( "/:id", protect, + authorizeOwnership({ model: Book, ownerField: "author", resourceType: "Book" }), invalidateCacheMiddleware([`${CACHE_KEYS.BOOKS}*`, `${CACHE_KEYS.BOOK}*`, `${CACHE_KEYS.EDUCATORS}*`]), deleteBook ); @@ -98,39 +105,70 @@ router.post( router.put( "/:id/reviews", protect, + authorizeReviewOwnership({ model: Book }), invalidateCacheMiddleware([`${CACHE_KEYS.BOOK}*`, `${CACHE_KEYS.BOOKS}*`]), updateBookReview ); router.patch( "/:id/reviews", protect, + authorizeReviewOwnership({ model: Book }), invalidateCacheMiddleware([`${CACHE_KEYS.BOOK}*`, `${CACHE_KEYS.BOOKS}*`]), updateBookReview ); router.put( "/:id/reviews/:reviewId", protect, + authorizeReviewOwnership({ model: Book }), invalidateCacheMiddleware([`${CACHE_KEYS.BOOK}*`, `${CACHE_KEYS.BOOKS}*`]), updateBookReview ); router.patch( "/:id/reviews/:reviewId", protect, + authorizeReviewOwnership({ model: Book }), invalidateCacheMiddleware([`${CACHE_KEYS.BOOK}*`, `${CACHE_KEYS.BOOKS}*`]), updateBookReview ); router.delete( "/:id/reviews", protect, + authorizeReviewOwnership({ model: Book }), invalidateCacheMiddleware([`${CACHE_KEYS.BOOK}*`, `${CACHE_KEYS.BOOKS}*`]), deleteBookReview ); router.delete( "/:id/reviews/:reviewId", protect, + authorizeReviewOwnership({ model: Book }), invalidateCacheMiddleware([`${CACHE_KEYS.BOOK}*`, `${CACHE_KEYS.BOOKS}*`]), deleteBookReview ); +import { + createHighlight, + getHighlights, + deleteHighlight, + createNote, + getNotes, + deleteNote, + getHighlightsAndNotes, + searchHighlightsAndNotes, + exportHighlights, +} from "../../controllers/highlight.controller.js"; + +// Highlights & Notes Endpoints (placed before dynamic :id routes where needed) +router.get("/highlights-notes/search", protect, searchHighlightsAndNotes); +router.delete("/highlights/:id", protect, deleteHighlight); +router.delete("/notes/:id", protect, deleteNote); + +router.post("/:bookId/highlights", protect, createHighlight); +router.get("/:bookId/highlights", protect, getHighlights); +router.post("/:bookId/notes", protect, createNote); +router.get("/:bookId/notes", protect, getNotes); +router.get("/:bookId/highlights-notes", protect, getHighlightsAndNotes); +router.get("/:bookId/highlights-notes/search", protect, searchHighlightsAndNotes); +router.get("/:bookId/highlights/export", protect, exportHighlights); + export default router; diff --git a/src/routes/books/readingProgressRoutes.js b/src/routes/books/readingProgressRoutes.js new file mode 100644 index 00000000..a6c29d54 --- /dev/null +++ b/src/routes/books/readingProgressRoutes.js @@ -0,0 +1,38 @@ +import express from "express"; +import { + updateReadingProgress, + getReadingProgress, + getReadingLibrary, +} from "../../controllers/books/readingProgressController.js"; +import { protect } from "../../middlewares/authMiddleware.js"; +import { validate } from "../../middlewares/validate.js"; +import { + updateReadingProgressValidation, + readingProgressBookIdValidation, +} from "../../validators/readingProgressValidators.js"; + +const router = express.Router(); + +// Reading library augmented with progress %. Declared before the dynamic +// ":bookId" route so the static "library" segment is not captured as a bookId. +router.get("/library/progress", protect, getReadingLibrary); + +// Update progress as the user reads (upsert per user + book). +router.put( + "/:bookId/progress", + protect, + updateReadingProgressValidation, + validate, + updateReadingProgress +); + +// Resume: fetch the last stored position for this user + book. +router.get( + "/:bookId/progress", + protect, + readingProgressBookIdValidation, + validate, + getReadingProgress +); + +export default router; diff --git a/src/routes/categoryRoutes.js b/src/routes/categoryRoutes.js new file mode 100644 index 00000000..b8bde138 --- /dev/null +++ b/src/routes/categoryRoutes.js @@ -0,0 +1,13 @@ +import express from "express"; +import { authorizeRoles, protect } from "../middlewares/authMiddleware.js"; +import { cacheMiddleware, invalidateCacheMiddleware } from "../middlewares/cache.js"; +import { CACHE_TTL } from "../utils/cache.js"; +import { createCategory, deleteCategory, getCategory, listCategories, updateCategory } from "../controllers/categoryController.js"; + +const router = express.Router(); +router.get("/", cacheMiddleware(CACHE_TTL.COURSES, () => "categories:list"), listCategories); +router.get("/:slug", cacheMiddleware(CACHE_TTL.COURSES, (req) => `categories:${req.originalUrl}`), getCategory); +router.post("/", protect, authorizeRoles("admin"), invalidateCacheMiddleware(["categories:*", "courses:*"]), createCategory); +router.patch("/:id", protect, authorizeRoles("admin"), invalidateCacheMiddleware(["categories:*", "courses:*"]), updateCategory); +router.delete("/:id", protect, authorizeRoles("admin"), invalidateCacheMiddleware(["categories:*", "courses:*"]), deleteCategory); +export default router; diff --git a/src/routes/certificate.routes.js b/src/routes/certificate.routes.js new file mode 100644 index 00000000..9ad85ead --- /dev/null +++ b/src/routes/certificate.routes.js @@ -0,0 +1,21 @@ +import express from "express"; +import { protect } from "../middlewares/authMiddleware.js"; +import { + generateCertificateController, + getCertificateByIdController, + getUserCertificatesController, + downloadCertificateController, +} from "../controllers/certificate.controller.js"; + +const router = express.Router(); + +// Download & lookup routes (public/protected) +router.get("/:id/download", downloadCertificateController); +router.get("/user", protect, getUserCertificatesController); +router.get("/user/:userId", protect, getUserCertificatesController); +router.get("/:id", getCertificateByIdController); + +// Protected mutation route +router.post("/generate", protect, generateCertificateController); + +export default router; diff --git a/src/routes/certificate.routes.ts b/src/routes/certificate.routes.ts new file mode 100644 index 00000000..9ad85ead --- /dev/null +++ b/src/routes/certificate.routes.ts @@ -0,0 +1,21 @@ +import express from "express"; +import { protect } from "../middlewares/authMiddleware.js"; +import { + generateCertificateController, + getCertificateByIdController, + getUserCertificatesController, + downloadCertificateController, +} from "../controllers/certificate.controller.js"; + +const router = express.Router(); + +// Download & lookup routes (public/protected) +router.get("/:id/download", downloadCertificateController); +router.get("/user", protect, getUserCertificatesController); +router.get("/user/:userId", protect, getUserCertificatesController); +router.get("/:id", getCertificateByIdController); + +// Protected mutation route +router.post("/generate", protect, generateCertificateController); + +export default router; diff --git a/src/routes/course-bundle.routes.js b/src/routes/course-bundle.routes.js new file mode 100644 index 00000000..3fe96478 --- /dev/null +++ b/src/routes/course-bundle.routes.js @@ -0,0 +1,26 @@ +import express from "express"; +import { protect } from "../middlewares/authMiddleware.js"; +import { + createBundle, + getBundles, + getBundleById, + getBundlesByCourse, + updateBundle, + deleteBundle, + purchaseBundle, +} from "../controllers/course-bundle.controller.js"; + +const router = express.Router(); + +// Public routes +router.get("/", getBundles); +router.get("/course/:courseId", getBundlesByCourse); +router.get("/:id", getBundleById); + +// Protected routes +router.post("/", protect, createBundle); +router.put("/:id", protect, updateBundle); +router.delete("/:id", protect, deleteBundle); +router.post("/:id/purchase", protect, purchaseBundle); + +export default router; diff --git a/src/routes/course-bundle.routes.ts b/src/routes/course-bundle.routes.ts new file mode 100644 index 00000000..3fe96478 --- /dev/null +++ b/src/routes/course-bundle.routes.ts @@ -0,0 +1,26 @@ +import express from "express"; +import { protect } from "../middlewares/authMiddleware.js"; +import { + createBundle, + getBundles, + getBundleById, + getBundlesByCourse, + updateBundle, + deleteBundle, + purchaseBundle, +} from "../controllers/course-bundle.controller.js"; + +const router = express.Router(); + +// Public routes +router.get("/", getBundles); +router.get("/course/:courseId", getBundlesByCourse); +router.get("/:id", getBundleById); + +// Protected routes +router.post("/", protect, createBundle); +router.put("/:id", protect, updateBundle); +router.delete("/:id", protect, deleteBundle); +router.post("/:id/purchase", protect, purchaseBundle); + +export default router; diff --git a/src/routes/courses/analyticsRoutes.js b/src/routes/courses/analyticsRoutes.js new file mode 100644 index 00000000..6d3b7d04 --- /dev/null +++ b/src/routes/courses/analyticsRoutes.js @@ -0,0 +1,44 @@ +// routes/courses/analyticsRoutes.js +// +// Creator course-analytics endpoints. Mounted at /api/courses/analytics in +// app.js BEFORE the generic course routes so the static "analytics" segment is +// not swallowed by the courseRoutes "/:id" matcher. +// +// All endpoints require authentication (protect). Per-course endpoints +// additionally require ownership of the target course (authorizeOwnership), +// so only a course's creator (or an admin) can read its analytics. + +import express from "express"; +import { protect } from "../../middlewares/authMiddleware.js"; +import { authorizeOwnership } from "../../middlewares/authorize.js"; +import Course from "../../models/Course.js"; +import { + getCourseAnalyticsHandler, + exportCourseAnalyticsHandler, + getCreatorOverviewHandler, +} from "../../controllers/analytics/courseAnalyticsController.js"; + +const router = express.Router(); + +// Ownership guard bound to the :courseId route param. +const requireCourseOwnership = authorizeOwnership({ + model: Course, + ownerField: "createdBy", + resourceType: "Course", + idParam: "courseId", +}); + +// Portfolio overview across all of the creator's courses. +// Declared before "/:courseId" so the static path wins. +router.get("/overview", protect, getCreatorOverviewHandler); + +// Single-course analytics + CSV export (creator-owned). +router.get("/:courseId", protect, requireCourseOwnership, getCourseAnalyticsHandler); +router.get( + "/:courseId/export", + protect, + requireCourseOwnership, + exportCourseAnalyticsHandler +); + +export default router; diff --git a/src/routes/courses/courseRoutes.js b/src/routes/courses/courseRoutes.js index 57c46a1b..9b0f05cd 100644 --- a/src/routes/courses/courseRoutes.js +++ b/src/routes/courses/courseRoutes.js @@ -22,17 +22,25 @@ import { getCourseProgress, updateCourseProgress, } from "../../controllers/analytics/analyticsController.js"; -import { protect } from "../../middlewares/authMiddleware.js"; +import { getBundlesByCourse } from "../../controllers/course-bundle.controller.js"; +import { protect, requireVerifiedEducator } from "../../middlewares/authMiddleware.js"; +import { + authorizeOwnership, + authorizeReviewOwnership, +} from "../../middlewares/authorize.js"; +import Course from "../../models/Course.js"; import { cacheMiddleware, invalidateCacheMiddleware, } from "../../middlewares/cache.js"; +import { validate } from "../../middlewares/validate.js"; +import { prerequisitesValidation } from "../../validators/requestValidators.js"; import { CACHE_TTL, CACHE_KEYS } from "../../utils/cache.js"; const router = express.Router(); // Cache key generators -const coursesListCacheKey = () => `${CACHE_KEYS.COURSES}list`; +const coursesListCacheKey = (req) => `${CACHE_KEYS.COURSES}list:${req.query.category || "all"}`; const courseDetailCacheKey = (req) => `${CACHE_KEYS.COURSE}${req.params.id}`; const coursesByUserCacheKey = (req) => `${CACHE_KEYS.COURSES}user:${req.query.createdBy}`; @@ -60,6 +68,7 @@ router.post("/:id/progress", protect, updateCourseProgress); // Review listing route router.get("/:id/reviews", getCourseReviews); +router.get("/:courseId/bundles", getBundlesByCourse); // Dynamic routes - cached for 15 minutes router.get( @@ -72,6 +81,9 @@ router.get( router.post( "/", protect, + requireVerifiedEducator, + prerequisitesValidation, + validate, invalidateCacheMiddleware([`${CACHE_KEYS.COURSES}*`, `${CACHE_KEYS.EDUCATORS}*`]), createCourse ); @@ -90,45 +102,53 @@ router.post( router.put( "/:id/reviews", protect, + authorizeReviewOwnership({ model: Course }), invalidateCacheMiddleware([`${CACHE_KEYS.COURSE}*`, `${CACHE_KEYS.COURSES}*`]), updateCourseReview ); router.patch( "/:id/reviews", protect, + authorizeReviewOwnership({ model: Course }), invalidateCacheMiddleware([`${CACHE_KEYS.COURSE}*`, `${CACHE_KEYS.COURSES}*`]), updateCourseReview ); router.put( "/:id/reviews/:reviewId", protect, + authorizeReviewOwnership({ model: Course }), invalidateCacheMiddleware([`${CACHE_KEYS.COURSE}*`, `${CACHE_KEYS.COURSES}*`]), updateCourseReview ); router.patch( "/:id/reviews/:reviewId", protect, + authorizeReviewOwnership({ model: Course }), invalidateCacheMiddleware([`${CACHE_KEYS.COURSE}*`, `${CACHE_KEYS.COURSES}*`]), updateCourseReview ); router.delete( "/:id/reviews", protect, + authorizeReviewOwnership({ model: Course }), invalidateCacheMiddleware([`${CACHE_KEYS.COURSE}*`, `${CACHE_KEYS.COURSES}*`]), deleteCourseReview ); router.delete( "/:id/reviews/:reviewId", protect, + authorizeReviewOwnership({ model: Course }), invalidateCacheMiddleware([`${CACHE_KEYS.COURSE}*`, `${CACHE_KEYS.COURSES}*`]), deleteCourseReview ); router.put( "/:id", protect, + authorizeOwnership({ model: Course, ownerField: "createdBy", resourceType: "Course" }), + prerequisitesValidation, + validate, invalidateCacheMiddleware([`${CACHE_KEYS.COURSES}*`, `${CACHE_KEYS.COURSE}*`]), updateCourse ); export default router; - diff --git a/src/routes/educatorVerificationRoutes.js b/src/routes/educatorVerificationRoutes.js new file mode 100644 index 00000000..2f2dac2b --- /dev/null +++ b/src/routes/educatorVerificationRoutes.js @@ -0,0 +1,19 @@ +import express from "express"; +import { protect } from "../middlewares/authMiddleware.js"; +import { + getMyApplication, + submitApplication, + getDocumentSignedUrl, + generateUploadSignature, +} from "../controllers/educatorVerificationController.js"; + +const router = express.Router(); + +router.use(protect); + +router.get("/", getMyApplication); +router.get("/documents/:documentIndex/signed-url", getDocumentSignedUrl); +router.get("/upload-signature", generateUploadSignature); +router.post("/submit", submitApplication); + +export default router; diff --git a/src/routes/health/database.js b/src/routes/health/database.js new file mode 100644 index 00000000..b300dece --- /dev/null +++ b/src/routes/health/database.js @@ -0,0 +1,30 @@ +import express from "express"; +import { checkDatabaseHealth } from "../../../mongo/utils/healthCheck.js"; + +const router = express.Router(); + +router.get("/", async (req, res) => { + try { + const result = await checkDatabaseHealth(); + const statusCode = result.healthy ? 200 : 503; + return res.status(statusCode).json({ + success: result.healthy, + message: result.healthy + ? "Database connection is healthy" + : "Database connection is unhealthy", + data: result, + }); + } catch (error) { + return res.status(503).json({ + success: false, + message: "Database health check failed", + data: { + healthy: false, + status: "unhealthy", + error: error.message, + }, + }); + } +}); + +export default router; diff --git a/src/routes/internal/aiRoutes.js b/src/routes/internal/aiRoutes.js new file mode 100644 index 00000000..ccdfc42a --- /dev/null +++ b/src/routes/internal/aiRoutes.js @@ -0,0 +1,27 @@ +// routes/internal/aiRoutes.js +// +// Internal, service-to-service routes callable ONLY by the AI service (dnb-ai) +// over the signed-request channel. Every route here is guarded by +// requireServiceAuth with an explicit scope — there is no end-user JWT path in. +// See docs/service-to-service-auth.md for the signing contract. +import express from "express"; +import { requireServiceAuth } from "../../middlewares/serviceAuth.js"; + +const router = express.Router(); + +// GET /api/internal/ai/whoami +// Reflects the authenticated service identity — a genuine, mountable endpoint a +// reviewer (or the dnb-ai client) can hit to confirm its credentials work. +router.get( + "/whoami", + requireServiceAuth({ scope: "ai:read-content" }), + (req, res) => { + res.json({ + success: true, + service: req.service, + timestamp: new Date().toISOString(), + }); + } +); + +export default router; diff --git a/src/routes/jobsRoutes.js b/src/routes/jobsRoutes.js index ef55df21..25b45fa3 100644 --- a/src/routes/jobsRoutes.js +++ b/src/routes/jobsRoutes.js @@ -1,7 +1,18 @@ import express from "express"; +import crypto from "crypto"; import Job from "../models/Job.js"; const router = express.Router(); + +// Constant-time bearer-token comparison. timingSafeEqual throws on unequal +// lengths, so guard first — a length mismatch is simply a non-match and must +// not short-circuit through a timing side channel. +const safeTokenEqual = (a, b) => { + const bufA = Buffer.from(String(a), "utf8"); + const bufB = Buffer.from(String(b), "utf8"); + if (bufA.length !== bufB.length) return false; + return crypto.timingSafeEqual(bufA, bufB); +}; const escapeHtml = (value) => String(value) .replaceAll("&", "&") @@ -13,7 +24,8 @@ const escapeHtml = (value) => 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}`) { + const provided = req.headers.authorization || ""; + if (!safeTokenEqual(provided, `Bearer ${token}`)) { return res.status(401).json({ success: false, message: "Unauthorized" }); } next(); diff --git a/src/routes/messaging.routes.js b/src/routes/messaging.routes.js new file mode 100644 index 00000000..921e12f5 --- /dev/null +++ b/src/routes/messaging.routes.js @@ -0,0 +1,19 @@ +import express from "express"; +import { protect } from "../middlewares/authMiddleware.js"; +import { + getOrCreateConversation, + getConversations, + getMessages, + sendMessage, + markAsRead, +} from "../controllers/messaging.controller.js"; + +const router = express.Router(); + +router.get("/conversations", protect, getConversations); +router.post("/conversations/:userId", protect, getOrCreateConversation); +router.get("/conversations/:conversationId/messages", protect, getMessages); +router.post("/conversations/:conversationId/messages", protect, sendMessage); +router.post("/conversations/:conversationId/read", protect, markAsRead); + +export default router; diff --git a/src/routes/metrics/database.js b/src/routes/metrics/database.js new file mode 100644 index 00000000..54f23536 --- /dev/null +++ b/src/routes/metrics/database.js @@ -0,0 +1,36 @@ +import express from "express"; +import mongoose from "mongoose"; +import poolMetrics from "../../../mongo/monitoring/poolMetrics.js"; + +const router = express.Router(); + +/** + * GET /metrics/database + * --------------------------------------------------------------------------- + * Exposes MongoDB connection-pool statistics in Prometheus text exposition + * format (v0.0.4). The collector is normally wired up at boot in + * `src/config/db.js`; as a safety net this handler lazily attaches the + * listeners if a live connection exists but has not yet been instrumented + * (e.g. when the process connected after this route was first imported). + * + * The endpoint never fails on a missing/partial connection — it returns valid + * zeroed metrics so a Prometheus scrape always succeeds. + */ +router.get("/", (req, res) => { + try { + if ( + !poolMetrics.isAttached() && + mongoose.connection && + mongoose.connection.readyState !== 0 + ) { + poolMetrics.attach(mongoose.connection); + } + } catch { + // Never let instrumentation wiring break the scrape. + } + + res.setHeader("Content-Type", "text/plain; version=0.0.4; charset=utf-8"); + res.status(200).send(poolMetrics.render()); +}); + +export default router; diff --git a/src/routes/payoutRoutes.js b/src/routes/payoutRoutes.js index cbb1a31c..25e38b93 100644 --- a/src/routes/payoutRoutes.js +++ b/src/routes/payoutRoutes.js @@ -1,6 +1,7 @@ // routes/payoutRoutes.js import express from "express"; import { protect } from "../middlewares/authMiddleware.js"; +import { idempotency } from "../middlewares/idempotency.js"; import { buildBatch, submitBatch, @@ -20,7 +21,7 @@ router.get("/me/statement", getMyStatement); router.get("/me/history", getMyHistory); // Operator endpoints (gated by PAYOUT_ADMIN_USER_IDS allowlist in controller) -router.post("/build", buildBatch); -router.post("/:batchId/submit", submitBatch); +router.post("/build", idempotency(), buildBatch); +router.post("/:batchId/submit", idempotency(), submitBatch); export default router; diff --git a/src/routes/readingGroupRoutes.js b/src/routes/readingGroupRoutes.js new file mode 100644 index 00000000..a29b2345 --- /dev/null +++ b/src/routes/readingGroupRoutes.js @@ -0,0 +1,29 @@ +import express from "express"; +import { protect } from "../middlewares/authMiddleware.js"; +import { + createGroup, + getGroups, + getGroupDetails, + joinGroup, + inviteMember, + updateSchedule, + addDiscussionPost, + getDiscussions, + updateMemberProgress, + getMemberProgressDashboard, +} from "../controllers/reading-group.controller.js"; + +const router = express.Router(); + +router.post("/", protect, createGroup); +router.get("/", protect, getGroups); +router.get("/:id", protect, getGroupDetails); +router.post("/:id/join", protect, joinGroup); +router.post("/:id/invite", protect, inviteMember); +router.put("/:id/schedule", protect, updateSchedule); +router.post("/:id/discussions", protect, addDiscussionPost); +router.get("/:id/discussions", protect, getDiscussions); +router.put("/:id/progress", protect, updateMemberProgress); +router.get("/:id/dashboard", protect, getMemberProgressDashboard); + +export default router; diff --git a/src/routes/reelsRoutes.js b/src/routes/reelsRoutes.js index 7bb5ebed..e0b3c013 100644 --- a/src/routes/reelsRoutes.js +++ b/src/routes/reelsRoutes.js @@ -9,9 +9,16 @@ import { deleteReelComment, registerReelShare, registerReelView, + createReelDuet, + getReelDerivatives, } from "../controllers/reelController.js"; import { protect } from "../middlewares/authMiddleware.js"; import upload from "../middlewares/upload.js"; +import { validate } from "../middlewares/validate.js"; +import { + createReelDuetValidation, + listReelDuetsValidation, +} from "../validators/reelValidators.js"; const router = express.Router(); @@ -25,4 +32,26 @@ router.delete("/:id/comments/:commentId", protect, deleteReelComment); router.post("/:id/share", protect, registerReelShare); router.post("/:id/view", protect, registerReelView); +// Duet / stitch: create a response video linked to the original reel and +// browse all duets/stitches for a given reel. +router.post( + "/:id/duet", + protect, + upload.single("video"), + createReelDuetValidation, + validate, + createReelDuet +); +router.get( + "/:id/duets", + protect, + listReelDuetsValidation, + validate, + getReelDerivatives +); + +import { flagReel } from "../controllers/moderation.controller.js"; + +router.post("/:reelId/flag", protect, flagReel); + export default router; \ No newline at end of file diff --git a/src/routes/spaceRoutes.js b/src/routes/spaceRoutes.js index 92f08b71..21a68474 100644 --- a/src/routes/spaceRoutes.js +++ b/src/routes/spaceRoutes.js @@ -1,5 +1,7 @@ import express from "express"; -import { protect } from "../middlewares/authMiddleware.js"; +import { protect, requireVerifiedEducator } from "../middlewares/authMiddleware.js"; +import { authorizeOwnership } from "../middlewares/authorize.js"; +import Space from "../models/Space.js"; import upload from "../middlewares/upload.js"; import { cacheMiddleware, @@ -50,6 +52,7 @@ router.get( router.post( "/", protect, + requireVerifiedEducator, upload.fields([{ name: "thumbnail", maxCount: 1 }]), invalidateCacheMiddleware([`${CACHE_KEYS.SPACES}*`, `${CACHE_KEYS.EDUCATORS}*`]), createSpace @@ -67,6 +70,7 @@ router.post( router.put( "/update/:id", protect, + authorizeOwnership({ model: Space, ownerField: "host", resourceType: "Space" }), invalidateCacheMiddleware([`${CACHE_KEYS.SPACES}*`, `${CACHE_KEYS.SPACE}*`]), updateSpace ); @@ -75,8 +79,26 @@ router.put( router.delete( "/:id", protect, + authorizeOwnership({ model: Space, ownerField: "host", resourceType: "Space" }), invalidateCacheMiddleware([`${CACHE_KEYS.SPACES}*`, `${CACHE_KEYS.SPACE}*`, `${CACHE_KEYS.EDUCATORS}*`]), deleteSpace ); +import { + createPoll, + getSpacePolls, + getPollResults, + voteInPoll, + closePoll, + exportPollResults, +} from "../controllers/space-poll.controller.js"; + +// Poll Endpoints +router.post("/:spaceId/polls", protect, createPoll); +router.get("/:spaceId/polls", protect, getSpacePolls); +router.get("/polls/:pollId", protect, getPollResults); +router.post("/polls/:pollId/vote", protect, voteInPoll); +router.patch("/polls/:pollId/close", protect, closePoll); +router.get("/polls/:pollId/export", protect, exportPollResults); + export default router; diff --git a/src/routes/stellar/analyticsRoutes.js b/src/routes/stellar/analyticsRoutes.js new file mode 100644 index 00000000..285914ce --- /dev/null +++ b/src/routes/stellar/analyticsRoutes.js @@ -0,0 +1,41 @@ +// routes/stellar/analyticsRoutes.js +import express from "express"; +import { protect, authorizeRoles } from "../../middlewares/authMiddleware.js"; +import { validate } from "../../middlewares/validate.js"; +import { + getAnalyticsOverview, + getAnalyticsSummary, + getAnalyticsTimeSeries, +} from "../../controllers/stellar/analyticsController.js"; +import { + analyticsTimeSeriesValidation, + analyticsSummaryValidation, +} from "../../validators/stellarAnalyticsValidators.js"; + +const router = express.Router(); + +// Payment analytics is an admin dashboard surface: require auth (mirroring the +// other stellar routes) plus the admin role. +router.use(protect); +router.use(authorizeRoles("admin")); + +// Combined summary + time series in one call. +router.get("/", analyticsTimeSeriesValidation, validate, getAnalyticsOverview); + +// Per-asset totals only. +router.get( + "/summary", + analyticsSummaryValidation, + validate, + getAnalyticsSummary +); + +// Time series bucketed by day|week|month|year. +router.get( + "/timeseries", + analyticsTimeSeriesValidation, + validate, + getAnalyticsTimeSeries +); + +export default router; diff --git a/src/routes/stellar/donationRoutes.js b/src/routes/stellar/donationRoutes.js index c44a54a9..354d4883 100644 --- a/src/routes/stellar/donationRoutes.js +++ b/src/routes/stellar/donationRoutes.js @@ -1,6 +1,7 @@ // routes/stellar/donationRoutes.js import express from "express"; import { protect } from "../../middlewares/authMiddleware.js"; +import { idempotency } from "../../middlewares/idempotency.js"; import { initializeDonation, submitDonation, @@ -13,7 +14,7 @@ const router = express.Router(); router.get("/stats", getDonationStats); // Protected routes (require authentication) -router.post("/initialize", protect, initializeDonation); -router.post("/submit", protect, submitDonation); +router.post("/initialize", protect, idempotency(), initializeDonation); +router.post("/submit", protect, idempotency(), submitDonation); export default router; diff --git a/src/routes/stellar/giftRoutes.js b/src/routes/stellar/giftRoutes.js new file mode 100644 index 00000000..b7906f58 --- /dev/null +++ b/src/routes/stellar/giftRoutes.js @@ -0,0 +1,30 @@ +// routes/stellar/giftRoutes.js +import express from "express"; +import { protect } from "../../middlewares/authMiddleware.js"; +import { + initializeGift, + submitGift, + listGifts, + getGift, + claimInitialize, + claimSubmit, +} from "../../controllers/stellar/giftController.js"; + +const router = express.Router(); + +// All gift routes require authentication. +router.use(protect); + +// Gift flow (sender funds a claimable balance for the recipient) +router.post("/initialize", initializeGift); +router.post("/submit", submitGift); + +// Gift listing / detail +router.get("/", listGifts); +router.get("/:id", getGift); + +// Claim / reclaim flow +router.post("/:id/claim/initialize", claimInitialize); +router.post("/:id/claim/submit", claimSubmit); + +export default router; diff --git a/src/routes/stellar/onrampRoutes.js b/src/routes/stellar/onrampRoutes.js new file mode 100644 index 00000000..0a9648dc --- /dev/null +++ b/src/routes/stellar/onrampRoutes.js @@ -0,0 +1,20 @@ +// routes/stellar/onrampRoutes.js +import express from "express"; +import { protect } from "../../middlewares/authMiddleware.js"; +import { + createOnrampSession, + getOnrampTransactions, + handleWebhook, +} from "../../controllers/stellar/onrampController.js"; + +const router = express.Router(); + +// Public webhook — authenticity is established by provider HMAC signature +// verification inside the handler (over req.rawBody), not by auth middleware. +router.post("/webhook", handleWebhook); + +// Protected routes (require authentication) +router.post("/session", protect, createOnrampSession); +router.get("/transactions", protect, getOnrampTransactions); + +export default router; diff --git a/src/routes/stellar/paymentRoutes.js b/src/routes/stellar/paymentRoutes.js index e6b9539b..93d8e360 100644 --- a/src/routes/stellar/paymentRoutes.js +++ b/src/routes/stellar/paymentRoutes.js @@ -1,6 +1,7 @@ // routes/stellar/paymentRoutes.js import express from "express"; import { protect, authorizeRoles } from "../../middlewares/authMiddleware.js"; +import { idempotency } from "../../middlewares/idempotency.js"; import { initializePayment, submitPayment, @@ -9,6 +10,7 @@ import { getTransactionHistory, getTransaction, cancelTransaction, + sponsorshipStatus, } from "../../controllers/stellar/paymentController.js"; import { requestRefund, @@ -19,6 +21,11 @@ import { arbitrateDispute, } from "../../controllers/stellar/refundController.js"; import { reconciliationStatus } from "../../controllers/stellar/reconciliationController.js"; +import { validate } from "../../middlewares/validate.js"; +import { + initializePaymentValidation, + submitPaymentValidation, +} from "../../validators/requestValidators.js"; const router = express.Router(); @@ -28,8 +35,20 @@ router.use(protect); // Payment flow router.post("/quote", getQuote); router.post("/preflight", getPaymentPreflight); -router.post("/initialize", initializePayment); -router.post("/submit", submitPayment); +router.post( + "/initialize", + initializePaymentValidation, + validate, + idempotency(), + initializePayment +); +router.post( + "/submit", + submitPaymentValidation, + validate, + idempotency(), + submitPayment +); // Transaction management router.get("/transactions", getTransactionHistory); @@ -37,11 +56,11 @@ router.get("/transactions/:transactionId", getTransaction); router.delete("/transactions/:transactionId", cancelTransaction); // Refund & Dispute flow -router.post("/transactions/:id/refund-request", requestRefund); -router.post("/refunds/:refundId/build", buildRefundXdr); -router.post("/refunds/:refundId/submit", submitRefund); -router.post("/refunds/:refundId/reject", rejectRefund); -router.post("/refunds/:refundId/dispute", escalateDispute); +router.post("/transactions/:id/refund-request", idempotency(), requestRefund); +router.post("/refunds/:refundId/build", idempotency(), buildRefundXdr); +router.post("/refunds/:refundId/submit", idempotency(), submitRefund); +router.post("/refunds/:refundId/reject", idempotency(), rejectRefund); +router.post("/refunds/:refundId/dispute", idempotency(), escalateDispute); router.patch( "/refunds/:refundId/arbitrate", authorizeRoles("admin"), @@ -55,4 +74,11 @@ router.get( reconciliationStatus ); +// Fee-bump sponsorship status (admin) — sponsor float + today's spend (#30) +router.get( + "/sponsorship/status", + authorizeRoles("admin"), + sponsorshipStatus +); + export default router; diff --git a/src/routes/stellar/pledgeRoutes.js b/src/routes/stellar/pledgeRoutes.js new file mode 100644 index 00000000..f8296b4a --- /dev/null +++ b/src/routes/stellar/pledgeRoutes.js @@ -0,0 +1,23 @@ +import express from "express"; +import { protect } from "../../middlewares/authMiddleware.js"; +import { idempotency } from "../../middlewares/idempotency.js"; +import { + createPledge, + getPledgeStats, + initializePledgeCycle, + listPledgeCycles, + listPledges, + submitPledgeCycle, + updatePledgeStatus, +} from "../../controllers/stellar/pledgeController.js"; + +const router = express.Router(); +router.use(protect); +router.get("/", listPledges); +router.get("/stats", getPledgeStats); +router.post("/", idempotency(), createPledge); +router.patch("/:id/status", updatePledgeStatus); +router.get("/:id/cycles", listPledgeCycles); +router.post("/cycles/:cycleId/initialize", idempotency(), initializePledgeCycle); +router.post("/cycles/:cycleId/submit", idempotency(), submitPledgeCycle); +export default router; diff --git a/src/routes/stellar/walletRoutes.js b/src/routes/stellar/walletRoutes.js index 7a8e6898..e58d4ec2 100644 --- a/src/routes/stellar/walletRoutes.js +++ b/src/routes/stellar/walletRoutes.js @@ -8,11 +8,19 @@ import { getMyWallet, checkUserWallet, } from "../../controllers/stellar/walletController.js"; +import { validate } from "../../middlewares/validate.js"; +import { connectWalletValidation } from "../../validators/requestValidators.js"; const router = express.Router(); // Protected routes (require authentication) -router.post("/connect", protect, connectWallet); +router.post( + "/connect", + protect, + connectWalletValidation, + validate, + connectWallet +); router.delete("/disconnect", protect, disconnectWallet); router.get("/me", protect, getMyWallet); diff --git a/src/routes/userRoutes.js b/src/routes/userRoutes.js index 08797257..e832658c 100644 --- a/src/routes/userRoutes.js +++ b/src/routes/userRoutes.js @@ -17,6 +17,7 @@ import { getLearningDashboard, } from "../controllers/userController.js"; import { searchAll } from "../controllers/searchController.js"; +import { getUserBadgesController } from "../controllers/badge.controller.js"; import { cacheMiddleware, invalidateCacheMiddleware, @@ -58,6 +59,9 @@ router.put( updateUser ); +// Fetch user badges +router.get("/:userId/badges", protect, getUserBadgesController); + // Get user by ID - cached for 10 minutes router.get( "/:id", diff --git a/src/routes/webhookRoutes.js b/src/routes/webhookRoutes.js new file mode 100644 index 00000000..85615286 --- /dev/null +++ b/src/routes/webhookRoutes.js @@ -0,0 +1,42 @@ +// routes/webhookRoutes.js +// +// Management API for outbound webhooks. Mounted at /api/webhooks in app.js. +// Every route requires an authenticated admin (issue #20 role gate). +import express from "express"; +import { protect, authorizeRoles } from "../middlewares/authMiddleware.js"; +import { + createEndpoint, + listEndpoints, + getEndpoint, + updateEndpoint, + deleteEndpoint, + rotateSecret, + listDeliveries, + redeliver, + pingEndpoint, +} from "../controllers/webhookController.js"; + +const router = express.Router(); + +// Authentication + admin privilege gate for the whole management surface. +router.use(protect); +router.use(authorizeRoles("admin")); + +// Endpoint CRUD +router.post("/", createEndpoint); +router.get("/", listEndpoints); +router.get("/:id", getEndpoint); +router.patch("/:id", updateEndpoint); +router.delete("/:id", deleteEndpoint); + +// Secret rotation +router.post("/:id/rotate-secret", rotateSecret); + +// Deliveries + dead-letter redelivery +router.get("/:id/deliveries", listDeliveries); +router.post("/:id/deliveries/:deliveryId/redeliver", redeliver); + +// Integration-test ping +router.post("/:id/ping", pingEndpoint); + +export default router; diff --git a/src/scripts/migrateCategories.js b/src/scripts/migrateCategories.js new file mode 100644 index 00000000..7caaa628 --- /dev/null +++ b/src/scripts/migrateCategories.js @@ -0,0 +1,28 @@ +import dotenv from "dotenv"; +import mongoose from "mongoose"; +import Book from "../models/Book.js"; +import Category from "../models/Category.js"; +import Course from "../models/Course.js"; +import { slugifyCategory, uniqueCategorySlug } from "../services/categoryService.js"; + +dotenv.config(); + +export async function migrateCategories() { + const values = [...new Set([...(await Course.distinct("category")), ...(await Book.distinct("category"))].filter(Boolean))]; + for (const value of values) { + const base = slugifyCategory(value); + let category = await Category.findOne({ $or: [{ slug: base }, { name: new RegExp(`^${value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "i") }] }); + if (!category) category = await Category.create({ name: value.trim(), slug: await uniqueCategorySlug(value) }); + await Promise.all([ + Course.updateMany({ category: value, categoryRef: { $exists: false } }, { $set: { categoryRef: category._id, category: category.name } }), + Book.updateMany({ category: value, categoryRef: { $exists: false } }, { $set: { categoryRef: category._id, category: category.name } }), + ]); + } +} + +if (process.argv[1]?.endsWith("migrateCategories.js")) { + if (!process.env.MONGO_URI) throw new Error("MONGO_URI is required"); + await mongoose.connect(process.env.MONGO_URI); + await migrateCategories(); + await mongoose.disconnect(); +} diff --git a/src/scripts/seedCategories.js b/src/scripts/seedCategories.js new file mode 100644 index 00000000..b71a4118 --- /dev/null +++ b/src/scripts/seedCategories.js @@ -0,0 +1,32 @@ +import dotenv from "dotenv"; +import mongoose from "mongoose"; +import Category from "../models/Category.js"; + +dotenv.config(); + +export const CATEGORY_SEEDS = [ + { name: "Qur'an", slug: "quran", order: 10, children: [{ name: "Tajweed", slug: "tajweed" }, { name: "Tafsir", slug: "tafsir" }] }, + { name: "Hadith", slug: "hadith", order: 20 }, + { name: "Aqeedah", slug: "aqeedah", order: 30 }, + { name: "Fiqh", slug: "fiqh", order: 40 }, + { name: "Seerah / History", slug: "seerah-history", order: 50 }, + { name: "Arabic Language", slug: "arabic-language", order: 60 }, + { name: "Islamic Finance", slug: "islamic-finance", order: 70 }, + { name: "Spirituality / Tazkiyah", slug: "spirituality-tazkiyah", order: 80 }, +]; + +export async function seedCategories() { + for (const seed of CATEGORY_SEEDS) { + const parent = await Category.findOneAndUpdate({ slug: seed.slug }, { $set: { name: seed.name, order: seed.order, isActive: true } }, { upsert: true, new: true, setDefaultsOnInsert: true }); + for (const child of seed.children || []) { + await Category.findOneAndUpdate({ slug: child.slug }, { $set: { name: child.name, parent: parent._id, isActive: true } }, { upsert: true, setDefaultsOnInsert: true }); + } + } +} + +if (process.argv[1]?.endsWith("seedCategories.js")) { + if (!process.env.MONGO_URI) throw new Error("MONGO_URI is required"); + await mongoose.connect(process.env.MONGO_URI); + await seedCategories(); + await mongoose.disconnect(); +} diff --git a/src/services/analytics/courseAnalyticsService.js b/src/services/analytics/courseAnalyticsService.js new file mode 100644 index 00000000..27df23ee --- /dev/null +++ b/src/services/analytics/courseAnalyticsService.js @@ -0,0 +1,269 @@ +// services/analytics/courseAnalyticsService.js +// +// Data-access + orchestration layer for creator course analytics. Pulls the +// raw signals from the existing collections (Course, CourseProgress, +// Transaction, User.purchasedCourses) and hands them to the pure calculators +// in utils/analyticsCalculator.js to produce the metrics returned by the +// creator analytics endpoints. +// +// Nothing here mutates state — these are read-only aggregations. + +import mongoose from "mongoose"; +import Course from "../../models/Course.js"; +import CourseProgress from "../../models/CourseProgress.js"; +import Transaction from "../../models/Transaction.js"; +import User from "../../models/User.js"; +import { + computeCompletionRate, + computeConversionRate, + computeEngagement, + computeDropOff, + sumRevenue, +} from "../../utils/analyticsCalculator.js"; + +/** + * Normalise a raw date-range input into concrete Date objects. + * + * @param {string|Date} [startDate] - Inclusive lower bound (ISO string or Date). + * @param {string|Date} [endDate] - Inclusive upper bound (ISO string or Date). + * @returns {{start: Date|null, end: Date|null}} Parsed bounds (null when absent/invalid). + */ +export const parseDateRange = (startDate, endDate) => { + const toDate = (value) => { + if (!value) return null; + const d = new Date(value); + return Number.isNaN(d.getTime()) ? null : d; + }; + return { start: toDate(startDate), end: toDate(endDate) }; +}; + +/** + * Build a Mongo range filter fragment for a timestamp field. + * + * @param {Date|null} start - Inclusive lower bound. + * @param {Date|null} end - Inclusive upper bound. + * @returns {object} A `{ $gte, $lte }` object, or `{}` when both bounds are null. + */ +const rangeFilter = (start, end) => { + const filter = {}; + if (start) filter.$gte = start; + if (end) filter.$lte = end; + return filter; +}; + +/** + * Flatten a course's ordered sections/lessons into a single ordered lesson list. + * + * @param {object} course - A Course document (lean or hydrated). + * @returns {Array<{lessonId: string, title: string}>} Ordered lessons. + */ +export const flattenLessons = (course) => { + const sections = [...(course.sections || [])].sort( + (a, b) => (a.order ?? 0) - (b.order ?? 0) + ); + const lessons = []; + for (const section of sections) { + const ordered = [...(section.lessons || [])].sort( + (a, b) => (a.order ?? 0) - (b.order ?? 0) + ); + for (const lesson of ordered) { + if (!lesson?._id) continue; + lessons.push({ + lessonId: String(lesson._id), + title: lesson.title || section.title || "Lesson", + }); + } + } + return lessons; +}; + +/** + * Count enrollments for a course within an optional date range, using the + * per-user `purchasedCourses.purchaseDate` (covers both free enrollments and + * paid purchases, each of which records a purchaseDate). + * + * @param {mongoose.Types.ObjectId} courseId - Course id. + * @param {Date|null} start - Inclusive lower bound. + * @param {Date|null} end - Inclusive upper bound. + * @returns {Promise} Enrollment count in the window. + */ +const countEnrollmentsInRange = async (courseId, start, end) => { + const range = rangeFilter(start, end); + const hasRange = Object.keys(range).length > 0; + + const match = { "purchasedCourses.courseId": courseId }; + const unwoundMatch = { "purchasedCourses.courseId": courseId }; + if (hasRange) unwoundMatch["purchasedCourses.purchaseDate"] = range; + + const result = await User.aggregate([ + { $match: match }, + { $unwind: "$purchasedCourses" }, + { $match: unwoundMatch }, + { $count: "count" }, + ]); + + return result[0]?.count || 0; +}; + +/** + * Compute the full analytics payload for a single course. + * + * @param {string|mongoose.Types.ObjectId} courseId - Course id. + * @param {object} [options] + * @param {string|Date} [options.startDate] - Inclusive range start. + * @param {string|Date} [options.endDate] - Inclusive range end. + * @param {object} [options.courseDoc] - Pre-loaded Course doc to avoid a re-fetch + * (e.g. `req.resource` from the ownership middleware). + * @returns {Promise} Analytics object, or null when the course is missing. + */ +export const getCourseAnalytics = async (courseId, options = {}) => { + const { startDate, endDate, courseDoc } = options; + const objectId = new mongoose.Types.ObjectId(courseId); + const { start, end } = parseDateRange(startDate, endDate); + + const course = + courseDoc || (await Course.findById(objectId).lean()); + if (!course) return null; + + const completionRange = rangeFilter(start, end); + const hasRange = Object.keys(completionRange).length > 0; + + // Completions in-range: progress docs with a completedAt inside the window. + const completionFilter = { course: objectId, completedAt: { $ne: null } }; + if (hasRange) completionFilter.completedAt = { ...completionRange, $ne: null }; + + const revenueFilter = { + creator: course.createdBy, + itemType: "course", + itemId: objectId, + status: "confirmed", + }; + if (hasRange) revenueFilter.createdAt = completionRange; + + const [progressDocs, completions, enrollmentsInRange, transactions] = + await Promise.all([ + CourseProgress.find({ course: objectId }).lean(), + CourseProgress.countDocuments(completionFilter), + countEnrollmentsInRange(objectId, start, end), + Transaction.find(revenueFilter).select("amount currency createdAt").lean(), + ]); + + const enrollmentsTotal = Array.isArray(course.enrolledUsers) + ? course.enrolledUsers.length + : 0; + const enrollments = hasRange ? enrollmentsInRange : enrollmentsTotal; + + const lessons = flattenLessons(course); + const engagement = computeEngagement(progressDocs, start); + const dropOff = computeDropOff(lessons, progressDocs); + const revenue = sumRevenue(transactions); + + return { + courseId: String(course._id), + title: course.title, + createdBy: String(course.createdBy), + range: { + startDate: start ? start.toISOString() : null, + endDate: end ? end.toISOString() : null, + }, + metrics: { + views: course.views || 0, + enrollments, + enrollmentsTotal, + completions, + completionRate: computeCompletionRate(completions, enrollments), + conversionRate: computeConversionRate(enrollments, course.views || 0), + revenue, + engagement, + dropOff, + totalLessons: lessons.length, + }, + }; +}; + +/** + * Aggregate a lightweight analytics overview across every course owned by a + * creator, plus a portfolio-level roll-up. + * + * @param {string|mongoose.Types.ObjectId} creatorId - The creator's user id. + * @param {object} [options] + * @param {string|Date} [options.startDate] - Inclusive range start. + * @param {string|Date} [options.endDate] - Inclusive range end. + * @returns {Promise<{creatorId: string, range: object, totals: object, courses: object[]}>} + */ +export const getCreatorOverview = async (creatorId, options = {}) => { + const { startDate, endDate } = options; + const objectId = new mongoose.Types.ObjectId(creatorId); + const { start, end } = parseDateRange(startDate, endDate); + + const courses = await Course.find({ createdBy: objectId }).lean(); + + const perCourse = await Promise.all( + courses.map((courseDoc) => + getCourseAnalytics(courseDoc._id, { + startDate, + endDate, + courseDoc, + }) + ) + ); + + const totals = { + courses: courses.length, + views: 0, + enrollments: 0, + completions: 0, + revenueByCurrency: {}, + }; + + const summaries = []; + for (const analytics of perCourse) { + if (!analytics) continue; + const m = analytics.metrics; + totals.views += m.views; + totals.enrollments += m.enrollments; + totals.completions += m.completions; + for (const [currency, amount] of Object.entries( + m.revenue.revenueByCurrency + )) { + totals.revenueByCurrency[currency] = + (totals.revenueByCurrency[currency] || 0) + amount; + } + summaries.push({ + courseId: analytics.courseId, + title: analytics.title, + views: m.views, + enrollments: m.enrollments, + completions: m.completions, + completionRate: m.completionRate, + conversionRate: m.conversionRate, + revenue: m.revenue.grossByCurrency, + }); + } + + totals.completionRate = computeCompletionRate( + totals.completions, + totals.enrollments + ); + totals.conversionRate = computeConversionRate( + totals.enrollments, + totals.views + ); + + return { + creatorId: String(objectId), + range: { + startDate: start ? start.toISOString() : null, + endDate: end ? end.toISOString() : null, + }, + totals, + courses: summaries, + }; +}; + +export default { + parseDateRange, + flattenLessons, + getCourseAnalytics, + getCreatorOverview, +}; diff --git a/src/services/audit/auditService.js b/src/services/audit/auditService.js index 4d5dca0c..eb80f98f 100644 --- a/src/services/audit/auditService.js +++ b/src/services/audit/auditService.js @@ -30,6 +30,11 @@ const METADATA_ALLOWLIST = new Set([ "assignedRole", "name", + // 2FA + "mfaRequired", + "recoveryCodeUsed", + "twoFactorEnabled", + // Wallet "publicKey", "network", @@ -59,9 +64,30 @@ const METADATA_ALLOWLIST = new Set([ "newRole", "changedBy", + // Educator verification (issue #92) + "verificationId", + "previousStatus", + "newStatus", + "reviewedBy", + "reviewNotes", + "documentCount", + // Generic error context "reason", "conflictUserId", + + // Service-to-service auth (dnb-ai) + "serviceId", + "kid", + "scope", + + // Outbound webhooks (issue #45) + "endpointId", + "deliveryId", + "eventType", + "url", + "events", + "disabledReason", ]); /** @@ -105,8 +131,12 @@ export function recordAudit({ status, metadata = null, }) { - // Schedule asynchronously — do not block caller - Promise.resolve() + // Schedule asynchronously so the caller is never blocked by the write. + // The chain is `return`ed (and its .catch always swallows errors) so that + // security-critical callers MAY `await recordAudit(...)` to guarantee the + // row is durable before responding — but awaiting is optional and never + // throws to the caller. + return Promise.resolve() .then(async () => { // If DB is not connected and AuditLog.create is not mocked (e.g. unit tests without DB), // skip write to prevent 10s Mongoose buffer timeouts. diff --git a/src/services/badge.service.js b/src/services/badge.service.js new file mode 100644 index 00000000..ea90cd63 --- /dev/null +++ b/src/services/badge.service.js @@ -0,0 +1,87 @@ +import Badge from "../models/badge.model.js"; +import UserBadge from "../models/user-badge.model.js"; +import CourseProgress from "../models/CourseProgress.js"; +import { DEFAULT_BADGES } from "../utils/badge-criteria.js"; + +export class BadgeService { + async seedDefaultBadges() { + for (const b of DEFAULT_BADGES) { + await Badge.findOneAndUpdate( + { slug: b.slug }, + { $setOnInsert: b }, + { upsert: true, new: true } + ); + } + } + + async checkAndAwardBadges(userId) { + await this.seedDefaultBadges(); + + const completedProgresses = await CourseProgress.find({ + user: userId, + $or: [{ percentComplete: 100 }, { completedAt: { $ne: null } }], + }).populate("course", "category"); + + const completedCount = completedProgresses.length; + + const categoryCounts = {}; + for (const cp of completedProgresses) { + if (cp.course && cp.course.category) { + const cat = cp.course.category; + categoryCounts[cat] = (categoryCounts[cat] || 0) + 1; + } + } + + const maxCategoryCount = Math.max(0, ...Object.values(categoryCounts)); + const badges = await Badge.find(); + const newlyAwarded = []; + + for (const badge of badges) { + let isEligible = false; + + if (badge.criteriaType === "courses_completed") { + isEligible = completedCount >= badge.threshold; + } else if (badge.criteriaType === "category_completed") { + isEligible = maxCategoryCount >= badge.threshold; + } + + if (isEligible) { + const existing = await UserBadge.findOne({ user: userId, badge: badge._id }); + if (!existing) { + try { + const userBadge = await UserBadge.create({ + user: userId, + badge: badge._id, + metadata: { + completedCount, + maxCategoryCount, + }, + }); + const populated = await UserBadge.findById(userBadge._id).populate("badge"); + newlyAwarded.push(populated); + } catch (err) { + if (!err.message?.includes("E11000") && err.code !== 11000) { + throw err; + } + } + } + } + } + + return newlyAwarded; + } + + async getUserBadges(userId) { + await this.checkAndAwardBadges(userId); + return await UserBadge.find({ user: userId }) + .populate("badge") + .sort({ awardedAt: -1 }); + } + + async getAllBadges() { + await this.seedDefaultBadges(); + return await Badge.find().sort({ threshold: 1 }); + } +} + +export default new BadgeService(); diff --git a/src/services/badge.service.ts b/src/services/badge.service.ts new file mode 100644 index 00000000..869921fb --- /dev/null +++ b/src/services/badge.service.ts @@ -0,0 +1,101 @@ +import Badge from "../models/badge.model.js"; +import UserBadge from "../models/user-badge.model.js"; +import CourseProgress from "../models/CourseProgress.js"; +import { DEFAULT_BADGES } from "../utils/badge-criteria.js"; + +export class BadgeService { + /** + * Initializes default system badges if they do not exist. + */ + async seedDefaultBadges() { + for (const b of DEFAULT_BADGES) { + await Badge.findOneAndUpdate( + { slug: b.slug }, + { $setOnInsert: b }, + { upsert: true, new: true } + ); + } + } + + /** + * Evaluates user milestone achievements and awards badges automatically. + */ + async checkAndAwardBadges(userId) { + await this.seedDefaultBadges(); + + const completedProgresses = await CourseProgress.find({ + user: userId, + $or: [{ percentComplete: 100 }, { completedAt: { $ne: null } }], + }).populate("course", "category"); + + const completedCount = completedProgresses.length; + + // Count category completion totals + const categoryCounts = {}; + for (const cp of completedProgresses) { + if (cp.course && cp.course.category) { + const cat = cp.course.category; + categoryCounts[cat] = (categoryCounts[cat] || 0) + 1; + } + } + + const maxCategoryCount = Math.max(0, ...Object.values(categoryCounts)); + const badges = await Badge.find(); + const newlyAwarded = []; + + for (const badge of badges) { + let isEligible = false; + + if (badge.criteriaType === "courses_completed") { + isEligible = completedCount >= badge.threshold; + } else if (badge.criteriaType === "category_completed") { + isEligible = maxCategoryCount >= badge.threshold; + } + + if (isEligible) { + const existing = await UserBadge.findOne({ user: userId, badge: badge._id }); + if (!existing) { + try { + const userBadge = await UserBadge.create({ + user: userId, + badge: badge._id, + metadata: { + completedCount, + maxCategoryCount, + }, + }); + const populated = await UserBadge.findById(userBadge._id).populate("badge"); + newlyAwarded.push(populated); + } catch (err) { + // Ignore duplicate key race condition errors + if (!err.message?.includes("E11000") && err.code !== 11000) { + throw err; + } + } + } + } + } + + return newlyAwarded; + } + + /** + * Fetches all badges earned by a user. + */ + async getUserBadges(userId) { + await this.checkAndAwardBadges(userId); + return await UserBadge.find({ user: userId }) + .populate("badge") + .sort({ awardedAt: -1 }); + } + + /** + * Fetches all badge definitions. + */ + async getAllBadges() { + await this.seedDefaultBadges(); + return await Badge.find().sort({ threshold: 1 }); + } +} + +export default new BadgeService(); diff --git a/src/services/categoryService.js b/src/services/categoryService.js new file mode 100644 index 00000000..2d5f9399 --- /dev/null +++ b/src/services/categoryService.js @@ -0,0 +1,46 @@ +import mongoose from "mongoose"; +import Category from "../models/Category.js"; + +export const slugifyCategory = (value) => + value + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[’']/g, "") + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); + +export const uniqueCategorySlug = async (name, excludeId = null) => { + const base = slugifyCategory(name) || "category"; + let slug = base; + let suffix = 2; + while ( + await Category.exists({ + slug, + ...(excludeId ? { _id: { $ne: excludeId } } : {}), + }) + ) { + slug = `${base}-${suffix}`; + suffix += 1; + } + return slug; +}; + +export const resolveActiveCategory = async (value) => { + if (!value) return null; + const query = mongoose.Types.ObjectId.isValid(value) + ? { _id: value, isActive: true } + : { slug: slugifyCategory(value), isActive: true }; + return Category.findOne(query); +}; + +export const getValidCategorySlugs = async () => + Category.find({ isActive: true }).sort({ order: 1, name: 1 }).distinct("slug"); + +export const categoryTaxonomyExists = async () => Boolean(await Category.exists({})); + +export const categoryValidationError = async () => { + const validSlugs = await getValidCategorySlugs(); + return `Unknown or inactive category. Valid slugs: ${validSlugs.join(", ")}`; +}; diff --git a/src/services/certificate.service.js b/src/services/certificate.service.js new file mode 100644 index 00000000..05dfbafd --- /dev/null +++ b/src/services/certificate.service.js @@ -0,0 +1,87 @@ +import crypto from "crypto"; +import Certificate from "../models/certificate.model.js"; +import CourseProgress from "../models/CourseProgress.js"; +import Course from "../models/Course.js"; +import User from "../models/User.js"; +import { generateCertificatePDF } from "../templates/certificate.template.js"; + +export class CertificateService { + generateCertificateId() { + const timestamp = Date.now().toString(36).toUpperCase(); + const randomHex = crypto.randomBytes(3).toString("hex").toUpperCase(); + return `CERT-${timestamp}-${randomHex}`; + } + + async generateCertificate({ userId, courseId }) { + const existing = await Certificate.findOne({ user: userId, course: courseId }); + if (existing) { + return existing; + } + + const progress = await CourseProgress.findOne({ user: userId, course: courseId }); + if (!progress || (progress.percentComplete < 100 && !progress.completedAt)) { + throw new Error("Course has not been completed yet"); + } + + const [user, course] = await Promise.all([ + User.findById(userId), + Course.findById(courseId).populate("createdBy", "name"), + ]); + + if (!user) throw new Error("User not found"); + if (!course) throw new Error("Course not found"); + + const certificateId = this.generateCertificateId(); + const certificateUrl = `/api/certificates/${certificateId}/download`; + + const certificate = await Certificate.create({ + certificateId, + user: userId, + course: courseId, + learnerName: user.name, + courseTitle: course.title, + completionDate: progress.completedAt || new Date(), + instructorName: course.createdBy?.name || "DeenBridge Instructor", + instructorSignature: "DeenBridge Verification", + certificateUrl, + }); + + return certificate; + } + + async getCertificateById(idOrCertificateId) { + const isObjectId = /^[0-9a-fA-F]{24}$/.test(idOrCertificateId); + const filter = isObjectId + ? { $or: [{ _id: idOrCertificateId }, { certificateId: idOrCertificateId }] } + : { certificateId: idOrCertificateId }; + + const certificate = await Certificate.findOne(filter) + .populate("user", "name email avatar") + .populate("course", "title category thumbnail"); + + if (!certificate) { + throw new Error("Certificate not found"); + } + + return certificate; + } + + async getUserCertificates(userId) { + return await Certificate.find({ user: userId }) + .populate("course", "title category thumbnail rating") + .sort({ createdAt: -1 }); + } + + async generatePDFBuffer(certificate) { + return await generateCertificatePDF({ + learnerName: certificate.learnerName, + courseTitle: certificate.courseTitle, + completionDate: certificate.completionDate, + certificateId: certificate.certificateId, + instructorName: certificate.instructorName, + instructorSignature: certificate.instructorSignature, + }); + } +} + +export default new CertificateService(); diff --git a/src/services/certificate.service.ts b/src/services/certificate.service.ts new file mode 100644 index 00000000..9454f376 --- /dev/null +++ b/src/services/certificate.service.ts @@ -0,0 +1,93 @@ +import crypto from "crypto"; +import Certificate from "../models/certificate.model.js"; +import CourseProgress from "../models/CourseProgress.js"; +import Course from "../models/Course.js"; +import User from "../models/User.js"; +import { generateCertificatePDF } from "../templates/certificate.template.js"; + +export class CertificateService { + /** + * Generates a unique certificate ID. + */ + generateCertificateId() { + const timestamp = Date.now().toString(36).toUpperCase(); + const randomHex = crypto.randomBytes(3).toString("hex").toUpperCase(); + return `CERT-${timestamp}-${randomHex}`; + } + + /** + * Issue / generate certificate upon course completion. + */ + async generateCertificate({ userId, courseId }) { + const existing = await Certificate.findOne({ user: userId, course: courseId }); + if (existing) { + return existing; + } + + const progress = await CourseProgress.findOne({ user: userId, course: courseId }); + if (!progress || (progress.percentComplete < 100 && !progress.completedAt)) { + throw new Error("Course has not been completed yet"); + } + + const [user, course] = await Promise.all([ + User.findById(userId), + Course.findById(courseId).populate("createdBy", "name"), + ]); + + if (!user) throw new Error("User not found"); + if (!course) throw new Error("Course not found"); + + const certificateId = this.generateCertificateId(); + const certificateUrl = `/api/certificates/${certificateId}/download`; + + const certificate = await Certificate.create({ + certificateId, + user: userId, + course: courseId, + learnerName: user.name, + courseTitle: course.title, + completionDate: progress.completedAt || new Date(), + instructorName: course.createdBy?.name || "DeenBridge Instructor", + instructorSignature: "DeenBridge Verification", + certificateUrl, + }); + + return certificate; + } + + async getCertificateById(idOrCertificateId) { + const isObjectId = /^[0-9a-fA-F]{24}$/.test(idOrCertificateId); + const filter = isObjectId + ? { $or: [{ _id: idOrCertificateId }, { certificateId: idOrCertificateId }] } + : { certificateId: idOrCertificateId }; + + const certificate = await Certificate.findOne(filter) + .populate("user", "name email avatar") + .populate("course", "title category thumbnail"); + + if (!certificate) { + throw new Error("Certificate not found"); + } + + return certificate; + } + + async getUserCertificates(userId) { + return await Certificate.find({ user: userId }) + .populate("course", "title category thumbnail rating") + .sort({ createdAt: -1 }); + } + + async generatePDFBuffer(certificate) { + return await generateCertificatePDF({ + learnerName: certificate.learnerName, + courseTitle: certificate.courseTitle, + completionDate: certificate.completionDate, + certificateId: certificate.certificateId, + instructorName: certificate.instructorName, + instructorSignature: certificate.instructorSignature, + }); + } +} + +export default new CertificateService(); diff --git a/src/services/course-bundle.service.js b/src/services/course-bundle.service.js new file mode 100644 index 00000000..cff73e18 --- /dev/null +++ b/src/services/course-bundle.service.js @@ -0,0 +1,180 @@ +import CourseBundle from "../models/course-bundle.model.js"; +import Course from "../models/Course.js"; +import User from "../models/User.js"; +import CourseProgress from "../models/CourseProgress.js"; + +export class CourseBundleService { + calculateDiscount(courses, bundlePrice) { + const originalPrice = courses.reduce((sum, course) => sum + (course.price || 0), 0); + let discountPercentage = 0; + if (originalPrice > 0 && bundlePrice < originalPrice) { + discountPercentage = Math.round(((originalPrice - bundlePrice) / originalPrice) * 100 * 100) / 100; + } + return { originalPrice, discountPercentage }; + } + + async createBundle({ title, description, courses: courseIds, price, currency = "USDC", createdBy }) { + if (!courseIds || !Array.isArray(courseIds) || courseIds.length === 0) { + throw new Error("A bundle must contain at least one course"); + } + + const fetchedCourses = await Course.find({ _id: { $in: courseIds } }); + if (fetchedCourses.length !== courseIds.length) { + throw new Error("One or more specified courses do not exist"); + } + + const { originalPrice, discountPercentage } = this.calculateDiscount(fetchedCourses, price); + + const bundle = await CourseBundle.create({ + title, + description, + courses: courseIds, + price, + currency, + originalPrice, + discountPercentage, + createdBy, + }); + + return await CourseBundle.findById(bundle._id) + .populate("courses", "title description price thumbnail category rating") + .populate("createdBy", "name email avatar"); + } + + async getBundles(query = {}) { + const filter = { isActive: true }; + if (query.courseId) { + filter.courses = query.courseId; + } + if (query.createdBy) { + filter.createdBy = query.createdBy; + } + if (query.search) { + filter.$text = { $search: query.search }; + } + + return await CourseBundle.find(filter) + .populate("courses", "title description price thumbnail category rating") + .populate("createdBy", "name email avatar") + .sort({ createdAt: -1 }); + } + + async getBundleById(bundleId) { + const bundle = await CourseBundle.findById(bundleId) + .populate("courses", "title description price thumbnail category rating createdBy") + .populate("createdBy", "name email avatar"); + + if (!bundle) { + throw new Error("Course bundle not found"); + } + return bundle; + } + + async getBundlesByCourse(courseId) { + return await CourseBundle.find({ courses: courseId, isActive: true }) + .populate("courses", "title description price thumbnail category rating") + .populate("createdBy", "name email avatar"); + } + + async updateBundle(bundleId, updateData, userId, userRole) { + const bundle = await CourseBundle.findById(bundleId); + if (!bundle) { + throw new Error("Course bundle not found"); + } + + if (userRole !== "admin" && bundle.createdBy.toString() !== userId.toString()) { + throw new Error("Not authorized to update this bundle"); + } + + if (updateData.courses || updateData.price !== undefined) { + const courseIds = updateData.courses || bundle.courses; + const bundlePrice = updateData.price !== undefined ? updateData.price : bundle.price; + + const fetchedCourses = await Course.find({ _id: { $in: courseIds } }); + if (fetchedCourses.length !== courseIds.length) { + throw new Error("One or more specified courses do not exist"); + } + + const { originalPrice, discountPercentage } = this.calculateDiscount(fetchedCourses, bundlePrice); + updateData.originalPrice = originalPrice; + updateData.discountPercentage = discountPercentage; + } + + Object.assign(bundle, updateData); + await bundle.save(); + + return await CourseBundle.findById(bundle._id) + .populate("courses", "title description price thumbnail category rating") + .populate("createdBy", "name email avatar"); + } + + async deleteBundle(bundleId, userId, userRole) { + const bundle = await CourseBundle.findById(bundleId); + if (!bundle) { + throw new Error("Course bundle not found"); + } + + if (userRole !== "admin" && bundle.createdBy.toString() !== userId.toString()) { + throw new Error("Not authorized to delete this bundle"); + } + + bundle.isActive = false; + await bundle.save(); + return { success: true, message: "Bundle deactivated successfully" }; + } + + async purchaseBundle(bundleId, userId) { + const bundle = await CourseBundle.findById(bundleId).populate("courses"); + if (!bundle || !bundle.isActive) { + throw new Error("Course bundle not found or inactive"); + } + + const user = await User.findById(userId); + if (!user) { + throw new Error("User not found"); + } + + let newlyEnrolledCount = 0; + + for (const course of bundle.courses) { + const courseIdStr = course._id.toString(); + const alreadyPurchased = user.purchasedCourses.some( + (pc) => pc.courseId && pc.courseId.toString() === courseIdStr + ); + + if (!alreadyPurchased) { + user.purchasedCourses.push({ + courseId: course._id, + purchaseDate: new Date(), + }); + user.stat.coursesEnrolled = (user.stat.coursesEnrolled || 0) + 1; + newlyEnrolledCount++; + } + + const isEnrolledInCourse = course.enrolledUsers.some( + (uId) => uId.toString() === userId.toString() + ); + if (!isEnrolledInCourse) { + course.enrolledUsers.push(userId); + await course.save(); + } + + await CourseProgress.findOneAndUpdate( + { user: userId, course: course._id }, + { $setOnInsert: { user: userId, course: course._id, percentComplete: 0 } }, + { upsert: true, new: true } + ); + } + + await user.save(); + + return { + success: true, + message: `Enrolled successfully in bundle '${bundle.title}'`, + bundle, + newlyEnrolledCount, + }; + } +} + +export default new CourseBundleService(); diff --git a/src/services/course-bundle.service.ts b/src/services/course-bundle.service.ts new file mode 100644 index 00000000..fcc220b0 --- /dev/null +++ b/src/services/course-bundle.service.ts @@ -0,0 +1,183 @@ +import CourseBundle from "../models/course-bundle.model.js"; +import Course from "../models/Course.js"; +import User from "../models/User.js"; +import CourseProgress from "../models/CourseProgress.js"; + +export class CourseBundleService { + /** + * Calculates original total price and discount percentage. + */ + calculateDiscount(courses, bundlePrice) { + const originalPrice = courses.reduce((sum, course) => sum + (course.price || 0), 0); + let discountPercentage = 0; + if (originalPrice > 0 && bundlePrice < originalPrice) { + discountPercentage = Math.round(((originalPrice - bundlePrice) / originalPrice) * 100 * 100) / 100; + } + return { originalPrice, discountPercentage }; + } + + async createBundle({ title, description, courses: courseIds, price, currency = "USDC", createdBy }) { + if (!courseIds || !Array.isArray(courseIds) || courseIds.length === 0) { + throw new Error("A bundle must contain at least one course"); + } + + const fetchedCourses = await Course.find({ _id: { $in: courseIds } }); + if (fetchedCourses.length !== courseIds.length) { + throw new Error("One or more specified courses do not exist"); + } + + const { originalPrice, discountPercentage } = this.calculateDiscount(fetchedCourses, price); + + const bundle = await CourseBundle.create({ + title, + description, + courses: courseIds, + price, + currency, + originalPrice, + discountPercentage, + createdBy, + }); + + return await CourseBundle.findById(bundle._id) + .populate("courses", "title description price thumbnail category rating") + .populate("createdBy", "name email avatar"); + } + + async getBundles(query = {}) { + const filter = { isActive: true }; + if (query.courseId) { + filter.courses = query.courseId; + } + if (query.createdBy) { + filter.createdBy = query.createdBy; + } + if (query.search) { + filter.$text = { $search: query.search }; + } + + return await CourseBundle.find(filter) + .populate("courses", "title description price thumbnail category rating") + .populate("createdBy", "name email avatar") + .sort({ createdAt: -1 }); + } + + async getBundleById(bundleId) { + const bundle = await CourseBundle.findById(bundleId) + .populate("courses", "title description price thumbnail category rating createdBy") + .populate("createdBy", "name email avatar"); + + if (!bundle) { + throw new Error("Course bundle not found"); + } + return bundle; + } + + async getBundlesByCourse(courseId) { + return await CourseBundle.find({ courses: courseId, isActive: true }) + .populate("courses", "title description price thumbnail category rating") + .populate("createdBy", "name email avatar"); + } + + async updateBundle(bundleId, updateData, userId, userRole) { + const bundle = await CourseBundle.findById(bundleId); + if (!bundle) { + throw new Error("Course bundle not found"); + } + + if (userRole !== "admin" && bundle.createdBy.toString() !== userId.toString()) { + throw new Error("Not authorized to update this bundle"); + } + + if (updateData.courses || updateData.price !== undefined) { + const courseIds = updateData.courses || bundle.courses; + const bundlePrice = updateData.price !== undefined ? updateData.price : bundle.price; + + const fetchedCourses = await Course.find({ _id: { $in: courseIds } }); + if (fetchedCourses.length !== courseIds.length) { + throw new Error("One or more specified courses do not exist"); + } + + const { originalPrice, discountPercentage } = this.calculateDiscount(fetchedCourses, bundlePrice); + updateData.originalPrice = originalPrice; + updateData.discountPercentage = discountPercentage; + } + + Object.assign(bundle, updateData); + await bundle.save(); + + return await CourseBundle.findById(bundle._id) + .populate("courses", "title description price thumbnail category rating") + .populate("createdBy", "name email avatar"); + } + + async deleteBundle(bundleId, userId, userRole) { + const bundle = await CourseBundle.findById(bundleId); + if (!bundle) { + throw new Error("Course bundle not found"); + } + + if (userRole !== "admin" && bundle.createdBy.toString() !== userId.toString()) { + throw new Error("Not authorized to delete this bundle"); + } + + bundle.isActive = false; + await bundle.save(); + return { success: true, message: "Bundle deactivated successfully" }; + } + + async purchaseBundle(bundleId, userId) { + const bundle = await CourseBundle.findById(bundleId).populate("courses"); + if (!bundle || !bundle.isActive) { + throw new Error("Course bundle not found or inactive"); + } + + const user = await User.findById(userId); + if (!user) { + throw new Error("User not found"); + } + + let newlyEnrolledCount = 0; + + for (const course of bundle.courses) { + const courseIdStr = course._id.toString(); + const alreadyPurchased = user.purchasedCourses.some( + (pc) => pc.courseId && pc.courseId.toString() === courseIdStr + ); + + if (!alreadyPurchased) { + user.purchasedCourses.push({ + courseId: course._id, + purchaseDate: new Date(), + }); + user.stat.coursesEnrolled = (user.stat.coursesEnrolled || 0) + 1; + newlyEnrolledCount++; + } + + const isEnrolledInCourse = course.enrolledUsers.some( + (uId) => uId.toString() === userId.toString() + ); + if (!isEnrolledInCourse) { + course.enrolledUsers.push(userId); + await course.save(); + } + + await CourseProgress.findOneAndUpdate( + { user: userId, course: course._id }, + { $setOnInsert: { user: userId, course: course._id, percentComplete: 0 } }, + { upsert: true, new: true } + ); + } + + await user.save(); + + return { + success: true, + message: `Enrolled successfully in bundle '${bundle.title}'`, + bundle, + newlyEnrolledCount, + }; + } +} + +export default new CourseBundleService(); diff --git a/src/services/highlight.service.js b/src/services/highlight.service.js new file mode 100644 index 00000000..50a8502f --- /dev/null +++ b/src/services/highlight.service.js @@ -0,0 +1,204 @@ +import Highlight from "../models/highlight.model.js"; +import Note from "../models/note.model.js"; +import Book from "../models/Book.js"; + +export class HighlightService { + async createHighlight({ + userId, + bookId, + text, + color = "yellow", + pageNumber, + passage, + cfiRange, + }) { + const book = await Book.findById(bookId); + if (!book) { + throw new Error("Book not found"); + } + + const highlight = await Highlight.create({ + user: userId, + book: bookId, + text, + color, + pageNumber, + passage, + cfiRange, + }); + + return highlight; + } + + async getHighlights({ userId, bookId }) { + const query = { user: userId }; + if (bookId) { + query.book = bookId; + } + return Highlight.find(query).sort({ createdAt: -1 }); + } + + async deleteHighlight({ userId, highlightId }) { + const highlight = await Highlight.findOneAndDelete({ _id: highlightId, user: userId }); + if (!highlight) { + throw new Error("Highlight not found or unauthorized"); + } + await Note.deleteMany({ highlight: highlightId, user: userId }); + return highlight; + } + + async createNote({ + userId, + bookId, + highlightId, + content, + pageNumber, + passage, + }) { + const book = await Book.findById(bookId); + if (!book) { + throw new Error("Book not found"); + } + + if (highlightId) { + const highlight = await Highlight.findById(highlightId); + if (!highlight) { + throw new Error("Target highlight not found"); + } + } + + const note = await Note.create({ + user: userId, + book: bookId, + highlight: highlightId || undefined, + content, + pageNumber, + passage, + }); + + return note; + } + + async getNotes({ userId, bookId }) { + const query = { user: userId }; + if (bookId) { + query.book = bookId; + } + return Note.find(query).populate("highlight").sort({ createdAt: -1 }); + } + + async deleteNote({ userId, noteId }) { + const note = await Note.findOneAndDelete({ _id: noteId, user: userId }); + if (!note) { + throw new Error("Note not found or unauthorized"); + } + return note; + } + + async getHighlightsAndNotes({ userId, bookId }) { + const [highlights, notes] = await Promise.all([ + this.getHighlights({ userId, bookId }), + this.getNotes({ userId, bookId }), + ]); + + return { + bookId, + highlights, + notes, + }; + } + + async searchHighlightsAndNotes({ + userId, + bookId, + query, + }) { + if (!query || query.trim() === "") { + return { highlights: [], notes: [] }; + } + + const regex = new RegExp(query, "i"); + const filter = { user: userId }; + if (bookId) { + filter.book = bookId; + } + + const [highlights, notes] = await Promise.all([ + Highlight.find({ + ...filter, + $or: [{ text: regex }, { passage: regex }], + }).sort({ createdAt: -1 }), + Note.find({ + ...filter, + $or: [{ content: regex }, { passage: regex }], + }) + .populate("highlight") + .sort({ createdAt: -1 }), + ]); + + return { highlights, notes }; + } + + async exportHighlights({ + userId, + bookId, + format = "text", + }) { + const book = await Book.findById(bookId); + const { highlights, notes } = await this.getHighlightsAndNotes({ userId, bookId }); + + const title = book ? book.title : "Book Highlights & Notes"; + const author = book ? book.author : "Unknown Author"; + + if (format === "pdf") { + const pdfHeader = `%PDF-1.4\n1 0 obj << /Title (${title}) /Author (${author}) >> endobj\n`; + let pdfContent = `HIGHLIGHTS & NOTES FOR: ${title} by ${author}\n\n`; + pdfContent += `--- HIGHLIGHTS (${highlights.length}) ---\n`; + highlights.forEach((h, idx) => { + pdfContent += `${idx + 1}. [${h.color.toUpperCase()}] Page ${h.pageNumber || "N/A"}: "${h.text}"\n`; + }); + pdfContent += `\n--- NOTES (${notes.length}) ---\n`; + notes.forEach((n, idx) => { + pdfContent += `${idx + 1}. Page ${n.pageNumber || "N/A"}: ${n.content}\n`; + }); + + return { + format: "pdf", + mimeType: "application/pdf", + filename: `${title.toLowerCase().replace(/[^a-z0-9]/g, "_")}_highlights.pdf`, + content: pdfHeader + Buffer.from(pdfContent).toString("utf8"), + rawText: pdfContent, + }; + } + + let textExport = `=========================================\n`; + textExport += `BOOK: ${title}\n`; + textExport += `AUTHOR: ${author}\n`; + textExport += `EXPORTED: ${new Date().toISOString()}\n`; + textExport += `=========================================\n\n`; + + textExport += `HIGHLIGHTS (${highlights.length})\n`; + textExport += `-----------------------------------------\n`; + highlights.forEach((h, i) => { + textExport += `${i + 1}. Color: ${h.color} | Page: ${h.pageNumber || "N/A"}\n`; + textExport += ` "${h.text}"\n\n`; + }); + + textExport += `NOTES (${notes.length})\n`; + textExport += `-----------------------------------------\n`; + notes.forEach((n, i) => { + textExport += `${i + 1}. Page: ${n.pageNumber || "N/A"}\n`; + textExport += ` Note: ${n.content}\n\n`; + }); + + return { + format: "text", + mimeType: "text/plain", + filename: `${title.toLowerCase().replace(/[^a-z0-9]/g, "_")}_highlights.txt`, + content: textExport, + }; + } +} + +export const highlightService = new HighlightService(); +export default highlightService; diff --git a/src/services/highlight.service.ts b/src/services/highlight.service.ts new file mode 100644 index 00000000..805913b7 --- /dev/null +++ b/src/services/highlight.service.ts @@ -0,0 +1,257 @@ +import Highlight from "../models/highlight.model.ts"; +import Note from "../models/note.model.ts"; +import Book from "../models/Book.js"; + +export class HighlightService { + /** + * Save a new text highlight for a book. + */ + async createHighlight({ + userId, + bookId, + text, + color = "yellow", + pageNumber, + passage, + cfiRange, + }: { + userId: string; + bookId: string; + text: string; + color?: string; + pageNumber?: number; + passage?: string; + cfiRange?: string; + }) { + const book = await Book.findById(bookId); + if (!book) { + throw new Error("Book not found"); + } + + const highlight = await Highlight.create({ + user: userId, + book: bookId, + text, + color, + pageNumber, + passage, + cfiRange, + }); + + return highlight; + } + + /** + * Get all highlights for a book by a specific user. + */ + async getHighlights({ userId, bookId }: { userId: string; bookId?: string }) { + const query: any = { user: userId }; + if (bookId) { + query.book = bookId; + } + return Highlight.find(query).sort({ createdAt: -1 }); + } + + /** + * Delete a highlight owned by user. + */ + async deleteHighlight({ userId, highlightId }: { userId: string; highlightId: string }) { + const highlight = await Highlight.findOneAndDelete({ _id: highlightId, user: userId }); + if (!highlight) { + throw new Error("Highlight not found or unauthorized"); + } + // Delete associated notes if any + await Note.deleteMany({ highlight: highlightId, user: userId }); + return highlight; + } + + /** + * Add a note to a specific passage, page, or highlight. + */ + async createNote({ + userId, + bookId, + highlightId, + content, + pageNumber, + passage, + }: { + userId: string; + bookId: string; + highlightId?: string; + content: string; + pageNumber?: number; + passage?: string; + }) { + const book = await Book.findById(bookId); + if (!book) { + throw new Error("Book not found"); + } + + if (highlightId) { + const highlight = await Highlight.findById(highlightId); + if (!highlight) { + throw new Error("Target highlight not found"); + } + } + + const note = await Note.create({ + user: userId, + book: bookId, + highlight: highlightId || undefined, + content, + pageNumber, + passage, + }); + + return note; + } + + /** + * Get all notes for a book by user. + */ + async getNotes({ userId, bookId }: { userId: string; bookId?: string }) { + const query: any = { user: userId }; + if (bookId) { + query.book = bookId; + } + return Note.find(query).populate("highlight").sort({ createdAt: -1 }); + } + + /** + * Delete a note owned by user. + */ + async deleteNote({ userId, noteId }: { userId: string; noteId: string }) { + const note = await Note.findOneAndDelete({ _id: noteId, user: userId }); + if (!note) { + throw new Error("Note not found or unauthorized"); + } + return note; + } + + /** + * View all highlights and notes for a book. + */ + async getHighlightsAndNotes({ userId, bookId }: { userId: string; bookId: string }) { + const [highlights, notes] = await Promise.all([ + this.getHighlights({ userId, bookId }), + this.getNotes({ userId, bookId }), + ]); + + return { + bookId, + highlights, + notes, + }; + } + + /** + * Search through highlights and notes. + */ + async searchHighlightsAndNotes({ + userId, + bookId, + query, + }: { + userId: string; + bookId?: string; + query: string; + }) { + if (!query || query.trim() === "") { + return { highlights: [], notes: [] }; + } + + const regex = new RegExp(query, "i"); + const filter: any = { user: userId }; + if (bookId) { + filter.book = bookId; + } + + const [highlights, notes] = await Promise.all([ + Highlight.find({ + ...filter, + $or: [{ text: regex }, { passage: regex }], + }).sort({ createdAt: -1 }), + Note.find({ + ...filter, + $or: [{ content: regex }, { passage: regex }], + }) + .populate("highlight") + .sort({ createdAt: -1 }), + ]); + + return { highlights, notes }; + } + + /** + * Export highlights and notes as text or PDF formatted structure. + */ + async exportHighlights({ + userId, + bookId, + format = "text", + }: { + userId: string; + bookId: string; + format?: string; + }) { + const book = await Book.findById(bookId); + const { highlights, notes } = await this.getHighlightsAndNotes({ userId, bookId }); + + const title = book ? book.title : "Book Highlights & Notes"; + const author = book ? book.author : "Unknown Author"; + + if (format === "pdf") { + // Build PDF document format string / buffer structure + const pdfHeader = `%PDF-1.4\n1 0 obj << /Title (${title}) /Author (${author}) >> endobj\n`; + let pdfContent = `HIGHLIGHTS & NOTES FOR: ${title} by ${author}\n\n`; + pdfContent += `--- HIGHLIGHTS (${highlights.length}) ---\n`; + highlights.forEach((h: any, idx: number) => { + pdfContent += `${idx + 1}. [${h.color.toUpperCase()}] Page ${h.pageNumber || "N/A"}: "${h.text}"\n`; + }); + pdfContent += `\n--- NOTES (${notes.length}) ---\n`; + notes.forEach((n: any, idx: number) => { + pdfContent += `${idx + 1}. Page ${n.pageNumber || "N/A"}: ${n.content}\n`; + }); + + return { + format: "pdf", + mimeType: "application/pdf", + filename: `${title.toLowerCase().replace(/[^a-z0-9]/g, "_")}_highlights.pdf`, + content: pdfHeader + Buffer.from(pdfContent).toString("utf8"), + rawText: pdfContent, + }; + } + + // Default text format + let textExport = `=========================================\n`; + textExport += `BOOK: ${title}\n`; + textExport += `AUTHOR: ${author}\n`; + textExport += `EXPORTED: ${new Date().toISOString()}\n`; + textExport += `=========================================\n\n`; + + textExport += `HIGHLIGHTS (${highlights.length})\n`; + textExport += `-----------------------------------------\n`; + highlights.forEach((h: any, i: number) => { + textExport += `${i + 1}. Color: ${h.color} | Page: ${h.pageNumber || "N/A"}\n`; + textExport += ` "${h.text}"\n\n`; + }); + + textExport += `NOTES (${notes.length})\n`; + textExport += `-----------------------------------------\n`; + notes.forEach((n: any, i: number) => { + textExport += `${i + 1}. Page: ${n.pageNumber || "N/A"}\n`; + textExport += ` Note: ${n.content}\n\n`; + }); + + return { + format: "text", + mimeType: "text/plain", + filename: `${title.toLowerCase().replace(/[^a-z0-9]/g, "_")}_highlights.txt`, + content: textExport, + }; + } +} + +export const highlightService = new HighlightService(); +export default highlightService; diff --git a/src/services/messaging.service.ts b/src/services/messaging.service.ts new file mode 100644 index 00000000..6dd0999b --- /dev/null +++ b/src/services/messaging.service.ts @@ -0,0 +1,131 @@ +import Conversation from "../models/conversation.model.ts"; +import Message from "../models/message.model.ts"; + +export class MessagingService { + async getOrCreateConversation(userId1: string, userId2: string) { + const sorted = [userId1, userId2].sort(); + + let conversation = await Conversation.findOne({ + participants: { $all: sorted, $size: 2 }, + }); + + if (!conversation) { + conversation = await Conversation.create({ + participants: sorted, + }); + } + + return conversation.populate("participants", "name email avatar"); + } + + async sendMessage({ + conversationId, + senderId, + text, + image, + }: { + conversationId: string; + senderId: string; + text?: string; + image?: string; + }) { + const conversation = await Conversation.findById(conversationId); + if (!conversation) { + throw new Error("Conversation not found"); + } + + if (!conversation.participants.some((p: any) => p.toString() === senderId)) { + throw new Error("You are not a participant in this conversation"); + } + + const message = await Message.create({ + conversation: conversationId, + sender: senderId, + text: text || "", + image: image || undefined, + readBy: [senderId], + }); + + conversation.lastMessage = message._id as any; + conversation.lastMessageAt = new Date(); + await conversation.save(); + + return message.populate([ + { path: "sender", select: "name email avatar" }, + { path: "readBy", select: "name email avatar" }, + ]); + } + + async getConversations(userId: string) { + const conversations = await Conversation.find({ + participants: userId, + }) + .populate("participants", "name email avatar") + .populate("lastMessage") + .sort({ lastMessageAt: -1 }); + + return conversations; + } + + async getMessages({ + conversationId, + userId, + page = 1, + limit = 30, + }: { + conversationId: string; + userId: string; + page?: number; + limit?: number; + }) { + const conversation = await Conversation.findById(conversationId); + if (!conversation) { + throw new Error("Conversation not found"); + } + + if (!conversation.participants.some((p: any) => p.toString() === userId)) { + throw new Error("You are not a participant in this conversation"); + } + + const skip = (page - 1) * limit; + const messages = await Message.find({ conversation: conversationId }) + .populate("sender", "name email avatar") + .populate("readBy", "name email avatar") + .sort({ createdAt: -1 }) + .skip(skip) + .limit(limit); + + const total = await Message.countDocuments({ conversation: conversationId }); + + return { + messages, + pagination: { + page, + limit, + total, + pages: Math.ceil(total / limit), + }, + }; + } + + async markAsRead({ + conversationId, + userId, + }: { + conversationId: string; + userId: string; + }) { + await Message.updateMany( + { + conversation: conversationId, + readBy: { $ne: userId }, + }, + { $addToSet: { readBy: userId } } + ); + + return { success: true }; + } +} + +export const messagingService = new MessagingService(); +export default messagingService; diff --git a/src/services/moderation.service.js b/src/services/moderation.service.js new file mode 100644 index 00000000..fb16a858 --- /dev/null +++ b/src/services/moderation.service.js @@ -0,0 +1,214 @@ +import ContentFlag from "../models/content-flag.model.js"; +import ModerationAction from "../models/moderation-action.model.js"; +import Reel from "../models/Reel.js"; +import Notification from "../models/Notification.js"; + +const DEFAULT_PROHIBITED_KEYWORDS = [ + "hate", + "scam", + "violence", + "abuse", + "explicit", + "spam", + "illegal", + "offensive", + "nude", + "porn", + "gambling", + "harassment", +]; + +export class ModerationService { + checkKeywords(text, keywords = DEFAULT_PROHIBITED_KEYWORDS) { + if (!text) return []; + const lowerText = text.toLowerCase(); + return keywords.filter((kw) => { + const regex = new RegExp(`\\b${kw}\\b`, "i"); + return regex.test(lowerText); + }); + } + + async autoFlagReel(reel) { + const textToScan = `${reel.title || ""} ${reel.description || ""} ${(reel.tags || []).join(" ")}`; + const matchedKeywords = this.checkKeywords(textToScan); + + if (matchedKeywords.length > 0) { + const flag = await ContentFlag.create({ + reel: reel._id, + reporter: null, + reason: "Auto-flagged keyword filter", + details: `Content matched flagged keywords: ${matchedKeywords.join(", ")}`, + status: "pending", + isAutoFlagged: true, + flaggedKeywords: matchedKeywords, + }); + + return flag; + } + + return null; + } + + async flagReel({ + reelId, + reporterId, + reason, + details, + }) { + const reel = await Reel.findById(reelId); + if (!reel) { + throw new Error("Reel not found"); + } + + const flag = await ContentFlag.create({ + reel: reelId, + reporter: reporterId, + reason, + details, + status: "pending", + isAutoFlagged: false, + }); + + return flag; + } + + async getModerationQueue({ + status = "pending", + page = 1, + limit = 20, + }) { + const query = {}; + if (status && status !== "all") { + query.status = status; + } + + const skip = (page - 1) * limit; + const [flags, total] = await Promise.all([ + ContentFlag.find(query) + .populate("reel") + .populate("reporter", "name email avatar") + .sort({ createdAt: -1 }) + .skip(skip) + .limit(limit), + ContentFlag.countDocuments(query), + ]); + + return { + flags, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }; + } + + async processModerationAction({ + flagId, + reelId, + adminId, + action, + notes, + }) { + let flag = null; + if (flagId) { + flag = await ContentFlag.findById(flagId); + } else if (reelId) { + flag = await ContentFlag.findOne({ reel: reelId, status: "pending" }); + } + + const targetReelId = flag ? flag.reel : reelId; + const reel = await Reel.findById(targetReelId); + if (!reel) { + throw new Error("Reel not found"); + } + + let updatedStatus = "pending"; + if (action === "approve") { + updatedStatus = "approved"; + } else if (action === "reject") { + updatedStatus = "rejected"; + } else if (action === "remove") { + updatedStatus = "removed"; + reel.status = "removed"; + reel.isRemoved = true; + await reel.save(); + } + + if (flag) { + flag.status = updatedStatus; + await flag.save(); + } + + const moderationAction = await ModerationAction.create({ + flag: flag ? flag._id : undefined, + reel: reel._id, + admin: adminId, + action, + notes, + }); + + const creatorId = reel.user || reel.author || reel.creator; + if (creatorId) { + const decisionText = + action === "remove" + ? "has been removed due to community guideline violations." + : action === "approve" + ? "has been reviewed and approved." + : "flag review has been resolved."; + + await Notification.create({ + recipient: creatorId, + sender: adminId, + type: "system", + title: "Reel Moderation Notice", + message: `Your reel "${reel.title || "Content"}" ${decisionText}`, + data: { reelId: reel._id }, + priority: action === "remove" ? "high" : "medium", + }).catch(() => {}); + } + + return { + flag, + moderationAction, + reel, + }; + } + + async getModerationHistory({ + reelId, + adminId, + page = 1, + limit = 20, + }) { + const query = {}; + if (reelId) query.reel = reelId; + if (adminId) query.admin = adminId; + + const skip = (page - 1) * limit; + const [actions, total] = await Promise.all([ + ModerationAction.find(query) + .populate("reel") + .populate("admin", "name email avatar") + .populate("flag") + .sort({ createdAt: -1 }) + .skip(skip) + .limit(limit), + ModerationAction.countDocuments(query), + ]); + + return { + actions, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }; + } +} + +export const moderationService = new ModerationService(); +export default moderationService; diff --git a/src/services/moderation.service.ts b/src/services/moderation.service.ts new file mode 100644 index 00000000..b0306435 --- /dev/null +++ b/src/services/moderation.service.ts @@ -0,0 +1,256 @@ +import ContentFlag from "../models/content-flag.model.ts"; +import ModerationAction from "../models/moderation-action.model.ts"; +import Reel from "../models/Reel.js"; +import Notification from "../models/Notification.js"; + +const DEFAULT_PROHIBITED_KEYWORDS = [ + "hate", + "scam", + "violence", + "abuse", + "explicit", + "spam", + "illegal", + "offensive", + "nude", + "porn", + "gambling", + "harassment", +]; + +export class ModerationService { + /** + * Check text content for prohibited keywords. + */ + checkKeywords(text: string, keywords: string[] = DEFAULT_PROHIBITED_KEYWORDS): string[] { + if (!text) return []; + const lowerText = text.toLowerCase(); + return keywords.filter((kw) => { + const regex = new RegExp(`\\b${kw}\\b`, "i"); + return regex.test(lowerText); + }); + } + + /** + * Automatically flag a reel if it contains inappropriate keyword content. + */ + async autoFlagReel(reel: any) { + const textToScan = `${reel.title || ""} ${reel.description || ""} ${(reel.tags || []).join(" ")}`; + const matchedKeywords = this.checkKeywords(textToScan); + + if (matchedKeywords.length > 0) { + const flag = await ContentFlag.create({ + reel: reel._id, + reporter: null, + reason: "Auto-flagged keyword filter", + details: `Content matched flagged keywords: ${matchedKeywords.join(", ")}`, + status: "pending", + isAutoFlagged: true, + flaggedKeywords: matchedKeywords, + }); + + return flag; + } + + return null; + } + + /** + * User flags a reel for inappropriate content. + */ + async flagReel({ + reelId, + reporterId, + reason, + details, + }: { + reelId: string; + reporterId: string; + reason: string; + details?: string; + }) { + const reel = await Reel.findById(reelId); + if (!reel) { + throw new Error("Reel not found"); + } + + const flag = await ContentFlag.create({ + reel: reelId, + reporter: reporterId, + reason, + details, + status: "pending", + isAutoFlagged: false, + }); + + return flag; + } + + /** + * Fetch admin moderation queue for flagged reels. + */ + async getModerationQueue({ + status = "pending", + page = 1, + limit = 20, + }: { + status?: string; + page?: number; + limit?: number; + }) { + const query: any = {}; + if (status && status !== "all") { + query.status = status; + } + + const skip = (page - 1) * limit; + const [flags, total] = await Promise.all([ + ContentFlag.find(query) + .populate("reel") + .populate("reporter", "name email avatar") + .sort({ createdAt: -1 }) + .skip(skip) + .limit(limit), + ContentFlag.countDocuments(query), + ]); + + return { + flags, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }; + } + + /** + * Process a moderation action (approve, reject, remove) on a flagged reel. + */ + async processModerationAction({ + flagId, + reelId, + adminId, + action, + notes, + }: { + flagId?: string; + reelId?: string; + adminId: string; + action: "approve" | "reject" | "remove"; + notes?: string; + }) { + let flag: any = null; + if (flagId) { + flag = await ContentFlag.findById(flagId); + } else if (reelId) { + flag = await ContentFlag.findOne({ reel: reelId, status: "pending" }); + } + + const targetReelId = flag ? flag.reel : reelId; + const reel = await Reel.findById(targetReelId); + if (!reel) { + throw new Error("Reel not found"); + } + + let updatedStatus = "pending"; + if (action === "approve") { + updatedStatus = "approved"; + } else if (action === "reject") { + updatedStatus = "rejected"; + } else if (action === "remove") { + updatedStatus = "removed"; + // Perform content removal (mark reel removed / inactive or delete) + reel.status = "removed"; + reel.isRemoved = true; + await reel.save(); + } + + if (flag) { + flag.status = updatedStatus; + await flag.save(); + } + + const moderationAction = await ModerationAction.create({ + flag: flag ? flag._id : undefined, + reel: reel._id, + admin: adminId, + action, + notes, + }); + + // Notify content creator of moderation decision + const creatorId = reel.user || reel.author || reel.creator; + if (creatorId) { + const decisionText = + action === "remove" + ? "has been removed due to community guideline violations." + : action === "approve" + ? "has been reviewed and approved." + : "flag review has been resolved."; + + await Notification.create({ + recipient: creatorId, + sender: adminId, + type: "system", + title: "Reel Moderation Notice", + message: `Your reel "${reel.title || "Content"}" ${decisionText}`, + data: { reelId: reel._id }, + priority: action === "remove" ? "high" : "medium", + }).catch((err) => { + // Notification creation logging fallback + }); + } + + return { + flag, + moderationAction, + reel, + }; + } + + /** + * Retrieve moderation history and audit log. + */ + async getModerationHistory({ + reelId, + adminId, + page = 1, + limit = 20, + }: { + reelId?: string; + adminId?: string; + page?: number; + limit?: number; + }) { + const query: any = {}; + if (reelId) query.reel = reelId; + if (adminId) query.admin = adminId; + + const skip = (page - 1) * limit; + const [actions, total] = await Promise.all([ + ModerationAction.find(query) + .populate("reel") + .populate("admin", "name email avatar") + .populate("flag") + .sort({ createdAt: -1 }) + .skip(skip) + .limit(limit), + ModerationAction.countDocuments(query), + ]); + + return { + actions, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }; + } +} + +export const moderationService = new ModerationService(); +export default moderationService; diff --git a/src/services/pledgeService.js b/src/services/pledgeService.js new file mode 100644 index 00000000..17d2603e --- /dev/null +++ b/src/services/pledgeService.js @@ -0,0 +1,56 @@ +import mongoose from "mongoose"; + +import Pledge from "../models/Pledge.js"; +import PledgeCycle from "../models/PledgeCycle.js"; + +const STROOPS_PER_UNIT = 10000000n; +const toStroops = (amount) => { + const [whole, fraction = ""] = amount.toString().split("."); + return BigInt(whole || "0") * STROOPS_PER_UNIT + BigInt((fraction + "0000000").slice(0, 7)); +}; + +const DAY_MS = 24 * 60 * 60 * 1000; + +export const addPledgeCadence = (date, pledge) => { + const source = new Date(date); + if (pledge.cadence === "daily") return new Date(source.getTime() + DAY_MS); + if (pledge.cadence === "weekly") return new Date(source.getTime() + 7 * DAY_MS); + const year = source.getUTCFullYear(); + const month = source.getUTCMonth() + 1; + const lastDay = new Date(Date.UTC(year, month + 1, 0)).getUTCDate(); + const day = Math.min(pledge.anchorDate || source.getUTCDate(), lastDay); + return new Date(Date.UTC(year, month, day, source.getUTCHours(), source.getUTCMinutes(), source.getUTCSeconds(), source.getUTCMilliseconds())); +}; + +export const firstDueAt = ({ cadence, anchorDay, anchorDate, startAt = new Date() }) => { + const start = new Date(startAt); + if (cadence === "daily") return start; + if (cadence === "weekly") { + const target = anchorDay ?? start.getUTCDay(); + const delta = (target - start.getUTCDay() + 7) % 7; + return new Date(start.getTime() + delta * DAY_MS); + } + const target = anchorDate ?? start.getUTCDate(); + const lastDay = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth() + 1, 0)).getUTCDate(); + if (start.getUTCDate() <= Math.min(target, lastDay)) { + return new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), Math.min(target, lastDay), start.getUTCHours(), start.getUTCMinutes(), start.getUTCSeconds(), start.getUTCMilliseconds())); + } + return addPledgeCadence(start, { cadence, anchorDate: target }); +}; + +export const markPledgeTransactionPaid = async (transaction, paidAt = new Date()) => { + if (!mongoose.Types.ObjectId.isValid(transaction?._id)) return null; + const cycle = await PledgeCycle.findOne({ transaction: transaction._id, status: { $ne: "paid" } }); + if (!cycle) return null; + cycle.status = "paid"; + await cycle.save(); + const pledge = await Pledge.findById(cycle.pledge); + if (!pledge) return cycle; + const consecutivePaid = pledge.consecutivePaid + 1; + pledge.consecutivePaid = consecutivePaid; + pledge.longestStreak = Math.max(pledge.longestStreak, consecutivePaid); + pledge.totalPaidStroops = (BigInt(pledge.totalPaidStroops || "0") + toStroops(transaction.amount)).toString(); + pledge.lastPaidAt = paidAt; + await pledge.save(); + return cycle; +}; diff --git a/src/services/reading-group.service.js b/src/services/reading-group.service.js new file mode 100644 index 00000000..0b44b00c --- /dev/null +++ b/src/services/reading-group.service.js @@ -0,0 +1,280 @@ +import ReadingGroup from "../models/reading-group.model.js"; +import ReadingGroupMember from "../models/reading-group-member.model.js"; +import Book from "../models/Book.js"; +import Notification from "../models/Notification.js"; + +export class ReadingGroupService { + async createGroup({ + name, + description, + bookId, + creatorId, + privacy = "public", + chaptersPerWeek = 1, + readingSchedule = [], + }) { + const book = await Book.findById(bookId); + if (!book) { + throw new Error("Book not found"); + } + + const group = await ReadingGroup.create({ + name, + description, + book: bookId, + creator: creatorId, + privacy, + chaptersPerWeek, + readingSchedule, + }); + + await ReadingGroupMember.create({ + group: group._id, + user: creatorId, + role: "admin", + status: "active", + }); + + return group; + } + + async getGroups({ + bookId, + privacy, + search, + page = 1, + limit = 20, + }) { + const query = {}; + if (bookId) query.book = bookId; + if (privacy) query.privacy = privacy; + if (search && search.trim() !== "") { + const regex = new RegExp(search, "i"); + query.$or = [{ name: regex }, { description: regex }]; + } + + const skip = (page - 1) * limit; + const [groups, total] = await Promise.all([ + ReadingGroup.find(query) + .populate("book", "title author thumbnail") + .populate("creator", "name email avatar") + .sort({ createdAt: -1 }) + .skip(skip) + .limit(limit), + ReadingGroup.countDocuments(query), + ]); + + return { + groups, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }; + } + + async getGroupDetails(groupId, userId) { + const group = await ReadingGroup.findById(groupId) + .populate("book", "title author thumbnail totalPages chapters") + .populate("creator", "name email avatar"); + + if (!group) { + throw new Error("Reading group not found"); + } + + const membersCount = await ReadingGroupMember.countDocuments({ group: group._id, status: "active" }); + + let userMembership = null; + if (userId) { + userMembership = await ReadingGroupMember.findOne({ group: group._id, user: userId }); + } + + return { + group, + membersCount, + userMembership, + }; + } + + async joinGroup(groupId, userId) { + const group = await ReadingGroup.findById(groupId); + if (!group) { + throw new Error("Reading group not found"); + } + + const status = group.privacy === "private" ? "pending" : "active"; + + const membership = await ReadingGroupMember.findOneAndUpdate( + { group: groupId, user: userId }, + { + role: "member", + status, + }, + { upsert: true, new: true, setDefaultsOnInsert: true } + ); + + return membership; + } + + async inviteMember(groupId, adminId, targetUserId) { + const group = await ReadingGroup.findById(groupId); + if (!group) { + throw new Error("Reading group not found"); + } + + const adminMember = await ReadingGroupMember.findOne({ group: groupId, user: adminId, role: "admin" }); + const isCreator = group.creator.toString() === adminId.toString(); + + if (!adminMember && !isCreator) { + throw new Error("Only group admins can invite members"); + } + + const membership = await ReadingGroupMember.findOneAndUpdate( + { group: groupId, user: targetUserId }, + { + role: "member", + status: "invited", + }, + { upsert: true, new: true, setDefaultsOnInsert: true } + ); + + await Notification.create({ + recipient: targetUserId, + sender: adminId, + type: "system", + title: "Reading Group Invitation", + message: `You have been invited to join the reading group "${group.name}".`, + data: { bookId: group.book }, + priority: "medium", + }).catch(() => {}); + + return membership; + } + + async updateSchedule( + groupId, + adminId, + readingSchedule, + chaptersPerWeek + ) { + const group = await ReadingGroup.findById(groupId); + if (!group) { + throw new Error("Reading group not found"); + } + + const adminMember = await ReadingGroupMember.findOne({ group: groupId, user: adminId, role: "admin" }); + const isCreator = group.creator.toString() === adminId.toString(); + + if (!adminMember && !isCreator) { + throw new Error("Only group admins can update the reading schedule"); + } + + if (readingSchedule) { + group.readingSchedule = readingSchedule; + } + if (chaptersPerWeek !== undefined) { + group.chaptersPerWeek = chaptersPerWeek; + } + + await group.save(); + return group; + } + + async addDiscussionPost({ + groupId, + chapter, + userId, + content, + }) { + const group = await ReadingGroup.findById(groupId); + if (!group) { + throw new Error("Reading group not found"); + } + + const member = await ReadingGroupMember.findOne({ group: groupId, user: userId, status: "active" }); + if (!member) { + throw new Error("You must be an active member of this group to post discussions"); + } + + const post = { + chapter, + user: userId, + content, + createdAt: new Date(), + }; + + group.discussions.push(post); + await group.save(); + + return group.discussions; + } + + async getDiscussions(groupId, chapter) { + const group = await ReadingGroup.findById(groupId).populate("discussions.user", "name email avatar"); + if (!group) { + throw new Error("Reading group not found"); + } + + let discussions = group.discussions; + if (chapter !== undefined && !isNaN(chapter)) { + discussions = discussions.filter((d) => d.chapter === Number(chapter)); + } + + return discussions; + } + + async updateMemberProgress({ + groupId, + userId, + currentChapter, + currentProgressPercent, + }) { + const member = await ReadingGroupMember.findOne({ group: groupId, user: userId }); + if (!member) { + throw new Error("Member not found in reading group"); + } + + if (currentChapter !== undefined) member.currentChapter = currentChapter; + if (currentProgressPercent !== undefined) member.currentProgressPercent = currentProgressPercent; + member.lastReadDate = new Date(); + + await member.save(); + return member; + } + + async getMemberProgressDashboard(groupId) { + const group = await ReadingGroup.findById(groupId).populate("book", "title totalPages"); + if (!group) { + throw new Error("Reading group not found"); + } + + const members = await ReadingGroupMember.find({ group: groupId, status: "active" }) + .populate("user", "name email avatar") + .sort({ currentProgressPercent: -1, currentChapter: -1 }); + + const totalMembers = members.length; + const avgProgress = + totalMembers > 0 + ? Number((members.reduce((sum, m) => sum + m.currentProgressPercent, 0) / totalMembers).toFixed(1)) + : 0; + + return { + group: { + _id: group._id, + name: group.name, + book: group.book, + chaptersPerWeek: group.chaptersPerWeek, + }, + stats: { + totalMembers, + avgProgressPercent: avgProgress, + }, + membersProgress: members, + }; + } +} + +export const readingGroupService = new ReadingGroupService(); +export default readingGroupService; diff --git a/src/services/reading-group.service.ts b/src/services/reading-group.service.ts new file mode 100644 index 00000000..00ea834a --- /dev/null +++ b/src/services/reading-group.service.ts @@ -0,0 +1,335 @@ +import ReadingGroup from "../models/reading-group.model.ts"; +import ReadingGroupMember from "../models/reading-group-member.model.ts"; +import Book from "../models/Book.js"; +import Notification from "../models/Notification.js"; + +export class ReadingGroupService { + /** + * Create a reading group / book club for a specific book. + */ + async createGroup({ + name, + description, + bookId, + creatorId, + privacy = "public", + chaptersPerWeek = 1, + readingSchedule = [], + }: { + name: string; + description?: string; + bookId: string; + creatorId: string; + privacy?: "public" | "private"; + chaptersPerWeek?: number; + readingSchedule?: Array<{ chapter: number; title?: string; targetPages?: string; startDate?: Date; endDate?: Date }>; + }) { + const book = await Book.findById(bookId); + if (!book) { + throw new Error("Book not found"); + } + + const group = await ReadingGroup.create({ + name, + description, + book: bookId, + creator: creatorId, + privacy, + chaptersPerWeek, + readingSchedule, + }); + + // Automatically add creator as admin member + await ReadingGroupMember.create({ + group: group._id, + user: creatorId, + role: "admin", + status: "active", + }); + + return group; + } + + /** + * List reading groups with search & filter options. + */ + async getGroups({ + bookId, + privacy, + search, + page = 1, + limit = 20, + }: { + bookId?: string; + privacy?: string; + search?: string; + page?: number; + limit?: number; + }) { + const query: any = {}; + if (bookId) query.book = bookId; + if (privacy) query.privacy = privacy; + if (search && search.trim() !== "") { + const regex = new RegExp(search, "i"); + query.$or = [{ name: regex }, { description: regex }]; + } + + const skip = (page - 1) * limit; + const [groups, total] = await Promise.all([ + ReadingGroup.find(query) + .populate("book", "title author thumbnail") + .populate("creator", "name email avatar") + .sort({ createdAt: -1 }) + .skip(skip) + .limit(limit), + ReadingGroup.countDocuments(query), + ]); + + return { + groups, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }; + } + + /** + * Get detailed info for a reading group. + */ + async getGroupDetails(groupId: string, userId?: string) { + const group = await ReadingGroup.findById(groupId) + .populate("book", "title author thumbnail totalPages chapters") + .populate("creator", "name email avatar"); + + if (!group) { + throw new Error("Reading group not found"); + } + + const membersCount = await ReadingGroupMember.countDocuments({ group: group._id, status: "active" }); + + let userMembership = null; + if (userId) { + userMembership = await ReadingGroupMember.findOne({ group: group._id, user: userId }); + } + + return { + group, + membersCount, + userMembership, + }; + } + + /** + * Join a reading group or submit join request. + */ + async joinGroup(groupId: string, userId: string) { + const group = await ReadingGroup.findById(groupId); + if (!group) { + throw new Error("Reading group not found"); + } + + const status = group.privacy === "private" ? "pending" : "active"; + + const membership = await ReadingGroupMember.findOneAndUpdate( + { group: groupId, user: userId }, + { + role: "member", + status, + }, + { upsert: true, new: true, setDefaultsOnInsert: true } + ); + + return membership; + } + + /** + * Invite a user to a reading group (Group admin only). + */ + async inviteMember(groupId: string, adminId: string, targetUserId: string) { + const group = await ReadingGroup.findById(groupId); + if (!group) { + throw new Error("Reading group not found"); + } + + const adminMember = await ReadingGroupMember.findOne({ group: groupId, user: adminId, role: "admin" }); + const isCreator = group.creator.toString() === adminId.toString(); + + if (!adminMember && !isCreator) { + throw new Error("Only group admins can invite members"); + } + + const membership = await ReadingGroupMember.findOneAndUpdate( + { group: groupId, user: targetUserId }, + { + role: "member", + status: "invited", + }, + { upsert: true, new: true, setDefaultsOnInsert: true } + ); + + await Notification.create({ + recipient: targetUserId, + sender: adminId, + type: "system", + title: "Reading Group Invitation", + message: `You have been invited to join the reading group "${group.name}".`, + data: { bookId: group.book }, + priority: "medium", + }).catch(() => {}); + + return membership; + } + + /** + * Update reading schedule (chapters per week, target dates/pages). + */ + async updateSchedule( + groupId: string, + adminId: string, + readingSchedule: Array<{ chapter: number; title?: string; targetPages?: string; startDate?: Date; endDate?: Date }>, + chaptersPerWeek?: number + ) { + const group = await ReadingGroup.findById(groupId); + if (!group) { + throw new Error("Reading group not found"); + } + + const adminMember = await ReadingGroupMember.findOne({ group: groupId, user: adminId, role: "admin" }); + const isCreator = group.creator.toString() === adminId.toString(); + + if (!adminMember && !isCreator) { + throw new Error("Only group admins can update the reading schedule"); + } + + if (readingSchedule) { + group.readingSchedule = readingSchedule as any; + } + if (chaptersPerWeek !== undefined) { + group.chaptersPerWeek = chaptersPerWeek; + } + + await group.save(); + return group; + } + + /** + * Add a post to a chapter discussion thread. + */ + async addDiscussionPost({ + groupId, + chapter, + userId, + content, + }: { + groupId: string; + chapter: number; + userId: string; + content: string; + }) { + const group = await ReadingGroup.findById(groupId); + if (!group) { + throw new Error("Reading group not found"); + } + + const member = await ReadingGroupMember.findOne({ group: groupId, user: userId, status: "active" }); + if (!member) { + throw new Error("You must be an active member of this group to post discussions"); + } + + const post = { + chapter, + user: userId, + content, + createdAt: new Date(), + }; + + group.discussions.push(post as any); + await group.save(); + + return group.discussions; + } + + /** + * Get discussion threads per chapter. + */ + async getDiscussions(groupId: string, chapter?: number) { + const group = await ReadingGroup.findById(groupId).populate("discussions.user", "name email avatar"); + if (!group) { + throw new Error("Reading group not found"); + } + + let discussions = group.discussions; + if (chapter !== undefined && !isNaN(chapter)) { + discussions = discussions.filter((d: any) => d.chapter === Number(chapter)) as any; + } + + return discussions; + } + + /** + * Update individual member reading progress in group. + */ + async updateMemberProgress({ + groupId, + userId, + currentChapter, + currentProgressPercent, + }: { + groupId: string; + userId: string; + currentChapter?: number; + currentProgressPercent?: number; + }) { + const member = await ReadingGroupMember.findOne({ group: groupId, user: userId }); + if (!member) { + throw new Error("Member not found in reading group"); + } + + if (currentChapter !== undefined) member.currentChapter = currentChapter; + if (currentProgressPercent !== undefined) member.currentProgressPercent = currentProgressPercent; + member.lastReadDate = new Date(); + + await member.save(); + return member; + } + + /** + * Track member progress in group dashboard. + */ + async getMemberProgressDashboard(groupId: string) { + const group = await ReadingGroup.findById(groupId).populate("book", "title totalPages"); + if (!group) { + throw new Error("Reading group not found"); + } + + const members = await ReadingGroupMember.find({ group: groupId, status: "active" }) + .populate("user", "name email avatar") + .sort({ currentProgressPercent: -1, currentChapter: -1 }); + + const totalMembers = members.length; + const avgProgress = + totalMembers > 0 + ? Number((members.reduce((sum, m) => sum + m.currentProgressPercent, 0) / totalMembers).toFixed(1)) + : 0; + + return { + group: { + _id: group._id, + name: group.name, + book: group.book, + chaptersPerWeek: group.chaptersPerWeek, + }, + stats: { + totalMembers, + avgProgressPercent: avgProgress, + }, + membersProgress: members, + }; + } +} + +export const readingGroupService = new ReadingGroupService(); +export default readingGroupService; diff --git a/src/services/reading-progress.service.js b/src/services/reading-progress.service.js new file mode 100644 index 00000000..e13d1261 --- /dev/null +++ b/src/services/reading-progress.service.js @@ -0,0 +1,109 @@ +import ReadingProgress from "../models/ReadingProgress.js"; +import Book from "../models/Book.js"; +import { emitProgress } from "../sockets/reading-progress.socket.js"; + +/** + * ReadingProgressService + * + * Persists one progress record per user + book (upsert), exposes a resume + * endpoint and augments a user's book-library listing with progress %. Mirrors + * the class-based service style used across the codebase (e.g. HighlightService, + * SpacePollService). + */ +export class ReadingProgressService { + /** + * Derive a 0-100 percentage. Prefers an explicit percentage; otherwise + * computes it from page / totalPages when both are known. + */ + computePercentage({ percentage, page, totalPages }) { + if (percentage !== undefined && percentage !== null) { + return Math.min(100, Math.max(0, Number(percentage))); + } + if (totalPages && totalPages > 0 && page !== undefined && page !== null) { + return Math.min(100, Math.max(0, Number(((page / totalPages) * 100).toFixed(2)))); + } + return undefined; + } + + /** + * Create or update the reading-progress record for a user + book. Upserts so + * there is never more than one record per combination, bumps `version` and + * (via timestamps) `updatedAt`, then emits a real-time sync event to the + * user's other devices when a socket layer is attached. + */ + async upsertProgress({ userId, bookId, page, totalPages, percentage, lastPosition, device }) { + const book = await Book.findById(bookId).select("_id"); + if (!book) { + throw new Error("Book not found"); + } + + const set = {}; + if (page !== undefined && page !== null) set.page = page; + if (totalPages !== undefined && totalPages !== null) set.totalPages = totalPages; + if (lastPosition !== undefined && lastPosition !== null) set.lastPosition = lastPosition; + if (device !== undefined && device !== null) set.device = device; + + const computed = this.computePercentage({ percentage, page, totalPages }); + if (computed !== undefined) { + set.percentage = computed; + if (computed >= 100) { + set.completedAt = new Date(); + } + } + + const progress = await ReadingProgress.findOneAndUpdate( + { user: userId, book: bookId }, + { $set: set, $inc: { version: 1 } }, + { upsert: true, new: true, setDefaultsOnInsert: true } + ); + + // Push to the user's other devices (no-op if no socket server is wired). + emitProgress(userId, { + book: bookId, + page: progress.page, + percentage: progress.percentage, + lastPosition: progress.lastPosition, + version: progress.version, + updatedAt: progress.updatedAt, + device: progress.device, + }); + + return progress; + } + + /** + * Get the stored progress for a user + book so the reader can resume from the + * last position. Returns null when there is no record yet. + */ + async getProgress({ userId, bookId }) { + return ReadingProgress.findOne({ user: userId, book: bookId }); + } + + /** + * Return the user's reading library augmented with progress. Each entry pairs + * the populated book with its progress percentage / last position so the + * frontend can display a progress bar on the library view. + */ + async getLibraryWithProgress({ userId }) { + const records = await ReadingProgress.find({ user: userId }) + .sort({ updatedAt: -1 }) + .populate("book", "title author image category price"); + + return records + // A book may have been deleted after progress was stored. + .filter((record) => record.book) + .map((record) => ({ + book: record.book, + page: record.page, + totalPages: record.totalPages, + percentage: record.percentage, + lastPosition: record.lastPosition, + version: record.version, + completedAt: record.completedAt, + updatedAt: record.updatedAt, + })); + } +} + +export const readingProgressService = new ReadingProgressService(); +export default readingProgressService; diff --git a/src/services/reelDuetService.js b/src/services/reelDuetService.js new file mode 100644 index 00000000..63736d3c --- /dev/null +++ b/src/services/reelDuetService.js @@ -0,0 +1,148 @@ +// services/reelDuetService.js +// +// Business logic for duet/stitch response videos. A derivative reel is a normal +// reel that additionally links back to an original reel via `originalReelId` +// and records its `duetType`. Creating one increments the derivative counter on +// the original so it can be surfaced on the original reel. + +import mongoose from "mongoose"; +import Reel from "../models/Reel.js"; +import { + isDuetType, + buildCompositionDescriptor, + normalizeStitchClip, +} from "../utils/videoCompositor.js"; + +const httpError = (message, statusCode) => + Object.assign(new Error(message), { statusCode }); + +/** + * Create a duet/stitch response reel linked to an original reel. + * + * @param {Object} params + * @param {string} params.originalReelId + * @param {"duet"|"stitch"} params.type + * @param {string} params.userId - author of the response + * @param {string} params.description + * @param {string} [params.category] + * @param {string[]} [params.tags] + * @param {string} params.video - uploaded response video URL + * @param {string} [params.videoPublicId] + * @param {string} [params.thumbnail] + * @param {number} [params.duration] + * @param {Object} [params.clip] - stitch clip range ({ start, end }) + * @returns {Promise} the created derivative reel + */ +export const createReelDerivative = async ({ + originalReelId, + type, + userId, + description, + category, + tags, + video, + videoPublicId, + thumbnail, + duration, + clip, +}) => { + if (!isDuetType(type)) { + throw httpError("type must be one of: duet, stitch", 400); + } + if (!mongoose.Types.ObjectId.isValid(originalReelId)) { + throw httpError("A valid original reel id is required", 400); + } + + const original = await Reel.findById(originalReelId).select( + "_id video duration" + ); + if (!original) { + throw httpError("Original reel not found", 404); + } + + const stitchClip = type === "stitch" ? normalizeStitchClip(clip) : null; + if (type === "stitch" && !stitchClip) { + throw httpError( + "A stitch requires a valid clip range ({ start, end } in seconds, end > start)", + 400 + ); + } + + const composition = buildCompositionDescriptor({ + type, + original, + response: { video, duration }, + clip: stitchClip, + }); + + const derivative = await Reel.create({ + description, + category, + tags: Array.isArray(tags) ? tags : [], + video, + videoPublicId, + thumbnail, + duration, + createdBy: userId, + originalReelId: original._id, + duetType: type, + stitchClip: stitchClip || undefined, + composition, + }); + + // Increment the derivative counter surfaced on the original reel. + const counterField = type === "duet" ? "duetCount" : "stitchCount"; + await Reel.updateOne( + { _id: original._id }, + { $inc: { [counterField]: 1 } } + ); + + return derivative; +}; + +/** + * List duet/stitch derivatives for a given reel, paginated (newest first). + * + * @param {string} originalReelId + * @param {Object} [options] + * @param {number} [options.page=1] + * @param {number} [options.limit=10] + * @param {"duet"|"stitch"} [options.type] - optional filter + * @returns {Promise<{items: Object[], page: number, limit: number, total: number, hasMore: boolean}>} + */ +export const listReelDerivatives = async ( + originalReelId, + { page = 1, limit = 10, type } = {} +) => { + const safePage = Math.max(parseInt(page, 10) || 1, 1); + const safeLimit = Math.min(Math.max(parseInt(limit, 10) || 10, 1), 50); + const skip = (safePage - 1) * safeLimit; + + const filter = { originalReelId }; + if (isDuetType(type)) { + filter.duetType = type; + } + + const [items, total] = await Promise.all([ + Reel.find(filter) + .sort({ createdAt: -1 }) + .skip(skip) + .limit(safeLimit) + .populate("createdBy", "name avatar") + .lean(), + Reel.countDocuments(filter), + ]); + + return { + items, + page: safePage, + limit: safeLimit, + total, + hasMore: skip + items.length < total, + }; +}; + +export default { + createReelDerivative, + listReelDerivatives, +}; diff --git a/src/services/space-poll.service.js b/src/services/space-poll.service.js new file mode 100644 index 00000000..cc32aa62 --- /dev/null +++ b/src/services/space-poll.service.js @@ -0,0 +1,177 @@ +import SpacePoll from "../models/space-poll.model.js"; +import PollVote from "../models/poll-vote.model.js"; +import Space from "../models/Space.js"; + +export class SpacePollService { + async createPoll({ spaceId, hostId, question, options }) { + const space = await Space.findById(spaceId); + if (!space) { + throw new Error("Space not found"); + } + + const isHost = space.host.toString() === hostId.toString(); + if (!isHost) { + throw new Error("Only the host can create polls for this space"); + } + + if (!options || !Array.isArray(options) || options.length < 2) { + throw new Error("At least two options are required to create a poll"); + } + + const formattedOptions = options.map((opt, index) => { + if (typeof opt === "string") { + return { optionIndex: index, text: opt }; + } + return { optionIndex: index, text: opt.text }; + }); + + const poll = await SpacePoll.create({ + space: spaceId, + creator: hostId, + question, + options: formattedOptions, + status: "active", + }); + + return this.getPollResults(poll._id.toString()); + } + + async voteInPoll({ pollId, userId, optionIndex }) { + const poll = await SpacePoll.findById(pollId); + if (!poll) { + throw new Error("Poll not found"); + } + + if (poll.status !== "active") { + throw new Error("Poll is closed for voting"); + } + + const validOption = poll.options.some((o) => o.optionIndex === optionIndex); + if (!validOption) { + throw new Error("Invalid option selected"); + } + + await PollVote.findOneAndUpdate( + { poll: poll._id, user: userId }, + { space: poll.space, optionIndex }, + { upsert: true, new: true, setDefaultsOnInsert: true } + ); + + return this.getPollResults(poll._id.toString(), userId); + } + + async getPollResults(pollId, userId) { + const poll = await SpacePoll.findById(pollId).populate("creator", "name email avatar"); + if (!poll) { + throw new Error("Poll not found"); + } + + const votes = await PollVote.find({ poll: poll._id }); + const totalVotes = votes.length; + + const voteCounts = {}; + poll.options.forEach((o) => { + voteCounts[o.optionIndex] = 0; + }); + + votes.forEach((v) => { + if (voteCounts[v.optionIndex] !== undefined) { + voteCounts[v.optionIndex] += 1; + } + }); + + const results = poll.options.map((o) => { + const count = voteCounts[o.optionIndex] || 0; + const percentage = totalVotes > 0 ? Number(((count / totalVotes) * 100).toFixed(1)) : 0; + return { + optionIndex: o.optionIndex, + text: o.text, + votes: count, + percentage, + }; + }); + + let userVote = null; + if (userId) { + const existingVote = votes.find((v) => v.user.toString() === userId.toString()); + if (existingVote) { + userVote = existingVote.optionIndex; + } + } + + return { + _id: poll._id, + space: poll.space, + creator: poll.creator, + question: poll.question, + status: poll.status, + closedAt: poll.closedAt, + createdAt: poll.createdAt, + updatedAt: poll.updatedAt, + totalVotes, + results, + userVote, + }; + } + + async getSpacePolls(spaceId, userId) { + const polls = await SpacePoll.find({ space: spaceId }).sort({ createdAt: -1 }); + const pollResults = await Promise.all( + polls.map((poll) => this.getPollResults(poll._id.toString(), userId)) + ); + return pollResults; + } + + async closePoll({ pollId, hostId }) { + const poll = await SpacePoll.findById(pollId); + if (!poll) { + throw new Error("Poll not found"); + } + + const space = await Space.findById(poll.space); + const isHost = space && space.host.toString() === hostId.toString(); + const isCreator = poll.creator.toString() === hostId.toString(); + + if (!isHost && !isCreator) { + throw new Error("Only the space host or poll creator can close this poll"); + } + + poll.status = "closed"; + poll.closedAt = new Date(); + await poll.save(); + + return this.getPollResults(poll._id.toString()); + } + + async exportPollResults(pollId) { + const pollData = await this.getPollResults(pollId); + const votes = await PollVote.find({ poll: pollId }).populate("user", "name email"); + + const csvHeader = "Option Index,Option Text,Votes,Percentage\n"; + const csvRows = pollData.results + .map((r) => `"${r.optionIndex}","${r.text.replace(/"/g, '""')}",${r.votes},${r.percentage}%`) + .join("\n"); + + const exportSummary = { + pollId: pollData._id, + spaceId: pollData.space, + question: pollData.question, + status: pollData.status, + totalVotes: pollData.totalVotes, + createdAt: pollData.createdAt, + closedAt: pollData.closedAt, + results: pollData.results, + votes: votes.map((v) => ({ + user: v.user, + optionIndex: v.optionIndex, + votedAt: v.createdAt, + })), + csvData: csvHeader + csvRows, + }; + + return exportSummary; + } +} + +export const spacePollService = new SpacePollService(); +export default spacePollService; diff --git a/src/services/space-poll.service.ts b/src/services/space-poll.service.ts new file mode 100644 index 00000000..1a3e9d98 --- /dev/null +++ b/src/services/space-poll.service.ts @@ -0,0 +1,204 @@ +import SpacePoll from "../models/space-poll.model.ts"; +import PollVote from "../models/poll-vote.model.ts"; +import Space from "../models/Space.js"; + +export class SpacePollService { + /** + * Create a new poll in a space session. + */ + async createPoll({ spaceId, hostId, question, options }: { + spaceId: string; + hostId: string; + question: string; + options: string[] | { optionIndex?: number; text: string }[]; + }) { + const space = await Space.findById(spaceId); + if (!space) { + throw new Error("Space not found"); + } + + const isHost = space.host.toString() === hostId.toString(); + if (!isHost) { + throw new Error("Only the host can create polls for this space"); + } + + if (!options || !Array.isArray(options) || options.length < 2) { + throw new Error("At least two options are required to create a poll"); + } + + const formattedOptions = options.map((opt, index) => { + if (typeof opt === "string") { + return { optionIndex: index, text: opt }; + } + return { optionIndex: index, text: opt.text }; + }); + + const poll = await SpacePoll.create({ + space: spaceId, + creator: hostId, + question, + options: formattedOptions, + status: "active", + }); + + return this.getPollResults(poll._id.toString()); + } + + /** + * Vote in a poll during a space session. + */ + async voteInPoll({ pollId, userId, optionIndex }: { + pollId: string; + userId: string; + optionIndex: number; + }) { + const poll = await SpacePoll.findById(pollId); + if (!poll) { + throw new Error("Poll not found"); + } + + if (poll.status !== "active") { + throw new Error("Poll is closed for voting"); + } + + const validOption = poll.options.some((o: any) => o.optionIndex === optionIndex); + if (!validOption) { + throw new Error("Invalid option selected"); + } + + await PollVote.findOneAndUpdate( + { poll: poll._id, user: userId }, + { space: poll.space, optionIndex }, + { upsert: true, new: true, setDefaultsOnInsert: true } + ); + + return this.getPollResults(poll._id.toString(), userId); + } + + /** + * Get live poll results with counts and percentages. + */ + async getPollResults(pollId: string, userId?: string) { + const poll = await SpacePoll.findById(pollId).populate("creator", "name email avatar"); + if (!poll) { + throw new Error("Poll not found"); + } + + const votes = await PollVote.find({ poll: poll._id }); + const totalVotes = votes.length; + + const voteCounts: Record = {}; + poll.options.forEach((o: any) => { + voteCounts[o.optionIndex] = 0; + }); + + votes.forEach((v: any) => { + if (voteCounts[v.optionIndex] !== undefined) { + voteCounts[v.optionIndex] += 1; + } + }); + + const results = poll.options.map((o: any) => { + const count = voteCounts[o.optionIndex] || 0; + const percentage = totalVotes > 0 ? Number(((count / totalVotes) * 100).toFixed(1)) : 0; + return { + optionIndex: o.optionIndex, + text: o.text, + votes: count, + percentage, + }; + }); + + let userVote: number | null = null; + if (userId) { + const existingVote = votes.find((v: any) => v.user.toString() === userId.toString()); + if (existingVote) { + userVote = existingVote.optionIndex; + } + } + + return { + _id: poll._id, + space: poll.space, + creator: poll.creator, + question: poll.question, + status: poll.status, + closedAt: poll.closedAt, + createdAt: (poll as any).createdAt, + updatedAt: (poll as any).updatedAt, + totalVotes, + results, + userVote, + }; + } + + /** + * Get all polls for a space session with live vote results. + */ + async getSpacePolls(spaceId: string, userId?: string) { + const polls = await SpacePoll.find({ space: spaceId }).sort({ createdAt: -1 }); + const pollResults = await Promise.all( + polls.map((poll) => this.getPollResults(poll._id.toString(), userId)) + ); + return pollResults; + } + + /** + * Close a poll to stop accepting new votes. + */ + async closePoll({ pollId, hostId }: { pollId: string; hostId: string }) { + const poll = await SpacePoll.findById(pollId); + if (!poll) { + throw new Error("Poll not found"); + } + + const space = await Space.findById(poll.space); + const isHost = space && space.host.toString() === hostId.toString(); + const isCreator = poll.creator.toString() === hostId.toString(); + + if (!isHost && !isCreator) { + throw new Error("Only the space host or poll creator can close this poll"); + } + + poll.status = "closed"; + poll.closedAt = new Date(); + await poll.save(); + + return this.getPollResults(poll._id.toString()); + } + + /** + * Export poll results in JSON / text / CSV friendly structure. + */ + async exportPollResults(pollId: string) { + const pollData = await this.getPollResults(pollId); + const votes = await PollVote.find({ poll: pollId }).populate("user", "name email"); + + const csvHeader = "Option Index,Option Text,Votes,Percentage\n"; + const csvRows = pollData.results + .map((r) => `"${r.optionIndex}","${r.text.replace(/"/g, '""')}",${r.votes},${r.percentage}%`) + .join("\n"); + + const exportSummary = { + pollId: pollData._id, + spaceId: pollData.space, + question: pollData.question, + status: pollData.status, + totalVotes: pollData.totalVotes, + createdAt: pollData.createdAt, + closedAt: pollData.closedAt, + results: pollData.results, + votes: votes.map((v: any) => ({ + user: v.user, + optionIndex: v.optionIndex, + votedAt: v.createdAt, + })), + csvData: csvHeader + csvRows, + }; + + return exportSummary; + } +} + +export const spacePollService = new SpacePollService(); +export default spacePollService; diff --git a/src/services/stellar/analyticsService.js b/src/services/stellar/analyticsService.js new file mode 100644 index 00000000..f7f3689d --- /dev/null +++ b/src/services/stellar/analyticsService.js @@ -0,0 +1,239 @@ +// services/stellar/analyticsService.js +import mongoose from "mongoose"; +import Transaction from "../../models/Transaction.js"; +import logger from "../../config/logger.js"; + +/** + * Payment analytics service. + * + * Aggregates Stellar {@link Transaction} data into dashboard-friendly + * statistics using MongoDB aggregation pipelines (never in-application loops), + * so it stays efficient over large datasets. + * + * Monetary values are stored on the model as precision-preserving STRINGS and a + * single collection can hold rows in several assets (see `currency`). Sums are + * therefore always grouped per `currency`, converted to `Decimal128` inside the + * pipeline via `$toDecimal`, and returned as strings so no precision is lost. + * + * Recommended indexes for the query shapes below (the Transaction model already + * ships some of these; do NOT add them here — this is documentation for ops): + * - `{ createdAt: 1 }` — every analytics query filters/buckets on `createdAt`. + * - `{ status: 1, createdAt: 1 }` — status filter + time bucketing. + * - `{ type: 1, status: 1, createdAt: -1 }` — already present on the model. + * - `{ currency: 1, createdAt: 1 }` — asset-scoped time series. + * - `{ buyer: 1, createdAt: 1 }` / `{ creator: 1, createdAt: 1 }` — per-user. + * A compound index that matches the leading equality filters followed by + * `createdAt` lets the `$match` stage use an index and the pipeline stream + * results instead of collection-scanning. + * + * @module services/stellar/analyticsService + */ + +/** Time-bucket granularities supported by the analytics endpoints. */ +export const SUPPORTED_PERIODS = ["day", "week", "month", "year"]; + +/** Default bucket granularity when the caller does not specify one. */ +export const DEFAULT_PERIOD = "month"; + +/** + * Map an analytics period onto the `unit` argument of `$dateTrunc`. + * @param {string} period One of {@link SUPPORTED_PERIODS}. + * @returns {string} A `$dateTrunc` unit. + */ +const dateTruncUnit = (period) => + SUPPORTED_PERIODS.includes(period) ? period : DEFAULT_PERIOD; + +/** + * @typedef {Object} AnalyticsFilters + * @property {string} [status] Restrict to a single transaction status. + * @property {string} [type] Restrict to a transaction type ("purchase"|"donation"). + * @property {string} [currency] Restrict to a single asset code (e.g. "USDC"). + * @property {string} [buyerId] Restrict to transactions bought by this user id. + * @property {string} [creatorId] Restrict to transactions credited to this creator id. + * @property {Date|string} [startDate] Inclusive lower bound on `createdAt`. + * @property {Date|string} [endDate] Inclusive upper bound on `createdAt`. + */ + +/** + * Build the `$match` stage from validated, already-sanitized filters. + * + * Only whitelisted fields are consulted, so untrusted query input cannot inject + * operators into the pipeline. + * + * @param {AnalyticsFilters} [filters={}] Filter selection. + * @returns {Object} A MongoDB match expression. + */ +export const buildMatchStage = (filters = {}) => { + const match = {}; + + if (filters.status) match.status = filters.status; + if (filters.type) match.type = filters.type; + if (filters.currency) match.currency = filters.currency; + + if (filters.buyerId && mongoose.Types.ObjectId.isValid(filters.buyerId)) { + match.buyer = new mongoose.Types.ObjectId(filters.buyerId); + } + if (filters.creatorId && mongoose.Types.ObjectId.isValid(filters.creatorId)) { + match.creator = new mongoose.Types.ObjectId(filters.creatorId); + } + + if (filters.startDate || filters.endDate) { + match.createdAt = {}; + if (filters.startDate) match.createdAt.$gte = new Date(filters.startDate); + if (filters.endDate) match.createdAt.$lte = new Date(filters.endDate); + } + + return match; +}; + +/** + * Aggregate payment statistics bucketed by time period and asset. + * + * Each returned bucket carries: `totalVolume` (summed amount as a string), + * `transactionCount`, and `averageAmount` (as a string) for a single + * `(period, currency)` pair. + * + * @param {AnalyticsFilters & { period?: string }} [options={}] Bucketing period + * plus any filters. + * @returns {Promise>} One entry per (period, currency), sorted oldest-first. + */ +export const getTimeSeriesAnalytics = async (options = {}) => { + const { period, ...filters } = options; + const unit = dateTruncUnit(period); + const match = buildMatchStage(filters); + + const pipeline = [ + { $match: match }, + { + $addFields: { + // Amounts are stored as strings; coerce to Decimal128 so sums/averages + // keep full precision. Malformed values fall back to 0. + amountDecimal: { + $convert: { input: "$amount", to: "decimal", onError: 0, onNull: 0 }, + }, + }, + }, + { + $group: { + _id: { + periodStart: { $dateTrunc: { date: "$createdAt", unit } }, + currency: { $ifNull: ["$currency", "USDC"] }, + }, + totalVolume: { $sum: "$amountDecimal" }, + transactionCount: { $sum: 1 }, + averageAmount: { $avg: "$amountDecimal" }, + }, + }, + { + $project: { + _id: 0, + period: unit, + periodStart: "$_id.periodStart", + currency: "$_id.currency", + // Return monetary figures as strings to preserve precision on the wire. + totalVolume: { $toString: "$totalVolume" }, + transactionCount: 1, + averageAmount: { $toString: { $ifNull: ["$averageAmount", 0] } }, + }, + }, + { $sort: { periodStart: 1, currency: 1 } }, + ]; + + return Transaction.aggregate(pipeline); +}; + +/** + * Aggregate overall payment statistics (no time bucketing), grouped by asset. + * + * @param {AnalyticsFilters} [filters={}] Filter selection. + * @returns {Promise>} One entry per asset, highest transaction count first. + */ +export const getSummaryAnalytics = async (filters = {}) => { + const match = buildMatchStage(filters); + + const pipeline = [ + { $match: match }, + { + $addFields: { + amountDecimal: { + $convert: { input: "$amount", to: "decimal", onError: 0, onNull: 0 }, + }, + }, + }, + { + $group: { + _id: { $ifNull: ["$currency", "USDC"] }, + totalVolume: { $sum: "$amountDecimal" }, + transactionCount: { $sum: 1 }, + averageAmount: { $avg: "$amountDecimal" }, + }, + }, + { + $project: { + _id: 0, + currency: "$_id", + totalVolume: { $toString: "$totalVolume" }, + transactionCount: 1, + averageAmount: { $toString: { $ifNull: ["$averageAmount", 0] } }, + }, + }, + { $sort: { transactionCount: -1, currency: 1 } }, + ]; + + return Transaction.aggregate(pipeline); +}; + +/** + * Convenience wrapper returning both the per-asset summary and the time series + * in a single call, so a dashboard can populate headline totals and a chart + * from one request. + * + * @param {AnalyticsFilters & { period?: string }} [options={}] Period + filters. + * @returns {Promise<{ + * period: string, + * filters: AnalyticsFilters, + * summary: Array, + * series: Array + * }>} + */ +export const getPaymentAnalytics = async (options = {}) => { + const { period = DEFAULT_PERIOD, ...filters } = options; + + try { + const [summary, series] = await Promise.all([ + getSummaryAnalytics(filters), + getTimeSeriesAnalytics({ period, ...filters }), + ]); + + return { + period: dateTruncUnit(period), + filters, + summary, + series, + }; + } catch (error) { + logger.error("Payment analytics aggregation error:", error); + throw error; + } +}; + +export default { + SUPPORTED_PERIODS, + DEFAULT_PERIOD, + buildMatchStage, + getTimeSeriesAnalytics, + getSummaryAnalytics, + getPaymentAnalytics, +}; diff --git a/src/services/stellar/claimableBalanceService.js b/src/services/stellar/claimableBalanceService.js new file mode 100644 index 00000000..9a3c21a0 --- /dev/null +++ b/src/services/stellar/claimableBalanceService.js @@ -0,0 +1,335 @@ +// services/stellar/claimableBalanceService.js +// +// Stellar claimable balances for gifting courses/books and trustline-free +// receiving. The sender creates an on-ledger USDC balance the recipient can +// claim whenever they're ready, with a reclaim-after-expiry predicate so +// funds are never stranded: +// +// - recipient claimant: predicateBeforeAbsoluteTime(expiresAt) +// - sender claimant: predicateNot(predicateBeforeAbsoluteTime(expiresAt)) +// +// All signing stays client-side; this service only builds unsigned XDR, +// resolves the REAL claimable-balance id after inclusion, verifies on-chain, +// and lets the controller grant access to the recipient. +// +// The #1 trap this module exists to avoid: the claimable-balance id is NOT +// the transaction hash. It is the hex-encoded XDR of the ClaimableBalanceId +// produced by the create_claimable_balance operation result. + +import * as StellarSdk from "@stellar/stellar-sdk"; +import logger from "../../config/logger.js"; +import { client } from "./horizonClient.js"; +import { + server, + networkPassphrase, + USDC, + USDC_ISSUER, + toStroops, + hasUsdcTrustline, +} from "./stellarService.js"; + +/** How long a gifted balance stays claimable before the sender can reclaim. */ +export const GIFT_EXPIRY_DAYS = 30; +export const giftExpiryFromNow = () => + new Date(Date.now() + GIFT_EXPIRY_DAYS * 24 * 60 * 60 * 1000); + +/** + * Build an unsigned transaction that creates a USDC claimable balance for the + * recipient, with the sender as the reclaim-after-expiry claimant. + * @returns {Promise<{xdr: string, hash: string, networkPassphrase: string, expiresAt: Date}>} + */ +export const buildCreateClaimableBalanceTx = async ({ + sourcePublicKey, + claimantPublicKey, + amount, + expiresAt, + memo = "DeenBridge Gift", +}) => { + const sourceAccount = await client.execute((srv) => + srv.loadAccount(sourcePublicKey) + ); + + const builder = new StellarSdk.TransactionBuilder(sourceAccount, { + fee: StellarSdk.BASE_FEE, + networkPassphrase, + }); + + builder.addOperation( + StellarSdk.Operation.createClaimableBalance({ + asset: USDC, + amount: amount.toString(), + claimants: [ + // Recipient can claim up until the expiry instant. + new StellarSdk.Claimant( + claimantPublicKey, + StellarSdk.Claimant.predicateBeforeAbsoluteTime(expiresAt) + ), + // Sender can reclaim once the expiry instant has passed — strictly + // complementary predicates, so there is no window where neither (or + // both) can claim. + new StellarSdk.Claimant( + sourcePublicKey, + StellarSdk.Claimant.predicateNot( + StellarSdk.Claimant.predicateBeforeAbsoluteTime(expiresAt) + ) + ), + ], + }) + ); + + const transaction = builder + .addMemo(StellarSdk.Memo.text(memo)) + .setTimeout(300) + .build(); + + return { + xdr: transaction.toXDR(), + hash: transaction.hash().toString("hex"), + networkPassphrase, + expiresAt, + }; +}; + +/** + * Build an unsigned claim transaction for a claimable balance. When the + * claimant has no USDC trustline yet, a changeTrust(USDC) operation is + * prepended IN THE SAME TRANSACTION so claiming is a single signature — + * this is the trustline-free receiving path. + * @returns {Promise<{xdr: string, hash: string, networkPassphrase: string, includesChangeTrust: boolean}>} + */ +export const buildClaimTx = async ({ claimantPublicKey, balanceId }) => { + const includesChangeTrust = !(await hasUsdcTrustline(claimantPublicKey)); + const sourceAccount = await client.execute((srv) => + srv.loadAccount(claimantPublicKey) + ); + + const builder = new StellarSdk.TransactionBuilder(sourceAccount, { + fee: StellarSdk.BASE_FEE, + networkPassphrase, + }); + + if (includesChangeTrust) { + builder.addOperation(StellarSdk.Operation.changeTrust({ asset: USDC })); + } + builder.addOperation( + StellarSdk.Operation.claimClaimableBalance({ balanceId }) + ); + + const transaction = builder.setTimeout(300).build(); + + return { + xdr: transaction.toXDR(), + hash: transaction.hash().toString("hex"), + networkPassphrase, + includesChangeTrust, + }; +}; + +/** + * Resolve the REAL claimable-balance id for a create transaction. + * + * Primary path: parse the transaction result XDR + * (CreateClaimableBalanceResult → balanceId). This is deterministic and + * immune to races — unlike querying Horizon by claimant, which is ambiguous + * when the same account has created several balances. + * + * Fallback: when the result XDR is unavailable (e.g. Horizon lag), query + * `claimableBalances().forClaimant(source)` and match the amount/asset. + * + * @param {string} createTxHash - hash of the create_claimable_balance tx + * @param {object} [opts] - { amount, claimantPublicKey } to disambiguate the fallback + * @returns {Promise} the balance id (hex XDR), or null + */ +export const resolveBalanceId = async ( + createTxHash, + { amount, claimantPublicKey } = {} +) => { + // Primary: parse the operation result from the transaction result XDR. + try { + const tx = await client.execute((srv) => + srv.transactions().transaction(createTxHash).call() + ); + if (tx?.result_xdr) { + const result = StellarSdk.xdr.TransactionResult.fromXDR( + tx.result_xdr, + "base64" + ); + const operationResults = result.result().results() || []; + for (const opResult of operationResults) { + const createResult = opResult.tr().createClaimableBalanceResult(); + if (createResult && createResult.balanceId()) { + const balanceId = createResult.balanceId().toXDR("hex"); + if (balanceId && balanceId !== createTxHash) { + return balanceId; + } + } + } + } + } catch (error) { + logger.warn( + { createTxHash, err: error.message }, + "resolveBalanceId: could not parse transaction result XDR, falling back to Horizon query" + ); + } + + // Fallback: query by the balance's sponsor (the create tx source account). + try { + let sourceAccount; + if (claimantPublicKey) { + sourceAccount = claimantPublicKey; + } else { + const tx = await client.execute((srv) => + srv.transactions().transaction(createTxHash).call() + ); + sourceAccount = tx?.source_account; + } + if (!sourceAccount) return null; + + const page = await client.execute((srv) => + srv.claimableBalances().forClaimant(sourceAccount).call() + ); + const records = page?.records || []; + const match = records.find((r) => { + // Horizon encodes the asset as "CODE:ISSUER" on claimable balances. + const assetIsUsdc = + typeof r.asset === "string" && r.asset.startsWith("USDC:"); + if (!assetIsUsdc) return false; + if (amount != null && toStroops(r.amount) !== toStroops(amount)) { + return false; + } + return true; + }); + return match?.id || null; + } catch (error) { + logger.warn( + { createTxHash, err: error.message }, + "resolveBalanceId: fallback Horizon query failed" + ); + return null; + } +}; + +/** + * Look up a claimable balance on Horizon for live status/predicate checks. + * @returns {Promise<{exists: boolean, record?: object}>} + */ +export const getClaimableBalance = async (balanceId) => { + try { + const record = await client.execute((srv) => + srv.claimableBalances().claimableBalance(balanceId).call() + ); + return { exists: true, record }; + } catch (error) { + if (error.response?.status === 404) { + return { exists: false }; + } + logger.error("Error fetching claimable balance:", error); + throw error; + } +}; + +/** + * Decode a claim predicate XDR into a plain, comparable shape. + * @returns {{type: string, time?: string, seconds?: string, children?: Array}} + */ +export const describePredicate = (pred) => { + const name = pred?._switch?.name; + switch (name) { + case "claimPredicateUnconditional": + return { type: "unconditional" }; + case "claimPredicateAnd": + return { type: "and", children: (pred._value || []).map(describePredicate) }; + case "claimPredicateOr": + return { type: "or", children: (pred._value || []).map(describePredicate) }; + case "claimPredicateNot": + return { type: "not", child: describePredicate(pred._value) }; + case "claimPredicateBeforeAbsoluteTime": + return { type: "before_absolute_time", time: String(pred._value?._value ?? "") }; + case "claimPredicateBeforeRelativeTime": + return { type: "before_relative_time", seconds: String(pred._value?._value ?? "") }; + default: + return { type: "unknown", name }; + } +}; + +/** + * Validate a signed gift XDR against the expected create_claimable_balance + * before ANY database write or access grant. Mirrors the discipline of + * validateSignedPaymentXdr (stellarService.js): a tampered XDR (wrong asset, + * wrong amount, or altered claimants/predicates) is rejected outright. + * + * @param {string} signedXdr + * @param {{assetCode?: string, amount: string, recipientWallet: string, senderWallet: string, expiresAt: Date|string|number}} expected + * @returns {object} the parsed transaction + */ +export const validateSignedGiftXdr = (signedXdr, expected) => { + const { + assetCode = "USDC", + amount, + recipientWallet, + senderWallet, + expiresAt, + } = expected; + + const tx = StellarSdk.TransactionBuilder.fromXDR(signedXdr, networkPassphrase); + + const giftOps = tx.operations.filter( + (op) => op.type === "createClaimableBalance" + ); + if (giftOps.length === 0) { + throw new Error( + "Signed XDR missing a create_claimable_balance operation" + ); + } + const op = giftOps[0]; + + const assetMatches = + (op.asset?.code === assetCode && op.asset?.issuer === USDC_ISSUER) || + (op.asset_type === "credit_alphanum4" && + op.asset?.code === assetCode && + op.asset?.issuer === USDC_ISSUER); + if (!assetMatches) { + throw new Error( + `Signed XDR create_claimable_balance uses the wrong asset (expected ${assetCode})` + ); + } + + if (toStroops(op.amount) !== toStroops(amount)) { + throw new Error( + `Signed XDR create_claimable_balance amount mismatch (expected ${amount})` + ); + } + + const expectedTime = String(new Date(expiresAt).getTime()); + const claimants = (op.claimants || []).map((c) => ({ + destination: c.destination, + predicate: describePredicate(c.predicate), + })); + + const recipientClaimant = claimants.find( + (c) => c.destination === recipientWallet + ); + const recipientPredicateOk = + recipientClaimant?.predicate.type === "before_absolute_time" && + recipientClaimant.predicate.time === expectedTime; + if (!recipientPredicateOk) { + throw new Error( + "Signed XDR missing the recipient claimant with before_absolute_time(expiresAt)" + ); + } + + const senderClaimant = claimants.find((c) => c.destination === senderWallet); + const senderPredicateOk = + senderClaimant?.predicate.type === "not" && + senderClaimant.predicate.child?.type === "before_absolute_time" && + senderClaimant.predicate.child.time === expectedTime; + if (!senderPredicateOk) { + throw new Error( + "Signed XDR missing the sender claimant with not(before_absolute_time(expiresAt))" + ); + } + + return tx; +}; + +export { server }; diff --git a/src/services/stellar/donationIntentService.js b/src/services/stellar/donationIntentService.js new file mode 100644 index 00000000..862f9439 --- /dev/null +++ b/src/services/stellar/donationIntentService.js @@ -0,0 +1,68 @@ +import Transaction from "../../models/Transaction.js"; +import { + isValidPublicKey, + buildPaymentTransaction, + buildSep7Uri, + NETWORK, + DONATION_WALLET_PUBLIC_KEY, +} from "./stellarService.js"; + +export const DONATION_MEMO = "DNB-SADAQAH"; + +export const validateDonationAmount = (amount) => { + const parsedAmount = Number(amount); + return Boolean( + amount && + Number.isFinite(parsedAmount) && + parsedAmount > 0 && + /^\d+(\.\d{1,7})?$/.test(amount.toString()) + ); +}; + +export const createDonationIntent = async ({ donorId, publicKey, amount, session, memo = DONATION_MEMO }) => { + if (!DONATION_WALLET_PUBLIC_KEY) { + const error = new Error("Donations are not available right now. Please try again later."); + error.statusCode = 503; + throw error; + } + if (!publicKey || !isValidPublicKey(publicKey)) { + const error = new Error("Invalid Stellar public key"); + error.statusCode = 400; + throw error; + } + if (!validateDonationAmount(amount)) { + const error = new Error("Invalid amount. Must be a positive number with at most 7 decimal places"); + error.statusCode = 400; + throw error; + } + + const paymentTx = await buildPaymentTransaction({ + sourcePublicKey: publicKey, + destinationPublicKey: DONATION_WALLET_PUBLIC_KEY, + amount: amount.toString(), + memo, + }); + const sep7Uri = buildSep7Uri({ + destination: DONATION_WALLET_PUBLIC_KEY, + amount: amount.toString(), + memo, + }); + const transaction = new Transaction({ + type: "donation", + buyer: donorId, + buyerWallet: publicKey, + creatorWallet: DONATION_WALLET_PUBLIC_KEY, + amount: amount.toString(), + network: NETWORK, + status: "pending", + expectedHash: paymentTx.hash, + memo, + }); + await transaction.save({ session }); + return { + transaction, + transactionXdr: paymentTx.xdr, + sep7Uri, + networkPassphrase: paymentTx.networkPassphrase, + }; +}; diff --git a/src/services/stellar/feeSponsorService.js b/src/services/stellar/feeSponsorService.js new file mode 100644 index 00000000..2b503270 --- /dev/null +++ b/src/services/stellar/feeSponsorService.js @@ -0,0 +1,528 @@ +// services/stellar/feeSponsorService.js +// +// Fee-bump sponsorship (#30). The platform can pay a user's Stellar network +// fee by wrapping the user-signed inner transaction in a fee-bump transaction +// signed by a dedicated, low-balance "fee-source" account. The user still +// signs (and only signs) their own payment operations; the sponsor key only +// ever signs the fee-bump wrapper and can never move user funds. +// +// Because the server signs on behalf of the platform, this path is guarded by: +// 1. a reject-by-default STRUCTURAL WHITELIST — the user's inner transaction +// must match, operation-for-operation, the pending Transaction row the +// server already built (see validateInnerTransaction); and +// 2. durable SPEND CAPS — per-transaction, per-UTC-day total, and per-user +// per-day (see SponsorshipSpend + checkSpendCaps). +// +// Everything here is a no-op unless FEE_SPONSOR_ENABLED=true; with the flag off +// the caller never reaches this module and the base payment/donation flow is +// byte-for-byte unchanged. +import * as StellarSdk from "@stellar/stellar-sdk"; +import logger from "../../config/logger.js"; +import SponsorshipSpend from "../../models/SponsorshipSpend.js"; +import { + toStroops, + resolveAsset, + getAccountBalance, + networkPassphrase, +} from "./stellarService.js"; + +// StellarSdk's minimum fee-bump base fee (per operation), in stroops. +const MIN_BASE_FEE_STROOPS = Number(StellarSdk.BASE_FEE); // 100 + +// Sensible defaults applied when a numeric cap env var is unset or invalid. +// The master switch (FEE_SPONSOR_ENABLED) and the secret have no defaults. +export const FEE_SPONSOR_DEFAULTS = Object.freeze({ + maxFeeStroops: 1_000_000, // 0.1 XLM per-transaction fee ceiling + dailyCapStroops: 100_000_000, // 10 XLM total per UTC day + perUserDailyLimit: 10, // sponsored transactions per user per UTC day +}); + +/** + * A sponsorship-specific failure. These MUST NOT mark the user's Transaction + * `failed`: the user can always retry the submit without sponsorship and pay + * their own fee. `httpStatus` is a distinct non-fatal 4xx (or 503 for a + * server-side misconfiguration) and `retryUnsponsored` signals the client to + * fall back to the normal flow. + */ +export class SponsorshipError extends Error { + constructor(code, message, { httpStatus = 422 } = {}) { + super(message); + this.name = "SponsorshipError"; + this.code = code; + this.httpStatus = httpStatus; + this.retryUnsponsored = true; + } +} + +// ── Config ────────────────────────────────────────────────────────────────── + +const parsePositiveInt = (value, fallback) => { + const n = Number(value); + if (!Number.isInteger(n) || n <= 0) return fallback; + return n; +}; + +export const isFeeSponsorEnabled = () => + process.env.FEE_SPONSOR_ENABLED === "true"; + +/** + * Resolve the sponsorship config from the environment. Read at call time so a + * deploy can tune caps without a code change; the numeric caps fall back to + * FEE_SPONSOR_DEFAULTS when unset/invalid. + */ +export const getFeeSponsorConfig = () => ({ + enabled: isFeeSponsorEnabled(), + maxFeeStroops: parsePositiveInt( + process.env.FEE_SPONSOR_MAX_FEE_STROOPS, + FEE_SPONSOR_DEFAULTS.maxFeeStroops + ), + dailyCapStroops: parsePositiveInt( + process.env.FEE_SPONSOR_DAILY_CAP_STROOPS, + FEE_SPONSOR_DEFAULTS.dailyCapStroops + ), + perUserDailyLimit: parsePositiveInt( + process.env.FEE_SPONSOR_PER_USER_DAILY_LIMIT, + FEE_SPONSOR_DEFAULTS.perUserDailyLimit + ), +}); + +// The sponsor secret is read from env and parsed into a Keypair once, then +// cached by its secret value. It is never logged and never returned over HTTP. +let cachedKeypair = null; +let cachedSecret = null; + +/** + * Parse the sponsor secret into a Keypair, caching the result. Throws a + * SponsorshipError (503) when the secret is missing or invalid so the caller + * can surface a non-fatal "retry unsponsored" without leaking the secret. + */ +export const getFeeSponsorKeypair = () => { + const secret = process.env.FEE_SPONSOR_SECRET; + if (!secret) { + throw new SponsorshipError( + "sponsor_misconfigured", + "Fee sponsor secret is not configured", + { httpStatus: 503 } + ); + } + if (cachedSecret === secret && cachedKeypair) return cachedKeypair; + try { + cachedKeypair = StellarSdk.Keypair.fromSecret(secret); + cachedSecret = secret; + return cachedKeypair; + } catch { + throw new SponsorshipError( + "sponsor_misconfigured", + "Fee sponsor secret is invalid", + { httpStatus: 503 } + ); + } +}; + +/** Public key of the sponsor account, or null if not configured/invalid. */ +export const getFeeSponsorPublicKey = () => { + try { + return getFeeSponsorKeypair().publicKey(); + } catch { + return null; + } +}; + +/** + * Boot-time validation: when FEE_SPONSOR_ENABLED=true, the secret must be a + * valid Stellar secret key. Returns { ok } / { ok:false, message } so the + * caller (validateEnv) can fail fast with a clear message. A no-op when the + * flag is off. + */ +export const validateFeeSponsorBootConfig = () => { + if (!isFeeSponsorEnabled()) return { ok: true }; + const secret = process.env.FEE_SPONSOR_SECRET; + if (!secret) { + return { + ok: false, + message: + "FEE_SPONSOR_ENABLED=true but FEE_SPONSOR_SECRET is not set. Provide the dedicated fee-source secret or disable sponsorship.", + }; + } + try { + StellarSdk.Keypair.fromSecret(secret); + } catch { + return { + ok: false, + message: + "FEE_SPONSOR_SECRET is not a valid Stellar secret key (expected an S... seed).", + }; + } + return { ok: true }; +}; + +// ── Structural whitelist ───────────────────────────────────────────────────── + +/** UTC calendar day (YYYY-MM-DD) used to key daily spend accounting. */ +export const utcDay = (date = new Date()) => date.toISOString().slice(0, 10); + +const assetsEqual = (a, b) => { + if (!a || !b) return false; + if (a.isNative() || b.isNative()) return a.isNative() && b.isNative(); + return a.getCode() === b.getCode() && a.getIssuer() === b.getIssuer(); +}; + +const extractTextMemo = (memo) => { + if (!memo) return null; + const type = memo.type ?? memo._type; + if (type !== "text") return null; + const value = memo.value ?? memo._value; + if (value == null) return null; + return Buffer.isBuffer(value) ? value.toString("utf8") : String(value); +}; + +/** + * The settlement asset for a row. Donations and purchases both settle in + * `row.currency` (defaulting to USDC for legacy rows with none set). + */ +const settlementAssetFor = (row) => resolveAsset(row.currency || "USDC"); + +/** + * Build the exact, ordered set of payment operations the inner transaction is + * allowed to contain, derived entirely from the server-persisted row: + * - a fee split → [creator op, platform op] (order matches buildPaymentTransaction); + * - otherwise → [single settlement op] (direct purchase or donation). + * Amounts are compared in stroops. + */ +export const buildExpectedOperations = (row) => { + const asset = settlementAssetFor(row); + if (row.platformFee && row.platformFee.platformAmount) { + return [ + { + destination: row.creatorWallet, + amountStroops: toStroops(row.platformFee.creatorAmount), + asset, + }, + { + destination: row.platformFee.platformWallet, + amountStroops: toStroops(row.platformFee.platformAmount), + asset, + }, + ]; + } + return [ + { + destination: row.creatorWallet, + amountStroops: toStroops(row.amount), + asset, + }, + ]; +}; + +const reject = (detail) => { + throw new SponsorshipError( + "whitelist_rejected", + `Structural whitelist rejected the signed transaction: ${detail}`, + { httpStatus: 422 } + ); +}; + +/** + * The structural whitelist. Reject-by-default: the inner transaction is + * accepted ONLY if it is, operation-for-operation, exactly what the server + * built for `row`. Enforced by allow-list (only `payment` ops in the settled + * asset are permitted) and exact count, so any foreign/extra operation — of + * any type, including one not yet invented — fails. + * + * @param {StellarSdk.Transaction} innerTx decoded user-signed inner transaction + * @param {object} row the pending Transaction document + * @returns {true} on success; throws SponsorshipError otherwise + */ +export const validateInnerTransaction = (innerTx, row) => { + if (!innerTx || innerTx instanceof StellarSdk.FeeBumpTransaction) { + reject("expected a plain inner transaction"); + } + + // Source must be the buyer/donor wallet the server recorded. + if (innerTx.source !== row.buyerWallet) { + reject(`source ${innerTx.source} does not match buyerWallet`); + } + + // Memo must match exactly. + const memoText = extractTextMemo(innerTx.memo); + if ((row.memo ?? null) !== memoText) { + reject("memo does not match the row"); + } + + const expected = buildExpectedOperations(row); + + // Exact operation count — rejects both extra/foreign ops and a missing op. + if (innerTx.operations.length !== expected.length) { + reject( + `operation count ${innerTx.operations.length} does not equal expected ${expected.length}` + ); + } + + // Every operation, positionally, must be a payment matching the row. The + // server builds these in a deterministic order (creator then platform), and + // wallets sign the exact envelope, so a positional check is the strictest + // form and never rejects a legitimate signature. + for (let i = 0; i < expected.length; i++) { + const op = innerTx.operations[i]; + // Allow-list: only `payment` is permitted. changeTrust, setOptions, + // manageData, accountMerge, createAccount, pathPayment*, or any unknown + // future type falls through here and is rejected. + if (op.type !== "payment") { + reject(`operation ${i} is a non-payment "${op.type}" operation`); + } + if (op.destination !== expected[i].destination) { + reject(`operation ${i} destination does not match the row`); + } + if (!assetsEqual(op.asset, expected[i].asset)) { + reject(`operation ${i} asset does not match the settlement asset`); + } + if (toStroops(op.amount) !== expected[i].amountStroops) { + reject(`operation ${i} amount does not match the row`); + } + } + + return true; +}; + +// ── Fee-bump wrapping ───────────────────────────────────────────────────────── + +/** + * Compute the fee-bump base fee (per operation) and the resulting total max + * fee, clamped to the per-transaction ceiling. The fee-bump is priced over the + * inner operations PLUS the wrapper (inner ops + 1), verified against the + * installed @stellar/stellar-sdk. We declare the highest per-op fee the ceiling + * allows so the sponsor tolerates fee surges up to the cap; Horizon still only + * charges the true network fee, which is recorded as the actual spend. + */ +export const computeFeeBumpFee = (innerTx, config = getFeeSponsorConfig()) => { + const innerOps = innerTx.operations.length; + const units = innerOps + 1; // inner operations + fee-bump wrapper + const innerPerOp = Math.ceil(Number(innerTx.fee) / innerOps); + const perOpCeiling = Math.floor(config.maxFeeStroops / units); + + // The per-op fee must be at least the inner tx's per-op fee and the network + // minimum. If the ceiling can't cover even that, the ceiling is too low to + // sponsor this transaction at all. + const minPerOp = Math.max(MIN_BASE_FEE_STROOPS, innerPerOp); + if (perOpCeiling < minPerOp) { + throw new SponsorshipError( + "fee_ceiling_too_low", + `Per-transaction fee ceiling ${config.maxFeeStroops} stroops is below the minimum required to fee-bump ${innerOps} operation(s)`, + { httpStatus: 503 } + ); + } + + const baseFeePerOp = perOpCeiling; // highest per-op fee within the ceiling + const totalMaxFeeStroops = baseFeePerOp * units; + return { baseFeePerOp, totalMaxFeeStroops, units }; +}; + +/** + * Wrap a validated inner transaction in a fee-bump signed by the sponsor key. + * The inner transaction (and its user signature) is left untouched. + */ +export const wrapWithFeeBump = ( + innerTx, + { keypair = getFeeSponsorKeypair(), baseFeePerOp } = {} +) => { + const { baseFeePerOp: computed } = + baseFeePerOp == null ? computeFeeBumpFee(innerTx) : { baseFeePerOp }; + const feeBump = StellarSdk.TransactionBuilder.buildFeeBumpTransaction( + keypair, + String(computed), + innerTx, + networkPassphrase + ); + feeBump.sign(keypair); + return feeBump; +}; + +// ── Spend accounting ────────────────────────────────────────────────────────── + +/** + * Read today's spend row and enforce caps BEFORE any wrapping/submission: + * - per-user daily count (FEE_SPONSOR_PER_USER_DAILY_LIMIT), and + * - per-UTC-day total stroops (FEE_SPONSOR_DAILY_CAP_STROOPS), reserving the + * worst-case fee for this transaction. + * Throws a distinct non-fatal SponsorshipError (429) when a cap is hit. + */ +export const checkSpendCaps = async ({ + userId, + estimatedFeeStroops, + config = getFeeSponsorConfig(), + session = null, +}) => { + const day = utcDay(); + const query = SponsorshipSpend.findOne({ day }); + const doc = session ? await query.session(session) : await query; + + const userCount = doc?.userCounts?.get?.(String(userId)) ?? 0; + if (userCount >= config.perUserDailyLimit) { + throw new SponsorshipError( + "per_user_daily_limit", + `Per-user daily sponsorship limit (${config.perUserDailyLimit}) reached`, + { httpStatus: 429 } + ); + } + + const currentTotal = doc?.totalStroops ?? 0; + if (currentTotal + estimatedFeeStroops > config.dailyCapStroops) { + throw new SponsorshipError( + "daily_cap_exceeded", + `Daily sponsorship spend cap (${config.dailyCapStroops} stroops) would be exceeded`, + { httpStatus: 429 } + ); + } +}; + +/** + * Record a successful sponsorship: atomically increment today's total spend + * (by the actual fee charged), the global count, and the per-user count. + * Called only AFTER the fee-bump has landed on-chain. + */ +export const recordSponsorshipSpend = async ({ + userId, + feeStroops, + session = null, +}) => { + const day = utcDay(); + const amount = Number.isFinite(Number(feeStroops)) ? Number(feeStroops) : 0; + await SponsorshipSpend.updateOne( + { day }, + { + $inc: { + totalStroops: amount, + sponsoredCount: 1, + [`userCounts.${String(userId)}`]: 1, + }, + }, + { upsert: true, ...(session ? { session } : {}) } + ); +}; + +// ── Orchestration ───────────────────────────────────────────────────────────── + +/** + * Refuse (non-fatally) if the sponsor account cannot cover the declared max + * fee, so an underfunded float never causes a Stellar submit failure that + * would mark the user's transaction `failed`. If the balance can't be read we + * do NOT block — a genuine failure still surfaces at submit time. + */ +const assertSponsorFunded = async ({ publicKey, requiredStroops, loadBalance }) => { + let balance; + try { + balance = await loadBalance(publicKey); + } catch { + return; // undeterminable — let submission proceed rather than false-refuse + } + const available = balance?.exists ? toStroops(balance.xlmBalance || "0") : 0n; + if (!balance?.exists || available < BigInt(requiredStroops)) { + throw new SponsorshipError( + "sponsor_underfunded", + "Sponsor float is insufficient to cover the network fee", + { httpStatus: 503 } + ); + } +}; + +/** + * Validate → cap-check → float-check → wrap. Returns everything the controller + * needs to submit the fee-bump and record the outcome. Throws SponsorshipError + * on any guard failure (whitelist, cap, underfunded, or sponsor + * misconfiguration) so the caller returns a distinct non-fatal 4xx and leaves + * the row untouched. + * + * NOTE: caps are only READ here; the spend is recorded (with the real + * fee_charged) via recordSponsorshipSpend after the fee-bump confirms. + * `loadBalance` is injectable so the float pre-check is unit-testable offline. + */ +export const prepareSponsoredSubmission = async ({ + signedXdr, + transactionRow, + userId, + session = null, + loadBalance = getAccountBalance, +}) => { + const config = getFeeSponsorConfig(); + const keypair = getFeeSponsorKeypair(); + + let decoded; + try { + decoded = StellarSdk.TransactionBuilder.fromXDR(signedXdr, networkPassphrase); + } catch { + throw new SponsorshipError( + "whitelist_rejected", + "Signed XDR could not be decoded", + { httpStatus: 422 } + ); + } + + validateInnerTransaction(decoded, transactionRow); + + const { baseFeePerOp, totalMaxFeeStroops } = computeFeeBumpFee(decoded, config); + await checkSpendCaps({ + userId, + estimatedFeeStroops: totalMaxFeeStroops, + config, + session, + }); + + await assertSponsorFunded({ + publicKey: keypair.publicKey(), + requiredStroops: totalMaxFeeStroops, + loadBalance, + }); + + const feeBump = wrapWithFeeBump(decoded, { keypair, baseFeePerOp }); + + return { + innerHash: decoded.hash().toString("hex"), + outerHash: feeBump.hash().toString("hex"), + feeBumpXdr: feeBump.toXDR(), + maxFeeStroops: totalMaxFeeStroops, + }; +}; + +/** + * Auth-protected status snapshot for ops: whether sponsorship is on, the + * sponsor account's public key (never the secret) and live XLM float, the + * configured caps, and today's spend. Used to top up the float before it runs + * dry. + */ +export const getSponsorshipStatus = async () => { + const config = getFeeSponsorConfig(); + const publicKey = getFeeSponsorPublicKey(); + const day = utcDay(); + const doc = await SponsorshipSpend.findOne({ day }); + const totalStroops = doc?.totalStroops ?? 0; + + let float = null; + if (publicKey) { + try { + const balance = await getAccountBalance(publicKey); + float = { exists: balance.exists, xlmBalance: balance.xlmBalance }; + } catch (error) { + logger.warn( + { err: error, sponsorAccount: publicKey }, + "Failed to read sponsor float balance" + ); + } + } + + return { + enabled: config.enabled, + sponsorAccount: publicKey, // public key only — the secret is never exposed + caps: { + maxFeeStroops: config.maxFeeStroops, + dailyCapStroops: config.dailyCapStroops, + perUserDailyLimit: config.perUserDailyLimit, + }, + today: { + day, + totalStroops, + sponsoredCount: doc?.sponsoredCount ?? 0, + remainingStroops: Math.max(0, config.dailyCapStroops - totalStroops), + }, + float, + }; +}; diff --git a/src/services/stellar/horizonClient.js b/src/services/stellar/horizonClient.js index f16aa3e7..9ea80dbd 100644 --- a/src/services/stellar/horizonClient.js +++ b/src/services/stellar/horizonClient.js @@ -1,5 +1,6 @@ import * as StellarSdk from "@stellar/stellar-sdk"; import logger from "../../config/logger.js"; +import { resolveStellarConfig } from "../../config/stellar.js"; export class HorizonClient { constructor(urls, timeoutMs = 10000) { @@ -184,15 +185,12 @@ export class HorizonClient { } } -// Resolve Horizon endpoints from the environment. The default is network-aware -// (mainnet vs testnet) so a mainnet deployment never silently falls back to -// testnet Horizon when HORIZON_URLS is left unset. +// Resolve Horizon endpoints from the single source of truth +// (config/stellar.js). The default is network-aware (mainnet vs testnet) so +// a mainnet deployment never silently falls back to testnet Horizon when +// HORIZON_URLS is left unset. function resolveHorizonEndpoints() { - const fallback = - process.env.STELLAR_NETWORK === "mainnet" - ? "https://horizon.stellar.org" - : "https://horizon-testnet.stellar.org"; - return (process.env.HORIZON_URLS || fallback).split(",").map((u) => u.trim()); + return resolveStellarConfig().horizonUrls; } // Construct the client lazily on first use, so it reads HORIZON_URLS / diff --git a/src/services/stellar/loyaltyService.js b/src/services/stellar/loyaltyService.js new file mode 100644 index 00000000..292a2cb3 --- /dev/null +++ b/src/services/stellar/loyaltyService.js @@ -0,0 +1,310 @@ +// services/stellar/loyaltyService.js +// +// Backend bridge to the Deen Bridge loyalty-points Soroban contract +// (contracts/loyalty-points). Users earn points for platform activities +// (course/book purchases, referrals, milestones), redeem them for discounts, +// and can transfer them to other users. +// +// Consistent with the rest of this module's Stellar services, all signing +// stays client-side: this service BUILDS UNSIGNED transaction XDR that the +// frontend signs and submits, performs read-only queries against Soroban RPC, +// and never holds keys. Server-signed flows (admin mint/rate changes) are an +// explicit extension point marked below. +// +// Contract surface (see contracts/loyalty-points/src/lib.rs): +// init(admin) / set_rate(activity, rate) — admin setup +// earn(user, activity, spend_amount) -> balance — claim activity points +// mint(user, amount) — admin issuance +// redeem(user, amount) -> balance — burn for rewards +// transfer(from, to, amount) — user-to-user gifting +// balance(user) / rate(activity) / state() — views + +import * as StellarSdk from "@stellar/stellar-sdk"; +import logger from "../../config/logger.js"; +import { resolveStellarNetwork } from "../../config/stellar.js"; + +/** Env var holding the deployed loyalty contract id (C…55-char StrKey). */ +export const LOYALTY_CONTRACT_ENV = "LOYALTY_CONTRACT_ID"; + +/** Default public Soroban RPC endpoints per network. */ +const SOROBAN_RPC_DEFAULTS = { + testnet: "https://soroban-testnet.stellar.org", + // No fixed public mainnet RPC — operators must configure one explicitly. + mainnet: null, +}; + +/** Allowed activity discriminants, mirroring the contract's Activity enum. */ +export const LOYALTY_ACTIVITIES = Object.freeze({ + PURCHASE: "Purchase", + REFERRAL: "Referral", + MILESTONE: "Milestone", +}); + +/** + * Resolve the configured network using the shared Stellar config. + * @returns {string} "testnet" | "mainnet" + */ +export const resolveLoyaltyNetwork = () => resolveStellarNetwork(); + +/** + * Lazily-built singleton RPC client for the active network. + * + * @returns {{server: StellarSdk.rpc.Server, networkPassphrase: string}} + */ +let cachedRpc; +export const loyaltyRpc = () => { + if (cachedRpc) return cachedRpc; + + const network = resolveLoyaltyNetwork(); + const url = + process.env.SOROBAN_RPC_URL || SOROBAN_RPC_DEFAULTS[network] || null; + if (!url) { + throw new Error( + `SOROBAN_RPC_URL must be configured for the "${network}" network` + ); + } + + const passphrase = + StellarSdk.Networks[network.toUpperCase()] ?? + (() => { + throw new Error(`Unknown Stellar network "${network}"`); + })(); + + cachedRpc = { + server: new StellarSdk.rpc.Server(url, { + allowHttp: url.startsWith("http://"), + }), + networkPassphrase: passphrase, + }; + return cachedRpc; +}; + +/** + * The deployed loyalty contract handle. + * + * @returns {StellarSdk.Contract} + */ +export const loyaltyContract = () => { + const contractId = process.env[LOYALTY_CONTRACT_ENV]; + if (!contractId) { + throw new Error( + `${LOYALTY_CONTRACT_ENV} is not configured — deploy contracts/loyalty-points and record its id` + ); + } + return new StellarSdk.Contract(contractId); +}; + +/* ------------------------------------------------------------------ */ +/* Encoding helpers */ +/* ------------------------------------------------------------------ */ + +/** @returns {StellarSdk.xdr.ScVal} */ +const addressScVal = (publicKey) => + StellarSdk.Address.fromString(publicKey).toScVal(); + +/** @returns {StellarSdk.xdr.ScVal} */ +const i128ScVal = (value) => + StellarSdk.nativeToScVal(BigInt(value), { type: "i128" }); + +/** Activity enum values encode as their variant symbol on-chain. */ +const activityScVal = (activity) => { + if (!Object.values(LOYALTY_ACTIVITIES).includes(activity)) { + throw new Error( + `Unknown loyalty activity "${activity}" — expected one of ${Object.values( + LOYALTY_ACTIVITIES + ).join(", ")}` + ); + } + return StellarSdk.xdr.ScVal.scvSymbol(activity); +}; + +/* ------------------------------------------------------------------ */ +/* Unsigned transaction builders (client signs & submits) */ +/* ------------------------------------------------------------------ */ + +/** + * Shared builder: assemble an unsigned Soroban invoke for the loyalty + * contract. The returned XDR must be signed by `sourcePublicKey`'s keypair + * and submitted via a wallet or the platform's submission flow. + * + * @param {string} sourcePublicKey Signer's G… address (sequence source). + * @param {(contract: StellarSdk.Contract) => StellarSdk.xdr.Operation} buildOp + * @param {{memo?: string}} [options] + * @returns {Promise<{xdr: string, contractId: string, networkPassphrase: string}>} + */ +const buildInvokeTx = async (sourcePublicKey, buildOp, options = {}) => { + const { server, networkPassphrase } = loyaltyRpc(); + const contract = loyaltyContract(); + + const account = await server.getAccount(sourcePublicKey); + const tx = new StellarSdk.TransactionBuilder(account, { + fee: StellarSdk.BASE_FEE, + networkPassphrase, + memo: options.memo ? StellarSdk.Memo.text(options.memo) : undefined, + }) + .addOperation(buildOp(contract)) + .setTimeout(180) + .build(); + + logger.debug( + { sourcePublicKey, contractId: contract.contractId() }, + "Built unsigned loyalty contract invocation" + ); + + return { + xdr: tx.toXDR(), + contractId: contract.contractId(), + networkPassphrase, + }; +}; + +/** + * Build an unsigned `earn(user, activity, spend_amount)` invocation so a user + * can claim points for a completed platform activity. + * + * @param {{sourcePublicKey: string, activity: string, spendAmount?: number|string}} params + * `spendAmount` is required for purchases (raw 7-decimal asset units) and + * ignored for flat-bonus activities. + */ +export const buildEarnPointsTx = ({ sourcePublicKey, activity, spendAmount = 0 }) => + buildInvokeTx(sourcePublicKey, (contract) => + contract.call( + "earn", + addressScVal(sourcePublicKey), + activityScVal(activity), + i128ScVal(spendAmount) + ) + ); + +/** + * Build an unsigned `redeem(user, amount)` invocation that burns points + * against a discount/reward. + * + * @param {{sourcePublicKey: string, amount: number|string}} params + */ +export const buildRedeemPointsTx = ({ sourcePublicKey, amount }) => + buildInvokeTx( + sourcePublicKey, + (contract) => + contract.call("redeem", addressScVal(sourcePublicKey), i128ScVal(amount)), + { memo: "DeenBridge Rewards" } + ); + +/** + * Build an unsigned `transfer(from, to, amount)` invocation for gifting. + * + * @param {{sourcePublicKey: string, destinationPublicKey: string, amount: number|string}} params + */ +export const buildTransferPointsTx = ({ + sourcePublicKey, + destinationPublicKey, + amount, +}) => + buildInvokeTx( + sourcePublicKey, + (contract) => + contract.call( + "transfer", + addressScVal(sourcePublicKey), + addressScVal(destinationPublicKey), + i128ScVal(amount) + ), + { memo: "DeenBridge Points Gift" } + ); + +/** + * ADMIN FLOW (extension point): build an unsigned `set_rate` invocation. + * Today the admin signs off-platform; wire a secured key management flow + * before ever exposing this through a route. + * + * @param {{adminPublicKey: string, activity: string, rate: number|string}} params + */ +export const buildSetRateTx = ({ adminPublicKey, activity, rate }) => + buildInvokeTx(adminPublicKey, (contract) => + contract.call("set_rate", activityScVal(activity), i128ScVal(rate)) + ); + +/* ------------------------------------------------------------------ */ +/* Read-only queries */ +/* ------------------------------------------------------------------ */ + +/** + * Simulate a read-only contract call without submitting a transaction. + * Simulation requires a funded sequence source, so callers pass any funded + * G… address they control (typically the querying user's own public key). + * + * @param {{sourcePublicKey: string, method: string, args?: StellarSdk.xdr.ScVal[]}} params + * @returns {Promise} raw return value + */ +const queryContract = async ({ sourcePublicKey, method, args = [] }) => { + if (!sourcePublicKey) { + throw new Error( + `A funded sourcePublicKey is required to query "${method}" from the loyalty contract` + ); + } + + const { server, networkPassphrase } = loyaltyRpc(); + const account = await server.getAccount(sourcePublicKey); + const tx = new StellarSdk.TransactionBuilder(account, { + fee: StellarSdk.BASE_FEE, + networkPassphrase, + }) + .addOperation(loyaltyContract().call(method, ...args)) + .setTimeout(60) + .build(); + + const simulation = await server.simulateTransaction(tx); + if (simulation.error || !simulation.result?.retval) { + throw new Error( + `Loyalty query "${method}" failed: ${simulation.error ?? "no result"}` + ); + } + return simulation.result.retval; +}; + +/** + * On-chain point balance for a user. + * + * @param {{publicKey: string, sourcePublicKey?: string}} params + * `sourcePublicKey` defaults to `publicKey` when it is itself funded. + * @returns {Promise} + */ +export const getLoyaltyBalance = async ({ publicKey, sourcePublicKey }) => { + const retval = await queryContract({ + sourcePublicKey: sourcePublicKey ?? publicKey, + method: "balance", + args: [addressScVal(publicKey)], + }); + return StellarSdk.scValToNative(retval); +}; + +/** + * Configured award rate for an activity (0 when never set). + * + * @param {{activity: string, sourcePublicKey: string}} params + * @returns {Promise} + */ +export const getLoyaltyRate = async ({ activity, sourcePublicKey }) => { + const retval = await queryContract({ + sourcePublicKey, + method: "rate", + args: [activityScVal(activity)], + }); + return StellarSdk.scValToNative(retval); +}; + +/** + * Program totals/admin — mirrors the contract's LoyaltyState struct. + * + * @param {{sourcePublicKey: string}} params + * @returns {Promise<{admin: string, totalIssued: bigint, totalRedeemed: bigint}>} + */ +export const getLoyaltyState = async ({ sourcePublicKey }) => { + const retval = await queryContract({ sourcePublicKey, method: "state" }); + const native = StellarSdk.scValToNative(retval); + return { + admin: String(native.admin), + totalIssued: BigInt(native.total_issued ?? 0), + totalRedeemed: BigInt(native.total_redeemed ?? 0), + }; +}; diff --git a/src/services/stellar/onrampService.js b/src/services/stellar/onrampService.js new file mode 100644 index 00000000..5d418210 --- /dev/null +++ b/src/services/stellar/onrampService.js @@ -0,0 +1,235 @@ +// services/stellar/onrampService.js +import crypto from "crypto"; +import logger from "../../config/logger.js"; +import { isValidPublicKey } from "./stellarService.js"; + +/** + * Fiat on-ramp integration (MoonPay). + * + * Provides the widget-URL builder (with the MoonPay `signature` HMAC), webhook + * signature verification, and provider→internal status mapping used by the + * on-ramp controller. All provider credentials are read lazily from the + * environment so the module imports cleanly even when nothing is configured. + * + * Required environment variables: + * - MOONPAY_API_KEY Publishable key embedded in the widget URL. + * - MOONPAY_SECRET_KEY Secret used to sign widget URLs (server-side only). + * - MOONPAY_WEBHOOK_KEY Secret used to verify inbound webhook signatures. + * Falls back to MOONPAY_SECRET_KEY when unset. + * Optional: + * - MOONPAY_WIDGET_URL Override the widget base URL. + * - MOONPAY_DEFAULT_CRYPTO_CODE Default crypto currency code (default "usdc"). + * + * @module services/stellar/onrampService + */ + +const DEFAULT_LIVE_WIDGET_URL = "https://buy.moonpay.com"; +const DEFAULT_SANDBOX_WIDGET_URL = "https://buy-sandbox.moonpay.com"; +const DEFAULT_CRYPTO_CODE = "usdc"; + +/** + * Read the current MoonPay configuration from the environment. + * + * Read on each call (rather than cached at import time) so tests and deploys + * can set the variables after the module is first loaded. + * + * @returns {{apiKey: string|undefined, secretKey: string|undefined, + * webhookKey: string|undefined, widgetBaseUrl: string, + * defaultCryptoCode: string}} Resolved config. + */ +export const getMoonpayConfig = () => { + const apiKey = process.env.MOONPAY_API_KEY; + // A live publishable key is prefixed "pk_live_"; anything else (or unset) + // uses the sandbox widget host so test keys never point at production. + const isLive = typeof apiKey === "string" && apiKey.startsWith("pk_live_"); + const widgetBaseUrl = + process.env.MOONPAY_WIDGET_URL || + (isLive ? DEFAULT_LIVE_WIDGET_URL : DEFAULT_SANDBOX_WIDGET_URL); + + return { + apiKey, + secretKey: process.env.MOONPAY_SECRET_KEY, + webhookKey: process.env.MOONPAY_WEBHOOK_KEY || process.env.MOONPAY_SECRET_KEY, + widgetBaseUrl, + defaultCryptoCode: + process.env.MOONPAY_DEFAULT_CRYPTO_CODE || DEFAULT_CRYPTO_CODE, + }; +}; + +/** + * Whether the on-ramp is usable (both the publishable key and signing secret + * are present). Used by the controller to return 503 when unconfigured. + * + * @returns {boolean} True when widget URLs can be signed and served. + */ +export const isOnrampConfigured = () => { + const { apiKey, secretKey } = getMoonpayConfig(); + return Boolean(apiKey && secretKey); +}; + +/** + * Build a signed MoonPay buy-widget URL with the user's wallet pre-filled. + * + * The MoonPay `signature` is a base64-encoded HMAC-SHA256 of the full URL query + * string (including the leading "?") using the secret key. It is appended as + * the final `signature` query parameter. + * + * @param {object} params + * @param {string} params.walletAddress Stellar public key to deliver crypto to. + * @param {string} [params.cryptoCurrency] Crypto currency code (default from env). + * @param {string} [params.baseCurrencyCode] Fiat currency code (e.g. "usd"). + * @param {number|string} [params.baseCurrencyAmount] Fiat amount to prefill. + * @param {string} [params.externalTransactionId] Our transaction id, echoed back + * in webhooks so a provider event can be linked to the originating record. + * @param {string} [params.email] Customer email to prefill in the widget. + * @param {string} [params.redirectUrl] URL MoonPay redirects to on completion. + * @returns {{url: string, cryptoCurrency: string}} Signed widget URL and the + * resolved crypto currency code. + * @throws {Error} With `statusCode` set when unconfigured or inputs are invalid. + */ +export const buildWidgetUrl = ({ + walletAddress, + cryptoCurrency, + baseCurrencyCode, + baseCurrencyAmount, + externalTransactionId, + email, + redirectUrl, +} = {}) => { + const { apiKey, secretKey, widgetBaseUrl, defaultCryptoCode } = + getMoonpayConfig(); + + if (!apiKey || !secretKey) { + const error = new Error( + "Fiat on-ramp is not available right now. Please try again later." + ); + error.statusCode = 503; + throw error; + } + if (!walletAddress || !isValidPublicKey(walletAddress)) { + const error = new Error("Invalid Stellar wallet address"); + error.statusCode = 400; + throw error; + } + + const currencyCode = (cryptoCurrency || defaultCryptoCode).toLowerCase(); + + const url = new URL(widgetBaseUrl); + url.searchParams.append("apiKey", apiKey); + url.searchParams.append("currencyCode", currencyCode); + url.searchParams.append("walletAddress", walletAddress); + + if (baseCurrencyCode) { + url.searchParams.append("baseCurrencyCode", baseCurrencyCode.toLowerCase()); + } + if (baseCurrencyAmount !== undefined && baseCurrencyAmount !== null) { + url.searchParams.append("baseCurrencyAmount", String(baseCurrencyAmount)); + } + if (externalTransactionId) { + url.searchParams.append("externalTransactionId", String(externalTransactionId)); + } + if (email) { + url.searchParams.append("email", email); + } + if (redirectUrl) { + url.searchParams.append("redirectURL", redirectUrl); + } + + // MoonPay signs the query string including the leading "?". + const signature = crypto + .createHmac("sha256", secretKey) + .update(url.search) + .digest("base64"); + url.searchParams.append("signature", signature); + + return { url: url.toString(), cryptoCurrency: currencyCode }; +}; + +/** + * Constant-time comparison of two signature strings of possibly differing + * length (returns false instead of throwing on a length mismatch). + * + * @param {string} a First value. + * @param {string} b Second value. + * @returns {boolean} True when equal. + */ +const safeEqual = (a, b) => { + const bufA = Buffer.from(String(a)); + const bufB = Buffer.from(String(b)); + if (bufA.length !== bufB.length) return false; + return crypto.timingSafeEqual(bufA, bufB); +}; + +/** + * Verify a MoonPay webhook signature against the raw request body. + * + * MoonPay sends the `Moonpay-Signature-V2` header formatted as + * `t=,s=`, where the signed payload is + * `.` HMAC-SHA256 hex-digested with the webhook key. A bare + * hex signature (HMAC of the raw body alone) is also accepted as a fallback. + * + * @param {Buffer|string} rawBody Exact bytes of the request body. + * @param {string} signatureHeader Value of the signature header. + * @returns {boolean} True when the signature is valid and the webhook key is set. + */ +export const verifyWebhookSignature = (rawBody, signatureHeader) => { + const { webhookKey } = getMoonpayConfig(); + if (!webhookKey || !signatureHeader || rawBody === undefined || rawBody === null) { + return false; + } + + const payload = Buffer.isBuffer(rawBody) ? rawBody.toString("utf8") : String(rawBody); + + // Parse the "t=...,s=..." structured header when present. + let timestamp; + let signature; + for (const part of String(signatureHeader).split(",")) { + const [key, value] = part.split("="); + if (key && value !== undefined) { + const trimmedKey = key.trim(); + if (trimmedKey === "t") timestamp = value.trim(); + else if (trimmedKey === "s") signature = value.trim(); + } + } + + if (timestamp && signature) { + const expected = crypto + .createHmac("sha256", webhookKey) + .update(`${timestamp}.${payload}`) + .digest("hex"); + return safeEqual(expected, signature); + } + + // Fallback: header is a bare signature over the raw body. + const bare = String(signatureHeader).trim(); + const expectedBody = crypto + .createHmac("sha256", webhookKey) + .update(payload) + .digest("hex"); + return safeEqual(expectedBody, bare); +}; + +/** + * Map a raw MoonPay transaction status to an internal ONRAMP_STATUS. + * + * MoonPay states: `waitingPayment`, `pending`, `waitingAuthorization`, + * `completed`, `failed`. + * + * @param {string} providerStatus Raw provider status. + * @returns {string} One of the internal ONRAMP_STATUSES values. + */ +export const mapProviderStatus = (providerStatus) => { + switch (providerStatus) { + case "completed": + return "completed"; + case "failed": + return "failed"; + case "waitingPayment": + case "pending": + case "waitingAuthorization": + return "pending"; + default: + logger.warn(`Unknown MoonPay on-ramp status: ${providerStatus}`); + return "pending"; + } +}; diff --git a/src/services/stellar/reconciliationService.js b/src/services/stellar/reconciliationService.js index 41d7d1df..5703e148 100644 --- a/src/services/stellar/reconciliationService.js +++ b/src/services/stellar/reconciliationService.js @@ -55,6 +55,7 @@ const promoteTransaction = async (transaction, paymentRecord) => { transaction.stellarLedger = paymentRecord.ledger || undefined; transaction.status = "confirmed"; transaction.confirmedAt = new Date(); + transaction.expiresAt = undefined; // terminal state — never TTL-reapable await transaction.save(); await recordSaleEarnings(transaction); @@ -86,6 +87,10 @@ const createConfirmedDonation = async ({ sourceAccount, amount, hash, memo }) => status: "confirmed", stellarTxHash: hash, confirmedAt: new Date(), + // Terminal state: the conditional schema default omits expiresAt, and the + // pre-save hook enforces it — an already-confirmed row must never carry a + // TTL deadline. + expiresAt: undefined, }); await donation.save(); @@ -130,6 +135,10 @@ const createConfirmedPurchase = async ({ sourceAccount, amount, hash, memo, item status: "confirmed", stellarTxHash: hash, confirmedAt: new Date(), + // Terminal state: the conditional schema default omits expiresAt, and the + // pre-save hook enforces it — an already-confirmed row must never carry a + // TTL deadline. + expiresAt: undefined, }); await purchase.save(); diff --git a/src/services/stellar/stellarService.js b/src/services/stellar/stellarService.js index 7f1b6600..3d25dcff 100644 --- a/src/services/stellar/stellarService.js +++ b/src/services/stellar/stellarService.js @@ -8,21 +8,23 @@ import { getDefaultAssetCode, getSupportedCodes, } from "../../config/assets.js"; +import { resolveStellarConfig } from "../../config/stellar.js"; import { client } from "./horizonClient.js"; -const NETWORK = process.env.STELLAR_NETWORK || "testnet"; -const networkPassphrase = - NETWORK === "mainnet" - ? StellarSdk.Networks.PUBLIC - : StellarSdk.Networks.TESTNET; - -// Back-compat: USDC / USDC_ISSUER are now derived from the registry -// instead of being hardcoded, but keep the same exported shape so -// existing callers (and the path-payment flow, out of scope for #60) -// keep working unchanged. -const USDC_CONFIG = getAssetConfig("USDC", NETWORK); -const USDC_ISSUER = USDC_CONFIG.issuer; +// Single source of truth for network identity (see config/stellar.js): +// network name, network passphrase, Horizon URLs, and USDC issuer all +// resolve here so a deployment can never mix testnet/mainnet settings. +const { + network: NETWORK, + networkPassphrase, + usdcIssuer: USDC_ISSUER, +} = resolveStellarConfig(); + +// Back-compat: USDC / USDC_ISSUER are derived from the registry via the +// config module instead of being hardcoded, but keep the same exported +// shape so existing callers (and the path-payment flow, out of scope for +// #60) keep working unchanged. const USDC = new StellarSdk.Asset("USDC", USDC_ISSUER); const DEFAULT_ASSET_CODE = getDefaultAssetCode(NETWORK); @@ -614,6 +616,10 @@ export const submitTransaction = async (signedXdr) => { hash: result.hash, ledger: result.ledger, successful: result.successful, + // `fee_charged` is the actual fee the network took. For a fee-bump + // submission (#30) this is what the sponsor account paid; undefined for + // transactions where the response omits it (e.g. the verifyFn dedupe path). + feeCharged: result.fee_charged, }; } catch (error) { logger.error("Error submitting transaction:", error); @@ -646,6 +652,71 @@ export const submitTransaction = async (signedXdr) => { } }; +/** + * Validate a signed transaction XDR against expected payments and memo/source + * @param {string} signedXdr + * @param {Array<{destination:string, amount:string}>} expectedPayments + * @param {string} expectedMemo + * @param {string} expectedSource + * @param {boolean} requireSource + */ +export const validateSignedPaymentXdr = ( + signedXdr, + expectedPayments = [], + expectedMemo, + expectedSource, + requireSource = true +) => { + const tx = StellarSdk.TransactionBuilder.fromXDR( + signedXdr, + networkPassphrase + ); + + // Memo check + if (expectedMemo) { + const memo = tx.memo; + let memoText = null; + if (memo && memo._type === "text") { + const val = memo._value; + memoText = Buffer.isBuffer(val) ? val.toString() : String(val); + } + if (memoText !== expectedMemo) { + throw new Error("Memo mismatch"); + } + } + + // Source check + if (requireSource && expectedSource) { + if (tx.source !== expectedSource) { + throw new Error("Source account mismatch"); + } + } + + // Payment operations check + const paymentOps = tx.operations.filter((op) => op.type === "payment"); + + for (const expected of expectedPayments) { + const match = paymentOps.find((op) => { + const assetMatches = + (op.asset && op.asset.code === "USDC" && op.asset.issuer === USDC_ISSUER) || + (op.asset_type === "credit_alphanum4" && op.asset?.code === "USDC" && op.asset?.issuer === USDC_ISSUER); + + const amountMatches = toStroops(op.amount) === toStroops(expected.amount); + const destMatches = op.destination === expected.destination; + + return assetMatches && amountMatches && destMatches; + }); + + if (!match) { + throw new Error( + `Signed XDR missing expected USDC payment of ${expected.amount} to ${expected.destination}` + ); + } + } + + return tx; +}; + export const verifyTransaction = async (txHash) => { try { const tx = await timedHorizonCall("fetchTransaction", () => diff --git a/src/services/webhooks/deliveryWorker.js b/src/services/webhooks/deliveryWorker.js new file mode 100644 index 00000000..6ad3f1d0 --- /dev/null +++ b/src/services/webhooks/deliveryWorker.js @@ -0,0 +1,308 @@ +// services/webhooks/deliveryWorker.js +// +// Out-of-band delivery loop for outbound webhooks. Claims one due delivery at +// a time with an atomic findOneAndUpdate (so two loops/instances never +// double-send the same row), POSTs the signed body with a strict timeout and +// NO redirect following, and records the outcome. Failures are retried on an +// exponential backoff-with-jitter schedule and land in `dead` after the max +// attempts. Sustained dead deliveries auto-disable the endpoint. +// +// All scheduling state lives in the WebhookDelivery document, so this loop can +// later be replaced by the durable job queue (issue #32) without schema change. +import axios from "axios"; +import mongoose from "mongoose"; +import WebhookEndpoint from "../../models/WebhookEndpoint.js"; +import WebhookDelivery, { + MAX_STORED_ATTEMPTS, + MAX_ERROR_LENGTH, +} from "../../models/WebhookDelivery.js"; +import logger from "../../config/logger.js"; +import { decryptSecret } from "./webhookSecret.js"; +import { signPayload, WEBHOOK_HEADERS } from "./signing.js"; +import { assertDeliverableUrl } from "./urlGuard.js"; + +// Backoff schedule between attempts: 1m, 5m, 30m, 2h, 12h. +export const BACKOFF_SCHEDULE_MS = [ + 60_000, + 5 * 60_000, + 30 * 60_000, + 2 * 60 * 60_000, + 12 * 60 * 60_000, +]; + +// Total delivery attempts before a delivery is declared dead. +export const MAX_ATTEMPTS = parseInt(process.env.WEBHOOK_MAX_ATTEMPTS || "6", 10); + +// Consecutive dead deliveries that auto-disable an endpoint. +export const AUTO_DISABLE_THRESHOLD = parseInt( + process.env.WEBHOOK_AUTO_DISABLE_THRESHOLD || "5", + 10 +); + +// Max random jitter added to each backoff. Tests set this to 0 for +// deterministic scheduling assertions. +const BACKOFF_JITTER_MS = parseInt(process.env.WEBHOOK_BACKOFF_JITTER_MS || "30000", 10); + +// While a claim is in flight the row's nextAttemptAt is pushed forward by this +// lock window so a concurrent tick cannot re-claim it mid-POST. +const CLAIM_LOCK_MS = 30_000; + +const HTTP_TIMEOUT_MS = parseInt(process.env.WEBHOOK_HTTP_TIMEOUT_MS || "10000", 10); +const POLL_INTERVAL_MS = parseInt(process.env.WEBHOOK_POLL_INTERVAL_MS || "5000", 10); +const MAX_PER_TICK = parseInt(process.env.WEBHOOK_MAX_PER_TICK || "50", 10); + +/** + * Compute the delay before the next attempt given how many attempts have + * already been made. `attemptCount` is 1-based (1 = first attempt just failed). + */ +export const computeBackoffMs = (attemptCount) => { + const idx = Math.min(attemptCount - 1, BACKOFF_SCHEDULE_MS.length - 1); + const base = BACKOFF_SCHEDULE_MS[Math.max(0, idx)]; + const jitter = BACKOFF_JITTER_MS > 0 ? Math.floor(Math.random() * BACKOFF_JITTER_MS) : 0; + return base + jitter; +}; + +const truncate = (str) => + typeof str === "string" && str.length > MAX_ERROR_LENGTH + ? str.slice(0, MAX_ERROR_LENGTH) + : str; + +// Default HTTP client: a thin axios wrapper that never throws on status and +// never follows redirects. Tests inject their own `post` to stay offline. +const defaultPost = async (url, body, headers) => { + const res = await axios.post(url, body, { + headers, + timeout: HTTP_TIMEOUT_MS, + maxRedirects: 0, + // We classify status ourselves; don't let axios throw on 4xx/5xx. + validateStatus: () => true, + transformRequest: [(data) => data], // body is already a serialized string + }); + return { status: res.status }; +}; + +/** + * Atomically claim the next due delivery. Only ONE concurrent caller can win a + * given row: the claim flips it to `retrying`, increments `attemptCount`, and + * pushes `nextAttemptAt` forward by the lock window in a single update. + * + * @returns {Promise} + */ +export const claimNextDelivery = async (now = new Date()) => { + return WebhookDelivery.findOneAndUpdate( + { + status: { $in: ["pending", "retrying"] }, + nextAttemptAt: { $lte: now }, + }, + { + $set: { status: "retrying", nextAttemptAt: new Date(now.getTime() + CLAIM_LOCK_MS) }, + $inc: { attemptCount: 1 }, + }, + { new: true, sort: { nextAttemptAt: 1 } } + ).select("+_id"); +}; + +const recordSuccessOnEndpoint = async (endpointId, when) => { + await WebhookEndpoint.updateOne( + { _id: endpointId }, + { $set: { consecutiveFailures: 0, lastSuccessAt: when, lastDeliveryAt: when } } + ); +}; + +const recordDeadOnEndpoint = async (endpointId, when) => { + const ep = await WebhookEndpoint.findOneAndUpdate( + { _id: endpointId }, + { $inc: { consecutiveFailures: 1 }, $set: { lastDeliveryAt: when } }, + { new: true } + ); + if (ep && ep.isActive && ep.consecutiveFailures >= AUTO_DISABLE_THRESHOLD) { + ep.isActive = false; + ep.disabledAt = when; + ep.disabledReason = `Auto-disabled after ${ep.consecutiveFailures} consecutive failed deliveries`; + await ep.save(); + logger.warn( + { endpointId: String(endpointId), consecutiveFailures: ep.consecutiveFailures }, + "webhook: endpoint auto-disabled after sustained failures" + ); + } +}; + +/** + * Deliver a single already-claimed delivery: sign, POST, record the outcome, + * and schedule a retry / mark dead / mark delivered as appropriate. + * + * @param {Document} delivery a claimed (status `retrying`) delivery document + * @param {object} [opts] + * @param {Function} [opts.post] injected HTTP client `(url, body, headers) => { status }` + * @param {Date} [opts.now] + * @returns {Promise} the updated delivery + */ +export const deliverClaimed = async (delivery, { post = defaultPost, now = new Date() } = {}) => { + const endpoint = await WebhookEndpoint.findById(delivery.endpoint).select( + "+secretEncrypted" + ); + + // Endpoint gone or deactivated — nothing to deliver to. Mark dead so it + // doesn't churn forever. + if (!endpoint || !endpoint.isActive) { + delivery.status = "dead"; + delivery.lastError = "Endpoint missing or inactive"; + delivery.attempts.push({ + at: now, + error: delivery.lastError, + durationMs: 0, + }); + await delivery.save(); + return delivery; + } + + // Delivery-time SSRF re-check (DNS resolution in production). + const guard = await assertDeliverableUrl(endpoint.url); + if (!guard.ok) { + return finalizeFailure(delivery, endpoint, { + statusCode: undefined, + error: `Blocked by SSRF guard: ${guard.reason}`, + durationMs: 0, + now, + }); + } + + let secret; + try { + secret = decryptSecret(endpoint.secretEncrypted); + } catch (err) { + return finalizeFailure(delivery, endpoint, { + statusCode: undefined, + error: `Secret decrypt failed: ${err.message}`, + durationMs: 0, + now, + }); + } + + // Serialize ONCE, sign those exact bytes, POST the same string. + const rawBody = JSON.stringify(delivery.payload); + const timestamp = Math.floor(now.getTime() / 1000).toString(); + const signature = signPayload({ secret, timestamp, rawBody }); + + const headers = { + "Content-Type": "application/json", + [WEBHOOK_HEADERS.EVENT]: delivery.eventType, + [WEBHOOK_HEADERS.EVENT_ID]: delivery.eventId, + [WEBHOOK_HEADERS.TIMESTAMP]: timestamp, + [WEBHOOK_HEADERS.SIGNATURE]: signature, + }; + + const started = Date.now(); + let statusCode; + let error; + try { + const res = await post(endpoint.url, rawBody, headers); + statusCode = res?.status; + } catch (err) { + error = err?.message || "delivery request failed"; + } + const durationMs = Date.now() - started; + + const delivered = statusCode >= 200 && statusCode < 300; + if (delivered) { + delivery.status = "delivered"; + delivery.deliveredAt = now; + delivery.lastError = undefined; + pushAttempt(delivery, { at: now, statusCode, durationMs }); + await delivery.save(); + await recordSuccessOnEndpoint(endpoint._id, now); + return delivery; + } + + return finalizeFailure(delivery, endpoint, { + statusCode, + error: error || `Non-2xx response: ${statusCode}`, + durationMs, + now, + }); +}; + +const pushAttempt = (delivery, attempt) => { + delivery.attempts.push({ ...attempt, error: truncate(attempt.error) }); + if (delivery.attempts.length > MAX_STORED_ATTEMPTS) { + delivery.attempts = delivery.attempts.slice(-MAX_STORED_ATTEMPTS); + } +}; + +async function finalizeFailure(delivery, endpoint, { statusCode, error, durationMs, now }) { + pushAttempt(delivery, { at: now, statusCode, error, durationMs }); + delivery.lastError = truncate(error); + + if (delivery.attemptCount >= MAX_ATTEMPTS) { + delivery.status = "dead"; + await delivery.save(); + await recordDeadOnEndpoint(endpoint._id, now); + logger.warn( + { deliveryId: String(delivery._id), attempts: delivery.attemptCount }, + "webhook: delivery moved to dead-letter" + ); + } else { + delivery.status = "retrying"; + delivery.nextAttemptAt = new Date(now.getTime() + computeBackoffMs(delivery.attemptCount)); + await delivery.save(); + } + return delivery; +} + +/** + * Claim + deliver a single due delivery. Returns the delivery, or null when + * nothing is due. Used by the interval loop and driveable directly by tests. + */ +export const processOne = async ({ post = defaultPost, now = new Date() } = {}) => { + const delivery = await claimNextDelivery(now); + if (!delivery) return null; + return deliverClaimed(delivery, { post, now }); +}; + +/** + * Drain all currently-due deliveries (bounded per invocation). + * @returns {Promise} number of deliveries processed + */ +export const runDueDeliveries = async ({ post = defaultPost, now = new Date() } = {}) => { + let processed = 0; + while (processed < MAX_PER_TICK) { + const delivery = await processOne({ post, now }); + if (!delivery) break; + processed += 1; + } + return processed; +}; + +// ── Interval loop (guarded by env flag, like the ingestion worker) ────────── +let running = false; +let pollTimer = null; + +const loop = async () => { + if (!running) return; + try { + if (mongoose.connection.readyState === 1) { + await runDueDeliveries({ now: new Date() }); + } + } catch (err) { + logger.error({ err }, "webhook: delivery loop iteration failed"); + } + if (!running) return; + pollTimer = setTimeout(loop, POLL_INTERVAL_MS); + if (pollTimer && typeof pollTimer.unref === "function") pollTimer.unref(); +}; + +export const startDeliveryWorker = async () => { + if (running) return; + running = true; + logger.info({ intervalMs: POLL_INTERVAL_MS }, "webhook: delivery worker started"); + loop(); +}; + +export const stopDeliveryWorker = async () => { + running = false; + if (pollTimer) { + clearTimeout(pollTimer); + pollTimer = null; + } + logger.info("webhook: delivery worker stopped"); +}; diff --git a/src/services/webhooks/signing.js b/src/services/webhooks/signing.js new file mode 100644 index 00000000..f5d6950a --- /dev/null +++ b/src/services/webhooks/signing.js @@ -0,0 +1,83 @@ +// services/webhooks/signing.js +// +// HMAC-SHA256 request signing for outbound webhooks (Stripe/Svix-style). +// +// Canonical string that is signed: +// +// `${timestamp}.${rawBody}` +// +// where `timestamp` is unix seconds (as a string) and `rawBody` is the EXACT +// serialized bytes that are POSTed. Serialize the body ONCE, sign those bytes, +// and send the same buffer — re-serializing JSON can reorder keys and break +// verification on the consumer side. +import crypto from "crypto"; + +export const SIGNATURE_VERSION = "v1"; + +// Consumers must reject deliveries whose timestamp is older than this to +// blunt replay attacks. Documented in docs/webhooks.md. +export const DEFAULT_TOLERANCE_SEC = 300; // 5 minutes + +export const WEBHOOK_HEADERS = Object.freeze({ + EVENT: "X-DeenBridge-Event", + EVENT_ID: "X-DeenBridge-Event-Id", + TIMESTAMP: "X-DeenBridge-Timestamp", + SIGNATURE: "X-DeenBridge-Signature", +}); + +/** + * Build the canonical string that gets HMAC'd. + * @param {string|number} timestamp unix seconds + * @param {string} rawBody the exact serialized body being sent + */ +export const buildSignatureBase = (timestamp, rawBody) => + `${timestamp}.${rawBody}`; + +/** + * Produce the value for the X-DeenBridge-Signature header: + * `v1=` + */ +export const signPayload = ({ secret, timestamp, rawBody }) => { + const digest = crypto + .createHmac("sha256", secret) + .update(buildSignatureBase(timestamp, rawBody)) + .digest("hex"); + return `${SIGNATURE_VERSION}=${digest}`; +}; + +/** + * Constant-time verification of a signature header. This mirrors the snippet + * documented for consumers in docs/webhooks.md and is used by the test suite. + * + * @returns {boolean} true only if the version matches, the timestamp is fresh, + * and the HMAC matches in constant time. + */ +export const verifySignature = ({ + secret, + timestamp, + rawBody, + signatureHeader, + toleranceSec = DEFAULT_TOLERANCE_SEC, +}) => { + if (!signatureHeader || typeof signatureHeader !== "string") return false; + + const [version, provided] = signatureHeader.split("="); + if (version !== SIGNATURE_VERSION || !provided) return false; + + // Reject stale timestamps (replay protection). + const ts = Number(timestamp); + if (!Number.isFinite(ts)) return false; + const nowSec = Math.floor(Date.now() / 1000); + if (Math.abs(nowSec - ts) > toleranceSec) return false; + + const expected = crypto + .createHmac("sha256", secret) + .update(buildSignatureBase(timestamp, rawBody)) + .digest("hex"); + + // timingSafeEqual throws if the buffers differ in length, so guard first. + const a = Buffer.from(expected, "hex"); + const b = Buffer.from(provided, "hex"); + if (a.length !== b.length) return false; + return crypto.timingSafeEqual(a, b); +}; diff --git a/src/services/webhooks/urlGuard.js b/src/services/webhooks/urlGuard.js new file mode 100644 index 00000000..31352fd5 --- /dev/null +++ b/src/services/webhooks/urlGuard.js @@ -0,0 +1,135 @@ +// services/webhooks/urlGuard.js +// +// SSRF guard for outbound webhook targets. Validated at BOTH registration time +// (synchronous, structural checks) and delivery time (DNS resolution in +// production). Rejects non-https (outside development), loopback, RFC-1918 +// private ranges, link-local, and other non-routable targets. +// +// RESIDUAL LIMITATION (TOCTOU): DNS is resolved at delivery time, but a +// malicious operator who controls the endpoint's DNS could still rebind the +// hostname to a private address in the window between our resolution and the +// actual socket connect. Fully closing this requires pinning the resolved IP +// onto the connecting socket (custom agent/lookup), which is out of scope +// here. Registration-time literal-IP checks plus delivery-time resolution +// cover the common cases. +import net from "net"; +import dns from "dns"; + +const isDevelopment = () => process.env.NODE_ENV === "development"; + +/** + * Classify an IPv4/IPv6 address as private / non-routable and therefore an + * illegitimate webhook target. + */ +export const isPrivateAddress = (ip) => { + const family = net.isIP(ip); + if (family === 4) { + const parts = ip.split(".").map(Number); + const [a, b] = parts; + if (a === 0) return true; // 0.0.0.0/8 "this network" + if (a === 10) return true; // 10.0.0.0/8 + if (a === 127) return true; // loopback + if (a === 169 && b === 254) return true; // link-local + if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 + if (a === 192 && b === 168) return true; // 192.168.0.0/16 + if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT + if (a >= 224) return true; // multicast / reserved + return false; + } + if (family === 6) { + const addr = ip.toLowerCase(); + if (addr === "::1" || addr === "::") return true; // loopback / unspecified + if (addr.startsWith("fe80")) return true; // link-local + if (addr.startsWith("fc") || addr.startsWith("fd")) return true; // unique local fc00::/7 + // IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1) — re-check the embedded v4. + const mapped = addr.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/); + if (mapped) return isPrivateAddress(mapped[1]); + return false; + } + return false; +}; + +/** + * Structural, synchronous validation used at registration time and as a first + * pass at delivery time. Throws Error with a human-readable message on failure. + * @returns {URL} the parsed URL + */ +export const validateWebhookUrl = (rawUrl) => { + let url; + try { + url = new URL(rawUrl); + } catch { + throw new Error("Invalid webhook URL"); + } + + if (url.protocol !== "https:" && url.protocol !== "http:") { + throw new Error("Webhook URL must use http or https"); + } + + // https is mandatory everywhere except local development. + if (url.protocol !== "https:" && !isDevelopment()) { + throw new Error("Webhook URL must use https"); + } + + const hostname = url.hostname.toLowerCase(); + if (!hostname) { + throw new Error("Webhook URL must include a host"); + } + if (hostname === "localhost" || hostname.endsWith(".localhost")) { + throw new Error("Webhook URL host is not allowed (loopback)"); + } + + // If the host is a literal IP, reject private/non-routable ranges outright — + // no DNS needed, and enforced in every environment. + if (net.isIP(hostname) && isPrivateAddress(hostname)) { + throw new Error("Webhook URL points at a private or non-routable address"); + } + + return url; +}; + +/** + * Delivery-time (and production registration-time) check: resolve the hostname + * and reject if ANY resolved address is private/non-routable. In non-production + * environments only the structural checks run (DNS is skipped so tests and dev + * don't depend on network resolution). + * + * @returns {Promise<{ ok: boolean, reason?: string }>} + */ +export const assertDeliverableUrl = async (rawUrl) => { + let url; + try { + url = validateWebhookUrl(rawUrl); + } catch (err) { + return { ok: false, reason: err.message }; + } + + // Only resolve DNS in production. Elsewhere the structural checks above + // (including literal-IP rejection) are sufficient and keep the worker + // offline-testable. + if (process.env.NODE_ENV !== "production") { + return { ok: true }; + } + + const hostname = url.hostname.toLowerCase(); + if (net.isIP(hostname)) { + // Already validated as a public literal above. + return { ok: true }; + } + + try { + const records = await dns.promises.lookup(hostname, { all: true }); + for (const { address } of records) { + if (isPrivateAddress(address)) { + return { + ok: false, + reason: `Resolved address ${address} is private or non-routable`, + }; + } + } + } catch (err) { + return { ok: false, reason: `DNS resolution failed: ${err.message}` }; + } + + return { ok: true }; +}; diff --git a/src/services/webhooks/webhookSecret.js b/src/services/webhooks/webhookSecret.js new file mode 100644 index 00000000..75fb7cbb --- /dev/null +++ b/src/services/webhooks/webhookSecret.js @@ -0,0 +1,69 @@ +// services/webhooks/webhookSecret.js +// +// Webhook signing secrets are stored ENCRYPTED at rest (AES-256-GCM), not +// hashed — the delivery worker must recover the plaintext to compute the HMAC +// signature on every attempt, so a one-way hash is not an option. The secret +// is generated server-side, returned to the caller exactly once at creation +// (and once again on rotation), and never returned by any read endpoint. +// +// The encryption key is derived (SHA-256) from WEBHOOK_SECRET_ENCRYPTION_KEY. +// That variable is REQUIRED in production (validateEnv fails fast if missing); +// in development/test a fixed fallback key is used so the app boots without +// extra setup. Rotating WEBHOOK_SECRET_ENCRYPTION_KEY invalidates all stored +// secrets — rotate individual endpoint secrets via the API instead. +import crypto from "crypto"; + +const ALGORITHM = "aes-256-gcm"; +const IV_LENGTH = 12; // GCM standard nonce length +const DEV_FALLBACK_KEY_MATERIAL = "dnb-webhook-dev-fallback-key-do-not-use-in-prod"; + +const deriveKey = () => { + const material = process.env.WEBHOOK_SECRET_ENCRYPTION_KEY; + if (!material) { + if (process.env.NODE_ENV === "production") { + // Should never happen — validateEnv fails fast — but never fall back to + // a well-known key in production. + throw new Error("WEBHOOK_SECRET_ENCRYPTION_KEY is required in production"); + } + return crypto.createHash("sha256").update(DEV_FALLBACK_KEY_MATERIAL).digest(); + } + return crypto.createHash("sha256").update(material).digest(); +}; + +/** Generate a fresh, high-entropy webhook signing secret (hex). */ +export const generateSecret = () => crypto.randomBytes(32).toString("hex"); + +/** + * Encrypt a plaintext secret for storage. Returns `iv:authTag:ciphertext`, + * all hex-encoded. + */ +export const encryptSecret = (plaintext) => { + const key = deriveKey(); + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + const ciphertext = Buffer.concat([ + cipher.update(plaintext, "utf8"), + cipher.final(), + ]); + const authTag = cipher.getAuthTag(); + return [iv.toString("hex"), authTag.toString("hex"), ciphertext.toString("hex")].join(":"); +}; + +/** Decrypt a stored `iv:authTag:ciphertext` secret back to plaintext. */ +export const decryptSecret = (stored) => { + if (!stored || typeof stored !== "string") { + throw new Error("No stored secret to decrypt"); + } + const [ivHex, tagHex, ctHex] = stored.split(":"); + if (!ivHex || !tagHex || !ctHex) { + throw new Error("Malformed stored webhook secret"); + } + const key = deriveKey(); + const decipher = crypto.createDecipheriv(ALGORITHM, key, Buffer.from(ivHex, "hex")); + decipher.setAuthTag(Buffer.from(tagHex, "hex")); + const plaintext = Buffer.concat([ + decipher.update(Buffer.from(ctHex, "hex")), + decipher.final(), + ]); + return plaintext.toString("utf8"); +}; diff --git a/src/services/webhooks/webhookService.js b/src/services/webhooks/webhookService.js new file mode 100644 index 00000000..3ff33637 --- /dev/null +++ b/src/services/webhooks/webhookService.js @@ -0,0 +1,166 @@ +// services/webhooks/webhookService.js +// +// Typed event catalog and the fire-and-forget `emitEvent` used by controllers. +// +// CONTRACT: emitEvent MUST NEVER throw or reject into the request path. It is +// called AFTER the Mongo transaction commits (a rolled-back write emits +// nothing). It resolves matching active endpoints, persists one +// WebhookDelivery per endpoint (status `pending`, `nextAttemptAt = now`), and +// returns. The delivery worker does the actual HTTP work out of band. All +// errors are caught and logged so a webhook problem can never break a payment. +import crypto from "crypto"; +import mongoose from "mongoose"; +import WebhookEndpoint from "../../models/WebhookEndpoint.js"; +import WebhookDelivery from "../../models/WebhookDelivery.js"; +import logger from "../../config/logger.js"; + +// Bumped when the payload envelope shape changes so consumers can branch. +export const API_VERSION = process.env.WEBHOOK_API_VERSION || "2025-01-01"; + +// The full event catalog. `ping` is emitted only via the management API. +export const EVENT_TYPES = Object.freeze({ + PAYMENT_INITIALIZED: "payment.initialized", + PAYMENT_CONFIRMED: "payment.confirmed", + PAYMENT_FAILED: "payment.failed", + PAYMENT_EXPIRED: "payment.expired", + COURSE_ENROLLED: "course.enrolled", + WALLET_CONNECTED: "wallet.connected", + WALLET_DISCONNECTED: "wallet.disconnected", + PING: "ping", +}); + +const EVENT_CATALOG = new Set(Object.values(EVENT_TYPES)); + +// Explicit allowlist of fields permitted inside `data`. Anything else (emails, +// password hashes, full user documents, secrets) is stripped before the +// envelope is persisted or sent. IDs, wallet public keys, amounts, tx hashes, +// and item references only. +const EVENT_DATA_ALLOWLIST = new Set([ + "transactionId", + "type", + "itemType", + "itemId", + "itemTitle", + "amount", + "currency", + "network", + "settlement", + "stellarTxHash", + "stellarLedger", + "status", + "failureReason", + "buyerId", + "creatorId", + "buyerWallet", + "creatorWallet", + "publicKey", + "courseId", + "userId", + "message", +]); + +/** + * Strip any key not on the allowlist. Never mutates the caller's object. + */ +export const sanitizeEventData = (data) => { + if (!data || typeof data !== "object") return {}; + const safe = {}; + for (const key of Object.keys(data)) { + if (EVENT_DATA_ALLOWLIST.has(key) && data[key] !== undefined) { + safe[key] = data[key]; + } + } + return safe; +}; + +/** + * Build the signed event envelope. Exposed for reuse/testing. + */ +export const buildEventEnvelope = (type, data) => ({ + eventId: crypto.randomUUID(), + type, + createdAt: new Date().toISOString(), + apiVersion: API_VERSION, + data: sanitizeEventData(data), +}); + +const buildPendingDelivery = (endpointId, envelope) => ({ + endpoint: endpointId, + eventId: envelope.eventId, + eventType: envelope.type, + payload: envelope, + status: "pending", + attemptCount: 0, + nextAttemptAt: new Date(), +}); + +/** + * Emit an event to every active endpoint subscribed to it (explicitly or via + * `["*"]`). Never rejects. Awaited after the txn commit; persists rows and returns while + * the HTTP delivery happens out of band (worker). No-ops when the DB is down. + * + * @returns {Promise<{ eventId: string, deliveries: number }>} + */ +export const emitEvent = async (type, data = {}) => { + try { + if (!EVENT_CATALOG.has(type)) { + logger.warn({ type }, "webhook: refusing to emit unknown event type"); + return { eventId: null, deliveries: 0 }; + } + + const envelope = buildEventEnvelope(type, data); + + // Skip persistence when the DB isn't connected (e.g. unit tests that mock + // models without a live connection, or a DB outage) — mirrors the audit + // service, so awaiting emitEvent after commit never hangs the request path. + if (mongoose.connection.readyState !== 1) { + return { eventId: envelope.eventId, deliveries: 0 }; + } + + const endpoints = await WebhookEndpoint.find({ + isActive: true, + $or: [{ events: type }, { events: "*" }], + }).select("_id"); + + if (endpoints.length === 0) { + return { eventId: envelope.eventId, deliveries: 0 }; + } + + const docs = endpoints.map((ep) => buildPendingDelivery(ep._id, envelope)); + await WebhookDelivery.insertMany(docs); + + logger.info( + { eventId: envelope.eventId, type, deliveries: docs.length }, + "webhook: event emitted" + ); + return { eventId: envelope.eventId, deliveries: docs.length }; + } catch (err) { + // Emission must never surface to the request path. + logger.error({ err, type }, "webhook: emitEvent failed"); + return { eventId: null, deliveries: 0 }; + } +}; + +/** + * Emit an event to a single, specific endpoint (used by the `ping` action). + * Also fire-and-forget-safe. Creates the delivery regardless of subscription + * so an operator can test any endpoint. + * + * @returns {Promise<{ eventId: string|null, delivery: object|null }>} + */ +export const emitEventToEndpoint = async (endpointId, type, data = {}) => { + try { + if (!EVENT_CATALOG.has(type)) { + logger.warn({ type }, "webhook: refusing to emit unknown event type"); + return { eventId: null, delivery: null }; + } + const envelope = buildEventEnvelope(type, data); + const delivery = await WebhookDelivery.create( + buildPendingDelivery(endpointId, envelope) + ); + return { eventId: envelope.eventId, delivery }; + } catch (err) { + logger.error({ err, type, endpointId }, "webhook: emitEventToEndpoint failed"); + return { eventId: null, delivery: null }; + } +}; diff --git a/src/sockets/messaging.socket.ts b/src/sockets/messaging.socket.ts new file mode 100644 index 00000000..2aeb2de3 --- /dev/null +++ b/src/sockets/messaging.socket.ts @@ -0,0 +1,76 @@ +import { Server, Socket } from "socket.io"; +import messagingService from "../services/messaging.service.ts"; +import logger from "../config/logger.js"; + +export const initMessagingSocket = (io: Server) => { + const messagingNamespace = io.of("/messaging"); + + messagingNamespace.on("connection", (socket: Socket) => { + logger.info(`Socket connected to /messaging: ${socket.id}`); + + socket.on("join_conversation", (conversationId: string) => { + const room = `dm_${conversationId}`; + socket.join(room); + logger.info(`Socket ${socket.id} joined room ${room}`); + }); + + socket.on("leave_conversation", (conversationId: string) => { + const room = `dm_${conversationId}`; + socket.leave(room); + logger.info(`Socket ${socket.id} left room ${room}`); + }); + + socket.on( + "send_message", + async (data: { + conversationId: string; + senderId: string; + text?: string; + image?: string; + }) => { + try { + const message = await messagingService.sendMessage(data); + const room = `dm_${data.conversationId}`; + messagingNamespace.to(room).emit("new_message", message); + } catch (error: any) { + socket.emit("message_error", { message: error.message }); + } + } + ); + + socket.on( + "typing", + (data: { conversationId: string; userId: string; isTyping: boolean }) => { + const room = `dm_${data.conversationId}`; + socket.to(room).emit("user_typing", data); + } + ); + + socket.on( + "mark_read", + async (data: { conversationId: string; userId: string }) => { + try { + await messagingService.markAsRead({ + conversationId: data.conversationId, + userId: data.userId, + }); + const room = `dm_${data.conversationId}`; + messagingNamespace.to(room).emit("messages_read", { + conversationId: data.conversationId, + userId: data.userId, + }); + } catch (error: any) { + socket.emit("message_error", { message: error.message }); + } + } + ); + + socket.on("disconnect", () => { + logger.info(`Socket disconnected from /messaging: ${socket.id}`); + }); + }); + + return messagingNamespace; +}; + +export default initMessagingSocket; diff --git a/src/sockets/reading-progress.socket.js b/src/sockets/reading-progress.socket.js new file mode 100644 index 00000000..c88f3caf --- /dev/null +++ b/src/sockets/reading-progress.socket.js @@ -0,0 +1,68 @@ +import logger from "../config/logger.js"; + +/** + * Reading-progress real-time sync. + * + * Follows the same convention as space-poll.socket.js: a dedicated namespace + * with per-user rooms. When a socket.io server is attached (see + * initReadingProgressSocket), progress updates written through the service are + * pushed to every other device the same user has connected, so reading + * position stays in sync in real time. + * + * If no socket.io server is wired in, emitProgress() is a safe no-op and the + * REST endpoints + the `version`/`updatedAt` fields on the library listing act + * as a poll-based fallback. Importing this module has NO side effects. + */ + +let ioRef = null; + +const roomFor = (userId) => `reading_progress_${userId}`; + +export const initReadingProgressSocket = (io) => { + ioRef = io; + const progressNamespace = io.of("/reading-progress"); + + progressNamespace.on("connection", (socket) => { + logger.info(`Socket connected to /reading-progress: ${socket.id}`); + + socket.on("join_reading_progress_room", (userId) => { + const room = roomFor(userId); + socket.join(room); + logger.info(`Socket ${socket.id} joined room ${room}`); + }); + + socket.on("leave_reading_progress_room", (userId) => { + const room = roomFor(userId); + socket.leave(room); + logger.info(`Socket ${socket.id} left room ${room}`); + }); + + socket.on("disconnect", () => { + logger.info(`Socket disconnected from /reading-progress: ${socket.id}`); + }); + }); + + return progressNamespace; +}; + +/** + * emitProgress — seam used by the service layer to push a progress update to a + * user's other devices. No-op (returns false) until a socket.io server is + * attached via initReadingProgressSocket, so it is safe to call unconditionally + * and never throws during CI / health-check boots. + */ +export const emitProgress = (userId, progress) => { + if (!ioRef || !userId) return false; + try { + ioRef + .of("/reading-progress") + .to(roomFor(userId)) + .emit("reading_progress_updated", progress); + return true; + } catch (error) { + logger.error("Failed to emit reading progress update:", error); + return false; + } +}; + +export default initReadingProgressSocket; diff --git a/src/sockets/space-poll.socket.js b/src/sockets/space-poll.socket.js new file mode 100644 index 00000000..f7663f0e --- /dev/null +++ b/src/sockets/space-poll.socket.js @@ -0,0 +1,60 @@ +import spacePollService from "../services/space-poll.service.js"; +import logger from "../config/logger.js"; + +export const initSpacePollSocket = (io) => { + const pollNamespace = io.of("/space-polls"); + + pollNamespace.on("connection", (socket) => { + logger.info(`Socket connected to /space-polls: ${socket.id}`); + + socket.on("join_space_poll_room", (spaceId) => { + const room = `space_poll_${spaceId}`; + socket.join(room); + logger.info(`Socket ${socket.id} joined room ${room}`); + }); + + socket.on("leave_space_poll_room", (spaceId) => { + const room = `space_poll_${spaceId}`; + socket.leave(room); + logger.info(`Socket ${socket.id} left room ${room}`); + }); + + socket.on("create_poll", async (data) => { + try { + const poll = await spacePollService.createPoll(data); + const room = `space_poll_${data.spaceId}`; + pollNamespace.to(room).emit("poll_created", poll); + } catch (error) { + socket.emit("poll_error", { message: error.message }); + } + }); + + socket.on("cast_vote", async (data) => { + try { + const updatedPoll = await spacePollService.voteInPoll(data); + const room = `space_poll_${updatedPoll.space}`; + pollNamespace.to(room).emit("poll_results_updated", updatedPoll); + } catch (error) { + socket.emit("poll_error", { message: error.message }); + } + }); + + socket.on("close_poll", async (data) => { + try { + const closedPoll = await spacePollService.closePoll(data); + const room = `space_poll_${closedPoll.space}`; + pollNamespace.to(room).emit("poll_closed", closedPoll); + } catch (error) { + socket.emit("poll_error", { message: error.message }); + } + }); + + socket.on("disconnect", () => { + logger.info(`Socket disconnected from /space-polls: ${socket.id}`); + }); + }); + + return pollNamespace; +}; + +export default initSpacePollSocket; diff --git a/src/sockets/space-poll.socket.ts b/src/sockets/space-poll.socket.ts new file mode 100644 index 00000000..bbad66d7 --- /dev/null +++ b/src/sockets/space-poll.socket.ts @@ -0,0 +1,61 @@ +import { Server, Socket } from "socket.io"; +import spacePollService from "../services/space-poll.service.ts"; +import logger from "../config/logger.js"; + +export const initSpacePollSocket = (io: Server) => { + const pollNamespace = io.of("/space-polls"); + + pollNamespace.on("connection", (socket: Socket) => { + logger.info(`Socket connected to /space-polls: ${socket.id}`); + + socket.on("join_space_poll_room", (spaceId: string) => { + const room = `space_poll_${spaceId}`; + socket.join(room); + logger.info(`Socket ${socket.id} joined room ${room}`); + }); + + socket.on("leave_space_poll_room", (spaceId: string) => { + const room = `space_poll_${spaceId}`; + socket.leave(room); + logger.info(`Socket ${socket.id} left room ${room}`); + }); + + socket.on("create_poll", async (data: { spaceId: string; hostId: string; question: string; options: string[] }) => { + try { + const poll = await spacePollService.createPoll(data); + const room = `space_poll_${data.spaceId}`; + pollNamespace.to(room).emit("poll_created", poll); + } catch (error: any) { + socket.emit("poll_error", { message: error.message }); + } + }); + + socket.on("cast_vote", async (data: { pollId: string; userId: string; optionIndex: number }) => { + try { + const updatedPoll = await spacePollService.voteInPoll(data); + const room = `space_poll_${updatedPoll.space}`; + pollNamespace.to(room).emit("poll_results_updated", updatedPoll); + } catch (error: any) { + socket.emit("poll_error", { message: error.message }); + } + }); + + socket.on("close_poll", async (data: { pollId: string; hostId: string }) => { + try { + const closedPoll = await spacePollService.closePoll(data); + const room = `space_poll_${closedPoll.space}`; + pollNamespace.to(room).emit("poll_closed", closedPoll); + } catch (error: any) { + socket.emit("poll_error", { message: error.message }); + } + }); + + socket.on("disconnect", () => { + logger.info(`Socket disconnected from /space-polls: ${socket.id}`); + }); + }); + + return pollNamespace; +}; + +export default initSpacePollSocket; diff --git a/src/templates/certificate.template.js b/src/templates/certificate.template.js new file mode 100644 index 00000000..0fd33620 --- /dev/null +++ b/src/templates/certificate.template.js @@ -0,0 +1,139 @@ +import PDFDocument from "pdfkit"; + +export function generateCertificatePDF(data) { + return new Promise((resolve, reject) => { + try { + const doc = new PDFDocument({ + layout: "landscape", + size: "A4", + margin: 40, + }); + + const buffers = []; + doc.on("data", (chunk) => buffers.push(chunk)); + doc.on("end", () => resolve(Buffer.concat(buffers))); + doc.on("error", (err) => reject(err)); + + const width = doc.page.width; + const height = doc.page.height; + const contentWidth = width - 80; + + // Outer Border (Dark navy blue) + doc + .lineWidth(6) + .strokeColor("#1E3A8A") + .rect(20, 20, width - 40, height - 40) + .stroke(); + + // Inner Decorative Border (Gold/amber accent) + doc + .lineWidth(2) + .strokeColor("#D97706") + .rect(30, 30, width - 60, height - 60) + .stroke(); + + // Header Brand + doc + .fillColor("#1E3A8A") + .fontSize(22) + .text("DEENBRIDGE ACADEMY", 40, 75, { width: contentWidth, align: "center" }); + + // Title + doc + .fillColor("#111827") + .fontSize(32) + .text("CERTIFICATE OF COMPLETION", 40, 120, { width: contentWidth, align: "center" }); + + // Subtitle + doc + .fillColor("#4B5563") + .fontSize(16) + .text("PROUDLY PRESENTED TO", 40, 175, { width: contentWidth, align: "center" }); + + // Learner Name + doc + .fillColor("#1E3A8A") + .fontSize(28) + .text((data.learnerName || "Learner").toUpperCase(), 40, 210, { width: contentWidth, align: "center" }); + + // Description text + doc + .fillColor("#374151") + .fontSize(15) + .text("for successfully mastering and completing all modules of the course", 40, 260, { + width: contentWidth, + align: "center", + }); + + // Course Title + doc + .fillColor("#D97706") + .fontSize(24) + .text(`"${data.courseTitle || "Course"}"`, 40, 295, { width: contentWidth, align: "center" }); + + // Completion Date + const formattedDate = new Date(data.completionDate || Date.now()).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); + + doc + .fillColor("#4B5563") + .fontSize(14) + .text(`Awarded on ${formattedDate}`, 40, 350, { width: contentWidth, align: "center" }); + + // Signatures + const sigY = 420; + + // Instructor Signature Line + doc + .strokeColor("#9CA3AF") + .lineWidth(1) + .moveTo(150, sigY) + .lineTo(330, sigY) + .stroke(); + + doc + .fillColor("#111827") + .fontSize(13) + .text(data.instructorName || "DeenBridge Instructor", 150, sigY + 8, { width: 180, align: "center" }); + + doc + .fillColor("#6B7280") + .fontSize(11) + .text("Course Educator", 150, sigY + 24, { width: 180, align: "center" }); + + // Organization Signature Line + doc + .strokeColor("#9CA3AF") + .lineWidth(1) + .moveTo(width - 330, sigY) + .lineTo(width - 150, sigY) + .stroke(); + + doc + .fillColor("#111827") + .fontSize(13) + .text(data.instructorSignature || "DeenBridge Verification", width - 330, sigY + 8, { + width: 180, + align: "center", + }); + + doc + .fillColor("#6B7280") + .fontSize(11) + .text("Authorized Issuer", width - 330, sigY + 24, { width: 180, align: "center" }); + + // Footer - Certificate ID + doc + .fillColor("#9CA3AF") + .fontSize(10) + .text(`Certificate ID: ${data.certificateId}`, 40, height - 60, { width: contentWidth, align: "center" }); + + doc.end(); + } catch (err) { + reject(err); + } + }); +} diff --git a/src/templates/certificate.template.ts b/src/templates/certificate.template.ts new file mode 100644 index 00000000..6a5e94d9 --- /dev/null +++ b/src/templates/certificate.template.ts @@ -0,0 +1,139 @@ +import PDFDocument from "pdfkit"; + +export function generateCertificatePDF(data: any): Promise { + return new Promise((resolve, reject) => { + try { + const doc = new PDFDocument({ + layout: "landscape", + size: "A4", + margin: 40, + }); + + const buffers: Buffer[] = []; + doc.on("data", (chunk) => buffers.push(chunk)); + doc.on("end", () => resolve(Buffer.concat(buffers))); + doc.on("error", (err) => reject(err)); + + const width = doc.page.width; + const height = doc.page.height; + const contentWidth = width - 80; + + // Outer Border (Dark navy blue) + doc + .lineWidth(6) + .strokeColor("#1E3A8A") + .rect(20, 20, width - 40, height - 40) + .stroke(); + + // Inner Decorative Border (Gold/amber accent) + doc + .lineWidth(2) + .strokeColor("#D97706") + .rect(30, 30, width - 60, height - 60) + .stroke(); + + // Header Brand + doc + .fillColor("#1E3A8A") + .fontSize(22) + .text("DEENBRIDGE ACADEMY", 40, 75, { width: contentWidth, align: "center" }); + + // Title + doc + .fillColor("#111827") + .fontSize(32) + .text("CERTIFICATE OF COMPLETION", 40, 120, { width: contentWidth, align: "center" }); + + // Subtitle + doc + .fillColor("#4B5563") + .fontSize(16) + .text("PROUDLY PRESENTED TO", 40, 175, { width: contentWidth, align: "center" }); + + // Learner Name + doc + .fillColor("#1E3A8A") + .fontSize(28) + .text((data.learnerName || "Learner").toUpperCase(), 40, 210, { width: contentWidth, align: "center" }); + + // Description text + doc + .fillColor("#374151") + .fontSize(15) + .text("for successfully mastering and completing all modules of the course", 40, 260, { + width: contentWidth, + align: "center", + }); + + // Course Title + doc + .fillColor("#D97706") + .fontSize(24) + .text(`"${data.courseTitle || "Course"}"`, 40, 295, { width: contentWidth, align: "center" }); + + // Completion Date + const formattedDate = new Date(data.completionDate || Date.now()).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); + + doc + .fillColor("#4B5563") + .fontSize(14) + .text(`Awarded on ${formattedDate}`, 40, 350, { width: contentWidth, align: "center" }); + + // Signatures + const sigY = 420; + + // Instructor Signature Line + doc + .strokeColor("#9CA3AF") + .lineWidth(1) + .moveTo(150, sigY) + .lineTo(330, sigY) + .stroke(); + + doc + .fillColor("#111827") + .fontSize(13) + .text(data.instructorName || "DeenBridge Instructor", 150, sigY + 8, { width: 180, align: "center" }); + + doc + .fillColor("#6B7280") + .fontSize(11) + .text("Course Educator", 150, sigY + 24, { width: 180, align: "center" }); + + // Organization Signature Line + doc + .strokeColor("#9CA3AF") + .lineWidth(1) + .moveTo(width - 330, sigY) + .lineTo(width - 150, sigY) + .stroke(); + + doc + .fillColor("#111827") + .fontSize(13) + .text(data.instructorSignature || "DeenBridge Verification", width - 330, sigY + 8, { + width: 180, + align: "center", + }); + + doc + .fillColor("#6B7280") + .fontSize(11) + .text("Authorized Issuer", width - 330, sigY + 24, { width: 180, align: "center" }); + + // Footer - Certificate ID + doc + .fillColor("#9CA3AF") + .fontSize(10) + .text(`Certificate ID: ${data.certificateId}`, 40, height - 60, { width: contentWidth, align: "center" }); + + doc.end(); + } catch (err) { + reject(err); + } + }); +} diff --git a/src/utils/analyticsCalculator.js b/src/utils/analyticsCalculator.js new file mode 100644 index 00000000..59d8278e --- /dev/null +++ b/src/utils/analyticsCalculator.js @@ -0,0 +1,263 @@ +// utils/analyticsCalculator.js +// +// Pure, side-effect-free helpers for turning raw course data (enrollment +// counts, progress documents, confirmed transactions, lesson lists) into the +// derived metrics surfaced by the creator analytics endpoints: completion and +// conversion rates, revenue roll-ups, engagement averages, per-lesson +// drop-off, and CSV serialisation. +// +// Keeping the math here (no DB access) makes each rule independently testable +// and lets the service layer stay focused on queries. + +/** + * Round a number to a fixed number of decimal places, guarding against NaN. + * + * @param {number} value - Raw value to round. + * @param {number} [places] - Decimal places to keep (default 2). + * @returns {number} The rounded value, or 0 when the input is not finite. + */ +export const round = (value, places = 2) => { + if (!Number.isFinite(value)) return 0; + const factor = 10 ** places; + return Math.round(value * factor) / factor; +}; + +/** + * Completion rate as a percentage of enrolled learners who finished a course. + * + * @param {number} completions - Number of learners who completed the course. + * @param {number} enrollments - Total number of enrolled learners. + * @returns {number} Percentage in the range 0–100 (0 when there are no enrollments). + */ +export const computeCompletionRate = (completions, enrollments) => { + if (!enrollments || enrollments <= 0) return 0; + return round((completions / enrollments) * 100); +}; + +/** + * Conversion rate as a percentage of course viewers who enrolled. + * + * @param {number} enrollments - Number of enrollments in the window. + * @param {number} views - Number of course views (cumulative). + * @returns {number} Percentage in the range 0–100 (0 when there are no views). + */ +export const computeConversionRate = (enrollments, views) => { + if (!views || views <= 0) return 0; + return round((enrollments / views) * 100); +}; + +/** + * Sum confirmed transaction amounts into a per-currency revenue roll-up. + * + * Transaction amounts are stored as precision-preserving strings and may span + * multiple currencies, so revenue is grouped by currency rather than summed + * into a single (meaningless) cross-currency total. + * + * @param {Array<{amount: string|number, currency?: string}>} transactions + * Confirmed transactions for the course/creator in the window. + * @returns {{revenueByCurrency: Object, transactionCount: number, grossByCurrency: Array<{currency: string, amount: number}>}} + * `revenueByCurrency` maps currency code -> total; `grossByCurrency` is the + * same data as a sorted array for easy CSV/row rendering. + */ +export const sumRevenue = (transactions = []) => { + const revenueByCurrency = {}; + + for (const tx of transactions) { + const amount = parseFloat(tx?.amount); + if (!Number.isFinite(amount)) continue; + const currency = tx?.currency || "USDC"; + revenueByCurrency[currency] = round((revenueByCurrency[currency] || 0) + amount, 7); + } + + const grossByCurrency = Object.entries(revenueByCurrency) + .map(([currency, amount]) => ({ currency, amount })) + .sort((a, b) => a.currency.localeCompare(b.currency)); + + return { + revenueByCurrency, + grossByCurrency, + transactionCount: transactions.length, + }; +}; + +/** + * Compute engagement averages across a set of progress documents. + * + * @param {Array<{percentComplete?: number, lessonsCompleted?: Array}>} progressDocs + * Progress records for learners of the course. + * @param {Date|null} [activeSince] - When provided, learners whose progress was + * updated on/after this date are counted as "active". + * @returns {{learnersStarted: number, avgPercentComplete: number, avgLessonsCompleted: number, activeLearners: number}} + */ +export const computeEngagement = (progressDocs = [], activeSince = null) => { + const learnersStarted = progressDocs.length; + if (!learnersStarted) { + return { + learnersStarted: 0, + avgPercentComplete: 0, + avgLessonsCompleted: 0, + activeLearners: 0, + }; + } + + let percentSum = 0; + let lessonsSum = 0; + let activeLearners = 0; + + for (const progress of progressDocs) { + percentSum += Number(progress?.percentComplete || 0); + lessonsSum += Array.isArray(progress?.lessonsCompleted) + ? progress.lessonsCompleted.length + : 0; + if (activeSince && progress?.updatedAt && new Date(progress.updatedAt) >= activeSince) { + activeLearners += 1; + } + } + + return { + learnersStarted, + avgPercentComplete: round(percentSum / learnersStarted), + avgLessonsCompleted: round(lessonsSum / learnersStarted), + activeLearners: activeSince ? activeLearners : learnersStarted, + }; +}; + +/** + * Compute per-lesson reach and drop-off across ordered lessons. + * + * For each lesson (in course order) we count how many learners have completed + * it (`reached`). The drop-off at a lesson is the number of learners who + * reached the previous lesson but not this one — i.e. where they fell out of + * the funnel. The lesson with the largest drop is flagged as the biggest + * drop-off point. + * + * @param {Array<{lessonId: string, title?: string}>} lessons - Ordered lessons. + * @param {Array<{lessonsCompleted?: Array}>} progressDocs - Learner progress. + * @returns {{lessons: Array<{order: number, lessonId: string, title: string, reached: number, dropOff: number, dropOffRate: number}>, biggestDropOff: object|null}} + */ +export const computeDropOff = (lessons = [], progressDocs = []) => { + const completedSets = progressDocs.map( + (p) => new Set((p?.lessonsCompleted || []).map((id) => String(id))) + ); + + const rows = lessons.map((lesson, index) => { + const lessonId = String(lesson.lessonId); + const reached = completedSets.reduce( + (count, set) => (set.has(lessonId) ? count + 1 : count), + 0 + ); + return { + order: index + 1, + lessonId, + title: lesson.title || `Lesson ${index + 1}`, + reached, + dropOff: 0, + dropOffRate: 0, + }; + }); + + // Drop-off is measured against the previous lesson's reach (the first lesson + // is the funnel entry point, so it has no upstream drop). + for (let i = 0; i < rows.length; i += 1) { + const prevReached = i === 0 ? rows[0].reached : rows[i - 1].reached; + const drop = Math.max(0, prevReached - rows[i].reached); + rows[i].dropOff = i === 0 ? 0 : drop; + rows[i].dropOffRate = + i === 0 || prevReached <= 0 ? 0 : round((drop / prevReached) * 100); + } + + const biggestDropOff = rows + .filter((row) => row.order > 1) + .reduce((max, row) => (!max || row.dropOff > max.dropOff ? row : max), null); + + return { lessons: rows, biggestDropOff }; +}; + +/** + * Escape a single value for inclusion in a CSV cell (RFC 4180 style): wrap in + * double quotes and double any embedded quotes when the value contains a + * comma, quote, or newline. + * + * @param {*} value - The raw cell value. + * @returns {string} A CSV-safe cell string. + */ +export const escapeCsvCell = (value) => { + const str = value === null || value === undefined ? "" : String(value); + if (/[",\n\r]/.test(str)) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; +}; + +/** + * Build a CSV string from a header row and an array of row arrays. + * + * @param {string[]} headers - Column headers. + * @param {Array>} rows - Row values (each inner array is one row). + * @returns {string} A CSV document terminated with a trailing newline. + */ +export const buildCsv = (headers, rows) => { + const lines = [headers.map(escapeCsvCell).join(",")]; + for (const row of rows) { + lines.push(row.map(escapeCsvCell).join(",")); + } + return `${lines.join("\r\n")}\r\n`; +}; + +/** + * Flatten a computed analytics object into a two-column (metric,value) CSV + * suitable for spreadsheet download. Per-currency revenue and per-lesson + * drop-off are expanded into their own labelled rows. + * + * @param {object} analytics - The object returned by the analytics service. + * @returns {string} CSV document. + */ +export const analyticsToCsv = (analytics = {}) => { + const rows = []; + const m = analytics.metrics || {}; + + rows.push(["Course ID", analytics.courseId || ""]); + rows.push(["Course Title", analytics.title || ""]); + rows.push(["Range Start", analytics.range?.startDate || "all-time"]); + rows.push(["Range End", analytics.range?.endDate || "all-time"]); + rows.push(["Views (cumulative)", m.views ?? 0]); + rows.push(["Enrollments (total)", m.enrollmentsTotal ?? 0]); + rows.push(["Enrollments (in range)", m.enrollments ?? 0]); + rows.push(["Completions", m.completions ?? 0]); + rows.push(["Completion Rate (%)", m.completionRate ?? 0]); + rows.push(["Conversion Rate (%)", m.conversionRate ?? 0]); + rows.push(["Learners Started", m.engagement?.learnersStarted ?? 0]); + rows.push(["Active Learners", m.engagement?.activeLearners ?? 0]); + rows.push(["Avg Percent Complete (%)", m.engagement?.avgPercentComplete ?? 0]); + rows.push(["Avg Lessons Completed", m.engagement?.avgLessonsCompleted ?? 0]); + rows.push(["Confirmed Transactions", m.revenue?.transactionCount ?? 0]); + + for (const entry of m.revenue?.grossByCurrency || []) { + rows.push([`Revenue (${entry.currency})`, entry.amount]); + } + + for (const lesson of m.dropOff?.lessons || []) { + rows.push([ + `Lesson ${lesson.order} reached - ${lesson.title}`, + lesson.reached, + ]); + rows.push([ + `Lesson ${lesson.order} drop-off (%) - ${lesson.title}`, + lesson.dropOffRate, + ]); + } + + return buildCsv(["metric", "value"], rows); +}; + +export default { + round, + computeCompletionRate, + computeConversionRate, + sumRevenue, + computeEngagement, + computeDropOff, + escapeCsvCell, + buildCsv, + analyticsToCsv, +}; diff --git a/src/utils/badge-criteria.js b/src/utils/badge-criteria.js new file mode 100644 index 00000000..89e77aff --- /dev/null +++ b/src/utils/badge-criteria.js @@ -0,0 +1,47 @@ +export const DEFAULT_BADGES = [ + { + name: "First Steps", + slug: "first-course", + description: "Completed your first course on DeenBridge", + icon: "badge_first_course.png", + category: "milestone", + criteriaType: "courses_completed", + threshold: 1, + }, + { + name: "Dedicated Learner", + slug: "5-courses", + description: "Successfully completed 5 courses", + icon: "badge_5_courses.png", + category: "milestone", + criteriaType: "courses_completed", + threshold: 5, + }, + { + name: "Knowledge Seeker", + slug: "10-courses", + description: "Successfully completed 10 courses", + icon: "badge_10_courses.png", + category: "milestone", + criteriaType: "courses_completed", + threshold: 10, + }, + { + name: "Master Scholar", + slug: "20-courses", + description: "Achieved mastery by completing 20 courses", + icon: "badge_20_courses.png", + category: "milestone", + criteriaType: "courses_completed", + threshold: 20, + }, + { + name: "Category Expert", + slug: "category-expert", + description: "Completed 3 or more courses within a single topic category", + icon: "badge_category_expert.png", + category: "achievement", + criteriaType: "category_completed", + threshold: 3, + }, +]; diff --git a/src/utils/badge-criteria.ts b/src/utils/badge-criteria.ts new file mode 100644 index 00000000..89e77aff --- /dev/null +++ b/src/utils/badge-criteria.ts @@ -0,0 +1,47 @@ +export const DEFAULT_BADGES = [ + { + name: "First Steps", + slug: "first-course", + description: "Completed your first course on DeenBridge", + icon: "badge_first_course.png", + category: "milestone", + criteriaType: "courses_completed", + threshold: 1, + }, + { + name: "Dedicated Learner", + slug: "5-courses", + description: "Successfully completed 5 courses", + icon: "badge_5_courses.png", + category: "milestone", + criteriaType: "courses_completed", + threshold: 5, + }, + { + name: "Knowledge Seeker", + slug: "10-courses", + description: "Successfully completed 10 courses", + icon: "badge_10_courses.png", + category: "milestone", + criteriaType: "courses_completed", + threshold: 10, + }, + { + name: "Master Scholar", + slug: "20-courses", + description: "Achieved mastery by completing 20 courses", + icon: "badge_20_courses.png", + category: "milestone", + criteriaType: "courses_completed", + threshold: 20, + }, + { + name: "Category Expert", + slug: "category-expert", + description: "Completed 3 or more courses within a single topic category", + icon: "badge_category_expert.png", + category: "achievement", + criteriaType: "category_completed", + threshold: 3, + }, +]; diff --git a/src/utils/cache.js b/src/utils/cache.js index 867093d5..3d1deb79 100644 --- a/src/utils/cache.js +++ b/src/utils/cache.js @@ -36,6 +36,7 @@ export const CACHE_KEYS = { REEL: "reel:", SEARCH: "search:", EDUCATORS: "educators:", + CATEGORIES: "categories:", }; /** diff --git a/src/utils/captcha.js b/src/utils/captcha.js new file mode 100644 index 00000000..c6415737 --- /dev/null +++ b/src/utils/captcha.js @@ -0,0 +1,47 @@ +// utils/captcha.js +// +// Pluggable captcha gate for burst-mitigation on auth endpoints. +// +// No-op when CAPTCHA_SECRET_KEY is unset — so local, dev, and test flows are +// never blocked by an unconfigured integration. When configured, verifies a +// client token against the provider's siteverify endpoint (hCaptcha and +// Google reCAPTCHA v2/v3 share the same POST + form-encoded protocol), and +// fails OPEN (logs + allows) if the captcha provider is unreachable so a +// captcha outage does not lock users out. +import axios from "axios"; +import logger from "../config/logger.js"; + +const CAPTCHA_VERIFY_URL = + process.env.CAPTCHA_VERIFY_URL || "https://hcaptcha.com/siteverify"; +const CAPTCHA_TIMEOUT_MS = + parseInt(process.env.CAPTCHA_TIMEOUT_MS, 10) || 5000; + +/** + * Verify a captcha token. Returns true when captcha is not configured + * (no-op), when the token passes, or when the provider is unreachable + * (fail-open). Returns false only when a configured provider rejects the token. + * + * @param {string} [token] + * @returns {Promise} + */ +export async function verifyCaptcha(token) { + const secret = process.env.CAPTCHA_SECRET_KEY; + if (!secret) return true; // not configured — no-op + + try { + const { data } = await axios.post( + CAPTCHA_VERIFY_URL, + new URLSearchParams({ secret, response: token || "" }), + { timeout: CAPTCHA_TIMEOUT_MS } + ); + return Boolean(data && data.success === true); + } catch (err) { + logger.warn( + { err: err.message }, + "captcha: verification failed, failing open" + ); + return true; + } +} + +export default verifyCaptcha; diff --git a/src/utils/hibp.js b/src/utils/hibp.js new file mode 100644 index 00000000..55af3a4a --- /dev/null +++ b/src/utils/hibp.js @@ -0,0 +1,81 @@ +// utils/hibp.js +// +// Breached-password check against the HaveIBeenPwned (HIBP) k-anonymity range +// API (https://haveibeenpwned.com/API/v3#PwnedPasswords). +// +// Security model: the full password is NEVER sent. We send only the first 5 +// hex characters of its SHA-1 digest (the "prefix"), and the API returns every +// known-breach suffix for that prefix. We compare locally. Because the request +// is keyed on a 20-bit prefix shared by ~millions of passwords, HIBP learns +// nothing about the specific password. +// +// Fail-open policy: on any network error / timeout / outage we return `false` +// (not breached) and log, so a HIBP outage never blocks legitimate signups or +// password resets. The static password policy (passwordPolicy.js) remains the +// hard boundary; this check is a progressive hardening layer on top. +import axios from "axios"; +import crypto from "crypto"; +import logger from "../config/logger.js"; + +const HIBP_RANGE_URL = + process.env.HIBP_RANGE_URL || "https://api.pwnedpasswords.com/range/"; +const HIBP_TIMEOUT_MS = parseInt(process.env.HIBP_TIMEOUT_MS, 10) || 2000; + +/** + * Returns true when the password has appeared in a known breach. + * Only the 5-char SHA-1 prefix is transmitted; the password never leaves the + * process. Fails open (returns false) on outage/timeout and logs the degraded + * state. + * + * @param {string} password + * @returns {Promise} + */ +export async function isPasswordBreached(password) { + if (typeof password !== "string" || password.length === 0) { + return false; + } + + const sha1 = crypto + .createHash("sha1") + .update(password) + .digest("hex") + .toUpperCase(); + const prefix = sha1.slice(0, 5); + const suffix = sha1.slice(5); + + try { + const { data } = await axios.get(`${HIBP_RANGE_URL}${prefix}`, { + timeout: HIBP_TIMEOUT_MS, + headers: { + "User-Agent": "DeenBridgeBackend/1.0", + // Ask HIBP to append padding records so responses are a fixed size and + // cannot be fingerprinted over TLS. + "Add-Padding": "true", + }, + }); + // Each line is ":". Padding records have count 0 + // and must be ignored — a real breach always has count >= 1. + const records = String(data || "") + .split("\n") + .map((line) => line.trim().split(":")) + .filter((parts) => parts.length >= 2) + .filter((parts) => parseInt(parts[1], 10) > 0); + const breached = records.some( + (parts) => parts[0].toUpperCase() === suffix + ); + + if (breached) { + logger.warn("hibp: password is present in a known breach"); + } + return breached; + } catch (err) { + // Fail open — never break signup/reset because HIBP is unreachable. + logger.warn( + { err: err.message }, + "hibp: breached-password check unavailable, failing open" + ); + return false; + } +} + +export default isPasswordBreached; diff --git a/src/utils/otp.js b/src/utils/otp.js index 44fe8306..50534532 100644 --- a/src/utils/otp.js +++ b/src/utils/otp.js @@ -1,5 +1,5 @@ import crypto from "crypto"; -import bcrypt from "bcrypt"; +import bcrypt from "bcryptjs"; /** * Generate a cryptographically secure 6-digit numeric OTP. diff --git a/src/utils/twoFactorCrypto.js b/src/utils/twoFactorCrypto.js new file mode 100644 index 00000000..f7fb1c30 --- /dev/null +++ b/src/utils/twoFactorCrypto.js @@ -0,0 +1,167 @@ +// utils/twoFactorCrypto.js +import crypto from "crypto"; +import bcrypt from "bcryptjs"; + +const ALGORITHM = "aes-256-gcm"; +const KEY_STRING = + process.env.TWO_FACTOR_ENCRYPTION_KEY || + process.env.JWT_SECRET || + "deenbridge-default-2fa-encryption-secret-key-32-chars!"; + +// Derive a 32-byte key from key string +const getKey = () => crypto.createHash("sha256").update(KEY_STRING).digest(); + +/** + * Encrypt a plain secret string (AES-256-GCM) + * Format: ivHex:tagHex:encryptedHex + */ +export const encryptSecret = (text) => { + if (!text) return text; + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv(ALGORITHM, getKey(), iv); + let encrypted = cipher.update(text, "utf8", "hex"); + encrypted += cipher.final("hex"); + const tag = cipher.getAuthTag().toString("hex"); + return `${iv.toString("hex")}:${tag}:${encrypted}`; +}; + +/** + * Decrypt an encrypted secret string (AES-256-GCM) + */ +export const decryptSecret = (encryptedText) => { + if (!encryptedText) return encryptedText; + const parts = encryptedText.split(":"); + if (parts.length !== 3) { + // If not in iv:tag:ciphertext format (e.g. legacy/testing), return as-is + return encryptedText; + } + const [ivHex, tagHex, encryptedDataHex] = parts; + const iv = Buffer.from(ivHex, "hex"); + const tag = Buffer.from(tagHex, "hex"); + const decipher = crypto.createDecipheriv(ALGORITHM, getKey(), iv); + decipher.setAuthTag(tag); + let decrypted = decipher.update(encryptedDataHex, "hex", "utf8"); + decrypted += decipher.final("utf8"); + return decrypted; +}; + +/** + * Generate N single-use recovery codes. + * Returns { plainCodes, hashedCodes } + */ +export const generateRecoveryCodes = async (count = 10) => { + const plainCodes = []; + const hashedCodes = []; + for (let i = 0; i < count; i++) { + const raw = crypto.randomBytes(6).toString("hex").toUpperCase(); + const formatted = `${raw.slice(0, 4)}-${raw.slice(4, 8)}-${raw.slice(8, 12)}`; + plainCodes.push(formatted); + const hashed = await bcrypt.hash(formatted, 10); + hashedCodes.push(hashed); + } + return { plainCodes, hashedCodes }; +}; + +/** + * Check input code against user's hashed recovery codes. + * If a match is found, remove the code (single-use) and return true. + */ +export const verifyAndConsumeRecoveryCode = async (user, inputCode) => { + if ( + !user.twoFactor || + !Array.isArray(user.twoFactor.recoveryCodes) || + user.twoFactor.recoveryCodes.length === 0 + ) { + return false; + } + + const formattedInput = inputCode.trim().toUpperCase(); + + for (let i = 0; i < user.twoFactor.recoveryCodes.length; i++) { + const hashed = user.twoFactor.recoveryCodes[i]; + const isMatch = await bcrypt.compare(formattedInput, hashed); + if (isMatch) { + user.twoFactor.recoveryCodes.splice(i, 1); + return true; + } + } + + return false; +}; + +/** + * Base32 decoding helper for TOTP secrets + */ +const base32Decode = (base32) => { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + const clean = base32.replace(/=+$/, "").toUpperCase(); + let bits = 0; + let value = 0; + const bytes = []; + for (let i = 0; i < clean.length; i++) { + const idx = alphabet.indexOf(clean[i]); + if (idx === -1) continue; + value = (value << 5) | idx; + bits += 5; + if (bits >= 8) { + bytes.push((value >>> (bits - 8)) & 255); + bits -= 8; + } + } + return Buffer.from(bytes); +}; + +/** + * Generate a random Base32 TOTP secret string (20 chars) + */ +export const generateBase32Secret = (length = 20) => { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + const randomBytes = crypto.randomBytes(length); + let secret = ""; + for (let i = 0; i < length; i++) { + secret += alphabet[randomBytes[i] % 32]; + } + return secret; +}; + +/** + * Generate a 6-digit TOTP code (RFC 6238) for a given time step (default: current 30s window) + */ +export const generateTOTPCode = (secret, timeStep = Math.floor(Date.now() / 1000 / 30)) => { + const key = base32Decode(secret); + const buf = Buffer.alloc(8); + buf.writeBigInt64BE(BigInt(timeStep)); + const hmac = crypto.createHmac("sha1", key).update(buf).digest(); + const offset = hmac[hmac.length - 1] & 0x0f; + const codeInt = + ((hmac[offset] & 0x7f) << 24) | + ((hmac[offset + 1] & 0xff) << 16) | + ((hmac[offset + 2] & 0xff) << 8) | + (hmac[offset + 3] & 0xff); + return (codeInt % 1000000).toString().padStart(6, "0"); +}; + +/** + * Verify a 6-digit TOTP token against a Base32 secret with +-window steps (default 1 = +-30s) + */ +export const verifyTOTPCode = (token, secret, window = 1) => { + if (!token || !secret) return false; + const cleanToken = token.toString().trim(); + const currentStep = Math.floor(Date.now() / 1000 / 30); + for (let i = -window; i <= window; i++) { + const expected = generateTOTPCode(secret, currentStep + i); + if (cleanToken === expected) { + return true; + } + } + return false; +}; + +/** + * Build standard otpauth:// URI + */ +export const generateOtpauthUrl = (email, secret, issuer = "DeenBridge") => { + const label = `${issuer}:${email}`; + return `otpauth://totp/${encodeURIComponent(label)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}`; +}; + diff --git a/src/utils/videoCompositor.js b/src/utils/videoCompositor.js new file mode 100644 index 00000000..f1fe99cf --- /dev/null +++ b/src/utils/videoCompositor.js @@ -0,0 +1,86 @@ +// utils/videoCompositor.js +// +// Dependency-free abstraction that records the *intent* of composing a +// duet/stitch response video against an original reel. It intentionally does +// NOT perform any frame compositing or transcoding (no ffmpeg, no native deps): +// the returned descriptor is metadata that the downstream media pipeline uses +// to render the final composited video asynchronously. +// +// - duet -> the response is played side-by-side with the original. +// - stitch -> a clip of the original is prepended, then the response plays. + +export const DUET_TYPES = ["duet", "stitch"]; + +export const COMPOSITION_LAYOUTS = { + duet: "side-by-side", + stitch: "prepend-clip", +}; + +export const isDuetType = (value) => DUET_TYPES.includes(value); + +/** + * Normalize a stitch clip range. Returns { start, end } in seconds, or null + * when the range is absent/invalid. `end` must be greater than `start`. + */ +export const normalizeStitchClip = (clip) => { + if (!clip) return null; + const start = Number(clip.start); + const end = Number(clip.end); + if (!Number.isFinite(start) || !Number.isFinite(end)) return null; + if (start < 0 || end <= start) return null; + return { start, end }; +}; + +/** + * Build a composition descriptor for a duet/stitch response. + * + * @param {Object} params + * @param {"duet"|"stitch"} params.type + * @param {Object} params.original - source original reel ({ _id, video, duration }) + * @param {Object} params.response - the response video ({ video, duration }) + * @param {Object} [params.clip] - stitch clip range ({ start, end }) in seconds + * @returns {Object} composition descriptor persisted on the derivative reel + */ +export const buildCompositionDescriptor = ({ type, original, response, clip } = {}) => { + if (!isDuetType(type)) { + throw new Error(`Unsupported composition type: ${type}`); + } + + const descriptor = { + type, + layout: COMPOSITION_LAYOUTS[type], + // Rendering is performed later by the media pipeline; this marks intent. + status: "pending", + sources: { + original: { + reelId: original?._id ? String(original._id) : null, + video: original?.video ?? null, + duration: original?.duration ?? null, + }, + response: { + video: response?.video ?? null, + duration: response?.duration ?? null, + }, + }, + }; + + if (type === "stitch") { + const range = normalizeStitchClip(clip); + if (!range) { + throw new Error( + "A stitch requires a valid clip range ({ start, end } in seconds, end > start)" + ); + } + descriptor.clip = range; + } + + return descriptor; +}; + +export default { + DUET_TYPES, + COMPOSITION_LAYOUTS, + isDuetType, + normalizeStitchClip, + buildCompositionDescriptor, +}; diff --git a/src/validators/readingProgressValidators.js b/src/validators/readingProgressValidators.js new file mode 100644 index 00000000..495244d5 --- /dev/null +++ b/src/validators/readingProgressValidators.js @@ -0,0 +1,50 @@ +// validators/readingProgressValidators.js +import { body, param } from "express-validator"; +import mongoose from "mongoose"; + +const isValidObjectId = (value) => mongoose.Types.ObjectId.isValid(value); + +const bookIdParam = param("bookId") + .custom(isValidObjectId) + .withMessage("A valid book id is required"); + +/** + * Validation for updating reading progress. Requires at least one of + * page / percentage / lastPosition, and bounds the numeric fields + * (percentage 0-100, page >= 0). + */ +export const updateReadingProgressValidation = [ + bookIdParam, + body("percentage") + .optional({ nullable: true }) + .isFloat({ min: 0, max: 100 }) + .withMessage("percentage must be a number between 0 and 100"), + body("page") + .optional({ nullable: true }) + .isInt({ min: 0 }) + .withMessage("page must be an integer >= 0"), + body("totalPages") + .optional({ nullable: true }) + .isInt({ min: 0 }) + .withMessage("totalPages must be an integer >= 0"), + body("lastPosition") + .optional({ nullable: true }) + .isString() + .withMessage("lastPosition must be a string"), + body("device") + .optional({ nullable: true }) + .isString() + .withMessage("device must be a string"), + body().custom((value) => { + if ( + (value.page === undefined || value.page === null) && + (value.percentage === undefined || value.percentage === null) && + (value.lastPosition === undefined || value.lastPosition === null) + ) { + throw new Error("Provide at least one of page, percentage or lastPosition"); + } + return true; + }), +]; + +export const readingProgressBookIdValidation = [bookIdParam]; diff --git a/src/validators/reelValidators.js b/src/validators/reelValidators.js new file mode 100644 index 00000000..39c8d142 --- /dev/null +++ b/src/validators/reelValidators.js @@ -0,0 +1,75 @@ +// validators/reelValidators.js +import { body, param, query } from "express-validator"; +import mongoose from "mongoose"; + +const isValidObjectId = (value) => mongoose.Types.ObjectId.isValid(value); + +const reelIdParam = param("id") + .custom(isValidObjectId) + .withMessage("A valid reel id is required"); + +// Create a duet/stitch response for the reel identified by :id. +// Note: this runs *after* multer (upload.single), so multipart text fields are +// available on req.body. +export const createReelDuetValidation = [ + reelIdParam, + body("description") + .exists({ values: "null" }) + .withMessage("Description is required") + .bail() + .isString() + .withMessage("Description must be a string") + .bail() + .trim() + .notEmpty() + .withMessage("Description is required"), + body("type") + .exists({ values: "null" }) + .withMessage("type is required") + .bail() + .isIn(["duet", "stitch"]) + .withMessage("type must be one of: duet, stitch"), + body("stitchStart") + .if(body("type").equals("stitch")) + .exists({ values: "null" }) + .withMessage("stitchStart is required for a stitch") + .bail() + .isFloat({ min: 0 }) + .withMessage("stitchStart must be a number >= 0"), + body("stitchEnd") + .if(body("type").equals("stitch")) + .exists({ values: "null" }) + .withMessage("stitchEnd is required for a stitch") + .bail() + .isFloat({ gt: 0 }) + .withMessage("stitchEnd must be a number > 0") + .bail() + .custom((value, { req }) => { + if (Number(value) <= Number(req.body.stitchStart)) { + throw new Error("stitchEnd must be greater than stitchStart"); + } + return true; + }), +]; + +// Browse duets/stitches for the reel identified by :id. +export const listReelDuetsValidation = [ + reelIdParam, + query("type") + .optional({ values: "falsy" }) + .isIn(["duet", "stitch"]) + .withMessage("type must be one of: duet, stitch"), + query("page") + .optional({ values: "falsy" }) + .isInt({ min: 1 }) + .withMessage("page must be a positive integer"), + query("limit") + .optional({ values: "falsy" }) + .isInt({ min: 1, max: 50 }) + .withMessage("limit must be between 1 and 50"), +]; + +export default { + createReelDuetValidation, + listReelDuetsValidation, +}; diff --git a/src/validators/requestValidators.js b/src/validators/requestValidators.js new file mode 100644 index 00000000..7dcaf45a --- /dev/null +++ b/src/validators/requestValidators.js @@ -0,0 +1,147 @@ +import { body } from "express-validator"; +import mongoose from "mongoose"; +import * as StellarSdk from "@stellar/stellar-sdk"; +import { isValidPublicKey, NETWORK } from "../services/stellar/stellarService.js"; +import { PASSWORD_MIN } from "../utils/passwordPolicy.js"; + +const networkPassphrase = + NETWORK === "mainnet" + ? StellarSdk.Networks.PUBLIC + : StellarSdk.Networks.TESTNET; + +const isValidObjectId = (value) => mongoose.Types.ObjectId.isValid(value); + +const isWellFormedXdr = (value) => { + try { + StellarSdk.TransactionBuilder.fromXDR(value, networkPassphrase); + return true; + } catch { + return false; + } +}; + +const requiredString = (field, message) => + body(field) + .exists({ values: "null" }) + .withMessage(message) + .bail() + .isString() + .withMessage(`${field} must be a string`) + .bail() + .trim() + .notEmpty() + .withMessage(message) + .bail(); + +const objectIdField = (field) => + requiredString(field, `${field} is required`) + .custom(isValidObjectId) + .withMessage(`${field} must be a valid Mongo ObjectId`); + +export const registerValidation = [ + requiredString("name", "Name is required"), + body("email") + .exists({ values: "null" }) + .withMessage("Email is required") + .bail() + .isString() + .withMessage("Email must be a string") + .bail() + .trim() + .isEmail() + .withMessage("Email must be a valid email address") + .normalizeEmail(), + body("password") + .exists({ values: "null" }) + .withMessage("Password is required") + .bail() + .isString() + .withMessage("Password must be a string") + .bail() + .isLength({ min: PASSWORD_MIN }) + .withMessage(`Password must be at least ${PASSWORD_MIN} characters`), + body("role") + .optional({ values: "undefined" }) + .isIn(["student", "mentor", "admin"]) + .withMessage("Role must be one of: student, mentor, admin"), +]; + +export const loginValidation = [ + body("email") + .exists({ values: "null" }) + .withMessage("Email is required") + .bail() + .isString() + .withMessage("Email must be a string") + .bail() + .trim() + .isEmail() + .withMessage("Email must be a valid email address") + .normalizeEmail(), + body("password") + .exists({ values: "null" }) + .withMessage("Password is required") + .bail() + .isString() + .withMessage("Password must be a string") + .bail() + .custom((value) => value.trim().length > 0) + .withMessage("Password is required"), +]; + +export const initializePaymentValidation = [ + body("itemType") + .exists({ values: "null" }) + .withMessage("itemType is required") + .bail() + .isIn(["book", "course"]) + .withMessage("itemType must be one of: book, course"), + objectIdField("itemId"), + requiredString("buyerWallet", "buyerWallet is required").custom(isValidPublicKey) + .withMessage("buyerWallet must be a valid Stellar public key"), +]; + +export const submitPaymentValidation = [ + objectIdField("transactionId"), + requiredString("signedXdr", "signedXdr is required") + .custom(isWellFormedXdr) + .withMessage("signedXdr must be a well-formed Stellar transaction XDR"), +]; + +export const connectWalletValidation = [ + requiredString("publicKey", "publicKey is required") + .custom(isValidPublicKey) + .withMessage("publicKey must be a valid Stellar public key"), +]; + +// Optional prerequisites: an array of course ObjectIds a learner must complete +// before enrolling. Each entry must be a valid ObjectId and (defensively) a +// course may not list itself as a prerequisite. Shared by course create/update. +export const prerequisitesValidation = [ + body("prerequisites") + .optional({ values: "undefined" }) + .isArray() + .withMessage("prerequisites must be an array"), + body("prerequisites.*") + .custom(isValidObjectId) + .withMessage("Each prerequisite must be a valid Mongo ObjectId"), + body("prerequisites") + .optional({ values: "undefined" }) + .custom((value, { req }) => { + if (!Array.isArray(value)) return true; + const courseId = req.params?.id; + if (courseId && value.some((p) => String(p) === String(courseId))) { + throw new Error("A course cannot list itself as a prerequisite"); + } + return true; + }), +]; + +export default { + registerValidation, + loginValidation, + initializePaymentValidation, + submitPaymentValidation, + connectWalletValidation, + prerequisitesValidation, +}; diff --git a/src/validators/stellarAnalyticsValidators.js b/src/validators/stellarAnalyticsValidators.js new file mode 100644 index 00000000..0f4ddda0 --- /dev/null +++ b/src/validators/stellarAnalyticsValidators.js @@ -0,0 +1,81 @@ +// validators/stellarAnalyticsValidators.js +import { query } from "express-validator"; +import mongoose from "mongoose"; +import { getSupportedCodes } from "../config/assets.js"; +import { SUPPORTED_PERIODS } from "../services/stellar/analyticsService.js"; + +/** + * express-validator chains for the payment analytics endpoints. + * + * All parameters are optional query-string filters. Enums are validated against + * the same sources of truth used by the Transaction model so the analytics API + * cannot drift from the schema. + * + * @module validators/stellarAnalyticsValidators + */ + +const TRANSACTION_STATUSES = [ + "pending", + "submitted", + "retrying", + "confirmed", + "failed", + "expired", + "refunded", + "disputed", +]; + +const TRANSACTION_TYPES = ["purchase", "donation"]; + +const isValidObjectId = (value) => mongoose.Types.ObjectId.isValid(value); + +/** Filters shared by every analytics endpoint. */ +const commonFilters = [ + query("status") + .optional() + .isIn(TRANSACTION_STATUSES) + .withMessage(`status must be one of: ${TRANSACTION_STATUSES.join(", ")}`), + query("type") + .optional() + .isIn(TRANSACTION_TYPES) + .withMessage(`type must be one of: ${TRANSACTION_TYPES.join(", ")}`), + query("currency") + .optional() + .isIn(getSupportedCodes()) + .withMessage(`currency must be a supported asset code`), + query("buyerId") + .optional() + .custom(isValidObjectId) + .withMessage("buyerId must be a valid Mongo ObjectId"), + query("creatorId") + .optional() + .custom(isValidObjectId) + .withMessage("creatorId must be a valid Mongo ObjectId"), + query("startDate") + .optional() + .isISO8601() + .withMessage("startDate must be an ISO 8601 date") + .toDate(), + query("endDate") + .optional() + .isISO8601() + .withMessage("endDate must be an ISO 8601 date") + .toDate(), +]; + +/** Validation for endpoints that accept a time-bucket `period`. */ +export const analyticsTimeSeriesValidation = [ + query("period") + .optional() + .isIn(SUPPORTED_PERIODS) + .withMessage(`period must be one of: ${SUPPORTED_PERIODS.join(", ")}`), + ...commonFilters, +]; + +/** Validation for the summary endpoint (no `period`). */ +export const analyticsSummaryValidation = [...commonFilters]; + +export default { + analyticsTimeSeriesValidation, + analyticsSummaryValidation, +}; diff --git a/src/workers/pledgeScheduler.js b/src/workers/pledgeScheduler.js new file mode 100644 index 00000000..c15de0b0 --- /dev/null +++ b/src/workers/pledgeScheduler.js @@ -0,0 +1,68 @@ +import Pledge from "../models/Pledge.js"; +import PledgeCycle from "../models/PledgeCycle.js"; +import { sendNotificationToUser } from "../controllers/notificationController.js"; +import { addPledgeCadence } from "../services/pledgeService.js"; +import logger from "../config/logger.js"; + +const INTERVAL_MS = Number(process.env.PLEDGE_SCHEDULER_INTERVAL_MS || 60000); +const WINDOW_MS = Number(process.env.PLEDGE_PAYMENT_WINDOW_MS || 3 * 24 * 60 * 60 * 1000); +let running = false; +let timer = null; + +export const tickPledgeScheduler = async (now = new Date()) => { + const lapsed = await PledgeCycle.find({ status: { $in: ["due", "notified"] }, windowEndsAt: { $lte: now } }).select("pledge"); + if (lapsed.length) { + const ids = lapsed.map((cycle) => cycle._id); + await PledgeCycle.updateMany({ _id: { $in: ids } }, { $set: { status: "lapsed" } }); + await Pledge.updateMany({ _id: { $in: lapsed.map((cycle) => cycle.pledge) } }, { $set: { consecutivePaid: 0 } }); + } + + while (true) { + const pledge = await Pledge.findOneAndUpdate( + { + status: "active", + nextDueAt: { $lte: now }, + $or: [{ schedulerLockUntil: { $exists: false } }, { schedulerLockUntil: { $lte: now } }], + }, + { $set: { schedulerLockUntil: new Date(now.getTime() + 30000) } }, + { new: true, sort: { nextDueAt: 1 } } + ); + if (!pledge) break; + const dueAt = pledge.nextDueAt; + const nextDueAt = addPledgeCadence(dueAt, pledge); + try { + const cycle = await PledgeCycle.findOneAndUpdate( + { pledge: pledge._id, dueAt }, + { $setOnInsert: { status: "due", windowEndsAt: new Date(dueAt.getTime() + WINDOW_MS) } }, + { upsert: true, new: true } + ); + if (cycle.status === "due") { + await sendNotificationToUser(pledge.user, { + sender: pledge.user, + type: "pledge_due", + title: "Your sadaqah pledge is due", + message: `${pledge.amount} USDC is ready for your signature.`, + data: { pledgeId: pledge._id, pledgeCycleId: cycle._id }, + priority: "high", + }); + cycle.status = "notified"; + await cycle.save(); + } + await Pledge.updateOne({ _id: pledge._id, nextDueAt: dueAt }, { $set: { nextDueAt }, $unset: { schedulerLockUntil: 1 } }); + } catch (error) { + await Pledge.updateOne({ _id: pledge._id }, { $unset: { schedulerLockUntil: 1 } }); + logger.error({ pledgeId: pledge._id, error: error.message }, "Pledge scheduler tick failed"); + throw error; + } + } +}; + +const loop = async () => { + if (!running) return; + try { await tickPledgeScheduler(); } catch (error) { logger.error(error, "Pledge scheduler failed"); } + timer = setTimeout(loop, INTERVAL_MS); + timer.unref?.(); +}; + +export const startPledgeScheduler = async () => { if (!running) { running = true; loop(); } }; +export const stopPledgeScheduler = async () => { running = false; if (timer) clearTimeout(timer); timer = null; }; diff --git a/test/analyticsCalculator.test.js b/test/analyticsCalculator.test.js new file mode 100644 index 00000000..238ab4fc --- /dev/null +++ b/test/analyticsCalculator.test.js @@ -0,0 +1,156 @@ +import { + round, + computeCompletionRate, + computeConversionRate, + sumRevenue, + computeEngagement, + computeDropOff, + escapeCsvCell, + buildCsv, + analyticsToCsv, +} from "../src/utils/analyticsCalculator.js"; + +describe("analyticsCalculator", () => { + describe("round", () => { + it("rounds to two decimals and guards non-finite input", () => { + expect(round(1.23456)).toBe(1.23); + expect(round(Number.NaN)).toBe(0); + expect(round(1.23456, 3)).toBe(1.235); + }); + }); + + describe("computeCompletionRate", () => { + it("returns percentage of completions over enrollments", () => { + expect(computeCompletionRate(5, 20)).toBe(25); + }); + it("returns 0 when there are no enrollments", () => { + expect(computeCompletionRate(5, 0)).toBe(0); + }); + }); + + describe("computeConversionRate", () => { + it("returns percentage of enrollments over views", () => { + expect(computeConversionRate(10, 200)).toBe(5); + }); + it("returns 0 when there are no views", () => { + expect(computeConversionRate(10, 0)).toBe(0); + }); + }); + + describe("sumRevenue", () => { + it("groups revenue by currency and counts transactions", () => { + const result = sumRevenue([ + { amount: "10.5", currency: "USDC" }, + { amount: "4.5", currency: "USDC" }, + { amount: "2", currency: "XLM" }, + { amount: "not-a-number", currency: "USDC" }, + ]); + expect(result.revenueByCurrency.USDC).toBe(15); + expect(result.revenueByCurrency.XLM).toBe(2); + expect(result.transactionCount).toBe(4); + expect(result.grossByCurrency).toEqual([ + { currency: "USDC", amount: 15 }, + { currency: "XLM", amount: 2 }, + ]); + }); + }); + + describe("computeEngagement", () => { + it("averages progress and counts active learners since a date", () => { + const since = new Date("2026-01-10"); + const result = computeEngagement( + [ + { + percentComplete: 100, + lessonsCompleted: [1, 2, 3], + updatedAt: "2026-01-15", + }, + { + percentComplete: 50, + lessonsCompleted: [1], + updatedAt: "2026-01-05", + }, + ], + since + ); + expect(result.learnersStarted).toBe(2); + expect(result.avgPercentComplete).toBe(75); + expect(result.avgLessonsCompleted).toBe(2); + expect(result.activeLearners).toBe(1); + }); + + it("returns zeros for an empty cohort", () => { + expect(computeEngagement([])).toEqual({ + learnersStarted: 0, + avgPercentComplete: 0, + avgLessonsCompleted: 0, + activeLearners: 0, + }); + }); + }); + + describe("computeDropOff", () => { + it("computes per-lesson reach and biggest drop-off point", () => { + const lessons = [ + { lessonId: "a", title: "Intro" }, + { lessonId: "b", title: "Middle" }, + { lessonId: "c", title: "End" }, + ]; + const progress = [ + { lessonsCompleted: ["a", "b", "c"] }, + { lessonsCompleted: ["a", "b"] }, + { lessonsCompleted: ["a"] }, + { lessonsCompleted: ["a"] }, + ]; + const { lessons: rows, biggestDropOff } = computeDropOff(lessons, progress); + expect(rows[0].reached).toBe(4); + expect(rows[1].reached).toBe(2); + expect(rows[2].reached).toBe(1); + expect(rows[1].dropOff).toBe(2); + expect(biggestDropOff.lessonId).toBe("b"); + }); + }); + + describe("CSV helpers", () => { + it("escapes cells containing commas, quotes and newlines", () => { + expect(escapeCsvCell("plain")).toBe("plain"); + expect(escapeCsvCell("a,b")).toBe('"a,b"'); + expect(escapeCsvCell('say "hi"')).toBe('"say ""hi"""'); + }); + + it("builds a CSV document with a header row", () => { + const csv = buildCsv(["metric", "value"], [["views", 3]]); + expect(csv).toBe("metric,value\r\nviews,3\r\n"); + }); + + it("serialises an analytics object into metric/value rows", () => { + const csv = analyticsToCsv({ + courseId: "abc", + title: "Test Course", + range: { startDate: null, endDate: null }, + metrics: { + views: 100, + enrollmentsTotal: 10, + enrollments: 10, + completions: 4, + completionRate: 40, + conversionRate: 10, + engagement: { + learnersStarted: 8, + activeLearners: 5, + avgPercentComplete: 55, + avgLessonsCompleted: 3, + }, + revenue: { + transactionCount: 2, + grossByCurrency: [{ currency: "USDC", amount: 25 }], + }, + dropOff: { lessons: [] }, + }, + }); + expect(csv).toContain("Course ID,abc"); + expect(csv).toContain("Revenue (USDC),25"); + expect(csv).toContain("Completion Rate (%),40"); + }); + }); +}); diff --git a/test/app.test.js b/test/app.test.js index acc40e2f..9cbfdb7a 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -17,9 +17,12 @@ import { let mongoServer; beforeAll(async () => { - if (process.env.MONGO_URI && !process.env.MONGO_URI.includes("localhost")) { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (process.env.MONGO_URI) { try { - await mongoose.connect(process.env.MONGO_URI); + await mongoose.connect(process.env.MONGO_URI, { serverSelectionTimeoutMS: 2000 }); return; } catch (_err) { // Fallback to MongoMemoryServer @@ -53,10 +56,34 @@ describe("DeenBridge API", () => { expect(res.text).toContain("Welcome to DeenBridge API"); }); - it("should respond to GET /health", async () => { + it("should report degraded readiness when Redis is unavailable", async () => { const res = await request(app).get("/health"); + expect(res.statusCode).toBe(503); + expect(res.body).toMatchObject({ + success: false, + data: { + status: "unhealthy", + environment: "test", + dependencies: { + mongodb: { + status: "up", + state: "connected", + }, + redis: { + status: "down", + }, + }, + }, + }); + expect(res.body.data.uptime).toEqual(expect.any(Number)); + expect(Number.isNaN(Date.parse(res.body.data.timestamp))).toBe(false); + }); + + it("should respond to GET /ping without checking dependencies", async () => { + const res = await request(app).get("/ping"); + expect(res.statusCode).toBe(200); - expect(res.body).toHaveProperty("success", true); + expect(res.text).toBe("pong"); }); it("should respond to GET /api/courses", async () => { diff --git a/test/auditLog.test.js b/test/auditLog.test.js index 9854d001..ea3ecc32 100644 --- a/test/auditLog.test.js +++ b/test/auditLog.test.js @@ -7,6 +7,7 @@ import { jest } from "@jest/globals"; import request from "supertest"; import mongoose from "mongoose"; +import axios from "axios"; import app from "../app.js"; import AuditLog, { AUDIT_ACTIONS } from "../src/models/AuditLog.js"; import User from "../src/models/User.js"; @@ -26,18 +27,23 @@ const JWT_SECRET = process.env.JWT_SECRET || "deenbridge-temp-secret-key-2024"; // Helper: mint a JWT for a user in usersStore const mintToken = (user) => - jwt.sign({ userId: user._id, role: user.role, sessionId: "sess-1" }, JWT_SECRET, { - expiresIn: "15m", - }); + jwt.sign( + { userId: user._id, role: user.role, sessionId: "sess-1", is2FAVerified: true }, + JWT_SECRET, + { expiresIn: "15m" } + ); // Helper: make a minimal user object const makeUser = (overrides = {}) => { const _id = new mongoose.Types.ObjectId().toString(); + const role = overrides.role || "student"; + const defaultTwoFactor = role === "admin" ? { enabled: true } : { enabled: false }; return { _id, name: "Test User", email: `user_${_id}@example.com`, - role: "student", + role, + twoFactor: defaultTwoFactor, save: async function () { return this; }, ...overrides, }; @@ -47,6 +53,9 @@ const makeUser = (overrides = {}) => { // Global mock setup // ───────────────────────────────────────────────────────────────────────────── beforeAll(() => { + // Mock the HIBP breached-password range call (empty data => not breached). + jest.spyOn(axios, "get").mockResolvedValue({ status: 200, statusText: "OK", data: "" }); + // ── AuditLog mocks ────────────────────────────────────────────────────── jest.spyOn(AuditLog, "create").mockImplementation(async (data) => { const doc = { diff --git a/test/auth.test.js b/test/auth.test.js index 73317513..bc77e3a1 100644 --- a/test/auth.test.js +++ b/test/auth.test.js @@ -23,6 +23,8 @@ describe("Authentication & Session Management", () => { beforeAll(() => { // Mock axios to prevent network calls during tests jest.spyOn(axios, "post").mockResolvedValue({ status: 200, statusText: "OK", data: {} }); + // Mock the HIBP breached-password range call (empty data => not breached). + jest.spyOn(axios, "get").mockResolvedValue({ status: 200, statusText: "OK", data: "" }); // Mock User methods jest.spyOn(User, "findOne").mockImplementation((query) => { diff --git a/test/auth2FA.test.js b/test/auth2FA.test.js new file mode 100644 index 00000000..31361aaa --- /dev/null +++ b/test/auth2FA.test.js @@ -0,0 +1,495 @@ +import { jest } from "@jest/globals"; +import request from "supertest"; +import mongoose from "mongoose"; +import jwt from "jsonwebtoken"; +import bcrypt from "bcryptjs"; +import app from "../app.js"; +import User from "../src/models/User.js"; +import Session from "../src/models/Session.js"; +import AuditLog, { AUDIT_ACTIONS } from "../src/models/AuditLog.js"; +import { + encryptSecret, + decryptSecret, + generateBase32Secret, + generateTOTPCode, +} from "../src/utils/twoFactorCrypto.js"; + +const JWT_SECRET = process.env.JWT_SECRET || "deenbridge-temp-secret-key-2024"; + +describe("TOTP Two-Factor Authentication (2FA)", () => { + let usersStore = []; + let sessionsStore = []; + let auditStore = []; + + const makeUser = (overrides = {}) => { + const _id = new mongoose.Types.ObjectId().toString(); + const userDoc = { + _id, + name: "Test 2FA User", + email: `user_${_id}@example.com`, + password: "", // set in test + role: "mentor", + isVerified: true, + twoFactor: { + enabled: false, + secret: undefined, + pendingSecret: undefined, + recoveryCodes: [], + enrolledAt: undefined, + }, + save: async function () { + const idx = usersStore.findIndex((u) => u._id.toString() === this._id.toString()); + if (idx >= 0) usersStore[idx] = this; + else usersStore.push(this); + return this; + }, + ...overrides, + }; + return userDoc; + }; + + const mintToken = (user, is2FAVerified = false) => + jwt.sign( + { userId: user._id, role: user.role, sessionId: "sess-1", is2FAVerified }, + JWT_SECRET, + { expiresIn: "15m" } + ); + + beforeAll(() => { + // ── AuditLog mocks ────────────────────────────────────────────────────── + jest.spyOn(AuditLog, "create").mockImplementation(async (data) => { + const doc = { + _id: new mongoose.Types.ObjectId().toString(), + createdAt: new Date(), + ...data, + }; + auditStore.push(doc); + return doc; + }); + + jest.spyOn(AuditLog, "find").mockImplementation((filter = {}) => { + const filtered = auditStore.filter((d) => { + if (filter.action && d.action !== filter.action) return false; + if (filter.status && d.status !== filter.status) return false; + return true; + }); + return { + sort: function () { return this; }, + skip: function (n) { return this; }, + limit: function (n) { return this; }, + populate: function () { return this; }, + lean: async function () { return filtered; }, + then: (resolve) => resolve(filtered), + }; + }); + + jest.spyOn(AuditLog, "countDocuments").mockImplementation(async () => auditStore.length); + + // ── User mocks ────────────────────────────────────────────────────────── + jest.spyOn(User, "findOne").mockImplementation((query) => { + const email = query?.email; + const found = usersStore.find((u) => u.email === email); + return { + select: (fields) => { + // If select(+password) or select(+twoFactor.secret) is called + return found || null; + }, + then: (resolve) => resolve(found || null), + }; + }); + + jest.spyOn(User, "findById").mockImplementation((id) => { + const found = usersStore.find((u) => u._id.toString() === id.toString()); + return { + select: (fields) => found || null, + then: (resolve) => resolve(found || null), + }; + }); + + jest.spyOn(User, "create").mockImplementation(async (data) => { + const user = makeUser(data); + usersStore.push(user); + return user; + }); + + jest.spyOn(User, "deleteMany").mockImplementation(async () => { + usersStore = []; + return { acknowledged: true }; + }); + + // ── Session mocks ─────────────────────────────────────────────────────── + jest.spyOn(Session, "create").mockImplementation(async (data) => { + const sess = { + _id: new mongoose.Types.ObjectId().toString(), + revokedAt: null, + replacedBy: null, + lastUsedAt: new Date(), + expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + ...data, + save: async function () { return this; }, + }; + sessionsStore.push(sess); + return sess; + }); + + jest.spyOn(Session, "findOne").mockImplementation((query) => { + let found = null; + if (query?.refreshTokenHash) { + found = sessionsStore.find((s) => s.refreshTokenHash === query.refreshTokenHash); + } else if (query?._id) { + found = sessionsStore.find((s) => s._id.toString() === query._id.toString()); + } + return { + populate: () => { + if (!found) return null; + const userObj = usersStore.find((u) => u._id.toString() === (found.user?._id || found.user)?.toString()); + if (userObj) found.user = userObj; + return found; + }, + then: (resolve) => resolve(found || null), + }; + }); + + jest.spyOn(Session, "find").mockImplementation((query) => { + let results = sessionsStore; + if (query?.user) results = results.filter((s) => (s.user?._id || s.user)?.toString() === query.user.toString()); + if (query?.revokedAt === null) results = results.filter((s) => s.revokedAt === null); + return results; + }); + + jest.spyOn(Session, "deleteMany").mockImplementation(async () => { + sessionsStore = []; + return { acknowledged: true }; + }); + }); + + beforeEach(() => { + usersStore = []; + sessionsStore = []; + auditStore = []; + delete process.env.ENABLE_TEST_RATE_LIMIT; + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // 1. ADMIN 2FA ENFORCEMENT + // ───────────────────────────────────────────────────────────────────────────── + describe("Admin 2FA Enforcement", () => { + it("rejects admin actions with 403 when admin has NOT enabled 2FA", async () => { + const adminNo2FA = makeUser({ + name: "Admin No 2FA", + email: "admin_no_2fa@example.com", + role: "admin", + twoFactor: { enabled: false }, + }); + usersStore.push(adminNo2FA); + const token = mintToken(adminNo2FA, false); + + const res = await request(app) + .get("/api/admin/audit") + .set("Authorization", `Bearer ${token}`); + + expect(res.statusCode).toBe(403); + expect(res.body.message).toContain("Admin access requires TOTP two-factor authentication to be enabled"); + }); + + it("rejects admin actions with 403 when admin token is NOT 2FA-verified", async () => { + const adminWith2FA = makeUser({ + name: "Admin With 2FA", + email: "admin_with_2fa@example.com", + role: "admin", + twoFactor: { enabled: true, secret: encryptSecret(generateBase32Secret()) }, + }); + usersStore.push(adminWith2FA); + // Mint token without is2FAVerified claim (e.g. single factor session) + const token = mintToken(adminWith2FA, false); + + const res = await request(app) + .get("/api/admin/audit") + .set("Authorization", `Bearer ${token}`); + + expect(res.statusCode).toBe(403); + expect(res.body.message).toContain("Admin access requires a 2FA-verified session"); + }); + + it("allows admin actions when admin has 2FA enabled AND token is 2FA-verified", async () => { + const adminWith2FA = makeUser({ + name: "Admin Verified", + email: "admin_verified@example.com", + role: "admin", + twoFactor: { enabled: true, secret: encryptSecret(generateBase32Secret()) }, + }); + usersStore.push(adminWith2FA); + const verifiedToken = mintToken(adminWith2FA, true); + + const res = await request(app) + .get("/api/admin/audit") + .set("Authorization", `Bearer ${verifiedToken}`); + + expect(res.statusCode).toBe(200); + expect(res.body.success).toBe(true); + }); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // 2. ENROLL -> CONFIRM -> LOGIN ROUND-TRIP + // ───────────────────────────────────────────────────────────────────────────── + describe("Enrollment, Confirmation & Step-up Login Flow", () => { + it("completes full 2FA lifecycle (setup -> confirm -> step-up login)", async () => { + const plainPassword = "Qx7#vLmp92Zt"; + const hashedPassword = await bcrypt.hash(plainPassword, 12); + const user = makeUser({ + email: "mentor_2fa@example.com", + password: hashedPassword, + role: "mentor", + }); + usersStore.push(user); + const initialToken = mintToken(user, false); + + // ── Step 1: POST /api/auth/2fa/setup ──────────────────────────────────── + const setupRes = await request(app) + .post("/api/auth/2fa/setup") + .set("Authorization", `Bearer ${initialToken}`); + + expect(setupRes.statusCode).toBe(200); + expect(setupRes.body.success).toBe(true); + expect(setupRes.body.secret).toBeDefined(); + expect(setupRes.body.otpauthUrl).toContain("otpauth://totp/"); + expect(setupRes.body.qrCode).toContain("data:image/png;base64,"); + + const plainSecret = setupRes.body.secret; + expect(user.twoFactor.enabled).toBe(false); + expect(user.twoFactor.pendingSecret).toBeDefined(); + + // Check setup initiated audit log + await new Promise((r) => setImmediate(r)); + const setupAudit = auditStore.find((a) => a.action === AUDIT_ACTIONS.AUTH_2FA_SETUP_INITIATED); + expect(setupAudit).toBeDefined(); + + // ── Step 2: POST /api/auth/2fa/verify (Confirm Setup with wrong code) ─── + const wrongConfirmRes = await request(app) + .post("/api/auth/2fa/verify") + .set("Authorization", `Bearer ${initialToken}`) + .send({ code: "000000" }); + + expect(wrongConfirmRes.statusCode).toBe(401); + expect(user.twoFactor.enabled).toBe(false); + + // ── Step 3: POST /api/auth/2fa/verify (Confirm Setup with valid code) ──── + const validSetupCode = generateTOTPCode(plainSecret); + + const confirmRes = await request(app) + .post("/api/auth/2fa/verify") + .set("Authorization", `Bearer ${initialToken}`) + .send({ code: validSetupCode }); + + expect(confirmRes.statusCode).toBe(200); + expect(confirmRes.body.success).toBe(true); + expect(confirmRes.body.recoveryCodes).toBeDefined(); + expect(confirmRes.body.recoveryCodes.length).toBe(10); + expect(user.twoFactor.enabled).toBe(true); + expect(user.twoFactor.pendingSecret).toBeUndefined(); + + const recoveryCodes = confirmRes.body.recoveryCodes; + + // ── Step 4: POST /api/auth/login (First Factor Password Check) ─────────── + const loginRes = await request(app) + .post("/api/auth/login") + .send({ email: user.email, password: plainPassword }); + + expect(loginRes.statusCode).toBe(200); + expect(loginRes.body.mfaRequired).toBe(true); + expect(loginRes.body.mfaToken).toBeDefined(); + // Ensure NO access or refresh tokens in password-only response! + expect(loginRes.body.accessToken).toBeUndefined(); + expect(loginRes.body.refreshToken).toBeUndefined(); + expect(loginRes.body.token).toBeUndefined(); + + const mfaToken = loginRes.body.mfaToken; + + // ── Step 5: POST /api/auth/2fa/verify (Login Step-up with invalid code) ── + const badMfaRes = await request(app) + .post("/api/auth/2fa/verify") + .send({ mfaToken, code: "999999" }); + + expect(badMfaRes.statusCode).toBe(401); + + // ── Step 6: POST /api/auth/2fa/verify (Login Step-up with valid code) ─── + const validLoginCode = generateTOTPCode(plainSecret); + + const mfaSuccessRes = await request(app) + .post("/api/auth/2fa/verify") + .send({ mfaToken, code: validLoginCode }); + + expect(mfaSuccessRes.statusCode).toBe(200); + expect(mfaSuccessRes.body.success).toBe(true); + expect(mfaSuccessRes.body.accessToken).toBeDefined(); + expect(mfaSuccessRes.body.refreshToken).toBeDefined(); + + // Verify the issued JWT access token has is2FAVerified: true + const decodedAccess = jwt.verify(mfaSuccessRes.body.accessToken, JWT_SECRET); + expect(decodedAccess.is2FAVerified).toBe(true); + }); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // 3. SINGLE-USE RECOVERY CODES + // ───────────────────────────────────────────────────────────────────────────── + describe("Single-Use Recovery Codes", () => { + it("allows login via recovery code and burns the used code immediately", async () => { + const plainPassword = "Qx7#vLmp92Zt"; + const hashedPassword = await bcrypt.hash(plainPassword, 12); + const secret = generateBase32Secret(); + + // Generate hashed recovery codes + const rawCode = "A1B2-C3D4-E5F6"; + const hashedCode = await bcrypt.hash(rawCode, 10); + + const user = makeUser({ + email: "recovery_user@example.com", + password: hashedPassword, + role: "mentor", + twoFactor: { + enabled: true, + secret: encryptSecret(secret), + recoveryCodes: [hashedCode], + enrolledAt: new Date(), + }, + }); + usersStore.push(user); + + // 1. Password check + const loginRes = await request(app) + .post("/api/auth/login") + .send({ email: user.email, password: plainPassword }); + + expect(loginRes.body.mfaRequired).toBe(true); + const mfaToken1 = loginRes.body.mfaToken; + + // 2. Submit recovery code for second factor + const mfaRes1 = await request(app) + .post("/api/auth/2fa/verify") + .send({ mfaToken: mfaToken1, recoveryCode: rawCode }); + + expect(mfaRes1.statusCode).toBe(200); + expect(mfaRes1.body.accessToken).toBeDefined(); + + // Verify the code was burned (removed from user.twoFactor.recoveryCodes) + expect(user.twoFactor.recoveryCodes.length).toBe(0); + + // 3. Attempt to reuse the SAME recovery code on a new login attempt + const loginRes2 = await request(app) + .post("/api/auth/login") + .send({ email: user.email, password: plainPassword }); + + const mfaToken2 = loginRes2.body.mfaToken; + + const mfaRes2 = await request(app) + .post("/api/auth/2fa/verify") + .send({ mfaToken: mfaToken2, recoveryCode: rawCode }); + + expect(mfaRes2.statusCode).toBe(401); + expect(mfaRes2.body.message).toContain("Invalid 2FA code or recovery code"); + }); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // 4. SECRET & RECOVERY CODE SERIALIZATION EXCLUSION + // ───────────────────────────────────────────────────────────────────────────── + describe("Secret & Recovery Code Privacy", () => { + it("never serializes secret or recovery code hashes in user responses", async () => { + const secret = generateBase32Secret(); + const user = makeUser({ + email: "privacy_user@example.com", + role: "student", + twoFactor: { + enabled: true, + secret: encryptSecret(secret), + recoveryCodes: ["$2b$10$hashedrecoverycode"], + }, + }); + usersStore.push(user); + + const token = mintToken(user, true); + + // Check logout/user responses + const userRes = await request(app) + .get("/api/auth/sessions") + .set("Authorization", `Bearer ${token}`); + + const jsonStr = JSON.stringify(userRes.body); + expect(jsonStr).not.toContain("twoFactor"); + expect(jsonStr).not.toContain("secret"); + expect(jsonStr).not.toContain("recoveryCodes"); + }); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // 5. RATE LIMITING + // ───────────────────────────────────────────────────────────────────────────── + describe("Rate Limiting on 2FA Verification", () => { + it("throttles repeated invalid verify requests to 429 when enabled", async () => { + process.env.ENABLE_TEST_RATE_LIMIT = "true"; + + const user = makeUser({ email: "ratelimit_user@example.com" }); + usersStore.push(user); + const token = mintToken(user, false); + + let lastStatus = 0; + for (let i = 0; i < 6; i++) { + const res = await request(app) + .post("/api/auth/2fa/verify") + .set("Authorization", `Bearer ${token}`) + .send({ code: "000000" }); + lastStatus = res.statusCode; + } + + expect(lastStatus).toBe(429); + }); + }); + + // ───────────────────────────────────────────────────────────────────────────── + // 6. DISABLE 2FA + // ───────────────────────────────────────────────────────────────────────────── + describe("Disable 2FA", () => { + it("requires a valid code to disable 2FA", async () => { + const secret = generateBase32Secret(); + const user = makeUser({ + email: "disable_user@example.com", + role: "mentor", + twoFactor: { + enabled: true, + secret: encryptSecret(secret), + enrolledAt: new Date(), + }, + }); + usersStore.push(user); + + const token = mintToken(user, true); + + // Invalid code -> 401 + const badDisableRes = await request(app) + .post("/api/auth/2fa/disable") + .set("Authorization", `Bearer ${token}`) + .send({ code: "111111" }); + + expect(badDisableRes.statusCode).toBe(401); + expect(user.twoFactor.enabled).toBe(true); + + // Valid code -> 200 + const validCode = generateTOTPCode(secret); + + const validDisableRes = await request(app) + .post("/api/auth/2fa/disable") + .set("Authorization", `Bearer ${token}`) + .send({ code: validCode }); + + expect(validDisableRes.statusCode).toBe(200); + expect(user.twoFactor.enabled).toBe(false); + expect(user.twoFactor.secret).toBeUndefined(); + }); + }); +}); diff --git a/test/authRoles.test.js b/test/authRoles.test.js index d760c800..c4dc4dfb 100644 --- a/test/authRoles.test.js +++ b/test/authRoles.test.js @@ -2,7 +2,6 @@ import { jest } from "@jest/globals"; import express from "express"; import request from "supertest"; import mongoose from "mongoose"; -import { MongoMemoryServer } from "mongodb-memory-server"; import User from "../src/models/User.js"; import PendingUser from "../src/models/PendingUser.js"; import Book from "../src/models/Book.js"; @@ -10,33 +9,205 @@ import Space from "../src/models/Space.js"; import Course from "../src/models/Course.js"; import "../src/jobs/handlers.js"; import { protect, authorize, restrictTo } from "../src/middlewares/authMiddleware.js"; +import { authorizeOwnership } from "../src/middlewares/authorize.js"; +import { errorHandler } from "../src/middlewares/errorHandler.js"; import { registerUser } from "../src/controllers/authController.js"; import { deleteBook } from "../src/controllers/books/bookController.js"; import { deleteSpace, updateSpace } from "../src/controllers/spaceController.js"; import { updateUser, deleteUser, getUser } from "../src/controllers/userController.js"; -import { updateCourse } from "../src/controllers/courses/courseController.js"; describe("Role-Based Access Control (RBAC) & Anti-Escalation", () => { - let mongoServer; - - beforeAll(async () => { - mongoServer = await MongoMemoryServer.create(); - await mongoose.connect(mongoServer.getUri()); - }, 30000); - - afterAll(async () => { - await mongoose.disconnect(); - if (mongoServer) { - await mongoServer.stop(); - } + let usersStore = []; + let pendingStore = []; + let booksStore = []; + let spacesStore = []; + let coursesStore = []; + + beforeAll(() => { + jest.spyOn(User, "create").mockImplementation(async (data) => { + if (data.role && !["student", "mentor", "admin"].includes(data.role)) { + throw new Error(`User validation failed: role: \`${data.role}\` is not a valid enum value for path \`role\`.`); + } + const _id = new mongoose.Types.ObjectId().toString(); + const user = { + _id, + role: "student", + following: [], + followers: [], + purchasedBooks: [], + purchasedCourses: [], + save: async function () { return this; }, + toObject: function () { + const clone = { ...this }; + delete clone.save; + delete clone.toObject; + return clone; + }, + ...data, + }; + usersStore.push(user); + return user; + }); + + jest.spyOn(User, "findOne").mockImplementation(async (query) => { + if (query?.email) return usersStore.find((u) => u.email === query.email) || null; + if (query?._id) return usersStore.find((u) => u._id.toString() === query._id.toString()) || null; + return null; + }); + + jest.spyOn(User, "findById").mockImplementation((id) => { + const found = usersStore.find((u) => u._id.toString() === id?.toString()); + let selectedFields = null; + const queryObj = { + select: (fields) => { + selectedFields = fields; + return queryObj; + }, + then: (resolve) => { + if (!found) return resolve(null); + const clone = { ...found }; + if (selectedFields && typeof selectedFields === "string") { + if (selectedFields.includes("-password")) { + delete clone.password; + } else { + const allowed = selectedFields.split(" "); + Object.keys(clone).forEach((key) => { + if (key !== "_id" && !allowed.includes(key)) { + delete clone[key]; + } + }); + } + } + return resolve(clone); + }, + }; + return queryObj; + }); + + jest.spyOn(User, "findByIdAndUpdate").mockImplementation(async (id, update) => { + const user = usersStore.find((u) => u._id.toString() === id?.toString()); + if (!user) return null; + const targetEmail = update.email || update.$set?.email; + if (targetEmail) { + const existing = usersStore.find((u) => u.email === targetEmail && u._id.toString() !== id.toString()); + if (existing) { + const err = new Error("E11000 duplicate key error collection"); + err.code = 11000; + throw err; + } + } + if (update.$set) Object.assign(user, update.$set); + else Object.assign(user, update); + return user; + }); + + jest.spyOn(User, "findByIdAndDelete").mockImplementation(async (id) => { + const idx = usersStore.findIndex((u) => u._id.toString() === id?.toString()); + if (idx !== -1) { + const deleted = usersStore[idx]; + usersStore.splice(idx, 1); + return deleted; + } + return null; + }); + + jest.spyOn(User, "deleteMany").mockImplementation(async () => { + usersStore = []; + return { acknowledged: true }; + }); + + jest.spyOn(PendingUser, "findOneAndUpdate").mockImplementation(async (query, update) => { + let pending = pendingStore.find((p) => p.email === query?.email); + if (pending) { + Object.assign(pending, update); + } else { + pending = { _id: new mongoose.Types.ObjectId().toString(), ...update }; + pendingStore.push(pending); + } + return pending; + }); + + jest.spyOn(PendingUser, "findOne").mockImplementation(async (query) => { + if (query?.email) return pendingStore.find((p) => p.email === query.email) || null; + return null; + }); + + jest.spyOn(PendingUser, "deleteMany").mockImplementation(async () => { + pendingStore = []; + return { acknowledged: true }; + }); + + jest.spyOn(Book, "create").mockImplementation(async (data) => { + const _id = new mongoose.Types.ObjectId().toString(); + const book = { _id, ...data }; + booksStore.push(book); + return book; + }); + + jest.spyOn(Book, "findById").mockImplementation(async (id) => { + return booksStore.find((b) => b._id.toString() === id?.toString()) || null; + }); + + jest.spyOn(Book, "findByIdAndDelete").mockImplementation(async (id) => { + const idx = booksStore.findIndex((b) => b._id.toString() === id?.toString()); + if (idx !== -1) { + const deleted = booksStore[idx]; + booksStore.splice(idx, 1); + return deleted; + } + return null; + }); + + jest.spyOn(Book, "deleteMany").mockImplementation(async () => { + booksStore = []; + return { acknowledged: true }; + }); + + jest.spyOn(Space, "create").mockImplementation(async (data) => { + const _id = new mongoose.Types.ObjectId().toString(); + const space = { _id, ...data }; + spacesStore.push(space); + return space; + }); + + jest.spyOn(Space, "findById").mockImplementation(async (id) => { + return spacesStore.find((s) => s._id.toString() === id?.toString()) || null; + }); + + jest.spyOn(Space, "findByIdAndDelete").mockImplementation(async (id) => { + const idx = spacesStore.findIndex((s) => s._id.toString() === id?.toString()); + if (idx !== -1) { + const deleted = spacesStore[idx]; + spacesStore.splice(idx, 1); + return deleted; + } + return null; + }); + + jest.spyOn(Space, "findByIdAndUpdate").mockImplementation(async (id, update) => { + const space = spacesStore.find((s) => s._id.toString() === id?.toString()); + if (!space) return null; + Object.assign(space, update); + return space; + }); + + jest.spyOn(Space, "deleteMany").mockImplementation(async () => { + spacesStore = []; + return { acknowledged: true }; + }); + + jest.spyOn(Course, "deleteMany").mockImplementation(async () => { + coursesStore = []; + return { acknowledged: true }; + }); }); - beforeEach(async () => { - await User.deleteMany({}); - await PendingUser.deleteMany({}); - await Book.deleteMany({}); - await Space.deleteMany({}); - await Course.deleteMany({}); + beforeEach(() => { + usersStore = []; + pendingStore = []; + booksStore = []; + spacesStore = []; + coursesStore = []; }); describe("User Model Role Enum Validation", () => { @@ -71,7 +242,8 @@ describe("Role-Based Access Control (RBAC) & Anti-Escalation", () => { app.get( "/admin-only", (req, _res, next) => { - req.user = { role: "admin" }; + req.user = { role: "admin", twoFactor: { enabled: true } }; + req.is2FAVerified = true; next(); }, authorize("admin"), @@ -191,6 +363,7 @@ describe("Role-Based Access Control (RBAC) & Anti-Escalation", () => { email: "admin_auth@example.com", password: "Qx7#vLmp92Zt", role: "admin", + twoFactor: { enabled: true }, }); testBook = await Book.create({ @@ -222,11 +395,16 @@ describe("Role-Based Access Control (RBAC) & Anti-Escalation", () => { req.user = studentUser; next(); }); - app.delete("/books/:id", deleteBook); + app.delete( + "/books/:id", + authorizeOwnership({ model: Book, ownerField: "author", resourceType: "Book" }), + deleteBook + ); + app.use(errorHandler); const res = await request(app).delete(`/books/${testBook._id}`); expect(res.status).toBe(403); - expect(res.body.message).toContain("Not authorized to delete this book"); + expect(res.body.message).toContain("not authorized to modify this book"); // Verify book still exists const bookExists = await Book.findById(testBook._id); @@ -260,6 +438,7 @@ describe("Role-Based Access Control (RBAC) & Anti-Escalation", () => { const appAdmin = express(); appAdmin.use((req, _res, next) => { req.user = adminUser; + req.is2FAVerified = true; next(); }); appAdmin.delete("/books/:id", deleteBook); @@ -275,8 +454,17 @@ describe("Role-Based Access Control (RBAC) & Anti-Escalation", () => { req.user = studentUser; next(); }); - app.delete("/spaces/:id", deleteSpace); - app.put("/spaces/:id", updateSpace); + app.delete( + "/spaces/:id", + authorizeOwnership({ model: Space, ownerField: "host", resourceType: "Space" }), + deleteSpace + ); + app.put( + "/spaces/:id", + authorizeOwnership({ model: Space, ownerField: "host", resourceType: "Space" }), + updateSpace + ); + app.use(errorHandler); const resDelete = await request(app).delete(`/spaces/${testSpace._id}`); expect(resDelete.status).toBe(403); @@ -328,6 +516,7 @@ describe("Role-Based Access Control (RBAC) & Anti-Escalation", () => { app.use(express.json()); app.use((req, _res, next) => { req.user = adminUser; + req.is2FAVerified = true; next(); }); app.put("/users/:id", updateUser); diff --git a/test/authSecurity.test.js b/test/authSecurity.test.js new file mode 100644 index 00000000..e1d3b6ea --- /dev/null +++ b/test/authSecurity.test.js @@ -0,0 +1,366 @@ +// test/authSecurity.test.js +// +// Jest + supertest tests for the authentication abuse hardening (issue #89): +// - progressive per-account login lockout (failedLoginAttempts + lockUntil) +// - AUTH_ACCOUNT_LOCKED audit emission +// - per-email signup / verification-resend throttling (survives IP rotation) +// - captcha gate no-op when unconfigured +// +// Uses the in-memory mock-store pattern (no DB / no network / no HIBP). +import { jest } from "@jest/globals"; +import request from "supertest"; +import mongoose from "mongoose"; +import bcrypt from "bcryptjs"; +import axios from "axios"; +import User from "../src/models/User.js"; +import PendingUser from "../src/models/PendingUser.js"; +import Session from "../src/models/Session.js"; +import AuditLog, { AUDIT_ACTIONS } from "../src/models/AuditLog.js"; + +// Set a small per-email window BEFORE importing the app so the email limiter +// (created at module load) picks up max=3 for the burst assertions. Cleaned up +// in afterAll so other suites re-import with the production defaults. +let app; + +describe("Authentication abuse hardening (issue #89)", () => { + let usersStore = []; + let sessionsStore = []; + let pendingStore = []; + let auditStore = []; + let emailAuthLimiter; + let usedEmails = new Set(); + + beforeAll(async () => { + process.env.RATE_LIMIT_EMAIL_AUTH_MAX = "3"; + process.env.RATE_LIMIT_EMAIL_AUTH_WINDOW_MS = String(60 * 1000); + ({ default: app } = await import("../app.js")); + ({ emailAuthLimiter } = await import("../src/middlewares/security.js")); + + // Block real network: HIBP range GET + any POST axios would make. + jest.spyOn(axios, "get").mockResolvedValue({ status: 200, statusText: "OK", data: "" }); + jest.spyOn(axios, "post").mockResolvedValue({ status: 200, statusText: "OK", data: {} }); + + // ── AuditLog mock (recordAudit writes into auditStore) ──────────────── + jest.spyOn(AuditLog, "create").mockImplementation(async (data) => { + const doc = { _id: new mongoose.Types.ObjectId().toString(), createdAt: new Date(), ...data }; + auditStore.push(doc); + return doc; + }); + + // ── User mocks ───────────────────────────────────────────────────────── + jest.spyOn(User, "findOne").mockImplementation((query) => { + const email = query?.email; + const found = usersStore.find((u) => u.email === email); + return { select: () => found || null, then: (resolve) => resolve(found || null) }; + }); + + jest.spyOn(User, "findById").mockImplementation((id) => { + const found = usersStore.find((u) => u._id.toString() === id.toString()); + return { select: () => found || null, then: (resolve) => resolve(found || null) }; + }); + + // Atomic $inc used by loginUser's failed-login path — applies the + // increment to the same store object the assertions inspect. + jest.spyOn(User, "findByIdAndUpdate").mockImplementation(async (id, update) => { + const user = usersStore.find((u) => u._id.toString() === id.toString()); + if (!user) return null; + if (update?.$inc) { + for (const [field, amount] of Object.entries(update.$inc)) { + user[field] = (user[field] || 0) + amount; + } + } + return user; + }); + + jest.spyOn(User, "updateOne").mockImplementation(async (query, update) => { + const user = usersStore.find((u) => u._id.toString() === query?._id?.toString()); + if (user && update?.$set) Object.assign(user, update.$set); + return { acknowledged: true, modifiedCount: 1 }; + }); + + jest.spyOn(User, "create").mockImplementation(async (data) => { + const _id = new mongoose.Types.ObjectId().toString(); + const user = { _id, ...data, save: async function () { return this; } }; + usersStore.push(user); + return user; + }); + + jest.spyOn(User, "deleteMany").mockImplementation(async () => { + usersStore = []; + return { acknowledged: true }; + }); + + // ── PendingUser mocks (register -> pending -> verify flow) ──────────── + jest.spyOn(PendingUser, "findOneAndUpdate").mockImplementation(async (query, update) => { + let pending = pendingStore.find((p) => p.email === query?.email); + if (pending) Object.assign(pending, update); + else pending = { _id: new mongoose.Types.ObjectId().toString(), ...update }; + pending.save = async function () { return this; }; + pendingStore.push(pending); + return pending; + }); + + jest.spyOn(PendingUser, "findOne").mockImplementation(async (query) => { + if (query?.verificationToken) { + return pendingStore.find((p) => p.verificationToken === query.verificationToken) || null; + } + if (query?.email) { + return pendingStore.find((p) => p.email === query.email) || null; + } + return null; + }); + + jest.spyOn(PendingUser, "deleteOne").mockImplementation(async (query) => { + pendingStore = pendingStore.filter((p) => p._id !== query?._id && p.email !== query?.email); + return { deletedCount: 1 }; + }); + + // ── Session mocks ────────────────────────────────────────────────────── + jest.spyOn(Session, "create").mockImplementation(async (data) => { + const _id = new mongoose.Types.ObjectId().toString(); + const session = { + _id, + revokedAt: null, + replacedBy: null, + lastUsedAt: new Date(), + ...data, + save: async function () { return this; }, + }; + sessionsStore.push(session); + return session; + }); + + jest.spyOn(Session, "find").mockImplementation((query) => sessionsStore); + jest.spyOn(Session, "updateOne").mockImplementation(async () => ({ acknowledged: true })); + jest.spyOn(Session, "updateMany").mockImplementation(async () => ({ acknowledged: true })); + jest.spyOn(Session, "deleteMany").mockImplementation(async () => { + sessionsStore = []; + return { acknowledged: true }; + }); + }); + + afterAll(() => { + delete process.env.RATE_LIMIT_EMAIL_AUTH_MAX; + delete process.env.RATE_LIMIT_EMAIL_AUTH_WINDOW_MS; + jest.restoreAllMocks(); + }); + + beforeEach(() => { + usersStore = []; + sessionsStore = []; + pendingStore = []; + auditStore = []; + // Reset the per-email limiter buckets so counters never carry across tests. + for (const email of usedEmails) { + emailAuthLimiter?.resetKey(`email:${email}`); + } + usedEmails = new Set(); + }); + + const trackEmail = (email) => usedEmails.add(email); + + const makeUser = (overrides = {}) => { + const _id = new mongoose.Types.ObjectId().toString(); + const user = { + _id, + name: "Lockout User", + email: "lockout@example.com", + password: "hash", + role: "student", + isVerified: true, + failedLoginAttempts: 0, + lockUntil: null, + ...overrides, + }; + user.save = async function () { return this; }; + return user; + }; + + const flushAudit = () => new Promise((resolve) => setImmediate(resolve)); + + // ── 1. Progressive login lockout ───────────────────────────────────────── + describe("progressive login lockout", () => { + it("locks the account after N consecutive failures with escalating backoff", async () => { + const email = "lockout@example.com"; + const user = makeUser({ email }); + usersStore.push(user); + + // 4 failures: still just 401s, counters increment. + for (let i = 0; i < 4; i += 1) { + const res = await request(app) + .post("/api/auth/login") + .send({ email, password: "wrong-password" }); + expect(res.statusCode).toBe(401); + } + expect(user.failedLoginAttempts).toBe(4); + expect(user.lockUntil).toBeNull(); + + // 5th failure: lock is applied. + const fifth = await request(app) + .post("/api/auth/login") + .send({ email, password: "wrong-password" }); + expect(fifth.statusCode).toBe(401); + + expect(user.failedLoginAttempts).toBe(5); + expect(user.lockUntil).toBeInstanceOf(Date); + expect(new Date(user.lockUntil).getTime()).toBeGreaterThan(Date.now()); + + await flushAudit(); + expect( + auditStore.some((a) => a.action === AUDIT_ACTIONS.AUTH_ACCOUNT_LOCKED && a.status === "failure") + ).toBe(true); + }); + + it("rejects even a correct password while locked, without leaking the account exists", async () => { + const email = "locked@example.com"; + const hashed = await bcrypt.hash("CorrectPass123!", 4); + const user = makeUser({ email, password: hashed, failedLoginAttempts: 5, lockUntil: new Date(Date.now() + 60 * 1000) }); + usersStore.push(user); + + const res = await request(app) + .post("/api/auth/login") + .send({ email, password: "CorrectPass123!" }); + + // Identical to a nonexistent-account login — no enumeration. + expect(res.statusCode).toBe(401); + expect(res.body.success).toBe(false); + expect(res.body.message).toBe("Invalid credentials"); + // The response must not reveal the account exists beyond the generic + // "Invalid credentials" phrasing. + expect(res.body.message).not.toMatch(/user|account exists|found|attempts/i); + expect(user.failedLoginAttempts).toBe(5); // untouched while locked + }); + + it("auto-clears the lock after backoff and resets the counters on success", async () => { + const email = "recovering@example.com"; + const hashed = await bcrypt.hash("CorrectPass123!", 4); + const user = makeUser({ + email, + password: hashed, + failedLoginAttempts: 6, + // Simulate the backoff window having elapsed (time advance). + lockUntil: new Date(Date.now() - 1000), + }); + usersStore.push(user); + + const res = await request(app) + .post("/api/auth/login") + .send({ email, password: "CorrectPass123!" }); + + expect(res.statusCode).toBe(200); + expect(res.body.success).toBe(true); + expect(user.failedLoginAttempts).toBe(0); + expect(user.lockUntil).toBeNull(); + }); + + it("extends the lock with a longer backoff on further failures after expiry", async () => { + const email = "escalating@example.com"; + const user = makeUser({ + email, + failedLoginAttempts: 5, + // Backoff for the first lock has elapsed (time advance). + lockUntil: new Date(Date.now() - 1000), + }); + usersStore.push(user); + + const res = await request(app) + .post("/api/auth/login") + .send({ email, password: "still-wrong" }); + + expect(res.statusCode).toBe(401); + expect(user.failedLoginAttempts).toBe(6); + // 6th failure => base * 2^(6-5) = 2 minutes, not the 1-minute base. + const remainingMs = new Date(user.lockUntil).getTime() - Date.now(); + expect(remainingMs).toBeGreaterThan(90 * 1000); + expect(remainingMs).toBeLessThanOrEqual(120 * 1000); + }); + + it("caps the escalating backoff at LOGIN_LOCKOUT_MAX_MS", async () => { + const email = "capped@example.com"; + const user = makeUser({ + email, + failedLoginAttempts: 20, + // Prior lock has long since elapsed. + lockUntil: new Date(Date.now() - 1000), + }); + usersStore.push(user); + + const res = await request(app) + .post("/api/auth/login") + .send({ email, password: "still-wrong" }); + + expect(res.statusCode).toBe(401); + expect(user.failedLoginAttempts).toBe(21); + const maxMs = 24 * 60 * 60 * 1000; // LOGIN_LOCKOUT_MAX_MS default (24h) + const remainingMs = new Date(user.lockUntil).getTime() - Date.now(); + expect(remainingMs).toBeGreaterThan(maxMs - 60 * 1000); + expect(remainingMs).toBeLessThanOrEqual(maxMs); + }); + }); + + // ── 2. Per-email signup / verification throttling ──────────────────────── + describe("per-email signup/verification throttling", () => { + it("returns 429 for burst signups with the same email (works in test env)", async () => { + const email = "burst-signup@example.com"; + trackEmail(email); + const statuses = []; + for (let i = 0; i < 4; i += 1) { + const res = await request(app) + .post("/api/auth/register") + .send({ + name: `Burst ${i}`, + email, + password: "Qx7#vLmp92Zt", + role: "student", + }); + statuses.push(res.statusCode); + } + + expect(statuses.slice(0, 3)).toEqual([201, 201, 201]); + expect(statuses[3]).toBe(429); + }); + + it("returns 429 for burst verification resends with the same email", async () => { + const email = "burst-resend@example.com"; + trackEmail(email); + const pending = { + _id: new mongoose.Types.ObjectId().toString(), + email, + name: "Resend", + verificationToken: "initial-token", + }; + pending.save = async function () { return this; }; + pendingStore.push(pending); + + const statuses = []; + for (let i = 0; i < 4; i += 1) { + const res = await request(app) + .post("/api/auth/resend-verification") + .send({ email }); + statuses.push(res.statusCode); + } + + expect(statuses.slice(0, 3)).toEqual([200, 200, 200]); + expect(statuses[3]).toBe(429); + }); + }); + + // ── 3. Captcha gate is a pluggable no-op when unconfigured ─────────────── + describe("captcha gate", () => { + it("passes register requests when captcha is not configured", async () => { + const email = "nocaptcha@example.com"; + trackEmail(email); + const res = await request(app) + .post("/api/auth/register") + .send({ + name: "No Captcha", + email, + password: "Qx7#vLmp92Zt", + captchaToken: "whatever-client-token", + }); + + expect(res.statusCode).toBe(201); + expect(res.body.success).toBe(true); + }); + }); +}); \ No newline at end of file diff --git a/test/badge.test.js b/test/badge.test.js new file mode 100644 index 00000000..e46bd8c5 --- /dev/null +++ b/test/badge.test.js @@ -0,0 +1,100 @@ +import request from "supertest"; +import mongoose from "mongoose"; +import { MongoMemoryServer } from "mongodb-memory-server"; +import app from "../app.js"; +import Course from "../src/models/Course.js"; +import CourseProgress from "../src/models/CourseProgress.js"; +import Badge from "../src/models/badge.model.js"; +import UserBadge from "../src/models/user-badge.model.js"; +import User from "../src/models/User.js"; +import badgeService from "../src/services/badge.service.js"; +import { seedUserAndLogin } from "./helpers/testAuth.js"; + +describe("Course Badges API", () => { + let mongoServer; + let token; + let user; + + beforeAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (process.env.MONGO_URI) { + try { + await mongoose.connect(process.env.MONGO_URI, { serverSelectionTimeoutMS: 2000 }); + return; + } catch (_err) {} + } + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + }); + + afterAll(async () => { + await mongoose.disconnect(); + if (mongoServer) { + await mongoServer.stop(); + } + }); + + beforeEach(async () => { + await UserBadge.deleteMany({}); + await Badge.deleteMany({}); + await CourseProgress.deleteMany({}); + await Course.deleteMany({}); + await User.deleteMany({}); + + const auth = await seedUserAndLogin(app, { email: "badge_student@example.com" }); + token = auth.token; + user = auth.user; + }); + + it("should seed default badges and return all badge definitions", async () => { + const res = await request(app).get("/api/badges"); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.length).toBeGreaterThan(0); + }); + + it("should automatically award First Steps badge when user completes 1 course", async () => { + const course = await Course.create({ + title: "Introduction to Islam", + description: "Basics of faith", + category: "Theology", + createdBy: user._id, + }); + + await CourseProgress.create({ + user: user._id, + course: course._id, + percentComplete: 100, + completedAt: new Date(), + }); + + const userBadges = await badgeService.getUserBadges(user._id); + expect(userBadges).toHaveLength(1); + expect(userBadges[0].badge.slug).toBe("first-course"); + }); + + it("should fetch user badges via API endpoint", async () => { + const course = await Course.create({ + title: "Fiqh 101", + description: "Introductory Fiqh", + category: "Fiqh", + createdBy: user._id, + }); + + await CourseProgress.create({ + user: user._id, + course: course._id, + percentComplete: 100, + completedAt: new Date(), + }); + + const res = await request(app) + .get(`/api/badges/user/${user._id}`); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/test/bookDeleteAuthorization.test.js b/test/bookDeleteAuthorization.test.js new file mode 100644 index 00000000..9a4ac6b6 --- /dev/null +++ b/test/bookDeleteAuthorization.test.js @@ -0,0 +1,119 @@ +import mongoose from "mongoose"; +import { MongoMemoryServer } from "mongodb-memory-server"; +import request from "supertest"; +import app from "../app.js"; +import Book from "../src/models/Book.js"; +import Session from "../src/models/Session.js"; +import User from "../src/models/User.js"; +import { seedUserAndLogin } from "./helpers/testAuth.js"; + +describe("Book deletion authorization", () => { + let mongoServer; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + }, 30000); + + beforeEach(async () => { + await Promise.all([ + Book.deleteMany({}), + Session.deleteMany({}), + User.deleteMany({}), + ]); + }); + + afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); + }); + + const createBook = (author) => + Book.create({ + title: "Protected Book", + author, + category: "History", + description: "A book that only its author may delete", + image: "https://example.com/book.jpg", + fileUrl: "https://example.com/book.pdf", + }); + + it("rejects unauthenticated deletion with 401", async () => { + const book = await createBook(new mongoose.Types.ObjectId()); + + const response = await request(app).delete(`/api/books/${book._id}`); + + expect(response.status).toBe(401); + expect(await Book.exists({ _id: book._id })).not.toBeNull(); + }); + + it("rejects deletion by a non-owner with 403", async () => { + const owner = await User.create({ + name: "Book Owner", + email: "book.owner@example.com", + password: "Qx7#vLmp92Zt", + role: "mentor", + isVerified: true, + }); + const { token } = await seedUserAndLogin(app, { + name: "Other User", + email: "other.book.user@example.com", + }); + const book = await createBook(owner._id); + + const response = await request(app) + .delete(`/api/books/${book._id}`) + .set("Authorization", `Bearer ${token}`); + + expect(response.status).toBe(403); + expect(await Book.exists({ _id: book._id })).not.toBeNull(); + }); + + it("allows the author to delete their book", async () => { + const { token, user } = await seedUserAndLogin(app, { + name: "Book Owner", + email: "deleting.owner@example.com", + role: "mentor", + }); + const book = await createBook(user._id); + + const response = await request(app) + .delete(`/api/books/${book._id}`) + .set("Authorization", `Bearer ${token}`); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + success: true, + message: "Book deleted", + data: null, + }); + expect(await Book.exists({ _id: book._id })).toBeNull(); + }); + + it("returns 404 when the book does not exist", async () => { + const { token } = await seedUserAndLogin(app, { + name: "Missing Book Owner", + email: "missing.book.owner@example.com", + }); + + const response = await request(app) + .delete(`/api/books/${new mongoose.Types.ObjectId()}`) + .set("Authorization", `Bearer ${token}`); + + expect(response.status).toBe(404); + expect(response.body.message).toBe("Book not found"); + }); + + it("returns 400 for an invalid book id", async () => { + const { token } = await seedUserAndLogin(app, { + name: "Invalid Book Owner", + email: "invalid.book.owner@example.com", + }); + + const response = await request(app) + .delete("/api/books/not-a-book-id") + .set("Authorization", `Bearer ${token}`); + + expect(response.status).toBe(400); + }); +}); diff --git a/test/bookUpload.test.js b/test/bookUpload.test.js index a2d6ebcc..88fe5263 100644 --- a/test/bookUpload.test.js +++ b/test/bookUpload.test.js @@ -23,8 +23,20 @@ describe("Media Upload Hardening", () => { let mongoServer; beforeAll(async () => { - mongoServer = await MongoMemoryServer.create(); - await mongoose.connect(mongoServer.getUri()); + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (process.env.MONGO_URI) { + try { + await mongoose.connect(process.env.MONGO_URI, { serverSelectionTimeoutMS: 2000 }); + } catch (_err) { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + } + } else { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + } // Mock cloudinary upload stream jest.spyOn(cloudinary.uploader, "upload_stream").mockImplementation((options, cb) => { @@ -41,20 +53,13 @@ describe("Media Upload Hardening", () => { const { token: authToken, user } = await seedUserAndLogin(app, { name: "Uploader", email: "uploader@example.com", + role: "mentor", + verifiedEducator: true, }); token = authToken; testUser = user; }); - afterAll(async () => { - await mongoose.disconnect(); - if (mongoServer) { - await mongoServer.stop(); - } - jest.restoreAllMocks(); - }); - - it("should reject oversized files (Multer limits)", async () => { const largeBuffer = Buffer.alloc(55 * 1024 * 1024); // 55MB (limit is 50MB) @@ -68,8 +73,8 @@ describe("Media Upload Hardening", () => { .field("price", 10) .field("description", "Test Description"); - expect(res.status).toBe(400); - expect(res.body.message).toMatch(/File too large/i); + expect([400, 413]).toContain(res.status); + expect(res.body.message).toMatch(/File too large|Payload Too Large/i); }); it("should reject mismatched magic bytes (server validation)", async () => { @@ -149,4 +154,14 @@ describe("Media Upload Hardening", () => { expect(res.status).toBe(302); expect(res.headers.location).toBe("https://example.com/signed-url"); }); + + afterAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (mongoServer) { + await mongoServer.stop(); + } + }); }); + diff --git a/test/breachedPassword.test.js b/test/breachedPassword.test.js new file mode 100644 index 00000000..949f2b4f --- /dev/null +++ b/test/breachedPassword.test.js @@ -0,0 +1,239 @@ +// test/breachedPassword.test.js +// +// Jest + supertest tests for the HaveIBeenPwned breached-password check +// (issue #89): rejects breached passwords at register AND reset, only sends +// the 5-char SHA-1 prefix, and fails OPEN on a HIBP outage. +// +// The HIBP range call is ALWAYS mocked — never hits the network in CI. +import { jest } from "@jest/globals"; +import request from "supertest"; +import mongoose from "mongoose"; +import bcrypt from "bcryptjs"; +import crypto from "crypto"; +import axios from "axios"; +import app from "../app.js"; +import User from "../src/models/User.js"; +import PendingUser from "../src/models/PendingUser.js"; +import Session from "../src/models/Session.js"; +import AuditLog, { AUDIT_ACTIONS } from "../src/models/AuditLog.js"; +import { hashOtp } from "../src/utils/otp.js"; + +// A password that passes the static policy so ONLY the breach check decides. +const STRONG_PASSWORD = "Qx7#vLmp92Zt"; +const SHA1 = crypto + .createHash("sha1") + .update(STRONG_PASSWORD) + .digest("hex") + .toUpperCase(); +const PREFIX = SHA1.slice(0, 5); +const SUFFIX = SHA1.slice(5); + +// HIBP range response listing our password as breached (suffix:count lines). +const BREACHED_BODY = `${SUFFIX}:873482\n0123ABCDEF:2\nFFFF0000AA:1`; + +describe("Breached-password rejection (HIBP)", () => { + let usersStore = []; + let pendingStore = []; + let auditStore = []; + let getSpy; + + beforeAll(async () => { + // Mock AuditLog.create so recordAudit writes into auditStore. + jest.spyOn(AuditLog, "create").mockImplementation(async (data) => { + const doc = { _id: new mongoose.Types.ObjectId().toString(), createdAt: new Date(), ...data }; + auditStore.push(doc); + return doc; + }); + + // User mocks (login/reset read by email; register writes pending). + jest.spyOn(User, "findOne").mockImplementation((query) => { + const email = query?.email; + const found = usersStore.find((u) => u.email === email); + return { select: () => found || null, then: (resolve) => resolve(found || null) }; + }); + + jest.spyOn(User, "create").mockImplementation(async (data) => { + const _id = new mongoose.Types.ObjectId().toString(); + const user = { _id, ...data, save: async function () { return this; } }; + usersStore.push(user); + return user; + }); + + jest.spyOn(PendingUser, "findOneAndUpdate").mockImplementation(async (query, update) => { + let pending = pendingStore.find((p) => p.email === query?.email); + if (pending) Object.assign(pending, update); + else pending = { _id: new mongoose.Types.ObjectId().toString(), ...update }; + pending.save = async function () { return this; }; + pendingStore.push(pending); + return pending; + }); + + jest.spyOn(PendingUser, "findOne").mockImplementation(async (query) => { + if (query?.email) return pendingStore.find((p) => p.email === query.email) || null; + return null; + }); + + jest.spyOn(Session, "create").mockImplementation(async (data) => { + const _id = new mongoose.Types.ObjectId().toString(); + const session = { _id, ...data, save: async function () { return this; } }; + return session; + }); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + beforeEach(() => { + usersStore = []; + pendingStore = []; + auditStore = []; + if (getSpy) getSpy.mockRestore(); + }); + + // Default: mock the range GET as NOT breached (empty body). Accepts either a + // response body string or a custom mock implementation (e.g. a rejection for + // outage tests) so the suite's shared getSpy is always the spy that gets + // restored by beforeEach. + const mockHibp = (dataOrImpl) => { + getSpy = + typeof dataOrImpl === "function" + ? jest.spyOn(axios, "get").mockImplementation(dataOrImpl) + : jest + .spyOn(axios, "get") + .mockResolvedValue({ status: 200, statusText: "OK", data: dataOrImpl }); + return getSpy; + }; + + const flushAudit = () => new Promise((resolve) => setImmediate(resolve)); + + describe("register", () => { + it("rejects a known-breached password with 400 and audits the failure", async () => { + mockHibp(BREACHED_BODY); + const email = "breached-register@example.com"; + + const res = await request(app) + .post("/api/auth/register") + .send({ name: "Breached", email, password: STRONG_PASSWORD, role: "student" }); + + expect(res.statusCode).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.message).toMatch(/breach/i); + // No pending user was created. + expect(pendingStore.find((p) => p.email === email)).toBeFalsy(); + + await flushAudit(); + const row = auditStore.find((a) => a.action === AUDIT_ACTIONS.AUTH_REGISTER_FAILURE); + expect(row).toBeDefined(); + expect(row.metadata?.reason).toBe("breached_password"); + }); + + it("sends only the 5-char SHA-1 prefix — never the full hash or password", async () => { + const getSpy = mockHibp(BREACHED_BODY); + + await request(app) + .post("/api/auth/register") + .send({ name: "Prefix", email: "prefix@example.com", password: STRONG_PASSWORD }); + + const [url] = getSpy.mock.calls[0]; + expect(url).toContain(`/range/${PREFIX}`); + expect(url).not.toContain(SHA1); + expect(url).not.toContain(SUFFIX); + }); + + it("accepts a non-breached password", async () => { + mockHibp(""); // no suffixes + const email = "clean-register@example.com"; + + const res = await request(app) + .post("/api/auth/register") + .send({ name: "Clean", email, password: STRONG_PASSWORD, role: "student" }); + + expect(res.statusCode).toBe(201); + expect(res.body.success).toBe(true); + }); + + it("ignores HIBP padding records (count 0) and never treats them as a breach", async () => { + // Add-Padding responses append fake suffixes with a 0 occurrence count; + // even our own suffix must be ignored when its count is 0. + mockHibp(`${SUFFIX}:0\n0123ABCDEF:2\nFFFF0000AA:1`); + const email = "padding-register@example.com"; + + const res = await request(app) + .post("/api/auth/register") + .send({ name: "Padding", email, password: STRONG_PASSWORD, role: "student" }); + + expect(res.statusCode).toBe(201); + expect(res.body.success).toBe(true); + }); + + it("fails OPEN (allows signup) when the HIBP API is down", async () => { + mockHibp(() => + Promise.reject(new Error("ECONNREFUSED — HIBP outage")) + ); + const email = "outage-register@example.com"; + + const res = await request(app) + .post("/api/auth/register") + .send({ name: "Outage", email, password: STRONG_PASSWORD, role: "student" }); + + expect(res.statusCode).toBe(201); + expect(res.body.success).toBe(true); + }); + }); + + describe("reset password", () => { + it("rejects a known-breached new password at reset", async () => { + const email = "breached-reset@example.com"; + const hashedPassword = await bcrypt.hash("oldPassword123", 4); + const hashedOtp = await hashOtp("123456"); + const user = { + _id: new mongoose.Types.ObjectId().toString(), + name: "Reset", + email, + password: hashedPassword, + role: "student", + resetTokenHash: hashedOtp, + resetTokenExpiry: new Date(Date.now() + 15 * 60 * 1000), + save: async function () { return this; }, + }; + usersStore.push(user); + + mockHibp(BREACHED_BODY); + + const res = await request(app) + .post("/api/auth/reset-password") + .send({ email, otp: "123456", newPassword: STRONG_PASSWORD }); + + expect(res.statusCode).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.message).toMatch(/breach/i); + }); + + it("accepts a non-breached new password at reset", async () => { + const email = "clean-reset@example.com"; + const hashedPassword = await bcrypt.hash("oldPassword123", 4); + const hashedOtp = await hashOtp("123456"); + const user = { + _id: new mongoose.Types.ObjectId().toString(), + name: "Reset", + email, + password: hashedPassword, + role: "student", + resetTokenHash: hashedOtp, + resetTokenExpiry: new Date(Date.now() + 15 * 60 * 1000), + save: async function () { return this; }, + }; + usersStore.push(user); + + mockHibp(""); // not breached + + const res = await request(app) + .post("/api/auth/reset-password") + .send({ email, otp: "123456", newPassword: STRONG_PASSWORD }); + + expect(res.statusCode).toBe(200); + expect(res.body.success).toBe(true); + }); + }); +}); \ No newline at end of file diff --git a/test/certificate.test.js b/test/certificate.test.js new file mode 100644 index 00000000..b17a7669 --- /dev/null +++ b/test/certificate.test.js @@ -0,0 +1,111 @@ +import request from "supertest"; +import mongoose from "mongoose"; +import { MongoMemoryServer } from "mongodb-memory-server"; +import app from "../app.js"; +import Course from "../src/models/Course.js"; +import CourseProgress from "../src/models/CourseProgress.js"; +import Certificate from "../src/models/certificate.model.js"; +import User from "../src/models/User.js"; +import { seedUserAndLogin } from "./helpers/testAuth.js"; + +describe("Course Certificates API", () => { + let mongoServer; + let token; + let user; + let course; + + beforeAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (process.env.MONGO_URI) { + try { + await mongoose.connect(process.env.MONGO_URI, { serverSelectionTimeoutMS: 2000 }); + return; + } catch (_err) {} + } + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + }); + + afterAll(async () => { + await mongoose.disconnect(); + if (mongoServer) { + await mongoServer.stop(); + } + }); + + beforeEach(async () => { + await Certificate.deleteMany({}); + await CourseProgress.deleteMany({}); + await Course.deleteMany({}); + await User.deleteMany({}); + + const auth = await seedUserAndLogin(app, { email: "certificate_student@example.com" }); + token = auth.token; + user = auth.user; + + course = await Course.create({ + title: "Fullstack Web Development", + description: "Master Node and React", + category: "Programming", + price: 0, + createdBy: user._id, + }); + }); + + it("should fail generating certificate if course is incomplete", async () => { + await CourseProgress.create({ + user: user._id, + course: course._id, + percentComplete: 50, + }); + + const res = await request(app) + .post("/api/certificates/generate") + .set("Authorization", `Bearer ${token}`) + .send({ courseId: course._id }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.message).toMatch(/not been completed/i); + }); + + it("should generate certificate when course is 100% complete", async () => { + await CourseProgress.create({ + user: user._id, + course: course._id, + percentComplete: 100, + completedAt: new Date(), + }); + + const res = await request(app) + .post("/api/certificates/generate") + .set("Authorization", `Bearer ${token}`) + .send({ courseId: course._id }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + expect(res.body.data.certificateId).toMatch(/^CERT-/); + expect(res.body.data.learnerName).toBe(user.name); + expect(res.body.data.courseTitle).toBe(course.title); + }); + + it("should download PDF certificate", async () => { + const cert = await Certificate.create({ + certificateId: "CERT-TEST-12345", + user: user._id, + course: course._id, + learnerName: user.name, + courseTitle: course.title, + completionDate: new Date(), + certificateUrl: "/api/certificates/CERT-TEST-12345/download", + }); + + const res = await request(app).get(`/api/certificates/${cert.certificateId}/download`); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toBe("application/pdf"); + expect(res.headers["content-disposition"]).toMatch(/CERT-TEST-12345\.pdf/); + }); +}); diff --git a/test/claimableBalanceService.test.js b/test/claimableBalanceService.test.js new file mode 100644 index 00000000..2c1e5503 --- /dev/null +++ b/test/claimableBalanceService.test.js @@ -0,0 +1,267 @@ +import { jest } from "@jest/globals"; +import * as StellarSdk from "@stellar/stellar-sdk"; +import { + buildCreateClaimableBalanceTx, + buildClaimTx, + resolveBalanceId, + getClaimableBalance, + validateSignedGiftXdr, + describePredicate, +} from "../src/services/stellar/claimableBalanceService.js"; +import { + server, + networkPassphrase, + USDC_ISSUER, +} from "../src/services/stellar/stellarService.js"; + +const TESTNET_USDC = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; + +// Craft a TransactionResult XDR containing a create_claimable_balance success +// whose balanceId is the hex-encoded ClaimableBalanceId of the given hash. +const craftCreateBalanceResultXdr = (hashHex) => { + const x = StellarSdk.xdr; + const balId = x.ClaimableBalanceId.claimableBalanceIdTypeV0( + Buffer.from(hashHex, "hex") + ); + const createResult = + x.CreateClaimableBalanceResult.createClaimableBalanceSuccess(balId); + const opTr = x.OperationResultTr.createClaimableBalance(createResult); + const opRes = new x.OperationResult(x.OperationResultCode.opInner(), opTr); + const result = x.TransactionResultResult.txSuccess([opRes]); + const txResult = new x.TransactionResult({ + feeCharged: 100n, + result, + ext: new x.TransactionResultExt(0), + }); + return { + balanceId: balId.toXDR("hex"), + resultXdr: txResult.toXDR("base64"), + }; +}; + +describe("claimableBalanceService: build + predicates", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("builds a create_claimable_balance tx with complementary recipient/sender predicates", async () => { + const source = StellarSdk.Keypair.random(); + const claimant = StellarSdk.Keypair.random(); + const expiresAt = new Date(Date.now() + 30 * 24 * 3600 * 1000); + jest + .spyOn(server, "loadAccount") + .mockResolvedValue(new StellarSdk.Account(source.publicKey(), "1")); + + const built = await buildCreateClaimableBalanceTx({ + sourcePublicKey: source.publicKey(), + claimantPublicKey: claimant.publicKey(), + amount: "15", + expiresAt, + }); + + const tx = StellarSdk.TransactionBuilder.fromXDR(built.xdr, networkPassphrase); + expect(tx.operations).toHaveLength(1); + const op = tx.operations[0]; + expect(op.type).toBe("createClaimableBalance"); + expect(op.asset.code).toBe("USDC"); + expect(op.asset.issuer).toBe(TESTNET_USDC); + expect(op.amount).toBe("15.0000000"); + expect(op.claimants).toHaveLength(2); + + const [recipientClaimant, senderClaimant] = op.claimants; + expect(recipientClaimant.destination).toBe(claimant.publicKey()); + expect(describePredicate(recipientClaimant.predicate)).toEqual({ + type: "before_absolute_time", + time: String(expiresAt.getTime()), + }); + expect(senderClaimant.destination).toBe(source.publicKey()); + expect(describePredicate(senderClaimant.predicate)).toEqual({ + type: "not", + child: { + type: "before_absolute_time", + time: String(expiresAt.getTime()), + }, + }); + }); + + it("prepends changeTrust(USDC) when the claimant has no USDC trustline", async () => { + const claimant = StellarSdk.Keypair.random(); + const balanceId = "00000000" + "ab".repeat(32); + + // First loadAccount (hasUsdcTrustline → getAccountBalance) returns no USDC + // balance; second loadAccount returns the source account for the builder. + jest + .spyOn(server, "loadAccount") + .mockResolvedValueOnce({ + balances: [{ asset_type: "native", balance: "2.5" }], + }) + .mockResolvedValueOnce(new StellarSdk.Account(claimant.publicKey(), "1")); + + const built = await buildClaimTx({ + claimantPublicKey: claimant.publicKey(), + balanceId, + }); + + const tx = StellarSdk.TransactionBuilder.fromXDR(built.xdr, networkPassphrase); + expect(built.includesChangeTrust).toBe(true); + expect(tx.operations.map((o) => o.type)).toEqual([ + "changeTrust", + "claimClaimableBalance", + ]); + expect(tx.operations[0].line.code).toBe("USDC"); + }); + + it("omits changeTrust when the claimant already has a USDC trustline", async () => { + const claimant = StellarSdk.Keypair.random(); + const balanceId = "00000000" + "cd".repeat(32); + + jest + .spyOn(server, "loadAccount") + .mockResolvedValueOnce({ + balances: [ + { asset_type: "native", balance: "2.5" }, + { asset_type: "credit_alphanum4", asset_code: "USDC", asset_issuer: TESTNET_USDC, balance: "1" }, + ], + }) + .mockResolvedValueOnce(new StellarSdk.Account(claimant.publicKey(), "1")); + + const built = await buildClaimTx({ + claimantPublicKey: claimant.publicKey(), + balanceId, + }); + + expect(built.includesChangeTrust).toBe(false); + const tx = StellarSdk.TransactionBuilder.fromXDR(built.xdr, networkPassphrase); + expect(tx.operations.map((o) => o.type)).toEqual(["claimClaimableBalance"]); + }); +}); + +describe("claimableBalanceService: validateSignedGiftXdr", () => { + const source = StellarSdk.Keypair.random(); + const claimant = StellarSdk.Keypair.random(); + const expiresAt = new Date(Date.now() + 30 * 24 * 3600 * 1000); + + const buildSignedGift = ({ amount = "15", asset = new StellarSdk.Asset("USDC", TESTNET_USDC), extraClaimant } = {}) => { + const account = new StellarSdk.Account(source.publicKey(), "1"); + const claimants = [ + new StellarSdk.Claimant(claimant.publicKey(), StellarSdk.Claimant.predicateBeforeAbsoluteTime(expiresAt)), + new StellarSdk.Claimant(source.publicKey(), StellarSdk.Claimant.predicateNot(StellarSdk.Claimant.predicateBeforeAbsoluteTime(expiresAt))), + ...(extraClaimant ? [extraClaimant] : []), + ]; + const tx = new StellarSdk.TransactionBuilder(account, { fee: StellarSdk.BASE_FEE, networkPassphrase }) + .addOperation(StellarSdk.Operation.createClaimableBalance({ asset, amount, claimants })) + .setTimeout(300) + .build(); + tx.sign(source); + return tx.toXDR(); + }; + + it("accepts a correctly-formed signed gift XDR", () => { + const xdr = buildSignedGift(); + expect(() => + validateSignedGiftXdr(xdr, { + amount: "15", + recipientWallet: claimant.publicKey(), + senderWallet: source.publicKey(), + expiresAt, + }) + ).not.toThrow(); + }); + + it("rejects a tampered amount", () => { + const xdr = buildSignedGift({ amount: "999" }); + expect(() => + validateSignedGiftXdr(xdr, { + amount: "15", + recipientWallet: claimant.publicKey(), + senderWallet: source.publicKey(), + expiresAt, + }) + ).toThrow(/amount mismatch/i); + }); + + it("rejects a wrong asset", () => { + const xdr = buildSignedGift({ asset: StellarSdk.Asset.native() }); + expect(() => + validateSignedGiftXdr(xdr, { + amount: "15", + recipientWallet: claimant.publicKey(), + senderWallet: source.publicKey(), + expiresAt, + }) + ).toThrow(/wrong asset/i); + }); + + it("rejects a missing recipient claimant", () => { + const other = StellarSdk.Keypair.random(); + const xdr = buildSignedGift(); + expect(() => + validateSignedGiftXdr(xdr, { + amount: "15", + recipientWallet: other.publicKey(), // not a claimant + senderWallet: source.publicKey(), + expiresAt, + }) + ).toThrow(/recipient claimant/i); + }); +}); + +describe("claimableBalanceService: resolveBalanceId + getClaimableBalance", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("parses the balance id from the transaction result XDR (not the tx hash)", async () => { + const txHash = "a".repeat(64); + const { balanceId, resultXdr } = craftCreateBalanceResultXdr("ab".repeat(32)); + + jest.spyOn(server, "transactions").mockReturnValue({ + transaction: () => ({ call: async () => ({ result_xdr: resultXdr }) }), + }); + + const resolved = await resolveBalanceId(txHash, { amount: "15" }); + expect(resolved).toBe(balanceId); + expect(resolved).not.toBe(txHash); + }); + + it("falls back to the forClaimant query when result XDR is unavailable", async () => { + const txHash = "b".repeat(64); + jest.spyOn(server, "transactions").mockReturnValue({ + transaction: () => ({ call: async () => ({}) }), + }); + jest.spyOn(server, "claimableBalances").mockReturnValue({ + forClaimant: () => ({ + call: async () => ({ + records: [{ id: "fallback-balance-id", amount: "15.0000000", asset: `USDC:${USDC_ISSUER}` }], + }), + }), + }); + + const resolved = await resolveBalanceId(txHash, { + amount: "15", + claimantPublicKey: "GCLAIMANT", + }); + expect(resolved).toBe("fallback-balance-id"); + }); + + it("returns { exists: true } with the record for a known balance", async () => { + jest.spyOn(server, "claimableBalances").mockReturnValue({ + claimableBalance: () => ({ + call: async () => ({ id: "balance-1", state: "available", amount: "15.0000000" }), + }), + }); + const result = await getClaimableBalance("balance-1"); + expect(result.exists).toBe(true); + expect(result.record.state).toBe("available"); + }); + + it("returns { exists: false } for a 404", async () => { + const err = new Error("not found"); + err.response = { status: 404 }; + jest.spyOn(server, "claimableBalances").mockReturnValue({ + claimableBalance: () => ({ call: async () => { throw err; } }), + }); + const result = await getClaimableBalance("missing"); + expect(result).toEqual({ exists: false }); + }); +}); diff --git a/test/coreFlows.test.js b/test/coreFlows.test.js new file mode 100644 index 00000000..acb5b8e0 --- /dev/null +++ b/test/coreFlows.test.js @@ -0,0 +1,177 @@ +import { spawnSync } from "child_process"; +import { jest } from "@jest/globals"; +import bcrypt from "bcryptjs"; +import mongoose from "mongoose"; +import { MongoMemoryServer } from "mongodb-memory-server"; +import request from "supertest"; +import axios from "axios"; +import app from "../app.js"; +import User from "../src/models/User.js"; +import PendingUser from "../src/models/PendingUser.js"; +import Session from "../src/models/Session.js"; +import Book from "../src/models/Book.js"; +import { testOutbox } from "../services/emails/sendMail.js"; + +const PASSWORD = "Qx7#vLmp92Zt"; + +describe("Core auth, authorization, and wallet flows", () => { + let mongoServer; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + jest.spyOn(axios, "get").mockResolvedValue({ data: "" }); + }, 30000); + + beforeEach(async () => { + await Promise.all([ + User.deleteMany({}), + PendingUser.deleteMany({}), + Session.deleteMany({}), + Book.deleteMany({}), + ]); + testOutbox.length = 0; + }); + + afterAll(async () => { + jest.restoreAllMocks(); + await mongoose.disconnect(); + await mongoServer.stop(); + }); + + const createVerifiedUser = async (overrides = {}) => { + const { password = PASSWORD, ...fields } = overrides; + return User.create({ + name: "Test User", + email: "user@example.com", + password: await bcrypt.hash(password, 12), + role: "student", + isVerified: true, + ...fields, + }); + }; + + const login = async (email, password = PASSWORD) => { + const response = await request(app) + .post("/api/auth/login") + .send({ email, password }); + return response.body.accessToken; + }; + + it("imports the app without database or environment validation side effects", () => { + const result = spawnSync( + process.execPath, + [ + "--input-type=module", + "--eval", + 'import("./app.js").then(() => console.log("app-imported"))', + ], + { + cwd: process.cwd(), + env: { NODE_ENV: "test", PATH: process.env.PATH }, + encoding: "utf8", + } + ); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("app-imported"); + expect(result.stderr).toBe(""); + }); + + it("registers, verifies, and logs in a user", async () => { + const email = "new.user@example.com"; + const registration = await request(app).post("/api/auth/register").send({ + name: "New User", + email, + password: PASSWORD, + role: "student", + }); + + expect(registration.status).toBe(201); + expect(registration.body.success).toBe(true); + expect(testOutbox).toHaveLength(1); + + const pending = await PendingUser.findOne({ email }); + expect(pending).not.toBeNull(); + expect(pending.password).not.toBe(PASSWORD); + + const verification = await request(app).get( + `/api/auth/verify-email/${pending.verificationToken}` + ); + expect(verification.status).toBe(200); + expect(verification.body.accessToken).toBeTruthy(); + + const loginResponse = await request(app) + .post("/api/auth/login") + .send({ email, password: PASSWORD }); + expect(loginResponse.status).toBe(200); + expect(loginResponse.body.accessToken).toBeTruthy(); + expect(loginResponse.body.user.email).toBe(email); + }); + + it("rejects an incorrect password without issuing a token", async () => { + await createVerifiedUser({ email: "wrong.password@example.com" }); + + const response = await request(app).post("/api/auth/login").send({ + email: "wrong.password@example.com", + password: "NotTheRightPassword1!", + }); + + expect(response.status).toBe(401); + expect(response.body.message).toBe("Invalid credentials"); + expect(response.body.accessToken).toBeUndefined(); + }); + + it("rejects a non-owner attempting to delete another user's book", async () => { + const owner = await createVerifiedUser({ + name: "Owner", + email: "owner@example.com", + }); + const otherUser = await createVerifiedUser({ + name: "Other User", + email: "other@example.com", + }); + const book = await Book.create({ + title: "Owner's Book", + author: owner._id, + category: "History", + description: "A protected book", + image: "https://example.com/image.jpg", + fileUrl: "https://example.com/book.pdf", + }); + const otherToken = await login(otherUser.email); + + const response = await request(app) + .delete(`/api/books/${book._id}`) + .set("Authorization", `Bearer ${otherToken}`); + + expect(response.status).toBe(403); + expect(await Book.exists({ _id: book._id })).not.toBeNull(); + }); + + it("rejects an invalid Stellar public key before querying Horizon", async () => { + const user = await createVerifiedUser({ email: "wallet@example.com" }); + const token = await login(user.email); + + const response = await request(app) + .post("/api/stellar/wallet/connect") + .set("Authorization", `Bearer ${token}`) + .send({ publicKey: "not-a-stellar-public-key" }); + + expect(response.status).toBe(400); + expect(response.body).toMatchObject({ + success: false, + message: "Validation failed", + data: null, + errors: [ + { + field: "publicKey", + message: "publicKey must be a valid Stellar public key", + }, + ], + }); + + const persisted = await User.findById(user._id).select("stellarWallet"); + expect(persisted.stellarWallet?.publicKey).toBeUndefined(); + }); +}); diff --git a/test/courseBundle.test.js b/test/courseBundle.test.js new file mode 100644 index 00000000..a020777f --- /dev/null +++ b/test/courseBundle.test.js @@ -0,0 +1,143 @@ +import request from "supertest"; +import mongoose from "mongoose"; +import { MongoMemoryServer } from "mongodb-memory-server"; +import app from "../app.js"; +import Course from "../src/models/Course.js"; +import CourseBundle from "../src/models/course-bundle.model.js"; +import User from "../src/models/User.js"; +import { seedUserAndLogin } from "./helpers/testAuth.js"; + +describe("Course Bundles API", () => { + let mongoServer; + let token; + let user; + let course1; + let course2; + + beforeAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (process.env.MONGO_URI) { + try { + await mongoose.connect(process.env.MONGO_URI, { serverSelectionTimeoutMS: 2000 }); + return; + } catch (_err) {} + } + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + }); + + afterAll(async () => { + await mongoose.disconnect(); + if (mongoServer) { + await mongoServer.stop(); + } + }); + + beforeEach(async () => { + await CourseBundle.deleteMany({}); + await Course.deleteMany({}); + await User.deleteMany({}); + + const auth = await seedUserAndLogin(app, { email: "bundle_creator@example.com" }); + token = auth.token; + user = auth.user; + + course1 = await Course.create({ + title: "React Fundamentals", + description: "Learn React from scratch", + category: "Programming", + price: 100, + createdBy: user._id, + }); + + course2 = await Course.create({ + title: "Advanced React", + description: "Master React patterns", + category: "Programming", + price: 150, + createdBy: user._id, + }); + }); + + it("should create a course bundle and calculate discount percentage", async () => { + const res = await request(app) + .post("/api/course-bundles") + .set("Authorization", `Bearer ${token}`) + .send({ + title: "Complete React Suite", + description: "Get both React courses at a discount", + courses: [course1._id, course2._id], + price: 200, + }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + expect(res.body.data.originalPrice).toBe(250); + expect(res.body.data.discountPercentage).toBe(20); + expect(res.body.data.courses).toHaveLength(2); + }); + + it("should fetch all course bundles", async () => { + await CourseBundle.create({ + title: "React Bundle", + description: "Bundle description", + courses: [course1._id, course2._id], + price: 200, + originalPrice: 250, + discountPercentage: 20, + createdBy: user._id, + }); + + const res = await request(app).get("/api/course-bundles"); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].title).toBe("React Bundle"); + }); + + it("should fetch bundles containing a specific course", async () => { + await CourseBundle.create({ + title: "React Bundle", + description: "Bundle description", + courses: [course1._id, course2._id], + price: 200, + originalPrice: 250, + discountPercentage: 20, + createdBy: user._id, + }); + + const res = await request(app).get(`/api/course-bundles/course/${course1._id}`); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data).toHaveLength(1); + }); + + it("should purchase a bundle and enroll user in all courses", async () => { + const learnerAuth = await seedUserAndLogin(app, { email: "learner@example.com" }); + + const bundle = await CourseBundle.create({ + title: "React Bundle", + description: "Bundle description", + courses: [course1._id, course2._id], + price: 200, + originalPrice: 250, + discountPercentage: 20, + createdBy: user._id, + }); + + const res = await request(app) + .post(`/api/course-bundles/${bundle._id}/purchase`) + .set("Authorization", `Bearer ${learnerAuth.token}`) + .send({}); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + + const updatedLearner = await User.findById(learnerAuth.user._id); + expect(updatedLearner.purchasedCourses).toHaveLength(2); + }); +}); diff --git a/test/coursePrerequisites.test.js b/test/coursePrerequisites.test.js new file mode 100644 index 00000000..3effb229 --- /dev/null +++ b/test/coursePrerequisites.test.js @@ -0,0 +1,153 @@ +import request from "supertest"; +import mongoose from "mongoose"; +import jwt from "jsonwebtoken"; +import { MongoMemoryServer } from "mongodb-memory-server"; +import app from "../app.js"; +import User from "../src/models/User.js"; +import Course from "../src/models/Course.js"; +import CourseProgress from "../src/models/CourseProgress.js"; + +describe("Course prerequisites enrollment gate", () => { + let mongoServer; + let learnerToken; + let owner; + let learner; + + beforeAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (process.env.MONGO_URI) { + try { + await mongoose.connect(process.env.MONGO_URI, { serverSelectionTimeoutMS: 2000 }); + return; + } catch (_err) {} + } + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + }); + + beforeEach(async () => { + await Promise.all([ + User.deleteMany({}), + Course.deleteMany({}), + CourseProgress.deleteMany({}), + ]); + + owner = await User.create({ + name: "Owner", + email: "owner@example.com", + password: "Qx7#vLmp92Zt", + role: "mentor", + }); + learner = await User.create({ + name: "Learner", + email: "learner@example.com", + password: "Qx7#vLmp92Zt", + role: "student", + }); + + learnerToken = jwt.sign( + { userId: learner._id, sessionId: "l1" }, + process.env.JWT_SECRET || "deenbridge-temp-secret-key-2024" + ); + }); + + it("blocks enrollment when a prerequisite is not completed", async () => { + const prereq = await Course.create({ + title: "Intro to Fiqh", + description: "Basics", + category: "Tech", + createdBy: owner._id, + }); + const advanced = await Course.create({ + title: "Advanced Fiqh", + description: "Deep dive", + category: "Tech", + createdBy: owner._id, + prerequisites: [prereq._id], + }); + + const res = await request(app) + .post(`/api/courses/${advanced._id}/enroll`) + .set("Authorization", `Bearer ${learnerToken}`) + .send({}); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.message).toContain("Complete these prerequisites first"); + expect(res.body.message).toContain("Intro to Fiqh"); + + const refreshed = await Course.findById(advanced._id); + expect(refreshed.enrolledUsers.map(String)).not.toContain( + learner._id.toString() + ); + }); + + it("allows enrollment once the prerequisite is completed", async () => { + const prereq = await Course.create({ + title: "Intro to Fiqh", + description: "Basics", + category: "Tech", + createdBy: owner._id, + }); + const advanced = await Course.create({ + title: "Advanced Fiqh", + description: "Deep dive", + category: "Tech", + createdBy: owner._id, + prerequisites: [prereq._id], + }); + + await CourseProgress.create({ + user: learner._id, + course: prereq._id, + percentComplete: 100, + completedAt: new Date(), + }); + + const res = await request(app) + .post(`/api/courses/${advanced._id}/enroll`) + .set("Authorization", `Bearer ${learnerToken}`) + .send({}); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + + const refreshed = await Course.findById(advanced._id); + expect(refreshed.enrolledUsers.map(String)).toContain( + learner._id.toString() + ); + }); + + it("returns prerequisites (id + title) on the course detail endpoint", async () => { + const prereq = await Course.create({ + title: "Intro to Fiqh", + description: "Basics", + category: "Tech", + createdBy: owner._id, + }); + const advanced = await Course.create({ + title: "Advanced Fiqh", + description: "Deep dive", + category: "Tech", + createdBy: owner._id, + prerequisites: [prereq._id], + }); + + const res = await request(app).get(`/api/courses/${advanced._id}`); + + expect(res.status).toBe(200); + expect(res.body.course.prerequisites).toHaveLength(1); + expect(res.body.course.prerequisites[0].title).toBe("Intro to Fiqh"); + }); + + afterAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (mongoServer) { + await mongoServer.stop(); + } + }); +}); diff --git a/test/courseProgress.test.js b/test/courseProgress.test.js index dcd1cdec..5ee34d4a 100644 --- a/test/courseProgress.test.js +++ b/test/courseProgress.test.js @@ -15,6 +15,15 @@ describe("Course progress endpoints", () => { let learner; beforeAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (process.env.MONGO_URI) { + try { + await mongoose.connect(process.env.MONGO_URI, { serverSelectionTimeoutMS: 2000 }); + return; + } catch (_err) {} + } mongoServer = await MongoMemoryServer.create(); await mongoose.connect(mongoServer.getUri()); }); @@ -43,11 +52,6 @@ describe("Course progress endpoints", () => { learnerToken = jwt.sign({ userId: learner._id, sessionId: "l1" }, process.env.JWT_SECRET || "deenbridge-temp-secret-key-2024"); }); - afterAll(async () => { - await mongoose.disconnect(); - await mongoServer.stop(); - }); - it("creates progress for a learner and computes percent completion idempotently", async () => { const course = await Course.create({ title: "Course 1", @@ -113,4 +117,14 @@ describe("Course progress endpoints", () => { expect(res.body.courses).toHaveLength(1); expect(res.body.courses[0].percentComplete).toBe(50); }); + + afterAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (mongoServer) { + await mongoServer.stop(); + } + }); }); + diff --git a/test/dbHealth.test.js b/test/dbHealth.test.js new file mode 100644 index 00000000..12390022 --- /dev/null +++ b/test/dbHealth.test.js @@ -0,0 +1,23 @@ +import request from "supertest"; +import app from "../app.js"; +import { checkDatabaseHealth } from "../mongo/utils/healthCheck.js"; + +describe("Database Health Endpoint", () => { + it("GET /health/database should return health status and connection details", async () => { + const res = await request(app).get("/health/database"); + expect([200, 503]).toContain(res.status); + expect(res.body).toHaveProperty("success"); + expect(res.body).toHaveProperty("data"); + expect(res.body.data).toHaveProperty("status"); + expect(res.body.data).toHaveProperty("connection"); + expect(res.body.data).toHaveProperty("responseTimeMs"); + }); + + it("checkDatabaseHealth utility should return formatted object", async () => { + const health = await checkDatabaseHealth(); + expect(health).toHaveProperty("healthy"); + expect(health).toHaveProperty("status"); + expect(health).toHaveProperty("connection"); + expect(typeof health.responseTimeMs).toBe("number"); + }); +}); diff --git a/test/educatorVerification.test.js b/test/educatorVerification.test.js new file mode 100644 index 00000000..5f14ea57 --- /dev/null +++ b/test/educatorVerification.test.js @@ -0,0 +1,737 @@ +import { jest } from "@jest/globals"; +import request from "supertest"; +import mongoose from "mongoose"; +import { MongoMemoryReplSet } from "mongodb-memory-server"; +import app from "../app.js"; +import User from "../src/models/User.js"; +import Book from "../src/models/Book.js"; +import Course from "../src/models/Course.js"; +import Space from "../src/models/Space.js"; +import EducatorVerification, { + VERIFICATION_STATUS, + LEGAL_TRANSITIONS, +} from "../src/models/EducatorVerification.js"; +import AuditLog, { AUDIT_ACTIONS } from "../src/models/AuditLog.js"; +import { requireVerifiedEducator } from "../src/middlewares/authMiddleware.js"; +import express from "express"; +import jwt from "jsonwebtoken"; + +const JWT_SECRET = process.env.JWT_SECRET || "deenbridge-temp-secret-key-2024"; +const mintToken = (user) => + jwt.sign( + { + userId: user._id.toString(), + role: user.role, + sessionId: "sess-test", + is2FAVerified: user.role === "admin" ? true : false, + }, + JWT_SECRET, + { expiresIn: "15m" } + ); + +describe("Issue #92 — Educator Verification Pipeline + Content Gating", () => { + let mongoServer; + + beforeAll(async () => { + mongoServer = await MongoMemoryReplSet.create({ + replSet: { count: 1, storageEngine: "wiredTiger" }, + }); + await mongoose.connect(mongoServer.getUri()); + }, 60000); + + afterAll(async () => { + await mongoose.disconnect(); + if (mongoServer) await mongoServer.stop(); + }); + + beforeEach(async () => { + await User.deleteMany({}); + await Book.deleteMany({}); + await Course.deleteMany({}); + await Space.deleteMany({}); + await EducatorVerification.deleteMany({}); + await AuditLog.collection.deleteMany({}); + }); + + // ── Shared helpers ────────────────────────────────────────────────────── + const createUsers = async () => { + const student = await User.create({ + name: "Student User", + email: "student@example.com", + password: "Qx7#vLmp92Zt", + role: "student", + }); + const mentor = await User.create({ + name: "Mentor User", + email: "mentor@example.com", + password: "Qx7#vLmp92Zt", + role: "mentor", + }); + const verifiedEducator = await User.create({ + name: "Verified Educator", + email: "verified@example.com", + password: "Qx7#vLmp92Zt", + role: "mentor", + verifiedEducator: true, + }); + const admin = await User.create({ + name: "Admin User", + email: "admin@example.com", + password: "Qx7#vLmp92Zt", + role: "admin", + twoFactor: { enabled: true, secret: "MOCKSECRET", enrolledAt: new Date() }, + }); + return { student, mentor, verifiedEducator, admin }; + }; + + const authHeader = (user) => `Bearer ${mintToken(user)}`; + + const sampleDocuments = () => [ + { + type: "government_id", + cloudinaryPublicId: "educator-verification/sample-id", + originalFileName: "government_id.pdf", + }, + { + type: "teaching_certificate", + cloudinaryPublicId: "educator-verification/sample-cert", + originalFileName: "teaching_cert.pdf", + }, + ]; + + describe("1. EducatorVerification Model — State Machine", () => { + it("exposes correct status enum values", () => { + expect(VERIFICATION_STATUS).toEqual({ + DRAFT: "draft", + PENDING: "pending", + APPROVED: "approved", + REJECTED: "rejected", + }); + }); + + it("defines only legal transitions", () => { + expect(LEGAL_TRANSITIONS).toEqual({ + draft: ["pending"], + pending: ["approved", "rejected"], + approved: [], + rejected: ["pending"], + }); + }); + + it("allows draft→pending transition", () => { + expect( + EducatorVerification.isValidTransition("draft", "pending") + ).toBe(true); + }); + + it("allows pending→approved and pending→rejected transitions", () => { + expect( + EducatorVerification.isValidTransition("pending", "approved") + ).toBe(true); + expect( + EducatorVerification.isValidTransition("pending", "rejected") + ).toBe(true); + }); + + it("allows rejected→pending (resubmit) transition", () => { + expect( + EducatorVerification.isValidTransition("rejected", "pending") + ).toBe(true); + }); + + it("rejects illegal transitions", () => { + const illegal = [ + ["draft", "approved"], + ["draft", "rejected"], + ["pending", "draft"], + ["approved", "pending"], + ["approved", "rejected"], + ["rejected", "approved"], + ["rejected", "rejected"], + ["approved", "draft"], + ]; + for (const [from, to] of illegal) { + expect(EducatorVerification.isValidTransition(from, to)).toBe(false); + } + }); + + it("instance method canTransitionTo mirrors the static check", async () => { + const { mentor } = await createUsers(); + const v = await EducatorVerification.create({ + applicant: mentor._id, + status: VERIFICATION_STATUS.REJECTED, + }); + expect(v.canTransitionTo(VERIFICATION_STATUS.PENDING)).toBe(true); + expect(v.canTransitionTo(VERIFICATION_STATUS.APPROVED)).toBe(false); + }); + + it("rejects invalid status strings at model level", async () => { + const { mentor } = await createUsers(); + await expect( + EducatorVerification.create({ + applicant: mentor._id, + status: "totally_invalid_status", + }) + ).rejects.toThrow(); + }); + }); + + describe("2. requireVerifiedEducator Middleware", () => { + const setupApp = () => { + const a = express(); + a.post("/create", (req, _res, next) => { + const hdr = req.headers.authorization || ""; + const tok = hdr.startsWith("Bearer ") ? hdr.slice(7) : null; + if (tok) { + try { + const dec = jwt.verify(tok, JWT_SECRET); + req.user = { + _id: dec.userId, + role: dec.role, + verifiedEducator: + dec.userId === "verified-1" || dec.role === "admin", + }; + } catch (_) {} + } + next(); + }, requireVerifiedEducator, (_req, res) => + res.status(200).json({ success: true, created: true }) + ); + return a; + }; + + it("returns 401 when no authenticated user", async () => { + const a = setupApp(); + const res = await request(a).post("/create"); + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + }); + + it("returns 403 when a normal (unverified) user hits the route", async () => { + const a = setupApp(); + const token = jwt.sign( + { userId: "student-1", role: "student", sessionId: "x" }, + JWT_SECRET + ); + const res = await request(a) + .post("/create") + .set("Authorization", `Bearer ${token}`); + expect(res.status).toBe(403); + expect(res.body.success).toBe(false); + expect(res.body.message).toMatch(/verified educator/); + }); + + it("allows admin to bypass the verifiedEducator gate", async () => { + const a = setupApp(); + const token = jwt.sign( + { userId: "admin-1", role: "admin", sessionId: "x" }, + JWT_SECRET + ); + const res = await request(a) + .post("/create") + .set("Authorization", `Bearer ${token}`); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); + + it("allows verified educator through", async () => { + const a = setupApp(); + const token = jwt.sign( + { userId: "verified-1", role: "mentor", sessionId: "x" }, + JWT_SECRET + ); + const res = await request(a) + .post("/create") + .set("Authorization", `Bearer ${token}`); + expect(res.status).toBe(200); + expect(res.body.created).toBe(true); + }); + }); + + describe("3. Applicant API — Submit / Resubmit / Get own", () => { + it("returns null application when applicant has not applied yet", async () => { + const { mentor } = await createUsers(); + const res = await request(app) + .get("/api/educator-verification") + .set("Authorization", authHeader(mentor)); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.application).toBeNull(); + }); + + it("requires auth for applicant endpoints — returns 401", async () => { + const res = await request(app).get("/api/educator-verification"); + expect(res.status).toBe(401); + + const res2 = await request(app) + .post("/api/educator-verification/submit") + .send({ documents: [] }); + expect(res2.status).toBe(401); + }); + + it("rejects submit with no documents (400)", async () => { + const { mentor } = await createUsers(); + const res = await request(app) + .post("/api/educator-verification/submit") + .set("Authorization", authHeader(mentor)) + .send({ documents: [] }); + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.message).toMatch(/document/); + }); + + it("rejects submit with malformed document entries (400)", async () => { + const { mentor } = await createUsers(); + const res = await request(app) + .post("/api/educator-verification/submit") + .set("Authorization", authHeader(mentor)) + .send({ documents: [{ type: "government_id" }] }); + expect(res.status).toBe(400); + }); + + it("submits a new application — moves to PENDING, writes AUDIT submit", async () => { + const { mentor } = await createUsers(); + const docs = sampleDocuments(); + + const res = await request(app) + .post("/api/educator-verification/submit") + .set("Authorization", authHeader(mentor)) + .send({ documents: docs, personalStatement: "I love teaching" }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + expect(res.body.application.status).toBe(VERIFICATION_STATUS.PENDING); + expect(res.body.application.documents.length).toBe(2); + expect(res.body.application.submittedAt).toBeDefined(); + + const v = await EducatorVerification.findOne({ + applicant: mentor._id, + }); + expect(v).not.toBeNull(); + expect(v.status).toBe(VERIFICATION_STATUS.PENDING); + expect(v.personalStatement).toBe("I love teaching"); + expect(v.documents.length).toBe(2); + + const audit = await AuditLog.findOne({ + targetId: v._id.toString(), + }); + expect(audit).not.toBeNull(); + expect(audit.action).toBe(AUDIT_ACTIONS.EDUCATOR_VERIFY_SUBMIT); + expect(audit.status).toBe("success"); + expect(audit.actor.toString()).toBe(mentor._id.toString()); + }); + + it("prevents creating duplicate application while one is pending (409)", async () => { + const { mentor } = await createUsers(); + await EducatorVerification.create({ + applicant: mentor._id, + status: VERIFICATION_STATUS.PENDING, + submittedAt: new Date(), + documents: sampleDocuments(), + }); + + const res = await request(app) + .post("/api/educator-verification/submit") + .set("Authorization", authHeader(mentor)) + .send({ documents: sampleDocuments() }); + expect(res.status).toBe(409); + }); + + it("resubmit after rejection — returns PENDING + RESUBMIT audit", async () => { + const { mentor } = await createUsers(); + const v = await EducatorVerification.create({ + applicant: mentor._id, + status: VERIFICATION_STATUS.REJECTED, + documents: sampleDocuments(), + submittedAt: new Date(Date.now() - 86400000), + reviewNotes: "Need more docs", + reviewedAt: new Date(), + }); + + const newDocs = [ + ...sampleDocuments(), + { + type: "degree", + cloudinaryPublicId: "educator-verification/degree-v2", + originalFileName: "degree.pdf", + }, + ]; + + const res = await request(app) + .post("/api/educator-verification/submit") + .set("Authorization", authHeader(mentor)) + .send({ documents: newDocs }); + + expect(res.status).toBe(201); + expect(res.body.message).toMatch(/resubmitted/i); + expect(res.body.application.status).toBe(VERIFICATION_STATUS.PENDING); + + const reloaded = await EducatorVerification.findById(v._id); + expect(reloaded.status).toBe(VERIFICATION_STATUS.PENDING); + expect(reloaded.reviewNotes).toBeNull(); + expect(reloaded.reviewedBy).toBeNull(); + expect(reloaded.reviewedAt).toBeNull(); + expect(reloaded.documents.length).toBe(3); + + const audit = await AuditLog.findOne({ + action: AUDIT_ACTIONS.EDUCATOR_VERIFY_RESUBMIT, + }); + expect(audit).not.toBeNull(); + }); + + it("GET /api/educator-verification returns applicant's own application", async () => { + const { mentor } = await createUsers(); + const v = await EducatorVerification.create({ + applicant: mentor._id, + status: VERIFICATION_STATUS.PENDING, + submittedAt: new Date(), + documents: sampleDocuments(), + }); + const res = await request(app) + .get("/api/educator-verification") + .set("Authorization", authHeader(mentor)); + expect(res.status).toBe(200); + expect(res.body.application._id.toString()).toBe(v._id.toString()); + expect(res.body.application.documents.length).toBe(2); + }); + + it("GET /upload-signature returns signed upload credentials", async () => { + const { mentor } = await createUsers(); + const res = await request(app) + .get("/api/educator-verification/upload-signature") + .set("Authorization", authHeader(mentor)); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.timestamp).toBeDefined(); + expect(res.body.data.signature).toBeDefined(); + expect(res.body.data.folder).toBe("educator-verification"); + expect(res.body.data.uploadType).toBe("authenticated"); + }); + }); + + describe("4. Admin Review Queue — Admin-Only Gating", () => { + it("non-admin users get 403 on all admin endpoints", async () => { + const { student, mentor, verifiedEducator } = await createUsers(); + const nonAdmins = [student, mentor, verifiedEducator]; + for (const u of nonAdmins) { + const list = await request(app) + .get("/api/admin/educator-verification") + .set("Authorization", authHeader(u)); + expect(list.status).toBe(403); + } + }); + + it("admin can list pending applications", async () => { + const { admin, mentor, student } = await createUsers(); + await EducatorVerification.create({ + applicant: mentor._id, + status: VERIFICATION_STATUS.PENDING, + submittedAt: new Date(), + documents: sampleDocuments(), + }); + await EducatorVerification.create({ + applicant: student._id, + status: VERIFICATION_STATUS.REJECTED, + submittedAt: new Date(), + documents: sampleDocuments(), + }); + + const res = await request(app) + .get("/api/admin/educator-verification?status=pending") + .set("Authorization", authHeader(admin)); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.applications.length).toBe(1); + expect(res.body.pagination.total).toBe(1); + }); + + it("admin can fetch a single application by id", async () => { + const { admin, mentor } = await createUsers(); + const v = await EducatorVerification.create({ + applicant: mentor._id, + status: VERIFICATION_STATUS.PENDING, + submittedAt: new Date(), + documents: sampleDocuments(), + }); + const res = await request(app) + .get(`/api/admin/educator-verification/${v._id}`) + .set("Authorization", authHeader(admin)); + expect(res.status).toBe(200); + expect(res.body.application.applicant).toBeDefined(); + expect(res.body.application.documents.length).toBe(2); + }); + + it("admin approval sets verifiedEducator=true + APPROVE audit", async () => { + const { admin, mentor } = await createUsers(); + const v = await EducatorVerification.create({ + applicant: mentor._id, + status: VERIFICATION_STATUS.PENDING, + submittedAt: new Date(), + documents: sampleDocuments(), + }); + + const res = await request(app) + .post(`/api/admin/educator-verification/${v._id}/approve`) + .set("Authorization", authHeader(admin)) + .send({ reviewNotes: "Credentials look good." }); + + expect(res.status).toBe(200); + expect(res.body.message).toMatch(/approved/i); + + const reloadedV = await EducatorVerification.findById(v._id); + expect(reloadedV.status).toBe(VERIFICATION_STATUS.APPROVED); + expect(reloadedV.reviewedBy.toString()).toBe(admin._id.toString()); + expect(reloadedV.reviewNotes).toBe("Credentials look good."); + expect(reloadedV.reviewedAt).not.toBeNull(); + + const reloadedUser = await User.findById(mentor._id); + expect(reloadedUser.verifiedEducator).toBe(true); + + const audit = await AuditLog.findOne({ + action: AUDIT_ACTIONS.EDUCATOR_VERIFY_APPROVE, + }); + expect(audit).not.toBeNull(); + expect(audit.targetId).toBe(v._id.toString()); + }); + + it("admin rejection does NOT set verifiedEducator + REJECT audit", async () => { + const { admin, mentor } = await createUsers(); + const v = await EducatorVerification.create({ + applicant: mentor._id, + status: VERIFICATION_STATUS.PENDING, + submittedAt: new Date(), + documents: sampleDocuments(), + }); + + const res = await request(app) + .post(`/api/admin/educator-verification/${v._id}/reject`) + .set("Authorization", authHeader(admin)) + .send({ reviewNotes: "Please upload a clearer ID." }); + + expect(res.status).toBe(200); + expect(res.body.message).toMatch(/rejected/i); + + const reloadedV = await EducatorVerification.findById(v._id); + expect(reloadedV.status).toBe(VERIFICATION_STATUS.REJECTED); + + const reloadedUser = await User.findById(mentor._id); + expect(reloadedUser.verifiedEducator).toBe(false); + + const audit = await AuditLog.findOne({ + action: AUDIT_ACTIONS.EDUCATOR_VERIFY_REJECT, + }); + expect(audit).not.toBeNull(); + }); + + it("illegal transition (approve already APPROVED) returns 409", async () => { + const { admin, mentor } = await createUsers(); + const v = await EducatorVerification.create({ + applicant: mentor._id, + status: VERIFICATION_STATUS.APPROVED, + submittedAt: new Date(), + reviewedAt: new Date(), + documents: sampleDocuments(), + }); + const res = await request(app) + .post(`/api/admin/educator-verification/${v._id}/approve`) + .set("Authorization", authHeader(admin)); + expect(res.status).toBe(409); + }); + }); + + describe("5. Content Creation Gating — 403 for Unverified Educator", () => { + it("POST /api/courses (createCourse) returns 403 for unverified user", async () => { + const { student } = await createUsers(); + const res = await request(app) + .post("/api/courses") + .set("Authorization", authHeader(student)) + .send({ + title: "My Course", + description: "Intro", + category: "Fiqh", + }); + expect(res.status).toBe(403); + expect(res.body.success).toBe(false); + expect(res.body.message).toMatch(/verified educator/); + }); + + it("POST /api/courses succeeds for verifiedEducator user (2xx)", async () => { + const { verifiedEducator } = await createUsers(); + const res = await request(app) + .post("/api/courses") + .set("Authorization", authHeader(verifiedEducator)) + .send({ + title: "Fiqh 101", + description: "An intro to fiqh", + category: "Fiqh", + price: 0, + }); + expect(res.status).toBeLessThan(400); + }); + + it("POST /api/courses succeeds for admin (bypass) — 2xx", async () => { + const { admin } = await createUsers(); + const res = await request(app) + .post("/api/courses") + .set("Authorization", authHeader(admin)) + .send({ + title: "Admin Course", + description: "Admin intro", + category: "General", + price: 0, + }); + expect(res.status).toBeLessThan(400); + }); + + it("POST /api/books (createBook) — live session gating: book create route returns 403 for unverified", async () => { + const { student } = await createUsers(); + const res = await request(app) + .post("/api/books") + .set("Authorization", authHeader(student)); + expect(res.status).toBe(403); + expect(res.body.success).toBe(false); + expect(res.body.message).toMatch(/verified educator/); + }); + + it("POST /api/spaces (createSpace — the live-session create route per issue #92) returns 403 for unverified", async () => { + const { student } = await createUsers(); + const res = await request(app) + .post("/api/spaces") + .set("Authorization", authHeader(student)) + .send({ + title: "Live Tafsir", + description: "Session", + category: "Tafsir", + eventDate: new Date().toISOString(), + eventTime: "10:00 AM", + duration: 60, + }); + expect(res.status).toBe(403); + expect(res.body.success).toBe(false); + expect(res.body.message).toMatch(/verified educator/); + }); + }); + + describe("6. Full Lifecycle — Submit → Pending → Approve (and Reject→Resubmit path)", () => { + it("happy path: submit → pending → approve → verifiedEducator=true → can create course", async () => { + const { mentor, admin } = await createUsers(); + + const submitRes = await request(app) + .post("/api/educator-verification/submit") + .set("Authorization", authHeader(mentor)) + .send({ documents: sampleDocuments() }); + expect(submitRes.status).toBe(201); + const vId = submitRes.body.application._id; + + const beforeCreate = await request(app) + .post("/api/courses") + .set("Authorization", authHeader(mentor)) + .send({ + title: "Awaiting Approval", + description: "x", + category: "Fiqh", + price: 0, + }); + expect(beforeCreate.status).toBe(403); + + const approveRes = await request(app) + .post(`/api/admin/educator-verification/${vId}/approve`) + .set("Authorization", authHeader(admin)); + expect(approveRes.status).toBe(200); + + const afterCreate = await request(app) + .post("/api/courses") + .set("Authorization", authHeader(mentor)) + .send({ + title: "Fiqh 303", + description: "Advanced", + category: "Fiqh", + price: 0, + }); + expect(afterCreate.status).toBeLessThan(400); + }); + + it("reject → resubmit → approve lifecycle works end-to-end", async () => { + const { mentor, admin } = await createUsers(); + + const submitRes = await request(app) + .post("/api/educator-verification/submit") + .set("Authorization", authHeader(mentor)) + .send({ documents: sampleDocuments() }); + const vId = submitRes.body.application._id; + + const rejectRes = await request(app) + .post(`/api/admin/educator-verification/${vId}/reject`) + .set("Authorization", authHeader(admin)) + .send({ reviewNotes: "Please resubmit with clearer images." }); + expect(rejectRes.status).toBe(200); + + const illegalApprove = await request(app) + .post(`/api/admin/educator-verification/${vId}/approve`) + .set("Authorization", authHeader(admin)); + expect(illegalApprove.status).toBe(409); + + const resubmitRes = await request(app) + .post("/api/educator-verification/submit") + .set("Authorization", authHeader(mentor)) + .send({ documents: sampleDocuments() }); + expect(resubmitRes.status).toBe(201); + + const approveRes = await request(app) + .post(`/api/admin/educator-verification/${vId}/approve`) + .set("Authorization", authHeader(admin)); + expect(approveRes.status).toBe(200); + + const u = await User.findById(mentor._id); + expect(u.verifiedEducator).toBe(true); + }); + }); + + describe("7. Metadata allowlist stores educator verification keys", () => { + it("recordAudit stores verificationId, newStatus, previousStatus in metadata", async () => { + const { mentor } = await createUsers(); + await request(app) + .post("/api/educator-verification/submit") + .set("Authorization", authHeader(mentor)) + .send({ documents: sampleDocuments() }); + + const audit = await AuditLog.findOne({ + action: AUDIT_ACTIONS.EDUCATOR_VERIFY_SUBMIT, + }).lean(); + expect(audit).not.toBeNull(); + expect(audit.metadata.verificationId).toBeDefined(); + expect(audit.metadata.newStatus).toBe(VERIFICATION_STATUS.PENDING); + expect(audit.metadata.documentCount).toBe(2); + }); + }); + + describe("8. Signed document URL security", () => { + it("returns 404 for an invalid document index", async () => { + const { mentor } = await createUsers(); + await EducatorVerification.create({ + applicant: mentor._id, + status: VERIFICATION_STATUS.PENDING, + submittedAt: new Date(), + documents: sampleDocuments(), + }); + const res = await request(app) + .get("/api/educator-verification/documents/99/signed-url") + .set("Authorization", authHeader(mentor)); + expect(res.status).toBe(404); + }); + + it("admin endpoint returns 403 for non-admin even with valid id", async () => { + const { mentor, admin } = await createUsers(); + const v = await EducatorVerification.create({ + applicant: mentor._id, + status: VERIFICATION_STATUS.PENDING, + submittedAt: new Date(), + documents: sampleDocuments(), + }); + const res = await request(app) + .get(`/api/admin/educator-verification/${v._id}/documents/0/signed-url`) + .set("Authorization", authHeader(mentor)); + expect(res.status).toBe(403); + }); + }); +}); diff --git a/test/educators.test.js b/test/educators.test.js index 9ecd6888..5ef82dae 100644 --- a/test/educators.test.js +++ b/test/educators.test.js @@ -6,15 +6,37 @@ import Book from "../src/models/Book.js"; import User from "../src/models/User.js"; import Space from "../src/models/Space.js"; +import { MongoMemoryServer } from "mongodb-memory-server"; + let userId1; let userId2; let userId3; let adminId; +let mongoServer; beforeAll(async () => { - await mongoose.connect(`${process.env.MONGO_URI}_educators`); + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (process.env.MONGO_URI) { + try { + await mongoose.connect(`${process.env.MONGO_URI}_educators`, { serverSelectionTimeoutMS: 2000 }); + return; + } catch (_err) {} + } + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); }, 60000); +afterAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (mongoServer) { + await mongoServer.stop(); + } +}); + beforeEach(async () => { await Course.deleteMany({}); await Book.deleteMany({}); diff --git a/test/emailRoutes.test.js b/test/emailRoutes.test.js index 9e9a011e..bf966a85 100644 --- a/test/emailRoutes.test.js +++ b/test/emailRoutes.test.js @@ -2,30 +2,43 @@ import { jest } from "@jest/globals"; import request from "supertest"; import app from "../app.js"; import logger from "../src/config/logger.js"; +import { testOutbox } from "../services/emails/sendMail.js"; // SENDLIB_API_KEY/URL are stripped by test/jest.setup.js, so in the test env -// sendMail logs the email body via [EMAIL LOG] instead of delivering. That lets -// us inspect the generated OTP. (Bodies are never logged in dev/prod.) - -const extractOtpFromLog = (logCalls) => { - const emailLog = logCalls - .map((call) => call[0]) - .find((msg) => typeof msg === "string" && msg.includes("[EMAIL LOG]")); - const match = emailLog && emailLog.match(/#166534;">(\d+)<\/span>/); +// sendMail captures the rendered message in its in-memory testOutbox instead +// of delivering. That lets us inspect the generated OTP without the body ever +// reaching a log stream (bodies are never logged in any environment). + +const extractOtpFromHtml = (html) => { + const match = html.match(/#166534;">(\d+)<\/span>/); return match ? match[1] : null; }; +const lastEmail = () => testOutbox[testOutbox.length - 1] || null; + +const capturedLogText = (spy) => + spy.mock.calls + .map((call) => call.map((arg) => (typeof arg === "string" ? arg : JSON.stringify(arg))).join(" ")) + .join("\n"); + describe("OTP email route", () => { - let loggerInfoSpy; + let loggerSpy; beforeAll(() => { - loggerInfoSpy = jest.spyOn(logger, "info"); + loggerSpy = jest.spyOn(logger, "info"); + jest.spyOn(logger, "warn"); + jest.spyOn(logger, "error"); }); afterAll(() => { jest.restoreAllMocks(); }); + beforeEach(() => { + testOutbox.length = 0; + loggerSpy.mockClear(); + }); + it("generates a fresh OTP per request (no shared module-level code)", async () => { const res1 = await request(app).post("/api/email").send({ email: "one@example.com" }); const res2 = await request(app).post("/api/email").send({ email: "two@example.com" }); @@ -34,13 +47,10 @@ describe("OTP email route", () => { expect(res1.body.success).toBe(true); expect(res2.statusCode).toBe(200); - const otp1 = extractOtpFromLog(loggerInfoSpy.mock.calls); - // Logs accumulate across requests; take the last two [EMAIL LOG] entries. - const calls = loggerInfoSpy.mock.calls.map((c) => c[0]); - const emailLogs = calls.filter((m) => typeof m === "string" && m.includes("[EMAIL LOG]")); - const otp2 = emailLogs.length >= 2 - ? (emailLogs[emailLogs.length - 1].match(/#166534;">(\d+)<\/span>/) || [])[1] - : null; + // The outbox accumulates across requests; the last two entries are the + // two emails just sent. + const otp1 = extractOtpFromHtml(testOutbox[testOutbox.length - 2].html); + const otp2 = extractOtpFromHtml(lastEmail().html); expect(otp1).toBeDefined(); expect(otp2).toBeDefined(); @@ -57,6 +67,23 @@ describe("OTP email route", () => { expect(res.body).not.toHaveProperty("otp"); }); + it("never writes the OTP or email body to the logger", async () => { + const res = await request(app).post("/api/email").send({ email: "secret@example.com" }); + + expect(res.statusCode).toBe(200); + + // The generated OTP exists in the test outbox but must never appear in + // any log line (interpolated or structured). + const otp = extractOtpFromHtml(lastEmail().html); + expect(otp).toBeDefined(); + + const logs = capturedLogText(loggerSpy); + expect(logs).not.toContain(otp); + // No full email body / HTML should ever reach the logs either. + expect(logs).not.toContain(" { const missing = await request(app).post("/api/email").send({}); expect(missing.statusCode).toBe(400); diff --git a/test/feeSponsorService.test.js b/test/feeSponsorService.test.js new file mode 100644 index 00000000..9b3cfae5 --- /dev/null +++ b/test/feeSponsorService.test.js @@ -0,0 +1,533 @@ +// Fee-bump sponsorship service (#30) — structural whitelist, fee-bump fee +// correctness, spend caps, and secret handling. Uses the REAL @stellar/stellar-sdk +// (no live network) so the whitelist and fee math are exercised end-to-end. +import { jest } from "@jest/globals"; +import * as StellarSdk from "@stellar/stellar-sdk"; +import mongoose from "mongoose"; +import { MongoMemoryServer } from "mongodb-memory-server"; + +// A dedicated sponsor account for the whole suite. Set before importing the +// service so getFeeSponsorKeypair() can read it. +const SPONSOR = StellarSdk.Keypair.random(); +process.env.STELLAR_NETWORK = "testnet"; +process.env.FEE_SPONSOR_ENABLED = "true"; +process.env.FEE_SPONSOR_SECRET = SPONSOR.secret(); + +const { + validateInnerTransaction, + buildExpectedOperations, + computeFeeBumpFee, + wrapWithFeeBump, + prepareSponsoredSubmission, + checkSpendCaps, + recordSponsorshipSpend, + getFeeSponsorKeypair, + getFeeSponsorPublicKey, + getFeeSponsorConfig, + validateFeeSponsorBootConfig, + SponsorshipError, + utcDay, +} = await import("../src/services/stellar/feeSponsorService.js"); +const { networkPassphrase, toStroops } = await import( + "../src/services/stellar/stellarService.js" +); +const SponsorshipSpend = (await import("../src/models/SponsorshipSpend.js")) + .default; + +const USDC_ISSUER = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; +const USDC = new StellarSdk.Asset("USDC", USDC_ISSUER); + +// A single Mongo connection for the whole suite (caps + orchestration tests +// touch SponsorshipSpend). Mirrors the fallback pattern used by other DB tests: +// prefer the CI-provided MONGO_URI, otherwise spin an in-memory server. +let mongoServer; +beforeAll(async () => { + if (mongoose.connection.readyState !== 0) await mongoose.disconnect(); + if (process.env.MONGO_URI) { + try { + await mongoose.connect(`${process.env.MONGO_URI}_feesponsor`, { + serverSelectionTimeoutMS: 2000, + }); + return; + } catch { + /* fall back to in-memory */ + } + } + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); +}); + +afterAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.connection.dropDatabase(); + await mongoose.disconnect(); + } + if (mongoServer) await mongoServer.stop(); +}); + +const BUYER = StellarSdk.Keypair.random(); +const CREATOR = StellarSdk.Keypair.random(); +const PLATFORM = StellarSdk.Keypair.random(); +const OTHER = StellarSdk.Keypair.random(); +const MEMO = "DNB-BOOK-abcd1234"; + +const directRow = { + buyerWallet: BUYER.publicKey(), + creatorWallet: CREATOR.publicKey(), + amount: "15", + currency: "USDC", + memo: MEMO, +}; + +const splitRow = { + buyerWallet: BUYER.publicKey(), + creatorWallet: CREATOR.publicKey(), + amount: "15", + currency: "USDC", + memo: MEMO, + platformFee: { + platformWallet: PLATFORM.publicKey(), + platformAmount: "1.5", + creatorAmount: "13.5", + }, +}; + +// Build a signed inner transaction from a list of operation builders. +const buildInner = (ops, { memo = MEMO, source = BUYER } = {}) => { + const account = new StellarSdk.Account(source.publicKey(), "7"); + const builder = new StellarSdk.TransactionBuilder(account, { + fee: StellarSdk.BASE_FEE, + networkPassphrase, + }); + for (const op of ops) builder.addOperation(op); + const tx = builder.addMemo(StellarSdk.Memo.text(memo)).setTimeout(300).build(); + tx.sign(source); + return tx; +}; +const pay = (dest, amount, asset = USDC) => + StellarSdk.Operation.payment({ destination: dest, asset, amount }); + +const expectRejected = (tx, row) => { + expect(() => validateInnerTransaction(tx, row)).toThrow(SponsorshipError); + try { + validateInnerTransaction(tx, row); + } catch (e) { + expect(e.code).toBe("whitelist_rejected"); + expect(e.httpStatus).toBe(422); + expect(e.retryUnsponsored).toBe(true); + } +}; + +describe("feeSponsorService — boot config", () => { + const withEnv = (env, fn) => { + const saved = { ...process.env }; + Object.assign(process.env, env); + try { + return fn(); + } finally { + process.env = saved; + } + }; + + it("passes when disabled regardless of secret", () => { + withEnv({ FEE_SPONSOR_ENABLED: "false", FEE_SPONSOR_SECRET: "" }, () => { + expect(validateFeeSponsorBootConfig().ok).toBe(true); + }); + }); + + it("fails fast when enabled but the secret is missing", () => { + withEnv({ FEE_SPONSOR_ENABLED: "true", FEE_SPONSOR_SECRET: "" }, () => { + const res = validateFeeSponsorBootConfig(); + expect(res.ok).toBe(false); + expect(res.message).toMatch(/FEE_SPONSOR_SECRET/); + }); + }); + + it("fails fast when enabled but the secret is invalid", () => { + withEnv( + { FEE_SPONSOR_ENABLED: "true", FEE_SPONSOR_SECRET: "not-a-secret" }, + () => { + expect(validateFeeSponsorBootConfig().ok).toBe(false); + } + ); + }); + + it("passes when enabled with a valid secret", () => { + withEnv( + { FEE_SPONSOR_ENABLED: "true", FEE_SPONSOR_SECRET: SPONSOR.secret() }, + () => { + expect(validateFeeSponsorBootConfig().ok).toBe(true); + } + ); + }); +}); + +describe("feeSponsorService — sponsor keypair", () => { + it("parses the secret and never exposes it, only the public key", () => { + expect(getFeeSponsorPublicKey()).toBe(SPONSOR.publicKey()); + const kp = getFeeSponsorKeypair(); + expect(kp.publicKey()).toBe(SPONSOR.publicKey()); + // The status/public surface must never carry the secret. + expect(getFeeSponsorPublicKey()).not.toContain(SPONSOR.secret()); + }); +}); + +describe("feeSponsorService — buildExpectedOperations", () => { + it("derives a single settlement op for a direct row", () => { + const ops = buildExpectedOperations(directRow); + expect(ops).toHaveLength(1); + expect(ops[0].destination).toBe(CREATOR.publicKey()); + expect(ops[0].amountStroops).toBe(toStroops("15")); + }); + + it("derives creator+platform ops (in order) for a fee-split row", () => { + const ops = buildExpectedOperations(splitRow); + expect(ops).toHaveLength(2); + expect(ops[0].destination).toBe(CREATOR.publicKey()); + expect(ops[0].amountStroops).toBe(toStroops("13.5")); + expect(ops[1].destination).toBe(PLATFORM.publicKey()); + expect(ops[1].amountStroops).toBe(toStroops("1.5")); + }); +}); + +describe("feeSponsorService — structural whitelist (adversarial matrix)", () => { + it("accepts a valid, exactly-matching direct payment", () => { + const tx = buildInner([pay(CREATOR.publicKey(), "15")]); + expect(validateInnerTransaction(tx, directRow)).toBe(true); + }); + + it("accepts a valid fee-split payment", () => { + const tx = buildInner([ + pay(CREATOR.publicKey(), "13.5"), + pay(PLATFORM.publicKey(), "1.5"), + ]); + expect(validateInnerTransaction(tx, splitRow)).toBe(true); + }); + + it("rejects a wrong source account", () => { + expectRejected( + buildInner([pay(CREATOR.publicKey(), "15")], { source: OTHER }), + directRow + ); + }); + + it("rejects an extra/foreign changeTrust appended to a valid payment", () => { + expectRejected( + buildInner([ + pay(CREATOR.publicKey(), "15"), + StellarSdk.Operation.changeTrust({ asset: USDC }), + ]), + directRow + ); + }); + + it("rejects a second, unexpected payment appended to a valid payment", () => { + expectRejected( + buildInner([ + pay(CREATOR.publicKey(), "15"), + pay(OTHER.publicKey(), "1"), + ]), + directRow + ); + }); + + it("rejects a setOptions operation", () => { + expectRejected( + buildInner([ + pay(CREATOR.publicKey(), "15"), + StellarSdk.Operation.setOptions({ homeDomain: "evil.example" }), + ]), + directRow + ); + }); + + it("rejects a manageData operation", () => { + expectRejected( + buildInner([ + pay(CREATOR.publicKey(), "15"), + StellarSdk.Operation.manageData({ name: "x", value: "y" }), + ]), + directRow + ); + }); + + it("rejects an accountMerge operation", () => { + expectRejected( + buildInner([ + pay(CREATOR.publicKey(), "15"), + StellarSdk.Operation.accountMerge({ destination: OTHER.publicKey() }), + ]), + directRow + ); + }); + + it("rejects a createAccount operation", () => { + expectRejected( + buildInner([ + pay(CREATOR.publicKey(), "15"), + StellarSdk.Operation.createAccount({ + destination: OTHER.publicKey(), + startingBalance: "1", + }), + ]), + directRow + ); + }); + + it("rejects a pathPaymentStrictReceive operation (allow-list: only plain payment)", () => { + // A lone non-payment op with the right count still fails: the allow-list + // permits only `payment`, so any other type — including one not explicitly + // block-listed — is rejected by construction. + expectRejected( + buildInner([ + StellarSdk.Operation.pathPaymentStrictReceive({ + sendAsset: StellarSdk.Asset.native(), + sendMax: "100", + destination: CREATOR.publicKey(), + destAsset: USDC, + destAmount: "15", + path: [], + }), + ]), + directRow + ); + }); + + it("rejects a wrong asset (native XLM instead of USDC)", () => { + expectRejected( + buildInner([pay(CREATOR.publicKey(), "15", StellarSdk.Asset.native())]), + directRow + ); + }); + + it("rejects a wrong issuer for the correct code", () => { + expectRejected( + buildInner([ + pay(CREATOR.publicKey(), "15", new StellarSdk.Asset("USDC", OTHER.publicKey())), + ]), + directRow + ); + }); + + it("rejects an amount that is too high", () => { + expectRejected(buildInner([pay(CREATOR.publicKey(), "16")]), directRow); + }); + + it("rejects an amount that is too low", () => { + expectRejected(buildInner([pay(CREATOR.publicKey(), "14.9999999")]), directRow); + }); + + it("rejects a wrong destination", () => { + expectRejected(buildInner([pay(OTHER.publicKey(), "15")]), directRow); + }); + + it("rejects a fee-split where the split amounts do not match platformFee", () => { + expectRejected( + buildInner([ + pay(CREATOR.publicKey(), "14"), + pay(PLATFORM.publicKey(), "1"), + ]), + splitRow + ); + }); + + it("rejects a memo mismatch", () => { + expectRejected( + buildInner([pay(CREATOR.publicKey(), "15")], { memo: "WRONG-MEMO" }), + directRow + ); + }); + + it("rejects when 1 op is present but 2 are expected (split)", () => { + expectRejected(buildInner([pay(CREATOR.publicKey(), "13.5")]), splitRow); + }); + + it("rejects when 2 ops are present but 1 is expected (direct)", () => { + expectRejected( + buildInner([ + pay(CREATOR.publicKey(), "13.5"), + pay(PLATFORM.publicKey(), "1.5"), + ]), + directRow + ); + }); + + it("rejects a fee-bump envelope where a plain inner transaction is expected", () => { + const inner = buildInner([pay(CREATOR.publicKey(), "15")]); + const fb = StellarSdk.TransactionBuilder.buildFeeBumpTransaction( + SPONSOR, + "200", + inner, + networkPassphrase + ); + expectRejected(fb, directRow); + }); +}); + +describe("feeSponsorService — fee-bump fee correctness", () => { + it("prices a 1-op inner over (ops + 1) units and clamps to the ceiling", () => { + const inner = buildInner([pay(CREATOR.publicKey(), "15")]); + const config = getFeeSponsorConfig(); + const { baseFeePerOp, totalMaxFeeStroops, units } = computeFeeBumpFee( + inner, + config + ); + expect(units).toBe(2); // 1 inner op + wrapper + expect(totalMaxFeeStroops).toBe(baseFeePerOp * units); + expect(totalMaxFeeStroops).toBeLessThanOrEqual(config.maxFeeStroops); + + const fb = wrapWithFeeBump(inner, { keypair: SPONSOR, baseFeePerOp }); + // The built envelope's actual fee is asserted against the SDK, not trusted. + expect(Number(fb.fee)).toBe(totalMaxFeeStroops); + expect(Number(fb.fee)).toBeLessThanOrEqual(config.maxFeeStroops); + }); + + it("prices a 2-op inner over 3 units", () => { + const inner = buildInner([ + pay(CREATOR.publicKey(), "13.5"), + pay(PLATFORM.publicKey(), "1.5"), + ]); + const config = getFeeSponsorConfig(); + const { totalMaxFeeStroops, units } = computeFeeBumpFee(inner, config); + expect(units).toBe(3); + expect(totalMaxFeeStroops).toBeLessThanOrEqual(config.maxFeeStroops); + const fb = wrapWithFeeBump(inner); + expect(Number(fb.fee)).toBeLessThanOrEqual(config.maxFeeStroops); + }); + + it("refuses when the per-transaction ceiling is too low to fee-bump at all", () => { + const inner = buildInner([pay(CREATOR.publicKey(), "15")]); + // Ceiling below (ops + 1) * MIN_BASE_FEE (=200 for 1 op) cannot build. + expect(() => + computeFeeBumpFee(inner, { maxFeeStroops: 150 }) + ).toThrow(SponsorshipError); + try { + computeFeeBumpFee(inner, { maxFeeStroops: 150 }); + } catch (e) { + expect(e.code).toBe("fee_ceiling_too_low"); + } + }); +}); + +describe("feeSponsorService — fee-bump leaves the inner transaction untouched", () => { + it("keeps inner bytes and the user signature; the sponsor signs only the wrapper", () => { + const inner = buildInner([pay(CREATOR.publicKey(), "15")]); + const innerXdrBefore = inner.toEnvelope().toXDR("base64"); + + const fb = wrapWithFeeBump(inner, { keypair: SPONSOR }); + + // Fee source is the sponsor; user signature on the inner tx is preserved. + expect(fb.feeSource).toBe(SPONSOR.publicKey()); + expect(fb.innerTransaction.signatures).toHaveLength(1); + expect(fb.signatures).toHaveLength(1); + + // Round-trip the envelope; the inner transaction bytes are byte-identical. + const decoded = StellarSdk.TransactionBuilder.fromXDR( + fb.toXDR(), + networkPassphrase + ); + expect(decoded).toBeInstanceOf(StellarSdk.FeeBumpTransaction); + expect(decoded.innerTransaction.toEnvelope().toXDR("base64")).toBe( + innerXdrBefore + ); + // The inner's own signature is the user's, and the sponsor did not sign it. + expect( + decoded.innerTransaction.signatures.map((s) => s.signature().toString("base64")) + ).toEqual(inner.signatures.map((s) => s.signature().toString("base64"))); + }); +}); + +describe("feeSponsorService — spend caps (durable accounting)", () => { + beforeEach(async () => { + await SponsorshipSpend.deleteMany({}); + }); + + const config = { maxFeeStroops: 1000000, dailyCapStroops: 1000000, perUserDailyLimit: 2 }; + + it("allows spend under all caps", async () => { + await expect( + checkSpendCaps({ userId: "user-a", estimatedFeeStroops: 400000, config }) + ).resolves.toBeUndefined(); + }); + + it("refuses when the per-UTC-day total cap would be exceeded", async () => { + await recordSponsorshipSpend({ userId: "user-a", feeStroops: 800000 }); + await expect( + checkSpendCaps({ userId: "user-b", estimatedFeeStroops: 400000, config }) + ).rejects.toMatchObject({ code: "daily_cap_exceeded", httpStatus: 429 }); + }); + + it("refuses when the per-user daily count limit is reached", async () => { + await recordSponsorshipSpend({ userId: "user-a", feeStroops: 10 }); + await recordSponsorshipSpend({ userId: "user-a", feeStroops: 10 }); + await expect( + checkSpendCaps({ userId: "user-a", estimatedFeeStroops: 10, config }) + ).rejects.toMatchObject({ code: "per_user_daily_limit", httpStatus: 429 }); + // A different user with headroom is still allowed. + await expect( + checkSpendCaps({ userId: "user-b", estimatedFeeStroops: 10, config }) + ).resolves.toBeUndefined(); + }); + + it("records spend atomically per UTC day and per user", async () => { + await recordSponsorshipSpend({ userId: "user-a", feeStroops: 123 }); + await recordSponsorshipSpend({ userId: "user-a", feeStroops: 77 }); + await recordSponsorshipSpend({ userId: "user-b", feeStroops: 50 }); + const doc = await SponsorshipSpend.findOne({ day: utcDay() }); + expect(doc.totalStroops).toBe(250); + expect(doc.sponsoredCount).toBe(3); + expect(doc.userCounts.get("user-a")).toBe(2); + expect(doc.userCounts.get("user-b")).toBe(1); + }); +}); + +describe("feeSponsorService — prepareSponsoredSubmission", () => { + const fundedBalance = async () => ({ exists: true, xlmBalance: "100" }); + const emptyBalance = async () => ({ exists: false, xlmBalance: "0" }); + + it("validates, wraps, and returns both hashes for a valid submit", async () => { + const inner = buildInner([pay(CREATOR.publicKey(), "15")]); + const result = await prepareSponsoredSubmission({ + signedXdr: inner.toXDR(), + transactionRow: directRow, + userId: "user-x", + loadBalance: fundedBalance, + }); + expect(result.innerHash).toBe(inner.hash().toString("hex")); + expect(result.outerHash).not.toBe(result.innerHash); + expect(result.maxFeeStroops).toBeLessThanOrEqual( + getFeeSponsorConfig().maxFeeStroops + ); + // The returned envelope decodes to a fee-bump wrapping the exact inner tx. + const decoded = StellarSdk.TransactionBuilder.fromXDR( + result.feeBumpXdr, + networkPassphrase + ); + expect(decoded.feeSource).toBe(SPONSOR.publicKey()); + expect(decoded.innerTransaction.hash().toString("hex")).toBe(result.innerHash); + }); + + it("propagates a whitelist rejection", async () => { + const inner = buildInner([pay(OTHER.publicKey(), "15")]); + await expect( + prepareSponsoredSubmission({ + signedXdr: inner.toXDR(), + transactionRow: directRow, + userId: "user-x", + loadBalance: fundedBalance, + }) + ).rejects.toMatchObject({ code: "whitelist_rejected" }); + }); + + it("refuses when the sponsor float is underfunded", async () => { + const inner = buildInner([pay(CREATOR.publicKey(), "15")]); + await expect( + prepareSponsoredSubmission({ + signedXdr: inner.toXDR(), + transactionRow: directRow, + userId: "user-x", + loadBalance: emptyBalance, + }) + ).rejects.toMatchObject({ code: "sponsor_underfunded", httpStatus: 503 }); + }); +}); diff --git a/test/feeSponsorSubmit.test.js b/test/feeSponsorSubmit.test.js new file mode 100644 index 00000000..fdacb0c1 --- /dev/null +++ b/test/feeSponsorSubmit.test.js @@ -0,0 +1,380 @@ +// Fee-bump sponsorship (#30) — controller wiring for the payment AND donation +// submit paths. Proves: +// - flag-off is byte-for-byte the original unsponsored path (regression guard) +// for BOTH payment and donation, even when requestSponsorship:true is sent; +// - flag-on sponsors the submit (fee-bump XDR, inner-hash verification, +// sponsored fields, spend recorded); +// - sponsorship-specific rejections (cap/whitelist) return a distinct 4xx and +// never mark the row `failed`. +// +// stellarService is mocked (no network); feeSponsorService is mocked so the +// controller integration is tested in isolation from the service internals, +// which are covered end-to-end in feeSponsorService.test.js. +import { jest } from "@jest/globals"; +import express from "express"; +import request from "supertest"; +import mongoose from "mongoose"; + +const submitTransaction = jest.fn(); +const verifyPaymentOperations = jest.fn(); +const validateSignedPaymentXdr = jest.fn(); +const getExplorerUrl = jest.fn((hash) => `https://stellar.expert/tx/${hash}`); +const recordSaleEarnings = jest.fn(); +const grantItemAccess = jest.fn(); +const enqueue = jest.fn(); + +// Sponsorship service mock — controllable per test. +const isFeeSponsorEnabled = jest.fn(); +const prepareSponsoredSubmission = jest.fn(); +const recordSponsorshipSpend = jest.fn(); +const getSponsorshipStatus = jest.fn(); +class SponsorshipError extends Error { + constructor(code, message, { httpStatus = 422 } = {}) { + super(message); + this.name = "SponsorshipError"; + this.code = code; + this.httpStatus = httpStatus; + this.retryUnsponsored = true; + } +} + +jest.unstable_mockModule("../src/services/stellar/stellarService.js", () => ({ + STROOPS_PER_UNIT: 10000000n, + toStroops: jest.fn(), + fromStroops: jest.fn(), + resolveAsset: jest.fn(), + applySlippage: jest.fn(), + findPaymentPaths: jest.fn(), + buildPathPaymentTransaction: jest.fn(), + calculateFeeSplit: jest.fn(() => null), + buildSep7Uri: jest.fn(), + isValidPublicKey: jest.fn(() => true), + getAccountBalance: jest.fn(), + MEMO_REQUIRED_DATA_KEY: "config.memo_required", + isMemoRequired: jest.fn(), + PREFLIGHT_REASON_CODES: {}, + preflightPayment: jest.fn(), + buildPaymentTransaction: jest.fn(), + buildReversePaymentTransaction: jest.fn(), + submitTransaction, + verifyTransaction: jest.fn(), + verifyPaymentOperations, + validateSignedPaymentXdr, + hasUsdcTrustline: jest.fn(), + getExplorerUrl, + getAccountExplorerUrl: jest.fn(), + server: {}, + USDC: "USDC", + USDC_ISSUER: "", + NETWORK: "testnet", + networkPassphrase: "Test SDF Network ; September 2015", + DONATION_WALLET_PUBLIC_KEY: "GDONATION", + PLATFORM_FEE_PERCENT: 0, + PLATFORM_WALLET_PUBLIC_KEY: "", +})); + +jest.unstable_mockModule("../src/services/stellar/feeSponsorService.js", () => ({ + isFeeSponsorEnabled, + prepareSponsoredSubmission, + recordSponsorshipSpend, + getSponsorshipStatus, + SponsorshipError, +})); + +jest.unstable_mockModule("../src/services/payoutService.js", () => ({ + recordSaleEarnings, +})); +jest.unstable_mockModule("../src/services/stellar/reconciliationService.js", () => ({ + grantItemAccess, +})); +jest.unstable_mockModule("../src/jobs/queue.js", () => ({ enqueue })); + +const { submitPayment } = await import( + "../src/controllers/stellar/paymentController.js" +); +const { submitDonation } = await import( + "../src/controllers/stellar/donationController.js" +); +const Transaction = (await import("../src/models/Transaction.js")).default; + +const makeQuery = (result) => { + const query = { + session: jest.fn(() => Promise.resolve(result)), + populate: jest.fn(() => query), + then: (resolve, reject) => Promise.resolve(result).then(resolve, reject), + }; + return query; +}; + +const makeSession = () => ({ + startTransaction: jest.fn(), + commitTransaction: jest.fn(() => Promise.resolve()), + abortTransaction: jest.fn(() => Promise.resolve()), + endSession: jest.fn(), +}); + +const buyerWallet = "GBUYER"; +const creatorWallet = "GCREATOR"; + +const mountApp = (userId, handler, path = "/submit") => { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.user = { _id: userId }; + next(); + }); + app.post(path, handler); + return app; +}; + +let buyerId; +let session; + +beforeEach(() => { + jest.clearAllMocks(); + delete process.env.FEE_SPONSOR_ENABLED; + buyerId = new mongoose.Types.ObjectId(); + session = makeSession(); + jest.spyOn(mongoose, "startSession").mockResolvedValue(session); + verifyPaymentOperations.mockResolvedValue({ verified: true }); + recordSaleEarnings.mockResolvedValue({ success: true }); + grantItemAccess.mockResolvedValue(undefined); + enqueue.mockResolvedValue(undefined); +}); + +afterEach(() => jest.restoreAllMocks()); + +// ── PAYMENT ────────────────────────────────────────────────────────────────── + +describe("submitPayment — fee sponsorship", () => { + const makePurchaseTx = () => ({ + _id: new mongoose.Types.ObjectId(), + buyer: buyerId, + creator: new mongoose.Types.ObjectId(), + buyerWallet, + creatorWallet, + itemType: "book", + itemId: new mongoose.Types.ObjectId(), + itemTitle: "Book", + amount: "15", + currency: "USDC", + memo: "DNB-BOOK-abcd1234", + settlement: "direct", + status: "pending", + save: jest.fn(function () { + return Promise.resolve(this); + }), + }); + + it("REGRESSION: flag off passes the raw XDR straight through; never sponsors", async () => { + isFeeSponsorEnabled.mockReturnValue(false); + const tx = makePurchaseTx(); + jest.spyOn(Transaction, "findOne").mockReturnValue(makeQuery(tx)); + submitTransaction.mockResolvedValue({ hash: "H_RAW", ledger: 10, successful: true }); + + const res = await request(mountApp(buyerId, submitPayment)) + .post("/submit") + .send({ transactionId: tx._id.toString(), signedXdr: "RAW_XDR", requestSponsorship: true }); + + expect(res.statusCode).toBe(200); + // Same XDR submitted as-is; no fee-bump path taken. + expect(prepareSponsoredSubmission).not.toHaveBeenCalled(); + expect(submitTransaction).toHaveBeenCalledWith("RAW_XDR"); + // Base validation ran (unchanged behaviour). + expect(validateSignedPaymentXdr).toHaveBeenCalledTimes(1); + // Verification runs against the submitted hash, not an inner hash. + expect(verifyPaymentOperations).toHaveBeenCalledWith( + "H_RAW", + expect.any(Array), + "USDC" + ); + expect(tx.status).toBe("confirmed"); + expect(tx.sponsored).toBeFalsy(); + expect(res.body.transaction.sponsored).toBeUndefined(); + expect(recordSponsorshipSpend).not.toHaveBeenCalled(); + }); + + it("flag on: wraps as a fee-bump, verifies the inner hash, records spend, marks sponsored", async () => { + isFeeSponsorEnabled.mockReturnValue(true); + prepareSponsoredSubmission.mockResolvedValue({ + innerHash: "H_INNER", + outerHash: "H_OUTER", + feeBumpXdr: "FEEBUMP_XDR", + maxFeeStroops: 1000000, + }); + const tx = makePurchaseTx(); + jest.spyOn(Transaction, "findOne").mockReturnValue(makeQuery(tx)); + submitTransaction.mockResolvedValue({ + hash: "H_OUTER", + ledger: 20, + successful: true, + feeCharged: "300", + }); + + const res = await request(mountApp(buyerId, submitPayment)) + .post("/submit") + .send({ transactionId: tx._id.toString(), signedXdr: "RAW_XDR", requestSponsorship: true }); + + expect(res.statusCode).toBe(200); + expect(prepareSponsoredSubmission).toHaveBeenCalledTimes(1); + // The fee-bump envelope is submitted, not the raw inner XDR. + expect(submitTransaction).toHaveBeenCalledWith("FEEBUMP_XDR"); + // Base validation is NOT re-run (structural whitelist supersedes it). + expect(validateSignedPaymentXdr).not.toHaveBeenCalled(); + // On-chain verification runs against the INNER hash. + expect(verifyPaymentOperations).toHaveBeenCalledWith( + "H_INNER", + expect.any(Array), + "USDC" + ); + expect(recordSponsorshipSpend).toHaveBeenCalledWith( + expect.objectContaining({ feeStroops: 300 }) + ); + expect(tx.sponsored).toBe(true); + expect(tx.feeBumpTxHash).toBe("H_OUTER"); + expect(tx.sponsorFeeCharged).toBe("300"); + expect(tx.stellarTxHash).toBe("H_INNER"); + expect(res.body.transaction).toMatchObject({ + sponsored: true, + feeBumpTxHash: "H_OUTER", + hash: "H_INNER", + }); + }); + + it("cap rejection returns a distinct 4xx and does NOT mark the row failed", async () => { + isFeeSponsorEnabled.mockReturnValue(true); + prepareSponsoredSubmission.mockRejectedValue( + new SponsorshipError("daily_cap_exceeded", "cap", { httpStatus: 429 }) + ); + const tx = makePurchaseTx(); + jest.spyOn(Transaction, "findOne").mockReturnValue(makeQuery(tx)); + + const res = await request(mountApp(buyerId, submitPayment)) + .post("/submit") + .send({ transactionId: tx._id.toString(), signedXdr: "RAW_XDR", requestSponsorship: true }); + + expect(res.statusCode).toBe(429); + expect(res.body).toMatchObject({ + success: false, + retryUnsponsored: true, + sponsorship: { approved: false, reason: "daily_cap_exceeded" }, + }); + // Row is untouched: not failed, not submitted, no on-network submit. + expect(tx.status).toBe("pending"); + expect(tx.save).not.toHaveBeenCalled(); + expect(submitTransaction).not.toHaveBeenCalled(); + expect(session.abortTransaction).toHaveBeenCalledTimes(1); + expect(session.commitTransaction).not.toHaveBeenCalled(); + }); + + it("whitelist rejection returns 422 and does NOT mark the row failed", async () => { + isFeeSponsorEnabled.mockReturnValue(true); + prepareSponsoredSubmission.mockRejectedValue( + new SponsorshipError("whitelist_rejected", "bad", { httpStatus: 422 }) + ); + const tx = makePurchaseTx(); + jest.spyOn(Transaction, "findOne").mockReturnValue(makeQuery(tx)); + + const res = await request(mountApp(buyerId, submitPayment)) + .post("/submit") + .send({ transactionId: tx._id.toString(), signedXdr: "RAW_XDR", requestSponsorship: true }); + + expect(res.statusCode).toBe(422); + expect(tx.status).toBe("pending"); + expect(tx.save).not.toHaveBeenCalled(); + expect(submitTransaction).not.toHaveBeenCalled(); + }); +}); + +// ── DONATION ───────────────────────────────────────────────────────────────── + +describe("submitDonation — fee sponsorship", () => { + const makeDonationTx = () => ({ + _id: new mongoose.Types.ObjectId(), + buyer: buyerId, + buyerWallet, + creatorWallet: "GDONATION", + type: "donation", + amount: "5", + currency: "USDC", + memo: "DNB-SADAQAH", + status: "pending", + save: jest.fn(function () { + return Promise.resolve(this); + }), + }); + + it("REGRESSION: flag off passes the raw XDR straight through; never sponsors", async () => { + isFeeSponsorEnabled.mockReturnValue(false); + const tx = makeDonationTx(); + jest.spyOn(Transaction, "findOne").mockReturnValue(makeQuery(tx)); + submitTransaction.mockResolvedValue({ hash: "H_RAW", ledger: 10, successful: true }); + + const res = await request(mountApp(buyerId, submitDonation)) + .post("/submit") + .send({ donationId: tx._id.toString(), signedXdr: "RAW_XDR", requestSponsorship: true }); + + expect(res.statusCode).toBe(200); + expect(prepareSponsoredSubmission).not.toHaveBeenCalled(); + expect(submitTransaction).toHaveBeenCalledWith("RAW_XDR"); + expect(validateSignedPaymentXdr).toHaveBeenCalledTimes(1); + expect(verifyPaymentOperations).toHaveBeenCalledWith("H_RAW", expect.any(Array)); + expect(tx.status).toBe("confirmed"); + expect(tx.sponsored).toBeFalsy(); + expect(res.body.sponsored).toBeUndefined(); + expect(recordSponsorshipSpend).not.toHaveBeenCalled(); + }); + + it("flag on: wraps as a fee-bump, verifies the inner hash, records spend", async () => { + isFeeSponsorEnabled.mockReturnValue(true); + prepareSponsoredSubmission.mockResolvedValue({ + innerHash: "H_INNER", + outerHash: "H_OUTER", + feeBumpXdr: "FEEBUMP_XDR", + maxFeeStroops: 1000000, + }); + const tx = makeDonationTx(); + jest.spyOn(Transaction, "findOne").mockReturnValue(makeQuery(tx)); + submitTransaction.mockResolvedValue({ + hash: "H_OUTER", + ledger: 20, + successful: true, + feeCharged: "200", + }); + + const res = await request(mountApp(buyerId, submitDonation)) + .post("/submit") + .send({ donationId: tx._id.toString(), signedXdr: "RAW_XDR", requestSponsorship: true }); + + expect(res.statusCode).toBe(200); + expect(submitTransaction).toHaveBeenCalledWith("FEEBUMP_XDR"); + expect(validateSignedPaymentXdr).not.toHaveBeenCalled(); + expect(verifyPaymentOperations).toHaveBeenCalledWith("H_INNER", expect.any(Array)); + expect(recordSponsorshipSpend).toHaveBeenCalledWith( + expect.objectContaining({ feeStroops: 200 }) + ); + expect(tx.sponsored).toBe(true); + expect(tx.feeBumpTxHash).toBe("H_OUTER"); + expect(res.body).toMatchObject({ sponsored: true, feeBumpTxHash: "H_OUTER", txHash: "H_INNER" }); + }); + + it("cap rejection returns a distinct 4xx and does NOT mark the donation failed", async () => { + isFeeSponsorEnabled.mockReturnValue(true); + prepareSponsoredSubmission.mockRejectedValue( + new SponsorshipError("daily_cap_exceeded", "cap", { httpStatus: 429 }) + ); + const tx = makeDonationTx(); + jest.spyOn(Transaction, "findOne").mockReturnValue(makeQuery(tx)); + + const res = await request(mountApp(buyerId, submitDonation)) + .post("/submit") + .send({ donationId: tx._id.toString(), signedXdr: "RAW_XDR", requestSponsorship: true }); + + expect(res.statusCode).toBe(429); + expect(res.body.retryUnsponsored).toBe(true); + expect(tx.status).toBe("pending"); + expect(tx.save).not.toHaveBeenCalled(); + expect(submitTransaction).not.toHaveBeenCalled(); + expect(session.abortTransaction).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/giftClaimableBalances.test.js b/test/giftClaimableBalances.test.js new file mode 100644 index 00000000..27d12d8a --- /dev/null +++ b/test/giftClaimableBalances.test.js @@ -0,0 +1,562 @@ +import { jest } from "@jest/globals"; +import express from "express"; +import request from "supertest"; +import mongoose from "mongoose"; + +const TESTNET_PASSPHRASE = "Test SDF Network ; September 2015"; + +// ── Mocks ─────────────────────────────────────────────────────────────────── +const submitTransaction = jest.fn(); +const verifyTransaction = jest.fn(); +const getExplorerUrl = jest.fn((hash) => `https://stellar.expert/tx/${hash}`); +const buildCreateClaimableBalanceTx = jest.fn(); +const buildClaimTx = jest.fn(); +const resolveBalanceId = jest.fn(); +const getClaimableBalance = jest.fn(); +const validateSignedGiftXdr = jest.fn(); +const giftExpiryFromNow = jest.fn(() => new Date(Date.now() + 30 * 24 * 3600 * 1000)); +const grantItemAccess = jest.fn(); +const recordSaleEarnings = jest.fn(); +const enqueue = jest.fn(); + +jest.unstable_mockModule("../src/services/stellar/stellarService.js", () => ({ + STROOPS_PER_UNIT: 10000000n, + toStroops: jest.fn(), + fromStroops: jest.fn(), + resolveAsset: jest.fn(), + applySlippage: jest.fn(), + findPaymentPaths: jest.fn(), + buildPathPaymentTransaction: jest.fn(), + calculateFeeSplit: jest.fn(), + buildSep7Uri: jest.fn(), + isValidPublicKey: jest.fn(), + getAccountBalance: jest.fn(), + MEMO_REQUIRED_DATA_KEY: "config.memo_required", + isMemoRequired: jest.fn(), + PREFLIGHT_REASON_CODES: {}, + preflightPayment: jest.fn(), + buildPaymentTransaction: jest.fn(), + buildReversePaymentTransaction: jest.fn(), + submitTransaction, + verifyTransaction, + verifyPaymentOperations: jest.fn(), + validateSignedPaymentXdr: jest.fn(), + hasUsdcTrustline: jest.fn(), + getExplorerUrl, + getAccountExplorerUrl: jest.fn(), + server: {}, + USDC: "USDC", + USDC_ISSUER: "", + NETWORK: "testnet", + networkPassphrase: TESTNET_PASSPHRASE, + DONATION_WALLET_PUBLIC_KEY: "", + PLATFORM_FEE_PERCENT: 0, + PLATFORM_WALLET_PUBLIC_KEY: "", +})); + +jest.unstable_mockModule("../src/services/stellar/claimableBalanceService.js", () => ({ + buildCreateClaimableBalanceTx, + buildClaimTx, + resolveBalanceId, + getClaimableBalance, + validateSignedGiftXdr, + giftExpiryFromNow, +})); + +jest.unstable_mockModule("../src/services/stellar/reconciliationService.js", () => ({ + grantItemAccess, +})); + +jest.unstable_mockModule("../src/services/payoutService.js", () => ({ + recordSaleEarnings, +})); + +jest.unstable_mockModule("../src/jobs/queue.js", () => ({ + enqueue, +})); + +const { + initializeGift, + submitGift, + listGifts, + getGift, + claimInitialize, + claimSubmit, +} = await import("../src/controllers/stellar/giftController.js"); +const { initializePayment } = await import( + "../src/controllers/stellar/paymentController.js" +); +const User = (await import("../src/models/User.js")).default; +const Book = (await import("../src/models/Book.js")).default; +const Course = (await import("../src/models/Course.js")).default; +const GiftClaim = (await import("../src/models/GiftClaim.js")).default; +const Transaction = (await import("../src/models/Transaction.js")).default; + +const makeQuery = (result) => { + const query = { + session: jest.fn(() => Promise.resolve(result)), + populate: jest.fn(() => query), + select: jest.fn(() => query), + sort: jest.fn(() => query), + skip: jest.fn(() => query), + limit: jest.fn(() => query), + then: (resolve, reject) => Promise.resolve(result).then(resolve, reject), + }; + return query; +}; + +const mountGiftApp = (userId) => { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.user = { _id: userId }; + next(); + }); + app.post("/initialize", initializeGift); + app.post("/submit", submitGift); + app.get("/", listGifts); + app.get("/:id", getGift); + app.post("/:id/claim/initialize", claimInitialize); + app.post("/:id/claim/submit", claimSubmit); + return app; +}; + +const senderWallet = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; +const recipientWallet = "GCKFBEIYTKPXL5UIRZ5OO3KSOFDP5D4R6YGNFWEQSIFGKWO3EZ5F3TGI"; +const BALANCE_ID = "00000000" + "ab".repeat(32); + +describe("Gift controller (claimable balances)", () => { + let senderId; + let recipientId; + let creatorId; + let itemId; + let savedGifts; + + beforeEach(() => { + jest.restoreAllMocks(); + submitTransaction.mockReset(); + verifyTransaction.mockReset(); + getExplorerUrl.mockClear(); + buildCreateClaimableBalanceTx.mockReset(); + buildClaimTx.mockReset(); + resolveBalanceId.mockReset(); + getClaimableBalance.mockReset(); + validateSignedGiftXdr.mockReset(); + giftExpiryFromNow.mockClear(); + grantItemAccess.mockReset().mockResolvedValue(undefined); + recordSaleEarnings.mockReset(); + enqueue.mockReset().mockResolvedValue(undefined); + + senderId = new mongoose.Types.ObjectId(); + recipientId = new mongoose.Types.ObjectId(); + creatorId = new mongoose.Types.ObjectId(); + itemId = new mongoose.Types.ObjectId(); + savedGifts = []; + + jest.spyOn(GiftClaim.prototype, "save").mockImplementation(function () { + savedGifts.push(this); + return Promise.resolve(this); + }); + jest.spyOn(GiftClaim, "updateMany").mockResolvedValue({ modifiedCount: 0 }); + jest.spyOn(Transaction.prototype, "save").mockImplementation(function () { + return Promise.resolve(this); + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("initializes a gift and persists a pending_signature GiftClaim", async () => { + const sender = { _id: senderId, stellarWallet: { publicKey: senderWallet } }; + const recipient = { _id: recipientId, name: "Recipient", stellarWallet: { publicKey: recipientWallet }, purchasedBooks: [] }; + const creator = { _id: creatorId, name: "Educator", stellarWallet: { publicKey: recipientWallet } }; + const book = { _id: itemId, title: "Paid Book", price: 15, author: creator }; + + jest.spyOn(User, "findById").mockImplementation((id) => { + if (String(id) === senderId.toString()) return makeQuery(sender); + if (String(id) === recipientId.toString()) return makeQuery(recipient); + return makeQuery(null); + }); + jest.spyOn(Book, "findById").mockReturnValue(makeQuery(book)); + jest.spyOn(GiftClaim, "findOne").mockReturnValue(makeQuery(null)); + const expiresAt = new Date(Date.now() + 30 * 24 * 3600 * 1000); + giftExpiryFromNow.mockReturnValue(expiresAt); + buildCreateClaimableBalanceTx.mockResolvedValue({ + xdr: "unsigned-gift-xdr", + hash: "expected-gift-hash", + networkPassphrase: TESTNET_PASSPHRASE, + expiresAt, + }); + + const res = await request(mountGiftApp(senderId)) + .post("/initialize") + .send({ itemType: "book", itemId: itemId.toString(), recipientUserId: recipientId.toString() }); + + expect(res.statusCode).toBe(200); + expect(res.body).toMatchObject({ + success: true, + payment: { xdr: "unsigned-gift-xdr", expectedHash: "expected-gift-hash" }, + }); + expect(savedGifts).toHaveLength(1); + expect(savedGifts[0]).toMatchObject({ + sender: senderId, + recipient: recipientId, + recipientWallet, + creator: creatorId, + itemType: "book", + itemId, + amount: "15", + status: "pending_signature", + createTxHash: "expected-gift-hash", + }); + expect(savedGifts[0].balanceId).toBeUndefined(); + }); + + it.each([ + { + name: "recipient with no wallet", + recipient: () => ({ _id: recipientId, name: "Recipient", purchasedBooks: [] }), + expected: "Recipient has not connected their Stellar wallet yet", + }, + { + name: "self-gift", + selfGift: true, + expected: "You cannot gift an item to yourself", + }, + ])("rejects $name", async ({ recipient, expected, selfGift }) => { + const sender = { _id: senderId, stellarWallet: { publicKey: senderWallet } }; + const creator = { _id: creatorId, name: "Educator", stellarWallet: { publicKey: recipientWallet } }; + const book = { _id: itemId, title: "Paid Book", price: 15, author: creator }; + + jest.spyOn(User, "findById").mockImplementation((id) => { + if (String(id) === senderId.toString()) return makeQuery(sender); + if (String(id) === recipientId.toString()) return makeQuery(recipient()); + return makeQuery(null); + }); + jest.spyOn(Book, "findById").mockReturnValue(makeQuery(book)); + + const res = await request(mountGiftApp(senderId)) + .post("/initialize") + .send({ + itemType: "book", + itemId: itemId.toString(), + recipientUserId: selfGift ? senderId.toString() : recipientId.toString(), + }); + + expect(res.statusCode).toBe(400); + expect(res.body.message).toBe(expected); + expect(savedGifts).toHaveLength(0); + }); + + it("rejects gifting an item the recipient already owns", async () => { + const sender = { _id: senderId, stellarWallet: { publicKey: senderWallet } }; + const recipient = { + _id: recipientId, + name: "Recipient", + stellarWallet: { publicKey: recipientWallet }, + purchasedBooks: [{ bookId: itemId }], + }; + const creator = { _id: creatorId, name: "Educator", stellarWallet: { publicKey: recipientWallet } }; + const book = { _id: itemId, title: "Paid Book", price: 15, author: creator }; + + jest.spyOn(User, "findById").mockImplementation((id) => { + if (String(id) === senderId.toString()) return makeQuery(sender); + if (String(id) === recipientId.toString()) return makeQuery(recipient); + return makeQuery(null); + }); + jest.spyOn(Book, "findById").mockReturnValue(makeQuery(book)); + + const res = await request(mountGiftApp(senderId)) + .post("/initialize") + .send({ + itemType: "book", + itemId: itemId.toString(), + recipientUserId: recipientId.toString(), + }); + + expect(res.statusCode).toBe(400); + expect(res.body.message).toBe("Recipient already owns this book"); + expect(savedGifts).toHaveLength(0); + }); + + it("rejects a duplicate pending gift for the same recipient+item", async () => { + const sender = { _id: senderId, stellarWallet: { publicKey: senderWallet } }; + const recipient = { _id: recipientId, name: "Recipient", stellarWallet: { publicKey: recipientWallet }, purchasedBooks: [] }; + const creator = { _id: creatorId, name: "Educator", stellarWallet: { publicKey: recipientWallet } }; + const book = { _id: itemId, title: "Paid Book", price: 15, author: creator }; + const existing = { _id: new mongoose.Types.ObjectId() }; + + jest.spyOn(User, "findById").mockImplementation((id) => { + if (String(id) === senderId.toString()) return makeQuery(sender); + if (String(id) === recipientId.toString()) return makeQuery(recipient); + return makeQuery(null); + }); + jest.spyOn(Book, "findById").mockReturnValue(makeQuery(book)); + jest.spyOn(GiftClaim, "findOne").mockReturnValue(makeQuery(existing)); + + const res = await request(mountGiftApp(senderId)) + .post("/initialize") + .send({ itemType: "book", itemId: itemId.toString(), recipientUserId: recipientId.toString() }); + + expect(res.statusCode).toBe(400); + expect(res.body.message).toContain("pending gift"); + expect(res.body.giftId).toBe(existing._id.toString()); + }); + + it("submits a gift, stores the REAL balance id (≠ tx hash), and sets status open", async () => { + const gift = { + _id: new mongoose.Types.ObjectId(), + sender: senderId, + recipient: recipientId, + recipientWallet, + itemType: "book", + itemId, + amount: "15", + assetCode: "USDC", + status: "pending_signature", + expiresAt: new Date(Date.now() + 30 * 24 * 3600 * 1000), + save: jest.fn(function () { return Promise.resolve(this); }), + }; + const sender = { _id: senderId, stellarWallet: { publicKey: senderWallet } }; + + jest.spyOn(User, "findById").mockReturnValue(makeQuery(sender)); + jest.spyOn(GiftClaim, "findOne").mockReturnValue(makeQuery(gift)); + submitTransaction.mockResolvedValue({ hash: "create-tx-hash", ledger: 5, successful: true }); + resolveBalanceId.mockResolvedValue(BALANCE_ID); + + const res = await request(mountGiftApp(senderId)) + .post("/submit") + .send({ giftId: gift._id.toString(), signedXdr: "signed-xdr" }); + + expect(res.statusCode).toBe(200); + expect(res.body).toMatchObject({ success: true, balanceId: BALANCE_ID, createTxHash: "create-tx-hash" }); + expect(gift.status).toBe("open"); + expect(gift.balanceId).toBe(BALANCE_ID); + expect(gift.balanceId).not.toBe("create-tx-hash"); + expect(validateSignedGiftXdr).toHaveBeenCalled(); + }); + + it("rejects a tampered signed XDR before any DB write", async () => { + const gift = { + _id: new mongoose.Types.ObjectId(), + sender: senderId, + recipient: recipientId, + recipientWallet, + itemType: "book", + itemId, + amount: "15", + assetCode: "USDC", + status: "pending_signature", + expiresAt: new Date(Date.now() + 30 * 24 * 3600 * 1000), + save: jest.fn(), + }; + const sender = { _id: senderId, stellarWallet: { publicKey: senderWallet } }; + + jest.spyOn(User, "findById").mockReturnValue(makeQuery(sender)); + jest.spyOn(GiftClaim, "findOne").mockReturnValue(makeQuery(gift)); + validateSignedGiftXdr.mockImplementation(() => { + throw new Error("Signed XDR missing the recipient claimant with before_absolute_time(expiresAt)"); + }); + + const res = await request(mountGiftApp(senderId)) + .post("/submit") + .send({ giftId: gift._id.toString(), signedXdr: "tampered-xdr" }); + + expect(res.statusCode).toBe(400); + expect(res.body.message).toBe("Signed transaction does not match expected gift details"); + expect(submitTransaction).not.toHaveBeenCalled(); + expect(gift.status).toBe("pending_signature"); + }); + + it("builds a claim XDR for the recipient before expiry (trustline-free)", async () => { + const gift = { + _id: new mongoose.Types.ObjectId(), + sender: senderId, + recipient: recipientId, + recipientWallet, + balanceId: BALANCE_ID, + itemType: "book", + itemId, + status: "open", + expiresAt: new Date(Date.now() + 30 * 24 * 3600 * 1000), + }; + const user = { _id: recipientId, stellarWallet: { publicKey: recipientWallet } }; + + jest.spyOn(GiftClaim, "findOne").mockReturnValue(makeQuery(gift)); + jest.spyOn(User, "findById").mockReturnValue(makeQuery(user)); + buildClaimTx.mockResolvedValue({ + xdr: "claim-xdr", + hash: "claim-hash", + networkPassphrase: TESTNET_PASSPHRASE, + includesChangeTrust: true, + }); + + const res = await request(mountGiftApp(recipientId)) + .post(`/${gift._id.toString()}/claim/initialize`) + .send({}); + + expect(res.statusCode).toBe(200); + expect(res.body).toMatchObject({ + success: true, + action: "claim", + claim: { xdr: "claim-xdr", includesChangeTrust: true }, + }); + }); + + it.each([ + { name: "sender before expiry", userId: () => "SENDER", expected: 403 }, + { name: "recipient after expiry", userId: () => "RECIPIENT", expiresPast: true, expected: 403 }, + { name: "stranger", userId: () => "STRANGER", expected: 403 }, + ])("authorizes claim: $name", async ({ userId, expected, expiresPast }) => { + const ids = { SENDER: senderId, RECIPIENT: recipientId, STRANGER: new mongoose.Types.ObjectId() }; + const actualId = ids[userId()]; + const gift = { + _id: new mongoose.Types.ObjectId(), + sender: senderId, + recipient: recipientId, + recipientWallet, + balanceId: BALANCE_ID, + itemType: "book", + itemId, + status: "open", + expiresAt: new Date(Date.now() + (expiresPast ? -1 : 1) * 3600 * 1000), + save: jest.fn(function () { return Promise.resolve(this); }), + }; + const user = { _id: actualId, stellarWallet: { publicKey: senderWallet } }; + + jest.spyOn(GiftClaim, "findOne").mockReturnValue(makeQuery(gift)); + jest.spyOn(User, "findById").mockReturnValue(makeQuery(user)); + + const res = await request(mountGiftApp(actualId)) + .post(`/${gift._id.toString()}/claim/initialize`) + .send({}); + + expect(res.statusCode).toBe(expected); + expect(buildClaimTx).not.toHaveBeenCalled(); + }); + + it("grants access to the RECIPIENT (never the sender) on a successful claim", async () => { + const gift = { + _id: new mongoose.Types.ObjectId(), + sender: senderId, + recipient: recipientId, + recipientWallet, + balanceId: BALANCE_ID, + itemType: "course", + itemId, + status: "open", + expiresAt: new Date(Date.now() + 30 * 24 * 3600 * 1000), + save: jest.fn(function () { return Promise.resolve(this); }), + }; + const user = { _id: recipientId, stellarWallet: { publicKey: recipientWallet } }; + + jest.spyOn(GiftClaim, "findOne").mockReturnValue(makeQuery(gift)); + jest.spyOn(User, "findById").mockReturnValue(makeQuery(user)); + submitTransaction.mockResolvedValue({ hash: "claim-tx-hash", ledger: 9, successful: true }); + verifyTransaction.mockResolvedValue({ + exists: true, + successful: true, + operations: [{ type: "claim_claimable_balance", balance_id: BALANCE_ID }], + }); + + const res = await request(mountGiftApp(recipientId)) + .post(`/${gift._id.toString()}/claim/submit`) + .send({ signedXdr: "signed-claim-xdr" }); + + expect(res.statusCode).toBe(200); + expect(res.body.action).toBe("claimed"); + expect(gift.status).toBe("claimed"); + expect(gift.claimTxHash).toBe("claim-tx-hash"); + // Access lands on the RECIPIENT, not the sender. + expect(grantItemAccess).toHaveBeenCalledWith({ + buyerId: recipientId, + itemType: "course", + itemId, + }); + expect(grantItemAccess.mock.calls[0][0].buyerId).not.toBe(senderId); + }); + + it("lets the sender reclaim after expiry without granting access", async () => { + const gift = { + _id: new mongoose.Types.ObjectId(), + sender: senderId, + recipient: recipientId, + recipientWallet, + balanceId: BALANCE_ID, + itemType: "book", + itemId, + status: "expired", + expiresAt: new Date(Date.now() - 3600 * 1000), + save: jest.fn(function () { return Promise.resolve(this); }), + }; + const user = { _id: senderId, stellarWallet: { publicKey: senderWallet } }; + + jest.spyOn(GiftClaim, "findOne").mockReturnValue(makeQuery(gift)); + jest.spyOn(User, "findById").mockReturnValue(makeQuery(user)); + submitTransaction.mockResolvedValue({ hash: "reclaim-tx-hash", ledger: 10, successful: true }); + verifyTransaction.mockResolvedValue({ + exists: true, + successful: true, + operations: [{ type: "claim_claimable_balance", balance_id: BALANCE_ID }], + }); + + const res = await request(mountGiftApp(senderId)) + .post(`/${gift._id.toString()}/claim/submit`) + .send({ signedXdr: "signed-reclaim-xdr" }); + + expect(res.statusCode).toBe(200); + expect(res.body.action).toBe("reclaimed"); + expect(gift.status).toBe("reclaimed"); + expect(grantItemAccess).not.toHaveBeenCalled(); + }); +}); + +describe("purchase-flow fallback to claimable balance", () => { + const buyerId = new mongoose.Types.ObjectId(); + const itemId = new mongoose.Types.ObjectId(); + const buyerWallet = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; + + const mountPaymentApp = () => { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.user = { _id: buyerId }; + next(); + }); + app.post("/initialize", initializePayment); + return app; + }; + + const makeSession = () => ({ + startTransaction: jest.fn(), + commitTransaction: jest.fn(() => Promise.resolve()), + abortTransaction: jest.fn(() => Promise.resolve()), + endSession: jest.fn(), + }); + + beforeEach(() => { + jest.restoreAllMocks(); + jest.spyOn(mongoose, "startSession").mockResolvedValue(makeSession()); + }); + + it("returns { fallback: 'claimable_balance' } when the creator has no wallet", async () => { + const buyer = { _id: buyerId, stellarWallet: { publicKey: buyerWallet } }; + // creator has no stellarWallet and PLATFORM_COLLECT_ENABLED is off + const book = { _id: itemId, title: "Book", price: 15, author: { _id: new mongoose.Types.ObjectId(), name: "Creator" } }; + + jest.spyOn(User, "findById").mockReturnValue(makeQuery(buyer)); + jest.spyOn(Book, "findById").mockReturnValue(makeQuery(book)); + + const res = await request(mountPaymentApp()) + .post("/initialize") + .send({ itemType: "book", itemId: itemId.toString(), buyerWallet }); + + expect(res.statusCode).toBe(400); + expect(res.body).toMatchObject({ + success: false, + fallback: "claimable_balance", + message: "Creator has not connected their Stellar wallet yet", + }); + }); +}); diff --git a/test/health.test.js b/test/health.test.js new file mode 100644 index 00000000..227ace9d --- /dev/null +++ b/test/health.test.js @@ -0,0 +1,109 @@ +import { jest } from "@jest/globals"; +import { createHealthHandler, ping } from "../src/controllers/healthController.js"; + +const createResponse = () => { + const res = { + status: jest.fn(), + json: jest.fn(), + send: jest.fn(), + }; + res.status.mockReturnValue(res); + res.json.mockReturnValue(res); + res.send.mockReturnValue(res); + return res; +}; + +describe("Health endpoints", () => { + it("returns healthy readiness metadata when critical dependencies are ready", () => { + const handler = createHealthHandler({ + getMongoReadyState: () => 1, + getRedisReady: () => true, + getUptime: () => 42.5, + getEnvironment: () => "test", + }); + const res = createResponse(); + + handler({}, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + success: true, + message: "All critical dependencies are ready", + data: { + status: "healthy", + timestamp: expect.any(String), + uptime: 42.5, + environment: "test", + dependencies: { + mongodb: { + status: "up", + state: "connected", + }, + redis: { + status: "up", + }, + }, + }, + }); + }); + + it.each([ + { + name: "MongoDB", + mongoReadyState: 0, + redisReady: true, + expectedMongoState: "disconnected", + }, + { + name: "Redis", + mongoReadyState: 1, + redisReady: false, + expectedMongoState: "connected", + }, + ])( + "returns unavailable readiness when $name is down", + ({ mongoReadyState, redisReady, expectedMongoState }) => { + const handler = createHealthHandler({ + getMongoReadyState: () => mongoReadyState, + getRedisReady: () => redisReady, + getUptime: () => 10, + getEnvironment: () => "test", + }); + const res = createResponse(); + + handler({}, res); + + expect(res.status).toHaveBeenCalledWith(503); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + message: "One or more critical dependencies are unavailable", + data: { + status: "unhealthy", + timestamp: expect.any(String), + uptime: 10, + environment: "test", + dependencies: { + mongodb: { + status: mongoReadyState === 1 ? "up" : "down", + state: expectedMongoState, + }, + redis: { + status: redisReady ? "up" : "down", + }, + }, + }, + }) + ); + } + ); + + it("keeps ping independent from dependency probes", () => { + const res = createResponse(); + + ping({}, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.send).toHaveBeenCalledWith("pong"); + }); +}); diff --git a/test/helpers/testAuth.js b/test/helpers/testAuth.js index 8507eea3..66175d0d 100644 --- a/test/helpers/testAuth.js +++ b/test/helpers/testAuth.js @@ -1,5 +1,5 @@ import request from "supertest"; -import bcrypt from "bcrypt"; +import bcrypt from "bcryptjs"; import User from "../../src/models/User.js"; // Registration is email-verification-first, so POST /api/auth/register no longer @@ -16,12 +16,14 @@ export async function seedUserAndLogin(app, overrides = {}) { }; const hashedPassword = await bcrypt.hash(creds.password, 12); + const { name, email, role, password, ...extraFields } = creds; const user = await User.create({ - name: creds.name, - email: creds.email, + name, + email, password: hashedPassword, - role: creds.role, + role, isVerified: true, + ...extraFields, }); const res = await request(app) diff --git a/test/highlight.test.js b/test/highlight.test.js new file mode 100644 index 00000000..3915e621 --- /dev/null +++ b/test/highlight.test.js @@ -0,0 +1,175 @@ +import request from "supertest"; +import mongoose from "mongoose"; +import jwt from "jsonwebtoken"; +import { MongoMemoryServer } from "mongodb-memory-server"; +import app from "../app.js"; +import User from "../src/models/User.js"; +import Book from "../src/models/Book.js"; +import Highlight from "../src/models/highlight.model.js"; +import Note from "../src/models/note.model.js"; + +const JWT_SECRET = process.env.JWT_SECRET; +const generateToken = (userId, role = "student") => { + return jwt.sign({ userId, role }, JWT_SECRET, { expiresIn: "1h" }); +}; + +describe("Highlights & Notes API (#204)", () => { + let mongoServer; + let user, author; + let token; + let testBook; + + beforeAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + }, 60000); + + afterAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (mongoServer) { + await mongoServer.stop(); + } + }); + + beforeEach(async () => { + await User.deleteMany({}); + await Book.deleteMany({}); + await Highlight.deleteMany({}); + await Note.deleteMany({}); + + user = await User.create({ + name: "Reader User", + email: "reader@example.com", + password: "Password123!", + role: "student", + }); + + author = await User.create({ + name: "Author User", + email: "author@example.com", + password: "Password123!", + role: "mentor", + }); + + token = generateToken(user._id, "student"); + + testBook = await Book.create({ + title: "Seerah of the Prophet", + author: author._id, + description: "Comprehensive biography", + category: "Seerah", + price: 0, + image: "https://example.com/cover.jpg", + fileUrl: "https://example.com/book.pdf", + }); + }); + + it("allows reader to select and save text highlights with color options", async () => { + const res = await request(app) + .post(`/api/books/${testBook._id}/highlights`) + .set("Authorization", `Bearer ${token}`) + .send({ + text: "The Year of the Elephant witnessed remarkable events.", + color: "green", + pageNumber: 15, + }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + expect(res.body.highlight.text).toContain("Year of the Elephant"); + expect(res.body.highlight.color).toBe("green"); + expect(res.body.highlight.pageNumber).toBe(15); + }); + + it("allows adding notes to specific passages or pages", async () => { + const highlightRes = await request(app) + .post(`/api/books/${testBook._id}/highlights`) + .set("Authorization", `Bearer ${token}`) + .send({ + text: "Patience and steadfastness in Makkah.", + color: "yellow", + pageNumber: 42, + }); + + const highlightId = highlightRes.body.highlight._id; + + const noteRes = await request(app) + .post(`/api/books/${testBook._id}/notes`) + .set("Authorization", `Bearer ${token}`) + .send({ + highlightId, + content: "Important reflection on perseverance during hardships.", + pageNumber: 42, + }); + + expect(noteRes.status).toBe(201); + expect(noteRes.body.success).toBe(true); + expect(noteRes.body.note.content).toContain("Important reflection"); + }); + + it("views all highlights and notes for a book", async () => { + await request(app) + .post(`/api/books/${testBook._id}/highlights`) + .set("Authorization", `Bearer ${token}`) + .send({ text: "Passage 1", color: "blue" }); + + await request(app) + .post(`/api/books/${testBook._id}/notes`) + .set("Authorization", `Bearer ${token}`) + .send({ content: "Note 1" }); + + const res = await request(app) + .get(`/api/books/${testBook._id}/highlights-notes`) + .set("Authorization", `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body.highlights.length).toBe(1); + expect(res.body.notes.length).toBe(1); + }); + + it("searches through highlights and notes", async () => { + await request(app) + .post(`/api/books/${testBook._id}/highlights`) + .set("Authorization", `Bearer ${token}`) + .send({ text: "Prophet's migration to Madinah", color: "purple" }); + + await request(app) + .post(`/api/books/${testBook._id}/notes`) + .set("Authorization", `Bearer ${token}`) + .send({ content: "Lessons from Hijrah and brotherhood" }); + + const searchRes = await request(app) + .get(`/api/books/${testBook._id}/highlights-notes/search?q=Hijrah`) + .set("Authorization", `Bearer ${token}`); + + expect(searchRes.status).toBe(200); + expect(searchRes.body.results.notes.length).toBe(1); + }); + + it("exports highlights as text or PDF", async () => { + await request(app) + .post(`/api/books/${testBook._id}/highlights`) + .set("Authorization", `Bearer ${token}`) + .send({ text: "Key lesson from treaty of Hudaybiyyah", color: "pink", pageNumber: 88 }); + + const textExport = await request(app) + .get(`/api/books/${testBook._id}/highlights/export?format=text`) + .set("Authorization", `Bearer ${token}`); + + expect(textExport.status).toBe(200); + expect(textExport.text).toContain("BOOK: Seerah of the Prophet"); + expect(textExport.text).toContain("Key lesson from treaty"); + + const pdfExport = await request(app) + .get(`/api/books/${testBook._id}/highlights/export?format=pdf`) + .set("Authorization", `Bearer ${token}`); + + expect(pdfExport.status).toBe(200); + expect(pdfExport.header["content-type"]).toContain("application/pdf"); + }); +}); diff --git a/test/idempotency.test.js b/test/idempotency.test.js new file mode 100644 index 00000000..35ad1a86 --- /dev/null +++ b/test/idempotency.test.js @@ -0,0 +1,299 @@ +import { jest } from "@jest/globals"; +import express from "express"; +import request from "supertest"; +import mongoose from "mongoose"; +import jwt from "jsonwebtoken"; +import { MongoMemoryReplSet } from "mongodb-memory-server"; +import { errorHandler } from "../src/middlewares/errorHandler.js"; + +jest.setTimeout(60000); + +jest.unstable_mockModule("../src/services/stellar/stellarService.js", () => ({ + resolveAsset: jest.fn(), + STROOPS_PER_UNIT: 10000000n, + toStroops: jest.fn(), + fromStroops: jest.fn(), + applySlippage: jest.fn(), + findPaymentPaths: jest.fn(), + buildPathPaymentTransaction: jest.fn(), + calculateFeeSplit: jest.fn().mockReturnValue(null), + buildSep7Uri: jest.fn().mockReturnValue("web+stellar:pay?mock"), + isValidPublicKey: jest.fn().mockReturnValue(true), + getAccountBalance: jest.fn(), + MEMO_REQUIRED_DATA_KEY: "config.memo_required", + isMemoRequired: jest.fn(), + PREFLIGHT_REASON_CODES: {}, + preflightPayment: jest.fn().mockResolvedValue({ ok: true }), + buildPaymentTransaction: jest.fn().mockResolvedValue({ + xdr: "mock_xdr_string", + networkPassphrase: "Test SDF Network ; September 2015", + network: "testnet", + hash: "mock_hash_12345", + }), + buildReversePaymentTransaction: jest.fn(), + submitTransaction: jest.fn().mockResolvedValue({ hash: "mock_tx_hash_123" }), + verifyTransaction: jest.fn(), + verifyPaymentOperations: jest.fn().mockResolvedValue({ ok: true }), + validateSignedPaymentXdr: jest.fn().mockReturnValue({ valid: true }), + hasTrustline: jest.fn().mockResolvedValue(true), + hasUsdcTrustline: jest.fn().mockResolvedValue(true), + getExplorerUrl: jest.fn((hash) => `https://stellar.expert/tx/${hash}`), + getAccountExplorerUrl: jest.fn(), + server: {}, + USDC: "USDC", + USDC_ISSUER: "", + NETWORK: "testnet", + networkPassphrase: "Test SDF Network ; September 2015", + DONATION_WALLET_PUBLIC_KEY: "GAAZI4TCR3TY5OJHCTJC2A4QSYRZPBTXFDVKT5GLA7IHQMMLVJSSZ26K", + PLATFORM_FEE_PERCENT: 0, + PLATFORM_WALLET_PUBLIC_KEY: "", + DEFAULT_ASSET_CODE: "USDC", +})); + +jest.unstable_mockModule("../src/jobs/queue.js", () => ({ + enqueue: jest.fn().mockResolvedValue(undefined), +})); + +const User = (await import("../src/models/User.js")).default; +const Book = (await import("../src/models/Book.js")).default; +const Transaction = (await import("../src/models/Transaction.js")).default; +const IdempotencyKey = (await import("../src/models/IdempotencyKey.js")).default; +const protect = (await import("../src/middlewares/authMiddleware.js")).protect; +const idempotencyMiddleware = (await import("../src/middlewares/idempotency.js")).idempotency; +const paymentRoutes = (await import("../src/routes/stellar/paymentRoutes.js")).default; +const donationRoutes = (await import("../src/routes/stellar/donationRoutes.js")).default; +const payoutRoutes = (await import("../src/routes/payoutRoutes.js")).default; + +const JWT_SECRET = process.env.JWT_SECRET || "test_secret_key_32_characters_long_for_testing"; + +describe("Request-Level Idempotency Layer (#93)", () => { + let mongoServer; + let user, book, token; + let app; + let mockConcurrencyHandler; + + beforeAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + process.env.DONATION_WALLET_PUBLIC_KEY = + "GAAZI4TCR3TY5OJHCTJC2A4QSYRZPBTXFDVKT5GLA7IHQMMLVJSSZ26K"; + + mongoServer = await MongoMemoryReplSet.create({ + replSet: { count: 1, storageEngine: "wiredTiger" }, + }); + await mongoose.connect(mongoServer.getUri()); + + await User.createCollection(); + await Book.createCollection(); + await Transaction.createCollection(); + await IdempotencyKey.createCollection(); + await IdempotencyKey.syncIndexes(); + }); + + afterAll(async () => { + await mongoose.disconnect(); + if (mongoServer) { + await mongoServer.stop(); + } + }); + + beforeEach(async () => { + await IdempotencyKey.deleteMany({}); + await Transaction.deleteMany({}); + await User.deleteMany({}); + await Book.deleteMany({}); + + user = await User.create({ + name: "Test Buyer", + username: "testbuyer", + email: "buyer@example.com", + password: "Password123!", + role: "student", + stellarWallet: { + publicKey: "GAAZI4TCR3TY5OJHCTJC2A4QSYRZPBTXFDVKT5GLA7IHQMMLVJSSZ26K", + }, + }); + + token = jwt.sign({ userId: user._id, role: "student" }, JWT_SECRET, { + expiresIn: "1h", + }); + + book = await Book.create({ + title: "Test Book for Idempotency", + author: user._id, + description: "Test Description for Idempotency Book", + image: "https://cloudinary.com/test.jpg", + fileUrl: "https://cloudinary.com/test.pdf", + price: 10, + currency: "USDC", + }); + + mockConcurrencyHandler = jest.fn((req, res) => { + setTimeout(() => res.status(200).json({ success: true, transactionId: "mock_tx_id" }), 30); + }); + + app = express(); + app.use(express.json()); + app.post("/test-concurrency", protect, idempotencyMiddleware({ required: true }), mockConcurrencyHandler); + app.use("/api/stellar/payment", paymentRoutes); + app.use("/api/stellar/donation", donationRoutes); + app.use("/api/payouts", payoutRoutes); + app.use(errorHandler); + }); + + it("proves the concurrency lock via unique-index insert on concurrent same-key requests", async () => { + const key = "concurrency-key-12345"; + const payload = { + itemType: "book", + itemId: book._id.toString(), + buyerWallet: user.stellarWallet.publicKey, + }; + + const [res1, res2] = await Promise.all([ + request(app) + .post("/test-concurrency") + .set("Authorization", `Bearer ${token}`) + .set("Idempotency-Key", key) + .send(payload), + request(app) + .post("/test-concurrency") + .set("Authorization", `Bearer ${token}`) + .set("Idempotency-Key", key) + .send(payload), + ]); + + const winnerRes = res1.status === 200 ? res1 : res2; + const loserRes = res1.status === 409 ? res1 : res2; + + expect(winnerRes.status).toBe(200); + expect(loserRes.status).toBe(409); + expect(winnerRes.body.success).toBe(true); + expect(loserRes.body.message).toMatch(/currently in progress/i); + expect(mockConcurrencyHandler).toHaveBeenCalledTimes(1); + }); + + it("replays a completed key returning identical status + body without creating second Transaction", async () => { + const key = "replay-key-99999"; + const payload = { + itemType: "book", + itemId: book._id.toString(), + buyerWallet: user.stellarWallet.publicKey, + }; + + const firstRes = await request(app) + .post("/api/stellar/payment/initialize") + .set("Authorization", `Bearer ${token}`) + .set("Idempotency-Key", key) + .send(payload); + + expect(firstRes.status).toBe(200); + expect(firstRes.body.success).toBe(true); + const originalTxId = firstRes.body.transactionId; + + const replayRes = await request(app) + .post("/api/stellar/payment/initialize") + .set("Authorization", `Bearer ${token}`) + .set("Idempotency-Key", key) + .send(payload); + + expect(replayRes.status).toBe(200); + expect(replayRes.body).toEqual(firstRes.body); + expect(replayRes.body.transactionId).toBe(originalTxId); + + const txCount = await Transaction.countDocuments({ buyer: user._id }); + expect(txCount).toBe(1); + }); + + it("returns 422 Unprocessable Entity when same key arrives with a different body payload", async () => { + const key = "mismatch-key-55555"; + const payload1 = { + itemType: "book", + itemId: book._id.toString(), + buyerWallet: user.stellarWallet.publicKey, + }; + const payload2 = { + itemType: "book", + itemId: book._id.toString(), + buyerWallet: "GOTHERWALLET1234567890123456789012345678901234567890", + }; + + const res1 = await request(app) + .post("/api/stellar/payment/initialize") + .set("Authorization", `Bearer ${token}`) + .set("Idempotency-Key", key) + .send(payload1); + expect(res1.status).toBe(200); + + const res2 = await request(app) + .post("/api/stellar/payment/initialize") + .set("Authorization", `Bearer ${token}`) + .set("Idempotency-Key", key) + .send(payload2); + + expect(res2.status).toBe(422); + expect(res2.body.message).toMatch(/payload mismatch/i); + }); + + it("allows missing key requests to proceed normally according to policy", async () => { + const payload = { + itemType: "book", + itemId: book._id.toString(), + buyerWallet: user.stellarWallet.publicKey, + }; + + const res = await request(app) + .post("/api/stellar/payment/initialize") + .set("Authorization", `Bearer ${token}`) + .send(payload); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); + + it("provides idempotency protection to donation routes", async () => { + const key = "donation-key-77777"; + const payload = { + amount: "50", + publicKey: user.stellarWallet.publicKey, + }; + + const firstRes = await request(app) + .post("/api/stellar/donation/initialize") + .set("Authorization", `Bearer ${token}`) + .set("Idempotency-Key", key) + .send(payload); + + expect(firstRes.status).toBe(200); + + const replayRes = await request(app) + .post("/api/stellar/donation/initialize") + .set("Authorization", `Bearer ${token}`) + .set("Idempotency-Key", key) + .send(payload); + + expect(replayRes.status).toBe(200); + expect(replayRes.body).toEqual(firstRes.body); + + const txCount = await Transaction.countDocuments({ buyer: user._id }); + expect(txCount).toBe(1); + }); + + it("verifies idempotency keys expire via TTL", async () => { + const indexes = IdempotencyKey.schema.indexes(); + const ttlIndex = indexes.find( + (idx) => idx[0].createdAt === 1 && idx[1] && idx[1].expireAfterSeconds === 86400 + ); + expect(ttlIndex).toBeDefined(); + }); + + afterAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (mongoServer) { + await mongoServer.stop(); + } + }); +}); + diff --git a/test/jest.setup.js b/test/jest.setup.js index d63283c9..4aedf275 100644 --- a/test/jest.setup.js +++ b/test/jest.setup.js @@ -26,13 +26,18 @@ for (const variable of [ delete process.env[variable]; } -for (const variable of ["MONGO_URI", "JWT_SECRET", "PORT"]) { - if (!process.env[variable]) { - throw new Error(`${variable} must be set when running tests`); - } -} +// The app is deliberately importable without a database. Individual +// integration suites opt into MongoMemoryServer or the CI Mongo service. +process.env.JWT_SECRET = + process.env.JWT_SECRET || "test-secret-key-at-least-32-characters-long"; +process.env.PORT = process.env.PORT || "5000"; +process.env.CLOUDINARY_CLOUD_NAME = + process.env.CLOUDINARY_CLOUD_NAME || "test_cloud"; +process.env.CLOUDINARY_API_KEY = + process.env.CLOUDINARY_API_KEY || "test_key"; +process.env.CLOUDINARY_API_SECRET = + process.env.CLOUDINARY_API_SECRET || "test_secret_that_should_not_leak"; if (typeof jest !== "undefined") { jest.setTimeout(60000); } - diff --git a/test/moderation.test.js b/test/moderation.test.js new file mode 100644 index 00000000..68b7b423 --- /dev/null +++ b/test/moderation.test.js @@ -0,0 +1,155 @@ +import request from "supertest"; +import mongoose from "mongoose"; +import jwt from "jsonwebtoken"; +import { MongoMemoryServer } from "mongodb-memory-server"; +import app from "../app.js"; +import User from "../src/models/User.js"; +import Reel from "../src/models/Reel.js"; +import ContentFlag from "../src/models/content-flag.model.js"; +import ModerationAction from "../src/models/moderation-action.model.js"; +import moderationService from "../src/services/moderation.service.js"; + +const JWT_SECRET = process.env.JWT_SECRET; +const generateToken = (userId, role = "student", is2FAVerified = true) => { + return jwt.sign({ userId, role, is2FAVerified }, JWT_SECRET, { expiresIn: "1h" }); +}; + +describe("Content Moderation Queue API (#213)", () => { + let mongoServer; + let user, creator, admin; + let userToken, creatorToken, adminToken; + let testReel; + + beforeAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + }, 60000); + + afterAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (mongoServer) { + await mongoServer.stop(); + } + }); + + beforeEach(async () => { + await User.deleteMany({}); + await Reel.deleteMany({}); + await ContentFlag.deleteMany({}); + await ModerationAction.deleteMany({}); + + user = await User.create({ + name: "Regular User", + email: "user@example.com", + password: "Password123!", + role: "student", + }); + + creator = await User.create({ + name: "Content Creator", + email: "creator@example.com", + password: "Password123!", + role: "mentor", + }); + + admin = await User.create({ + name: "Admin User", + email: "admin@example.com", + password: "Password123!", + role: "admin", + twoFactor: { enabled: true }, + }); + + userToken = generateToken(user._id, "student"); + creatorToken = generateToken(creator._id, "mentor"); + adminToken = generateToken(admin._id, "admin", true); + + testReel = await Reel.create({ + title: "Inspirational Short", + description: "Daily reminder reel", + video: "https://example.com/video.mp4", + createdBy: creator._id, + user: creator._id, + }); + }); + + it("allows users to flag reels for inappropriate content", async () => { + const res = await request(app) + .post(`/api/reels/${testReel._id}/flag`) + .set("Authorization", `Bearer ${userToken}`) + .send({ + reason: "Inappropriate language", + details: "Contains offensive comments at 0:15", + }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + expect(res.body.flag.status).toBe("pending"); + expect(res.body.flag.reason).toBe("Inappropriate language"); + }); + + it("places flagged reels in admin moderation queue", async () => { + await request(app) + .post(`/api/reels/${testReel._id}/flag`) + .set("Authorization", `Bearer ${userToken}`) + .send({ reason: "Spam content" }); + + const queueRes = await request(app) + .get("/api/admin/moderation/queue?status=pending") + .set("Authorization", `Bearer ${adminToken}`); + + expect(queueRes.status).toBe(200); + expect(queueRes.body.flags.length).toBe(1); + expect(queueRes.body.flags[0].reason).toBe("Spam content"); + }); + + it("allows admins to approve or remove content and notifies creator", async () => { + const flagRes = await request(app) + .post(`/api/reels/${testReel._id}/flag`) + .set("Authorization", `Bearer ${userToken}`) + .send({ reason: "Copyright violation" }); + + const flagId = flagRes.body.flag._id; + + const actionRes = await request(app) + .post(`/api/admin/moderation/${flagId}/action`) + .set("Authorization", `Bearer ${adminToken}`) + .send({ + action: "remove", + notes: "Confirmed copyright infringement", + }); + + expect(actionRes.status).toBe(200); + expect(actionRes.body.flag.status).toBe("removed"); + expect(actionRes.body.reel.status).toBe("removed"); + + const historyRes = await request(app) + .get("/api/admin/moderation/history") + .set("Authorization", `Bearer ${adminToken}`); + + expect(historyRes.status).toBe(200); + expect(historyRes.body.actions.length).toBe(1); + expect(historyRes.body.actions[0].action).toBe("remove"); + }); + + it("auto-flags reels based on keyword filters", async () => { + const badReel = await Reel.create({ + title: "Hate speech and harassment reel", + description: "Contains prohibited spam and violence", + video: "https://example.com/bad.mp4", + createdBy: creator._id, + user: creator._id, + }); + + const flag = await moderationService.autoFlagReel(badReel); + expect(flag).not.toBeNull(); + expect(flag.isAutoFlagged).toBe(true); + expect(flag.flaggedKeywords).toContain("hate"); + expect(flag.flaggedKeywords).toContain("spam"); + }); +}); diff --git a/test/notification.test.js b/test/notification.test.js index 1f319f85..28b27314 100644 --- a/test/notification.test.js +++ b/test/notification.test.js @@ -19,6 +19,15 @@ describe("Notification System & Event Wiring", () => { let mongoServer; beforeAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (process.env.MONGO_URI) { + try { + await mongoose.connect(`${process.env.MONGO_URI}_notification`, { serverSelectionTimeoutMS: 2000 }); + return; + } catch (_err) {} + } mongoServer = await MongoMemoryServer.create(); const mongoUri = mongoServer.getUri(); await mongoose.connect(mongoUri); diff --git a/test/ownershipAuthz.test.js b/test/ownershipAuthz.test.js new file mode 100644 index 00000000..091492ed --- /dev/null +++ b/test/ownershipAuthz.test.js @@ -0,0 +1,338 @@ +import express from "express"; +import request from "supertest"; +import mongoose from "mongoose"; +import { MongoMemoryServer } from "mongodb-memory-server"; +import User from "../src/models/User.js"; +import Book from "../src/models/Book.js"; +import Course from "../src/models/Course.js"; +import Space from "../src/models/Space.js"; +import AuditLog from "../src/models/AuditLog.js"; +import { errorHandler } from "../src/middlewares/errorHandler.js"; +import { + authorizeOwnership, + authorizeReviewOwnership, +} from "../src/middlewares/authorize.js"; + +// Mirrors test/authRoles.test.js: a mini express app + MongoMemoryServer with an +// injected req.user, mounting a single guard followed by a stub handler that +// returns 200 when the guard calls next(). Denials flow through the global +// errorHandler and surface as 403/404. + +// Build an app that injects `user`, runs `guard`, and returns 200 if it passes. +const buildApp = (user, method, path, guard) => { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.user = user; + next(); + }); + app[method](path, guard, (req, res) => + res.status(200).json({ + ok: true, + resourceId: req.resource?._id, + reviewId: req.review?._id, + }) + ); + app.use(errorHandler); + return app; +}; + +// Poll for a fire-and-forget audit row (recordAudit schedules the write async). +const waitForAudit = async (query, timeout = 3000) => { + const start = Date.now(); + while (Date.now() - start < timeout) { + const row = await AuditLog.findOne(query); + if (row) return row; + await new Promise((r) => setTimeout(r, 25)); + } + return null; +}; + +describe("Resource-Ownership Authorization Layer", () => { + let mongoServer; + let ownerMentor, otherMentor, studentUser, adminUser, reviewerUser; + let book, course, space, bookReviewId, courseReviewId; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + }, 30000); + + afterAll(async () => { + await mongoose.disconnect(); + if (mongoServer) { + await mongoServer.stop(); + } + }); + + beforeEach(async () => { + await User.deleteMany({}); + await Book.deleteMany({}); + await Course.deleteMany({}); + await Space.deleteMany({}); + // AuditLog is append-only (model hooks block deleteMany); clear via the + // raw collection so each test starts with a clean audit trail. + await mongoose.connection.collection("auditlogs").deleteMany({}); + + ownerMentor = await User.create({ + name: "Owner Mentor", + email: "owner_mentor@example.com", + password: "Qx7#vLmp92Zt", + role: "mentor", + }); + otherMentor = await User.create({ + name: "Other Mentor", + email: "other_mentor@example.com", + password: "Qx7#vLmp92Zt", + role: "mentor", + }); + studentUser = await User.create({ + name: "Student", + email: "student@example.com", + password: "Qx7#vLmp92Zt", + role: "student", + }); + adminUser = await User.create({ + name: "Admin", + email: "admin@example.com", + password: "Qx7#vLmp92Zt", + role: "admin", + }); + reviewerUser = await User.create({ + name: "Reviewer", + email: "reviewer@example.com", + password: "Qx7#vLmp92Zt", + role: "student", + }); + + book = await Book.create({ + title: "Owned Book", + description: "Desc", + category: "Tech", + price: 10, + author: ownerMentor._id, + image: "https://example.com/thumb.jpg", + fileUrl: "https://example.com/file.pdf", + reviews: [{ user: reviewerUser._id, comment: "Nice", rating: 5 }], + }); + bookReviewId = book.reviews[0]._id; + + course = await Course.create({ + title: "Owned Course", + description: "Desc", + category: "Tech", + price: 10, + createdBy: ownerMentor._id, + reviews: [{ user: reviewerUser._id, comment: "Great", rating: 4 }], + }); + courseReviewId = course.reviews[0]._id; + + space = await Space.create({ + title: "Owned Space", + description: "Desc", + category: "Tech", + host: ownerMentor._id, + price: 0, + eventDate: new Date(), + eventTime: "10:00", + duration: 60, + }); + }); + + const missingId = () => new mongoose.Types.ObjectId(); + + // ── Top-level resource ownership (Book / Course / Space) ─────────────────── + describe.each([ + { + label: "Book DELETE", + method: "delete", + path: "/books/:id", + guard: () => + authorizeOwnership({ model: Book, ownerField: "author", resourceType: "Book" }), + url: () => `/books/${book._id}`, + missingUrl: () => `/books/${missingId()}`, + targetId: () => String(book._id), + resourceType: "Book", + }, + { + label: "Course PUT", + method: "put", + path: "/courses/:id", + guard: () => + authorizeOwnership({ model: Course, ownerField: "createdBy", resourceType: "Course" }), + url: () => `/courses/${course._id}`, + missingUrl: () => `/courses/${missingId()}`, + targetId: () => String(course._id), + resourceType: "Course", + }, + { + label: "Space PUT (update)", + method: "put", + path: "/spaces/update/:id", + guard: () => + authorizeOwnership({ model: Space, ownerField: "host", resourceType: "Space" }), + url: () => `/spaces/update/${space._id}`, + missingUrl: () => `/spaces/update/${missingId()}`, + targetId: () => String(space._id), + resourceType: "Space", + }, + { + label: "Space DELETE", + method: "delete", + path: "/spaces/:id", + guard: () => + authorizeOwnership({ model: Space, ownerField: "host", resourceType: "Space" }), + url: () => `/spaces/${space._id}`, + missingUrl: () => `/spaces/${missingId()}`, + targetId: () => String(space._id), + resourceType: "Space", + }, + ])("$label", ({ method, path, guard, url, missingUrl, targetId, resourceType }) => { + it("allows the resource owner (2xx)", async () => { + const app = buildApp(ownerMentor, method, path, guard()); + const res = await request(app)[method](url()); + expect(res.status).toBe(200); + expect(res.body.ok).toBe(true); + }); + + it("allows an admin (2xx)", async () => { + const app = buildApp(adminUser, method, path, guard()); + const res = await request(app)[method](url()); + expect(res.status).toBe(200); + }); + + it("rejects a non-owner mentor (403) and audits the denial", async () => { + const app = buildApp(otherMentor, method, path, guard()); + const res = await request(app)[method](url()); + expect(res.status).toBe(403); + expect(res.body.success).toBe(false); + + const row = await waitForAudit({ + action: "authz.ownership.denied", + actor: otherMentor._id, + targetId: targetId(), + }); + expect(row).not.toBeNull(); + expect(row.status).toBe("failure"); + expect(row.targetType).toBe(resourceType); + expect(String(row.actor)).toBe(String(otherMentor._id)); + }); + + it("rejects a non-owner student (403)", async () => { + const app = buildApp(studentUser, method, path, guard()); + const res = await request(app)[method](url()); + expect(res.status).toBe(403); + }); + + it("returns 404 for a non-existent resource", async () => { + const app = buildApp(ownerMentor, method, path, guard()); + const res = await request(app)[method](missingUrl()); + expect(res.status).toBe(404); + }); + }); + + // ── Review subdocument ownership (Book / Course, id-scoped) ──────────────── + describe.each([ + { + label: "Book review (id-scoped)", + method: "put", + path: "/books/:id/reviews/:reviewId", + guard: () => authorizeReviewOwnership({ model: Book }), + url: () => `/books/${book._id}/reviews/${bookReviewId}`, + missingUrl: () => `/books/${missingId()}/reviews/${bookReviewId}`, + missingReviewUrl: () => `/books/${book._id}/reviews/${missingId()}`, + targetId: () => String(bookReviewId), + }, + { + label: "Book review DELETE (id-scoped)", + method: "delete", + path: "/books/:id/reviews/:reviewId", + guard: () => authorizeReviewOwnership({ model: Book }), + url: () => `/books/${book._id}/reviews/${bookReviewId}`, + missingUrl: () => `/books/${missingId()}/reviews/${bookReviewId}`, + missingReviewUrl: () => `/books/${book._id}/reviews/${missingId()}`, + targetId: () => String(bookReviewId), + }, + { + label: "Course review (id-scoped)", + method: "put", + path: "/courses/:id/reviews/:reviewId", + guard: () => authorizeReviewOwnership({ model: Course }), + url: () => `/courses/${course._id}/reviews/${courseReviewId}`, + missingUrl: () => `/courses/${missingId()}/reviews/${courseReviewId}`, + missingReviewUrl: () => `/courses/${course._id}/reviews/${missingId()}`, + targetId: () => String(courseReviewId), + }, + { + label: "Course review DELETE (id-scoped)", + method: "delete", + path: "/courses/:id/reviews/:reviewId", + guard: () => authorizeReviewOwnership({ model: Course }), + url: () => `/courses/${course._id}/reviews/${courseReviewId}`, + missingUrl: () => `/courses/${missingId()}/reviews/${courseReviewId}`, + missingReviewUrl: () => `/courses/${course._id}/reviews/${missingId()}`, + targetId: () => String(courseReviewId), + }, + ])("$label", ({ method, path, guard, url, missingUrl, missingReviewUrl, targetId }) => { + it("allows the review owner (2xx)", async () => { + const app = buildApp(reviewerUser, method, path, guard()); + const res = await request(app)[method](url()); + expect(res.status).toBe(200); + }); + + it("allows an admin (2xx)", async () => { + const app = buildApp(adminUser, method, path, guard()); + const res = await request(app)[method](url()); + expect(res.status).toBe(200); + }); + + it("rejects a non-owner mentor (403) and audits the denial", async () => { + const app = buildApp(otherMentor, method, path, guard()); + const res = await request(app)[method](url()); + expect(res.status).toBe(403); + + const row = await waitForAudit({ + action: "authz.ownership.denied", + actor: otherMentor._id, + targetId: targetId(), + }); + expect(row).not.toBeNull(); + expect(row.status).toBe("failure"); + expect(row.targetType).toBe("Review"); + }); + + it("rejects a non-owner student (403)", async () => { + const app = buildApp(studentUser, method, path, guard()); + const res = await request(app)[method](url()); + expect(res.status).toBe(403); + }); + + it("returns 404 for a non-existent parent item", async () => { + const app = buildApp(reviewerUser, method, path, guard()); + const res = await request(app)[method](missingUrl()); + expect(res.status).toBe(404); + }); + + it("returns 404 for a non-existent review id", async () => { + const app = buildApp(reviewerUser, method, path, guard()); + const res = await request(app)[method](missingReviewUrl()); + expect(res.status).toBe(404); + }); + }); + + // ── Review subdocument ownership (self-scoped: no :reviewId) ─────────────── + describe("Review (self-scoped) ownership", () => { + it("allows the caller to act on their own review (2xx)", async () => { + const app = buildApp(reviewerUser, "put", "/books/:id/reviews", authorizeReviewOwnership({ model: Book })); + const res = await request(app).put(`/books/${book._id}/reviews`); + expect(res.status).toBe(200); + expect(String(res.body.reviewId)).toBe(String(bookReviewId)); + }); + + it("returns 404 when the caller has no review of their own", async () => { + const app = buildApp(otherMentor, "delete", "/courses/:id/reviews", authorizeReviewOwnership({ model: Course })); + const res = await request(app).delete(`/courses/${course._id}/reviews`); + expect(res.status).toBe(404); + }); + }); +}); diff --git a/test/passwordReset.test.js b/test/passwordReset.test.js index ec0d389c..944b9013 100644 --- a/test/passwordReset.test.js +++ b/test/passwordReset.test.js @@ -1,12 +1,14 @@ import { jest } from "@jest/globals"; import request from "supertest"; import mongoose from "mongoose"; -import bcrypt from "bcrypt"; +import bcrypt from "bcryptjs"; +import axios from "axios"; import app from "../app.js"; import User from "../src/models/User.js"; import PendingUser from "../src/models/PendingUser.js"; import Session from "../src/models/Session.js"; import logger from "../src/config/logger.js"; +import { testOutbox } from "../services/emails/sendMail.js"; const testUser = { name: "Reset User", @@ -23,8 +25,12 @@ describe("Password Reset Flow", () => { let loggerInfoSpy; beforeAll(() => { - // Capture the OTP code from the [EMAIL LOG] fallback (SMTP is unset in tests) + // Capture log output so we can assert OTPs/tokens never reach the logger. loggerInfoSpy = jest.spyOn(logger, "info"); + jest.spyOn(logger, "warn"); + jest.spyOn(logger, "error"); + // Mock the HIBP breached-password range call (empty data => not breached). + jest.spyOn(axios, "get").mockResolvedValue({ status: 200, statusText: "OK", data: "" }); // Mock User methods jest.spyOn(User, "findOne").mockImplementation((query) => { @@ -104,6 +110,7 @@ describe("Password Reset Flow", () => { beforeEach(() => { usersStore = []; sessionsStore = []; + testOutbox.length = 0; if (loggerInfoSpy) loggerInfoSpy.mockClear(); }); @@ -112,17 +119,24 @@ describe("Password Reset Flow", () => { }); const getSentOtp = () => { - // Registration now also sends a verification email, so multiple [EMAIL LOG] - // entries exist. Pick the most recent one that actually carries an OTP span. - const otpLog = loggerInfoSpy.mock.calls - .map((call) => call[0]) - .filter((msg) => typeof msg === "string" && msg.includes("[EMAIL LOG]")) + // sendMail captures rendered emails in its in-memory testOutbox (never in + // logs). Registration also sends a verification email, so pick the most + // recent outbox entry that actually carries an OTP span. + const otpMail = [...testOutbox] .reverse() - .find((msg) => /#166534;">(\d+)<\/span>/.test(msg)); - const match = otpLog && otpLog.match(/#166534;">(\d+)<\/span>/); + .find((mail) => /#166534;">(\d+)<\/span>/.test(mail.html)); + const match = otpMail && otpMail.html.match(/#166534;">(\d+)<\/span>/); return match ? match[1] : null; }; + const capturedLogText = () => + ["info", "warn", "error"] + .flatMap((method) => logger[method].mock.calls || []) + .map((call) => + call.map((arg) => (typeof arg === "string" ? arg : JSON.stringify(arg))).join(" ") + ) + .join("\n"); + it("should request password reset without exposing OTP in response body and include success: true", async () => { await request(app).post("/api/auth/register").send(testUser); @@ -277,4 +291,27 @@ describe("Password Reset Flow", () => { expect(reuseRes.body.success).toBe(false); expect(reuseRes.body.message).toContain("Invalid or expired OTP"); }); + + it("never leaks the OTP or verification token into log output", async () => { + await request(app).post("/api/auth/register").send(testUser); + + // Registration renders a verification email carrying a token link; the + // reset request renders an OTP email. Both must stay out of the logs. + const verificationMail = testOutbox.find((m) => m.template === "verification"); + const tokenMatch = verificationMail && verificationMail.html.match(/token=([a-f0-9]{64})/); + expect(tokenMatch).not.toBeNull(); + + await request(app) + .post("/api/auth/request-password-reset") + .send({ email: testUser.email }); + + const otp = getSentOtp(); + expect(otp).toBeDefined(); + + const logs = capturedLogText(); + expect(logs).not.toContain(otp); + expect(logs).not.toContain(tokenMatch[1]); + expect(logs).not.toContain("token="); + expect(logs).not.toContain(" `https://stellar.expert/tx/${hash}`); +const recordSaleEarnings = jest.fn(); +const enqueue = jest.fn(); +const grantItemAccess = jest.fn(); + +// Mirrors every named export of stellarService.js (see the comment in +// stellarPaymentController.test.js for why the full surface is needed). +jest.unstable_mockModule("../src/services/stellar/stellarService.js", () => ({ + STROOPS_PER_UNIT: 10000000n, + toStroops: jest.fn(), + fromStroops: jest.fn(), + resolveAsset: jest.fn(), + applySlippage: jest.fn(), + findPaymentPaths: jest.fn(), + buildPathPaymentTransaction: jest.fn(), + calculateFeeSplit, + buildSep7Uri, + isValidPublicKey: jest.fn(), + getAccountBalance: jest.fn(), + MEMO_REQUIRED_DATA_KEY: "config.memo_required", + isMemoRequired: jest.fn(), + PREFLIGHT_REASON_CODES: {}, + preflightPayment, + buildPaymentTransaction, + buildReversePaymentTransaction: jest.fn(), + submitTransaction, + verifyTransaction: jest.fn(), + verifyPaymentOperations, + validateSignedPaymentXdr: jest.fn(), + hasUsdcTrustline: jest.fn(), + getExplorerUrl, + getAccountExplorerUrl: jest.fn(), + server: {}, + USDC: "USDC", + USDC_ISSUER: "", + NETWORK: "testnet", + networkPassphrase: TESTNET_PASSPHRASE, + DONATION_WALLET_PUBLIC_KEY: "", + PLATFORM_FEE_PERCENT: 0, + PLATFORM_WALLET_PUBLIC_KEY: "", +})); + +jest.unstable_mockModule("../src/services/payoutService.js", () => ({ + recordSaleEarnings, +})); + +jest.unstable_mockModule("../src/jobs/queue.js", () => ({ + enqueue, +})); + +// grantItemAccess is what must NOT run twice on a replayed submit — mock it +// so the test can count invocations. +jest.unstable_mockModule("../src/services/stellar/reconciliationService.js", () => ({ + grantItemAccess, +})); + +const { initializePayment, submitPayment } = await import( + "../src/controllers/stellar/paymentController.js" +); +const User = (await import("../src/models/User.js")).default; +const Book = (await import("../src/models/Book.js")).default; +const Transaction = (await import("../src/models/Transaction.js")).default; + +const makeQuery = (result) => { + const query = { + session: jest.fn(() => Promise.resolve(result)), + populate: jest.fn(() => query), + select: jest.fn(() => query), + sort: jest.fn(() => query), + skip: jest.fn(() => query), + limit: jest.fn(() => query), + then: (resolve, reject) => Promise.resolve(result).then(resolve, reject), + }; + return query; +}; + +const makeSession = () => ({ + startTransaction: jest.fn(), + commitTransaction: jest.fn(() => Promise.resolve()), + abortTransaction: jest.fn(() => Promise.resolve()), + endSession: jest.fn(), +}); + +const mountPaymentApp = (userId) => { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.user = { _id: userId }; + next(); + }); + app.post("/initialize", initializePayment); + app.post("/submit", submitPayment); + return app; +}; + +// A real, valid Stellar destination (the SDK validates the key format when +// building the operation). +const DESTINATION = StellarSdk.Keypair.random().publicKey(); + +// Build a real, signed USDC payment transaction (testnet passphrase) so the +// controller can parse it and derive a deterministic on-chain hash. +const buildSignedXdr = ({ amount = "15" } = {}) => { + const source = StellarSdk.Keypair.random(); + const account = new StellarSdk.Account(source.publicKey(), "1"); + const tx = new StellarSdk.TransactionBuilder(account, { + fee: StellarSdk.BASE_FEE, + networkPassphrase: TESTNET_PASSPHRASE, + }) + .addOperation( + StellarSdk.Operation.payment({ + destination: DESTINATION, + asset: new StellarSdk.Asset("USDC", "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"), + amount, + }) + ) + .addMemo(StellarSdk.Memo.text("DNB-BOOK-1234")) + .setTimeout(300) + .build(); + tx.sign(source); + return { xdr: tx.toXDR(), hash: tx.hash().toString("hex") }; +}; + +describe("Stellar payment idempotency", () => { + let buyerId; + let creatorId; + let itemId; + let buyerWallet; + let creatorWallet; + let session; + let savedTransactions; + + beforeEach(() => { + jest.restoreAllMocks(); + buildPaymentTransaction.mockReset(); + buildSep7Uri.mockReset(); + calculateFeeSplit.mockReset().mockReturnValue(null); + preflightPayment.mockReset().mockResolvedValue({ ok: true }); + submitTransaction.mockReset(); + verifyPaymentOperations.mockReset(); + getExplorerUrl.mockClear(); + recordSaleEarnings.mockReset(); + enqueue.mockReset().mockResolvedValue(undefined); + grantItemAccess.mockReset().mockResolvedValue(undefined); + + buyerId = new mongoose.Types.ObjectId(); + creatorId = new mongoose.Types.ObjectId(); + itemId = new mongoose.Types.ObjectId(); + buyerWallet = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; + creatorWallet = "GCKFBEIYTKPXL5UIRZ5OO3KSOFDP5D4R6YGNFWEQSIFGKWO3EZ5F3TGI"; + session = makeSession(); + savedTransactions = []; + + jest.spyOn(mongoose, "startSession").mockResolvedValue(session); + jest.spyOn(Transaction.prototype, "save").mockImplementation(function () { + savedTransactions.push(this); + return Promise.resolve(this); + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("returns the existing pending record instead of creating a duplicate on re-initialize", async () => { + const buyer = { + _id: buyerId, + stellarWallet: { publicKey: buyerWallet }, + purchasedBooks: [], + }; + const creator = { + _id: creatorId, + name: "Educator", + stellarWallet: { publicKey: creatorWallet }, + }; + const book = { + _id: itemId, + title: "Paid Book", + price: 15, + author: creator, + }; + const existingTx = { + _id: new mongoose.Types.ObjectId(), + unsignedXdr: "unsigned-xdr-from-first-init", + expectedHash: "expected-hash", + }; + + jest.spyOn(User, "findById").mockReturnValue(makeQuery(buyer)); + jest.spyOn(Book, "findById").mockReturnValue(makeQuery(book)); + jest.spyOn(Transaction, "findOne").mockReturnValue(makeQuery(existingTx)); + + const res = await request(mountPaymentApp(buyerId)) + .post("/initialize") + .send({ + itemType: "book", + itemId: itemId.toString(), + buyerWallet, + }); + + expect(res.statusCode).toBe(200); + expect(res.body).toMatchObject({ + success: true, + alreadyPending: true, + transactionId: existingTx._id.toString(), + payment: { + xdr: "unsigned-xdr-from-first-init", + networkPassphrase: TESTNET_PASSPHRASE, + expectedHash: "expected-hash", + }, + }); + // No XDR was built and no new document was saved. + expect(buildPaymentTransaction).not.toHaveBeenCalled(); + expect(savedTransactions).toHaveLength(0); + expect(session.abortTransaction).toHaveBeenCalled(); + }); + + it("replays the original success response on a duplicate submit of the same signed XDR", async () => { + const signed = buildSignedXdr(); + const tx = { + _id: new mongoose.Types.ObjectId(), + buyer: buyerId, + buyerWallet, + creator: creatorId, + creatorWallet, + itemType: "book", + itemId, + itemTitle: "Paid Book", + amount: "15", + currency: "USDC", + status: "pending", + memo: "DNB-BOOK-1234", + save: jest.fn(() => Promise.resolve()), + }; + + // findOne is called for both the confirmed-by-hash lookup and the pending + // lookup; the same object is returned so the second submit sees it already + // confirmed (status flipped by the first submit). + jest.spyOn(Transaction, "findOne").mockReturnValue(makeQuery(tx)); + submitTransaction.mockResolvedValue({ + hash: signed.hash, + ledger: 77, + successful: true, + }); + verifyPaymentOperations.mockResolvedValue({ verified: true }); + recordSaleEarnings.mockResolvedValue({ success: true }); + + const app = mountPaymentApp(buyerId); + + // First submit — normal confirmation. + const first = await request(app) + .post("/submit") + .send({ transactionId: tx._id.toString(), signedXdr: signed.xdr }); + expect(first.statusCode).toBe(200); + expect(first.body).toMatchObject({ + success: true, + message: "Payment successful!", + }); + expect(tx.status).toBe("confirmed"); + expect(tx.stellarTxHash).toBe(signed.hash); + + // Second submit — same XDR, must be recognized as already processed. + const second = await request(app) + .post("/submit") + .send({ transactionId: tx._id.toString(), signedXdr: signed.xdr }); + expect(second.statusCode).toBe(200); + expect(second.body).toMatchObject({ + success: true, + replay: true, + message: "Payment already processed", + transaction: { + hash: signed.hash, + itemTitle: "Paid Book", + amount: "15", + }, + }); + + // Access is granted exactly once across both submits. + expect(grantItemAccess).toHaveBeenCalledTimes(1); + expect(recordSaleEarnings).toHaveBeenCalledTimes(1); + }); + + it("treats a concurrent duplicate (unique-index E11000) as already processed", async () => { + const signed = buildSignedXdr(); + const tx = { + _id: new mongoose.Types.ObjectId(), + buyer: buyerId, + buyerWallet, + creator: creatorId, + creatorWallet, + itemType: "book", + itemId, + itemTitle: "Paid Book", + amount: "15", + currency: "USDC", + status: "pending", + memo: "DNB-BOOK-1234", + // The "confirmed" save must fail with a duplicate-key error to + // simulate the race where a concurrent request already wrote the same + // on-chain hash (the unique index on stellarTxHash is the backstop). + save: jest.fn(function () { + if (this.status === "confirmed") { + const err = new Error("E11000 duplicate key"); + err.code = 11000; + return Promise.reject(err); + } + return Promise.resolve(this); + }), + }; + const confirmedTx = { + _id: new mongoose.Types.ObjectId(), + buyer: buyerId, + stellarTxHash: signed.hash, + stellarLedger: 88, + itemTitle: "Paid Book", + amount: "15", + }; + + // findOne calls: confirmed-by-hash lookup (null), pending lookup (tx), + // then the backstop lookup after E11000 (confirmedTx). + jest + .spyOn(Transaction, "findOne") + .mockReturnValueOnce(makeQuery(null)) + .mockReturnValueOnce(makeQuery(tx)) + .mockReturnValueOnce(makeQuery(confirmedTx)); + + submitTransaction.mockResolvedValue({ + hash: signed.hash, + ledger: 88, + successful: true, + }); + verifyPaymentOperations.mockResolvedValue({ verified: true }); + + const res = await request(mountPaymentApp(buyerId)) + .post("/submit") + .send({ transactionId: tx._id.toString(), signedXdr: signed.xdr }); + + expect(res.statusCode).toBe(200); + expect(res.body).toMatchObject({ + success: true, + replay: true, + message: "Payment already processed", + transaction: { hash: signed.hash }, + }); + // The session was rolled back before any earnings/access were recorded. + expect(session.abortTransaction).toHaveBeenCalled(); + expect(recordSaleEarnings).not.toHaveBeenCalled(); + expect(grantItemAccess).not.toHaveBeenCalled(); + }); +}); + +describe("paymentLimiter (per-user rate limit on payment routes)", () => { + let paymentLimiter; + + beforeAll(async () => { + // Tighten the limit BEFORE security.js is imported so the limiter is + // constructed with the smaller max. + process.env.RATE_LIMIT_PAYMENT_MAX = "3"; + const security = await import("../src/middlewares/security.js"); + paymentLimiter = security.paymentLimiter; + }); + + afterAll(() => { + delete process.env.RATE_LIMIT_PAYMENT_MAX; + }); + + it("returns 429 once a single user exceeds the per-user budget", async () => { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.user = { _id: "user-abc" }; + next(); + }); + app.use("/api/stellar/payment", paymentLimiter); + app.post("/api/stellar/payment/initialize", (req, res) => + res.status(200).json({ success: true }) + ); + + const statuses = []; + for (let i = 0; i < 4; i++) { + const res = await request(app) + .post("/api/stellar/payment/initialize") + .send({ itemType: "book", itemId: "x" }); + statuses.push(res.statusCode); + } + + expect(statuses).toEqual([200, 200, 200, 429]); + }); +}); diff --git a/test/readingGroup.test.js b/test/readingGroup.test.js new file mode 100644 index 00000000..9196062d --- /dev/null +++ b/test/readingGroup.test.js @@ -0,0 +1,220 @@ +import request from "supertest"; +import mongoose from "mongoose"; +import jwt from "jsonwebtoken"; +import { MongoMemoryServer } from "mongodb-memory-server"; +import app from "../app.js"; +import User from "../src/models/User.js"; +import Book from "../src/models/Book.js"; +import ReadingGroup from "../src/models/reading-group.model.js"; +import ReadingGroupMember from "../src/models/reading-group-member.model.js"; + +const JWT_SECRET = process.env.JWT_SECRET; +const generateToken = (userId, role = "student") => { + return jwt.sign({ userId, role }, JWT_SECRET, { expiresIn: "1h" }); +}; + +describe("Book Clubs / Reading Groups API (#205)", () => { + let mongoServer; + let creatorUser, memberUser, inviteeUser, authorUser; + let creatorToken, memberToken, inviteeToken; + let testBook; + + beforeAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + }, 60000); + + afterAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (mongoServer) { + await mongoServer.stop(); + } + }); + + beforeEach(async () => { + await User.deleteMany({}); + await Book.deleteMany({}); + await ReadingGroup.deleteMany({}); + await ReadingGroupMember.deleteMany({}); + + creatorUser = await User.create({ + name: "Group Creator", + email: "creator@example.com", + password: "Password123!", + role: "student", + }); + + memberUser = await User.create({ + name: "Group Member", + email: "member@example.com", + password: "Password123!", + role: "student", + }); + + inviteeUser = await User.create({ + name: "Invited Member", + email: "invitee@example.com", + password: "Password123!", + role: "student", + }); + + authorUser = await User.create({ + name: "Imam An-Nawawi", + email: "author@example.com", + password: "Password123!", + role: "mentor", + }); + + creatorToken = generateToken(creatorUser._id, "student"); + memberToken = generateToken(memberUser._id, "student"); + inviteeToken = generateToken(inviteeUser._id, "student"); + + testBook = await Book.create({ + title: "Riyad as-Salihin", + author: authorUser._id, + description: "Gardens of the Righteous", + category: "Hadith", + price: 0, + image: "https://example.com/cover.jpg", + fileUrl: "https://example.com/book.pdf", + }); + }); + + it("creates reading groups for specific books", async () => { + const res = await request(app) + .post("/api/books/reading-groups") + .set("Authorization", `Bearer ${creatorToken}`) + .send({ + name: "Riyad Study Club", + description: "Weekly Hadith discussion", + bookId: testBook._id, + privacy: "public", + chaptersPerWeek: 2, + }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + expect(res.body.group.name).toBe("Riyad Study Club"); + }); + + it("invites members or allows joining public/private groups", async () => { + const createRes = await request(app) + .post("/api/books/reading-groups") + .set("Authorization", `Bearer ${creatorToken}`) + .send({ + name: "Private Hadith Group", + bookId: testBook._id, + privacy: "private", + }); + + const groupId = createRes.body.group._id; + + const joinRes = await request(app) + .post(`/api/books/reading-groups/${groupId}/join`) + .set("Authorization", `Bearer ${memberToken}`); + + expect(joinRes.status).toBe(200); + expect(joinRes.body.membership.status).toBe("pending"); + + const inviteRes = await request(app) + .post(`/api/books/reading-groups/${groupId}/invite`) + .set("Authorization", `Bearer ${creatorToken}`) + .send({ targetUserId: inviteeUser._id }); + + expect(inviteRes.status).toBe(200); + expect(inviteRes.body.membership.status).toBe("invited"); + }); + + it("sets reading schedules (chapters per week)", async () => { + const createRes = await request(app) + .post("/api/books/reading-groups") + .set("Authorization", `Bearer ${creatorToken}`) + .send({ + name: "Scheduled Group", + bookId: testBook._id, + }); + + const groupId = createRes.body.group._id; + + const scheduleRes = await request(app) + .put(`/api/books/reading-groups/${groupId}/schedule`) + .set("Authorization", `Bearer ${creatorToken}`) + .send({ + chaptersPerWeek: 3, + readingSchedule: [ + { chapter: 1, title: "Sincerity and Intention", targetPages: "1-10" }, + { chapter: 2, title: "Repentance", targetPages: "11-25" }, + ], + }); + + expect(scheduleRes.status).toBe(200); + expect(scheduleRes.body.group.chaptersPerWeek).toBe(3); + expect(scheduleRes.body.group.readingSchedule.length).toBe(2); + }); + + it("supports group discussion threads per chapter", async () => { + const createRes = await request(app) + .post("/api/books/reading-groups") + .set("Authorization", `Bearer ${creatorToken}`) + .send({ + name: "Discussion Group", + bookId: testBook._id, + }); + + const groupId = createRes.body.group._id; + + const postRes = await request(app) + .post(`/api/books/reading-groups/${groupId}/discussions`) + .set("Authorization", `Bearer ${creatorToken}`) + .send({ + chapter: 1, + content: "What are your key takeaways regarding Niyyah (Intention)?", + }); + + expect(postRes.status).toBe(201); + expect(postRes.body.discussions.length).toBe(1); + + const getRes = await request(app) + .get(`/api/books/reading-groups/${groupId}/discussions?chapter=1`) + .set("Authorization", `Bearer ${creatorToken}`); + + expect(getRes.status).toBe(200); + expect(getRes.body.discussions[0].content).toContain("key takeaways"); + }); + + it("tracks member progress in group dashboard", async () => { + const createRes = await request(app) + .post("/api/books/reading-groups") + .set("Authorization", `Bearer ${creatorToken}`) + .send({ + name: "Dashboard Group", + bookId: testBook._id, + }); + + const groupId = createRes.body.group._id; + + const progressRes = await request(app) + .put(`/api/books/reading-groups/${groupId}/progress`) + .set("Authorization", `Bearer ${creatorToken}`) + .send({ + currentChapter: 5, + currentProgressPercent: 50, + }); + + expect(progressRes.status).toBe(200); + expect(progressRes.body.member.currentChapter).toBe(5); + + const dashRes = await request(app) + .get(`/api/books/reading-groups/${groupId}/dashboard`) + .set("Authorization", `Bearer ${creatorToken}`); + + expect(dashRes.status).toBe(200); + expect(dashRes.body.stats.totalMembers).toBe(1); + expect(dashRes.body.stats.avgProgressPercent).toBe(50); + }); +}); diff --git a/test/readingProgress.test.js b/test/readingProgress.test.js new file mode 100644 index 00000000..3abbbfdd --- /dev/null +++ b/test/readingProgress.test.js @@ -0,0 +1,172 @@ +import request from "supertest"; +import mongoose from "mongoose"; +import jwt from "jsonwebtoken"; +import { MongoMemoryServer } from "mongodb-memory-server"; +import app from "../app.js"; +import User from "../src/models/User.js"; +import Book from "../src/models/Book.js"; +import ReadingProgress from "../src/models/ReadingProgress.js"; + +const JWT_SECRET = process.env.JWT_SECRET; +const generateToken = (userId, role = "student") => + jwt.sign({ userId, role }, JWT_SECRET, { expiresIn: "1h" }); + +describe("Reading progress sync API (#203)", () => { + let mongoServer; + let user; + let author; + let token; + let book; + + beforeAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + }, 60000); + + afterAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (mongoServer) { + await mongoServer.stop(); + } + }); + + beforeEach(async () => { + await Promise.all([ + User.deleteMany({}), + Book.deleteMany({}), + ReadingProgress.deleteMany({}), + ]); + + author = await User.create({ + name: "Author", + email: "author@example.com", + password: "Qx7#vLmp92Zt", + role: "mentor", + }); + user = await User.create({ + name: "Reader", + email: "reader@example.com", + password: "Qx7#vLmp92Zt", + role: "student", + }); + token = generateToken(user._id); + + book = await Book.create({ + title: "The Sealed Nectar", + author: author._id, + description: "A biography", + image: "https://example.com/cover.jpg", + fileUrl: "https://example.com/book.pdf", + }); + }); + + it("upserts exactly one record per user + book", async () => { + const first = await request(app) + .put(`/api/books/${book._id}/progress`) + .set("Authorization", `Bearer ${token}`) + .send({ page: 10, totalPages: 100 }); + + expect(first.status).toBe(200); + expect(first.body.success).toBe(true); + expect(first.body.progress.percentage).toBe(10); + + await request(app) + .put(`/api/books/${book._id}/progress`) + .set("Authorization", `Bearer ${token}`) + .send({ page: 25, totalPages: 100 }); + + const count = await ReadingProgress.countDocuments({ + user: user._id, + book: book._id, + }); + expect(count).toBe(1); + }); + + it("overwrites progress and bumps updatedAt / version on update", async () => { + const created = await request(app) + .put(`/api/books/${book._id}/progress`) + .set("Authorization", `Bearer ${token}`) + .send({ page: 5, percentage: 5, lastPosition: "cfi(/2)" }); + + const firstVersion = created.body.progress.version; + const firstUpdatedAt = new Date(created.body.progress.updatedAt).getTime(); + + await new Promise((resolve) => setTimeout(resolve, 20)); + + const updated = await request(app) + .put(`/api/books/${book._id}/progress`) + .set("Authorization", `Bearer ${token}`) + .send({ page: 40, percentage: 40, lastPosition: "cfi(/8)" }); + + expect(updated.body.progress.percentage).toBe(40); + expect(updated.body.progress.lastPosition).toBe("cfi(/8)"); + expect(updated.body.progress.version).toBe(firstVersion + 1); + expect(new Date(updated.body.progress.updatedAt).getTime()).toBeGreaterThan( + firstUpdatedAt + ); + }); + + it("returns the last stored position so the reader can resume", async () => { + await request(app) + .put(`/api/books/${book._id}/progress`) + .set("Authorization", `Bearer ${token}`) + .send({ page: 73, percentage: 73, lastPosition: "cfi(/15)" }); + + const res = await request(app) + .get(`/api/books/${book._id}/progress`) + .set("Authorization", `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body.progress.page).toBe(73); + expect(res.body.progress.percentage).toBe(73); + expect(res.body.progress.lastPosition).toBe("cfi(/15)"); + }); + + it("returns null progress when nothing has been stored yet", async () => { + const res = await request(app) + .get(`/api/books/${book._id}/progress`) + .set("Authorization", `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body.progress).toBeNull(); + }); + + it("rejects a percentage greater than 100", async () => { + const res = await request(app) + .put(`/api/books/${book._id}/progress`) + .set("Authorization", `Bearer ${token}`) + .send({ percentage: 150 }); + + expect(res.status).toBe(400); + }); + + it("rejects a percentage below 0", async () => { + const res = await request(app) + .put(`/api/books/${book._id}/progress`) + .set("Authorization", `Bearer ${token}`) + .send({ percentage: -5 }); + + expect(res.status).toBe(400); + }); + + it("includes progress percentage on the library listing", async () => { + await request(app) + .put(`/api/books/${book._id}/progress`) + .set("Authorization", `Bearer ${token}`) + .send({ page: 50, totalPages: 100 }); + + const res = await request(app) + .get(`/api/books/library/progress`) + .set("Authorization", `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body.library).toHaveLength(1); + expect(res.body.library[0].percentage).toBe(50); + expect(res.body.library[0].book._id.toString()).toBe(book._id.toString()); + }); +}); diff --git a/test/reconciliation.test.js b/test/reconciliation.test.js index 02e5fa55..b901ef99 100644 --- a/test/reconciliation.test.js +++ b/test/reconciliation.test.js @@ -42,6 +42,15 @@ describe("Payment Reconciliation Service", () => { let buyer, author, admin, book, course; beforeAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (process.env.MONGO_URI) { + try { + await mongoose.connect(`${process.env.MONGO_URI}_reconciliation`, { serverSelectionTimeoutMS: 2000 }); + return; + } catch (_err) {} + } mongoServer = await MongoMemoryServer.create(); await mongoose.connect(mongoServer.getUri()); }, 30000); @@ -236,6 +245,9 @@ describe("Payment Reconciliation Service", () => { const updated = await Transaction.findById(tx._id); expect(updated.status).toBe("confirmed"); expect(updated.confirmedAt).toBeDefined(); + // Terminal state — the reconciliation confirm path must leave the row + // without an expiry so the TTL reaper can never delete it. + expect(updated.expiresAt).toBeUndefined(); expect(mockRecordSaleEarnings).toHaveBeenCalled(); }); diff --git a/test/reelDuet.test.js b/test/reelDuet.test.js new file mode 100644 index 00000000..e1ea8761 --- /dev/null +++ b/test/reelDuet.test.js @@ -0,0 +1,159 @@ +import mongoose from "mongoose"; +import { MongoMemoryServer } from "mongodb-memory-server"; +import Reel from "../src/models/Reel.js"; +import User from "../src/models/User.js"; +import { + createReelDerivative, + listReelDerivatives, +} from "../src/services/reelDuetService.js"; + +describe("Reel duet/stitch service", () => { + let mongoServer; + let author; + let original; + + beforeAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (process.env.MONGO_URI) { + try { + await mongoose.connect(`${process.env.MONGO_URI}_reelduet`, { + serverSelectionTimeoutMS: 2000, + }); + return; + } catch (_err) {} + } + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + }, 60000); + + afterAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (mongoServer) { + await mongoServer.stop(); + } + }); + + beforeEach(async () => { + await Reel.deleteMany({}); + await User.deleteMany({}); + + author = await User.create({ + name: "Reel Author", + email: "reel_author@example.com", + password: "Qx7#vLmp92Zt", + role: "student", + }); + + original = await Reel.create({ + description: "Original reel", + video: "https://cdn.example.com/original.mp4", + duration: 30, + createdBy: author._id, + }); + }); + + test("creating a duet links it to the original and increments duetCount", async () => { + const derivative = await createReelDerivative({ + originalReelId: original._id, + type: "duet", + userId: author._id, + description: "My duet response", + video: "https://cdn.example.com/duet-response.mp4", + duration: 30, + }); + + expect(String(derivative.originalReelId)).toBe(String(original._id)); + expect(derivative.duetType).toBe("duet"); + expect(derivative.composition.layout).toBe("side-by-side"); + expect(derivative.composition.status).toBe("pending"); + expect(derivative.composition.sources.original.reelId).toBe( + String(original._id) + ); + + const refreshed = await Reel.findById(original._id).lean(); + expect(refreshed.duetCount).toBe(1); + expect(refreshed.stitchCount).toBe(0); + }); + + test("creating a stitch stores the clip range and increments stitchCount", async () => { + const derivative = await createReelDerivative({ + originalReelId: original._id, + type: "stitch", + userId: author._id, + description: "My stitch response", + video: "https://cdn.example.com/stitch-response.mp4", + duration: 20, + clip: { start: 2, end: 7 }, + }); + + expect(derivative.duetType).toBe("stitch"); + expect(derivative.stitchClip.start).toBe(2); + expect(derivative.stitchClip.end).toBe(7); + expect(derivative.composition.layout).toBe("prepend-clip"); + expect(derivative.composition.clip).toEqual({ start: 2, end: 7 }); + + const refreshed = await Reel.findById(original._id).lean(); + expect(refreshed.stitchCount).toBe(1); + expect(refreshed.duetCount).toBe(0); + }); + + test("a stitch without a valid clip range is rejected", async () => { + await expect( + createReelDerivative({ + originalReelId: original._id, + type: "stitch", + userId: author._id, + description: "Bad stitch", + video: "https://cdn.example.com/bad-stitch.mp4", + clip: { start: 5, end: 5 }, + }) + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + test("creating a derivative for a missing original throws 404", async () => { + await expect( + createReelDerivative({ + originalReelId: new mongoose.Types.ObjectId(), + type: "duet", + userId: author._id, + description: "Orphan duet", + video: "https://cdn.example.com/orphan.mp4", + }) + ).rejects.toMatchObject({ statusCode: 404 }); + }); + + test("listing returns derivatives for a reel and supports type filtering", async () => { + await createReelDerivative({ + originalReelId: original._id, + type: "duet", + userId: author._id, + description: "Duet A", + video: "https://cdn.example.com/a.mp4", + }); + await createReelDerivative({ + originalReelId: original._id, + type: "stitch", + userId: author._id, + description: "Stitch B", + video: "https://cdn.example.com/b.mp4", + clip: { start: 1, end: 4 }, + }); + + const all = await listReelDerivatives(original._id, {}); + expect(all.total).toBe(2); + expect(all.items).toHaveLength(2); + all.items.forEach((item) => { + expect(String(item.originalReelId)).toBe(String(original._id)); + }); + + const onlyStitches = await listReelDerivatives(original._id, { + type: "stitch", + }); + expect(onlyStitches.total).toBe(1); + expect(onlyStitches.items[0].duetType).toBe("stitch"); + }); +}); diff --git a/test/refund.test.js b/test/refund.test.js index e54f7eaa..ae54c6f8 100644 --- a/test/refund.test.js +++ b/test/refund.test.js @@ -8,6 +8,7 @@ import express from "express"; import request from "supertest"; import jwt from "jsonwebtoken"; import mongoose from "mongoose"; +import { MongoMemoryServer } from "mongodb-memory-server"; import * as StellarSdk from "@stellar/stellar-sdk"; import User from "../src/models/User.js"; import Book from "../src/models/Book.js"; @@ -16,17 +17,19 @@ import Transaction from "../src/models/Transaction.js"; import Refund from "../src/models/Refund.js"; import paymentRoutes from "../src/routes/stellar/paymentRoutes.js"; import { server } from "../src/services/stellar/stellarService.js"; +import { errorHandler } from "../src/middlewares/errorHandler.js"; jest.setTimeout(60000); const app = express(); app.use(express.json()); app.use("/api/stellar/payment", paymentRoutes); +app.use(errorHandler); const JWT_SECRET = process.env.JWT_SECRET || "deenbridge-temp-secret-key-2024"; -const generateToken = (userId, role = "student") => { - return jwt.sign({ userId, role }, JWT_SECRET, { expiresIn: "1h" }); +const generateToken = (userId, role = "student", is2FAVerified = true) => { + return jwt.sign({ userId, role, is2FAVerified }, JWT_SECRET, { expiresIn: "1h" }); }; describe("Non-Custodial Refund & Dispute Flow (#62)", () => { @@ -35,12 +38,22 @@ describe("Non-Custodial Refund & Dispute Flow (#62)", () => { let buyerToken, educatorToken, otherToken, adminToken; let confirmedTx; let course; + let mongoServer; beforeAll(async () => { - const uri = process.env.MONGO_URI || "mongodb://127.0.0.1:27017/dnb-backend-test"; - - if (mongoose.connection.readyState === 0) { - await mongoose.connect(uri); + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (process.env.MONGO_URI) { + try { + await mongoose.connect(`${process.env.MONGO_URI}_refund`, { serverSelectionTimeoutMS: 2000 }); + } catch (_err) { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + } + } else { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); } // Mock Horizon Server @@ -88,6 +101,9 @@ describe("Non-Custodial Refund & Dispute Flow (#62)", () => { if (mongoose.connection.readyState !== 0) { await mongoose.disconnect(); } + if (mongoServer) { + await mongoServer.stop(); + } }); beforeEach(async () => { @@ -132,12 +148,13 @@ describe("Non-Custodial Refund & Dispute Flow (#62)", () => { email: "admin@example.com", password: "Qx7#vLmp92Zt", role: "admin", + twoFactor: { enabled: true, secret: "MOCKSECRET", enrolledAt: new Date() }, }); - buyerToken = generateToken(buyer._id, "student"); - educatorToken = generateToken(educator._id, "tutor"); - otherToken = generateToken(otherUser._id, "student"); - adminToken = generateToken(adminUser._id, "admin"); + buyerToken = generateToken(buyer._id, "student", true); + educatorToken = generateToken(educator._id, "tutor", true); + otherToken = generateToken(otherUser._id, "student", true); + adminToken = generateToken(adminUser._id, "admin", true); // Create a purchased course and enroll buyer course = await Course.create({ @@ -381,4 +398,13 @@ describe("Non-Custodial Refund & Dispute Flow (#62)", () => { expect(res.status).toBe(403); }); }); + + afterAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (mongoServer) { + await mongoServer.stop(); + } + }); }); diff --git a/test/requestValidation.test.js b/test/requestValidation.test.js new file mode 100644 index 00000000..80025b7e --- /dev/null +++ b/test/requestValidation.test.js @@ -0,0 +1,221 @@ +import { jest } from "@jest/globals"; +import express from "express"; +import request from "supertest"; +import mongoose from "mongoose"; +import { errorHandler } from "../src/middlewares/errorHandler.js"; +import logger from "../src/config/logger.js"; + +const controller = (name) => + jest.fn((_req, res) => res.status(200).json({ handler: name })); + +const authHandlers = { + registerUser: controller("register"), + loginUser: controller("login"), + refreshSession: controller("refresh"), + getSessions: controller("sessions"), + revokeSession: controller("revokeSession"), + revokeAllOtherSessions: controller("revokeAllOtherSessions"), + logoutUser: controller("logout"), + requestPasswordReset: controller("requestPasswordReset"), + resetPassword: controller("resetPassword"), + changePassword: controller("changePassword"), + verifyEmail: controller("verifyEmail"), + resendVerification: controller("resendVerification"), + setup2FA: controller("setup2FA"), + verify2FA: controller("verify2FA"), + disable2FA: controller("disable2FA"), +}; + +const paymentHandlers = { + initializePayment: controller("initialize"), + submitPayment: controller("submit"), + getQuote: controller("quote"), + getPaymentPreflight: controller("preflight"), + getTransactionHistory: controller("history"), + getTransaction: controller("transaction"), + cancelTransaction: controller("cancel"), + sponsorshipStatus: controller("sponsorshipStatus"), +}; + +const refundHandlers = { + requestRefund: controller("requestRefund"), + buildRefundXdr: controller("buildRefundXdr"), + submitRefund: controller("submitRefund"), + rejectRefund: controller("rejectRefund"), + escalateDispute: controller("escalateDispute"), + arbitrateDispute: controller("arbitrateDispute"), +}; + +const walletHandlers = { + connectWallet: controller("connect"), + disconnectWallet: controller("disconnect"), + getWalletBalance: controller("balance"), + getMyWallet: controller("me"), + checkUserWallet: controller("check"), +}; + +jest.unstable_mockModule("../src/controllers/authController.js", () => authHandlers); +jest.unstable_mockModule("../src/controllers/stellar/sep10Controller.js", () => ({ + getStellarChallenge: controller("stellarChallenge"), + verifyStellarChallenge: controller("stellarVerify"), +})); +jest.unstable_mockModule("../src/controllers/stellar/paymentController.js", () => paymentHandlers); +jest.unstable_mockModule("../src/controllers/stellar/refundController.js", () => refundHandlers); +jest.unstable_mockModule("../src/controllers/stellar/reconciliationController.js", () => ({ + reconciliationStatus: controller("reconciliationStatus"), +})); +jest.unstable_mockModule("../src/controllers/stellar/walletController.js", () => walletHandlers); +jest.unstable_mockModule("../src/middlewares/authMiddleware.js", () => ({ + protect: (req, _res, next) => { + req.user = { _id: new mongoose.Types.ObjectId(), role: "student" }; + next(); + }, + authorizeRoles: () => (_req, _res, next) => next(), +})); +jest.unstable_mockModule("../src/middlewares/security.js", () => { + const passThrough = (_req, _res, next) => next(); + return { + refreshLimiter: passThrough, + twoFactorLimiter: passThrough, + emailAuthLimiter: passThrough, + captchaGate: () => passThrough, + }; +}); +jest.unstable_mockModule("../src/middlewares/idempotency.js", () => ({ + idempotency: () => (_req, _res, next) => next(), +})); + +const authRoutes = (await import("../src/routes/authRoutes.js")).default; +const paymentRoutes = (await import("../src/routes/stellar/paymentRoutes.js")).default; +const walletRoutes = (await import("../src/routes/stellar/walletRoutes.js")).default; + +const mount = (path, router) => { + const app = express(); + app.use(express.json()); + app.use(path, router); + app.use(errorHandler); + return app; +}; + +const expectValidationError = (res, expectedErrors) => { + expect(res.status).toBe(400); + expect(res.body).toMatchObject({ + success: false, + status: "fail", + message: "Validation failed", + data: null, + }); + expect(res.body.errors).toEqual( + expect.arrayContaining( + expectedErrors.map(([field, message]) => ({ field, message })) + ) + ); +}; + +describe("Request validation", () => { + beforeEach(() => { + Object.values(authHandlers) + .concat(Object.values(paymentHandlers), Object.values(walletHandlers)) + .forEach((handler) => handler.mockClear()); + }); + + it("rejects malformed registration data with field-level errors", async () => { + const res = await request(mount("/auth", authRoutes)) + .post("/auth/register") + .send({ + name: " ", + email: "not-an-email", + password: "short", + role: "moderator", + }); + + expectValidationError(res, [ + ["name", "Name is required"], + ["email", "Email must be a valid email address"], + ["password", "Password must be at least 8 characters"], + ["role", "Role must be one of: student, mentor, admin"], + ]); + expect(authHandlers.registerUser).not.toHaveBeenCalled(); + }); + + it("normalizes valid registration email addresses", async () => { + const res = await request(mount("/auth", authRoutes)) + .post("/auth/register") + .send({ + name: "Test User", + email: "USER@EXAMPLE.COM", + password: "Qx7#vLmp92Zt", + role: "student", + }); + + expect(res.status).toBe(200); + expect(authHandlers.registerUser).toHaveBeenCalledTimes(1); + expect(authHandlers.registerUser.mock.calls[0][0].body.email).toBe( + "user@example.com" + ); + }); + + it("rejects malformed login data before the controller runs", async () => { + const warnSpy = jest.spyOn(logger, "warn").mockImplementation(() => {}); + const res = await request(mount("/auth", authRoutes)) + .post("/auth/login?password=query-secret") + .send({ email: "invalid", password: " " }); + + expectValidationError(res, [ + ["email", "Email must be a valid email address"], + ["password", "Password is required"], + ]); + expect(JSON.stringify(warnSpy.mock.calls)).toContain("/auth/login"); + expect(JSON.stringify(warnSpy.mock.calls)).not.toContain("query-secret"); + expect(authHandlers.loginUser).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it("rejects invalid payment initialization fields before database access", async () => { + const res = await request(mount("/payment", paymentRoutes)) + .post("/payment/initialize") + .send({ + itemType: "video", + itemId: "not-an-object-id", + buyerWallet: "not-a-stellar-key", + }); + + expectValidationError(res, [ + ["itemType", "itemType must be one of: book, course"], + ["itemId", "itemId must be a valid Mongo ObjectId"], + ["buyerWallet", "buyerWallet must be a valid Stellar public key"], + ]); + expect(paymentHandlers.initializePayment).not.toHaveBeenCalled(); + }); + + it("rejects invalid transaction IDs and signed XDR before submission", async () => { + const res = await request(mount("/payment", paymentRoutes)) + .post("/payment/submit") + .send({ + transactionId: "not-an-object-id", + signedXdr: "not-an-xdr", + }); + + expectValidationError(res, [ + ["transactionId", "transactionId must be a valid Mongo ObjectId"], + ["signedXdr", "signedXdr must be a well-formed Stellar transaction XDR"], + ]); + expect(paymentHandlers.submitPayment).not.toHaveBeenCalled(); + }); + + it("rejects missing and malformed wallet public keys", async () => { + const missing = await request(mount("/wallet", walletRoutes)) + .post("/wallet/connect") + .send({}); + expectValidationError(missing, [["publicKey", "publicKey is required"]]); + + const malformed = await request(mount("/wallet", walletRoutes)) + .post("/wallet/connect") + .send({ publicKey: "not-a-stellar-key" }); + expectValidationError(malformed, [ + ["publicKey", "publicKey must be a valid Stellar public key"], + ]); + + expect(walletHandlers.connectWallet).not.toHaveBeenCalled(); + }); +}); diff --git a/test/reviews.test.js b/test/reviews.test.js index 69091870..aeccd396 100644 --- a/test/reviews.test.js +++ b/test/reviews.test.js @@ -9,22 +9,38 @@ import { computeReviewStats } from "../src/utils/reviewStats.js"; const JWT_SECRET = process.env.JWT_SECRET; -const generateToken = (userId) => { - return jwt.sign({ userId }, JWT_SECRET, { expiresIn: "1h" }); +const generateToken = (userId, role = "student", is2FAVerified = true) => { + return jwt.sign({ userId, role, is2FAVerified }, JWT_SECRET, { expiresIn: "1h" }); }; +import { MongoMemoryServer } from "mongodb-memory-server"; + describe("Reviews & Ratings API (Course and Book)", () => { let author, enrolledUser, purchaserUser, randomUser, adminUser; let authorToken, enrolledToken, purchaserToken, randomToken, adminToken; let course, book; + let mongoServer; beforeAll(async () => { - await mongoose.connect(`${process.env.MONGO_URI}_reviews`); + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (process.env.MONGO_URI) { + try { + await mongoose.connect(`${process.env.MONGO_URI}_reviews`, { serverSelectionTimeoutMS: 2000 }); + return; + } catch (_err) {} + } + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); }, 60000); afterAll(async () => { if (mongoose.connection.readyState !== 0) { - await mongoose.connection.close(); + await mongoose.disconnect(); + } + if (mongoServer) { + await mongoServer.stop(); } }); @@ -76,8 +92,9 @@ describe("Reviews & Ratings API (Course and Book)", () => { password: "Qx7#vLmp92Zt", avatar: "https://example.com/avatar_admin.png", role: "admin", + twoFactor: { enabled: true, secret: "MOCKSECRET", enrolledAt: new Date() }, }); - adminToken = generateToken(adminUser._id); + adminToken = generateToken(adminUser._id, "admin", true); // Create Course course = await Course.create({ diff --git a/test/search.test.js b/test/search.test.js index fb2edb45..e87783b1 100644 --- a/test/search.test.js +++ b/test/search.test.js @@ -7,10 +7,33 @@ import User from "../src/models/User.js"; import Space from "../src/models/Space.js"; import Reel from "../src/models/Reel.js"; +import { MongoMemoryServer } from "mongodb-memory-server"; + +let mongoServer; + beforeAll(async () => { - await mongoose.connect(`${process.env.MONGO_URI}_search`); + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (process.env.MONGO_URI) { + try { + await mongoose.connect(`${process.env.MONGO_URI}_search`, { serverSelectionTimeoutMS: 2000 }); + return; + } catch (_err) {} + } + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); }, 60000); +afterAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (mongoServer) { + await mongoServer.stop(); + } +}); + beforeEach(async () => { // Clean db await Course.deleteMany({}); diff --git a/test/serviceAuth.test.js b/test/serviceAuth.test.js new file mode 100644 index 00000000..17d734ac --- /dev/null +++ b/test/serviceAuth.test.js @@ -0,0 +1,233 @@ +import crypto from "crypto"; +import request from "supertest"; +import mongoose from "mongoose"; +import { MongoMemoryServer } from "mongodb-memory-server"; +import app from "../app.js"; +import AuditLog, { AUDIT_ACTIONS } from "../src/models/AuditLog.js"; + +// ── Independent client-side signer ────────────────────────────────────────── +// Deliberately reimplements the canonical form from docs/service-to-service-auth.md +// (rather than importing the middleware's helper) so this test doubles as proof +// that the dnb-ai client can reproduce the exact signature from the spec. +const WHOAMI_PATH = "/api/internal/ai/whoami"; + +function sha256hex(input) { + return crypto.createHash("sha256").update(input || "").digest("hex"); +} + +function signGet({ path = WHOAMI_PATH, secret, kid, serviceId = "dnb-ai", timestamp, body = "" }) { + const ts = String(timestamp ?? Math.floor(Date.now() / 1000)); + const canonical = ["GET", path, ts, sha256hex(body)].join("\n"); + const signature = crypto.createHmac("sha256", secret).update(canonical).digest("hex"); + return { + "X-Service-Id": serviceId, + "X-Service-Key-Id": kid, + "X-Timestamp": ts, + "X-Signature": signature, + }; +} + +const K1_SECRET = "k1-super-long-random-secret-value-0123456789"; +const K2_SECRET = "k2-super-long-random-secret-value-9876543210"; + +const KEYS_K1 = JSON.stringify([ + { kid: "k1", secret: K1_SECRET, scopes: ["ai:read-content"], active: true }, +]); + +const KEYS_K1_K2_ACTIVE = JSON.stringify([ + { kid: "k1", secret: K1_SECRET, scopes: ["ai:read-content"], active: true }, + { kid: "k2", secret: K2_SECRET, scopes: ["ai:read-content"], active: true }, +]); + +const KEYS_K1_RETIRED = JSON.stringify([ + { kid: "k1", secret: K1_SECRET, scopes: ["ai:read-content"], active: false }, + { kid: "k2", secret: K2_SECRET, scopes: ["ai:read-content"], active: true }, +]); + +// A key that authenticates but lacks the route's scope. +const KEYS_WRONG_SCOPE = JSON.stringify([ + { kid: "k1", secret: K1_SECRET, scopes: ["ai:write-answers"], active: true }, +]); + +let mongoServer; +const originalKeys = process.env.AI_SERVICE_KEYS; +const originalJobsToken = process.env.JOBS_DASHBOARD_TOKEN; + +beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + // Warm up the auditlogs collection so its indexes are built now (the first + // write to a fresh in-memory collection can take ~700ms). This keeps the + // later fire-and-forget denial write fast enough to observe within the poll. + await AuditLog.create({ action: AUDIT_ACTIONS.AUTH_LOGOUT, status: "success" }); +}, 30000); + +afterAll(async () => { + await mongoose.disconnect(); + if (mongoServer) await mongoServer.stop(); + if (originalKeys === undefined) delete process.env.AI_SERVICE_KEYS; + else process.env.AI_SERVICE_KEYS = originalKeys; + if (originalJobsToken === undefined) delete process.env.JOBS_DASHBOARD_TOKEN; + else process.env.JOBS_DASHBOARD_TOKEN = originalJobsToken; +}); + +beforeEach(() => { + process.env.AI_SERVICE_KEYS = KEYS_K1; +}); + +describe("requireServiceAuth via /api/internal/ai/whoami", () => { + it("accepts a valid signed request with a permitted scope and reflects req.service", async () => { + const headers = signGet({ secret: K1_SECRET, kid: "k1" }); + const res = await request(app).get(WHOAMI_PATH).set(headers); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.service).toEqual({ + id: "dnb-ai", + kid: "k1", + scopes: ["ai:read-content"], + }); + }); + + it("rejects a request with no signature headers (401)", async () => { + const res = await request(app).get(WHOAMI_PATH); + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + }); + + it("rejects a bad/forged signature (401)", async () => { + const headers = signGet({ secret: "the-wrong-secret", kid: "k1" }); + const res = await request(app).get(WHOAMI_PATH).set(headers); + expect(res.status).toBe(401); + }); + + it("rejects an unknown kid (401)", async () => { + const headers = signGet({ secret: K1_SECRET, kid: "does-not-exist" }); + const res = await request(app).get(WHOAMI_PATH).set(headers); + expect(res.status).toBe(401); + }); + + it("rejects a wrong scope with 403", async () => { + process.env.AI_SERVICE_KEYS = KEYS_WRONG_SCOPE; + const headers = signGet({ secret: K1_SECRET, kid: "k1" }); + const res = await request(app).get(WHOAMI_PATH).set(headers); + expect(res.status).toBe(403); + }); + + it("rejects a replayed (stale-timestamp) request with 401", async () => { + const stale = Math.floor(Date.now() / 1000) - 600; // outside ±300s window + const headers = signGet({ secret: K1_SECRET, kid: "k1", timestamp: stale }); + const res = await request(app).get(WHOAMI_PATH).set(headers); + expect(res.status).toBe(401); + }); + + it("rejects a future-dated timestamp with 401", async () => { + const future = Math.floor(Date.now() / 1000) + 600; + const headers = signGet({ secret: K1_SECRET, kid: "k1", timestamp: future }); + const res = await request(app).get(WHOAMI_PATH).set(headers); + expect(res.status).toBe(401); + }); + + it("does not throw on a length-mismatched signature (constant-time safe → 401)", async () => { + const headers = signGet({ secret: K1_SECRET, kid: "k1" }); + headers["X-Signature"] = "abc123"; // shorter than a real 64-char hex digest + const res = await request(app).get(WHOAMI_PATH).set(headers); + expect(res.status).toBe(401); // 401, never a 500 from timingSafeEqual throwing + }); +}); + +describe("key rotation without downtime", () => { + it("accepts two active kids simultaneously, then rejects the retired one while the other still works", async () => { + // Both k1 and k2 active → both valid. + process.env.AI_SERVICE_KEYS = KEYS_K1_K2_ACTIVE; + + const r1 = await request(app) + .get(WHOAMI_PATH) + .set(signGet({ secret: K1_SECRET, kid: "k1" })); + expect(r1.status).toBe(200); + expect(r1.body.service.kid).toBe("k1"); + + const r2 = await request(app) + .get(WHOAMI_PATH) + .set(signGet({ secret: K2_SECRET, kid: "k2" })); + expect(r2.status).toBe(200); + expect(r2.body.service.kid).toBe("k2"); + + // Retire k1 (active:false), keep k2. + process.env.AI_SERVICE_KEYS = KEYS_K1_RETIRED; + + const r1Retired = await request(app) + .get(WHOAMI_PATH) + .set(signGet({ secret: K1_SECRET, kid: "k1" })); + expect(r1Retired.status).toBe(401); + + const r2Still = await request(app) + .get(WHOAMI_PATH) + .set(signGet({ secret: K2_SECRET, kid: "k2" })); + expect(r2Still.status).toBe(200); + }); +}); + +describe("audit trail for denied S2S attempts", () => { + it("writes a service_auth.denied AuditLog row (status failure) on denial", async () => { + // Use a distinctive kid so we assert on THIS request's audit row, not one + // left by an earlier denial test. AuditLog is append-only (no deleteMany). + process.env.AI_SERVICE_KEYS = JSON.stringify([ + { kid: "audit-kid", secret: K1_SECRET, scopes: ["ai:read-content"], active: true }, + ]); + + const headers = signGet({ secret: "wrong-secret", kid: "audit-kid" }); + const res = await request(app).get(WHOAMI_PATH).set(headers); + expect(res.status).toBe(401); + + // recordAudit is fire-and-forget (microtask) — poll briefly for the row. + let row = null; + for (let i = 0; i < 40 && !row; i++) { + row = await AuditLog.findOne({ + action: "service_auth.denied", + "metadata.kid": "audit-kid", + }); + if (!row) await new Promise((r) => setTimeout(r, 50)); + } + + expect(row).not.toBeNull(); + expect(row.status).toBe("failure"); + expect(row.targetType).toBe("Service"); + expect(row.metadata?.kid).toBe("audit-kid"); + expect(row.metadata?.scope).toBe("ai:read-content"); + }); +}); + +describe("/admin/jobs timing-safe token comparison", () => { + const TOKEN = "jobs-dashboard-token-abcdefghijklmnop"; + + beforeEach(() => { + process.env.JOBS_DASHBOARD_TOKEN = TOKEN; + }); + + it("allows the correct bearer token", async () => { + const res = await request(app) + .get("/admin/jobs") + .set("Authorization", `Bearer ${TOKEN}`) + .set("Accept", "application/json"); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); + + it("rejects a wrong token of equal length with 401", async () => { + const wrong = "Bearer " + "x".repeat(TOKEN.length); + const res = await request(app) + .get("/admin/jobs") + .set("Authorization", wrong) + .set("Accept", "application/json"); + expect(res.status).toBe(401); + }); + + it("does not throw on a length-mismatched token (constant-time safe → 401)", async () => { + const res = await request(app) + .get("/admin/jobs") + .set("Authorization", "Bearer short") + .set("Accept", "application/json"); + expect(res.status).toBe(401); // not a 500 + }); +}); diff --git a/test/spacePoll.test.js b/test/spacePoll.test.js new file mode 100644 index 00000000..78a49fd0 --- /dev/null +++ b/test/spacePoll.test.js @@ -0,0 +1,227 @@ +import request from "supertest"; +import mongoose from "mongoose"; +import jwt from "jsonwebtoken"; +import { MongoMemoryServer } from "mongodb-memory-server"; +import app from "../app.js"; +import User from "../src/models/User.js"; +import Space from "../src/models/Space.js"; +import SpacePoll from "../src/models/space-poll.model.js"; +import PollVote from "../src/models/poll-vote.model.js"; + +const JWT_SECRET = process.env.JWT_SECRET; +const generateToken = (userId, role = "student") => { + return jwt.sign({ userId, role }, JWT_SECRET, { expiresIn: "1h" }); +}; + +describe("Space Polls API (#210)", () => { + let mongoServer; + let hostUser, participantUser, otherUser; + let hostToken, participantToken, otherToken; + let testSpace; + + beforeAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + }, 60000); + + afterAll(async () => { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (mongoServer) { + await mongoServer.stop(); + } + }); + + beforeEach(async () => { + await User.deleteMany({}); + await Space.deleteMany({}); + await SpacePoll.deleteMany({}); + await PollVote.deleteMany({}); + + hostUser = await User.create({ + name: "Host User", + email: "host@example.com", + password: "Password123!", + role: "mentor", + }); + + participantUser = await User.create({ + name: "Participant User", + email: "participant@example.com", + password: "Password123!", + role: "student", + }); + + otherUser = await User.create({ + name: "Other User", + email: "other@example.com", + password: "Password123!", + role: "student", + }); + + hostToken = generateToken(hostUser._id, "mentor"); + participantToken = generateToken(participantUser._id, "student"); + otherToken = generateToken(otherUser._id, "student"); + + testSpace = await Space.create({ + title: "Live Tafsir Session", + description: "Interactive Live Discussion", + category: "Quran", + host: hostUser._id, + eventDate: new Date(), + eventTime: "18:00", + duration: 60, + meetingRoom: "room-123-unique", + meetingUrl: "https://meet.jit.si/room-123-unique", + }); + }); + + it("allows host to create polls with multiple choice options", async () => { + const res = await request(app) + .post(`/api/spaces/${testSpace._id}/polls`) + .set("Authorization", `Bearer ${hostToken}`) + .send({ + question: "Which surah topic should we discuss next?", + options: ["Surah Yasin", "Surah Al-Kahf", "Surah Al-Mulk"], + }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + expect(res.body.poll.question).toBe("Which surah topic should we discuss next?"); + expect(res.body.poll.results.length).toBe(3); + expect(res.body.poll.status).toBe("active"); + }); + + it("prevents non-host from creating a poll in a space", async () => { + const res = await request(app) + .post(`/api/spaces/${testSpace._id}/polls`) + .set("Authorization", `Bearer ${participantToken}`) + .send({ + question: "Unauthorized poll?", + options: ["Yes", "No"], + }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it("allows participants to vote in real-time and displays live vote counts and percentages", async () => { + const pollRes = await request(app) + .post(`/api/spaces/${testSpace._id}/polls`) + .set("Authorization", `Bearer ${hostToken}`) + .send({ + question: "Best session time?", + options: ["Morning", "Evening"], + }); + + const pollId = pollRes.body.poll._id; + + const vote1 = await request(app) + .post(`/api/spaces/polls/${pollId}/vote`) + .set("Authorization", `Bearer ${participantToken}`) + .send({ optionIndex: 0 }); + + expect(vote1.status).toBe(200); + expect(vote1.body.poll.totalVotes).toBe(1); + expect(vote1.body.poll.results[0].votes).toBe(1); + expect(vote1.body.poll.results[0].percentage).toBe(100); + + const vote2 = await request(app) + .post(`/api/spaces/polls/${pollId}/vote`) + .set("Authorization", `Bearer ${otherToken}`) + .send({ optionIndex: 1 }); + + expect(vote2.status).toBe(200); + expect(vote2.body.poll.totalVotes).toBe(2); + expect(vote2.body.poll.results[0].percentage).toBe(50); + expect(vote2.body.poll.results[1].percentage).toBe(50); + }); + + it("closes poll to stop new votes", async () => { + const pollRes = await request(app) + .post(`/api/spaces/${testSpace._id}/polls`) + .set("Authorization", `Bearer ${hostToken}`) + .send({ + question: "Ready to proceed?", + options: ["Yes", "No"], + }); + + const pollId = pollRes.body.poll._id; + + const closeRes = await request(app) + .patch(`/api/spaces/polls/${pollId}/close`) + .set("Authorization", `Bearer ${hostToken}`); + + expect(closeRes.status).toBe(200); + expect(closeRes.body.poll.status).toBe("closed"); + + const voteRes = await request(app) + .post(`/api/spaces/polls/${pollId}/vote`) + .set("Authorization", `Bearer ${participantToken}`) + .send({ optionIndex: 0 }); + + expect(voteRes.status).toBe(400); + expect(voteRes.body.message).toContain("closed"); + }); + + it("exports poll results in JSON and CSV format", async () => { + const pollRes = await request(app) + .post(`/api/spaces/${testSpace._id}/polls`) + .set("Authorization", `Bearer ${hostToken}`) + .send({ + question: "Rating?", + options: ["Excellent", "Good"], + }); + + const pollId = pollRes.body.poll._id; + + await request(app) + .post(`/api/spaces/polls/${pollId}/vote`) + .set("Authorization", `Bearer ${participantToken}`) + .send({ optionIndex: 0 }); + + const jsonExport = await request(app) + .get(`/api/spaces/polls/${pollId}/export`) + .set("Authorization", `Bearer ${hostToken}`); + + expect(jsonExport.status).toBe(200); + expect(jsonExport.body.export.question).toBe("Rating?"); + expect(jsonExport.body.export.totalVotes).toBe(1); + + const csvExport = await request(app) + .get(`/api/spaces/polls/${pollId}/export?format=csv`) + .set("Authorization", `Bearer ${hostToken}`); + + expect(csvExport.status).toBe(200); + expect(csvExport.text).toContain("Option Index,Option Text,Votes,Percentage"); + }); + + it("supports multiple polls per space session", async () => { + await request(app) + .post(`/api/spaces/${testSpace._id}/polls`) + .set("Authorization", `Bearer ${hostToken}`) + .send({ + question: "Poll #1", + options: ["A", "B"], + }); + + await request(app) + .post(`/api/spaces/${testSpace._id}/polls`) + .set("Authorization", `Bearer ${hostToken}`) + .send({ + question: "Poll #2", + options: ["C", "D"], + }); + + const listRes = await request(app) + .get(`/api/spaces/${testSpace._id}/polls`) + .set("Authorization", `Bearer ${participantToken}`); + + expect(listRes.status).toBe(200); + expect(listRes.body.polls.length).toBe(2); + }); +}); diff --git a/test/stellarPaymentController.test.js b/test/stellarPaymentController.test.js index cee52db8..6f2e82d4 100644 --- a/test/stellarPaymentController.test.js +++ b/test/stellarPaymentController.test.js @@ -23,6 +23,7 @@ jest.unstable_mockModule("../src/services/stellar/stellarService.js", () => ({ STROOPS_PER_UNIT: 10000000n, toStroops: jest.fn(), fromStroops: jest.fn(), + resolveAsset: jest.fn(), applySlippage: jest.fn(), findPaymentPaths: jest.fn(), buildPathPaymentTransaction: jest.fn(), @@ -39,6 +40,7 @@ jest.unstable_mockModule("../src/services/stellar/stellarService.js", () => ({ submitTransaction, verifyTransaction: jest.fn(), verifyPaymentOperations, + validateSignedPaymentXdr: jest.fn(), hasUsdcTrustline: jest.fn(), getExplorerUrl, getAccountExplorerUrl: jest.fn(), @@ -201,7 +203,9 @@ describe("Stellar payment controller", () => { creator: creatorId, creatorWallet, status: "pending", - stellarTxHash: "expected-hash", + // #18: the pre-computed hash is stored as expectedHash at init for later + // XDR validation; stellarTxHash is only set after actual submission. + expectedHash: "expected-hash", }); expect(session.commitTransaction).toHaveBeenCalledTimes(1); expect(session.abortTransaction).not.toHaveBeenCalled(); @@ -331,6 +335,9 @@ describe("Stellar payment controller", () => { { session } ); expect(tx.status).toBe("confirmed"); + // Terminal state — the submit confirm path must leave the row without an + // expiry so the TTL reaper can never delete it. + expect(tx.expiresAt).toBeUndefined(); expect(session.commitTransaction).toHaveBeenCalledTimes(1); expect(session.abortTransaction).not.toHaveBeenCalled(); }); diff --git a/test/ttlTransactionExpiry.test.js b/test/ttlTransactionExpiry.test.js new file mode 100644 index 00000000..9b2738d5 --- /dev/null +++ b/test/ttlTransactionExpiry.test.js @@ -0,0 +1,256 @@ +import { jest } from "@jest/globals"; +import mongoose from "mongoose"; +import { MongoMemoryServer } from "mongodb-memory-server"; +import Transaction from "../src/models/Transaction.js"; +import User from "../src/models/User.js"; +import Book from "../src/models/Book.js"; +import { fixTtlTransactionExpiry } from "../src/migrations/fixTtlTransactionExpiry.js"; + +const TERMINAL_STATUSES = ["confirmed", "failed", "expired", "refunded", "disputed"]; + +const makeKey = (prefix) => { + const p = prefix.padEnd(55, "0").slice(0, 55).toUpperCase(); + return "G" + p; +}; + +describe("Transaction TTL expiry & lifecycle invariant", () => { + let mongoServer; + let buyer; + let author; + let book; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + }, 30000); + + afterAll(async () => { + await mongoose.disconnect(); + if (mongoServer) await mongoServer.stop(); + }); + + beforeEach(async () => { + await Promise.all([ + Transaction.deleteMany({}), + User.deleteMany({}), + Book.deleteMany({}), + ]); + + buyer = await User.create({ + name: "Buyer User", + email: "buyer_ttl@example.com", + password: "Qx7#vLmp92Zt", + stellarWallet: { publicKey: makeKey("BUYER") }, + }); + + author = await User.create({ + name: "Author User", + email: "author_ttl@example.com", + password: "Qx7#vLmp92Zt", + stellarWallet: { publicKey: makeKey("AUTHOR") }, + }); + + book = await Book.create({ + title: "TTL Test Book", + description: "Testing TTL invariants", + category: "Tech", + price: 15, + author: author._id, + thumbnail: "https://example.com/thumb.jpg", + image: "https://example.com/image.jpg", + fileUrl: "https://example.com/file.pdf", + }); + }); + + const baseFields = () => ({ + buyer: buyer._id, + buyerWallet: buyer.stellarWallet.publicKey, + creator: author._id, + creatorWallet: author.stellarWallet.publicKey, + itemType: "book", + itemId: book._id, + itemTypeModel: "Book", + itemTitle: book.title, + amount: "15", + network: "testnet", + }); + + describe("schema default", () => { + it("assigns a 30-minute expiresAt by default for pending transactions", async () => { + const tx = await Transaction.create({ + ...baseFields(), + status: "pending", + stellarTxHash: "pending-default-hash", + }); + + expect(tx.expiresAt).toBeInstanceOf(Date); + expect(tx.expiresAt.getTime()).toBeGreaterThan(Date.now()); + expect(tx.expiresAt.getTime()).toBeLessThanOrEqual( + Date.now() + 31 * 60 * 1000 + ); + }); + + it("does NOT assign expiresAt when created directly in a terminal state", async () => { + for (const status of TERMINAL_STATUSES) { + const tx = await Transaction.create({ + ...baseFields(), + status, + stellarTxHash: `terminal-${status}-hash`, + }); + expect(tx.expiresAt).toBeUndefined(); + } + }); + + it("keeps expiresAt for transient submitted/retrying states", async () => { + const pending = await Transaction.create({ + ...baseFields(), + status: "pending", + stellarTxHash: "transient-hash", + }); + + for (const status of ["submitted", "retrying"]) { + pending.status = status; + await pending.save(); + expect(pending.expiresAt).toBeInstanceOf(Date); + } + }); + }); + + describe("pre-save hook (defense in depth)", () => { + it("clears expiresAt when a document transitions to a terminal status", async () => { + const tx = await Transaction.create({ + ...baseFields(), + status: "pending", + stellarTxHash: "transition-hash", + }); + expect(tx.expiresAt).toBeInstanceOf(Date); + + // Simulate a code path that forgets to clear expiresAt — the hook must + // still rescue the row. + for (const status of TERMINAL_STATUSES) { + tx.status = status; + tx.expiresAt = new Date(Date.now() - 1000); // stale, in the past + await tx.save(); + expect(tx.expiresAt).toBeUndefined(); + } + }); + }); + + describe("migration", () => { + it("unsets expiresAt on legacy non-pending rows, keeps pending rows, and is idempotent", async () => { + // Simulate legacy rows that bypass hooks/defaults (as they were written + // before the invariant existed). + await Transaction.collection.insertOne({ + ...baseFields(), + stellarTxHash: "legacy-confirmed-hash", + status: "confirmed", + confirmedAt: new Date(), + expiresAt: new Date(Date.now() - 1000), // already past — reaper candidate + createdAt: new Date(), + updatedAt: new Date(), + }); + await Transaction.collection.insertOne({ + ...baseFields(), + stellarTxHash: "legacy-failed-hash", + status: "failed", + expiresAt: new Date(Date.now() + 5 * 60 * 1000), + createdAt: new Date(), + updatedAt: new Date(), + }); + const pending = await Transaction.create({ + ...baseFields(), + status: "pending", + stellarTxHash: "legacy-pending-hash", + }); + + const firstRun = await fixTtlTransactionExpiry(); + expect(firstRun.modifiedCount).toBe(2); + + const rescuedConfirmed = await Transaction.findOne({ + stellarTxHash: "legacy-confirmed-hash", + }); + expect(rescuedConfirmed).not.toBeNull(); + expect(rescuedConfirmed.expiresAt).toBeUndefined(); + + const rescuedFailed = await Transaction.findOne({ + stellarTxHash: "legacy-failed-hash", + }); + expect(rescuedFailed.expiresAt).toBeUndefined(); + + const keptPending = await Transaction.findById(pending._id); + expect(keptPending.expiresAt).toBeInstanceOf(Date); + + // Idempotent: a second run touches nothing. + const secondRun = await fixTtlTransactionExpiry(); + expect(secondRun.modifiedCount).toBe(0); + }); + + it("replaces a blanket TTL index with the pending-scoped partial index", async () => { + // Simulate the pre-fix DB state: blanket TTL index, no partial filter. + const collection = Transaction.collection; + const indexes = await collection.indexes(); + const existing = indexes.find((idx) => idx.key && idx.key.expiresAt === 1); + if (existing) { + await collection.dropIndex(existing.name); + } + await collection.createIndex( + { expiresAt: 1 }, + { expireAfterSeconds: 0 } + ); + + await fixTtlTransactionExpiry(); + + const after = await collection.indexes(); + const ttlIndex = after.find((idx) => idx.key && idx.key.expiresAt === 1); + expect(ttlIndex).toBeDefined(); + expect(ttlIndex.expireAfterSeconds).toBe(0); + expect(ttlIndex.partialFilterExpression).toEqual({ status: "pending" }); + + // Only one expiresAt index should remain after the swap. + const expiresAtIndexes = after.filter( + (idx) => idx.key && idx.key.expiresAt === 1 + ); + expect(expiresAtIndexes).toHaveLength(1); + }); + + it("leaves the already-correct partial index untouched (idempotent index handling)", async () => { + await fixTtlTransactionExpiry(); + + const after = await Transaction.collection.indexes(); + const ttlIndex = after.find((idx) => idx.key && idx.key.expiresAt === 1); + expect(ttlIndex.partialFilterExpression).toEqual({ status: "pending" }); + expect(ttlIndex.expireAfterSeconds).toBe(0); + }); + }); + + describe("TTL eligibility", () => { + it("exposes the pending-scoped partial index spec so the reaper can only match pending rows", async () => { + const indexes = await Transaction.collection.indexes(); + const ttlIndex = indexes.find((idx) => idx.key && idx.key.expiresAt === 1); + + expect(ttlIndex).toBeDefined(); + expect(ttlIndex.expireAfterSeconds).toBe(0); + expect(ttlIndex.partialFilterExpression).toEqual({ status: "pending" }); + }); + + it("survives past the original expiresAt: a confirmed row never carries an expiry", async () => { + // The regression this issue guards against: a confirmed transaction with + // an expiresAt in the past is eligible for deletion. With the schema + // default + pre-save hook, a confirmed row cannot even hold an expiry — + // and if legacy data still has one, the migration clears it. + const confirmed = await Transaction.create({ + ...baseFields(), + status: "confirmed", + stellarTxHash: "survival-hash", + }); + expect(confirmed.expiresAt).toBeUndefined(); + + await fixTtlTransactionExpiry(); + const persisted = await Transaction.findOne({ + stellarTxHash: "survival-hash", + }); + expect(persisted).not.toBeNull(); + expect(persisted.expiresAt).toBeUndefined(); + }); + }); +}); diff --git a/test/upload.test.js b/test/upload.test.js index 5ac84119..d803ab23 100644 --- a/test/upload.test.js +++ b/test/upload.test.js @@ -14,8 +14,20 @@ describe("Upload Routes", () => { let testUserId; beforeAll(async () => { - mongoServer = await MongoMemoryServer.create(); - await mongoose.connect(mongoServer.getUri()); + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } + if (process.env.MONGO_URI) { + try { + await mongoose.connect(`${process.env.MONGO_URI}_upload`, { serverSelectionTimeoutMS: 2000 }); + } catch (_err) { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + } + } else { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + } const { token: authToken, user } = await seedUserAndLogin(app, { name: "Uploader", diff --git a/test/webhooks.test.js b/test/webhooks.test.js new file mode 100644 index 00000000..96816a89 --- /dev/null +++ b/test/webhooks.test.js @@ -0,0 +1,715 @@ +// test/webhooks.test.js +// +// Offline (mocked-axios) suite for the outbound signed webhook system (#45). +// Every acceptance criterion is exercised: HMAC signing/verification, delivery +// row fan-out, backoff → dead-letter → redelivery, atomic claim (no double +// send), auto-disable, ping, transaction-commit-safe emission, the payload +// allowlist, and the admin-gated management API. No outbound network is used — +// the delivery worker's HTTP client is injected. + +// Worker constants are read from env at module load, so tune them BEFORE the +// dynamic import of deliveryWorker below. +process.env.WEBHOOK_MAX_ATTEMPTS = "3"; +process.env.WEBHOOK_BACKOFF_JITTER_MS = "0"; +process.env.WEBHOOK_AUTO_DISABLE_THRESHOLD = "2"; + +import { jest } from "@jest/globals"; +import express from "express"; +import request from "supertest"; +import crypto from "crypto"; +import jwt from "jsonwebtoken"; +import mongoose from "mongoose"; +import { MongoMemoryReplSet } from "mongodb-memory-server"; + +// ── Mocks for the payment controller's Stellar dependencies ───────────────── +// Full surface so the ESM linker can bind every named import in +// paymentController.js even though this suite only drives submitPayment. +const submitTransaction = jest.fn(); +const verifyPaymentOperations = jest.fn(); +const validateSignedPaymentXdr = jest.fn(); +const getExplorerUrl = jest.fn((h) => `https://stellar.expert/tx/${h}`); +const recordSaleEarnings = jest.fn(); +const grantItemAccess = jest.fn(); +const enqueue = jest.fn(); + +jest.unstable_mockModule("../src/services/stellar/stellarService.js", () => ({ + buildPaymentTransaction: jest.fn(), + buildPathPaymentTransaction: jest.fn(), + buildSep7Uri: jest.fn(), + calculateFeeSplit: jest.fn(() => null), + preflightPayment: jest.fn(), + PREFLIGHT_REASON_CODES: {}, + submitTransaction, + verifyTransaction: jest.fn(), + verifyPaymentOperations, + validateSignedPaymentXdr, + findPaymentPaths: jest.fn(), + applySlippage: jest.fn(), + NETWORK: "testnet", + networkPassphrase: "Test SDF Network ; September 2015", + getExplorerUrl, + USDC: "USDC", + PLATFORM_WALLET_PUBLIC_KEY: "", + // Exports pulled in transitively via feeSponsorService (#30) — mirror them + // so the ESM mock still satisfies every named import in the graph. + toStroops: jest.fn(), + resolveAsset: jest.fn(), + getAccountBalance: jest.fn(), + networkPassphrase: "Test SDF Network ; September 2015", +})); +jest.unstable_mockModule("../src/services/payoutService.js", () => ({ + recordSaleEarnings, +})); +jest.unstable_mockModule("../src/services/stellar/reconciliationService.js", () => ({ + grantItemAccess, +})); +jest.unstable_mockModule("../src/jobs/queue.js", () => ({ + enqueue, +})); + +// ── Dynamic imports (after env tuning + mock registration) ────────────────── +const WebhookEndpoint = (await import("../src/models/WebhookEndpoint.js")).default; +const WebhookDelivery = (await import("../src/models/WebhookDelivery.js")).default; +const User = (await import("../src/models/User.js")).default; +const Transaction = (await import("../src/models/Transaction.js")).default; + +const { signPayload, verifySignature, WEBHOOK_HEADERS } = await import( + "../src/services/webhooks/signing.js" +); +const { generateSecret, encryptSecret } = await import( + "../src/services/webhooks/webhookSecret.js" +); +const { emitEvent, buildEventEnvelope, sanitizeEventData, EVENT_TYPES } = + await import("../src/services/webhooks/webhookService.js"); +const { validateWebhookUrl, isPrivateAddress } = await import( + "../src/services/webhooks/urlGuard.js" +); +const worker = await import("../src/services/webhooks/deliveryWorker.js"); +const { submitPayment } = await import( + "../src/controllers/stellar/paymentController.js" +); +const webhookRoutes = (await import("../src/routes/webhookRoutes.js")).default; +const { errorHandler } = await import("../src/middlewares/errorHandler.js"); + +const JWT_SECRET = process.env.JWT_SECRET || "deenbridge-temp-secret-key-2024"; + +let mongoServer; + +beforeAll(async () => { + mongoServer = await MongoMemoryReplSet.create({ + replSet: { count: 1, storageEngine: "wiredTiger" }, + }); + await mongoose.connect(mongoServer.getUri()); +}, 60000); + +afterAll(async () => { + await mongoose.disconnect(); + if (mongoServer) await mongoServer.stop(); +}); + +beforeEach(async () => { + await Promise.all([ + WebhookEndpoint.deleteMany({}), + WebhookDelivery.deleteMany({}), + User.deleteMany({}), + Transaction.deleteMany({}), + ]); + jest.clearAllMocks(); +}); + +// ── Helpers ───────────────────────────────────────────────────────────────── +const KNOWN_SECRET = "test-secret-abcdef0123456789"; + +const createEndpointDoc = async (overrides = {}) => { + const owner = overrides.owner || new mongoose.Types.ObjectId(); + return WebhookEndpoint.create({ + url: "https://example.com/hook", + secretEncrypted: encryptSecret(overrides.secret || KNOWN_SECRET), + events: ["*"], + owner, + ...overrides, + secret: undefined, + }); +}; + +const captureClient = () => { + const calls = []; + const post = async (url, body, headers) => { + calls.push({ url, body, headers }); + return { status: 200 }; + }; + return { post, calls }; +}; + +// The controllers emit fire-and-forget (non-blocking), so delivery rows are +// inserted asynchronously after the handler responds. Poll for them to settle. +const waitForDeliveries = async (filter, count, timeout = 3000) => { + const start = Date.now(); + while (Date.now() - start < timeout) { + if ((await WebhookDelivery.countDocuments(filter)) >= count) return; + await new Promise((r) => setTimeout(r, 25)); + } +}; + +// ──────────────────────────────────────────────────────────────────────────── +describe("HMAC signing", () => { + it("generates a v1= signature that verifies against the documented scheme", () => { + const timestamp = Math.floor(Date.now() / 1000).toString(); + const rawBody = JSON.stringify({ hello: "world" }); + const header = signPayload({ secret: KNOWN_SECRET, timestamp, rawBody }); + + expect(header.startsWith("v1=")).toBe(true); + // Independently recompute the HMAC the way a consumer would. + const expected = + "v1=" + + crypto + .createHmac("sha256", KNOWN_SECRET) + .update(`${timestamp}.${rawBody}`) + .digest("hex"); + expect(header).toBe(expected); + expect( + verifySignature({ secret: KNOWN_SECRET, timestamp, rawBody, signatureHeader: header }) + ).toBe(true); + }); + + it("rejects a tampered body", () => { + const timestamp = Math.floor(Date.now() / 1000).toString(); + const rawBody = JSON.stringify({ amount: "10.00" }); + const header = signPayload({ secret: KNOWN_SECRET, timestamp, rawBody }); + const tampered = JSON.stringify({ amount: "9999.00" }); + expect( + verifySignature({ secret: KNOWN_SECRET, timestamp, rawBody: tampered, signatureHeader: header }) + ).toBe(false); + }); + + it("rejects a stale timestamp (> 5 min)", () => { + const stale = (Math.floor(Date.now() / 1000) - 600).toString(); + const rawBody = "{}"; + const header = signPayload({ secret: KNOWN_SECRET, timestamp: stale, rawBody }); + expect( + verifySignature({ secret: KNOWN_SECRET, timestamp: stale, rawBody, signatureHeader: header }) + ).toBe(false); + }); + + it("rejects a wrong secret", () => { + const timestamp = Math.floor(Date.now() / 1000).toString(); + const rawBody = "{}"; + const header = signPayload({ secret: KNOWN_SECRET, timestamp, rawBody }); + expect( + verifySignature({ secret: "other", timestamp, rawBody, signatureHeader: header }) + ).toBe(false); + }); +}); + +describe("Backoff schedule", () => { + it("follows 1m/5m/30m/2h/12h with zero jitter and caps at the last entry", () => { + expect(worker.computeBackoffMs(1)).toBe(60_000); + expect(worker.computeBackoffMs(2)).toBe(5 * 60_000); + expect(worker.computeBackoffMs(3)).toBe(30 * 60_000); + expect(worker.computeBackoffMs(4)).toBe(2 * 60 * 60_000); + expect(worker.computeBackoffMs(5)).toBe(12 * 60 * 60_000); + // Beyond the schedule length it stays at the max. + expect(worker.computeBackoffMs(9)).toBe(12 * 60 * 60_000); + }); +}); + +describe("Payload allowlist", () => { + it("strips non-allowlisted fields (emails, secrets, user docs)", () => { + const data = sanitizeEventData({ + transactionId: "tx1", + amount: "10.00", + email: "leak@example.com", + password: "hunter2", + user: { name: "x", passwordHash: "y" }, + }); + expect(data).toEqual({ transactionId: "tx1", amount: "10.00" }); + expect(data.email).toBeUndefined(); + expect(data.password).toBeUndefined(); + }); + + it("envelope carries eventId/type/createdAt/apiVersion and sanitized data", () => { + const env = buildEventEnvelope(EVENT_TYPES.PAYMENT_CONFIRMED, { + transactionId: "tx2", + email: "nope@example.com", + }); + expect(env.eventId).toEqual(expect.any(String)); + expect(env.type).toBe("payment.confirmed"); + expect(env.createdAt).toEqual(expect.any(String)); + expect(env.apiVersion).toEqual(expect.any(String)); + expect(env.data).toEqual({ transactionId: "tx2" }); + }); +}); + +describe("SSRF url guard", () => { + it("classifies private/loopback/link-local addresses", () => { + expect(isPrivateAddress("127.0.0.1")).toBe(true); + expect(isPrivateAddress("10.1.2.3")).toBe(true); + expect(isPrivateAddress("172.16.0.9")).toBe(true); + expect(isPrivateAddress("192.168.1.1")).toBe(true); + expect(isPrivateAddress("169.254.1.1")).toBe(true); + expect(isPrivateAddress("8.8.8.8")).toBe(false); + }); + + it("rejects non-https and private literal targets at registration", () => { + expect(() => validateWebhookUrl("http://example.com/x")).toThrow(); + expect(() => validateWebhookUrl("https://127.0.0.1/x")).toThrow(); + expect(() => validateWebhookUrl("https://localhost/x")).toThrow(); + expect(() => validateWebhookUrl("https://10.0.0.1/x")).toThrow(); + // A public https target is accepted. + expect(validateWebhookUrl("https://hooks.example.com/x")).toBeInstanceOf(URL); + }); +}); + +describe("emitEvent fan-out", () => { + it("creates one pending delivery per subscribed active endpoint", async () => { + const owner = new mongoose.Types.ObjectId(); + const subscribed = await createEndpointDoc({ owner, events: ["payment.confirmed"] }); + const wildcard = await createEndpointDoc({ owner, events: ["*"] }); + await createEndpointDoc({ owner, events: ["course.enrolled"] }); // not matching + await createEndpointDoc({ owner, events: ["*"], isActive: false }); // inactive + + const { deliveries } = await emitEvent(EVENT_TYPES.PAYMENT_CONFIRMED, { + transactionId: "tx1", + email: "leak@example.com", + }); + + expect(deliveries).toBe(2); + const rows = await WebhookDelivery.find({}).sort({ endpoint: 1 }); + expect(rows).toHaveLength(2); + const endpointIds = rows.map((r) => r.endpoint.toString()).sort(); + expect(endpointIds).toEqual([subscribed._id.toString(), wildcard._id.toString()].sort()); + for (const row of rows) { + expect(row.status).toBe("pending"); + expect(row.eventType).toBe("payment.confirmed"); + expect(row.payload.data.email).toBeUndefined(); + expect(row.payload.data.transactionId).toBe("tx1"); + } + }); + + it("returns without throwing when no endpoints match", async () => { + const res = await emitEvent(EVENT_TYPES.WALLET_CONNECTED, { userId: "u1" }); + expect(res.deliveries).toBe(0); + }); +}); + +describe("Delivery worker: signing over the wire", () => { + it("POSTs a signed body whose HMAC verifies, then marks delivered", async () => { + await createEndpointDoc({ events: ["*"], secret: KNOWN_SECRET }); + await emitEvent(EVENT_TYPES.PAYMENT_CONFIRMED, { transactionId: "tx1", amount: "5.00" }); + + const client = captureClient(); + const now = new Date(); + const delivery = await worker.processOne({ post: client.post, now }); + + expect(delivery.status).toBe("delivered"); + expect(client.calls).toHaveLength(1); + const { body, headers } = client.calls[0]; + expect(headers[WEBHOOK_HEADERS.EVENT]).toBe("payment.confirmed"); + expect(headers[WEBHOOK_HEADERS.EVENT_ID]).toEqual(expect.any(String)); + + // Verify exactly the bytes that were sent, using the known plaintext secret. + const ok = verifySignature({ + secret: KNOWN_SECRET, + timestamp: headers[WEBHOOK_HEADERS.TIMESTAMP], + rawBody: body, + signatureHeader: headers[WEBHOOK_HEADERS.SIGNATURE], + }); + expect(ok).toBe(true); + }); +}); + +describe("Delivery worker: retry → dead-letter → redeliver", () => { + const post500 = async () => ({ status: 500 }); + + it("retries on the backoff schedule then dead-letters after max attempts", async () => { + await createEndpointDoc({ events: ["*"] }); + await emitEvent(EVENT_TYPES.PAYMENT_FAILED, { transactionId: "tx1" }); + + const t0 = new Date(); + // Attempt 1 → retrying, nextAttemptAt = t0 + 1m + let d = await worker.processOne({ post: post500, now: t0 }); + expect(d.status).toBe("retrying"); + expect(d.attemptCount).toBe(1); + expect(d.nextAttemptAt.getTime()).toBe(t0.getTime() + 60_000); + + // Attempt 2 → retrying, nextAttemptAt = t1 + 5m + const t1 = new Date(t0.getTime() + 60_000); + d = await worker.processOne({ post: post500, now: t1 }); + expect(d.status).toBe("retrying"); + expect(d.attemptCount).toBe(2); + expect(d.nextAttemptAt.getTime()).toBe(t1.getTime() + 5 * 60_000); + + // Attempt 3 → dead (MAX_ATTEMPTS=3 for this suite) + const t2 = new Date(t1.getTime() + 5 * 60_000); + d = await worker.processOne({ post: post500, now: t2 }); + expect(d.status).toBe("dead"); + expect(d.attemptCount).toBe(3); + expect(d.attempts.length).toBe(3); + }); + + it("a dead delivery can be redelivered and then succeeds", async () => { + await createEndpointDoc({ events: ["*"], secret: KNOWN_SECRET }); + await emitEvent(EVENT_TYPES.PAYMENT_FAILED, { transactionId: "tx1" }); + + // Drive to dead. + let now = new Date(); + for (let i = 0; i < 5; i++) { + const cur = await WebhookDelivery.findOne({}); + if (cur.status === "dead") break; + now = new Date(now.getTime() + 13 * 60 * 60 * 1000); + await worker.processOne({ post: post500, now }); + } + let d = await WebhookDelivery.findOne({}); + expect(d.status).toBe("dead"); + + // Redeliver: dead → pending, nextAttemptAt = now. + d.status = "pending"; + d.nextAttemptAt = new Date(); + await d.save(); + + const client = captureClient(); + const redelivered = await worker.processOne({ post: client.post, now: new Date() }); + expect(redelivered.status).toBe("delivered"); + expect(client.calls).toHaveLength(1); + }); +}); + +describe("Delivery worker: atomic claim", () => { + it("two concurrent claims never grab the same row (exactly one send)", async () => { + await createEndpointDoc({ events: ["*"] }); + await emitEvent(EVENT_TYPES.PING, { message: "ping" }); + + const now = new Date(); + const [a, b] = await Promise.all([ + worker.claimNextDelivery(now), + worker.claimNextDelivery(now), + ]); + + const claimed = [a, b].filter(Boolean); + expect(claimed).toHaveLength(1); + }); + + it("runDueDeliveries sends each due row exactly once", async () => { + const owner = new mongoose.Types.ObjectId(); + await createEndpointDoc({ owner, events: ["*"] }); + await createEndpointDoc({ owner, events: ["*"] }); + await emitEvent(EVENT_TYPES.PAYMENT_CONFIRMED, { transactionId: "tx1" }); + + const client = captureClient(); + const processed = await worker.runDueDeliveries({ post: client.post, now: new Date() }); + expect(processed).toBe(2); + expect(client.calls).toHaveLength(2); + }); +}); + +describe("Delivery worker: auto-disable after sustained failures", () => { + const post500 = async () => ({ status: 500 }); + + it("disables the endpoint after the consecutive-dead threshold", async () => { + const endpoint = await createEndpointDoc({ events: ["*"] }); + + const driveOneToDead = async () => { + let now = new Date(); + for (let i = 0; i < 5; i++) { + const d = await WebhookDelivery.findOne({ status: { $in: ["pending", "retrying"] } }); + if (!d) break; + now = new Date(now.getTime() + 13 * 60 * 60 * 1000); + await worker.processOne({ post: post500, now }); + } + }; + + // Threshold is 2 for this suite: two dead deliveries → disabled. + await emitEvent(EVENT_TYPES.PAYMENT_FAILED, { transactionId: "tx1" }); + await driveOneToDead(); + let ep = await WebhookEndpoint.findById(endpoint._id); + expect(ep.isActive).toBe(true); + expect(ep.consecutiveFailures).toBe(1); + + await emitEvent(EVENT_TYPES.PAYMENT_FAILED, { transactionId: "tx2" }); + await driveOneToDead(); + ep = await WebhookEndpoint.findById(endpoint._id); + expect(ep.isActive).toBe(false); + expect(ep.consecutiveFailures).toBeGreaterThanOrEqual(2); + expect(ep.disabledReason).toMatch(/consecutive/i); + }); +}); + +// ── submitPayment emitter wiring + commit-safe emission ───────────────────── +const makeRes = () => { + const res = { statusCode: 200 }; + res.status = (c) => { + res.statusCode = c; + return res; + }; + res.json = (b) => { + res.body = b; + return res; + }; + return res; +}; + +const makePendingTransaction = async (buyerId) => + Transaction.create({ + buyer: buyerId, + buyerWallet: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + creator: new mongoose.Types.ObjectId(), + creatorWallet: "GCKFBEIYTKPXL5UIRZ5OO3KSOFDP5D4R6YGNFWEQSIFGKWO3EZ5F3TGI", + itemType: "course", + itemId: new mongoose.Types.ObjectId(), + itemTypeModel: "Course", + itemTitle: "Intro to Tajweed", + amount: "10.00", + currency: "USDC", + network: "testnet", + status: "pending", + }); + +describe("submitPayment emitter wiring", () => { + it("emits payment.confirmed after the txn commits, without blocking the response", async () => { + await createEndpointDoc({ events: ["payment.confirmed"] }); + const buyerId = new mongoose.Types.ObjectId(); + const tx = await makePendingTransaction(buyerId); + + validateSignedPaymentXdr.mockReturnValue(true); + submitTransaction.mockResolvedValue({ hash: "STELLARHASH", ledger: 999 }); + verifyPaymentOperations.mockResolvedValue({ verified: true }); + + const req = { + user: { _id: buyerId }, + body: { transactionId: tx._id.toString(), signedXdr: "AAAA" }, + ip: "127.0.0.1", + headers: {}, + id: "req-1", + }; + const res = makeRes(); + await submitPayment(req, res); + + expect(res.statusCode).toBe(200); + await waitForDeliveries({ eventType: "payment.confirmed" }, 1); + const rows = await WebhookDelivery.find({ eventType: "payment.confirmed" }); + expect(rows).toHaveLength(1); + expect(rows[0].payload.data.transactionId).toBe(tx._id.toString()); + expect(rows[0].payload.data.stellarTxHash).toBe("STELLARHASH"); + expect(rows[0].status).toBe("pending"); + }); + + it("emits nothing when the txn is rolled back (unknown transaction → 404)", async () => { + await createEndpointDoc({ events: ["*"] }); + const req = { + user: { _id: new mongoose.Types.ObjectId() }, + body: { transactionId: new mongoose.Types.ObjectId().toString(), signedXdr: "AAAA" }, + ip: "127.0.0.1", + headers: {}, + id: "req-2", + }; + const res = makeRes(); + await submitPayment(req, res); + + expect(res.statusCode).toBe(404); + // Give any (erroneous) fire-and-forget emit a chance to land, then confirm none did. + await new Promise((r) => setTimeout(r, 150)); + expect(await WebhookDelivery.countDocuments({})).toBe(0); + }); + + it("does not block the payment path when the endpoint is unreachable", async () => { + await createEndpointDoc({ events: ["*"], url: "https://unreachable.example.com/hook" }); + const buyerId = new mongoose.Types.ObjectId(); + const tx = await makePendingTransaction(buyerId); + + validateSignedPaymentXdr.mockReturnValue(true); + submitTransaction.mockResolvedValue({ hash: "HASH2", ledger: 1000 }); + verifyPaymentOperations.mockResolvedValue({ verified: true }); + + const req = { + user: { _id: buyerId }, + body: { transactionId: tx._id.toString(), signedXdr: "AAAA" }, + ip: "127.0.0.1", + headers: {}, + id: "req-3", + }; + const res = makeRes(); + const start = Date.now(); + await submitPayment(req, res); + // The request completes promptly — delivery happens out of band. + expect(Date.now() - start).toBeLessThan(5000); + expect(res.statusCode).toBe(200); + await waitForDeliveries({}, 1); + const rows = await WebhookDelivery.find({}); + expect(rows).toHaveLength(1); + expect(rows[0].status).toBe("pending"); + }); +}); + +// ── Management API (admin-gated), driven through the real router ──────────── +const buildApp = () => { + const app = express(); + app.use(express.json()); + app.use("/api/webhooks", webhookRoutes); + app.use(errorHandler); + return app; +}; + +const makeUser = async (role) => + User.create({ + name: `${role} user`, + email: `${role}_${new mongoose.Types.ObjectId()}@example.com`, + password: "Qx7#vLmp92Zt", + role, + twoFactor: role === "admin" ? { enabled: true, secret: "MOCKSECRET", enrolledAt: new Date() } : { enabled: false }, + }); + +const tokenFor = (user) => + jwt.sign( + { + userId: user._id, + role: user.role, + sessionId: "s1", + is2FAVerified: user.role === "admin" ? true : false, + }, + JWT_SECRET, + { + expiresIn: "15m", + } + ); + +describe("Management API", () => { + let app; + let admin; + let adminToken; + + beforeEach(async () => { + app = buildApp(); + admin = await makeUser("admin"); + adminToken = tokenFor(admin); + }); + + it("rejects unauthenticated callers with 401", async () => { + const res = await request(app).get("/api/webhooks"); + expect(res.status).toBe(401); + }); + + it("rejects non-admin callers with 403", async () => { + const student = await makeUser("student"); + const res = await request(app) + .get("/api/webhooks") + .set("Authorization", `Bearer ${tokenFor(student)}`); + expect(res.status).toBe(403); + }); + + it("creates an endpoint and returns the secret exactly once; reads never expose it", async () => { + const create = await request(app) + .post("/api/webhooks") + .set("Authorization", `Bearer ${adminToken}`) + .send({ url: "https://hooks.example.com/x", events: ["payment.confirmed"] }); + + expect(create.status).toBe(201); + expect(create.body.secret).toEqual(expect.any(String)); + expect(create.body.endpoint.secretEncrypted).toBeUndefined(); + const id = create.body.endpoint._id; + + const read = await request(app) + .get(`/api/webhooks/${id}`) + .set("Authorization", `Bearer ${adminToken}`); + expect(read.status).toBe(200); + expect(read.body.endpoint.secret).toBeUndefined(); + expect(read.body.endpoint.secretEncrypted).toBeUndefined(); + + const list = await request(app) + .get("/api/webhooks") + .set("Authorization", `Bearer ${adminToken}`); + expect(list.body.endpoints[0].secretEncrypted).toBeUndefined(); + }); + + it("rejects non-https and private-network URLs at registration", async () => { + const nonHttps = await request(app) + .post("/api/webhooks") + .set("Authorization", `Bearer ${adminToken}`) + .send({ url: "http://hooks.example.com/x" }); + expect(nonHttps.status).toBe(400); + + const priv = await request(app) + .post("/api/webhooks") + .set("Authorization", `Bearer ${adminToken}`) + .send({ url: "https://10.0.0.1/x" }); + expect(priv.status).toBe(400); + }); + + it("rotates the secret and returns a new one", async () => { + const create = await request(app) + .post("/api/webhooks") + .set("Authorization", `Bearer ${adminToken}`) + .send({ url: "https://hooks.example.com/x" }); + const id = create.body.endpoint._id; + + const rotate = await request(app) + .post(`/api/webhooks/${id}/rotate-secret`) + .set("Authorization", `Bearer ${adminToken}`); + expect(rotate.status).toBe(200); + expect(rotate.body.secret).toEqual(expect.any(String)); + expect(rotate.body.secret).not.toBe(create.body.secret); + }); + + it("pings an endpoint, creating a signed ping delivery that verifies", async () => { + const create = await request(app) + .post("/api/webhooks") + .set("Authorization", `Bearer ${adminToken}`) + .send({ url: "https://hooks.example.com/x" }); + const id = create.body.endpoint._id; + const secret = create.body.secret; + + const ping = await request(app) + .post(`/api/webhooks/${id}/ping`) + .set("Authorization", `Bearer ${adminToken}`); + expect(ping.status).toBe(202); + + const rows = await WebhookDelivery.find({ eventType: "ping" }); + expect(rows).toHaveLength(1); + + // Deliver it and verify the signature with the created secret. + const client = captureClient(); + await worker.processOne({ post: client.post, now: new Date() }); + expect(client.calls).toHaveLength(1); + const { body, headers } = client.calls[0]; + expect( + verifySignature({ + secret, + timestamp: headers[WEBHOOK_HEADERS.TIMESTAMP], + rawBody: body, + signatureHeader: headers[WEBHOOK_HEADERS.SIGNATURE], + }) + ).toBe(true); + }); + + it("lists deliveries filtered by status and redelivers a dead one", async () => { + const create = await request(app) + .post("/api/webhooks") + .set("Authorization", `Bearer ${adminToken}`) + .send({ url: "https://hooks.example.com/x" }); + const id = create.body.endpoint._id; + + // Seed a dead delivery directly. + const dead = await WebhookDelivery.create({ + endpoint: id, + eventId: crypto.randomUUID(), + eventType: "payment.confirmed", + payload: { data: {} }, + status: "dead", + attemptCount: 3, + nextAttemptAt: new Date(), + }); + + const list = await request(app) + .get(`/api/webhooks/${id}/deliveries?status=dead`) + .set("Authorization", `Bearer ${adminToken}`); + expect(list.status).toBe(200); + expect(list.body.deliveries).toHaveLength(1); + + const redeliver = await request(app) + .post(`/api/webhooks/${id}/deliveries/${dead._id}/redeliver`) + .set("Authorization", `Bearer ${adminToken}`); + expect(redeliver.status).toBe(200); + + const updated = await WebhookDelivery.findById(dead._id); + expect(updated.status).toBe("pending"); + }); +});