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:
commit
55335f14e7
24
.env.example
Normal file
24
.env.example
Normal 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
40
.gitignore
vendored
Normal 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
172
README.md
Normal 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
1994
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
package.json
Normal file
26
package.json
Normal 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
143
src/db/database.js
Normal 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
183
src/index.js
Normal 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
178
src/middleware/auth.js
Normal 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
463
src/routes/users.js
Normal 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
454
src/routes/webhooks.js
Normal 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
85
src/utils/helpers.js
Normal 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;
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user