API for the Nadbooks platform — a marketplace where authors upload books and users buy them with a wallet funded by fiat (Paystack) or crypto (Monad). Built with NestJS + Prisma (MySQL) + Redis + BullMQ, run with Bun.
- Tech stack
- Prerequisites
- Quick start
- Environment variables
- Provider setup guides
- Running & scripts
- API documentation
- Runtime: Bun
- Framework: NestJS 11
- ORM: Prisma 6 (MySQL)
- Cache / queues: Redis (ioredis) + BullMQ (email + webhook workers)
- Storage: Cloudflare R2 (S3-compatible), with Dropbox kept as a fallback provider
- Upload safety: magic-byte content-type detection +
sharpimage processing - Auth: JWT access tokens + Redis-backed rotating refresh tokens, Google OAuth, Argon2 hashing
- Email: Resend
- Rate limiting:
@nestjs/throttlerwith a Redis store
- Bun ≥ 1.0
- A MySQL database
- A Redis instance
- Accounts for the third-party services you intend to use (see provider setup guides)
# 1. Install dependencies
bun install
# 2. Create your env file and fill it in (see Environment variables below)
cp .env.example .env
# 3. Generate the Prisma client and apply migrations
bun prisma generate
bun prisma migrate deploy # or: bun prisma migrate dev (for local development)
# 4. Run
bun run start:dev # watch modeThe API is served under the /api prefix (e.g. http://localhost:3000/api).
Note on Prisma + env: this project uses
prisma.config.ts, which makes the Prisma CLI skip automatic.envloading. Ifbun prisma ...reportsEnvironment variable not found: DATABASE_URL, export it for the command, e.g.DATABASE_URL="mysql://..." bun prisma migrate dev.
Copy .env.example to .env and fill in the values. Every variable is described below.
| Variable | Required | Example | Description |
|---|---|---|---|
NODE_ENV |
yes | development |
development or production. In production, JWT expiry is always enforced and CORS is restricted to FRONTEND_ORIGIN_PREFIX. In development, verification/PIN codes are forced to 000000 and emails are not actually sent. |
PORT |
yes | 3000 |
Port the HTTP server listens on. |
FRONTEND_ORIGIN_PREFIX |
yes | https://app.nadbooks.com |
Comma-separated allowed CORS origins (used in production). |
| Variable | Required | Example | Description |
|---|---|---|---|
DATABASE_URL |
yes | mysql://user:pass@localhost:3306/nadbooks |
MySQL connection string used by Prisma. |
REDIS_URL |
yes | redis://localhost:6379 |
Redis connection. Backs refresh tokens, verification codes, the price cache, BullMQ queues, and the rate-limiter store. |
CACHE_TTL |
yes | 900 |
Default cache TTL (seconds) for cached reads such as book listings. |
| Variable | Required | Example | Description |
|---|---|---|---|
JWT_SECRET |
yes | (random string) | Secret used to sign access tokens. Generate a strong one: openssl rand -base64 48. |
JWT_EXPIRES_IN |
yes | 15m |
Access-token lifetime. |
IGNORE_JWT_EXPIRATION |
yes | false |
Dev-only convenience to accept expired tokens. Automatically ignored when NODE_ENV=production (expiry is always enforced). |
REFRESH_TOKEN_TTL |
yes | 604800 |
Refresh-token lifetime in seconds (e.g. 7 days). |
VERIFICATION_CODE_TTL |
yes | 600 |
Lifetime (seconds) of email-verification / PIN-reset / email-change codes. |
Used to bootstrap the first SUPER_ADMIN account.
| Variable | Required | Description |
|---|---|---|
SUPER_ADMIN_EMAIL |
yes | Super-admin email. |
SUPER_ADMIN_USERNAME |
yes | Super-admin username. |
SUPER_ADMIN_FIRSTNAME |
yes | First name. |
SUPER_ADMIN_LASTNAME |
yes | Last name. |
SUPER_ADMIN_PASSWORD |
yes | Initial password — change it after first login. |
STORAGE_PROVIDER=r2 (default). Books go to a private bucket and are served via short-lived presigned URLs; covers and avatars go to a public bucket. See the Cloudflare R2 setup guide.
| Variable | Required | Example | Description |
|---|---|---|---|
STORAGE_PROVIDER |
yes | r2 |
r2 (default) or dropbox. |
R2_ACCOUNT_ID |
yes (r2) | a1b2c3... |
Cloudflare account ID; the S3 endpoint is derived from it. |
R2_ACCESS_KEY_ID |
yes (r2) | R2 API token access key ID. | |
R2_SECRET_ACCESS_KEY |
yes (r2) | R2 API token secret. | |
R2_BUCKET |
yes (r2) | nadbooks-private |
Private bucket for book files. |
R2_PUBLIC_BUCKET |
no | nadbooks-public |
Public bucket for covers/avatars. Falls back to R2_BUCKET if unset. |
R2_PUBLIC_BASE_URL |
yes (r2) | https://cdn.nadbooks.com |
Public domain bound to R2_PUBLIC_BUCKET (no trailing slash). |
DOWNLOAD_URL_TTL |
no | 300 |
Seconds a presigned book-download link stays valid. |
Only needed if STORAGE_PROVIDER=dropbox. Kept for backward compatibility; R2 is the recommended provider.
| Variable | Description |
|---|---|
DROPBOX_APP_KEY / DROPBOX_APP_SECRET |
App credentials from the Dropbox developer console. |
DROPBOX_REFRESH_TOKEN |
Long-lived refresh token used to mint access tokens. |
DROPBOX_ACCESS_TOKEN |
Access token (short-lived). |
DROPBOX_AUTH_CODE / DROPBOX_REDIRECT_URI |
OAuth authorization code + redirect URI used to obtain the refresh token. |
| Variable | Required | Example | Description |
|---|---|---|---|
RESEND_API_KEY |
yes* | re_... |
Resend API key. If missing, emails are silently dropped (handy for local dev). |
EMAIL_FROM |
yes | noreply@nadbooks.com |
Verified sender address. |
| Variable | Required | Description |
|---|---|---|
GOOGLE_CLIENT_ID |
yes* | OAuth client ID (also used to verify Google ID tokens on /auth/google). |
GOOGLE_CLIENT_SECRET |
yes* | OAuth client secret. |
GOOGLE_CALLBACK_URL |
yes* | Redirect URI registered with Google, e.g. http://localhost:3000/api/auth/google/callback. |
* Required only if you enable Google sign-in.
| Variable | Required | Description |
|---|---|---|
PAYSTACK_SECRET |
yes* | Paystack secret key — used for fiat deposits, withdrawals, and webhook signature verification. Required for the fiat payment flow. |
Used by the price feed (NGN/USD + on-chain MON/BOOKS rates) and the crypto deposit/withdrawal flows.
| Variable | Required | Example | Description |
|---|---|---|---|
CURRENCYFREAK_SECRET |
yes | API key for CurrencyFreaks (USD→NGN rate). Quota: 1000 calls/month on the free plan. | |
FIAT_REFRESH_INTERVAL_MS |
no | 3600000 |
How often to refresh the USD/NGN rate. Default hourly (~744/month). Keep it high enough to stay under your CurrencyFreaks quota. |
ALCHEMY_RPC_URL |
yes* | RPC endpoint used to read on-chain DEX prices and send crypto transactions. | |
ALCHEMY_SECRET |
no | Alchemy API secret, if used. | |
CENTRAL_WALLET_ADDRESS |
yes* | Platform hot-wallet address that receives crypto deposits. | |
CENTRAL_WALLET_PRIVATE_KEY |
yes* | Private key for the central wallet. Keep this secret. | |
SMART_CONTRACT_ADDRESS |
yes* | Address of the platform smart contract. | |
BOOKS_ADDRESS |
no | BOOKS token contract address (for the on-chain BOOKS/USDT price). | |
BOOKS_USDT_POOL_ADDRESS |
no | BOOKS/USDT DEX pool address. | |
MOBULA_SECRET |
no | Mobula API key (alternative crypto price data source). |
* Required only for the crypto payment flow.
Mock prices: the fallback MON/USDT (
1.5) and BOOKS/USDT (0.00073) prices used before the DEX pools are live are now constants in code (src/price-feed/providers/dex-price.provider.ts), not env vars. Once the on-chain pools are wired up, those fallback branches stop being hit.
- Create buckets (R2 → Create bucket): one private (
R2_BUCKET, for books — leave public access off) and one public (R2_PUBLIC_BUCKET, for covers/avatars). - Account ID: shown on the R2 overview as part of the S3 endpoint
https://<ACCOUNT_ID>.r2.cloudflarestorage.com→R2_ACCOUNT_ID. - API token: R2 → Manage R2 API Tokens → Create API token with Object Read & Write. Copy the Access Key ID (
R2_ACCESS_KEY_ID) and Secret Access Key (R2_SECRET_ACCESS_KEY). - Public access: on the public bucket, enable the r2.dev subdomain (dev) or attach a custom domain (prod, recommended). That URL is
R2_PUBLIC_BASE_URL(no trailing slash). - CORS: if your frontend fetches objects via JS, add a CORS rule allowing
GETfrom your frontend origin (on the public bucket, and on the private bucket if youfetch()presigned links rather than navigating to them).
- Google Cloud Console → APIs & Services → Credentials → Create OAuth client ID (Web application).
- Add your redirect URI (
GOOGLE_CALLBACK_URL) to Authorized redirect URIs. - Copy the Client ID and Client secret.
- Resend dashboard → API Keys → create a key →
RESEND_API_KEY. - Verify a sending domain/address and set it as
EMAIL_FROM.
- Paystack dashboard → Settings → API Keys & Webhooks.
- Copy the Secret Key →
PAYSTACK_SECRET. Configure your webhook URL there to point at the backend's webhook endpoint.
- Sign up at currencyfreaks.com → copy the API key →
CURRENCYFREAK_SECRET. - The free plan allows 1000 requests/month; the backend refreshes USD/NGN hourly and caches it in Redis, so all conversions read from cache.
- Alchemy dashboard → create an app on the target network → copy the HTTPS RPC URL →
ALCHEMY_RPC_URL.
- MySQL: any MySQL 8 instance; set
DATABASE_URL. Runbun prisma migrate deployto create tables. - Redis: any Redis instance; set
REDIS_URL.
bun run start # start
bun run start:dev # watch mode
bun run start:prod # run compiled build (node dist/main)
bun run build # compile
bun run lint # eslint --fix
bun run test # unit tests
bun run test:e2e # e2e tests- Swagger UI is generated from the controllers (NestJS Swagger) — browse it at the configured docs path when the server is running.
- Yaak collection:
yaak.nadbooks-backend-api.jsonin the repo root can be imported into Yaak for a ready-made request collection covering every endpoint.
The switch itself is mostly mechanical (provider = "postgresql" in schema.prisma, a Postgres DATABASE_URL, and re-initialised migrations). The one behavioural difference to handle in code:
- Case sensitivity. MySQL string comparisons are case-insensitive by default; Postgres is case-sensitive. Before/at the switch:
- Normalise emails to lowercase on store and lookup (
register,login, OAuth) soJohn@x.comandjohn@x.comresolve to the same user. - Add
mode: 'insensitive'tocontainsfilters (e.g. the admin user search) to keep search case-insensitive.
- Normalise emails to lowercase on store and lookup (
Nothing else in the codebase is MySQL-specific (Json → jsonb, enums, Decimal, and autoincrement all map cleanly).