From 165279aaf9c4fb648d49c8652485098986563fee Mon Sep 17 00:00:00 2001 From: Sakariyah Abdulhazeem <150973162+zeemscript@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:19:34 +0100 Subject: [PATCH 01/24] Merge dev into main (#117) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * stellar: validate signed XDR contents before submit; store expectedHa… (#51) * stellar: validate signed XDR contents before submit; store expectedHash/memo; verify on-chain before granting access; add tests * test: add validateSignedPaymentXdr to stellarService mock (new import in paymentController) * test: expect expectedHash at payment init (XDR pre-validation stores it there) --------- Co-authored-by: zeemscript <150973162+zeemscript@users.noreply.github.com> * feat(stellar): publish Soroban giving-escrow contract id in stellar.toml Adds env-driven GIVING_ESCROW_CONTRACT (custom, non-SEP-1) so the deployed On-Chain Giving escrow is discoverable from /.well-known/stellar.toml. Set GIVING_ESCROW_CONTRACT_ID to enable. Includes test coverage. * fix(stellar): resolve Horizon endpoints lazily + network-aware default Horizon client was constructed at import time with a hardcoded testnet fallback, so a mainnet deploy with HORIZON_URLS unset would silently talk to testnet Horizon. Now the default is derived from STELLAR_NETWORK and the client is built lazily on first use (after env is loaded). Explicit HORIZON_URLS still wins. * feat(auth): authenticated change-password endpoint PUT /api/auth/change-password (protected): verifies current password, enforces the password policy, updates the hash, and signs out all other sessions. Adds the auth.password_change audit action. * feat(auth): harden authentication against login lockout, breached passwords, and signup abuse (#89) (#99) * feat(auth): harden authentication against login lockout, breached passwords, and signup abuse (#89) - Add progressive per-account login lockout (failedLoginAttempts + lockUntil) with env-configurable escalating backoff, generic 429 while locked, and AUTH_ACCOUNT_LOCKED audit emission. - Add HaveIBeenPwned breached-password check (SHA-1 prefix k-anonymity, never transmits the password) at register and password reset; fails open on outage. - Add per-email throttling on /register and /resend-verification (emailAuthLimiter, survives IP rotation, active in test env) plus a pluggable captcha gate (no-op when unconfigured). - Add User lockout fields and document new env vars in .env.example. * fix(auth): address CodeRabbit review on login lockout, HIBP, captcha, and JWT secret (#89) - loginUser: locked accounts now return the same generic 401 'Invalid credentials' as a nonexistent account (no enumeration); failed-login counter incremented atomically via findByIdAndUpdate \, lock persisted via updateOne - resetPassword: breached-password check moved to after successful OTP validation so unauthenticated callers cannot trigger HIBP lookups - authController: refuse to start when JWT_SECRET is missing (no hardcoded fallback) - hibp: request HIBP padding ('Add-Padding: true') and ignore zero-count records - captcha: configurable CAPTCHA_TIMEOUT_MS (default 5s) passed to the provider call; captcha rejection now returns the standard { success, message, data: null } shape - tests: assert escalating backoff magnitude (120s on 6th failure) and the 24h cap; locked-account test expects 401; per-email limiter buckets reset between tests; outage test routed through mockHibp so the shared spy is cleaned up; added padding-record and cap coverage * Feat/93 idempotency keys (#100) * feat(stellar): publish Soroban giving-escrow contract id in stellar.toml Adds env-driven GIVING_ESCROW_CONTRACT (custom, non-SEP-1) so the deployed On-Chain Giving escrow is discoverable from /.well-known/stellar.toml. Set GIVING_ESCROW_CONTRACT_ID to enable. Includes test coverage. * fix(stellar): resolve Horizon endpoints lazily + network-aware default Horizon client was constructed at import time with a hardcoded testnet fallback, so a mainnet deploy with HORIZON_URLS unset would silently talk to testnet Horizon. Now the default is derived from STELLAR_NETWORK and the client is built lazily on first use (after env is loaded). Explicit HORIZON_URLS still wins. * feat(auth): authenticated change-password endpoint PUT /api/auth/change-password (protected): verifies current password, enforces the password policy, updates the hash, and signs out all other sessions. Adds the auth.password_change audit action. * feat(payment): add request-level idempotency keys to payment endpoints (#93) * test: complement stellarService mock exports in idempotency test * test: refine idempotency middleware concurrency lock test (#93) * fix(stellar): export validateSignedPaymentXdr and complement test mock (#93) * fix(stellar): remove duplicate validateSignedPaymentXdr export (#93) --------- Co-authored-by: zeemscript <150973162+zeemscript@users.noreply.github.com> * feat(auth): enforce resource ownership across mutating endpoints (#88) (#105) Add a centralized authorization layer that verifies the authenticated user owns the target resource (or is an admin) before any mutating handler runs, replacing the ad-hoc inline checks scattered across controllers. - add authorizeOwnership + authorizeReviewOwnership middleware (src/middlewares/authorize.js); on success the loaded doc is attached to req so handlers can reuse it - apply the guards to book delete, course update, space update/delete, and review update/delete on books and courses; review create stays purchase-gated - record ownership denials to the audit log (authz.ownership.denied) - remove the now-redundant inline ownership checks from the book, course, space, and review controllers - document the resource x action x role matrix (docs/authorization-matrix.md) and cover it with an integration test suite (test/ownershipAuthz.test.js) Closes #88 Co-authored-by: BountySpaghetti <286941608+BountySpaghetti@users.noreply.github.com> * fix(security): stop logging OTP codes and verification tokens in email bodies (#104) The NODE_ENV === "test" branch of sendMail logged the full rendered email body — including the password-reset OTP span and the verification link's token query param — and pino's redact config cannot censor values baked into interpolated strings, so the leak bypassed the app-wide redaction. Remove the body from every log statement (log only recipient, subject, and template id via structured fields), and give tests a sanctioned in-memory outbox hook instead of log scraping. sendOtpEmail/sendVerificationEmail/ sendReceiptEmail now return the sendMail result so callers can capture it. Closes #95 * fix(transactions): prevent TTL index from deleting confirmed purchases and donations (#103) The Transaction collection used a blanket TTL index on expiresAt with a schema default that stamped a 30-minute expiry on every row regardless of status. Because confirm paths never cleared expiresAt, confirmed on-chain purchases and donations were permanently reaped ~30 minutes after creation, deleting the proof of payment and orphaning recorded earnings. Scope the TTL index to status: "pending" via partialFilterExpression, make the expiresAt default conditional on status, add a pre-save hook that clears expiresAt for any terminal state, explicitly unset expiresAt on every terminal transition (submit, donation, refund, dispute, cancel, job handler, reconciliation promotion), and add an idempotent migration that rescues legacy non-pending rows and rebuilds the index. Closes #94 * feat(security): implement educator verification pipeline and content-creation gating (#92) (#102) * feat(security): implement educator verification pipeline and content-creation gating (#92) - Add EducatorVerification model with legal state-machine transitions (draft -> pending -> approved/rejected, resubmit from rejected) - Add verifiedEducator durable flag on User, set atomically on approval - Add AUDIT_ACTIONS: EDUCATOR_VERIFY_SUBMIT/_RESUBMIT/_APPROVE/_REJECT with metadata allowlist entries in auditService - requireVerifiedEducator middleware (403 for unverified, admin bypass) - Applicant API: submit/resubmit app, get own app, signed doc URLs, signed Cloudinary upload-signature for private credential uploads - Admin review queue: list+filter pending, view signed docs, approve/reject with notes (Mongo transaction for verifiedEducator grant) - Gate all content-creation routes: * POST /api/courses (courseRoutes.js) * POST /api/books (bookRoutes.js) * POST /api/spaces (spaceRoutes.js — live sessions per issue) - Wire routes: /api/educator-verification + /api/admin/educator-verification - Comprehensive test suite in test/educatorVerification.test.js (state-machine, middleware gating, submit/resubmit, approve/reject, content 403/2xx, both full lifecycles submit->pending->approve and reject->resubmit->approve, signed URL security, admin-only gating, audit log instrumentation) Verification Results: app.test.js: 22/22 PASS (CI boot + endpoint health) auditLog.test.js: 21/21 PASS (append-only, redaction, admin gate) Closes #92 * fix(ci): resolve educator verification pipeline test failures - Remove redundant catchAsync double-wrap in educator-verification routes (controllers are already pre-wrapped; the outer wrap called .catch() on undefined, returning 500 for every new endpoint) - Use MongoMemoryReplSet in educatorVerification.test.js so the approve/reject MongoDB transaction can run (standalone MongoMemoryServer cannot) - Use AuditLog.collection.deleteMany in test cleanup to bypass append-only pre-hooks - Return recordAudit's promise so callers can await durability; await it in submitApplication and performReview to eliminate the fire-and-forget audit-race in tests - Fix testAuth.js password overwrite: destructure password out of the override spread so the hashed value is not clobbered by plaintext - Seed bookUpload test user as a verifiedEducator mentor so the new content gate lets it through --------- Co-authored-by: abimbolaalabi * feat(auth): signed service-to-service authentication for the AI service (#91) (#106) Give the backend a real machine-to-machine auth channel for the AI service (dnb-ai) — signed, scoped, rotatable keys instead of a single static shared secret. - add requireServiceAuth middleware (src/middlewares/serviceAuth.js): HMAC-SHA256 over a canonical method/path/timestamp/body-digest string, a ±300s replay window, constant-time signature comparison, per-key scope enforcement, and req.service on success - key store (src/config/serviceKeys.js): multiple active keys keyed by kid for zero-downtime rotation; resilient env parsing, never throws - mount a real internal route GET /api/internal/ai/whoami guarded by the guard (scope ai:read-content), plus raw-body capture in app.js - migrate /admin/jobs off raw !== to a length-guarded timingSafeEqual - audit denials (service_auth.denied), require AI_SERVICE_KEYS in prod (fail-fast), document the signing contract + rotation runbook - cover the full accept/reject matrix in test/serviceAuth.test.js Closes #91 Co-authored-by: Lspnjr1 <304024794+Lspnjr1@users.noreply.github.com> * feat(webhooks): signed outbound webhook event system (#45) (#107) Add an outbound webhook/event system so external consumers can subscribe to payment and enrollment lifecycle events over HMAC-signed HTTP callbacks, with retries, dead-lettering, and redelivery. - models: WebhookEndpoint (encrypted secret at rest, subscribed events, auto-disable counters) and WebhookDelivery (all scheduling state in the doc: status, attemptCount, nextAttemptAt indexed) - webhookService.emitEvent: typed event catalog, per-event id for consumer idempotency, strict payload allowlist (no secrets/emails/user docs); persists a delivery per subscribed endpoint after the txn commits, never blocks or fails the request path, no-ops when the DB is unavailable - signing: X-DeenBridge-Signature v1=hmac-sha256(secret, `${ts}.${body}`) over the exact sent bytes; timing-safe verify + 5-min staleness window - deliveryWorker: atomic findOneAndUpdate claim (no double-send), exponential backoff + jitter, dead-letter after max attempts, endpoint auto-disable after sustained failures - management API (/api/webhooks, admin-gated): endpoint CRUD, rotate secret, list deliveries, redeliver (atomic $set), and ping - SSRF guard: https-only in prod, reject loopback/RFC-1918/link-local - wire emitters into payment (initialized/confirmed/failed/expired), enrollment, and wallet connect/disconnect; migrate /admin/jobs to a timing-safe token compare - docs/webhooks.md consumer verifier + full offline test suite Closes #45 Co-authored-by: Lspnjr1 <304024794+Lspnjr1@users.noreply.github.com> * feat(security): implement TOTP two-factor authentication for admins a… (#98) * feat(stellar): publish Soroban giving-escrow contract id in stellar.toml Adds env-driven GIVING_ESCROW_CONTRACT (custom, non-SEP-1) so the deployed On-Chain Giving escrow is discoverable from /.well-known/stellar.toml. Set GIVING_ESCROW_CONTRACT_ID to enable. Includes test coverage. * fix(stellar): resolve Horizon endpoints lazily + network-aware default Horizon client was constructed at import time with a hardcoded testnet fallback, so a mainnet deploy with HORIZON_URLS unset would silently talk to testnet Horizon. Now the default is derived from STELLAR_NETWORK and the client is built lazily on first use (after env is loaded). Explicit HORIZON_URLS still wins. * feat(auth): authenticated change-password endpoint PUT /api/auth/change-password (protected): verifies current password, enforces the password policy, updates the hash, and signs out all other sessions. Adds the auth.password_change audit action. * feat(security): implement TOTP two-factor authentication for admins and mentors - Add TOTP (RFC 6238) lifecycle: secret generation, encrypted storage at rest (AES-256-GCM), otpauth:// URI and QR code generation - Add 10 single-use bcrypt-hashed recovery codes - Add two-factor rate-limiting middleware (5 verification attempts per 15-minute window) - Update login controller to issue step-up mfaToken challenges when 2FA is enabled - Update authorizeRoles('admin') middleware to enforce 2FA-enabled status and 2FA-verified sessions - Log audit actions for all 2FA lifecycle events (setup, enable, login challenge, success, failure, disable, recovery code usage) - Add comprehensive test suite in test/auth2FA.test.js and update existing auth test suites * ci: update node version to 22 and sync package-lock.json * ci: pin mongo service to 6.0 and add wait-for-mongodb step * test: add 2FA enablement and 2FA verified token to admin in refund.test.js * fix(auth): switch bcrypt imports to pure-JS bcryptjs for cross-platform compatibility * fix(test): remove duplicate MongoMemoryServer import in refund.test.js * fix(test): add errorHandler middleware to refund.test.js app * fix(test): clean up MongoDB connection logic and add afterAll hook in refund.test.js * fix(test): standardize Mongoose connection lifecycle and add missing afterAll hooks * fix(test): remove duplicate afterAll hooks and handle Multer 413 response status * test(refund): explicitly set 2FA enablement and 2FA verified tokens for admin in refund.test.js * test: ensure admin test helpers include 2FA enablement and 2FA verified JWT claims * fix(test): add 2FA enablement and 2FA verified tokens for admin in webhooks and educatorVerification tests --------- Co-authored-by: zeemscript <150973162+zeemscript@users.noreply.github.com> * Add scholarship escrow contract foundation (#108) * Improve application test coverage (#110) * Add dependency health checks (#112) * Validate auth and Stellar requests (#109) * Validate auth and Stellar requests * Address validation review feedback * Secure book deletion authorization (#113) * Secure book deletion * Keep delete response consistent * feat(stellar): gift courses/books via claimable balances with expiry reclaim (#116) Adds a gift-a-course/book flow built on Stellar claimable balances so a buyer can send an item to another user — including one who has not finished wallet onboarding — without the recipient needing a USDC trustline. The sender creates an on-ledger USDC balance the recipient claims when ready, with a sender reclaim-after-expiry predicate so funds are never stranded. Includes a GiftClaim model (no document-deleting TTL, so the record survives expiry for reclaim), a claimableBalanceService (build create/claim transactions with complementary predicates, resolve the REAL balance id from the create result XDR — not the tx hash — with a Horizon forClaimant fallback, and validate the signed gift XDR before any state change), gift routes/controller at /api/stellar/gifts, and granting item access to the RECIPIENT (never the payer) on claim. Wires a `{ fallback: "claimable_balance" }` response into the purchase flow when a creator wallet/trustline is missing. Tests cover predicate decoding, trustline-free single-signature claiming, claim authorization before/after expiry, the balance-id-vs-tx-hash distinction, tampered-XDR rejection, guards mirroring initializePayment, and the recipient access grant. * feat(stellar): add idempotency protection to the Stellar payment endpoints (#115) Makes /api/stellar/payment/initialize and /submit safe against double-clicks, client retries, and concurrent duplicates. Submit is naturally idempotent per transaction hash: the deterministic hash of the signed XDR is looked up against confirmed transactions before any processing, so a replayed submission returns the original success response without re-granting access, with the unique index on stellarTxHash as the database-level backstop (an E11000 on the confirm save is treated as already processed). Initialize no longer piles up duplicates: a pending checkout for the same user+item returns the existing record (with its persisted unsigned XDR) instead of creating a new document, and stale pending records are reaped by the existing pending-only TTL index. Adds a stricter per-user rate limiter (paymentLimiter) on the payment routes, keyed on the authenticated user id with an IPv6-aware IP fallback. Covers duplicate-submit, duplicate-initialize, the E11000 race, and limiter enforcement with tests. * feat(stellar): validate Stellar config at startup and document the mainnet switch (#114) Adds a single source of truth for the Stellar network configuration (src/config/stellar.js) that resolves the network name, network passphrase, Horizon URLs, and USDC issuer from STELLAR_NETWORK, and validates the whole setup fail-fast at boot so a misconfigured deployment (bad network value, mainnet flag with testnet Horizon or issuer) fails with an error naming the exact problem instead of at request time. stellarService.js and horizonClient.js now consume this module. Adds docs/MAINNET.md covering the env changes, creator trustlines, and a first-mainnet-transaction smoke checklist, plus unit tests for resolution and validation across both networks. * feat(stellar): fee-bump sponsorship with structural whitelist and spend caps (#111) Let the platform pay a user's Stellar network fee by wrapping the user-signed transaction in a fee-bump signed by a dedicated fee-source account, so a user holding USDC but ~no XLM can buy a book, buy a course, or donate. Opt-in per submit via `requestSponsorship: true`; off by default and byte-for-byte identical when disabled. Guard rails (server signs on the platform's behalf): - Structural whitelist (reject-by-default, allow-list of `payment` ops only): source, exact op count/order, destinations, amounts (stroops), asset, and memo must match the pending Transaction row exactly. Any foreign/extra operation — including unknown future types — is rejected. - Durable spend caps (SponsorshipSpend, keyed by UTC day): per-transaction fee ceiling, per-day total, and per-user per-day count. - Sponsor float pre-check so an underfunded sponsor never marks the user's transaction failed. Fee-bump fee is priced over inner ops + wrapper and clamped to the ceiling (verified against @stellar/stellar-sdk v16 and asserted in tests). Sponsored rows record `sponsored`, `sponsorFeeCharged`, and the fee-bump (outer) hash alongside the inner hash. Sponsorship-specific failures return a distinct non-fatal 4xx/503 with `retryUnsponsored: true` and never mark the row failed. - Config: FEE_SPONSOR_* in .env.example + validateEnv (fail-fast at boot when enabled with a missing/invalid secret); secret never logged or returned. - Admin status endpoint GET /api/stellar/payment/sponsorship/status exposes the sponsor public key, live float, caps, and today's spend. - Prometheus counters for approved/rejected sponsorship decisions. - Docs: docs/fee-sponsorship.md, README, and openapi.yaml. - Tests: feeSponsorService (whitelist adversarial matrix, fee correctness, inner-untouched, caps, secret handling, boot config) and feeSponsorSubmit (payment + donation flag-off regression, flag-on sponsorship, cap/whitelist rejections that don't fail the row). Closes #30 * feat: add managed course categories (#118) * feat(courses): add managed category taxonomy * fix(categories): preserve legacy course creation * feat: add recurring sadaqah pledges (#119) * feat(donations): add recurring sadaqah pledges * fix(pledges): preserve donation test compatibility * fix(pledges): ignore non-persisted transactions --------- Co-authored-by: Afeez Tomisin <132703022+Banx17@users.noreply.github.com> Co-authored-by: Alabi Ibrahim Abimbola <139625252+abimbolaalabi@users.noreply.github.com> Co-authored-by: Kelechukwu Izuaba <144479895+Kaycee276@users.noreply.github.com> Co-authored-by: BountySpaghetti Co-authored-by: BountySpaghetti <286941608+BountySpaghetti@users.noreply.github.com> Co-authored-by: Samuel Ojetunde Co-authored-by: abimbolaalabi Co-authored-by: Lspnjr1 Co-authored-by: Lspnjr1 <304024794+Lspnjr1@users.noreply.github.com> Co-authored-by: Alhassan Nuhu Idris Co-authored-by: Mantissa Co-authored-by: Ezekiel Akawa Co-authored-by: Claude Opus 4.5 Co-authored-by: Mfon <67503972+TS-mfon@users.noreply.github.com> --- .env.example | 79 +- .github/workflows/ci.yml | 47 +- .github/workflows/contracts.yml | 37 + README.md | 8 +- app.js | 67 +- contracts/.gitignore | 2 + contracts/Cargo.lock | 2121 +++++++++++++++++ contracts/Cargo.toml | 3 + contracts/README.md | 60 + contracts/scholarship_escrow/Cargo.toml | 14 + contracts/scholarship_escrow/src/lib.rs | 829 +++++++ docs/MAINNET.md | 187 ++ docs/authorization-matrix.md | 62 + docs/fee-sponsorship.md | 133 ++ docs/idempotency.md | 56 + docs/service-to-service-auth.md | 149 ++ docs/soroban-escrow-design.md | 171 ++ docs/transaction-lifecycle.md | 86 + docs/webhooks.md | 139 ++ openapi.yaml | 102 +- package-lock.json | 221 +- package.json | 4 + server.js | 48 +- services/emails/sendMail.js | 51 +- src/config/metrics.js | 18 + src/config/serviceKeys.js | 106 + src/config/stellar.js | 177 ++ src/config/stellarConfig.test.js | 179 ++ src/config/validateEnv.js | 83 +- .../educatorVerificationAdminController.js | 247 ++ src/controllers/authController.js | 484 +++- src/controllers/books/bookController.js | 44 +- src/controllers/categoryController.js | 118 + src/controllers/courses/courseController.js | 49 +- .../educatorVerificationController.js | 241 ++ src/controllers/healthController.js | 49 + src/controllers/reviewController.js | 12 +- src/controllers/spaceController.js | 20 +- src/controllers/stellar/donationController.js | 237 +- src/controllers/stellar/giftController.js | 639 +++++ src/controllers/stellar/paymentController.js | 429 +++- src/controllers/stellar/pledgeController.js | 145 ++ src/controllers/stellar/refundController.js | 8 +- src/controllers/stellar/walletController.js | 12 + src/controllers/uploadController.js | 4 +- src/controllers/webhookController.js | 308 +++ src/jobs/handlers.js | 7 + src/middlewares/authMiddleware.js | 58 + src/middlewares/authorize.js | 121 + src/middlewares/errorHandler.js | 7 +- src/middlewares/idempotency.js | 133 ++ src/middlewares/security.js | 101 +- src/middlewares/serviceAuth.js | 150 ++ src/middlewares/validate.js | 23 +- src/migrations/fixTtlTransactionExpiry.js | 82 + src/models/AuditLog.js | 33 + src/models/Book.js | 1 + src/models/Category.js | 19 + src/models/Course.js | 2 +- src/models/EducatorVerification.js | 138 ++ src/models/GiftClaim.js | 113 + src/models/IdempotencyKey.js | 52 + src/models/Notification.js | 3 + src/models/Pledge.js | 23 + src/models/PledgeCycle.js | 15 + src/models/Session.js | 4 + src/models/SponsorshipSpend.js | 43 + src/models/Transaction.js | 67 +- src/models/User.js | 36 + src/models/WebhookDelivery.js | 79 + src/models/WebhookEndpoint.js | 74 + .../admin/educatorVerificationAdminRoutes.js | 24 + src/routes/authRoutes.js | 50 +- src/routes/books/bookRoutes.js | 15 +- src/routes/categoryRoutes.js | 13 + src/routes/courses/courseRoutes.js | 18 +- src/routes/educatorVerificationRoutes.js | 19 + src/routes/internal/aiRoutes.js | 27 + src/routes/jobsRoutes.js | 14 +- src/routes/payoutRoutes.js | 5 +- src/routes/spaceRoutes.js | 7 +- src/routes/stellar/donationRoutes.js | 5 +- src/routes/stellar/giftRoutes.js | 30 + src/routes/stellar/paymentRoutes.js | 40 +- src/routes/stellar/pledgeRoutes.js | 23 + src/routes/stellar/walletRoutes.js | 10 +- src/routes/webhookRoutes.js | 42 + src/scripts/migrateCategories.js | 28 + src/scripts/seedCategories.js | 32 + src/services/audit/auditService.js | 34 +- src/services/categoryService.js | 46 + src/services/pledgeService.js | 56 + .../stellar/claimableBalanceService.js | 335 +++ src/services/stellar/donationIntentService.js | 68 + src/services/stellar/feeSponsorService.js | 528 ++++ src/services/stellar/horizonClient.js | 14 +- src/services/stellar/reconciliationService.js | 9 + src/services/stellar/stellarService.js | 95 +- src/services/webhooks/deliveryWorker.js | 308 +++ src/services/webhooks/signing.js | 83 + src/services/webhooks/urlGuard.js | 135 ++ src/services/webhooks/webhookSecret.js | 69 + src/services/webhooks/webhookService.js | 166 ++ src/utils/cache.js | 1 + src/utils/captcha.js | 47 + src/utils/hibp.js | 81 + src/utils/otp.js | 2 +- src/utils/twoFactorCrypto.js | 167 ++ src/validators/requestValidators.js | 123 + src/workers/pledgeScheduler.js | 68 + test/app.test.js | 35 +- test/auditLog.test.js | 17 +- test/auth.test.js | 2 + test/auth2FA.test.js | 495 ++++ test/authRoles.test.js | 239 +- test/authSecurity.test.js | 366 +++ test/bookDeleteAuthorization.test.js | 119 + test/bookUpload.test.js | 41 +- test/breachedPassword.test.js | 239 ++ test/claimableBalanceService.test.js | 267 +++ test/coreFlows.test.js | 177 ++ test/courseProgress.test.js | 24 +- test/educatorVerification.test.js | 737 ++++++ test/educators.test.js | 24 +- test/emailRoutes.test.js | 61 +- test/feeSponsorService.test.js | 533 +++++ test/feeSponsorSubmit.test.js | 380 +++ test/giftClaimableBalances.test.js | 562 +++++ test/health.test.js | 109 + test/helpers/testAuth.js | 10 +- test/idempotency.test.js | 299 +++ test/jest.setup.js | 17 +- test/notification.test.js | 9 + test/ownershipAuthz.test.js | 338 +++ test/passwordReset.test.js | 55 +- test/paymentIdempotency.test.js | 401 ++++ test/reconciliation.test.js | 12 + test/refund.test.js | 46 +- test/requestValidation.test.js | 221 ++ test/reviews.test.js | 27 +- test/search.test.js | 25 +- test/serviceAuth.test.js | 233 ++ test/stellarPaymentController.test.js | 9 +- test/ttlTransactionExpiry.test.js | 256 ++ test/upload.test.js | 16 +- test/webhooks.test.js | 715 ++++++ 146 files changed, 19159 insertions(+), 463 deletions(-) create mode 100644 .github/workflows/contracts.yml create mode 100644 contracts/.gitignore create mode 100644 contracts/Cargo.lock create mode 100644 contracts/Cargo.toml create mode 100644 contracts/README.md create mode 100644 contracts/scholarship_escrow/Cargo.toml create mode 100644 contracts/scholarship_escrow/src/lib.rs create mode 100644 docs/MAINNET.md create mode 100644 docs/authorization-matrix.md create mode 100644 docs/fee-sponsorship.md create mode 100644 docs/idempotency.md create mode 100644 docs/service-to-service-auth.md create mode 100644 docs/soroban-escrow-design.md create mode 100644 docs/transaction-lifecycle.md create mode 100644 docs/webhooks.md create mode 100644 src/config/serviceKeys.js create mode 100644 src/config/stellar.js create mode 100644 src/config/stellarConfig.test.js create mode 100644 src/controllers/admin/educatorVerificationAdminController.js create mode 100644 src/controllers/categoryController.js create mode 100644 src/controllers/educatorVerificationController.js create mode 100644 src/controllers/healthController.js create mode 100644 src/controllers/stellar/giftController.js create mode 100644 src/controllers/stellar/pledgeController.js create mode 100644 src/controllers/webhookController.js create mode 100644 src/middlewares/authorize.js create mode 100644 src/middlewares/idempotency.js create mode 100644 src/middlewares/serviceAuth.js create mode 100644 src/migrations/fixTtlTransactionExpiry.js create mode 100644 src/models/Category.js create mode 100644 src/models/EducatorVerification.js create mode 100644 src/models/GiftClaim.js create mode 100644 src/models/IdempotencyKey.js create mode 100644 src/models/Pledge.js create mode 100644 src/models/PledgeCycle.js create mode 100644 src/models/SponsorshipSpend.js create mode 100644 src/models/WebhookDelivery.js create mode 100644 src/models/WebhookEndpoint.js create mode 100644 src/routes/admin/educatorVerificationAdminRoutes.js create mode 100644 src/routes/categoryRoutes.js create mode 100644 src/routes/educatorVerificationRoutes.js create mode 100644 src/routes/internal/aiRoutes.js create mode 100644 src/routes/stellar/giftRoutes.js create mode 100644 src/routes/stellar/pledgeRoutes.js create mode 100644 src/routes/webhookRoutes.js create mode 100644 src/scripts/migrateCategories.js create mode 100644 src/scripts/seedCategories.js create mode 100644 src/services/categoryService.js create mode 100644 src/services/pledgeService.js create mode 100644 src/services/stellar/claimableBalanceService.js create mode 100644 src/services/stellar/donationIntentService.js create mode 100644 src/services/stellar/feeSponsorService.js create mode 100644 src/services/webhooks/deliveryWorker.js create mode 100644 src/services/webhooks/signing.js create mode 100644 src/services/webhooks/urlGuard.js create mode 100644 src/services/webhooks/webhookSecret.js create mode 100644 src/services/webhooks/webhookService.js create mode 100644 src/utils/captcha.js create mode 100644 src/utils/hibp.js create mode 100644 src/utils/twoFactorCrypto.js create mode 100644 src/validators/requestValidators.js create mode 100644 src/workers/pledgeScheduler.js create mode 100644 test/auth2FA.test.js create mode 100644 test/authSecurity.test.js create mode 100644 test/bookDeleteAuthorization.test.js create mode 100644 test/breachedPassword.test.js create mode 100644 test/claimableBalanceService.test.js create mode 100644 test/coreFlows.test.js create mode 100644 test/educatorVerification.test.js create mode 100644 test/feeSponsorService.test.js create mode 100644 test/feeSponsorSubmit.test.js create mode 100644 test/giftClaimableBalances.test.js create mode 100644 test/health.test.js create mode 100644 test/idempotency.test.js create mode 100644 test/ownershipAuthz.test.js create mode 100644 test/paymentIdempotency.test.js create mode 100644 test/requestValidation.test.js create mode 100644 test/serviceAuth.test.js create mode 100644 test/ttlTransactionExpiry.test.js create mode 100644 test/webhooks.test.js diff --git a/.env.example b/.env.example index 7b8b8cee..ab6f7986 100644 --- a/.env.example +++ b/.env.example @@ -36,7 +36,10 @@ 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 # Resilient Horizon Client Configuration (Optional) @@ -53,6 +56,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 +108,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 +155,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..c3fb2491 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,8 +22,6 @@ import { sanitizeInput } from "./src/middlewares/validate.js"; import { errorHandler, notFound, - handleUnhandledRejection, - handleUncaughtException, } from "./src/middlewares/errorHandler.js"; import authRoutes from "./src/routes/authRoutes.js"; @@ -49,21 +38,21 @@ import callRoutes from "./src/routes/callRoutes.js"; import stellarWalletRoutes from "./src/routes/stellar/walletRoutes.js"; import stellarPaymentRoutes from "./src/routes/stellar/paymentRoutes.js"; import stellarDonationRoutes from "./src/routes/stellar/donationRoutes.js"; +import 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 categoryRoutes from "./src/routes/categoryRoutes.js"; +import { healthCheck, ping } from "./src/controllers/healthController.js"; const app = express(); @@ -143,7 +132,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 +163,8 @@ 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); // SEP-1 stellar.toml — must be outside /api rate limiter app.use("/.well-known", wellKnownRoutes); @@ -186,6 +180,7 @@ app.use("/api/payouts", standardLimiter, payoutRoutes); // Read-heavy & content routes — generous limiter 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); @@ -194,14 +189,25 @@ 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); +// 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/pledges", generousLimiter, stellarPledgeRoutes); +app.use("/api/stellar/gifts", generousLimiter, stellarGiftRoutes); app.use("/api/notifications", generousLimiter, notificationRoutes); +// 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); + // Admin — no rate limit app.use("/admin/jobs", jobsRoutes); app.use("/api/admin/audit", auditRoutes); +app.use("/api/admin/educator-verification", educatorVerificationAdminRoutes); // ====================== // ERROR HANDLING @@ -209,7 +215,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/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/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/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/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..1678f6ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,11 +31,12 @@ "morgan": "^1.10.0", "multer": "^1.4.5-lts.2", "multer-storage-cloudinary": "^4.0.0", - "nodemailer": "^9.0.3", + "otplib": "^13.4.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", @@ -1083,6 +1084,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 +1234,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", @@ -1561,7 +1627,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" @@ -2272,7 +2337,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" @@ -2479,7 +2543,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 +2555,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 +2887,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", @@ -2914,6 +2985,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", @@ -3582,7 +3659,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", @@ -5210,7 +5286,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 +6130,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 +6391,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 +6425,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 +6437,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,7 +6452,6 @@ "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" @@ -6412,7 +6489,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" @@ -6589,6 +6665,15 @@ "node": ">=8" } }, + "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 +6827,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 +7031,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", @@ -8181,6 +8354,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..51efca64 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,12 @@ "morgan": "^1.10.0", "multer": "^1.4.5-lts.2", "multer-storage-cloudinary": "^4.0.0", + "otplib": "^13.4.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/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/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/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/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/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/courses/courseController.js b/src/controllers/courses/courseController.js index 65780cc8..648c52b5 100644 --- a/src/controllers/courses/courseController.js +++ b/src/controllers/courses/courseController.js @@ -4,6 +4,12 @@ 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"; /** * Create a new course @@ -21,12 +27,17 @@ export const createCourse = catchAsync(async (req, res, next) => { new APIError("Title, description, and category are required", 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 @@ -48,9 +59,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" ); @@ -149,6 +166,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({ @@ -169,23 +192,23 @@ export const updateCourse = catchAsync(async (req, res, next) => { 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) - ); - } - // 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 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/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/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/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/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/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..000d962e 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 }, @@ -95,4 +96,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/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/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/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/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/books/bookRoutes.js b/src/routes/books/bookRoutes.js index 56b3a833..d4a4a5b5 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,36 +105,42 @@ 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 ); 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/courses/courseRoutes.js b/src/routes/courses/courseRoutes.js index 57c46a1b..9c709e3d 100644 --- a/src/routes/courses/courseRoutes.js +++ b/src/routes/courses/courseRoutes.js @@ -22,7 +22,12 @@ import { getCourseProgress, updateCourseProgress, } from "../../controllers/analytics/analyticsController.js"; -import { protect } from "../../middlewares/authMiddleware.js"; +import { protect, requireVerifiedEducator } from "../../middlewares/authMiddleware.js"; +import { + authorizeOwnership, + authorizeReviewOwnership, +} from "../../middlewares/authorize.js"; +import Course from "../../models/Course.js"; import { cacheMiddleware, invalidateCacheMiddleware, @@ -32,7 +37,7 @@ 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}`; @@ -72,6 +77,7 @@ router.get( router.post( "/", protect, + requireVerifiedEducator, invalidateCacheMiddleware([`${CACHE_KEYS.COURSES}*`, `${CACHE_KEYS.EDUCATORS}*`]), createCourse ); @@ -90,45 +96,51 @@ 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" }), 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/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/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/spaceRoutes.js b/src/routes/spaceRoutes.js index 92f08b71..934530b1 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,6 +79,7 @@ router.put( router.delete( "/:id", protect, + authorizeOwnership({ model: Space, ownerField: "host", resourceType: "Space" }), invalidateCacheMiddleware([`${CACHE_KEYS.SPACES}*`, `${CACHE_KEYS.SPACE}*`, `${CACHE_KEYS.EDUCATORS}*`]), deleteSpace ); 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/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/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/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/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/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/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/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/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/validators/requestValidators.js b/src/validators/requestValidators.js new file mode 100644 index 00000000..d6d72858 --- /dev/null +++ b/src/validators/requestValidators.js @@ -0,0 +1,123 @@ +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"), +]; + +export default { + registerValidation, + loginValidation, + initializePaymentValidation, + submitPaymentValidation, + connectWalletValidation, +}; 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/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/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/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/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/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/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/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/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/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/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"); + }); +}); From 388a2649a850276e09a971959073617b95c77b6f Mon Sep 17 00:00:00 2001 From: Inspector222 Date: Mon, 24 Aug 2026 18:05:05 +0100 Subject: [PATCH 02/24] refactor(db): Create /mongo folder structure (#280) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * stellar: validate signed XDR contents before submit; store expectedHa… (#51) * stellar: validate signed XDR contents before submit; store expectedHash/memo; verify on-chain before granting access; add tests * test: add validateSignedPaymentXdr to stellarService mock (new import in paymentController) * test: expect expectedHash at payment init (XDR pre-validation stores it there) --------- Co-authored-by: zeemscript <150973162+zeemscript@users.noreply.github.com> * feat(stellar): publish Soroban giving-escrow contract id in stellar.toml Adds env-driven GIVING_ESCROW_CONTRACT (custom, non-SEP-1) so the deployed On-Chain Giving escrow is discoverable from /.well-known/stellar.toml. Set GIVING_ESCROW_CONTRACT_ID to enable. Includes test coverage. * fix(stellar): resolve Horizon endpoints lazily + network-aware default Horizon client was constructed at import time with a hardcoded testnet fallback, so a mainnet deploy with HORIZON_URLS unset would silently talk to testnet Horizon. Now the default is derived from STELLAR_NETWORK and the client is built lazily on first use (after env is loaded). Explicit HORIZON_URLS still wins. * feat(auth): authenticated change-password endpoint PUT /api/auth/change-password (protected): verifies current password, enforces the password policy, updates the hash, and signs out all other sessions. Adds the auth.password_change audit action. * feat(auth): harden authentication against login lockout, breached passwords, and signup abuse (#89) (#99) * feat(auth): harden authentication against login lockout, breached passwords, and signup abuse (#89) - Add progressive per-account login lockout (failedLoginAttempts + lockUntil) with env-configurable escalating backoff, generic 429 while locked, and AUTH_ACCOUNT_LOCKED audit emission. - Add HaveIBeenPwned breached-password check (SHA-1 prefix k-anonymity, never transmits the password) at register and password reset; fails open on outage. - Add per-email throttling on /register and /resend-verification (emailAuthLimiter, survives IP rotation, active in test env) plus a pluggable captcha gate (no-op when unconfigured). - Add User lockout fields and document new env vars in .env.example. * fix(auth): address CodeRabbit review on login lockout, HIBP, captcha, and JWT secret (#89) - loginUser: locked accounts now return the same generic 401 'Invalid credentials' as a nonexistent account (no enumeration); failed-login counter incremented atomically via findByIdAndUpdate \, lock persisted via updateOne - resetPassword: breached-password check moved to after successful OTP validation so unauthenticated callers cannot trigger HIBP lookups - authController: refuse to start when JWT_SECRET is missing (no hardcoded fallback) - hibp: request HIBP padding ('Add-Padding: true') and ignore zero-count records - captcha: configurable CAPTCHA_TIMEOUT_MS (default 5s) passed to the provider call; captcha rejection now returns the standard { success, message, data: null } shape - tests: assert escalating backoff magnitude (120s on 6th failure) and the 24h cap; locked-account test expects 401; per-email limiter buckets reset between tests; outage test routed through mockHibp so the shared spy is cleaned up; added padding-record and cap coverage * Feat/93 idempotency keys (#100) * feat(stellar): publish Soroban giving-escrow contract id in stellar.toml Adds env-driven GIVING_ESCROW_CONTRACT (custom, non-SEP-1) so the deployed On-Chain Giving escrow is discoverable from /.well-known/stellar.toml. Set GIVING_ESCROW_CONTRACT_ID to enable. Includes test coverage. * fix(stellar): resolve Horizon endpoints lazily + network-aware default Horizon client was constructed at import time with a hardcoded testnet fallback, so a mainnet deploy with HORIZON_URLS unset would silently talk to testnet Horizon. Now the default is derived from STELLAR_NETWORK and the client is built lazily on first use (after env is loaded). Explicit HORIZON_URLS still wins. * feat(auth): authenticated change-password endpoint PUT /api/auth/change-password (protected): verifies current password, enforces the password policy, updates the hash, and signs out all other sessions. Adds the auth.password_change audit action. * feat(payment): add request-level idempotency keys to payment endpoints (#93) * test: complement stellarService mock exports in idempotency test * test: refine idempotency middleware concurrency lock test (#93) * fix(stellar): export validateSignedPaymentXdr and complement test mock (#93) * fix(stellar): remove duplicate validateSignedPaymentXdr export (#93) --------- Co-authored-by: zeemscript <150973162+zeemscript@users.noreply.github.com> * feat(auth): enforce resource ownership across mutating endpoints (#88) (#105) Add a centralized authorization layer that verifies the authenticated user owns the target resource (or is an admin) before any mutating handler runs, replacing the ad-hoc inline checks scattered across controllers. - add authorizeOwnership + authorizeReviewOwnership middleware (src/middlewares/authorize.js); on success the loaded doc is attached to req so handlers can reuse it - apply the guards to book delete, course update, space update/delete, and review update/delete on books and courses; review create stays purchase-gated - record ownership denials to the audit log (authz.ownership.denied) - remove the now-redundant inline ownership checks from the book, course, space, and review controllers - document the resource x action x role matrix (docs/authorization-matrix.md) and cover it with an integration test suite (test/ownershipAuthz.test.js) Closes #88 Co-authored-by: BountySpaghetti <286941608+BountySpaghetti@users.noreply.github.com> * fix(security): stop logging OTP codes and verification tokens in email bodies (#104) The NODE_ENV === "test" branch of sendMail logged the full rendered email body — including the password-reset OTP span and the verification link's token query param — and pino's redact config cannot censor values baked into interpolated strings, so the leak bypassed the app-wide redaction. Remove the body from every log statement (log only recipient, subject, and template id via structured fields), and give tests a sanctioned in-memory outbox hook instead of log scraping. sendOtpEmail/sendVerificationEmail/ sendReceiptEmail now return the sendMail result so callers can capture it. Closes #95 * fix(transactions): prevent TTL index from deleting confirmed purchases and donations (#103) The Transaction collection used a blanket TTL index on expiresAt with a schema default that stamped a 30-minute expiry on every row regardless of status. Because confirm paths never cleared expiresAt, confirmed on-chain purchases and donations were permanently reaped ~30 minutes after creation, deleting the proof of payment and orphaning recorded earnings. Scope the TTL index to status: "pending" via partialFilterExpression, make the expiresAt default conditional on status, add a pre-save hook that clears expiresAt for any terminal state, explicitly unset expiresAt on every terminal transition (submit, donation, refund, dispute, cancel, job handler, reconciliation promotion), and add an idempotent migration that rescues legacy non-pending rows and rebuilds the index. Closes #94 * feat(security): implement educator verification pipeline and content-creation gating (#92) (#102) * feat(security): implement educator verification pipeline and content-creation gating (#92) - Add EducatorVerification model with legal state-machine transitions (draft -> pending -> approved/rejected, resubmit from rejected) - Add verifiedEducator durable flag on User, set atomically on approval - Add AUDIT_ACTIONS: EDUCATOR_VERIFY_SUBMIT/_RESUBMIT/_APPROVE/_REJECT with metadata allowlist entries in auditService - requireVerifiedEducator middleware (403 for unverified, admin bypass) - Applicant API: submit/resubmit app, get own app, signed doc URLs, signed Cloudinary upload-signature for private credential uploads - Admin review queue: list+filter pending, view signed docs, approve/reject with notes (Mongo transaction for verifiedEducator grant) - Gate all content-creation routes: * POST /api/courses (courseRoutes.js) * POST /api/books (bookRoutes.js) * POST /api/spaces (spaceRoutes.js — live sessions per issue) - Wire routes: /api/educator-verification + /api/admin/educator-verification - Comprehensive test suite in test/educatorVerification.test.js (state-machine, middleware gating, submit/resubmit, approve/reject, content 403/2xx, both full lifecycles submit->pending->approve and reject->resubmit->approve, signed URL security, admin-only gating, audit log instrumentation) Verification Results: app.test.js: 22/22 PASS (CI boot + endpoint health) auditLog.test.js: 21/21 PASS (append-only, redaction, admin gate) Closes #92 * fix(ci): resolve educator verification pipeline test failures - Remove redundant catchAsync double-wrap in educator-verification routes (controllers are already pre-wrapped; the outer wrap called .catch() on undefined, returning 500 for every new endpoint) - Use MongoMemoryReplSet in educatorVerification.test.js so the approve/reject MongoDB transaction can run (standalone MongoMemoryServer cannot) - Use AuditLog.collection.deleteMany in test cleanup to bypass append-only pre-hooks - Return recordAudit's promise so callers can await durability; await it in submitApplication and performReview to eliminate the fire-and-forget audit-race in tests - Fix testAuth.js password overwrite: destructure password out of the override spread so the hashed value is not clobbered by plaintext - Seed bookUpload test user as a verifiedEducator mentor so the new content gate lets it through --------- Co-authored-by: abimbolaalabi * feat(auth): signed service-to-service authentication for the AI service (#91) (#106) Give the backend a real machine-to-machine auth channel for the AI service (dnb-ai) — signed, scoped, rotatable keys instead of a single static shared secret. - add requireServiceAuth middleware (src/middlewares/serviceAuth.js): HMAC-SHA256 over a canonical method/path/timestamp/body-digest string, a ±300s replay window, constant-time signature comparison, per-key scope enforcement, and req.service on success - key store (src/config/serviceKeys.js): multiple active keys keyed by kid for zero-downtime rotation; resilient env parsing, never throws - mount a real internal route GET /api/internal/ai/whoami guarded by the guard (scope ai:read-content), plus raw-body capture in app.js - migrate /admin/jobs off raw !== to a length-guarded timingSafeEqual - audit denials (service_auth.denied), require AI_SERVICE_KEYS in prod (fail-fast), document the signing contract + rotation runbook - cover the full accept/reject matrix in test/serviceAuth.test.js Closes #91 Co-authored-by: Lspnjr1 <304024794+Lspnjr1@users.noreply.github.com> * feat(webhooks): signed outbound webhook event system (#45) (#107) Add an outbound webhook/event system so external consumers can subscribe to payment and enrollment lifecycle events over HMAC-signed HTTP callbacks, with retries, dead-lettering, and redelivery. - models: WebhookEndpoint (encrypted secret at rest, subscribed events, auto-disable counters) and WebhookDelivery (all scheduling state in the doc: status, attemptCount, nextAttemptAt indexed) - webhookService.emitEvent: typed event catalog, per-event id for consumer idempotency, strict payload allowlist (no secrets/emails/user docs); persists a delivery per subscribed endpoint after the txn commits, never blocks or fails the request path, no-ops when the DB is unavailable - signing: X-DeenBridge-Signature v1=hmac-sha256(secret, `${ts}.${body}`) over the exact sent bytes; timing-safe verify + 5-min staleness window - deliveryWorker: atomic findOneAndUpdate claim (no double-send), exponential backoff + jitter, dead-letter after max attempts, endpoint auto-disable after sustained failures - management API (/api/webhooks, admin-gated): endpoint CRUD, rotate secret, list deliveries, redeliver (atomic $set), and ping - SSRF guard: https-only in prod, reject loopback/RFC-1918/link-local - wire emitters into payment (initialized/confirmed/failed/expired), enrollment, and wallet connect/disconnect; migrate /admin/jobs to a timing-safe token compare - docs/webhooks.md consumer verifier + full offline test suite Closes #45 Co-authored-by: Lspnjr1 <304024794+Lspnjr1@users.noreply.github.com> * feat(security): implement TOTP two-factor authentication for admins a… (#98) * feat(stellar): publish Soroban giving-escrow contract id in stellar.toml Adds env-driven GIVING_ESCROW_CONTRACT (custom, non-SEP-1) so the deployed On-Chain Giving escrow is discoverable from /.well-known/stellar.toml. Set GIVING_ESCROW_CONTRACT_ID to enable. Includes test coverage. * fix(stellar): resolve Horizon endpoints lazily + network-aware default Horizon client was constructed at import time with a hardcoded testnet fallback, so a mainnet deploy with HORIZON_URLS unset would silently talk to testnet Horizon. Now the default is derived from STELLAR_NETWORK and the client is built lazily on first use (after env is loaded). Explicit HORIZON_URLS still wins. * feat(auth): authenticated change-password endpoint PUT /api/auth/change-password (protected): verifies current password, enforces the password policy, updates the hash, and signs out all other sessions. Adds the auth.password_change audit action. * feat(security): implement TOTP two-factor authentication for admins and mentors - Add TOTP (RFC 6238) lifecycle: secret generation, encrypted storage at rest (AES-256-GCM), otpauth:// URI and QR code generation - Add 10 single-use bcrypt-hashed recovery codes - Add two-factor rate-limiting middleware (5 verification attempts per 15-minute window) - Update login controller to issue step-up mfaToken challenges when 2FA is enabled - Update authorizeRoles('admin') middleware to enforce 2FA-enabled status and 2FA-verified sessions - Log audit actions for all 2FA lifecycle events (setup, enable, login challenge, success, failure, disable, recovery code usage) - Add comprehensive test suite in test/auth2FA.test.js and update existing auth test suites * ci: update node version to 22 and sync package-lock.json * ci: pin mongo service to 6.0 and add wait-for-mongodb step * test: add 2FA enablement and 2FA verified token to admin in refund.test.js * fix(auth): switch bcrypt imports to pure-JS bcryptjs for cross-platform compatibility * fix(test): remove duplicate MongoMemoryServer import in refund.test.js * fix(test): add errorHandler middleware to refund.test.js app * fix(test): clean up MongoDB connection logic and add afterAll hook in refund.test.js * fix(test): standardize Mongoose connection lifecycle and add missing afterAll hooks * fix(test): remove duplicate afterAll hooks and handle Multer 413 response status * test(refund): explicitly set 2FA enablement and 2FA verified tokens for admin in refund.test.js * test: ensure admin test helpers include 2FA enablement and 2FA verified JWT claims * fix(test): add 2FA enablement and 2FA verified tokens for admin in webhooks and educatorVerification tests --------- Co-authored-by: zeemscript <150973162+zeemscript@users.noreply.github.com> * Add scholarship escrow contract foundation (#108) * Improve application test coverage (#110) * Add dependency health checks (#112) * Validate auth and Stellar requests (#109) * Validate auth and Stellar requests * Address validation review feedback * Secure book deletion authorization (#113) * Secure book deletion * Keep delete response consistent * feat(stellar): gift courses/books via claimable balances with expiry reclaim (#116) Adds a gift-a-course/book flow built on Stellar claimable balances so a buyer can send an item to another user — including one who has not finished wallet onboarding — without the recipient needing a USDC trustline. The sender creates an on-ledger USDC balance the recipient claims when ready, with a sender reclaim-after-expiry predicate so funds are never stranded. Includes a GiftClaim model (no document-deleting TTL, so the record survives expiry for reclaim), a claimableBalanceService (build create/claim transactions with complementary predicates, resolve the REAL balance id from the create result XDR — not the tx hash — with a Horizon forClaimant fallback, and validate the signed gift XDR before any state change), gift routes/controller at /api/stellar/gifts, and granting item access to the RECIPIENT (never the payer) on claim. Wires a `{ fallback: "claimable_balance" }` response into the purchase flow when a creator wallet/trustline is missing. Tests cover predicate decoding, trustline-free single-signature claiming, claim authorization before/after expiry, the balance-id-vs-tx-hash distinction, tampered-XDR rejection, guards mirroring initializePayment, and the recipient access grant. * feat(stellar): add idempotency protection to the Stellar payment endpoints (#115) Makes /api/stellar/payment/initialize and /submit safe against double-clicks, client retries, and concurrent duplicates. Submit is naturally idempotent per transaction hash: the deterministic hash of the signed XDR is looked up against confirmed transactions before any processing, so a replayed submission returns the original success response without re-granting access, with the unique index on stellarTxHash as the database-level backstop (an E11000 on the confirm save is treated as already processed). Initialize no longer piles up duplicates: a pending checkout for the same user+item returns the existing record (with its persisted unsigned XDR) instead of creating a new document, and stale pending records are reaped by the existing pending-only TTL index. Adds a stricter per-user rate limiter (paymentLimiter) on the payment routes, keyed on the authenticated user id with an IPv6-aware IP fallback. Covers duplicate-submit, duplicate-initialize, the E11000 race, and limiter enforcement with tests. * feat(stellar): validate Stellar config at startup and document the mainnet switch (#114) Adds a single source of truth for the Stellar network configuration (src/config/stellar.js) that resolves the network name, network passphrase, Horizon URLs, and USDC issuer from STELLAR_NETWORK, and validates the whole setup fail-fast at boot so a misconfigured deployment (bad network value, mainnet flag with testnet Horizon or issuer) fails with an error naming the exact problem instead of at request time. stellarService.js and horizonClient.js now consume this module. Adds docs/MAINNET.md covering the env changes, creator trustlines, and a first-mainnet-transaction smoke checklist, plus unit tests for resolution and validation across both networks. * feat(stellar): fee-bump sponsorship with structural whitelist and spend caps (#111) Let the platform pay a user's Stellar network fee by wrapping the user-signed transaction in a fee-bump signed by a dedicated fee-source account, so a user holding USDC but ~no XLM can buy a book, buy a course, or donate. Opt-in per submit via `requestSponsorship: true`; off by default and byte-for-byte identical when disabled. Guard rails (server signs on the platform's behalf): - Structural whitelist (reject-by-default, allow-list of `payment` ops only): source, exact op count/order, destinations, amounts (stroops), asset, and memo must match the pending Transaction row exactly. Any foreign/extra operation — including unknown future types — is rejected. - Durable spend caps (SponsorshipSpend, keyed by UTC day): per-transaction fee ceiling, per-day total, and per-user per-day count. - Sponsor float pre-check so an underfunded sponsor never marks the user's transaction failed. Fee-bump fee is priced over inner ops + wrapper and clamped to the ceiling (verified against @stellar/stellar-sdk v16 and asserted in tests). Sponsored rows record `sponsored`, `sponsorFeeCharged`, and the fee-bump (outer) hash alongside the inner hash. Sponsorship-specific failures return a distinct non-fatal 4xx/503 with `retryUnsponsored: true` and never mark the row failed. - Config: FEE_SPONSOR_* in .env.example + validateEnv (fail-fast at boot when enabled with a missing/invalid secret); secret never logged or returned. - Admin status endpoint GET /api/stellar/payment/sponsorship/status exposes the sponsor public key, live float, caps, and today's spend. - Prometheus counters for approved/rejected sponsorship decisions. - Docs: docs/fee-sponsorship.md, README, and openapi.yaml. - Tests: feeSponsorService (whitelist adversarial matrix, fee correctness, inner-untouched, caps, secret handling, boot config) and feeSponsorSubmit (payment + donation flag-off regression, flag-on sponsorship, cap/whitelist rejections that don't fail the row). Closes #30 * feat: add managed course categories (#118) * feat(courses): add managed category taxonomy * fix(categories): preserve legacy course creation * feat: add recurring sadaqah pledges (#119) * feat(donations): add recurring sadaqah pledges * fix(pledges): preserve donation test compatibility * fix(pledges): ignore non-persisted transactions * refactor(db): scaffold /mongo data-layer structure (closes #167) --------- Co-authored-by: Afeez Tomisin <132703022+Banx17@users.noreply.github.com> Co-authored-by: zeemscript <150973162+zeemscript@users.noreply.github.com> Co-authored-by: Alabi Ibrahim Abimbola <139625252+abimbolaalabi@users.noreply.github.com> Co-authored-by: Kelechukwu Izuaba <144479895+Kaycee276@users.noreply.github.com> Co-authored-by: BountySpaghetti Co-authored-by: BountySpaghetti <286941608+BountySpaghetti@users.noreply.github.com> Co-authored-by: Samuel Ojetunde Co-authored-by: abimbolaalabi Co-authored-by: Lspnjr1 Co-authored-by: Lspnjr1 <304024794+Lspnjr1@users.noreply.github.com> Co-authored-by: Alhassan Nuhu Idris Co-authored-by: Mantissa Co-authored-by: Ezekiel Akawa Co-authored-by: Claude Opus 4.5 Co-authored-by: Mfon <67503972+TS-mfon@users.noreply.github.com> --- mongo/base/README.md | 5 +++++ mongo/index.js | 50 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 mongo/base/README.md create mode 100644 mongo/index.js 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/index.js b/mongo/index.js new file mode 100644 index 00000000..8b56d10b --- /dev/null +++ b/mongo/index.js @@ -0,0 +1,50 @@ +/** + * @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 (lands with Deen-Bridge/dnb-backend#168) + * ``` + * + * Intended usage once model-specific repositories start landing: + * + * ```js + * import { base } from "../mongo/index.js"; + * + * class CourseRepository extends base.BaseRepository { + * // 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. + */ + +/** + * Namespace for shared repository base classes. + * + * Currently empty pending #168, which introduces `base.BaseRepository` — the + * abstract CRUD/pagination/error-handling superclass every model-specific + * repository extends. Kept as a stable, frozen object so consumers can start + * importing `base` today without a breaking change when members land. + * + * @type {Readonly<{}> + */ +export const base = Object.freeze({}); + +/** + * Default export mirrors the named exports for callers that prefer + * `import mongo from "../mongo/index.js"`. + */ +export default { base }; From f7a203ce4811eeee35f14be1ed06ab3e4f50a1f4 Mon Sep 17 00:00:00 2001 From: Inspector222 Date: Mon, 24 Aug 2026 18:07:56 +0100 Subject: [PATCH 03/24] feat(soroban): Implement loyalty points contract (#279) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * stellar: validate signed XDR contents before submit; store expectedHa… (#51) * stellar: validate signed XDR contents before submit; store expectedHash/memo; verify on-chain before granting access; add tests * test: add validateSignedPaymentXdr to stellarService mock (new import in paymentController) * test: expect expectedHash at payment init (XDR pre-validation stores it there) --------- Co-authored-by: zeemscript <150973162+zeemscript@users.noreply.github.com> * feat(stellar): publish Soroban giving-escrow contract id in stellar.toml Adds env-driven GIVING_ESCROW_CONTRACT (custom, non-SEP-1) so the deployed On-Chain Giving escrow is discoverable from /.well-known/stellar.toml. Set GIVING_ESCROW_CONTRACT_ID to enable. Includes test coverage. * fix(stellar): resolve Horizon endpoints lazily + network-aware default Horizon client was constructed at import time with a hardcoded testnet fallback, so a mainnet deploy with HORIZON_URLS unset would silently talk to testnet Horizon. Now the default is derived from STELLAR_NETWORK and the client is built lazily on first use (after env is loaded). Explicit HORIZON_URLS still wins. * feat(auth): authenticated change-password endpoint PUT /api/auth/change-password (protected): verifies current password, enforces the password policy, updates the hash, and signs out all other sessions. Adds the auth.password_change audit action. * feat(auth): harden authentication against login lockout, breached passwords, and signup abuse (#89) (#99) * feat(auth): harden authentication against login lockout, breached passwords, and signup abuse (#89) - Add progressive per-account login lockout (failedLoginAttempts + lockUntil) with env-configurable escalating backoff, generic 429 while locked, and AUTH_ACCOUNT_LOCKED audit emission. - Add HaveIBeenPwned breached-password check (SHA-1 prefix k-anonymity, never transmits the password) at register and password reset; fails open on outage. - Add per-email throttling on /register and /resend-verification (emailAuthLimiter, survives IP rotation, active in test env) plus a pluggable captcha gate (no-op when unconfigured). - Add User lockout fields and document new env vars in .env.example. * fix(auth): address CodeRabbit review on login lockout, HIBP, captcha, and JWT secret (#89) - loginUser: locked accounts now return the same generic 401 'Invalid credentials' as a nonexistent account (no enumeration); failed-login counter incremented atomically via findByIdAndUpdate \, lock persisted via updateOne - resetPassword: breached-password check moved to after successful OTP validation so unauthenticated callers cannot trigger HIBP lookups - authController: refuse to start when JWT_SECRET is missing (no hardcoded fallback) - hibp: request HIBP padding ('Add-Padding: true') and ignore zero-count records - captcha: configurable CAPTCHA_TIMEOUT_MS (default 5s) passed to the provider call; captcha rejection now returns the standard { success, message, data: null } shape - tests: assert escalating backoff magnitude (120s on 6th failure) and the 24h cap; locked-account test expects 401; per-email limiter buckets reset between tests; outage test routed through mockHibp so the shared spy is cleaned up; added padding-record and cap coverage * Feat/93 idempotency keys (#100) * feat(stellar): publish Soroban giving-escrow contract id in stellar.toml Adds env-driven GIVING_ESCROW_CONTRACT (custom, non-SEP-1) so the deployed On-Chain Giving escrow is discoverable from /.well-known/stellar.toml. Set GIVING_ESCROW_CONTRACT_ID to enable. Includes test coverage. * fix(stellar): resolve Horizon endpoints lazily + network-aware default Horizon client was constructed at import time with a hardcoded testnet fallback, so a mainnet deploy with HORIZON_URLS unset would silently talk to testnet Horizon. Now the default is derived from STELLAR_NETWORK and the client is built lazily on first use (after env is loaded). Explicit HORIZON_URLS still wins. * feat(auth): authenticated change-password endpoint PUT /api/auth/change-password (protected): verifies current password, enforces the password policy, updates the hash, and signs out all other sessions. Adds the auth.password_change audit action. * feat(payment): add request-level idempotency keys to payment endpoints (#93) * test: complement stellarService mock exports in idempotency test * test: refine idempotency middleware concurrency lock test (#93) * fix(stellar): export validateSignedPaymentXdr and complement test mock (#93) * fix(stellar): remove duplicate validateSignedPaymentXdr export (#93) --------- Co-authored-by: zeemscript <150973162+zeemscript@users.noreply.github.com> * feat(auth): enforce resource ownership across mutating endpoints (#88) (#105) Add a centralized authorization layer that verifies the authenticated user owns the target resource (or is an admin) before any mutating handler runs, replacing the ad-hoc inline checks scattered across controllers. - add authorizeOwnership + authorizeReviewOwnership middleware (src/middlewares/authorize.js); on success the loaded doc is attached to req so handlers can reuse it - apply the guards to book delete, course update, space update/delete, and review update/delete on books and courses; review create stays purchase-gated - record ownership denials to the audit log (authz.ownership.denied) - remove the now-redundant inline ownership checks from the book, course, space, and review controllers - document the resource x action x role matrix (docs/authorization-matrix.md) and cover it with an integration test suite (test/ownershipAuthz.test.js) Closes #88 Co-authored-by: BountySpaghetti <286941608+BountySpaghetti@users.noreply.github.com> * fix(security): stop logging OTP codes and verification tokens in email bodies (#104) The NODE_ENV === "test" branch of sendMail logged the full rendered email body — including the password-reset OTP span and the verification link's token query param — and pino's redact config cannot censor values baked into interpolated strings, so the leak bypassed the app-wide redaction. Remove the body from every log statement (log only recipient, subject, and template id via structured fields), and give tests a sanctioned in-memory outbox hook instead of log scraping. sendOtpEmail/sendVerificationEmail/ sendReceiptEmail now return the sendMail result so callers can capture it. Closes #95 * fix(transactions): prevent TTL index from deleting confirmed purchases and donations (#103) The Transaction collection used a blanket TTL index on expiresAt with a schema default that stamped a 30-minute expiry on every row regardless of status. Because confirm paths never cleared expiresAt, confirmed on-chain purchases and donations were permanently reaped ~30 minutes after creation, deleting the proof of payment and orphaning recorded earnings. Scope the TTL index to status: "pending" via partialFilterExpression, make the expiresAt default conditional on status, add a pre-save hook that clears expiresAt for any terminal state, explicitly unset expiresAt on every terminal transition (submit, donation, refund, dispute, cancel, job handler, reconciliation promotion), and add an idempotent migration that rescues legacy non-pending rows and rebuilds the index. Closes #94 * feat(security): implement educator verification pipeline and content-creation gating (#92) (#102) * feat(security): implement educator verification pipeline and content-creation gating (#92) - Add EducatorVerification model with legal state-machine transitions (draft -> pending -> approved/rejected, resubmit from rejected) - Add verifiedEducator durable flag on User, set atomically on approval - Add AUDIT_ACTIONS: EDUCATOR_VERIFY_SUBMIT/_RESUBMIT/_APPROVE/_REJECT with metadata allowlist entries in auditService - requireVerifiedEducator middleware (403 for unverified, admin bypass) - Applicant API: submit/resubmit app, get own app, signed doc URLs, signed Cloudinary upload-signature for private credential uploads - Admin review queue: list+filter pending, view signed docs, approve/reject with notes (Mongo transaction for verifiedEducator grant) - Gate all content-creation routes: * POST /api/courses (courseRoutes.js) * POST /api/books (bookRoutes.js) * POST /api/spaces (spaceRoutes.js — live sessions per issue) - Wire routes: /api/educator-verification + /api/admin/educator-verification - Comprehensive test suite in test/educatorVerification.test.js (state-machine, middleware gating, submit/resubmit, approve/reject, content 403/2xx, both full lifecycles submit->pending->approve and reject->resubmit->approve, signed URL security, admin-only gating, audit log instrumentation) Verification Results: app.test.js: 22/22 PASS (CI boot + endpoint health) auditLog.test.js: 21/21 PASS (append-only, redaction, admin gate) Closes #92 * fix(ci): resolve educator verification pipeline test failures - Remove redundant catchAsync double-wrap in educator-verification routes (controllers are already pre-wrapped; the outer wrap called .catch() on undefined, returning 500 for every new endpoint) - Use MongoMemoryReplSet in educatorVerification.test.js so the approve/reject MongoDB transaction can run (standalone MongoMemoryServer cannot) - Use AuditLog.collection.deleteMany in test cleanup to bypass append-only pre-hooks - Return recordAudit's promise so callers can await durability; await it in submitApplication and performReview to eliminate the fire-and-forget audit-race in tests - Fix testAuth.js password overwrite: destructure password out of the override spread so the hashed value is not clobbered by plaintext - Seed bookUpload test user as a verifiedEducator mentor so the new content gate lets it through --------- Co-authored-by: abimbolaalabi * feat(auth): signed service-to-service authentication for the AI service (#91) (#106) Give the backend a real machine-to-machine auth channel for the AI service (dnb-ai) — signed, scoped, rotatable keys instead of a single static shared secret. - add requireServiceAuth middleware (src/middlewares/serviceAuth.js): HMAC-SHA256 over a canonical method/path/timestamp/body-digest string, a ±300s replay window, constant-time signature comparison, per-key scope enforcement, and req.service on success - key store (src/config/serviceKeys.js): multiple active keys keyed by kid for zero-downtime rotation; resilient env parsing, never throws - mount a real internal route GET /api/internal/ai/whoami guarded by the guard (scope ai:read-content), plus raw-body capture in app.js - migrate /admin/jobs off raw !== to a length-guarded timingSafeEqual - audit denials (service_auth.denied), require AI_SERVICE_KEYS in prod (fail-fast), document the signing contract + rotation runbook - cover the full accept/reject matrix in test/serviceAuth.test.js Closes #91 Co-authored-by: Lspnjr1 <304024794+Lspnjr1@users.noreply.github.com> * feat(webhooks): signed outbound webhook event system (#45) (#107) Add an outbound webhook/event system so external consumers can subscribe to payment and enrollment lifecycle events over HMAC-signed HTTP callbacks, with retries, dead-lettering, and redelivery. - models: WebhookEndpoint (encrypted secret at rest, subscribed events, auto-disable counters) and WebhookDelivery (all scheduling state in the doc: status, attemptCount, nextAttemptAt indexed) - webhookService.emitEvent: typed event catalog, per-event id for consumer idempotency, strict payload allowlist (no secrets/emails/user docs); persists a delivery per subscribed endpoint after the txn commits, never blocks or fails the request path, no-ops when the DB is unavailable - signing: X-DeenBridge-Signature v1=hmac-sha256(secret, `${ts}.${body}`) over the exact sent bytes; timing-safe verify + 5-min staleness window - deliveryWorker: atomic findOneAndUpdate claim (no double-send), exponential backoff + jitter, dead-letter after max attempts, endpoint auto-disable after sustained failures - management API (/api/webhooks, admin-gated): endpoint CRUD, rotate secret, list deliveries, redeliver (atomic $set), and ping - SSRF guard: https-only in prod, reject loopback/RFC-1918/link-local - wire emitters into payment (initialized/confirmed/failed/expired), enrollment, and wallet connect/disconnect; migrate /admin/jobs to a timing-safe token compare - docs/webhooks.md consumer verifier + full offline test suite Closes #45 Co-authored-by: Lspnjr1 <304024794+Lspnjr1@users.noreply.github.com> * feat(security): implement TOTP two-factor authentication for admins a… (#98) * feat(stellar): publish Soroban giving-escrow contract id in stellar.toml Adds env-driven GIVING_ESCROW_CONTRACT (custom, non-SEP-1) so the deployed On-Chain Giving escrow is discoverable from /.well-known/stellar.toml. Set GIVING_ESCROW_CONTRACT_ID to enable. Includes test coverage. * fix(stellar): resolve Horizon endpoints lazily + network-aware default Horizon client was constructed at import time with a hardcoded testnet fallback, so a mainnet deploy with HORIZON_URLS unset would silently talk to testnet Horizon. Now the default is derived from STELLAR_NETWORK and the client is built lazily on first use (after env is loaded). Explicit HORIZON_URLS still wins. * feat(auth): authenticated change-password endpoint PUT /api/auth/change-password (protected): verifies current password, enforces the password policy, updates the hash, and signs out all other sessions. Adds the auth.password_change audit action. * feat(security): implement TOTP two-factor authentication for admins and mentors - Add TOTP (RFC 6238) lifecycle: secret generation, encrypted storage at rest (AES-256-GCM), otpauth:// URI and QR code generation - Add 10 single-use bcrypt-hashed recovery codes - Add two-factor rate-limiting middleware (5 verification attempts per 15-minute window) - Update login controller to issue step-up mfaToken challenges when 2FA is enabled - Update authorizeRoles('admin') middleware to enforce 2FA-enabled status and 2FA-verified sessions - Log audit actions for all 2FA lifecycle events (setup, enable, login challenge, success, failure, disable, recovery code usage) - Add comprehensive test suite in test/auth2FA.test.js and update existing auth test suites * ci: update node version to 22 and sync package-lock.json * ci: pin mongo service to 6.0 and add wait-for-mongodb step * test: add 2FA enablement and 2FA verified token to admin in refund.test.js * fix(auth): switch bcrypt imports to pure-JS bcryptjs for cross-platform compatibility * fix(test): remove duplicate MongoMemoryServer import in refund.test.js * fix(test): add errorHandler middleware to refund.test.js app * fix(test): clean up MongoDB connection logic and add afterAll hook in refund.test.js * fix(test): standardize Mongoose connection lifecycle and add missing afterAll hooks * fix(test): remove duplicate afterAll hooks and handle Multer 413 response status * test(refund): explicitly set 2FA enablement and 2FA verified tokens for admin in refund.test.js * test: ensure admin test helpers include 2FA enablement and 2FA verified JWT claims * fix(test): add 2FA enablement and 2FA verified tokens for admin in webhooks and educatorVerification tests --------- Co-authored-by: zeemscript <150973162+zeemscript@users.noreply.github.com> * Add scholarship escrow contract foundation (#108) * Improve application test coverage (#110) * Add dependency health checks (#112) * Validate auth and Stellar requests (#109) * Validate auth and Stellar requests * Address validation review feedback * Secure book deletion authorization (#113) * Secure book deletion * Keep delete response consistent * feat(stellar): gift courses/books via claimable balances with expiry reclaim (#116) Adds a gift-a-course/book flow built on Stellar claimable balances so a buyer can send an item to another user — including one who has not finished wallet onboarding — without the recipient needing a USDC trustline. The sender creates an on-ledger USDC balance the recipient claims when ready, with a sender reclaim-after-expiry predicate so funds are never stranded. Includes a GiftClaim model (no document-deleting TTL, so the record survives expiry for reclaim), a claimableBalanceService (build create/claim transactions with complementary predicates, resolve the REAL balance id from the create result XDR — not the tx hash — with a Horizon forClaimant fallback, and validate the signed gift XDR before any state change), gift routes/controller at /api/stellar/gifts, and granting item access to the RECIPIENT (never the payer) on claim. Wires a `{ fallback: "claimable_balance" }` response into the purchase flow when a creator wallet/trustline is missing. Tests cover predicate decoding, trustline-free single-signature claiming, claim authorization before/after expiry, the balance-id-vs-tx-hash distinction, tampered-XDR rejection, guards mirroring initializePayment, and the recipient access grant. * feat(stellar): add idempotency protection to the Stellar payment endpoints (#115) Makes /api/stellar/payment/initialize and /submit safe against double-clicks, client retries, and concurrent duplicates. Submit is naturally idempotent per transaction hash: the deterministic hash of the signed XDR is looked up against confirmed transactions before any processing, so a replayed submission returns the original success response without re-granting access, with the unique index on stellarTxHash as the database-level backstop (an E11000 on the confirm save is treated as already processed). Initialize no longer piles up duplicates: a pending checkout for the same user+item returns the existing record (with its persisted unsigned XDR) instead of creating a new document, and stale pending records are reaped by the existing pending-only TTL index. Adds a stricter per-user rate limiter (paymentLimiter) on the payment routes, keyed on the authenticated user id with an IPv6-aware IP fallback. Covers duplicate-submit, duplicate-initialize, the E11000 race, and limiter enforcement with tests. * feat(stellar): validate Stellar config at startup and document the mainnet switch (#114) Adds a single source of truth for the Stellar network configuration (src/config/stellar.js) that resolves the network name, network passphrase, Horizon URLs, and USDC issuer from STELLAR_NETWORK, and validates the whole setup fail-fast at boot so a misconfigured deployment (bad network value, mainnet flag with testnet Horizon or issuer) fails with an error naming the exact problem instead of at request time. stellarService.js and horizonClient.js now consume this module. Adds docs/MAINNET.md covering the env changes, creator trustlines, and a first-mainnet-transaction smoke checklist, plus unit tests for resolution and validation across both networks. * feat(stellar): fee-bump sponsorship with structural whitelist and spend caps (#111) Let the platform pay a user's Stellar network fee by wrapping the user-signed transaction in a fee-bump signed by a dedicated fee-source account, so a user holding USDC but ~no XLM can buy a book, buy a course, or donate. Opt-in per submit via `requestSponsorship: true`; off by default and byte-for-byte identical when disabled. Guard rails (server signs on the platform's behalf): - Structural whitelist (reject-by-default, allow-list of `payment` ops only): source, exact op count/order, destinations, amounts (stroops), asset, and memo must match the pending Transaction row exactly. Any foreign/extra operation — including unknown future types — is rejected. - Durable spend caps (SponsorshipSpend, keyed by UTC day): per-transaction fee ceiling, per-day total, and per-user per-day count. - Sponsor float pre-check so an underfunded sponsor never marks the user's transaction failed. Fee-bump fee is priced over inner ops + wrapper and clamped to the ceiling (verified against @stellar/stellar-sdk v16 and asserted in tests). Sponsored rows record `sponsored`, `sponsorFeeCharged`, and the fee-bump (outer) hash alongside the inner hash. Sponsorship-specific failures return a distinct non-fatal 4xx/503 with `retryUnsponsored: true` and never mark the row failed. - Config: FEE_SPONSOR_* in .env.example + validateEnv (fail-fast at boot when enabled with a missing/invalid secret); secret never logged or returned. - Admin status endpoint GET /api/stellar/payment/sponsorship/status exposes the sponsor public key, live float, caps, and today's spend. - Prometheus counters for approved/rejected sponsorship decisions. - Docs: docs/fee-sponsorship.md, README, and openapi.yaml. - Tests: feeSponsorService (whitelist adversarial matrix, fee correctness, inner-untouched, caps, secret handling, boot config) and feeSponsorSubmit (payment + donation flag-off regression, flag-on sponsorship, cap/whitelist rejections that don't fail the row). Closes #30 * feat: add managed course categories (#118) * feat(courses): add managed category taxonomy * fix(categories): preserve legacy course creation * feat: add recurring sadaqah pledges (#119) * feat(donations): add recurring sadaqah pledges * fix(pledges): preserve donation test compatibility * fix(pledges): ignore non-persisted transactions * feat(stellar): add loyalty points Soroban contract and service (closes #161) --------- Co-authored-by: Afeez Tomisin <132703022+Banx17@users.noreply.github.com> Co-authored-by: zeemscript <150973162+zeemscript@users.noreply.github.com> Co-authored-by: Alabi Ibrahim Abimbola <139625252+abimbolaalabi@users.noreply.github.com> Co-authored-by: Kelechukwu Izuaba <144479895+Kaycee276@users.noreply.github.com> Co-authored-by: BountySpaghetti Co-authored-by: BountySpaghetti <286941608+BountySpaghetti@users.noreply.github.com> Co-authored-by: Samuel Ojetunde Co-authored-by: abimbolaalabi Co-authored-by: Lspnjr1 Co-authored-by: Lspnjr1 <304024794+Lspnjr1@users.noreply.github.com> Co-authored-by: Alhassan Nuhu Idris Co-authored-by: Mantissa Co-authored-by: Ezekiel Akawa Co-authored-by: Claude Opus 4.5 Co-authored-by: Mfon <67503972+TS-mfon@users.noreply.github.com> --- .env.example | 10 + contracts/loyalty-points/Cargo.toml | 15 + contracts/loyalty-points/src/lib.rs | 673 +++++++++++++++++++++++++ src/services/stellar/loyaltyService.js | 310 ++++++++++++ 4 files changed, 1008 insertions(+) create mode 100644 contracts/loyalty-points/Cargo.toml create mode 100644 contracts/loyalty-points/src/lib.rs create mode 100644 src/services/stellar/loyaltyService.js diff --git a/.env.example b/.env.example index ab6f7986..63d767fb 100644 --- a/.env.example +++ b/.env.example @@ -42,6 +42,16 @@ EMAIL_FROM=no-reply@deenbridge.com # 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) 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/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), + }; +}; From 83a1f28bd915f67d1c0a8e22d79eb234a627fac0 Mon Sep 17 00:00:00 2001 From: Ezekiel146 Date: Mon, 24 Aug 2026 20:02:21 +0100 Subject: [PATCH 04/24] feat(courses): add course analytics for creators (#193) (#287) --- app.js | 4 + .../analytics/courseAnalyticsController.js | 107 +++++++ src/controllers/courses/courseController.js | 6 + src/models/Course.js | 5 + src/routes/courses/analyticsRoutes.js | 44 +++ .../analytics/courseAnalyticsService.js | 269 ++++++++++++++++++ src/utils/analyticsCalculator.js | 263 +++++++++++++++++ test/analyticsCalculator.test.js | 156 ++++++++++ 8 files changed, 854 insertions(+) create mode 100644 src/controllers/analytics/courseAnalyticsController.js create mode 100644 src/routes/courses/analyticsRoutes.js create mode 100644 src/services/analytics/courseAnalyticsService.js create mode 100644 src/utils/analyticsCalculator.js create mode 100644 test/analyticsCalculator.test.js diff --git a/app.js b/app.js index c3fb2491..62c82b10 100644 --- a/app.js +++ b/app.js @@ -26,6 +26,7 @@ import { 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"; @@ -179,6 +180,9 @@ 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/courses", generousLimiter, courseRoutes); app.use("/api/categories", generousLimiter, categoryRoutes); app.use("/api/reels", generousLimiter, reelsRoute); 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/courses/courseController.js b/src/controllers/courses/courseController.js index 648c52b5..adde5b96 100644 --- a/src/controllers/courses/courseController.js +++ b/src/controllers/courses/courseController.js @@ -86,6 +86,12 @@ export const getCourseById = async (req, 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 }); diff --git a/src/models/Course.js b/src/models/Course.js index 000d962e..11aeb093 100644 --- a/src/models/Course.js +++ b/src/models/Course.js @@ -32,6 +32,11 @@ const courseSchema = new mongoose.Schema( default: "USDC", enum: getSupportedCodes(), }, + views: { + type: Number, + default: 0, + min: 0, + }, rating: { type: Number, default: 0, 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/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/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/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"); + }); + }); +}); From dbc8dd1b63c0bc7cce64c55a9575e3927cf97290 Mon Sep 17 00:00:00 2001 From: Ezekiel146 Date: Mon, 24 Aug 2026 20:02:57 +0100 Subject: [PATCH 05/24] feat(db): implement cursor-based pagination utilities (#188) (#286) Co-authored-by: Sakariyah Abdulhazeem <150973162+zeemscript@users.noreply.github.com> --- .../utils/__tests__/cursorPagination.test.js | 274 ++++++++++++++ mongo/utils/cursorPagination.js | 347 ++++++++++++++++++ 2 files changed, 621 insertions(+) create mode 100644 mongo/utils/__tests__/cursorPagination.test.js create mode 100644 mongo/utils/cursorPagination.js 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/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, +}; From fa299165076cdf9bb2126940b9c34d2d09d5570c Mon Sep 17 00:00:00 2001 From: Ezekiel146 Date: Mon, 24 Aug 2026 20:05:17 +0100 Subject: [PATCH 06/24] feat(db): implement read replica support (#190) (#284) Co-authored-by: Sakariyah Abdulhazeem <150973162+zeemscript@users.noreply.github.com> --- mongo/config/readPreference.js | 206 +++++++++++++++++++++++++++++ mongo/connection/replicaSet.js | 232 +++++++++++++++++++++++++++++++++ 2 files changed, 438 insertions(+) create mode 100644 mongo/config/readPreference.js create mode 100644 mongo/connection/replicaSet.js 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, +}; From 746b529767fb24f478f293ce2a11e89722c180a5 Mon Sep 17 00:00:00 2001 From: Ezekiel146 Date: Mon, 24 Aug 2026 20:11:24 +0100 Subject: [PATCH 07/24] refactor(db): create BookRepository (#172) (#285) Co-authored-by: Sakariyah Abdulhazeem <150973162+zeemscript@users.noreply.github.com> --- mongo/repositories/BookRepository.js | 456 +++++++++++++++++++++++++++ 1 file changed, 456 insertions(+) create mode 100644 mongo/repositories/BookRepository.js 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; From 2fb4e4b713722059d73feb863ca399f6eb261596 Mon Sep 17 00:00:00 2001 From: Heazzy500 Date: Mon, 24 Aug 2026 21:22:51 +0100 Subject: [PATCH 08/24] feat(stellar): add fiat on-ramp integration (#166) (#295) --- app.js | 2 + src/controllers/stellar/onrampController.js | 220 ++++++++++++++++++ src/models/OnrampTransaction.js | 103 +++++++++ src/routes/stellar/onrampRoutes.js | 20 ++ src/services/stellar/onrampService.js | 235 ++++++++++++++++++++ 5 files changed, 580 insertions(+) create mode 100644 src/controllers/stellar/onrampController.js create mode 100644 src/models/OnrampTransaction.js create mode 100644 src/routes/stellar/onrampRoutes.js create mode 100644 src/services/stellar/onrampService.js diff --git a/app.js b/app.js index 62c82b10..3cd6bc2a 100644 --- a/app.js +++ b/app.js @@ -39,6 +39,7 @@ import callRoutes from "./src/routes/callRoutes.js"; import stellarWalletRoutes from "./src/routes/stellar/walletRoutes.js"; import stellarPaymentRoutes from "./src/routes/stellar/paymentRoutes.js"; import stellarDonationRoutes from "./src/routes/stellar/donationRoutes.js"; +import 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"; @@ -198,6 +199,7 @@ app.use("/api/stellar/wallet", generousLimiter, stellarWalletRoutes); // 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); 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/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/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/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"; + } +}; From f9f9eed9b846736083321e2f771cb3eed362b556 Mon Sep 17 00:00:00 2001 From: Heazzy500 Date: Mon, 24 Aug 2026 21:39:30 +0100 Subject: [PATCH 09/24] feat(stellar): add payment analytics dashboard data (#162) (#293) Co-authored-by: Sakariyah Abdulhazeem <150973162+zeemscript@users.noreply.github.com> --- app.js | 2 + .../stellar/analyticsController.js | 114 +++++++++ src/routes/stellar/analyticsRoutes.js | 41 +++ src/services/stellar/analyticsService.js | 239 ++++++++++++++++++ src/validators/stellarAnalyticsValidators.js | 81 ++++++ 5 files changed, 477 insertions(+) create mode 100644 src/controllers/stellar/analyticsController.js create mode 100644 src/routes/stellar/analyticsRoutes.js create mode 100644 src/services/stellar/analyticsService.js create mode 100644 src/validators/stellarAnalyticsValidators.js diff --git a/app.js b/app.js index 3cd6bc2a..0de08656 100644 --- a/app.js +++ b/app.js @@ -37,6 +37,7 @@ 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"; @@ -196,6 +197,7 @@ 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/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); 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/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/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/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, +}; From 0cdfec1266b5ee3c976d25062409802938899027 Mon Sep 17 00:00:00 2001 From: Afeez Tomisin <132703022+Banx17@users.noreply.github.com> Date: Mon, 24 Aug 2026 23:38:35 +0100 Subject: [PATCH 10/24] feat(db): add full-text search abstraction (#297) Closes #185 - Add mongo/utils/textSearch.js: reusable utility wrapping MongoDB \ search operations with score-based ranking, filters, and pagination - Add mongo/mixins/Searchable.js: mixin that gives any model a consistent .search() interface built on top of textSearch.js - Add docs/full-text-search.md: comprehensive guide for configuring text indexes and using the search abstraction - Add tests for both textSearch utility (25 tests) and Searchable mixin (23 tests) covering score ranking, filters, pagination, empty results, and multi-model reuse Co-authored-by: glorious21-coder --- docs/full-text-search.md | 291 ++++++++++++++++++++++ mongo/mixins/Searchable.js | 140 +++++++++++ mongo/mixins/__tests__/Searchable.test.js | 286 +++++++++++++++++++++ mongo/utils/__tests__/textSearch.test.js | 280 +++++++++++++++++++++ mongo/utils/textSearch.js | 198 +++++++++++++++ 5 files changed, 1195 insertions(+) create mode 100644 docs/full-text-search.md create mode 100644 mongo/mixins/Searchable.js create mode 100644 mongo/mixins/__tests__/Searchable.test.js create mode 100644 mongo/utils/__tests__/textSearch.test.js create mode 100644 mongo/utils/textSearch.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/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/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/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, +}; From 17a5a5802762519f77877f2ffd6d35ff9f7994bf Mon Sep 17 00:00:00 2001 From: Ezekiel Akawa Date: Tue, 25 Aug 2026 01:16:19 +0100 Subject: [PATCH 11/24] feat(db): add MongoDB aggregation pipeline builders (#183) (#299) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds mongo/utils/aggregation.js — pure, dependency-free ESM helpers that build common aggregation stages/pipelines (callers run Model.aggregate()), matching the existing mongo/utils house style (textSearch.js, cursorPagination.js). - Group builders: groupBy, sumBy, countBy, averageBy (single-field or whole-collection grouping). - Stage builders: matchStage, sortStage, limitStage, skipStage, paginate. - Date aggregations: dateGroup(dateField, "daily"|"weekly"|"monthly", tz) and timeSeries(...) — date-bucketed group→sort pipelines (keys formatted so lexical order == chronological order). - buildPipeline({match, group, sort, skip, limit}) composes stages in canonical order, omitting empty parts. Defensive validation throws clear errors. Adds mongo/utils/__tests__/aggregation.test.js (MongoMemoryServer, 44 cases). --- mongo/utils/__tests__/aggregation.test.js | 410 ++++++++++++++++++++ mongo/utils/aggregation.js | 445 ++++++++++++++++++++++ 2 files changed, 855 insertions(+) create mode 100644 mongo/utils/__tests__/aggregation.test.js create mode 100644 mongo/utils/aggregation.js 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/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, +}; From 325c9b08d517f049942d386a09dbe3fb2f56fa4f Mon Sep 17 00:00:00 2001 From: Ezekiel Akawa Date: Tue, 25 Aug 2026 01:18:51 +0100 Subject: [PATCH 12/24] feat(courses): add course prerequisites system (#182) (#300) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Learners must complete prerequisite courses before enrolling in an advanced course. Additive and backward compatible; implemented in the real JS layout (the issue's .ts paths don't exist in this repo). - src/models/Course.js: add prerequisites: [ObjectId ref Course] (default []). - src/controllers/courses/courseController.js: normalizePrerequisites() validates each id and rejects self-reference; createCourse/updateCourse accept + persist prerequisites (update stays PATCH-like); getCourseById populates prerequisites (title, thumbnail). - enrollInCourse: before enrolling, verifies the user has COMPLETED every prerequisite (CourseProgress completedAt set or percentComplete >= 100); otherwise 400 "Complete these prerequisites first: " and no enroll. - src/validators/requestValidators.js: prerequisitesValidation (express-validator — optional array of ObjectIds, no self-reference); wired into POST / and PUT /:id in src/routes/courses/courseRoutes.js. Adds test/coursePrerequisites.test.js (MongoMemoryServer): blocks when unmet, allows when completed, and detail returns populated prerequisites. Co-authored-by: Sakariyah Abdulhazeem <150973162+zeemscript@users.noreply.github.com> --- src/controllers/courses/courseController.js | 80 +++++++++- src/models/Course.js | 5 + src/routes/courses/courseRoutes.js | 6 + src/validators/requestValidators.js | 24 +++ test/coursePrerequisites.test.js | 153 ++++++++++++++++++++ 5 files changed, 265 insertions(+), 3 deletions(-) create mode 100644 test/coursePrerequisites.test.js diff --git a/src/controllers/courses/courseController.js b/src/controllers/courses/courseController.js index adde5b96..2d926065 100644 --- a/src/controllers/courses/courseController.js +++ b/src/controllers/courses/courseController.js @@ -1,4 +1,5 @@ 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"; @@ -11,13 +12,37 @@ import { 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 * Note: Files are uploaded from frontend directly to Cloudinary * 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}`); @@ -27,6 +52,12 @@ export const createCourse = catchAsync(async (req, res, next) => { new APIError("Title, description, and category are required", 400) ); } + + 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)); @@ -42,6 +73,7 @@ export const createCourse = catchAsync(async (req, res, next) => { 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}`); @@ -80,7 +112,9 @@ 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) @@ -152,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(); @@ -193,7 +257,8 @@ 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}`); @@ -204,6 +269,12 @@ export const updateCourse = catchAsync(async (req, res, next) => { return next(new APIError("Course not found", 404)); } + 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; @@ -221,6 +292,9 @@ export const updateCourse = catchAsync(async (req, res, next) => { 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/models/Course.js b/src/models/Course.js index 11aeb093..eee7b6cd 100644 --- a/src/models/Course.js +++ b/src/models/Course.js @@ -79,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, diff --git a/src/routes/courses/courseRoutes.js b/src/routes/courses/courseRoutes.js index 9c709e3d..528c70ee 100644 --- a/src/routes/courses/courseRoutes.js +++ b/src/routes/courses/courseRoutes.js @@ -32,6 +32,8 @@ 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(); @@ -78,6 +80,8 @@ router.post( "/", protect, requireVerifiedEducator, + prerequisitesValidation, + validate, invalidateCacheMiddleware([`${CACHE_KEYS.COURSES}*`, `${CACHE_KEYS.EDUCATORS}*`]), createCourse ); @@ -139,6 +143,8 @@ router.put( "/:id", protect, authorizeOwnership({ model: Course, ownerField: "createdBy", resourceType: "Course" }), + prerequisitesValidation, + validate, invalidateCacheMiddleware([`${CACHE_KEYS.COURSES}*`, `${CACHE_KEYS.COURSE}*`]), updateCourse ); diff --git a/src/validators/requestValidators.js b/src/validators/requestValidators.js index d6d72858..7dcaf45a 100644 --- a/src/validators/requestValidators.js +++ b/src/validators/requestValidators.js @@ -114,10 +114,34 @@ export const connectWalletValidation = [ .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/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(); + } + }); +}); From fc608350fa4b253aef670de258d282aecac8540d Mon Sep 17 00:00:00 2001 From: Olarewaju1234 Date: Tue, 25 Aug 2026 08:44:10 +0100 Subject: [PATCH 13/24] feat(mongo): add TransactionRepository with analytics methods (#306) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create TransactionRepository extending BaseRepository with methods for transaction queries and analytics including findByUser, findByStatus, findByDateRange, getVolumeStats, getDailyVolume, and getCreatorEarnings aggregations. Follows established repository pattern conventions. Fixes #170 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Sakariyah Abdulhazeem <150973162+zeemscript@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 --- mongo/repositories/TransactionRepository.js | 555 ++++++++++++++++++++ 1 file changed, 555 insertions(+) create mode 100644 mongo/repositories/TransactionRepository.js 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; From 2714080c8931204d282a7b13b81fb57daffaa4e1 Mon Sep 17 00:00:00 2001 From: rasheedayinde015-a11y Date: Tue, 25 Aug 2026 08:48:32 +0100 Subject: [PATCH 14/24] feat(reels): add duet/stitch response videos (#211) (#303) Users can create response videos ("duets" and "stitches") linked to an original reel: - Reel model: add originalReelId (ref Reel, nullable), duetType enum (duet|stitch), stitchClip {start,end} for the stitched portion, a composition descriptor, and duetCount/stitchCount surfaced on the original. Add an originalReelId+createdAt index for browsing. - utils/videoCompositor.js: dependency-free compositing abstraction that records intent/metadata (side-by-side for duet, prepend-clip for stitch) and returns a composition descriptor. Actual frame compositing is delegated to the media pipeline (no ffmpeg / no new deps). - services/reelDuetService.js: create a derivative reel linked to the original and atomically increment the original's counter; list derivatives for a reel (paginated, optional type filter). - reelController + reelsRoutes: POST /api/reels/:id/duet (create) and GET /api/reels/:id/duets (browse), with express-validator validation. Counts are surfaced on the reel response envelope. - test/reelDuet.test.js: ESM Jest + mongodb-memory-server covering duet linkage/count, stitch clip range, validation, and listing. Implemented in JavaScript to match the repo (no TypeScript tooling) and adds no new npm dependencies. --- src/controllers/reelController.js | 105 ++++++++++++++++++++ src/models/Reel.js | 29 ++++++ src/routes/reelsRoutes.js | 25 +++++ src/services/reelDuetService.js | 148 +++++++++++++++++++++++++++ src/utils/videoCompositor.js | 86 ++++++++++++++++ src/validators/reelValidators.js | 75 ++++++++++++++ test/reelDuet.test.js | 159 ++++++++++++++++++++++++++++++ 7 files changed, 627 insertions(+) create mode 100644 src/services/reelDuetService.js create mode 100644 src/utils/videoCompositor.js create mode 100644 src/validators/reelValidators.js create mode 100644 test/reelDuet.test.js 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/models/Reel.js b/src/models/Reel.js index cb696516..d733be2c 100644 --- a/src/models/Reel.js +++ b/src/models/Reel.js @@ -27,6 +27,33 @@ const reelSchema = new Schema( 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 +65,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/routes/reelsRoutes.js b/src/routes/reelsRoutes.js index 7bb5ebed..02323efc 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,22 @@ 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 +); + export default router; \ No newline at end of file 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/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/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/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"); + }); +}); From cdaf507cea67dec0657d3aa868462dfbe46d026b Mon Sep 17 00:00:00 2001 From: Olarewaju1234 Date: Tue, 25 Aug 2026 08:52:05 +0100 Subject: [PATCH 15/24] feat(mongo): add SpaceRepository for member management (#305) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create SpaceRepository extending BaseRepository with specialized queries for space management including findByOwner, findByMember, findPublic, getMembers, addMember, removeMember, and isMember operations. Follows established repository pattern conventions. Fixes #175 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Sakariyah Abdulhazeem <150973162+zeemscript@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 --- mongo/repositories/SpaceRepository.js | 484 ++++++++++++++++++++++++++ 1 file changed, 484 insertions(+) create mode 100644 mongo/repositories/SpaceRepository.js 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; From 409b7f68f26e17ffa4a5e146f8de31e5dde2e846 Mon Sep 17 00:00:00 2001 From: Olarewaju1234 Date: Tue, 25 Aug 2026 08:57:47 +0100 Subject: [PATCH 16/24] feat(mongo): add QueryBuilder utility with fluent API (#304) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create a QueryBuilder class that provides a fluent interface for building Mongoose queries with chainable methods for where, select, sort, limit, skip, populate, paginate, and lean operations. Includes comprehensive test suite covering all query methods, pagination helpers, and edge cases. Fixes #180 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Sakariyah Abdulhazeem <150973162+zeemscript@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 --- mongo/utils/QueryBuilder.js | 437 +++++++++++++++++++++ mongo/utils/__tests__/QueryBuilder.test.js | 320 +++++++++++++++ 2 files changed, 757 insertions(+) create mode 100644 mongo/utils/QueryBuilder.js create mode 100644 mongo/utils/__tests__/QueryBuilder.test.js 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); + }); + }); +}); From a79b3f7e8a8465210af6093b05bec3b6b3079eea Mon Sep 17 00:00:00 2001 From: Afeez Tomisin <132703022+Banx17@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:58:15 +0100 Subject: [PATCH 17/24] refactor(db): Create EducatorBalanceRepository (closes #179) (#302) Add EducatorBalanceRepository extending BaseRepository with: - findByEducator, getAvailableBalance, getPendingAmount - getTransactionHistory with pagination (queries LedgerEntry collection) - reconcileBalance to detect stored vs computed balance mismatches - Atomic balance mutations using + aggregation pipeline updates - deductOwedBalance, creditOwedBalance, settleOwedToSettled Includes BaseRepository from merged PR #276 and 47 tests covering all methods including concurrent withdrawal atomicity testing. Co-authored-by: glorious21-coder Co-authored-by: Sakariyah Abdulhazeem <150973162+zeemscript@users.noreply.github.com> --- mongo/base/BaseRepository.js | 735 ++++++++++++++++++ mongo/index.js | 22 +- .../repositories/EducatorBalanceRepository.js | 526 +++++++++++++ .../EducatorBalanceRepository.test.js | 617 +++++++++++++++ 4 files changed, 1891 insertions(+), 9 deletions(-) create mode 100644 mongo/base/BaseRepository.js create mode 100644 mongo/repositories/EducatorBalanceRepository.js create mode 100644 mongo/repositories/__tests__/EducatorBalanceRepository.test.js 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/index.js b/mongo/index.js index 8b56d10b..2dccee60 100644 --- a/mongo/index.js +++ b/mongo/index.js @@ -11,8 +11,10 @@ * ``` * mongo/ * ├── index.js ← you are here: re-exports everything below - * └── base/ ← shared repository base classes - * └── BaseRepository.js (lands with Deen-Bridge/dnb-backend#168) + * ├── base/ ← shared repository base classes + * │ └── BaseRepository.js + * └── repositories/ ← model-specific repositories + * └── EducatorBalanceRepository.js * ``` * * Intended usage once model-specific repositories start landing: @@ -31,17 +33,19 @@ * - Every exported function/class carries complete JSDoc. */ +import BaseRepository from "./base/BaseRepository.js"; + /** * Namespace for shared repository base classes. * - * Currently empty pending #168, which introduces `base.BaseRepository` — the - * abstract CRUD/pagination/error-handling superclass every model-specific - * repository extends. Kept as a stable, frozen object so consumers can start - * importing `base` today without a breaking change when members land. - * - * @type {Readonly<{}> + * @type {{ BaseRepository: typeof BaseRepository }} + */ +export const base = Object.freeze({ BaseRepository }); + +/** + * Model-specific repositories. */ -export const base = Object.freeze({}); +export { default as EducatorBalanceRepository } from "./repositories/EducatorBalanceRepository.js"; /** * Default export mirrors the named exports for callers that prefer 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/__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"); + }); + }); +}); From 4127105bdd82356702106f63960c0e9fe5410109 Mon Sep 17 00:00:00 2001 From: Afeez Tomisin <132703022+Banx17@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:03:51 +0100 Subject: [PATCH 18/24] refactor(db): Create NotificationRepository (closes #177) (#298) - Add BaseRepository from dev branch (Deen-Bridge/dnb-backend#168) to mongo/base/ - Update mongo/index.js to export BaseRepository from the base namespace - Create NotificationRepository extending BaseRepository with: - findByUser: query notifications by recipient with pagination support - findUnread: query unread notifications for a user - findByType: filter notifications by type enum - findByPriority: filter notifications by priority enum - markAsRead: mark a single notification as read - markAllAsRead: bulk mark all unread notifications as read - markManyAsRead: mark multiple specific notifications as read - deleteOlderThan: hard delete old notifications (TTL-style cleanup) - softDeleteOlderThan: soft delete old notifications (archival) - Add comprehensive tests (49 tests) covering all methods - Note: Notification schema has no MongoDB TTL index; cleanup is manual via deleteOlderThan/softDeleteOlderThan Co-authored-by: glorious21-coder Co-authored-by: Sakariyah Abdulhazeem <150973162+zeemscript@users.noreply.github.com> --- mongo/index.js | 11 +- mongo/repositories/NotificationRepository.js | 419 ++++++++++++ .../__tests__/NotificationRepository.test.js | 604 ++++++++++++++++++ 3 files changed, 1031 insertions(+), 3 deletions(-) create mode 100644 mongo/repositories/NotificationRepository.js create mode 100644 mongo/repositories/__tests__/NotificationRepository.test.js diff --git a/mongo/index.js b/mongo/index.js index 2dccee60..9cf24db7 100644 --- a/mongo/index.js +++ b/mongo/index.js @@ -14,15 +14,20 @@ * ├── base/ ← shared repository base classes * │ └── BaseRepository.js * └── repositories/ ← model-specific repositories + * ├── BookRepository.js + * └── NotificationRepository.js * └── EducatorBalanceRepository.js * ``` * - * Intended usage once model-specific repositories start landing: + * Intended usage: * * ```js - * import { base } from "../mongo/index.js"; + * import BaseRepository from "../mongo/base/BaseRepository.js"; * - * class CourseRepository extends base.BaseRepository { + * class CourseRepository extends BaseRepository { + * constructor() { + * super(Course); + * } * // thin, course-specific query helpers only * } * ``` 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/__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"); + }); + }); +}); From ae8c612c0edf83fc83875da6e58f04b03d6aac2d Mon Sep 17 00:00:00 2001 From: Afeez Tomisin <132703022+Banx17@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:05:03 +0100 Subject: [PATCH 19/24] refactor(db): Create ReelRepository (closes #176) (#301) - Add BaseRepository from dev branch (Deen-Bridge/dnb-backend#168) to mongo/base/ - Update mongo/index.js to export BaseRepository and ReelRepository - Create ReelRepository extending BaseRepository with: - findByCreator: query reels by creator with pagination support - findBySpace: placeholder for future schema changes (Reel model has no spaceId yet) - findTrending: reels ranked by engagement (views + likes*2 + loves*3 + comments*4) - Engagement tracking: incrementViewCount, addLike, removeLike, addLove, removeLove, addComment, removeComment, incrementShareCount - filter: flexible multi-criterion filtering with sorting and pagination - Add comprehensive tests (62 tests) covering all methods - Note: Reel schema has no spaceId field; findBySpace returns empty results until schema is updated Co-authored-by: glorious21-coder Co-authored-by: Sakariyah Abdulhazeem <150973162+zeemscript@users.noreply.github.com> --- mongo/index.js | 11 + mongo/repositories/ReelRepository.js | 613 ++++++++++++++++ .../__tests__/ReelRepository.test.js | 680 ++++++++++++++++++ 3 files changed, 1304 insertions(+) create mode 100644 mongo/repositories/ReelRepository.js create mode 100644 mongo/repositories/__tests__/ReelRepository.test.js diff --git a/mongo/index.js b/mongo/index.js index 9cf24db7..c51174e4 100644 --- a/mongo/index.js +++ b/mongo/index.js @@ -15,6 +15,8 @@ * │ └── BaseRepository.js * └── repositories/ ← model-specific repositories * ├── BookRepository.js + * ├── NotificationRepository.js + * └── ReelRepository.js * └── NotificationRepository.js * └── EducatorBalanceRepository.js * ``` @@ -22,6 +24,12 @@ * 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 { @@ -50,6 +58,9 @@ 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"; /** 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/__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); + }); + }); +}); From e0099baa477e196e8d1d989973634cfcb414f89b Mon Sep 17 00:00:00 2001 From: makuo567 Date: Tue, 25 Aug 2026 10:58:49 +0100 Subject: [PATCH 20/24] feat: implement live session polls, highlights & notes, content moderation queue, and reading groups (#307) - Implement live session polls (#210) - Add text highlights and notes feature for books (#204) - Add content moderation queue and workflow for reels (#213) - Implement book clubs and reading groups (#205) --- app.js | 5 + src/controllers/highlight.controller.js | 168 ++++++++++ src/controllers/highlight.controller.ts | 169 ++++++++++ src/controllers/moderation.controller.js | 96 ++++++ src/controllers/moderation.controller.ts | 97 ++++++ src/controllers/reading-group.controller.js | 186 +++++++++++ src/controllers/reading-group.controller.ts | 187 +++++++++++ src/controllers/space-poll.controller.js | 110 +++++++ src/controllers/space-poll.controller.ts | 111 +++++++ src/models/Reel.js | 3 + src/models/content-flag.model.js | 40 +++ src/models/content-flag.model.ts | 40 +++ src/models/highlight.model.js | 43 +++ src/models/highlight.model.ts | 43 +++ src/models/moderation-action.model.js | 35 ++ src/models/moderation-action.model.ts | 35 ++ src/models/note.model.js | 40 +++ src/models/note.model.ts | 40 +++ src/models/poll-vote.model.js | 33 ++ src/models/poll-vote.model.ts | 33 ++ src/models/reading-group-member.model.js | 45 +++ src/models/reading-group-member.model.ts | 45 +++ src/models/reading-group.model.js | 63 ++++ src/models/reading-group.model.ts | 63 ++++ src/models/space-poll.model.js | 50 +++ src/models/space-poll.model.ts | 50 +++ src/routes/admin/moderationRoutes.js | 19 ++ src/routes/books/bookRoutes.js | 25 ++ src/routes/readingGroupRoutes.js | 29 ++ src/routes/reelsRoutes.js | 4 + src/routes/spaceRoutes.js | 17 + src/services/highlight.service.js | 204 ++++++++++++ src/services/highlight.service.ts | 257 +++++++++++++++ src/services/moderation.service.js | 214 +++++++++++++ src/services/moderation.service.ts | 256 +++++++++++++++ src/services/reading-group.service.js | 280 ++++++++++++++++ src/services/reading-group.service.ts | 335 ++++++++++++++++++++ src/services/space-poll.service.js | 177 +++++++++++ src/services/space-poll.service.ts | 204 ++++++++++++ src/sockets/space-poll.socket.js | 60 ++++ src/sockets/space-poll.socket.ts | 61 ++++ test/highlight.test.js | 175 ++++++++++ test/moderation.test.js | 155 +++++++++ test/readingGroup.test.js | 220 +++++++++++++ test/spacePoll.test.js | 227 +++++++++++++ 45 files changed, 4749 insertions(+) create mode 100644 src/controllers/highlight.controller.js create mode 100644 src/controllers/highlight.controller.ts create mode 100644 src/controllers/moderation.controller.js create mode 100644 src/controllers/moderation.controller.ts create mode 100644 src/controllers/reading-group.controller.js create mode 100644 src/controllers/reading-group.controller.ts create mode 100644 src/controllers/space-poll.controller.js create mode 100644 src/controllers/space-poll.controller.ts create mode 100644 src/models/content-flag.model.js create mode 100644 src/models/content-flag.model.ts create mode 100644 src/models/highlight.model.js create mode 100644 src/models/highlight.model.ts create mode 100644 src/models/moderation-action.model.js create mode 100644 src/models/moderation-action.model.ts create mode 100644 src/models/note.model.js create mode 100644 src/models/note.model.ts create mode 100644 src/models/poll-vote.model.js create mode 100644 src/models/poll-vote.model.ts create mode 100644 src/models/reading-group-member.model.js create mode 100644 src/models/reading-group-member.model.ts create mode 100644 src/models/reading-group.model.js create mode 100644 src/models/reading-group.model.ts create mode 100644 src/models/space-poll.model.js create mode 100644 src/models/space-poll.model.ts create mode 100644 src/routes/admin/moderationRoutes.js create mode 100644 src/routes/readingGroupRoutes.js create mode 100644 src/services/highlight.service.js create mode 100644 src/services/highlight.service.ts create mode 100644 src/services/moderation.service.js create mode 100644 src/services/moderation.service.ts create mode 100644 src/services/reading-group.service.js create mode 100644 src/services/reading-group.service.ts create mode 100644 src/services/space-poll.service.js create mode 100644 src/services/space-poll.service.ts create mode 100644 src/sockets/space-poll.socket.js create mode 100644 src/sockets/space-poll.socket.ts create mode 100644 test/highlight.test.js create mode 100644 test/moderation.test.js create mode 100644 test/readingGroup.test.js create mode 100644 test/spacePoll.test.js diff --git a/app.js b/app.js index 0de08656..d7bbb5a7 100644 --- a/app.js +++ b/app.js @@ -54,7 +54,9 @@ import educatorRoutes from "./src/routes/educatorRoutes.js"; 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 { healthCheck, ping } from "./src/controllers/healthController.js"; const app = express(); @@ -190,6 +192,8 @@ app.use("/api/categories", generousLimiter, categoryRoutes); app.use("/api/reels", generousLimiter, reelsRoute); app.use("/api/books", generousLimiter, bookRoutes); app.use("/api/books", generousLimiter, recommendedBooksRoutes); +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); @@ -216,6 +220,7 @@ app.use("/api/internal/ai", internalAiRoutes); 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 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/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/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/models/Reel.js b/src/models/Reel.js index d733be2c..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,6 +23,8 @@ 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", 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/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/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/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/books/bookRoutes.js b/src/routes/books/bookRoutes.js index d4a4a5b5..b3d9b60d 100644 --- a/src/routes/books/bookRoutes.js +++ b/src/routes/books/bookRoutes.js @@ -145,5 +145,30 @@ router.delete( 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/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 02323efc..e0b3c013 100644 --- a/src/routes/reelsRoutes.js +++ b/src/routes/reelsRoutes.js @@ -50,4 +50,8 @@ router.get( 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 934530b1..21a68474 100644 --- a/src/routes/spaceRoutes.js +++ b/src/routes/spaceRoutes.js @@ -84,4 +84,21 @@ router.delete( 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/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/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/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/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/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/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/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/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/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); + }); +}); From 88136bd2ccde518d4040811d7274a29079d30fbd Mon Sep 17 00:00:00 2001 From: Kelechukwu Izuaba <144479895+Kaycee276@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:01:00 +0100 Subject: [PATCH 21/24] feat: course bundles, certificates PDF, db health check, course completion badges (#308) - #184: Implement course bundles model, service, controller, and routes with discount calculation and bulk enrollment purchase endpoint - #186: Add PDF certificate generation using pdfkit, certificate schema, service, template, and download endpoint - #194: Add database health check endpoint /health/database with MongoDB ping, response time, connection status and timeout handling - #187: Implement course completion milestone badges schema, criteria definitions, awarding service, and user profile badge integration --- app.js | 9 + mongo/utils/healthCheck.js | 62 ++++++ package-lock.json | 167 +++++++++++++++- package.json | 1 + routes/health/database.js | 1 + .../analytics/analyticsController.js | 2 + src/controllers/badge.controller.js | 57 ++++++ src/controllers/badge.controller.ts | 57 ++++++ src/controllers/certificate.controller.js | 78 ++++++++ src/controllers/certificate.controller.ts | 78 ++++++++ src/controllers/course-bundle.controller.js | 129 ++++++++++++ src/controllers/course-bundle.controller.ts | 129 ++++++++++++ src/controllers/userController.js | 12 +- src/models/badge.model.js | 41 ++++ src/models/badge.model.ts | 41 ++++ src/models/certificate.model.js | 53 +++++ src/models/certificate.model.ts | 53 +++++ src/models/course-bundle.model.js | 55 ++++++ src/models/course-bundle.model.ts | 55 ++++++ src/models/user-badge.model.js | 30 +++ src/models/user-badge.model.ts | 30 +++ src/mongo/utils/healthCheck.js | 2 + src/routes/badge.routes.js | 19 ++ src/routes/badge.routes.ts | 19 ++ src/routes/certificate.routes.js | 21 ++ src/routes/certificate.routes.ts | 21 ++ src/routes/course-bundle.routes.js | 26 +++ src/routes/course-bundle.routes.ts | 26 +++ src/routes/courses/courseRoutes.js | 2 + src/routes/health/database.js | 30 +++ src/routes/userRoutes.js | 4 + src/services/badge.service.js | 87 +++++++++ src/services/badge.service.ts | 101 ++++++++++ src/services/certificate.service.js | 87 +++++++++ src/services/certificate.service.ts | 93 +++++++++ src/services/course-bundle.service.js | 180 +++++++++++++++++ src/services/course-bundle.service.ts | 183 ++++++++++++++++++ src/templates/certificate.template.js | 139 +++++++++++++ src/templates/certificate.template.ts | 139 +++++++++++++ src/utils/badge-criteria.js | 47 +++++ src/utils/badge-criteria.ts | 47 +++++ test/badge.test.js | 100 ++++++++++ test/certificate.test.js | 111 +++++++++++ test/courseBundle.test.js | 143 ++++++++++++++ test/dbHealth.test.js | 23 +++ 45 files changed, 2788 insertions(+), 2 deletions(-) create mode 100644 mongo/utils/healthCheck.js create mode 100644 routes/health/database.js create mode 100644 src/controllers/badge.controller.js create mode 100644 src/controllers/badge.controller.ts create mode 100644 src/controllers/certificate.controller.js create mode 100644 src/controllers/certificate.controller.ts create mode 100644 src/controllers/course-bundle.controller.js create mode 100644 src/controllers/course-bundle.controller.ts create mode 100644 src/models/badge.model.js create mode 100644 src/models/badge.model.ts create mode 100644 src/models/certificate.model.js create mode 100644 src/models/certificate.model.ts create mode 100644 src/models/course-bundle.model.js create mode 100644 src/models/course-bundle.model.ts create mode 100644 src/models/user-badge.model.js create mode 100644 src/models/user-badge.model.ts create mode 100644 src/mongo/utils/healthCheck.js create mode 100644 src/routes/badge.routes.js create mode 100644 src/routes/badge.routes.ts create mode 100644 src/routes/certificate.routes.js create mode 100644 src/routes/certificate.routes.ts create mode 100644 src/routes/course-bundle.routes.js create mode 100644 src/routes/course-bundle.routes.ts create mode 100644 src/routes/health/database.js create mode 100644 src/services/badge.service.js create mode 100644 src/services/badge.service.ts create mode 100644 src/services/certificate.service.js create mode 100644 src/services/certificate.service.ts create mode 100644 src/services/course-bundle.service.js create mode 100644 src/services/course-bundle.service.ts create mode 100644 src/templates/certificate.template.js create mode 100644 src/templates/certificate.template.ts create mode 100644 src/utils/badge-criteria.js create mode 100644 src/utils/badge-criteria.ts create mode 100644 test/badge.test.js create mode 100644 test/certificate.test.js create mode 100644 test/courseBundle.test.js create mode 100644 test/dbHealth.test.js diff --git a/app.js b/app.js index d7bbb5a7..da6cf152 100644 --- a/app.js +++ b/app.js @@ -57,7 +57,11 @@ 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 { healthCheck, ping } from "./src/controllers/healthController.js"; +import databaseHealthRoutes from "./src/routes/health/database.js"; const app = express(); @@ -170,6 +174,7 @@ app.get("/", (req, res) => { 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); @@ -187,6 +192,10 @@ app.use("/api/payouts", standardLimiter, payoutRoutes); // 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); 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/package-lock.json b/package-lock.json index 1678f6ca..b38222d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "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", @@ -1054,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", @@ -1334,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", @@ -2184,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", @@ -2475,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", @@ -2975,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", @@ -3508,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", @@ -3553,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", @@ -3694,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", @@ -5275,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", @@ -6457,6 +6551,12 @@ "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", @@ -6530,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", @@ -6665,6 +6791,14 @@ "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", @@ -7097,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", @@ -8047,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", @@ -8139,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": { @@ -8227,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", diff --git a/package.json b/package.json index 51efca64..1bfc51bd 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "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", 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/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/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/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/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/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/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/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/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/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/courseRoutes.js b/src/routes/courses/courseRoutes.js index 528c70ee..9b0f05cd 100644 --- a/src/routes/courses/courseRoutes.js +++ b/src/routes/courses/courseRoutes.js @@ -22,6 +22,7 @@ import { getCourseProgress, updateCourseProgress, } from "../../controllers/analytics/analyticsController.js"; +import { getBundlesByCourse } from "../../controllers/course-bundle.controller.js"; import { protect, requireVerifiedEducator } from "../../middlewares/authMiddleware.js"; import { authorizeOwnership, @@ -67,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( 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/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/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/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/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/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/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/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/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/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"); + }); +}); From d32301d9d5445cd96ebe1dd311571cc3b58e8c90 Mon Sep 17 00:00:00 2001 From: Olarewaju1234 Date: Tue, 25 Aug 2026 14:55:34 +0100 Subject: [PATCH 22/24] feat(books): implement reading progress sync (#203) (#310) Adds per-user per-book reading progress so readers can resume from their last position and see completion percentages on their library. - ReadingProgress model keyed by { user, book } with a unique compound index, storing page/percentage (0-100)/lastPosition, a monotonic version and timestamps. - Service upserts progress (one record per user+book), exposes a resume getter and augments the library listing with progress %. - Controller + routes mounted at /api/books: PUT /api/books/:bookId/progress (update/upsert) GET /api/books/:bookId/progress (resume) GET /api/books/library/progress (library with progress %) Inputs validated with express-validator (percentage 0-100, page >= 0). Real-time sync: socket.io is a dependency but no io server is wired in this repo, so REST is the reliable sync path and clients can poll the version/updatedAt fields to detect cross-device changes. A reading-progress socket namespace + emitProgress() seam (mirroring space-poll.socket.js) is provided so live push activates automatically once a socket.io server is attached, with zero import-time side effects. Implemented in the repo's real JavaScript ESM layout (the issue's .ts paths do not apply; this project has no TypeScript tooling). No new dependencies. Adds a Jest suite covering upsert uniqueness, overwrite + version/updatedAt bump, resume, percentage bounds validation and the library listing. --- app.js | 3 + .../books/readingProgressController.js | 61 +++++++ src/models/ReadingProgress.js | 75 ++++++++ src/routes/books/readingProgressRoutes.js | 38 ++++ src/services/reading-progress.service.js | 109 +++++++++++ src/sockets/reading-progress.socket.js | 68 +++++++ src/validators/readingProgressValidators.js | 50 +++++ test/readingProgress.test.js | 172 ++++++++++++++++++ 8 files changed, 576 insertions(+) create mode 100644 src/controllers/books/readingProgressController.js create mode 100644 src/models/ReadingProgress.js create mode 100644 src/routes/books/readingProgressRoutes.js create mode 100644 src/services/reading-progress.service.js create mode 100644 src/sockets/reading-progress.socket.js create mode 100644 src/validators/readingProgressValidators.js create mode 100644 test/readingProgress.test.js diff --git a/app.js b/app.js index da6cf152..1b6cec6a 100644 --- a/app.js +++ b/app.js @@ -31,6 +31,7 @@ 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"; @@ -201,6 +202,8 @@ 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); 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/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/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/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/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/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/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()); + }); +}); From 866ef179533c1736f0bf6e5b6f24cecc74ffeaad Mon Sep 17 00:00:00 2001 From: Meeynarh1 Date: Tue, 25 Aug 2026 14:56:02 +0100 Subject: [PATCH 23/24] feat(db): add connection pool monitoring metrics endpoint (#192) (#309) Add a MongoDB connection-pool metrics collector and a Prometheus-compatible metrics endpoint at GET /metrics/database. - mongo/monitoring/poolMetrics.js: hand-rolled collector that subscribes to the MongoDB driver CMAP pool events (connectionCreated/Closed, checkedOut/In, checkOutStarted/Failed, pool created/ready/cleared) plus connection errors, and derives open/in-use/available/wait-queue gauges and readyState. No import-time side effects; degrades gracefully when the DB is not connected. - src/routes/metrics/database.js (+ routes/metrics/database.js re-export shim): renders the metrics in Prometheus text exposition format (# HELP / # TYPE / labelled samples). Mounted in app.js. - src/config/db.js: attaches the collector to the live connection after mongoose.connect (best-effort). - docs/connection-pool-metrics.md: Prometheus scrape config, Grafana panel JSON examples, and suggested alert rules. - Jest unit test drives synthetic pool events, no live DB required. Pure JavaScript ESM. No new npm dependencies (Prometheus format hand-rolled). --- app.js | 5 + docs/connection-pool-metrics.md | 153 ++++++++ .../monitoring/__tests__/poolMetrics.test.js | 119 ++++++ mongo/monitoring/poolMetrics.js | 356 ++++++++++++++++++ routes/metrics/database.js | 5 + src/config/db.js | 9 + src/routes/metrics/database.js | 36 ++ 7 files changed, 683 insertions(+) create mode 100644 docs/connection-pool-metrics.md create mode 100644 mongo/monitoring/__tests__/poolMetrics.test.js create mode 100644 mongo/monitoring/poolMetrics.js create mode 100644 routes/metrics/database.js create mode 100644 src/routes/metrics/database.js diff --git a/app.js b/app.js index 1b6cec6a..973d26fc 100644 --- a/app.js +++ b/app.js @@ -63,6 +63,7 @@ import certificateRoutes from "./src/routes/certificate.routes.js"; import badgeRoutes from "./src/routes/badge.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(); @@ -228,6 +229,10 @@ 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); 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/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/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/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/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; From 2b88d8bd81242342e176c15450b25ab9f3523058 Mon Sep 17 00:00:00 2001 From: Sakariyah Abdulhazeem <150973162+zeemscript@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:06:06 +0100 Subject: [PATCH 24/24] feat(social): add real-time direct messaging system (closes #214) --- app.js | 2 + src/controllers/messaging.controller.ts | 95 +++++++++++++++++ src/models/conversation.model.ts | 34 ++++++ src/models/message.model.ts | 39 +++++++ src/routes/messaging.routes.js | 19 ++++ src/services/messaging.service.ts | 131 ++++++++++++++++++++++++ src/sockets/messaging.socket.ts | 76 ++++++++++++++ 7 files changed, 396 insertions(+) create mode 100644 src/controllers/messaging.controller.ts create mode 100644 src/models/conversation.model.ts create mode 100644 src/models/message.model.ts create mode 100644 src/routes/messaging.routes.js create mode 100644 src/services/messaging.service.ts create mode 100644 src/sockets/messaging.socket.ts diff --git a/app.js b/app.js index 973d26fc..12312200 100644 --- a/app.js +++ b/app.js @@ -61,6 +61,7 @@ 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"; @@ -222,6 +223,7 @@ 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); 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/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/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/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/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/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;