diff --git a/.env.example b/.env.example index e58a9ca..a3ab250 100644 --- a/.env.example +++ b/.env.example @@ -30,7 +30,7 @@ DB_HOST=localhost DB_PORT=5432 DB_NAME=trading_platform DB_USER=trading_user -DB_PASSWORD=trading_dev_2025 +DB_PASSWORD=trading_dev_2026 DB_SSL=false DB_POOL_MAX=20 DB_IDLE_TIMEOUT=30000 diff --git a/package-lock.json b/package-lock.json index 8d57b99..681040b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "firebase-admin": "^13.6.0", "google-auth-library": "^9.4.1", "helmet": "^8.1.0", + "ioredis": "^5.9.2", "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", "nodemailer": "^7.0.11", @@ -54,6 +55,7 @@ "@types/compression": "^1.7.5", "@types/cors": "^2.8.17", "@types/express": "^5.0.0", + "@types/ioredis": "^4.28.10", "@types/jest": "^30.0.0", "@types/jsonwebtoken": "^9.0.5", "@types/morgan": "^1.9.9", @@ -2425,6 +2427,12 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@ioredis/commands": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz", + "integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==", + "license": "MIT" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -4023,6 +4031,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ioredis": { + "version": "4.28.10", + "resolved": "https://registry.npmjs.org/@types/ioredis/-/ioredis-4.28.10.tgz", + "integrity": "sha512-69LyhUgrXdgcNDv7ogs1qXZomnfOEnSmrmMFqKgt1XMJxmoOSG/u3wYy13yACIfKuMJ8IhKgHafDO3sx19zVQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -6033,6 +6051,15 @@ "node": ">=0.8" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -6489,6 +6516,15 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -8311,6 +8347,30 @@ "node": ">= 0.4" } }, + "node_modules/ioredis": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.2.tgz", + "integrity": "sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.0", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -9752,6 +9812,12 @@ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", "license": "MIT" }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", @@ -11389,6 +11455,27 @@ "node": ">=10" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -11954,6 +12041,12 @@ "node": ">=8" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", diff --git a/package.json b/package.json index 1e05972..e57f464 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "firebase-admin": "^13.6.0", "google-auth-library": "^9.4.1", "helmet": "^8.1.0", + "ioredis": "^5.9.2", "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", "nodemailer": "^7.0.11", @@ -62,6 +63,7 @@ "@types/compression": "^1.7.5", "@types/cors": "^2.8.17", "@types/express": "^5.0.0", + "@types/ioredis": "^4.28.10", "@types/jest": "^30.0.0", "@types/jsonwebtoken": "^9.0.5", "@types/morgan": "^1.9.9", diff --git a/src/config/index.ts b/src/config/index.ts index d65e0c7..02fc9d4 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -11,13 +11,13 @@ export const config = { name: 'Trading Platform', version: '0.1.0', env: process.env.NODE_ENV || 'development', - port: parseInt(process.env.PORT || '3000', 10), - frontendUrl: process.env.FRONTEND_URL || 'http://localhost:5173', - apiUrl: process.env.API_URL || 'http://localhost:3000', + port: parseInt(process.env.PORT || '3081', 10), + frontendUrl: process.env.FRONTEND_URL || 'http://localhost:3080', + apiUrl: process.env.API_URL || 'http://localhost:3081', }, cors: { - origins: process.env.CORS_ORIGINS?.split(',') || ['http://localhost:5173'], + origins: process.env.CORS_ORIGINS?.split(',') || ['http://localhost:3080'], }, jwt: { @@ -30,9 +30,9 @@ export const config = { database: { host: process.env.DB_HOST || 'localhost', port: parseInt(process.env.DB_PORT || '5432', 10), - name: process.env.DB_NAME || 'trading', - user: process.env.DB_USER || 'postgres', - password: process.env.DB_PASSWORD || 'postgres', + name: process.env.DB_NAME || 'trading_platform', + user: process.env.DB_USER || 'trading_user', + password: process.env.DB_PASSWORD || 'trading_dev_2026', ssl: process.env.DB_SSL === 'true', poolMax: parseInt(process.env.DB_POOL_MAX || '20', 10), idleTimeout: parseInt(process.env.DB_IDLE_TIMEOUT || '30000', 10), @@ -44,7 +44,7 @@ export const config = { host: process.env.REDIS_HOST || 'localhost', port: parseInt(process.env.REDIS_PORT || '6379', 10), password: process.env.REDIS_PASSWORD, - db: parseInt(process.env.REDIS_DB || '0', 10), + db: parseInt(process.env.REDIS_DB || '1', 10), }, stripe: { diff --git a/src/modules/admin/admin.routes.ts b/src/modules/admin/admin.routes.ts index 93fb337..dc43c63 100644 --- a/src/modules/admin/admin.routes.ts +++ b/src/modules/admin/admin.routes.ts @@ -1,10 +1,13 @@ /** * Admin Routes * Admin-only endpoints for dashboard, user management, system health, and audit logs + * Wired to real database queries (trading_platform) */ import { Router, Request, Response, NextFunction } from 'express'; import { mlEngineClient, tradingAgentsClient } from '../../shared/clients/index.js'; +import { db } from '../../shared/database/index.js'; +import { redis } from '../../shared/redis/index.js'; const router = Router(); @@ -14,66 +17,105 @@ const router = Router(); /** * GET /api/v1/admin/dashboard - * Get dashboard statistics + * Get dashboard statistics from real database */ router.get('/dashboard', async (req: Request, res: Response, next: NextFunction) => { try { - // Mock stats for development - replace with actual DB queries in production - const stats = { - users: { - total_users: 150, - active_users: 142, - new_users_week: 12, - new_users_month: 45, - }, - trading: { - total_trades: 1256, - trades_today: 48, - winning_trades: 723, - avg_pnl: 125.50, - }, - models: { - total_models: 6, - active_models: 5, - predictions_today: 1247, - overall_accuracy: 0.68, - }, - agents: { - total_agents: 3, - active_agents: 1, - signals_today: 24, - }, - pnl: { - today: 1250.75, - week: 8456.32, - month: 32145.89, - }, - system: { - uptime: process.uptime(), - memory: process.memoryUsage(), - version: process.env.npm_package_version || '1.0.0', - }, - timestamp: new Date().toISOString(), - }; + // Run all stats queries in parallel + const [ + usersResult, + activeUsersResult, + newUsersWeekResult, + newUsersMonthResult, + totalTradesResult, + tradesTodayResult, + winningTradesResult, + totalModelsResult, + activeModelsResult, + predictionsTodayResult, + overallAccuracyResult, + signalsTodayResult, + pnlTodayResult, + pnlWeekResult, + pnlMonthResult, + avgPnlResult, + ] = await Promise.all([ + db.query<{ count: string }>('SELECT COUNT(*) as count FROM auth.users'), + db.query<{ count: string }>("SELECT COUNT(*) as count FROM auth.users WHERE status = 'active'"), + db.query<{ count: string }>("SELECT COUNT(*) as count FROM auth.users WHERE created_at >= NOW() - INTERVAL '7 days'"), + db.query<{ count: string }>("SELECT COUNT(*) as count FROM auth.users WHERE created_at >= NOW() - INTERVAL '30 days'"), + db.query<{ count: string }>('SELECT COUNT(*) as count FROM trading.trades'), + db.query<{ count: string }>("SELECT COUNT(*) as count FROM trading.trades WHERE DATE(executed_at) = CURRENT_DATE"), + db.query<{ count: string }>("SELECT COUNT(*) as count FROM trading.signals WHERE actual_outcome = 'hit_target'"), + db.query<{ count: string }>('SELECT COUNT(*) as count FROM ml.models'), + db.query<{ count: string }>("SELECT COUNT(*) as count FROM ml.models WHERE status = 'production'"), + db.query<{ count: string }>("SELECT COUNT(*) as count FROM ml.predictions WHERE DATE(created_at) = CURRENT_DATE"), + db.query<{ avg: string | null }>("SELECT AVG(overall_accuracy) as avg FROM ml.models WHERE status = 'production'"), + db.query<{ count: string }>("SELECT COUNT(*) as count FROM trading.signals WHERE DATE(created_at) = CURRENT_DATE"), + // P&L from closed positions + db.query<{ total: string | null }>( + "SELECT COALESCE(SUM(realized_pnl), 0) as total FROM trading.positions WHERE status = 'closed' AND DATE(closed_at) = CURRENT_DATE" + ), + db.query<{ total: string | null }>( + "SELECT COALESCE(SUM(realized_pnl), 0) as total FROM trading.positions WHERE status = 'closed' AND closed_at >= NOW() - INTERVAL '7 days'" + ), + db.query<{ total: string | null }>( + "SELECT COALESCE(SUM(realized_pnl), 0) as total FROM trading.positions WHERE status = 'closed' AND closed_at >= NOW() - INTERVAL '30 days'" + ), + db.query<{ avg: string | null }>( + "SELECT AVG(realized_pnl) as avg FROM trading.positions WHERE status = 'closed' AND realized_pnl != 0" + ), + ]); + + const totalUsers = parseInt(usersResult.rows[0]?.count || '0', 10); + const activeUsers = parseInt(activeUsersResult.rows[0]?.count || '0', 10); + const newUsersWeek = parseInt(newUsersWeekResult.rows[0]?.count || '0', 10); + const newUsersMonth = parseInt(newUsersMonthResult.rows[0]?.count || '0', 10); + const totalTrades = parseInt(totalTradesResult.rows[0]?.count || '0', 10); + const tradesToday = parseInt(tradesTodayResult.rows[0]?.count || '0', 10); + const winningTrades = parseInt(winningTradesResult.rows[0]?.count || '0', 10); + const totalModels = parseInt(totalModelsResult.rows[0]?.count || '0', 10); + const activeModels = parseInt(activeModelsResult.rows[0]?.count || '0', 10); + const predictionsToday = parseInt(predictionsTodayResult.rows[0]?.count || '0', 10); + const overallAccuracy = parseFloat(overallAccuracyResult.rows[0]?.avg || '0'); + const signalsToday = parseInt(signalsTodayResult.rows[0]?.count || '0', 10); + const pnlToday = parseFloat(pnlTodayResult.rows[0]?.total || '0'); + const pnlWeek = parseFloat(pnlWeekResult.rows[0]?.total || '0'); + const pnlMonth = parseFloat(pnlMonthResult.rows[0]?.total || '0'); + const avgPnl = parseFloat(avgPnlResult.rows[0]?.avg || '0'); res.json({ success: true, data: { - total_models: stats.models.total_models, - active_models: stats.models.active_models, - total_predictions_today: stats.models.predictions_today, - total_predictions_week: stats.models.predictions_today * 7, - overall_accuracy: stats.models.overall_accuracy, - total_agents: stats.agents.total_agents, - active_agents: stats.agents.active_agents, - total_signals_today: stats.agents.signals_today, - total_pnl_today: stats.pnl.today, - total_pnl_week: stats.pnl.week, - total_pnl_month: stats.pnl.month, + total_models: totalModels, + active_models: activeModels, + total_predictions_today: predictionsToday, + total_predictions_week: predictionsToday * 7, // Approximate + overall_accuracy: overallAccuracy, + total_agents: 3, // Atlas, Orion, Nova - static config + active_agents: 0, // Query external service if needed + total_signals_today: signalsToday, + total_pnl_today: pnlToday, + total_pnl_week: pnlWeek, + total_pnl_month: pnlMonth, system_health: 'healthy', - users: stats.users, - trading: stats.trading, - system: stats.system, + users: { + total_users: totalUsers, + active_users: activeUsers, + new_users_week: newUsersWeek, + new_users_month: newUsersMonth, + }, + trading: { + total_trades: totalTrades, + trades_today: tradesToday, + winning_trades: winningTrades, + avg_pnl: avgPnl, + }, + system: { + uptime: process.uptime(), + memory: process.memoryUsage(), + version: process.env.npm_package_version || '1.0.0', + }, }, }); } catch (error) { @@ -87,10 +129,22 @@ router.get('/dashboard', async (req: Request, res: Response, next: NextFunction) /** * GET /api/v1/admin/system/health - * Get system-wide health status + * Get system-wide health status with real checks */ router.get('/system/health', async (req: Request, res: Response, next: NextFunction) => { try { + // Check Database + let dbHealth = 'unknown'; + let dbLatency = 0; + try { + const dbStart = Date.now(); + const healthy = await db.healthCheck(); + dbLatency = Date.now() - dbStart; + dbHealth = healthy ? 'healthy' : 'unhealthy'; + } catch { + dbHealth = 'unhealthy'; + } + // Check ML Engine let mlHealth = 'unknown'; let mlLatency = 0; @@ -115,14 +169,32 @@ router.get('/system/health', async (req: Request, res: Response, next: NextFunct agentsHealth = 'unhealthy'; } - const overallHealth = (mlHealth === 'healthy' && agentsHealth === 'healthy') ? 'healthy' : 'degraded'; + // Check Redis + let redisHealth = 'unknown'; + let redisLatency = 0; + let redisType = 'unknown'; + try { + const redisCheck = await redis.healthCheck(); + redisHealth = redisCheck.status; + redisLatency = redisCheck.latency; + redisType = redisCheck.type; + } catch { + redisHealth = 'unhealthy'; + } + + const allHealthy = dbHealth === 'healthy' && mlHealth === 'healthy' && agentsHealth === 'healthy' && redisHealth === 'healthy'; + const overallHealth = allHealthy ? 'healthy' : (dbHealth === 'healthy' ? 'degraded' : 'unhealthy'); + + const poolStatus = db.getPoolStatus(); + const memUsage = process.memoryUsage(); const health = { status: overallHealth, services: { database: { - status: 'healthy', // Mock for now - add actual DB check - latency: 5, + status: dbHealth, + latency: dbLatency, + pool: poolStatus, }, mlEngine: { status: mlHealth, @@ -133,16 +205,17 @@ router.get('/system/health', async (req: Request, res: Response, next: NextFunct latency: agentsLatency, }, redis: { - status: 'healthy', // Mock for now - latency: 2, + status: redisHealth, + latency: redisLatency, + type: redisType, }, }, system: { uptime: process.uptime(), memory: { - used: process.memoryUsage().heapUsed, - total: process.memoryUsage().heapTotal, - percentage: (process.memoryUsage().heapUsed / process.memoryUsage().heapTotal) * 100, + used: memUsage.heapUsed, + total: memUsage.heapTotal, + percentage: Math.round((memUsage.heapUsed / memUsage.heapTotal) * 100), }, cpu: process.cpuUsage(), }, @@ -164,68 +237,91 @@ router.get('/system/health', async (req: Request, res: Response, next: NextFunct /** * GET /api/v1/admin/users - * List all users with filters and pagination + * List all users with filters and pagination from real database */ router.get('/users', async (req: Request, res: Response, next: NextFunction) => { try { - const { page = 1, limit = 20, status, role, search } = req.query; + const page = Math.max(1, parseInt(req.query.page as string, 10) || 1); + const limit = Math.min(100, Math.max(1, parseInt(req.query.limit as string, 10) || 20)); + const offset = (page - 1) * limit; + const { status, role, search } = req.query; - // Mock users data for development - const mockUsers = [ - { - id: '1', - email: 'admin@trading.local', - role: 'admin', - status: 'active', - created_at: new Date().toISOString(), - full_name: 'Admin Trading Platform', - }, - { - id: '2', - email: 'trader1@example.com', - role: 'premium', - status: 'active', - created_at: new Date().toISOString(), - full_name: 'Trader One', - }, - { - id: '3', - email: 'trader2@example.com', - role: 'user', - status: 'active', - created_at: new Date().toISOString(), - full_name: 'Trader Two', - }, - ]; + // Build dynamic query + const conditions: string[] = []; + const params: (string | number)[] = []; + let paramIdx = 1; - let filteredUsers = mockUsers; - - if (status) { - filteredUsers = filteredUsers.filter(u => u.status === status); - } - if (role) { - filteredUsers = filteredUsers.filter(u => u.role === role); - } - if (search) { - const searchLower = (search as string).toLowerCase(); - filteredUsers = filteredUsers.filter(u => - u.email.toLowerCase().includes(searchLower) || - u.full_name.toLowerCase().includes(searchLower) - ); + if (status && typeof status === 'string') { + conditions.push(`u.status = $${paramIdx}::auth.user_status`); + params.push(status); + paramIdx++; } - const total = filteredUsers.length; - const start = (Number(page) - 1) * Number(limit); - const paginatedUsers = filteredUsers.slice(start, start + Number(limit)); + if (role && typeof role === 'string') { + conditions.push(`u.role = $${paramIdx}::auth.user_role`); + params.push(role); + paramIdx++; + } + + if (search && typeof search === 'string') { + conditions.push(`( + u.email ILIKE $${paramIdx} + OR up.first_name ILIKE $${paramIdx} + OR up.last_name ILIKE $${paramIdx} + OR up.display_name ILIKE $${paramIdx} + )`); + params.push(`%${search}%`); + paramIdx++; + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + // Count total + const countResult = await db.query<{ count: string }>( + `SELECT COUNT(*) as count + FROM auth.users u + LEFT JOIN auth.user_profiles up ON u.id = up.user_id + ${whereClause}`, + params + ); + const total = parseInt(countResult.rows[0]?.count || '0', 10); + + // Fetch users + const usersResult = await db.query( + `SELECT + u.id, u.email, u.role::text, u.status::text, u.email_verified, + u.mfa_enabled, u.last_login_at, u.created_at, u.updated_at, + up.first_name, up.last_name, up.display_name, up.avatar_url + FROM auth.users u + LEFT JOIN auth.user_profiles up ON u.id = up.user_id + ${whereClause} + ORDER BY u.created_at DESC + LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`, + [...params, limit, offset] + ); + + const users = usersResult.rows.map((row: any) => ({ + id: row.id, + email: row.email, + role: row.role, + status: row.status, + email_verified: row.email_verified, + mfa_enabled: row.mfa_enabled, + last_login_at: row.last_login_at, + created_at: row.created_at, + updated_at: row.updated_at, + full_name: [row.first_name, row.last_name].filter(Boolean).join(' ') || row.display_name || row.email, + avatar_url: row.avatar_url, + })); res.json({ success: true, - data: paginatedUsers, + data: users, meta: { total, - page: Number(page), - limit: Number(limit), - totalPages: Math.ceil(total / Number(limit)), + page, + limit, + totalPages: Math.ceil(total / limit), }, }); } catch (error) { @@ -235,23 +331,61 @@ router.get('/users', async (req: Request, res: Response, next: NextFunction) => /** * GET /api/v1/admin/users/:id - * Get user details by ID + * Get user details by ID from real database */ router.get('/users/:id', async (req: Request, res: Response, next: NextFunction) => { try { const { id } = req.params; - // Mock user data + const result = await db.query( + `SELECT + u.id, u.email, u.role::text, u.status::text, u.email_verified, + u.mfa_enabled, u.mfa_method::text, u.phone_number, u.phone_verified, + u.last_login_at, u.last_login_ip, u.failed_login_attempts, + u.suspended_at, u.suspended_reason, u.created_at, u.updated_at, + up.first_name, up.last_name, up.display_name, up.avatar_url, + up.bio, up.language, up.timezone, up.country_code + FROM auth.users u + LEFT JOIN auth.user_profiles up ON u.id = up.user_id + WHERE u.id = $1`, + [id] + ); + + if (result.rows.length === 0) { + res.status(404).json({ + success: false, + error: { message: 'User not found', code: 'NOT_FOUND' }, + }); + return; + } + + const row = result.rows[0] as any; const user = { - id, - email: 'admin@trading.local', - role: 'admin', - status: 'active', - created_at: new Date().toISOString(), - full_name: 'Admin Trading Platform', - avatar_url: null, - bio: 'Platform administrator', - location: 'Remote', + id: row.id, + email: row.email, + role: row.role, + status: row.status, + email_verified: row.email_verified, + mfa_enabled: row.mfa_enabled, + mfa_method: row.mfa_method, + phone_number: row.phone_number, + phone_verified: row.phone_verified, + last_login_at: row.last_login_at, + last_login_ip: row.last_login_ip?.toString() || null, + failed_login_attempts: row.failed_login_attempts, + suspended_at: row.suspended_at, + suspended_reason: row.suspended_reason, + created_at: row.created_at, + updated_at: row.updated_at, + full_name: [row.first_name, row.last_name].filter(Boolean).join(' ') || row.display_name || row.email, + first_name: row.first_name, + last_name: row.last_name, + display_name: row.display_name, + avatar_url: row.avatar_url, + bio: row.bio, + language: row.language, + timezone: row.timezone, + country_code: row.country_code, }; res.json({ @@ -265,29 +399,46 @@ router.get('/users/:id', async (req: Request, res: Response, next: NextFunction) /** * PATCH /api/v1/admin/users/:id/status - * Update user status + * Update user status in real database */ router.patch('/users/:id/status', async (req: Request, res: Response, next: NextFunction) => { try { const { id } = req.params; const { status, reason } = req.body; - if (!['active', 'suspended', 'banned'].includes(status)) { + const validStatuses = ['active', 'suspended', 'deactivated', 'banned']; + if (!validStatuses.includes(status)) { res.status(400).json({ success: false, - error: { message: 'Invalid status value', code: 'VALIDATION_ERROR' }, + error: { message: `Invalid status. Must be one of: ${validStatuses.join(', ')}`, code: 'VALIDATION_ERROR' }, + }); + return; + } + + const suspendedAt = (status === 'suspended' || status === 'banned') ? 'NOW()' : 'NULL'; + + const result = await db.query( + `UPDATE auth.users + SET status = $1::auth.user_status, + suspended_at = ${suspendedAt}, + suspended_reason = $2, + updated_at = NOW() + WHERE id = $3 + RETURNING id, status::text, suspended_at, suspended_reason, updated_at`, + [status, reason || null, id] + ); + + if (result.rows.length === 0) { + res.status(404).json({ + success: false, + error: { message: 'User not found', code: 'NOT_FOUND' }, }); return; } - // Mock update - replace with actual DB update res.json({ success: true, - data: { - id, - status, - updated_at: new Date().toISOString(), - }, + data: result.rows[0], }); } catch (error) { next(error); @@ -296,29 +447,41 @@ router.patch('/users/:id/status', async (req: Request, res: Response, next: Next /** * PATCH /api/v1/admin/users/:id/role - * Update user role + * Update user role in real database */ router.patch('/users/:id/role', async (req: Request, res: Response, next: NextFunction) => { try { const { id } = req.params; const { role } = req.body; - if (!['user', 'premium', 'admin'].includes(role)) { + const validRoles = ['user', 'trader', 'analyst', 'admin', 'super_admin']; + if (!validRoles.includes(role)) { res.status(400).json({ success: false, - error: { message: 'Invalid role value', code: 'VALIDATION_ERROR' }, + error: { message: `Invalid role. Must be one of: ${validRoles.join(', ')}`, code: 'VALIDATION_ERROR' }, + }); + return; + } + + const result = await db.query( + `UPDATE auth.users + SET role = $1::auth.user_role, updated_at = NOW() + WHERE id = $2 + RETURNING id, role::text, updated_at`, + [role, id] + ); + + if (result.rows.length === 0) { + res.status(404).json({ + success: false, + error: { message: 'User not found', code: 'NOT_FOUND' }, }); return; } - // Mock update - replace with actual DB update res.json({ success: true, - data: { - id, - role, - updated_at: new Date().toISOString(), - }, + data: result.rows[0], }); } catch (error) { next(error); @@ -331,64 +494,100 @@ router.patch('/users/:id/role', async (req: Request, res: Response, next: NextFu /** * GET /api/v1/admin/audit/logs - * Get audit logs with filters + * Get audit logs with filters from real database */ router.get('/audit/logs', async (req: Request, res: Response, next: NextFunction) => { try { - const { page = 1, limit = 50, userId, action, startDate, endDate } = req.query; + const page = Math.max(1, parseInt(req.query.page as string, 10) || 1); + const limit = Math.min(100, Math.max(1, parseInt(req.query.limit as string, 10) || 50)); + const offset = (page - 1) * limit; + const { userId, action, startDate, endDate, severity } = req.query; - // Mock audit logs - const mockLogs = [ - { - id: '1', - user_id: '1', - action: 'LOGIN', - resource: 'auth', - details: { ip: '192.168.1.1' }, - ip_address: '192.168.1.1', - created_at: new Date().toISOString(), - }, - { - id: '2', - user_id: '1', - action: 'UPDATE_SETTINGS', - resource: 'users', - details: { theme: 'dark' }, - ip_address: '192.168.1.1', - created_at: new Date(Date.now() - 3600000).toISOString(), - }, - { - id: '3', - user_id: '1', - action: 'CREATE_SIGNAL', - resource: 'trading', - details: { symbol: 'XAUUSD', direction: 'long' }, - ip_address: '192.168.1.1', - created_at: new Date(Date.now() - 7200000).toISOString(), - }, - ]; + const conditions: string[] = []; + const params: (string | number)[] = []; + let paramIdx = 1; - let filteredLogs = mockLogs; - - if (userId) { - filteredLogs = filteredLogs.filter(l => l.user_id === userId); - } - if (action) { - filteredLogs = filteredLogs.filter(l => l.action === action); + if (userId && typeof userId === 'string') { + conditions.push(`al.user_id = $${paramIdx}`); + params.push(userId); + paramIdx++; } - const total = filteredLogs.length; - const start = (Number(page) - 1) * Number(limit); - const paginatedLogs = filteredLogs.slice(start, start + Number(limit)); + if (action && typeof action === 'string') { + conditions.push(`al.action = $${paramIdx}`); + params.push(action); + paramIdx++; + } + + if (severity && typeof severity === 'string') { + conditions.push(`al.severity = $${paramIdx}::audit.event_severity`); + params.push(severity); + paramIdx++; + } + + if (startDate && typeof startDate === 'string') { + conditions.push(`al.created_at >= $${paramIdx}`); + params.push(startDate); + paramIdx++; + } + + if (endDate && typeof endDate === 'string') { + conditions.push(`al.created_at <= $${paramIdx}`); + params.push(endDate); + paramIdx++; + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + // Count total + const countResult = await db.query<{ count: string }>( + `SELECT COUNT(*) as count FROM audit.audit_logs al ${whereClause}`, + params + ); + const total = parseInt(countResult.rows[0]?.count || '0', 10); + + // Fetch logs + const logsResult = await db.query( + `SELECT + al.id, al.event_type::text, al.event_status::text, al.severity::text, + al.user_id, al.session_id, al.ip_address, al.user_agent, + al.resource_type::text, al.resource_id, al.resource_name, + al.action, al.description, al.old_values, al.new_values, + al.metadata, al.created_at, + u.email as user_email + FROM audit.audit_logs al + LEFT JOIN auth.users u ON al.user_id = u.id + ${whereClause} + ORDER BY al.created_at DESC + LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`, + [...params, limit, offset] + ); + + const logs = logsResult.rows.map((row: any) => ({ + id: row.id, + event_type: row.event_type, + event_status: row.event_status, + severity: row.severity, + user_id: row.user_id, + user_email: row.user_email, + ip_address: row.ip_address?.toString() || null, + resource_type: row.resource_type, + resource_id: row.resource_id, + resource_name: row.resource_name, + action: row.action, + description: row.description, + details: { ...row.old_values, ...row.new_values, ...row.metadata }, + created_at: row.created_at, + })); res.json({ success: true, - data: paginatedLogs, + data: logs, meta: { total, - page: Number(page), - limit: Number(limit), - totalPages: Math.ceil(total / Number(limit)), + page, + limit, + totalPages: Math.ceil(total / limit), }, }); } catch (error) { @@ -397,29 +596,57 @@ router.get('/audit/logs', async (req: Request, res: Response, next: NextFunction }); // ============================================================================ -// Stats Endpoint (for admin dashboard widget) +// Stats Endpoint // ============================================================================ /** * GET /api/v1/admin/stats - * Get admin stats (alias for dashboard endpoint) + * Get admin stats from real database */ router.get('/stats', async (req: Request, res: Response, next: NextFunction) => { try { + const [ + modelsResult, + activeModelsResult, + predsTodayResult, + predsWeekResult, + accuracyResult, + signalsTodayResult, + statsPnlTodayResult, + statsPnlWeekResult, + statsPnlMonthResult, + ] = await Promise.all([ + db.query<{ count: string }>('SELECT COUNT(*) as count FROM ml.models'), + db.query<{ count: string }>("SELECT COUNT(*) as count FROM ml.models WHERE status = 'production'"), + db.query<{ count: string }>("SELECT COUNT(*) as count FROM ml.predictions WHERE DATE(created_at) = CURRENT_DATE"), + db.query<{ count: string }>("SELECT COUNT(*) as count FROM ml.predictions WHERE created_at >= NOW() - INTERVAL '7 days'"), + db.query<{ avg: string | null }>("SELECT AVG(overall_accuracy) as avg FROM ml.models WHERE status = 'production'"), + db.query<{ count: string }>("SELECT COUNT(*) as count FROM trading.signals WHERE DATE(created_at) = CURRENT_DATE"), + db.query<{ total: string | null }>( + "SELECT COALESCE(SUM(realized_pnl), 0) as total FROM trading.positions WHERE status = 'closed' AND DATE(closed_at) = CURRENT_DATE" + ), + db.query<{ total: string | null }>( + "SELECT COALESCE(SUM(realized_pnl), 0) as total FROM trading.positions WHERE status = 'closed' AND closed_at >= NOW() - INTERVAL '7 days'" + ), + db.query<{ total: string | null }>( + "SELECT COALESCE(SUM(realized_pnl), 0) as total FROM trading.positions WHERE status = 'closed' AND closed_at >= NOW() - INTERVAL '30 days'" + ), + ]); + res.json({ success: true, data: { - total_models: 6, - active_models: 5, - total_predictions_today: 1247, - total_predictions_week: 8729, - overall_accuracy: 0.68, + total_models: parseInt(modelsResult.rows[0]?.count || '0', 10), + active_models: parseInt(activeModelsResult.rows[0]?.count || '0', 10), + total_predictions_today: parseInt(predsTodayResult.rows[0]?.count || '0', 10), + total_predictions_week: parseInt(predsWeekResult.rows[0]?.count || '0', 10), + overall_accuracy: parseFloat(accuracyResult.rows[0]?.avg || '0'), total_agents: 3, - active_agents: 1, - total_signals_today: 24, - total_pnl_today: 1250.75, - total_pnl_week: 8456.32, - total_pnl_month: 32145.89, + active_agents: 0, + total_signals_today: parseInt(signalsTodayResult.rows[0]?.count || '0', 10), + total_pnl_today: parseFloat(statsPnlTodayResult.rows[0]?.total || '0'), + total_pnl_week: parseFloat(statsPnlWeekResult.rows[0]?.total || '0'), + total_pnl_month: parseFloat(statsPnlMonthResult.rows[0]?.total || '0'), system_health: 'healthy', }, }); diff --git a/src/modules/llm/types/llm.types.ts b/src/modules/llm/types/llm.types.ts new file mode 100644 index 0000000..c6f22ee --- /dev/null +++ b/src/modules/llm/types/llm.types.ts @@ -0,0 +1,168 @@ +/** + * LLM Types + * TypeScript interfaces matching llm.* DDL schema + */ + +// ============================================================================ +// Enums (from llm.00-enums.sql) +// ============================================================================ + +export type MessageRole = 'user' | 'assistant' | 'system' | 'tool'; +export type ConversationStatus = 'active' | 'archived' | 'deleted'; +export type ConversationType = 'general' | 'trading_advice' | 'education' | 'market_analysis' | 'support' | 'onboarding'; +export type CommunicationTone = 'casual' | 'professional' | 'technical'; +export type VerbosityLevel = 'brief' | 'normal' | 'detailed'; +export type AlertFrequency = 'low' | 'normal' | 'high'; +export type MemoryType = 'fact' | 'preference' | 'context' | 'goal' | 'constraint'; + +// ============================================================================ +// Conversation (from llm.conversations) +// ============================================================================ + +export interface Conversation { + id: string; + userId: string; + title?: string; + conversationType: ConversationType; + status: ConversationStatus; + summary?: string; + totalMessages: number; + totalTokensUsed: number; + tags: string[]; + relatedSymbols: string[]; + relatedTopics: string[]; + startedAt: Date; + lastMessageAt?: Date; + archivedAt?: Date; + createdAt: Date; + updatedAt: Date; +} + +// ============================================================================ +// Message (from llm.messages) +// ============================================================================ + +export interface Message { + id: string; + conversationId: string; + role: MessageRole; + content: string; + modelName?: string; + promptTokens?: number; + completionTokens?: number; + totalTokens?: number; + contextUsed?: Record; + toolCalls?: ToolCall[]; + toolResults?: Record; + responseTimeMs?: number; + temperature?: number; + userRating?: number; + userFeedback?: string; + referencesSymbols: string[]; + referencesConcepts: string[]; + metadata?: Record; + createdAt: Date; +} + +export interface ToolCall { + tool: string; + params: Record; + result?: unknown; +} + +// ============================================================================ +// User Preferences (from llm.user_preferences) +// ============================================================================ + +export interface UserPreferences { + id: string; + userId: string; + communicationTone: CommunicationTone; + verbosityLevel: VerbosityLevel; + alertFrequency: AlertFrequency; + preferredLanguage: string; + preferredSymbols: string[]; + preferredTimeframes: string[]; + tradingExperience: string; + riskTolerance: string; + createdAt: Date; + updatedAt: Date; +} + +// ============================================================================ +// User Memory (from llm.user_memory) +// ============================================================================ + +export interface UserMemory { + id: string; + userId: string; + memoryType: MemoryType; + key: string; + value: string; + confidence: number; + sourceConversationId?: string; + expiresAt?: Date; + createdAt: Date; + updatedAt: Date; +} + +// ============================================================================ +// Embedding (from llm.embeddings) +// ============================================================================ + +export interface Embedding { + id: string; + sourceType: string; + sourceId: string; + content: string; + embedding: number[]; + modelName: string; + dimensions: number; + metadata?: Record; + createdAt: Date; +} + +// ============================================================================ +// Row types (snake_case from DB) +// ============================================================================ + +export interface ConversationRow { + id: string; + user_id: string; + title: string | null; + conversation_type: string; + status: string; + summary: string | null; + total_messages: number; + total_tokens_used: number; + tags: string[]; + related_symbols: string[]; + related_topics: string[]; + started_at: Date; + last_message_at: Date | null; + archived_at: Date | null; + created_at: Date; + updated_at: Date; +} + +export interface MessageRow { + id: string; + conversation_id: string; + role: string; + content: string; + model_name: string | null; + prompt_tokens: number | null; + completion_tokens: number | null; + total_tokens: number | null; + context_used: Record | null; + tool_calls: Record | null; + tool_results: Record | null; + response_time_ms: number | null; + temperature: number | null; + user_rating: number | null; + user_feedback: string | null; + references_symbols: string[]; + references_concepts: string[]; + metadata: Record | null; + created_at: Date; +} diff --git a/src/modules/payments/types/financial.types.ts b/src/modules/payments/types/financial.types.ts new file mode 100644 index 0000000..09fa1f7 --- /dev/null +++ b/src/modules/payments/types/financial.types.ts @@ -0,0 +1,173 @@ +/** + * Financial Types + * TypeScript interfaces matching financial.* DDL schema + */ + +// ============================================================================ +// Enums (from financial.00-enums.sql) +// ============================================================================ + +export type WalletType = 'trading' | 'investment' | 'earnings' | 'referral'; +export type WalletStatus = 'active' | 'frozen' | 'closed'; +export type TransactionType = 'deposit' | 'withdrawal' | 'transfer_in' | 'transfer_out' | 'fee' | 'refund' | 'earning' | 'distribution' | 'bonus'; +export type TransactionStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled' | 'reversed'; +export type SubscriptionPlan = 'free' | 'basic' | 'pro' | 'premium' | 'enterprise'; +export type SubscriptionStatus = 'active' | 'past_due' | 'cancelled' | 'incomplete' | 'trialing' | 'unpaid' | 'paused'; +export type CurrencyCode = 'USD' | 'MXN' | 'EUR'; +export type PaymentMethodType = 'card' | 'bank_transfer' | 'wire' | 'crypto' | 'paypal' | 'stripe'; +export type PaymentStatus = 'pending' | 'processing' | 'succeeded' | 'failed' | 'cancelled' | 'refunded'; +export type InvoiceType = 'subscription' | 'one_time' | 'usage'; +export type InvoiceStatus = 'draft' | 'open' | 'paid' | 'void' | 'uncollectible'; +export type WalletAuditAction = 'created' | 'balance_updated' | 'status_changed' | 'limit_changed' | 'frozen' | 'unfrozen' | 'closed'; + +// ============================================================================ +// Wallet (from financial.wallets) +// ============================================================================ + +export interface Wallet { + id: string; + userId: string; + walletType: WalletType; + status: WalletStatus; + balance: number; + availableBalance: number; + pendingBalance: number; + currency: CurrencyCode; + stripeAccountId?: string; + stripeCustomerId?: string; + dailyWithdrawalLimit?: number; + monthlyWithdrawalLimit?: number; + minBalance: number; + lastTransactionAt?: Date; + totalDeposits: number; + totalWithdrawals: number; + metadata: Record; + createdAt: Date; + updatedAt: Date; + closedAt?: Date; +} + +// ============================================================================ +// Wallet Transaction (from financial.wallet_transactions) +// ============================================================================ + +export interface WalletTransaction { + id: string; + walletId: string; + transactionType: TransactionType; + status: TransactionStatus; + amount: number; + fee: number; + netAmount: number; + currency: CurrencyCode; + balanceBefore?: number; + balanceAfter?: number; + stripePaymentIntentId?: string; + stripeTransferId?: string; + stripeChargeId?: string; + referenceId?: string; + destinationWalletId?: string; + relatedTransactionId?: string; + description?: string; + notes?: string; + metadata: Record; + processedAt?: Date; + completedAt?: Date; + failedAt?: Date; + failedReason?: string; + idempotencyKey?: string; + createdAt: Date; + updatedAt: Date; +} + +// ============================================================================ +// Subscription (from financial.subscriptions) +// ============================================================================ + +export interface Subscription { + id: string; + userId: string; + plan: SubscriptionPlan; + status: SubscriptionStatus; + stripeSubscriptionId?: string; + stripeCustomerId?: string; + stripePriceId?: string; + stripeProductId?: string; + price: number; + currency: CurrencyCode; + billingInterval: 'month' | 'year'; + currentPeriodStart?: Date; + currentPeriodEnd?: Date; + trialStart?: Date; + trialEnd?: Date; + cancelledAt?: Date; + cancelAtPeriodEnd: boolean; + cancellationReason?: string; + cancellationFeedback?: Record; + previousPlan?: SubscriptionPlan; + scheduledPlan?: SubscriptionPlan; + scheduledPlanEffectiveAt?: Date; + lastPaymentAt?: Date; + nextPaymentAt?: Date; + failedPaymentCount: number; + metadata: Record; + createdAt: Date; + updatedAt: Date; + endedAt?: Date; +} + +// ============================================================================ +// Row types (snake_case from DB) +// ============================================================================ + +export interface WalletRow { + id: string; + user_id: string; + wallet_type: string; + status: string; + balance: string; + available_balance: string; + pending_balance: string; + currency: string; + stripe_account_id: string | null; + stripe_customer_id: string | null; + daily_withdrawal_limit: string | null; + monthly_withdrawal_limit: string | null; + min_balance: string; + last_transaction_at: Date | null; + total_deposits: string; + total_withdrawals: string; + metadata: Record; + created_at: Date; + updated_at: Date; + closed_at: Date | null; +} + +export interface WalletTransactionRow { + id: string; + wallet_id: string; + transaction_type: string; + status: string; + amount: string; + fee: string; + net_amount: string; + currency: string; + balance_before: string | null; + balance_after: string | null; + stripe_payment_intent_id: string | null; + stripe_transfer_id: string | null; + stripe_charge_id: string | null; + reference_id: string | null; + destination_wallet_id: string | null; + related_transaction_id: string | null; + description: string | null; + notes: string | null; + metadata: Record; + processed_at: Date | null; + completed_at: Date | null; + failed_at: Date | null; + failed_reason: string | null; + idempotency_key: string | null; + created_at: Date; + updated_at: Date; +} diff --git a/src/shared/redis/index.ts b/src/shared/redis/index.ts new file mode 100644 index 0000000..cbe793b --- /dev/null +++ b/src/shared/redis/index.ts @@ -0,0 +1,137 @@ +/** + * Trading Platform - Redis Connection + * Shared Redis client with health check support. + * Falls back to in-memory if ioredis is not installed. + */ + +import { config } from '../../config'; +import { logger } from '../utils/logger'; + +interface RedisClientLike { + get(key: string): Promise; + set(key: string, value: string, ...args: unknown[]): Promise; + setex(key: string, seconds: number, value: string): Promise; + del(...keys: string[]): Promise; + ping(): Promise; + quit(): Promise; + status?: string; +} + +class RedisManager { + private client: RedisClientLike | null = null; + private isConnected = false; + private usesFallback = false; + + async getClient(): Promise { + if (this.client) return this.client; + + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const Redis = require('ioredis'); + const redisClient = new Redis({ + host: config.redis.host, + port: config.redis.port, + password: config.redis.password || undefined, + db: config.redis.db, + lazyConnect: true, + maxRetriesPerRequest: 3, + retryStrategy(times: number) { + if (times > 3) return null; + return Math.min(times * 200, 2000); + }, + }); + + await redisClient.connect(); + this.client = redisClient as RedisClientLike; + this.isConnected = true; + this.usesFallback = false; + logger.info('Redis connected', { host: config.redis.host, port: config.redis.port, db: config.redis.db }); + return this.client; + } catch { + logger.warn('Redis not available, using in-memory fallback'); + this.client = new InMemoryFallback(); + this.isConnected = true; + this.usesFallback = true; + return this.client; + } + } + + async healthCheck(): Promise<{ status: string; latency: number; type: string }> { + const start = Date.now(); + try { + const client = await this.getClient(); + const pong = await client.ping(); + const latency = Date.now() - start; + return { + status: pong === 'PONG' ? 'healthy' : 'degraded', + latency, + type: this.usesFallback ? 'memory' : 'redis', + }; + } catch { + return { + status: 'unhealthy', + latency: Date.now() - start, + type: this.usesFallback ? 'memory' : 'redis', + }; + } + } + + getConnectionInfo(): { connected: boolean; type: string } { + return { + connected: this.isConnected, + type: this.usesFallback ? 'memory' : 'redis', + }; + } + + async close(): Promise { + if (this.client && !this.usesFallback) { + await this.client.quit(); + logger.info('Redis connection closed'); + } + this.client = null; + this.isConnected = false; + } +} + +class InMemoryFallback implements RedisClientLike { + private store = new Map(); + + async get(key: string): Promise { + const entry = this.store.get(key); + if (!entry) return null; + if (entry.expiresAt && Date.now() > entry.expiresAt) { + this.store.delete(key); + return null; + } + return entry.value; + } + + async set(key: string, value: string): Promise { + this.store.set(key, { value }); + return 'OK'; + } + + async setex(key: string, seconds: number, value: string): Promise { + this.store.set(key, { value, expiresAt: Date.now() + seconds * 1000 }); + return 'OK'; + } + + async del(...keys: string[]): Promise { + let count = 0; + for (const key of keys) { + if (this.store.delete(key)) count++; + } + return count; + } + + async ping(): Promise { + return 'PONG'; + } + + async quit(): Promise { + this.store.clear(); + return 'OK'; + } +} + +export const redis = new RedisManager(); diff --git a/src/shared/types/audit.types.ts b/src/shared/types/audit.types.ts new file mode 100644 index 0000000..dfb8895 --- /dev/null +++ b/src/shared/types/audit.types.ts @@ -0,0 +1,121 @@ +/** + * Audit Types + * TypeScript interfaces matching audit.* DDL schema + */ + +// ============================================================================ +// Enums (from audit.00-enums.sql) +// ============================================================================ + +export type AuditEventType = 'create' | 'read' | 'update' | 'delete' | 'login' | 'logout' | 'permission_change' | 'config_change' | 'export' | 'import'; +export type EventSeverity = 'debug' | 'info' | 'warning' | 'error' | 'critical'; +export type SecurityEventCategory = 'authentication' | 'authorization' | 'data_access' | 'configuration' | 'suspicious_activity' | 'compliance'; +export type EventStatus = 'success' | 'failure' | 'blocked' | 'pending_review'; +export type ResourceType = 'user' | 'account' | 'transaction' | 'order' | 'position' | 'bot' | 'subscription' | 'payment' | 'course' | 'model' | 'system_config'; + +// ============================================================================ +// Audit Log (from audit.audit_logs) +// ============================================================================ + +export interface AuditLog { + id: string; + eventType: AuditEventType; + eventStatus: EventStatus; + severity: EventSeverity; + userId?: string; + sessionId?: string; + ipAddress?: string; + userAgent?: string; + resourceType: ResourceType; + resourceId?: string; + resourceName?: string; + action: string; + description?: string; + oldValues?: Record; + newValues?: Record; + metadata: Record; + requestId?: string; + correlationId?: string; + serviceName?: string; + createdAt: Date; +} + +// ============================================================================ +// Security Event (from audit.security_events) +// ============================================================================ + +export interface SecurityEvent { + id: string; + userId?: string; + category: SecurityEventCategory; + severity: EventSeverity; + title: string; + description?: string; + ipAddress?: string; + userAgent?: string; + geoLocation?: Record; + metadata: Record; + resolved: boolean; + resolvedAt?: Date; + resolvedBy?: string; + createdAt: Date; +} + +// ============================================================================ +// Trading Audit (from audit.trading_audit) +// ============================================================================ + +export interface TradingAudit { + id: string; + userId: string; + action: string; + entityType: string; + entityId: string; + details: Record; + ipAddress?: string; + createdAt: Date; +} + +// ============================================================================ +// Row types (snake_case from DB) +// ============================================================================ + +export interface AuditLogRow { + id: string; + event_type: string; + event_status: string; + severity: string; + user_id: string | null; + session_id: string | null; + ip_address: string | null; + user_agent: string | null; + resource_type: string; + resource_id: string | null; + resource_name: string | null; + action: string; + description: string | null; + old_values: Record | null; + new_values: Record | null; + metadata: Record; + request_id: string | null; + correlation_id: string | null; + service_name: string | null; + created_at: Date; +} + +export interface SecurityEventRow { + id: string; + user_id: string | null; + category: string; + severity: string; + title: string; + description: string | null; + ip_address: string | null; + user_agent: string | null; + geo_location: Record | null; + metadata: Record; + resolved: boolean; + resolved_at: Date | null; + resolved_by: string | null; + created_at: Date; +} diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 6bd1c9a..9962baa 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -3,3 +3,5 @@ */ export * from './common.types'; +export * from './audit.types'; +export * from './market-data.types'; diff --git a/src/shared/types/market-data.types.ts b/src/shared/types/market-data.types.ts new file mode 100644 index 0000000..a749efa --- /dev/null +++ b/src/shared/types/market-data.types.ts @@ -0,0 +1,127 @@ +/** + * Market Data Types + * TypeScript interfaces matching market_data.* DDL schema + */ + +// ============================================================================ +// Enums (from market_data.00-enums.sql) +// ============================================================================ + +export type AssetType = 'forex' | 'crypto' | 'commodity' | 'index' | 'stock'; +export type Timeframe = '1m' | '5m' | '15m' | '30m' | '1h' | '4h' | '1d' | '1w'; + +// ============================================================================ +// Ticker (from market_data.tickers) +// ============================================================================ + +export interface Ticker { + id: string; + symbol: string; + name: string; + assetType: AssetType; + exchange?: string; + baseCurrency: string; + quoteCurrency: string; + pipSize: number; + lotSize: number; + minLotSize: number; + maxLotSize: number; + isActive: boolean; + metadata: Record; + createdAt: Date; + updatedAt: Date; +} + +// ============================================================================ +// OHLCV Data (from market_data.ohlcv_data) +// ============================================================================ + +export interface OhlcvData { + id: string; + tickerId: string; + timeframe: Timeframe; + openTime: Date; + closeTime: Date; + open: number; + high: number; + low: number; + close: number; + volume: number; + quoteVolume?: number; + numberOfTrades?: number; + metadata?: Record; + createdAt: Date; +} + +// ============================================================================ +// Market Snapshot (from market_data.market_snapshots) +// ============================================================================ + +export interface MarketSnapshot { + id: string; + tickerId: string; + bid: number; + ask: number; + last: number; + volume24h: number; + change24h: number; + changePercent24h: number; + high24h: number; + low24h: number; + metadata?: Record; + capturedAt: Date; +} + +// ============================================================================ +// Row types (snake_case from DB) +// ============================================================================ + +export interface TickerRow { + id: string; + symbol: string; + name: string; + asset_type: string; + exchange: string | null; + base_currency: string; + quote_currency: string; + pip_size: string; + lot_size: string; + min_lot_size: string; + max_lot_size: string; + is_active: boolean; + metadata: Record; + created_at: Date; + updated_at: Date; +} + +export interface OhlcvDataRow { + id: string; + ticker_id: string; + timeframe: string; + open_time: Date; + close_time: Date; + open: string; + high: string; + low: string; + close: string; + volume: string; + quote_volume: string | null; + number_of_trades: number | null; + metadata: Record | null; + created_at: Date; +} + +export interface MarketSnapshotRow { + id: string; + ticker_id: string; + bid: string; + ask: string; + last: string; + volume_24h: string; + change_24h: string; + change_percent_24h: string; + high_24h: string; + low_24h: string; + metadata: Record | null; + captured_at: Date; +}