Initial commit: Express backend with user management and SQLite database

Features:
- Express server on port 9991 with ESM syntax
- User registration, login, and session management
- Password hashing with bcryptjs
- SQLite database with sqlite3 package
- User credits and transaction tracking
- API key management
- Admin endpoints for user management
- Stripe and PayPal webhook endpoints (ready for integration)
- Rate limiting and error handling
- CORS and security headers with helmet

Database tables:
- users (accounts, subscriptions, credits)
- sessions (auth tokens)
- api_keys (user API access)
- credit_transactions (credit history)
- payments (payment tracking)
This commit is contained in:
Z User 2026-03-27 21:33:56 +00:00
commit 55335f14e7
11 changed files with 3762 additions and 0 deletions

24
.env.example Normal file
View File

@ -0,0 +1,24 @@
# Server Configuration
PORT=9991
NODE_ENV=development
# CORS
CORS_ORIGIN=*
# Stripe Configuration (for future use)
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
STRIPE_PUBLISHABLE_KEY=pk_test_xxx
# PayPal Configuration (for future use)
PAYPAL_CLIENT_ID=xxx
PAYPAL_CLIENT_SECRET=xxx
PAYPAL_WEBHOOK_ID=xxx
PAYPAL_MODE=sandbox
# JWT Secret (optional, for enhanced token security)
JWT_SECRET=your-super-secret-key-change-in-production
# Admin User (created on first run if set)
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=changeme

40
.gitignore vendored Normal file
View File

@ -0,0 +1,40 @@
# Dependencies
node_modules/
# Environment files
.env
.env.local
.env.production
# Database
data/
*.db
*.sqlite
*.sqlite3
# Logs
logs/
*.log
npm-debug.log*
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Build
dist/
build/
# Test coverage
coverage/
# Temporary files
tmp/
temp/

172
README.md Normal file
View File

@ -0,0 +1,172 @@
# Moxie Backend
Express.js backend API for user management of an AI site, built with ESM syntax and SQLite database.
## Features
- **User Management**: Registration, authentication, profile management
- **Credit System**: Track and manage user credits
- **API Keys**: Generate and manage API keys for programmatic access
- **Payment Webhooks**: Ready for Stripe and PayPal integration
- **Admin Endpoints**: User management for administrators
- **SQLite Database**: Lightweight, file-based storage
## Quick Start
```bash
# Install dependencies
npm install
# Start the server
npm start
# Start in development mode (with auto-reload)
npm run dev
```
The server runs on port 9991 by default.
## API Endpoints
### Public Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/health` | Health check |
| GET | `/api` | API information |
| POST | `/api/users/register` | Register a new user |
| POST | `/api/users/login` | Login and get session token |
### Authenticated Endpoints
All authenticated endpoints require `Authorization: Bearer <token>` header.
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/users/logout` | Logout and invalidate session |
| GET | `/api/users/me` | Get current user profile |
| PUT | `/api/users/me` | Update profile |
| PUT | `/api/users/me/password` | Change password |
| DELETE | `/api/users/me` | Delete account |
| GET | `/api/users/credits` | Get credits and history |
| GET | `/api/users/api-keys` | List API keys |
| POST | `/api/users/api-keys` | Create new API key |
| DELETE | `/api/users/api-keys/:keyId` | Revoke API key |
### Admin Endpoints
Requires `role: 'admin'` in user record.
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/users` | List all users |
| GET | `/api/users/:userId` | Get user by ID |
| PUT | `/api/users/:userId` | Update user |
| DELETE | `/api/users/:userId` | Delete user |
| POST | `/api/users/:userId/credits` | Adjust user credits |
### Webhook Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/webhooks/stripe` | Stripe webhook handler |
| POST | `/api/webhooks/paypal` | PayPal webhook handler |
## Database Schema
### Users Table
- `id` - Primary key (UUID)
- `email` - Unique email address
- `password_hash` - Bcrypt hashed password
- `name` - Display name
- `role` - User role ('user' or 'admin')
- `credits` - Available credits
- `subscription_status` - Subscription state
- `subscription_tier` - Subscription level
- `stripe_customer_id` - Stripe customer reference
- `paypal_customer_id` - PayPal customer reference
- `is_active` - Account status flag
### Sessions Table
- `id` - Session ID
- `user_id` - Foreign key to users
- `token_hash` - Session token
- `expires_at` - Token expiration
### API Keys Table
- `id` - Key ID
- `user_id` - Foreign key to users
- `key_hash` - Hashed API key
- `name` - Key name/description
- `is_active` - Key status
### Credit Transactions Table
- `id` - Transaction ID
- `user_id` - Foreign key to users
- `amount` - Credit amount (+/-)
- `type` - 'credit' or 'debit'
- `description` - Transaction description
### Payments Table
- `id` - Payment ID
- `user_id` - Foreign key to users
- `amount` - Payment amount
- `provider` - 'stripe' or 'paypal'
- `status` - Payment status
## Caddy Configuration
Add this to your Caddyfile to proxy the API:
```caddyfile
yourdomain.com {
# Static site
root * /path/to/static/site
file_server
# API proxy
handle /api/* {
reverse_proxy localhost:9991
}
}
```
## Environment Variables
Create a `.env` file based on `.env.example`:
```env
PORT=9991
NODE_ENV=production
CORS_ORIGIN=https://yourdomain.com
# Stripe (when ready)
STRIPE_SECRET_KEY=sk_live_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
# PayPal (when ready)
PAYPAL_CLIENT_ID=xxx
PAYPAL_CLIENT_SECRET=xxx
PAYPAL_WEBHOOK_ID=xxx
PAYPAL_MODE=live
```
## Payment Integration
### Stripe Setup
1. Create a Stripe account and get API keys
2. Add keys to environment variables
3. Create a webhook endpoint in Stripe dashboard pointing to `https://yourdomain.com/api/webhooks/stripe`
4. Copy the webhook signing secret to `STRIPE_WEBHOOK_SECRET`
### PayPal Setup
1. Create a PayPal Developer account
2. Create a REST API application
3. Add credentials to environment variables
4. Configure webhook in PayPal dashboard pointing to `https://yourdomain.com/api/webhooks/paypal`
## License
ISC

1994
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
package.json Normal file
View File

@ -0,0 +1,26 @@
{
"name": "moxie-backend",
"version": "1.0.0",
"description": "",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"repository": {
"type": "git",
"url": "https://f8e1300d871e905e27ce54c3455a3343104d9b04@git.client.guacamolebox.net/butterfly/moxie-backend.git"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "module",
"dependencies": {
"bcryptjs": "^3.0.3",
"cors": "^2.8.6",
"express": "^5.2.1",
"helmet": "^8.1.0",
"sqlite3": "^6.0.1",
"uuid": "^13.0.0"
}
}

143
src/db/database.js Normal file
View File

@ -0,0 +1,143 @@
import sqlite3 from 'sqlite3';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const DB_PATH = join(__dirname, '../../data/moxie.db');
// Promisify sqlite3 methods
const db = new sqlite3.Database(DB_PATH, (err) => {
if (err) {
console.error('Error opening database:', err.message);
} else {
console.log('Connected to SQLite database at:', DB_PATH);
initDatabase();
}
});
// Promisify database methods
const dbRun = (sql, params = []) => {
return new Promise((resolve, reject) => {
db.run(sql, params, function(err) {
if (err) reject(err);
else resolve({ id: this.lastID, changes: this.changes });
});
});
};
const dbGet = (sql, params = []) => {
return new Promise((resolve, reject) => {
db.get(sql, params, (err, row) => {
if (err) reject(err);
else resolve(row);
});
});
};
const dbAll = (sql, params = []) => {
return new Promise((resolve, reject) => {
db.all(sql, params, (err, rows) => {
if (err) reject(err);
else resolve(rows);
});
});
};
// Initialize database tables
const initDatabase = async () => {
try {
// Users table
await dbRun(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
name TEXT,
role TEXT DEFAULT 'user',
credits INTEGER DEFAULT 0,
subscription_status TEXT DEFAULT 'inactive',
subscription_tier TEXT,
stripe_customer_id TEXT,
paypal_customer_id TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_login DATETIME,
is_active INTEGER DEFAULT 1
)
`);
// API keys table for user API access
await dbRun(`
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
key_hash TEXT NOT NULL,
name TEXT,
last_used DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
is_active INTEGER DEFAULT 1,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
// Credit transactions table
await dbRun(`
CREATE TABLE IF NOT EXISTS credit_transactions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
amount INTEGER NOT NULL,
type TEXT NOT NULL,
description TEXT,
reference_id TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
// Payment history table
await dbRun(`
CREATE TABLE IF NOT EXISTS payments (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
amount REAL NOT NULL,
currency TEXT DEFAULT 'USD',
provider TEXT NOT NULL,
provider_payment_id TEXT,
status TEXT DEFAULT 'pending',
metadata TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
// Sessions table for user sessions
await dbRun(`
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
token_hash TEXT NOT NULL,
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
// Create indexes for better query performance
await dbRun(`CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)`);
await dbRun(`CREATE INDEX IF NOT EXISTS idx_users_stripe_customer ON users(stripe_customer_id)`);
await dbRun(`CREATE INDEX IF NOT EXISTS idx_users_paypal_customer ON users(paypal_customer_id)`);
await dbRun(`CREATE INDEX IF NOT EXISTS idx_api_keys_user ON api_keys(user_id)`);
await dbRun(`CREATE INDEX IF NOT EXISTS idx_credit_trans_user ON credit_transactions(user_id)`);
await dbRun(`CREATE INDEX IF NOT EXISTS idx_payments_user ON payments(user_id)`);
await dbRun(`CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)`);
console.log('Database tables initialized successfully');
} catch (err) {
console.error('Error initializing database:', err.message);
}
};
export { db, dbRun, dbGet, dbAll, initDatabase };

183
src/index.js Normal file
View File

@ -0,0 +1,183 @@
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import usersRouter from './routes/users.js';
import webhooksRouter from './routes/webhooks.js';
import { ApiResponse } from './utils/helpers.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const app = express();
const PORT = process.env.PORT || 9991;
// ============================================
// Middleware
// ============================================
// Security headers
app.use(helmet({
contentSecurityPolicy: false, // Disable for API
crossOriginEmbedderPolicy: false
}));
// CORS configuration
app.use(cors({
origin: process.env.CORS_ORIGIN || '*',
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true
}));
// Body parsing
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));
// Request logging
app.use((req, res, next) => {
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] ${req.method} ${req.path}`);
next();
});
// Trust proxy (for rate limiting behind reverse proxy)
app.set('trust proxy', 1);
// ============================================
// Routes
// ============================================
// Health check endpoint
app.get('/api/health', (req, res) => {
res.json(ApiResponse(true, {
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: process.uptime()
}));
});
// API info endpoint
app.get('/api', (req, res) => {
res.json(ApiResponse(true, {
name: 'Moxie Backend API',
version: '1.0.0',
endpoints: {
users: {
'POST /api/users/register': 'Register a new user',
'POST /api/users/login': 'Login user',
'POST /api/users/logout': 'Logout user (requires auth)',
'GET /api/users/me': 'Get current user profile (requires auth)',
'PUT /api/users/me': 'Update current user profile (requires auth)',
'PUT /api/users/me/password': 'Change password (requires auth)',
'DELETE /api/users/me': 'Delete account (requires auth)',
'GET /api/users/credits': 'Get credits and history (requires auth)',
'GET /api/users/api-keys': 'Get API keys (requires auth)',
'POST /api/users/api-keys': 'Create API key (requires auth)',
'DELETE /api/users/api-keys/:keyId': 'Revoke API key (requires auth)'
},
admin: {
'GET /api/users': 'List all users (admin only)',
'GET /api/users/:userId': 'Get user by ID (admin only)',
'PUT /api/users/:userId': 'Update user (admin only)',
'DELETE /api/users/:userId': 'Delete user (admin only)',
'POST /api/users/:userId/credits': 'Adjust user credits (admin only)'
},
webhooks: {
'POST /api/webhooks/stripe': 'Stripe webhook endpoint',
'POST /api/webhooks/paypal': 'PayPal webhook endpoint',
'POST /api/webhooks/create-payment': 'Create payment record (requires auth)'
}
}
}));
});
// Mount routers
app.use('/api/users', usersRouter);
app.use('/api/webhooks', webhooksRouter);
// ============================================
// Error Handling
// ============================================
// 404 handler
app.use((req, res) => {
res.status(404).json(ApiResponse(false, null, 'Endpoint not found'));
});
// Global error handler
app.use((err, req, res, next) => {
console.error('Error:', err);
// Handle specific error types
if (err.name === 'UnauthorizedError') {
return res.status(401).json(ApiResponse(false, null, 'Invalid token'));
}
if (err.name === 'ValidationError') {
return res.status(400).json(ApiResponse(false, null, err.message));
}
if (err.name === 'SyntaxError' && err.status === 400 && 'body' in err) {
return res.status(400).json(ApiResponse(false, null, 'Invalid JSON'));
}
// SQLite errors
if (err.code === 'SQLITE_CONSTRAINT') {
return res.status(409).json(ApiResponse(false, null, 'Database constraint violation'));
}
// Default error
res.status(err.status || 500).json(ApiResponse(
false,
process.env.NODE_ENV === 'development' ? { error: err.message, stack: err.stack } : null,
err.message || 'Internal server error'
));
});
// ============================================
// Server Startup
// ============================================
const server = app.listen(PORT, () => {
console.log(`
Moxie Backend Server Started
Port: ${PORT}
Mode: ${process.env.NODE_ENV || 'development'}
Time: ${new Date().toISOString()}
`);
});
// Graceful shutdown
process.on('SIGTERM', () => {
console.log('SIGTERM received, shutting down gracefully...');
server.close(() => {
console.log('Server closed');
process.exit(0);
});
});
process.on('SIGINT', () => {
console.log('SIGINT received, shutting down gracefully...');
server.close(() => {
console.log('Server closed');
process.exit(0);
});
});
// Handle uncaught exceptions
process.on('uncaughtException', (err) => {
console.error('Uncaught Exception:', err);
process.exit(1);
});
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
});
export default app;

178
src/middleware/auth.js Normal file
View File

@ -0,0 +1,178 @@
import bcrypt from 'bcryptjs';
import { dbGet } from '../db/database.js';
import { ApiResponse } from '../utils/helpers.js';
const SALT_ROUNDS = 12;
/**
* Hash a password
* @param {string} password - Plain text password
* @returns {Promise<string>} Hashed password
*/
export const hashPassword = async (password) => {
return bcrypt.hash(password, SALT_ROUNDS);
};
/**
* Compare password with hash
* @param {string} password - Plain text password
* @param {string} hash - Password hash
* @returns {Promise<boolean>} Passwords match
*/
export const comparePassword = async (password, hash) => {
return bcrypt.compare(password, hash);
};
/**
* Authentication middleware using Bearer token
* Expects Authorization: Bearer <token> header
*/
export const authenticateToken = async (req, res, next) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json(ApiResponse(false, null, 'Authorization token required'));
}
const token = authHeader.split(' ')[1];
if (!token) {
return res.status(401).json(ApiResponse(false, null, 'Invalid token format'));
}
// For now, we'll use a simple token validation
// In production, you'd validate JWT or check against sessions table
const session = await dbGet(
`SELECT s.*, u.id as user_id, u.email, u.name, u.role, u.credits,
u.subscription_status, u.subscription_tier, u.is_active
FROM sessions s
JOIN users u ON s.user_id = u.id
WHERE s.token_hash = ? AND s.expires_at > datetime('now')`,
[token]
);
if (!session) {
return res.status(401).json(ApiResponse(false, null, 'Invalid or expired token'));
}
if (!session.is_active) {
return res.status(403).json(ApiResponse(false, null, 'Account is disabled'));
}
// Attach user to request
req.user = {
id: session.user_id,
email: session.email,
name: session.name,
role: session.role,
credits: session.credits,
subscription_status: session.subscription_status,
subscription_tier: session.subscription_tier
};
req.sessionId = session.id;
next();
} catch (error) {
console.error('Auth middleware error:', error);
return res.status(500).json(ApiResponse(false, null, 'Authentication error'));
}
};
/**
* Optional authentication - doesn't fail if no token
*/
export const optionalAuth = async (req, res, next) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
req.user = null;
return next();
}
const token = authHeader.split(' ')[1];
const session = await dbGet(
`SELECT s.*, u.id as user_id, u.email, u.name, u.role, u.credits,
u.subscription_status, u.subscription_tier, u.is_active
FROM sessions s
JOIN users u ON s.user_id = u.id
WHERE s.token_hash = ? AND s.expires_at > datetime('now')`,
[token]
);
if (session && session.is_active) {
req.user = {
id: session.user_id,
email: session.email,
name: session.name,
role: session.role,
credits: session.credits,
subscription_status: session.subscription_status,
subscription_tier: session.subscription_tier
};
req.sessionId = session.id;
} else {
req.user = null;
}
next();
} catch (error) {
console.error('Optional auth error:', error);
req.user = null;
next();
}
};
/**
* Admin role check middleware
*/
export const requireAdmin = (req, res, next) => {
if (!req.user || req.user.role !== 'admin') {
return res.status(403).json(ApiResponse(false, null, 'Admin access required'));
}
next();
};
/**
* Rate limiting middleware (simple in-memory implementation)
* In production, use Redis or similar
*/
const rateLimitStore = new Map();
export const rateLimit = (maxRequests = 100, windowMs = 60000) => {
return (req, res, next) => {
const ip = req.ip || req.connection.remoteAddress;
const now = Date.now();
const windowStart = now - windowMs;
// Get or create request log for IP
let requests = rateLimitStore.get(ip) || [];
// Filter out old requests
requests = requests.filter(time => time > windowStart);
if (requests.length >= maxRequests) {
return res.status(429).json(ApiResponse(false, null, 'Too many requests, please try again later'));
}
requests.push(now);
rateLimitStore.set(ip, requests);
next();
};
};
// Clean up rate limit store periodically
setInterval(() => {
const now = Date.now();
for (const [ip, requests] of rateLimitStore.entries()) {
const filtered = requests.filter(time => time > now - 60000);
if (filtered.length === 0) {
rateLimitStore.delete(ip);
} else {
rateLimitStore.set(ip, filtered);
}
}
}, 60000);

463
src/routes/users.js Normal file
View File

@ -0,0 +1,463 @@
import express from 'express';
import { dbRun, dbGet, dbAll } from '../db/database.js';
import { hashPassword, comparePassword, authenticateToken, optionalAuth, requireAdmin } from '../middleware/auth.js';
import { generateId, isValidEmail, sanitizeUser, getPagination, ApiResponse, asyncHandler } from '../utils/helpers.js';
const router = express.Router();
/**
* @route POST /api/users/register
* @desc Register a new user
* @access Public
*/
router.post('/register', asyncHandler(async (req, res) => {
const { email, password, name } = req.body;
// Validation
if (!email || !password) {
return res.status(400).json(ApiResponse(false, null, 'Email and password are required'));
}
if (!isValidEmail(email)) {
return res.status(400).json(ApiResponse(false, null, 'Invalid email format'));
}
if (password.length < 8) {
return res.status(400).json(ApiResponse(false, null, 'Password must be at least 8 characters'));
}
// Check if user exists
const existingUser = await dbGet('SELECT id FROM users WHERE email = ?', [email.toLowerCase()]);
if (existingUser) {
return res.status(409).json(ApiResponse(false, null, 'Email already registered'));
}
// Create user
const userId = generateId();
const passwordHash = await hashPassword(password);
await dbRun(
`INSERT INTO users (id, email, password_hash, name, role, credits)
VALUES (?, ?, ?, ?, 'user', 0)`,
[userId, email.toLowerCase(), passwordHash, name || null]
);
const user = await dbGet('SELECT * FROM users WHERE id = ?', [userId]);
res.status(201).json(ApiResponse(true, { user: sanitizeUser(user) }, 'User registered successfully'));
}));
/**
* @route POST /api/users/login
* @desc Login user and return session token
* @access Public
*/
router.post('/login', asyncHandler(async (req, res) => {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json(ApiResponse(false, null, 'Email and password are required'));
}
// Find user
const user = await dbGet('SELECT * FROM users WHERE email = ?', [email.toLowerCase()]);
if (!user) {
return res.status(401).json(ApiResponse(false, null, 'Invalid credentials'));
}
if (!user.is_active) {
return res.status(403).json(ApiResponse(false, null, 'Account is disabled'));
}
// Verify password
const isValid = await comparePassword(password, user.password_hash);
if (!isValid) {
return res.status(401).json(ApiResponse(false, null, 'Invalid credentials'));
}
// Create session token
const sessionId = generateId();
const token = sessionId; // In production, use JWT or a more secure token
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); // 7 days
await dbRun(
`INSERT INTO sessions (id, user_id, token_hash, expires_at)
VALUES (?, ?, ?, ?)`,
[sessionId, user.id, token, expiresAt]
);
// Update last login
await dbRun('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?', [user.id]);
res.json(ApiResponse(true, {
user: sanitizeUser(user),
token,
expiresAt
}, 'Login successful'));
}));
/**
* @route POST /api/users/logout
* @desc Logout user (invalidate session)
* @access Private
*/
router.post('/logout', authenticateToken, asyncHandler(async (req, res) => {
await dbRun('DELETE FROM sessions WHERE id = ?', [req.sessionId]);
res.json(ApiResponse(true, null, 'Logged out successfully'));
}));
/**
* @route GET /api/users/me
* @desc Get current user profile
* @access Private
*/
router.get('/me', authenticateToken, asyncHandler(async (req, res) => {
const user = await dbGet('SELECT * FROM users WHERE id = ?', [req.user.id]);
if (!user) {
return res.status(404).json(ApiResponse(false, null, 'User not found'));
}
res.json(ApiResponse(true, { user: sanitizeUser(user) }));
}));
/**
* @route PUT /api/users/me
* @desc Update current user profile
* @access Private
*/
router.put('/me', authenticateToken, asyncHandler(async (req, res) => {
const { name, email } = req.body;
const updates = [];
const values = [];
if (name !== undefined) {
updates.push('name = ?');
values.push(name);
}
if (email !== undefined) {
if (!isValidEmail(email)) {
return res.status(400).json(ApiResponse(false, null, 'Invalid email format'));
}
const existing = await dbGet('SELECT id FROM users WHERE email = ? AND id != ?', [email.toLowerCase(), req.user.id]);
if (existing) {
return res.status(409).json(ApiResponse(false, null, 'Email already in use'));
}
updates.push('email = ?');
values.push(email.toLowerCase());
}
if (updates.length === 0) {
return res.status(400).json(ApiResponse(false, null, 'No fields to update'));
}
updates.push('updated_at = CURRENT_TIMESTAMP');
values.push(req.user.id);
await dbRun(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`, values);
const user = await dbGet('SELECT * FROM users WHERE id = ?', [req.user.id]);
res.json(ApiResponse(true, { user: sanitizeUser(user) }, 'Profile updated successfully'));
}));
/**
* @route PUT /api/users/me/password
* @desc Change user password
* @access Private
*/
router.put('/me/password', authenticateToken, asyncHandler(async (req, res) => {
const { currentPassword, newPassword } = req.body;
if (!currentPassword || !newPassword) {
return res.status(400).json(ApiResponse(false, null, 'Current and new password are required'));
}
if (newPassword.length < 8) {
return res.status(400).json(ApiResponse(false, null, 'New password must be at least 8 characters'));
}
const user = await dbGet('SELECT password_hash FROM users WHERE id = ?', [req.user.id]);
const isValid = await comparePassword(currentPassword, user.password_hash);
if (!isValid) {
return res.status(401).json(ApiResponse(false, null, 'Current password is incorrect'));
}
const passwordHash = await hashPassword(newPassword);
await dbRun('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [passwordHash, req.user.id]);
// Invalidate all other sessions
await dbRun('DELETE FROM sessions WHERE user_id = ? AND id != ?', [req.user.id, req.sessionId]);
res.json(ApiResponse(true, null, 'Password changed successfully'));
}));
/**
* @route DELETE /api/users/me
* @desc Delete user account
* @access Private
*/
router.delete('/me', authenticateToken, asyncHandler(async (req, res) => {
const { password } = req.body;
if (!password) {
return res.status(400).json(ApiResponse(false, null, 'Password confirmation required'));
}
const user = await dbGet('SELECT password_hash FROM users WHERE id = ?', [req.user.id]);
const isValid = await comparePassword(password, user.password_hash);
if (!isValid) {
return res.status(401).json(ApiResponse(false, null, 'Incorrect password'));
}
// Delete user (cascades to sessions, api_keys, etc.)
await dbRun('DELETE FROM users WHERE id = ?', [req.user.id]);
res.json(ApiResponse(true, null, 'Account deleted successfully'));
}));
/**
* @route GET /api/users/credits
* @desc Get user credits and transaction history
* @access Private
*/
router.get('/credits', authenticateToken, asyncHandler(async (req, res) => {
const user = await dbGet('SELECT credits FROM users WHERE id = ?', [req.user.id]);
const { page, limit, offset } = getPagination(req.query.page, req.query.limit);
const transactions = await dbAll(
`SELECT * FROM credit_transactions
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT ? OFFSET ?`,
[req.user.id, limit, offset]
);
const totalResult = await dbGet(
'SELECT COUNT(*) as count FROM credit_transactions WHERE user_id = ?',
[req.user.id]
);
res.json(ApiResponse(true, {
credits: user.credits,
transactions,
pagination: {
page,
limit,
total: totalResult.count,
totalPages: Math.ceil(totalResult.count / limit)
}
}));
}));
/**
* @route GET /api/users/api-keys
* @desc Get user API keys
* @access Private
*/
router.get('/api-keys', authenticateToken, asyncHandler(async (req, res) => {
const keys = await dbAll(
`SELECT id, name, last_used, created_at, is_active
FROM api_keys
WHERE user_id = ?
ORDER BY created_at DESC`,
[req.user.id]
);
res.json(ApiResponse(true, { keys }));
}));
/**
* @route POST /api/users/api-keys
* @desc Create a new API key
* @access Private
*/
router.post('/api-keys', authenticateToken, asyncHandler(async (req, res) => {
const { name } = req.body;
const keyId = generateId();
const keyValue = `moxie_${generateId()}_${generateId()}`;
const keyHash = await hashPassword(keyValue);
await dbRun(
`INSERT INTO api_keys (id, user_id, key_hash, name)
VALUES (?, ?, ?, ?)`,
[keyId, req.user.id, keyHash, name || 'API Key']
);
// Return the key value only once (can't be retrieved later)
res.status(201).json(ApiResponse(true, {
key: {
id: keyId,
name: name || 'API Key',
key: keyValue
}
}, 'API key created. Save this key - it cannot be retrieved again.'));
}));
/**
* @route DELETE /api/users/api-keys/:keyId
* @desc Revoke an API key
* @access Private
*/
router.delete('/api-keys/:keyId', authenticateToken, asyncHandler(async (req, res) => {
const result = await dbRun(
'DELETE FROM api_keys WHERE id = ? AND user_id = ?',
[req.params.keyId, req.user.id]
);
if (result.changes === 0) {
return res.status(404).json(ApiResponse(false, null, 'API key not found'));
}
res.json(ApiResponse(true, null, 'API key revoked'));
}));
// ============================================
// Admin routes
// ============================================
/**
* @route GET /api/users
* @desc List all users (admin)
* @access Admin
*/
router.get('/', authenticateToken, requireAdmin, asyncHandler(async (req, res) => {
const { page, limit, offset } = getPagination(req.query.page, req.query.limit);
const search = req.query.search;
let whereClause = '';
let params = [];
if (search) {
whereClause = 'WHERE email LIKE ? OR name LIKE ?';
const searchPattern = `%${search}%`;
params = [searchPattern, searchPattern];
}
const users = await dbAll(
`SELECT id, email, name, role, credits, subscription_status, subscription_tier,
is_active, created_at, last_login
FROM users
${whereClause}
ORDER BY created_at DESC
LIMIT ? OFFSET ?`,
[...params, limit, offset]
);
const totalResult = await dbGet(`SELECT COUNT(*) as count FROM users ${whereClause}`, params);
res.json(ApiResponse(true, {
users,
pagination: {
page,
limit,
total: totalResult.count,
totalPages: Math.ceil(totalResult.count / limit)
}
}));
}));
/**
* @route GET /api/users/:userId
* @desc Get user by ID (admin)
* @access Admin
*/
router.get('/:userId', authenticateToken, requireAdmin, asyncHandler(async (req, res) => {
const user = await dbGet('SELECT * FROM users WHERE id = ?', [req.params.userId]);
if (!user) {
return res.status(404).json(ApiResponse(false, null, 'User not found'));
}
res.json(ApiResponse(true, { user: sanitizeUser(user) }));
}));
/**
* @route PUT /api/users/:userId
* @desc Update user by ID (admin)
* @access Admin
*/
router.put('/:userId', authenticateToken, requireAdmin, asyncHandler(async (req, res) => {
const { name, role, credits, subscription_status, subscription_tier, is_active } = req.body;
const updates = [];
const values = [];
if (name !== undefined) { updates.push('name = ?'); values.push(name); }
if (role !== undefined) { updates.push('role = ?'); values.push(role); }
if (credits !== undefined) { updates.push('credits = ?'); values.push(credits); }
if (subscription_status !== undefined) { updates.push('subscription_status = ?'); values.push(subscription_status); }
if (subscription_tier !== undefined) { updates.push('subscription_tier = ?'); values.push(subscription_tier); }
if (is_active !== undefined) { updates.push('is_active = ?'); values.push(is_active ? 1 : 0); }
if (updates.length === 0) {
return res.status(400).json(ApiResponse(false, null, 'No fields to update'));
}
updates.push('updated_at = CURRENT_TIMESTAMP');
values.push(req.params.userId);
await dbRun(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`, values);
const user = await dbGet('SELECT * FROM users WHERE id = ?', [req.params.userId]);
res.json(ApiResponse(true, { user: sanitizeUser(user) }, 'User updated successfully'));
}));
/**
* @route DELETE /api/users/:userId
* @desc Delete user by ID (admin)
* @access Admin
*/
router.delete('/:userId', authenticateToken, requireAdmin, asyncHandler(async (req, res) => {
const result = await dbRun('DELETE FROM users WHERE id = ?', [req.params.userId]);
if (result.changes === 0) {
return res.status(404).json(ApiResponse(false, null, 'User not found'));
}
res.json(ApiResponse(true, null, 'User deleted successfully'));
}));
/**
* @route POST /api/users/:userId/credits
* @desc Add or remove credits from user (admin)
* @access Admin
*/
router.post('/:userId/credits', authenticateToken, requireAdmin, asyncHandler(async (req, res) => {
const { amount, description } = req.body;
if (!amount || typeof amount !== 'number') {
return res.status(400).json(ApiResponse(false, null, 'Valid amount is required'));
}
const user = await dbGet('SELECT credits FROM users WHERE id = ?', [req.params.userId]);
if (!user) {
return res.status(404).json(ApiResponse(false, null, 'User not found'));
}
const newBalance = user.credits + amount;
if (newBalance < 0) {
return res.status(400).json(ApiResponse(false, null, 'Insufficient credits'));
}
// Update user credits
await dbRun('UPDATE users SET credits = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [newBalance, req.params.userId]);
// Record transaction
const transactionId = generateId();
await dbRun(
`INSERT INTO credit_transactions (id, user_id, amount, type, description)
VALUES (?, ?, ?, ?, ?)`,
[transactionId, req.params.userId, amount, amount > 0 ? 'credit' : 'debit', description || 'Admin adjustment']
);
res.json(ApiResponse(true, {
previousBalance: user.credits,
amount,
newBalance,
transactionId
}, 'Credits updated successfully'));
}));
export default router;

454
src/routes/webhooks.js Normal file
View File

@ -0,0 +1,454 @@
import express from 'express';
import { dbRun, dbGet } from '../db/database.js';
import { generateId, ApiResponse, asyncHandler } from '../utils/helpers.js';
const router = express.Router();
// ============================================
// Stripe Webhook Endpoint
// ============================================
/**
* @route POST /api/webhooks/stripe
* @desc Handle Stripe webhook events
* @access Public (verified via signature)
*
* Stripe webhook events to handle:
* - checkout.session.completed - Payment successful
* - customer.created - New customer created
* - customer.updated - Customer updated
* - invoice.paid - Subscription payment
* - invoice.payment_failed - Payment failed
* - customer.subscription.created - New subscription
* - customer.subscription.updated - Subscription updated
* - customer.subscription.deleted - Subscription cancelled
*/
router.post('/stripe', express.raw({ type: 'application/json' }), asyncHandler(async (req, res) => {
const sig = req.headers['stripe-signature'];
const body = req.body;
// TODO: In production, verify Stripe signature
// Example:
// const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
// const event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET);
// For now, parse the body as JSON
let event;
try {
event = typeof body === 'string' ? JSON.parse(body) : body;
} catch (err) {
console.error('Failed to parse webhook body:', err);
return res.status(400).json(ApiResponse(false, null, 'Invalid payload'));
}
console.log('Stripe webhook received:', event.type);
try {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object;
await handleStripeCheckoutComplete(session);
break;
}
case 'customer.created': {
const customer = event.data.object;
await handleStripeCustomerCreated(customer);
break;
}
case 'invoice.paid': {
const invoice = event.data.object;
await handleStripeInvoicePaid(invoice);
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object;
await handleStripePaymentFailed(invoice);
break;
}
case 'customer.subscription.created':
case 'customer.subscription.updated': {
const subscription = event.data.object;
await handleStripeSubscriptionUpdated(subscription);
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object;
await handleStripeSubscriptionDeleted(subscription);
break;
}
default:
console.log('Unhandled Stripe event type:', event.type);
}
res.json({ received: true });
} catch (error) {
console.error('Error processing Stripe webhook:', error);
// Return 200 to acknowledge receipt, but log the error
res.json({ received: true, error: error.message });
}
}));
// Stripe event handlers
async function handleStripeCheckoutComplete(session) {
const customerId = session.customer;
const paymentIntentId = session.payment_intent;
const userId = session.client_reference_id || session.metadata?.user_id;
if (!userId) {
console.log('No user ID in checkout session');
return;
}
// Update payment record
await dbRun(
`UPDATE payments SET status = 'completed', updated_at = CURRENT_TIMESTAMP
WHERE provider_payment_id = ?`,
[paymentIntentId]
);
// Update user's Stripe customer ID
await dbRun(
`UPDATE users SET stripe_customer_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
[customerId, userId]
);
console.log(`Stripe checkout completed for user ${userId}`);
}
async function handleStripeCustomerCreated(customer) {
const userId = customer.metadata?.user_id;
if (userId) {
await dbRun(
`UPDATE users SET stripe_customer_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
[customer.id, userId]
);
console.log(`Stripe customer ${customer.id} linked to user ${userId}`);
}
}
async function handleStripeInvoicePaid(invoice) {
const customerId = invoice.customer;
const amount = invoice.amount_paid / 100; // Convert from cents
// Find user by Stripe customer ID
const user = await dbGet('SELECT id FROM users WHERE stripe_customer_id = ?', [customerId]);
if (!user) {
console.log(`No user found for Stripe customer ${customerId}`);
return;
}
// Add credits based on subscription or payment
const creditsToAdd = calculateCredits(amount, invoice.lines?.data);
if (creditsToAdd > 0) {
await dbRun('UPDATE users SET credits = credits + ? WHERE id = ?', [creditsToAdd, user.id]);
// Record transaction
await dbRun(
`INSERT INTO credit_transactions (id, user_id, amount, type, description, reference_id)
VALUES (?, ?, ?, 'credit', 'Stripe payment', ?)`,
[generateId(), user.id, creditsToAdd, invoice.id]
);
}
// Record payment
await dbRun(
`INSERT INTO payments (id, user_id, amount, provider, provider_payment_id, status, metadata)
VALUES (?, ?, ?, 'stripe', ?, 'completed', ?)`,
[generateId(), user.id, amount, invoice.id, JSON.stringify(invoice)]
);
console.log(`Stripe invoice paid: ${amount} for user ${user.id}`);
}
async function handleStripePaymentFailed(invoice) {
const customerId = invoice.customer;
const user = await dbGet('SELECT id FROM users WHERE stripe_customer_id = ?', [customerId]);
if (user) {
await dbRun(
`UPDATE users SET subscription_status = 'past_due', updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
[user.id]
);
// Record failed payment
await dbRun(
`INSERT INTO payments (id, user_id, amount, provider, provider_payment_id, status, metadata)
VALUES (?, ?, ?, 'stripe', ?, 'failed', ?)`,
[generateId(), user.id, invoice.amount_due / 100, invoice.id, JSON.stringify(invoice)]
);
}
}
async function handleStripeSubscriptionUpdated(subscription) {
const customerId = subscription.customer;
const user = await dbGet('SELECT id FROM users WHERE stripe_customer_id = ?', [customerId]);
if (user) {
const status = mapStripeSubscriptionStatus(subscription.status);
const tier = subscription.metadata?.tier || subscription.items?.data?.[0]?.price?.nickname || 'standard';
await dbRun(
`UPDATE users SET subscription_status = ?, subscription_tier = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
[status, tier, user.id]
);
}
}
async function handleStripeSubscriptionDeleted(subscription) {
const customerId = subscription.customer;
const user = await dbGet('SELECT id FROM users WHERE stripe_customer_id = ?', [customerId]);
if (user) {
await dbRun(
`UPDATE users SET subscription_status = 'cancelled', subscription_tier = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
[user.id]
);
}
}
// ============================================
// PayPal Webhook Endpoint
// ============================================
/**
* @route POST /api/webhooks/paypal
* @desc Handle PayPal webhook events
* @access Public (verified via signature)
*
* PayPal webhook events to handle:
* - CHECKOUT.ORDER.APPROVED - Order approved
* - PAYMENT.CAPTURE.COMPLETED - Payment captured
* - PAYMENT.CAPTURE.DENIED - Payment denied
* - BILLING.SUBSCRIPTION.CREATED - Subscription created
* - BILLING.SUBSCRIPTION.ACTIVATED - Subscription activated
* - BILLING.SUBSCRIPTION.CANCELLED - Subscription cancelled
* - BILLING.SUBSCRIPTION.SUSPENDED - Subscription suspended
*/
router.post('/paypal', express.json(), asyncHandler(async (req, res) => {
const event = req.body;
// TODO: In production, verify PayPal webhook signature
// See: https://developer.paypal.com/api/rest/webhooks/rest/#verify-webhook-signature
console.log('PayPal webhook received:', event.event_type);
try {
switch (event.event_type) {
case 'CHECKOUT.ORDER.APPROVED': {
await handlePaypalOrderApproved(event.resource);
break;
}
case 'PAYMENT.CAPTURE.COMPLETED': {
await handlePaypalPaymentCompleted(event.resource);
break;
}
case 'PAYMENT.CAPTURE.DENIED': {
await handlePaypalPaymentDenied(event.resource);
break;
}
case 'BILLING.SUBSCRIPTION.ACTIVATED': {
await handlePaypalSubscriptionActivated(event.resource);
break;
}
case 'BILLING.SUBSCRIPTION.CANCELLED': {
await handlePaypalSubscriptionCancelled(event.resource);
break;
}
case 'BILLING.SUBSCRIPTION.SUSPENDED': {
await handlePaypalSubscriptionSuspended(event.resource);
break;
}
default:
console.log('Unhandled PayPal event type:', event.event_type);
}
res.json({ received: true });
} catch (error) {
console.error('Error processing PayPal webhook:', error);
res.json({ received: true, error: error.message });
}
}));
// PayPal event handlers
async function handlePaypalOrderApproved(resource) {
const orderId = resource.id;
const userId = resource.purchase_units?.[0]?.custom_id || resource.metadata?.user_id;
console.log(`PayPal order ${orderId} approved for user ${userId}`);
// Order will be captured on the frontend or via API call
}
async function handlePaypalPaymentCompleted(resource) {
const captureId = resource.id;
const amount = parseFloat(resource.amount?.value || 0);
const customId = resource.custom_id || resource.supplementary_data?.related_ids?.order_id;
// Find payment record
const payment = await dbGet('SELECT user_id FROM payments WHERE provider_payment_id = ?', [captureId]);
let userId = payment?.user_id;
// Try to find user by custom_id if no payment record
if (!userId && customId) {
const user = await dbGet('SELECT id FROM users WHERE id = ?', [customId]);
if (user) userId = user.id;
}
if (!userId) {
console.log('No user ID found for PayPal payment');
return;
}
// Update payment status
await dbRun(
`UPDATE payments SET status = 'completed', updated_at = CURRENT_TIMESTAMP
WHERE provider_payment_id = ?`,
[captureId]
);
// Add credits
const creditsToAdd = calculateCredits(amount);
if (creditsToAdd > 0) {
await dbRun('UPDATE users SET credits = credits + ? WHERE id = ?', [creditsToAdd, userId]);
await dbRun(
`INSERT INTO credit_transactions (id, user_id, amount, type, description, reference_id)
VALUES (?, ?, ?, 'credit', 'PayPal payment', ?)`,
[generateId(), userId, creditsToAdd, captureId]
);
}
console.log(`PayPal payment completed: ${amount} for user ${userId}`);
}
async function handlePaypalPaymentDenied(resource) {
const captureId = resource.id;
await dbRun(
`UPDATE payments SET status = 'failed', updated_at = CURRENT_TIMESTAMP
WHERE provider_payment_id = ?`,
[captureId]
);
console.log(`PayPal payment denied: ${captureId}`);
}
async function handlePaypalSubscriptionActivated(resource) {
const subscriptionId = resource.id;
const customerId = resource.subscriber?.payer_id;
// Update or create user subscription
const user = await dbGet('SELECT id FROM users WHERE paypal_customer_id = ?', [customerId]);
if (user) {
await dbRun(
`UPDATE users SET subscription_status = 'active', subscription_tier = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
[resource.plan_id || 'standard', user.id]
);
}
console.log(`PayPal subscription activated: ${subscriptionId}`);
}
async function handlePaypalSubscriptionCancelled(resource) {
const subscriptionId = resource.id;
const customerId = resource.subscriber?.payer_id;
const user = await dbGet('SELECT id FROM users WHERE paypal_customer_id = ?', [customerId]);
if (user) {
await dbRun(
`UPDATE users SET subscription_status = 'cancelled', subscription_tier = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
[user.id]
);
}
console.log(`PayPal subscription cancelled: ${subscriptionId}`);
}
async function handlePaypalSubscriptionSuspended(resource) {
const subscriptionId = resource.id;
const customerId = resource.subscriber?.payer_id;
const user = await dbGet('SELECT id FROM users WHERE paypal_customer_id = ?', [customerId]);
if (user) {
await dbRun(
`UPDATE users SET subscription_status = 'suspended', updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
[user.id]
);
}
console.log(`PayPal subscription suspended: ${subscriptionId}`);
}
// ============================================
// Helper functions
// ============================================
function calculateCredits(amount, lineItems) {
// Implement your credit calculation logic here
// Example: $1 = 100 credits
const baseCredits = Math.floor(amount * 100);
// Check for bonus credits based on subscription tier
if (lineItems) {
// Add bonus for subscriptions
}
return baseCredits;
}
function mapStripeSubscriptionStatus(status) {
const statusMap = {
'active': 'active',
'past_due': 'past_due',
'canceled': 'cancelled',
'unpaid': 'unpaid',
'trialing': 'trialing',
'incomplete': 'incomplete',
'incomplete_expired': 'expired',
'paused': 'paused'
};
return statusMap[status] || status;
}
// ============================================
// Payment initialization endpoints
// ============================================
/**
* @route POST /api/webhooks/create-payment
* @desc Create a payment record for tracking
* @access Private
*/
router.post('/create-payment', asyncHandler(async (req, res) => {
// This would typically be called from your frontend before initiating payment
const { userId, amount, provider, metadata } = req.body;
if (!userId || !amount || !provider) {
return res.status(400).json(ApiResponse(false, null, 'userId, amount, and provider are required'));
}
const paymentId = generateId();
await dbRun(
`INSERT INTO payments (id, user_id, amount, provider, status, metadata)
VALUES (?, ?, ?, ?, 'pending', ?)`,
[paymentId, userId, amount, provider, JSON.stringify(metadata || {})]
);
res.status(201).json(ApiResponse(true, { paymentId }, 'Payment record created'));
}));
export default router;

85
src/utils/helpers.js Normal file
View File

@ -0,0 +1,85 @@
import { v4 as uuidv4 } from 'uuid';
/**
* Generate a unique ID
* @returns {string} UUID v4
*/
export const generateId = () => uuidv4();
/**
* Generate a random token
* @param {number} length - Token length
* @returns {string} Random token
*/
export const generateToken = (length = 32) => {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let token = '';
for (let i = 0; i < length; i++) {
token += chars.charAt(Math.floor(Math.random() * chars.length));
}
return token;
};
/**
* Validate email format
* @param {string} email - Email to validate
* @returns {boolean} Is valid email
*/
export const isValidEmail = (email) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
/**
* Sanitize user object (remove sensitive fields)
* @param {Object} user - User object
* @returns {Object} Sanitized user object
*/
export const sanitizeUser = (user) => {
if (!user) return null;
const { password_hash, ...safeUser } = user;
return safeUser;
};
/**
* Calculate pagination
* @param {number} page - Current page
* @param {number} limit - Items per page
* @returns {Object} Pagination object
*/
export const getPagination = (page = 1, limit = 10) => {
const offset = (page - 1) * limit;
return { offset, limit: parseInt(limit), page: parseInt(page) };
};
/**
* Format date to ISO string
* @param {Date|string} date - Date to format
* @returns {string} ISO date string
*/
export const formatDate = (date) => {
return new Date(date).toISOString();
};
/**
* Async handler wrapper for Express routes
* @param {Function} fn - Async function to wrap
* @returns {Function} Express middleware
*/
export const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
/**
* Create a standardized API response
* @param {boolean} success - Success status
* @param {*} data - Response data
* @param {string} message - Response message
* @returns {Object} API response object
*/
export const ApiResponse = (success, data = null, message = null) => {
const response = { success };
if (data !== null) response.data = data;
if (message) response.message = message;
return response;
};