Production-grade cryptocurrency dashboard with real-time market data, portfolio tracking, and analytics
CryptoPulse is a full-stack web application that gives investors a real-time, unified view of the cryptocurrency market. It combines live price data from CoinGecko with a personal portfolio tracker, interactive analytics charts, and a watchlist — all behind a secure, session-based authentication system backed by PostgreSQL.
Built with a production-grade stack: rate limiting, CSP headers, bcrypt password hashing, structured logging, Sentry error monitoring, a 40-test automated suite, and a GitHub Actions CI/CD pipeline that gates every deploy behind passing tests.
| Metric | Value |
|---|---|
| Cryptocurrencies tracked (Markets page) | 100 (top by market cap) |
| Cryptocurrencies on Homepage | 12 (top by market cap) |
| Coin search index (for Add Holding / Watchlist) | 250 coins, returns top 8 matches |
| API cache TTL — homepage & markets | 5 minutes |
| API cache TTL — coin search index | 30 minutes |
| Portfolio dashboard tabs | 4 (Overview, Holdings, Analytics, Watchlist) |
| Performance chart time ranges | 3 (7D, 30D, 90D) |
| Chart palette (allocation / analytics) | 8 distinct colours |
| Quantity precision (holdings) | Up to 8 decimal places |
| Auth rate limit | 10 requests / 15 minutes per IP |
| bcrypt cost factor | 10 |
| Session cookie lifetime | 7 days |
| Sentry trace sampling — production | 20% |
| Automated tests | 40 across 5 suites |
| Production dependencies | 13 |
| Dev dependencies | 3 |
| CI jobs | 2 (test → deploy, gated) |
- Features
- Tech Stack
- Architecture
- Getting Started
- Environment Variables
- API Reference
- Project Structure
- Testing
- CI/CD Pipeline
- Security
- License
| Feature | Detail |
|---|---|
| Live market data | Top 12 coins on the homepage; top 100 coins on the Markets page, ordered by market cap |
| Server-side caching | 2 independent caches — homepage/markets: 5-min TTL; coin search index: 30-min TTL. Stale-cache fallback on API outages |
| Coin search | Searches a 250-coin index, returns the top 8 matches by name or symbol — no extra network call |
| Interactive charts | Performance chart with 3 selectable time ranges (7D / 30D / 90D); allocation doughnut with 8-colour palette |
| Lottie animations | 2 blockchain-themed animations (blockchain.json, network.json) served from cdn.jsdelivr.net (CSP-compliant) |
| Tab | What it shows |
|---|---|
| Overview | 4 summary cards (total value, invested, P/L $, return %) + allocation doughnut + performance line chart |
| Holdings | Full CRUD table — add via 250-coin search, edit, delete. Quantity displayed to 8 decimal places |
| Analytics | Horizontal P/L bar chart per coin, mirrored allocation donut, best & worst performer cards |
| Watchlist | Inline coin cards with live price + 24h % change; add/remove without leaving the page |
Tab state is stored in the URL hash (#overview, #holdings, etc.) for direct-linkable deep navigation.
- bcrypt password hashing — cost factor 10 (~100ms deliberate delay against brute force)
- PostgreSQL session store (
connect-pg-simple) — sessions survive Vercel serverless cold starts; 7-day cookie lifetime req.session.save()wraps every post-auth redirect to guarantee session flush before response on serverless- Rate limiting — 10 requests / 15 minutes per IP on all
/authendpoints - Helmet CSP — strict
scriptSrc,imgSrc,connectSrcwhitelist; nounsafe-eval - express-validator sanitises and validates all form inputs server-side before any DB write
- Winston structured logging across 3 levels (info / warn / error) with ISO timestamps on every request
- Sentry error monitoring — 20% trace sampling in production, 100% in development; wired directly to the Express error handler
┌─────────────────────────────────────────────────────────────────┐
│ Browser / Client │
│ EJS templates + Vanilla JS │
└────────────────────────┬────────────────────────────────────────┘
│ HTTP
▼
┌─────────────────────────────────────────────────────────────────┐
│ Express 5.x (app.js) │
│ │
│ Middleware stack: │
│ Helmet (CSP) → express-session → user locals → routes │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌─────────────────────┐ │
│ │ /auth │ │ /portfolio │ │ /api + page routes │ │
│ │ login │ │ dashboard │ │ /api/markets │ │
│ │ register │ │ holdings │ │ /markets /about │ │
│ │ logout │ │ watchlist │ │ /graphs /news │ │
│ └──────┬──────┘ └──────┬───────┘ └────────┬────────────┘ │
│ │ │ │ │
│ ┌──────▼────────────────▼────────────────────▼──────────────┐ │
│ │ isAuthenticated middleware │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────┐ ┌───────────────────────┐ │
│ │ In-memory cache (5-min TTL) │ │ Winston logger │ │
│ │ Stale-cache fallback on err │ │ Sentry error handler │ │
│ └────────────────┬────────────────┘ └───────────────────────┘ │
└───────────────────│─────────────────────────────────────────────┘
│
┌───────────┴────────────┐
│ │
▼ ▼
┌───────────────┐ ┌──────────────────────┐
│ CoinGecko │ │ Supabase │
│ API │ │ (PostgreSQL) │
│ │ │ │
│ /coins/ │ │ users │
│ markets │ │ sessions │
│ /simple/ │ │ portfolio_holdings │
│ price │ │ watchlist │
└───────────────┘ └──────────────────────┘
GET /portfolio
│
├─ Promise.all([
│ supabase: portfolio_holdings WHERE user_id
│ supabase: watchlist WHERE user_id
│ ])
│
├─ Promise.all([
│ CoinGecko: /simple/price (holdings enrichment)
│ CoinGecko: /coins/markets (watchlist enrichment)
│ ])
│
└─ res.render('portfolio/dashboard', { holdings, watchlist, totals })
└─ Client: pure JS tab switching (URL hash, no page reload)
| Tool | Version |
|---|---|
| Node.js | 22.x |
| npm | 9.x+ |
| Supabase account | (for PostgreSQL) |
# 1. Clone the repository
git clone https://github.com/Emp1500/cryptopulse-api.git
cd cryptopulse-api
# 2. Install dependencies
npm install
# 3. Configure environment variables (see below)
cp .env.example .env # then fill in your values
# 4. Start development server
npm run devThe app will be available at http://localhost:3000.
Create a .env file in the project root:
# Supabase — production database
SUPABASE_URL=https://<your-project-id>.supabase.co
SUPABASE_SERVICE_KEY=<your-service-role-key>
# PostgreSQL connection string (for session store)
DATABASE_URL=postgresql://postgres:<password>@db.<project-id>.supabase.co:5432/postgres
# Session
SESSION_SECRET=<a-long-random-string>
# Error monitoring (optional)
SENTRY_DSN=<your-sentry-dsn>
# For running tests against an isolated DB
TEST_SUPABASE_URL=https://<test-project-id>.supabase.co
TEST_SUPABASE_SERVICE_KEY=<test-service-role-key>Note: The test suite runs against a separate Supabase project to keep production data clean. CI injects
TEST_SUPABASE_URLandTEST_SUPABASE_SERVICE_KEYvia GitHub Secrets.
-- Users
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Sessions (managed by connect-pg-simple)
CREATE TABLE sessions (
sid VARCHAR NOT NULL PRIMARY KEY,
sess JSON NOT NULL,
expire TIMESTAMPTZ NOT NULL
);
-- Portfolio holdings
CREATE TABLE portfolio_holdings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
coin_id TEXT NOT NULL,
symbol TEXT NOT NULL,
name TEXT NOT NULL,
image TEXT DEFAULT '',
quantity NUMERIC NOT NULL,
purchase_price NUMERIC NOT NULL,
purchase_date DATE,
notes TEXT DEFAULT '',
created_at TIMESTAMPTZ DEFAULT now()
);
-- Watchlist
CREATE TABLE watchlist (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
coin_id TEXT NOT NULL,
symbol TEXT NOT NULL,
name TEXT NOT NULL,
image TEXT DEFAULT '',
added_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(user_id, coin_id)
);| Method | Endpoint | Body | Response | Description |
|---|---|---|---|---|
GET |
/auth/login |
— | 200 HTML | Login page |
POST |
/auth/login |
email, password |
302 /portfolio |
Authenticate user |
GET |
/auth/register |
— | 200 HTML | Register page |
POST |
/auth/register |
name, email, password, confirmPassword |
302 /auth/login |
Create account |
POST |
/auth/logout |
— | 302 / |
Destroy session |
| Method | Endpoint | Body | Response | Description |
|---|---|---|---|---|
GET |
/portfolio |
— | 200 HTML | Dashboard (fetches holdings + watchlist in parallel) |
GET |
/portfolio/holdings |
— | 200 JSON | All holdings with live prices |
POST |
/portfolio/holdings |
coinId, symbol, name, quantity, purchasePrice, purchaseDate? |
200 JSON | Add holding |
PUT |
/portfolio/holdings/:id |
quantity?, purchasePrice?, purchaseDate? |
200 JSON | Update holding |
DELETE |
/portfolio/holdings/:id |
— | 200 JSON | Delete holding |
GET |
/portfolio/watchlist |
— | 200 HTML | Watchlist page |
POST |
/portfolio/watchlist |
coinId, symbol, name, image? |
200 JSON | Add to watchlist |
DELETE |
/portfolio/watchlist/:coinId |
— | 200 JSON | Remove from watchlist |
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/markets |
Top 100 coins JSON (used by coin-search in modals) |
GET |
/ |
Homepage — top 12 coins by market cap |
GET |
/markets |
Full markets table (top 100) |
GET |
/graphs |
Price charts page |
GET |
/about |
About page |
GET |
/news |
Redirects to CoinMarketCap news |
cryptipulse/
├── app.js # App entry point, middleware, page routes
├── vercel.json # Serverless deployment config
├── jest.config.js # Test runner config
├── package.json
│
├── config/
│ ├── database.js # Supabase client (prod/test env-aware)
│ └── logger.js # Winston logger (timestamps + levels)
│
├── middleware/
│ └── auth.js # isAuthenticated route guard
│
├── routes/
│ ├── api.js # GET /api/markets — CoinGecko proxy
│ ├── auth.js # Register, login, logout + rate limiting
│ └── portfolio.js # Holdings + watchlist CRUD
│
├── views/
│ ├── index.ejs # Homepage (top 12 coins + Lottie animations)
│ ├── graphs.ejs # Price charts page
│ ├── portfolio/
│ │ ├── dashboard.ejs # 4-tab portfolio dashboard
│ │ └── watchlist.ejs # Standalone watchlist page
│ ├── auth/
│ │ ├── login.ejs
│ │ └── register.ejs
│ └── partials/
│ ├── header.ejs # Sticky dark navbar (auth-aware)
│ ├── footer.ejs # 4-column footer
│ ├── markets.ejs # Top 100 markets table
│ └── about.ejs # About page
│
├── public/
│ ├── css/
│ │ ├── theme.css # Design token system (CSS custom properties)
│ │ ├── portfolio.css # Dashboard, tab nav, modal styles
│ │ ├── about.css # About page styles
│ │ ├── markets.css
│ │ └── other.css
│ ├── js/
│ │ ├── observer.js # IntersectionObserver scroll animations
│ │ └── markets.js # Markets table search + filter
│ └── animations/ # Lottie JSON animation files
│
├── tests/
│ ├── helpers/
│ │ ├── db.js # Test factories: createTestUser, createTestHolding, createTestWatchlistCoin
│ │ └── setup.js
│ ├── integration/
│ │ ├── auth.test.js # POST /auth/login, /register, /logout
│ │ ├── portfolio.test.js # GET /portfolio, holdings CRUD, watchlist data
│ │ └── watchlist.test.js # Watchlist add/remove/duplicate prevention
│ └── unit/
│ ├── auth.utils.test.js
│ └── portfolio.utils.test.js
│
└── .github/
└── workflows/
└── ci-cd.yml # Test → Deploy gated pipeline
40 tests · 5 suites · 0 mocks — all run against an isolated Supabase test project (completely separate from production data).
# Run all 40 tests
npm test
# Watch mode (development)
npm run test:watch
# Run a single suite
npx jest tests/integration/auth.test.js| Suite | Tests | What's covered |
|---|---|---|
auth.test.js |
7 | Register, login (valid + wrong password + unknown email), duplicate email rejection, logout, session destruction |
portfolio.test.js |
10 | Auth guard on GET /portfolio, watchlist data present in rendered HTML, holdings CRUD (add/update/delete), input validation (zero quantity, missing coinId) |
watchlist.test.js |
9 | Add coin, remove coin, duplicate coin prevention (unique constraint), unauthenticated access guard |
auth.utils.test.js |
7 | bcrypt hashing and comparison, session utility helpers |
portfolio.utils.test.js |
7 | P/L calculation logic, live price enrichment, percentage return computation |
| Total | 40 | — |
- Real database, no mocks — Tests hit a live Supabase Postgres instance. Mocking the DB previously masked a migration bug where mocked tests passed but the production schema rejected the same query.
- 3 factory helpers —
createTestUser(),createTestHolding(),createTestWatchlistCoin()each insert a row and return the created record;afterEachdeletes byuser_idfor clean state. - Supertest agent — Stateful
request.agent(app)carries session cookies across multiple requests, enabling full end-to-end authenticated flows (login → portfolio → delete) in a single test.
Every push to any branch triggers the test job. A deploy to Vercel production only fires if the tests pass and the branch is main.
git push origin main
│
└─▶ GitHub Actions
│
├─▶ [test] Node.js 22 · npm ci · npm test
│ │
│ ├── PASS ──▶ [deploy] vercel --prod --yes
│ │
│ └── FAIL ──▶ Pipeline halts. No deploy.
│
└─▶ Vercel Production (https://cryptipulse.vercel.app)
GitHub Secrets required:
| Secret | Purpose |
|---|---|
TEST_SUPABASE_URL |
Isolated test database URL |
TEST_SUPABASE_SERVICE_KEY |
Test DB service role key |
VERCEL_TOKEN |
Team-scoped Vercel deploy token |
VERCEL_ORG_ID |
Vercel team ID |
VERCEL_PROJECT_ID |
Vercel project ID |
| Layer | Implementation |
|---|---|
| Password hashing | bcryptjs — cost factor 10 (~100ms per hash; deliberate slowdown against brute force) |
| Session storage | PostgreSQL via connect-pg-simple — zero sensitive data client-side; 7-day cookie lifetime |
| Cookie hardening | httpOnly: true, secure: true in production, sameSite default |
| Rate limiting | 10 requests / 15 minutes per IP on all /auth endpoints (express-rate-limit) |
| Content Security Policy | Helmet — strict scriptSrc (jsdelivr + cdnjs only), imgSrc (coingecko only), connectSrc: self; no unsafe-eval |
| Input validation | express-validator — trim, normalise, and validate all form fields before any DB write |
| Session flush | req.session.save() before every post-auth redirect — prevents lost-session race on Vercel serverless |
| Error monitoring | Sentry — 20% trace sampling in production, 100% in development; wired to Express error handler |
Distributed under the ISC License.
Built by Vedant Wagh