RESTful API powering the EcoSpark Hub sustainability ideas platform.
- About
- Tech Stack
- Project Structure
- Database Schema
- API Reference
- Getting Started
- Environment Variables
- Available Scripts
- Authentication
- Payment Flow
- Image Uploads
- Deployment
EcoSpark API is the Express 5 backend for EcoSpark Hub. It provides:
- Full CRUD for sustainability ideas with a multi-stage review workflow
- Session-based authentication via Better Auth with PostgreSQL session storage
- Stripe-powered payments for monetised ideas (checkout + webhook)
- Cloudinary image uploads streamed from server memory via Multer
- Threaded comments with nested replies (up to 3 levels)
- Role-based access control β
MEMBERandADMINroles - Admin moderation β approve/reject ideas, manage users and categories
| Category | Technology |
|---|---|
| Runtime | Node.js 20+ |
| Language | TypeScript 5 (ESM) |
| Framework | Express 5 |
| ORM | Prisma 7 with @prisma/adapter-pg |
| Database | PostgreSQL 15+ |
| Authentication | Better Auth 1.5.6 (email/password + sessions) |
| Payments | Stripe 21.0 (Checkout Sessions + Webhooks) |
| File Upload | Multer 1.4 (memory storage) β Cloudinary 2.6 |
| Security | Helmet 8.1, CORS 2.8 |
| Logging | Morgan 1.10 |
| Deployment | Vercel (serverless via @vercel/node) |
ecospart-api/
βββ src/
β βββ server.ts # Entry point (local dev)
β βββ app.ts # Express app bootstrap (CORS, middleware, routes)
β βββ app/
β βββ config/
β β βββ index.ts # Better Auth configuration
β β βββ validateEnv.ts # Startup env validation (throws if missing)
β βββ lib/
β β βββ prisma.ts # Prisma client singleton
β β βββ cloudinary.ts # Cloudinary upload / delete utilities
β β βββ catchAsync.ts # Async error wrapper (eliminates try/catch boilerplate)
β βββ middleware/
β β βββ auth.middleware.ts # `protect` β validates session, attaches req.user
β β βββ adminOnly.middleware.ts # `adminOnly` β rejects non-ADMIN
β β βββ upload.middleware.ts # Multer config (5 MB, images only, memory storage)
β β βββ globalError.middleware.ts # Global error handler
β β βββ notFound.middleware.ts # 404 handler
β βββ module/ # Feature modules (controller / service / route / interface)
β β βββ admin/ # Admin stats, user management, newsletter
β β βββ category/ # Category CRUD
β β βββ comment/ # Threaded comments
β β βββ idea/ # Idea CRUD + status workflow
β β βββ payment/ # Stripe checkout + webhook
β β βββ upload/ # Image upload to Cloudinary
β β βββ vote/ # Upvote / downvote
β βββ routes/
β β βββ index.ts # Central route aggregator
β βββ utils/
β β βββ sendResponse.ts # Standardised JSON response helper
β β βββ trustedOrigins.ts # CORS origin builder from env
β βββ interfaces/
β β βββ common.interface.ts
β βββ errorHelpers/
β βββ AppError.ts # Custom error class
β
βββ prisma/
β βββ schema.prisma # Database schema (all models)
β βββ seed.ts # Demo data seeder
β
βββ dist/ # Compiled JavaScript (git-ignored)
βββ vercel.json # Vercel deployment config
βββ .env.example # Environment variable template
βββ tsconfig.json
βββ package.json
User βββββββ¬ββββ ideas ββββ Idea βββ¬ββββ votes ββββ Vote
βββββ votes βββββ comments ββββ Comment (nested)
βββββ purchases βββββ purchasesββββ Purchase
βββββ comments βββββ category ββββ Category
βββββ sessions (Better Auth)
βββββ accounts (Better Auth OAuth)
NewsletterSubscriber (standalone)
Verification (Better Auth)
User
id String @id
name String
email String @unique
role Role @default(MEMBER) // MEMBER | ADMIN
isActive Boolean @default(true)Idea
id String @id @default(cuid())
title String
problemStatement String
proposedSolution String
description String
images String[] // Cloudinary URLs
isPaid Boolean @default(false)
price Float?
status IdeaStatus @default(DRAFT)
// DRAFT | UNDER_REVIEW | APPROVED | REJECTED
rejectionFeedback String?
authorId String // β User
categoryId String // β CategoryVote β unique per [userId, ideaId]
type VoteType // UPVOTE | DOWNVOTEComment β supports self-referential nesting
content String
parentId String? // null = top-level comment
replies Comment[]Purchase β unique per [userId, ideaId]
stripePaymentId String?Base URL: https://ecospark-api.vercel.app/api/v1
Local: http://localhost:5000/api/v1
All responses follow this envelope:
{
"success": true,
"statusCode": 200,
"message": "Ideas retrieved successfully",
"data": { ... }
}Handled by Better Auth. Session cookie is set on
/api/v1/auth/*.
| Method | Endpoint | Description |
|---|---|---|
POST |
/auth/sign-up/email |
Register with name, email, password |
POST |
/auth/sign-in/email |
Login with email, password |
POST |
/auth/sign-out |
Destroy session |
GET |
/auth/get-session |
Get current session + user |
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET |
/categories |
β | List all categories |
POST |
/categories |
Admin | Create a category { name } |
DELETE |
/categories/:id |
Admin | Delete a category |
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET |
/ideas |
β | List approved ideas |
GET |
/ideas/:id |
Optional | Get one idea (handles paid gate) |
GET |
/ideas/my |
Member | Get current user's ideas |
GET |
/ideas/admin/all |
Admin | Get all ideas (any status) |
POST |
/ideas |
Member | Create idea (multipart/form-data) |
PATCH |
/ideas/:id |
Member | Update idea (multipart/form-data) |
DELETE |
/ideas/:id |
Member | Delete idea (DRAFT only) |
PATCH |
/ideas/:id/submit |
Member | Submit DRAFT β UNDER_REVIEW |
PATCH |
/ideas/:id/approve |
Admin | Approve idea β APPROVED |
PATCH |
/ideas/:id/reject |
Admin | Reject with feedback { feedback } |
GET /ideas β Query Parameters
| Param | Type | Description |
|---|---|---|
page |
number | Page number (default: 1) |
limit |
number | Items per page (default: 10) |
category |
string | Filter by category ID |
search |
string | Search title / description |
sort |
string | newest | popular | price-asc | price-desc |
isPaid |
boolean | Filter free / paid ideas |
GET /ideas/:id β Response Shapes
Create / Update β multipart/form-data Fields
| Field | Type | Required |
|---|---|---|
title |
string | β |
problemStatement |
string | β |
proposedSolution |
string | β |
description |
string | β |
categoryId |
string | β |
isPaid |
boolean | β |
price |
number | Only if isPaid: true |
images |
File[] | Up to 4 files, 5 MB each |
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET |
/votes/:ideaId |
β | Get all votes for an idea |
POST |
/votes/:ideaId |
Member | Cast / change / remove vote |
POST /votes/:ideaId body: { "type": "UPVOTE" | "DOWNVOTE" }
- Voting the same type again removes the vote (toggle)
- Voting a different type updates the existing vote
- Returns
201 Createdfor new vote,200 OKfor removal
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET |
/comments/:ideaId |
β | Get nested comment tree |
POST |
/comments/:ideaId |
Member | Post a comment or reply |
DELETE |
/comments/:id |
Member/Admin | Delete a comment |
POST /comments/:ideaId body:
{ "content": "Great idea!", "parentId": "optional-parent-id" }| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST |
/payments/checkout |
Member | Create Stripe Checkout Session |
GET |
/payments/status/:ideaId |
Member | Check purchase status |
POST |
/payments/webhook |
β (Stripe) | Handle checkout.session.completed |
POST /payments/checkout body: { "ideaId": "..." }
Returns: { "url": "https://checkout.stripe.com/..." }
After successful payment, Stripe redirects to:
{CLIENT_URL}/ideas/{ideaId}?purchase=success
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST |
/upload/image |
Member | Upload single image to Cloudinary |
Request: multipart/form-data with field name file
Response: { "url": "https://res.cloudinary.com/..." }
Constraints: images only, max 5 MB per file.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET |
/admin/stats |
Admin | Platform statistics |
GET |
/admin/users |
Admin | List all users |
PATCH |
/admin/users/:id/toggle-active |
Admin | Enable / disable user account |
PATCH |
/admin/users/:id/role |
Admin | Change user role |
DELETE |
/admin/ideas/:id |
Admin | Force-delete any idea |
POST |
/admin/newsletter/subscribe |
β | Subscribe email to newsletter |
GET /admin/stats response:
{
"totalUsers": 157,
"totalIdeas": 342,
"pendingIdeas": 12,
"approvedIdeas": 289
}GET /admin/users query params: page, limit, search
- Node.js 20 or later
- PostgreSQL database (local or hosted β e.g. Supabase, Neon)
- Stripe account (for payments)
- Cloudinary account (for image uploads)
git clone https://github.com/your-username/ecospart-api.git
cd ecospart-apinpm installcp .env.example .envFill in all values β see Environment Variables below.
# Push schema to your database (dev β no migration history)
npm run db:push
# Or use migrations (recommended for production)
npm run db:migrate
# Optional: seed with demo data
npm run db:seednpm run devAPI available at http://localhost:5000.
Create a .env file in the project root:
# βββ Database βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
DATABASE_URL="postgresql://USER:PASSWORD@HOST:PORT/DATABASE?sslmode=require"
# βββ Better Auth ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Generate with: openssl rand -base64 32
BETTER_AUTH_SECRET="your_random_secret_min_32_chars"
# No trailing slash β must match the deployed API URL
BETTER_AUTH_URL="http://localhost:5000"
# Your frontend URL β used for CORS and Stripe redirect URLs
CLIENT_URL="http://localhost:3000"
# Optional: extra trusted origins (comma or space separated)
# TRUSTED_ORIGINS="https://preview.vercel.app https://staging.vercel.app"
# βββ Stripe βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
STRIPE_SECRET_KEY="sk_test_..."
STRIPE_WEBHOOK_SECRET="whsec_..."
# βββ Cloudinary βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
CLOUDINARY_CLOUD_NAME="your_cloud_name"
CLOUDINARY_API_KEY="your_api_key"
CLOUDINARY_API_SECRET="your_api_secret"
# βββ Server βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
PORT=5000
NODE_ENV="development"Startup validation:
validateEnv.tsruns at boot and throws if any required variable is missing, preventing silent failures.
npm run dev # Start dev server with hot-reload (tsx watch)
npm run build # Compile TypeScript β dist/
npm run start # Run compiled production server
npm run db:push # Push Prisma schema (no migrations)
npm run db:migrate # Run Prisma migrations (dev)
npm run db:seed # Seed database with demo data
npm run db:studio # Open Prisma Studio in browserThis API uses Better Auth with PostgreSQL as the session store.
- Sign-up / Sign-in β Better Auth creates a session row in PostgreSQL and sets an
HttpOnlysession cookie - Protected routes β
protectmiddleware callsauth.api.getSession()to validate the cookie - Role check β
adminOnlymiddleware verifiesreq.user.role === "ADMIN"
// Protect any route
router.get("/my-route", protect, adminOnly, handler);
// req.user is available in all protected handlers:
interface RequestUser {
id: string;
role: "MEMBER" | "ADMIN";
}The session cookie is named:
better-auth.session_token(HTTP)__Secure-better-auth.session_token(HTTPS / production)
Member clicks "Unlock" on a paid idea
β
βΌ
POST /payments/checkout { ideaId }
β
βΌ
Server creates Stripe Checkout Session
β
βΌ
Response: { url: "https://checkout.stripe.com/..." }
β
βΌ
Client redirects user to Stripe-hosted checkout page
β
βΌ
User completes payment on Stripe
β
βΌ
Stripe calls POST /payments/webhook (checkout.session.completed)
β
βΌ
Server verifies webhook signature + creates Purchase record
β
βΌ
Stripe redirects user to {CLIENT_URL}/ideas/{ideaId}?purchase=success
β
βΌ
Frontend shows success toast + unlocks full idea content
# Install Stripe CLI
stripe listen --forward-to localhost:5000/api/v1/payments/webhook
# Copy the webhook signing secret it outputs into your .env:
# STRIPE_WEBHOOK_SECRET=whsec_...Images are uploaded in two steps:
- Multer receives the file into memory (max 5 MB,
image/*only) - Cloudinary receives the buffer via a server-side stream upload
// POST /upload/image
// multipart/form-data, field: "file"
// Response: { "url": "https://res.cloudinary.com/..." }The returned Cloudinary URL is stored in the images: String[] array on the Idea model.
The API is deployed on Vercel as a serverless Node.js function.
npm run build # Compile TypeScript first
vercel --prod{
"version": 2,
"builds": [{ "src": "dist/src/server.js", "use": "@vercel/node" }],
"routes": [{ "src": "/(.*)", "dest": "dist/src/server.js" }]
}- All environment variables set in Vercel dashboard
-
DATABASE_URLpoints to a production PostgreSQL instance (e.g. Neon, Supabase) -
CLIENT_URLset to the production frontend URL -
BETTER_AUTH_URLset to the production API URL (no trailing slash) - Stripe webhook registered for the production URL:
https://your-api.vercel.app/api/v1/payments/webhook -
NODE_ENV=productionset
ββββββββββββ
β DRAFT β βββ Member creates idea
ββββββ¬ββββββ
β Member submits for review
βΌ
βββββββββββββββββ
β UNDER_REVIEW β βββ Awaiting admin decision
βββββ¬ββββββββ¬ββββ
β β
Admin β β Admin
approves β β rejects (with feedback)
βΌ βΌ
ββββββββ ββββββββββββ
βAPPROVβ β REJECTED β βββ Member can edit + resubmit
β ED β ββββββββββββ
ββββββββ
β
Visible on
public ideas page
Made with π for a greener world Β· EcoSpark Hub API