commit 2521b63c6ddfd62888fc247ce9e8164a513dd091 Author: rckrdmrd Date: Fri Jan 16 08:33:17 2026 -0600 Migración desde trading-platform/apps/mcp-products - Estándar multi-repo v2 Co-Authored-By: Claude Opus 4.5 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7c4e005 --- /dev/null +++ b/.env.example @@ -0,0 +1,32 @@ +# ============================================================================= +# MCP PRODUCTS SERVER CONFIGURATION +# ============================================================================= + +# Server +PORT=3091 +NODE_ENV=development +LOG_LEVEL=info +API_KEY=your_api_key_here + +# Database (PostgreSQL) +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=trading_platform +DB_USER=trading_app +DB_PASSWORD=your_secure_password +DB_SSL=false +DB_POOL_MAX=20 + +# Products Config +PRODUCTS_PAGE_SIZE=20 +PRODUCTS_MAX_PAGE_SIZE=100 +PRODUCTS_FEATURED_LIMIT=6 + +# Wallet Service (for purchases) +WALLET_SERVICE_URL=http://localhost:3090 +WALLET_SERVICE_TIMEOUT=5000 + +# Stripe (for payment processing) +STRIPE_SECRET_KEY=sk_test_... +STRIPE_PUBLIC_KEY=pk_test_... +STRIPE_WEBHOOK_SECRET=whsec_... diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ff61a58 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +# Build stage +FROM node:20-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci --only=production=false +COPY tsconfig.json ./ +COPY src ./src +RUN npm run build +RUN npm prune --production + +# Production stage +FROM node:20-alpine AS production +WORKDIR /app +RUN addgroup -g 1001 -S nodejs && adduser -S products -u 1001 +COPY --from=builder --chown=products:nodejs /app/node_modules ./node_modules +COPY --from=builder --chown=products:nodejs /app/dist ./dist +COPY --from=builder --chown=products:nodejs /app/package.json ./ +RUN mkdir -p logs && chown products:nodejs logs +USER products +EXPOSE 3091 +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3091/health || exit 1 +CMD ["node", "dist/index.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..283dc2a --- /dev/null +++ b/README.md @@ -0,0 +1,81 @@ +# MCP Products Server + +Products Marketplace MCP Server for the Trading Platform. Manages product catalog, purchases, and delivery. + +## Features + +- **Product Catalog**: ML predictions, agent access, education, and more +- **Purchase System**: Wallet + Stripe payment support +- **Automatic Delivery**: Prediction credits, agent access grants +- **VIP Products**: Exclusive items for VIP subscribers +- **Search & Filtering**: Full-text search, category, price filters + +## Quick Start + +```bash +npm install +cp .env.example .env +npm run dev +``` + +## API Endpoints + +### Products +- `GET /api/v1/products` - List with filters +- `GET /api/v1/products/featured` - Featured products +- `GET /api/v1/products/vip` - VIP-only products +- `GET /api/v1/products/predictions` - ML prediction products +- `GET /api/v1/products/agents` - Agent access products +- `GET /api/v1/products/search?q=...` - Search +- `GET /api/v1/products/category/:category` - By category +- `GET /api/v1/products/:id` - Get by ID +- `GET /api/v1/products/:id/availability` - Check stock +- `GET /api/v1/products/:id/related` - Related products + +### Purchases +- `POST /api/v1/purchases/calculate` - Preview purchase +- `POST /api/v1/purchases` - Create purchase +- `GET /api/v1/purchases/:id` - Get purchase +- `GET /api/v1/users/:userId/purchases` - User history +- `GET /api/v1/users/:userId/owns/:productId` - Check ownership + +## MCP Tools (16 total) + +| Tool | Description | +|------|-------------| +| `product_get` | Get product by ID/SKU/slug | +| `product_list` | List with filters | +| `product_featured` | Featured products | +| `product_by_category` | By category | +| `product_predictions` | ML predictions | +| `product_agents` | Agent access | +| `product_vip` | VIP products | +| `product_search` | Search | +| `product_availability` | Check stock | +| `product_related` | Related products | +| `purchase_calculate` | Preview amounts | +| `purchase_create` | Create purchase | +| `purchase_get` | Get details | +| `purchase_history` | User history | +| `purchase_check_ownership` | Check if owns | +| `purchase_update_delivery` | Update status | + +## Product Categories + +- `PREDICTION` - ML model predictions +- `EDUCATION` - Courses, tutorials +- `CONSULTING` - Coaching sessions +- `AGENT_ACCESS` - Money Manager access +- `SIGNAL_PACK` - Trading signals +- `API_ACCESS` - API keys +- `PREMIUM_FEATURE` - VIP features + +## Purchase Types + +- `WALLET` - 100% wallet credits +- `STRIPE` - 100% card payment +- `COMBINED` - Wallet + card + +## License + +UNLICENSED - Private diff --git a/package.json b/package.json new file mode 100644 index 0000000..47fecb1 --- /dev/null +++ b/package.json @@ -0,0 +1,51 @@ +{ + "name": "@trading-platform/mcp-products", + "version": "1.0.0", + "description": "MCP Server for Products Marketplace - Predictions, subscriptions, and services", + "main": "dist/index.js", + "scripts": { + "dev": "ts-node-dev --respawn --transpile-only src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "lint": "eslint src --ext .ts", + "typecheck": "tsc --noEmit", + "test": "jest", + "test:watch": "jest --watch" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.0", + "express": "^4.18.2", + "pg": "^8.11.3", + "zod": "^3.22.4", + "winston": "^3.11.0", + "decimal.js": "^10.4.3", + "uuid": "^9.0.1", + "dotenv": "^16.3.1", + "helmet": "^7.1.0", + "cors": "^2.8.5", + "slugify": "^1.6.6", + "jsonwebtoken": "^9.0.2" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.10.0", + "@types/pg": "^8.10.9", + "@types/uuid": "^9.0.7", + "@types/cors": "^2.8.17", + "@types/jsonwebtoken": "^9.0.5", + "typescript": "^5.3.2", + "ts-node-dev": "^2.0.0", + "jest": "^29.7.0", + "@types/jest": "^29.5.11", + "ts-jest": "^29.1.1", + "eslint": "^8.55.0", + "@typescript-eslint/eslint-plugin": "^6.13.0", + "@typescript-eslint/parser": "^6.13.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "author": "Trading Platform Team", + "license": "UNLICENSED", + "private": true +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..f191794 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,86 @@ +import { Pool, PoolConfig } from 'pg'; +import dotenv from 'dotenv'; + +dotenv.config(); + +// Server configuration +export const serverConfig = { + port: parseInt(process.env.PORT || '3091', 10), + nodeEnv: process.env.NODE_ENV || 'development', + logLevel: process.env.LOG_LEVEL || 'info', + apiKey: process.env.API_KEY || '', +}; + +// Database configuration +export const dbConfig: PoolConfig = { + host: process.env.DB_HOST || 'localhost', + port: parseInt(process.env.DB_PORT || '5432', 10), + database: process.env.DB_NAME || 'trading_platform', + user: process.env.DB_USER || 'trading_app', + password: process.env.DB_PASSWORD || '', + ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false, + max: parseInt(process.env.DB_POOL_MAX || '20', 10), + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 2000, +}; + +// Products configuration +export const productsConfig = { + defaultPageSize: parseInt(process.env.PRODUCTS_PAGE_SIZE || '20', 10), + maxPageSize: parseInt(process.env.PRODUCTS_MAX_PAGE_SIZE || '100', 10), + featuredLimit: parseInt(process.env.PRODUCTS_FEATURED_LIMIT || '6', 10), +}; + +// Wallet service configuration (for purchases) +export const walletServiceConfig = { + baseUrl: process.env.WALLET_SERVICE_URL || 'http://localhost:3090', + timeout: parseInt(process.env.WALLET_SERVICE_TIMEOUT || '5000', 10), +}; + +// Stripe configuration +export const stripeConfig = { + secretKey: process.env.STRIPE_SECRET_KEY || '', + publicKey: process.env.STRIPE_PUBLIC_KEY || '', + webhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '', +}; + +// Database pool singleton +let pool: Pool | null = null; + +export function getPool(): Pool { + if (!pool) { + pool = new Pool(dbConfig); + + pool.on('error', (err) => { + console.error('Unexpected error on idle client', err); + }); + + pool.on('connect', () => { + if (serverConfig.nodeEnv === 'development') { + console.log('New database connection established'); + } + }); + } + return pool; +} + +export async function closePool(): Promise { + if (pool) { + await pool.end(); + pool = null; + } +} + +// Helper to set tenant context for RLS +export async function setTenantContext( + client: ReturnType extends Promise ? T : never, + tenantId: string +): Promise { + await client.query(`SET app.current_tenant_id = $1`, [tenantId]); +} + +// Validation helpers +export function isValidUUID(str: string): boolean { + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + return uuidRegex.test(str); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..e228137 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,384 @@ +/** + * MCP Products Server + * + * Products marketplace for trading platform. + * Handles product catalog, purchases, and delivery. + */ + +import express, { Request, Response, NextFunction } from 'express'; +import helmet from 'helmet'; +import cors from 'cors'; +import { ZodError } from 'zod'; + +import { serverConfig, closePool, getPool } from './config'; +import { logger } from './utils/logger'; +import { isProductError } from './utils/errors'; +import { allToolSchemas, toolHandlers, toolNames } from './tools'; +import { authMiddleware, optionalAuthMiddleware } from './middleware'; + +const app = express(); + +// Middleware +app.use(helmet()); +app.use(cors()); +app.use(express.json({ limit: '1mb' })); + +app.use((req: Request, _res: Response, next: NextFunction) => { + logger.debug('Incoming request', { method: req.method, path: req.path }); + next(); +}); + +// ============================================================================ +// HEALTH & INFO +// ============================================================================ + +app.get('/health', async (_req: Request, res: Response) => { + try { + const pool = getPool(); + await pool.query('SELECT 1'); + res.json({ status: 'healthy', service: 'mcp-products', timestamp: new Date().toISOString(), database: 'connected' }); + } catch (error) { + res.status(503).json({ status: 'unhealthy', service: 'mcp-products', database: 'disconnected' }); + } +}); + +app.get('/info', (_req: Request, res: Response) => { + res.json({ + name: 'mcp-products', + version: '1.0.0', + description: 'Products Marketplace MCP Server', + tools: toolNames, + toolCount: toolNames.length, + }); +}); + +// ============================================================================ +// MCP PROTOCOL ENDPOINTS +// ============================================================================ + +app.get('/mcp/tools', (_req: Request, res: Response) => { + res.json({ tools: Object.values(allToolSchemas) }); +}); + +app.post('/mcp/tools/:toolName', async (req: Request, res: Response, next: NextFunction) => { + const { toolName } = req.params; + const handler = toolHandlers[toolName]; + + if (!handler) { + res.status(404).json({ error: `Tool not found: ${toolName}`, availableTools: toolNames }); + return; + } + + try { + const result = await handler(req.body); + res.json(result); + } catch (error) { + next(error); + } +}); + +app.post('/mcp/call', async (req: Request, res: Response, next: NextFunction) => { + const { name, arguments: args } = req.body; + + if (!name) { + res.status(400).json({ error: 'Tool name is required' }); + return; + } + + const handler = toolHandlers[name]; + + if (!handler) { + res.status(404).json({ error: `Tool not found: ${name}` }); + return; + } + + try { + const result = await handler(args || {}); + res.json(result); + } catch (error) { + next(error); + } +}); + +// ============================================================================ +// REST API ENDPOINTS +// ============================================================================ + +// Products +app.get('/api/v1/products', async (req: Request, res: Response, next: NextFunction) => { + try { + const result = await toolHandlers.product_list({ + type: req.query.type, + category: req.query.category, + isVip: req.query.isVip === 'true' ? true : req.query.isVip === 'false' ? false : undefined, + isFeatured: req.query.isFeatured === 'true' ? true : undefined, + search: req.query.search, + minPrice: req.query.minPrice ? parseFloat(req.query.minPrice as string) : undefined, + maxPrice: req.query.maxPrice ? parseFloat(req.query.maxPrice as string) : undefined, + limit: req.query.limit ? parseInt(req.query.limit as string, 10) : undefined, + offset: req.query.offset ? parseInt(req.query.offset as string, 10) : undefined, + sortBy: req.query.sortBy, + sortOrder: req.query.sortOrder, + }); + res.json(result); + } catch (error) { + next(error); + } +}); + +app.get('/api/v1/products/featured', async (req: Request, res: Response, next: NextFunction) => { + try { + const result = await toolHandlers.product_featured({ + limit: req.query.limit ? parseInt(req.query.limit as string, 10) : undefined, + }); + res.json(result); + } catch (error) { + next(error); + } +}); + +app.get('/api/v1/products/vip', async (_req: Request, res: Response, next: NextFunction) => { + try { + const result = await toolHandlers.product_vip({}); + res.json(result); + } catch (error) { + next(error); + } +}); + +app.get('/api/v1/products/predictions', async (req: Request, res: Response, next: NextFunction) => { + try { + const result = await toolHandlers.product_predictions({ modelId: req.query.modelId }); + res.json(result); + } catch (error) { + next(error); + } +}); + +app.get('/api/v1/products/agents', async (req: Request, res: Response, next: NextFunction) => { + try { + const result = await toolHandlers.product_agents({ agentType: req.query.agentType }); + res.json(result); + } catch (error) { + next(error); + } +}); + +app.get('/api/v1/products/search', async (req: Request, res: Response, next: NextFunction) => { + try { + const result = await toolHandlers.product_search({ + query: req.query.q as string, + limit: req.query.limit ? parseInt(req.query.limit as string, 10) : undefined, + }); + res.json(result); + } catch (error) { + next(error); + } +}); + +app.get('/api/v1/products/category/:category', async (req: Request, res: Response, next: NextFunction) => { + try { + const result = await toolHandlers.product_by_category({ + category: req.params.category, + limit: req.query.limit ? parseInt(req.query.limit as string, 10) : undefined, + }); + res.json(result); + } catch (error) { + next(error); + } +}); + +app.get('/api/v1/products/:productId', async (req: Request, res: Response, next: NextFunction) => { + try { + const result = await toolHandlers.product_get({ productId: req.params.productId }); + res.json(result); + } catch (error) { + next(error); + } +}); + +app.get('/api/v1/products/:productId/availability', async (req: Request, res: Response, next: NextFunction) => { + try { + const result = await toolHandlers.product_availability({ productId: req.params.productId }); + res.json(result); + } catch (error) { + next(error); + } +}); + +app.get('/api/v1/products/:productId/related', async (req: Request, res: Response, next: NextFunction) => { + try { + const result = await toolHandlers.product_related({ + productId: req.params.productId, + limit: req.query.limit ? parseInt(req.query.limit as string, 10) : undefined, + }); + res.json(result); + } catch (error) { + next(error); + } +}); + +// Purchases - Protected routes +const purchasesRouter = express.Router(); + +// Calculate price (optional auth - for logged in users with VIP discounts) +purchasesRouter.post('/calculate', optionalAuthMiddleware, async (req: Request, res: Response, next: NextFunction) => { + try { + const result = await toolHandlers.purchase_calculate({ + ...req.body, + userId: req.userId, // Pass user ID for VIP pricing + }); + res.json(result); + } catch (error) { + next(error); + } +}); + +// Create purchase (requires auth) +purchasesRouter.post('/', authMiddleware, async (req: Request, res: Response, next: NextFunction) => { + try { + const result = await toolHandlers.purchase_create({ + tenantId: req.tenantId, + userId: req.userId, + ...req.body, + }); + res.status(201).json(result); + } catch (error) { + next(error); + } +}); + +// Get purchase (requires auth) +purchasesRouter.get('/:purchaseId', authMiddleware, async (req: Request, res: Response, next: NextFunction) => { + try { + const result = await toolHandlers.purchase_get({ + tenantId: req.tenantId, + purchaseId: req.params.purchaseId, + }); + res.json(result); + } catch (error) { + next(error); + } +}); + +app.use('/api/v1/purchases', purchasesRouter); + +// User-specific endpoints (requires auth) +const userRouter = express.Router(); +userRouter.use(authMiddleware); + +// Get user's purchases +userRouter.get('/:userId/purchases', async (req: Request, res: Response, next: NextFunction) => { + try { + // Users can only see their own purchases unless they're admins + if (req.params.userId !== req.userId && !req.isOwner) { + res.status(403).json({ error: 'Forbidden', code: 'ACCESS_DENIED' }); + return; + } + const result = await toolHandlers.purchase_history({ + tenantId: req.tenantId, + userId: req.params.userId, + limit: req.query.limit ? parseInt(req.query.limit as string, 10) : undefined, + offset: req.query.offset ? parseInt(req.query.offset as string, 10) : undefined, + }); + res.json(result); + } catch (error) { + next(error); + } +}); + +// Check product ownership +userRouter.get('/:userId/owns/:productId', async (req: Request, res: Response, next: NextFunction) => { + try { + const result = await toolHandlers.purchase_check_ownership({ + tenantId: req.tenantId, + userId: req.params.userId, + productId: req.params.productId, + }); + res.json(result); + } catch (error) { + next(error); + } +}); + +// Get current user's purchases (convenience endpoint) +userRouter.get('/me/purchases', async (req: Request, res: Response, next: NextFunction) => { + try { + const result = await toolHandlers.purchase_history({ + tenantId: req.tenantId, + userId: req.userId, + limit: req.query.limit ? parseInt(req.query.limit as string, 10) : undefined, + offset: req.query.offset ? parseInt(req.query.offset as string, 10) : undefined, + }); + res.json(result); + } catch (error) { + next(error); + } +}); + +app.use('/api/v1/users', userRouter); + +// ============================================================================ +// ERROR HANDLING +// ============================================================================ + +app.use((_req: Request, res: Response) => { + res.status(404).json({ error: 'Not found' }); +}); + +app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => { + if (err instanceof ZodError) { + res.status(400).json({ + error: 'Validation error', + details: err.errors.map((e) => ({ path: e.path.join('.'), message: e.message })), + }); + return; + } + + if (isProductError(err)) { + res.status(err.statusCode).json({ error: err.message, code: err.code, details: err.details }); + return; + } + + logger.error('Unhandled error', { error: err.message, stack: err.stack }); + res.status(500).json({ + error: 'Internal server error', + message: serverConfig.nodeEnv === 'development' ? err.message : undefined, + }); +}); + +// ============================================================================ +// SERVER STARTUP +// ============================================================================ + +const server = app.listen(serverConfig.port, () => { + logger.info(`MCP Products Server started`, { port: serverConfig.port, env: serverConfig.nodeEnv }); + + console.log(` +╔════════════════════════════════════════════════════════════╗ +║ MCP PRODUCTS SERVER ║ +╠════════════════════════════════════════════════════════════╣ +║ Port: ${serverConfig.port} ║ +║ Env: ${serverConfig.nodeEnv.padEnd(12)} ║ +║ Tools: ${String(toolNames.length).padEnd(12)} ║ +╠════════════════════════════════════════════════════════════╣ +║ Endpoints: ║ +║ /health, /info, /mcp/tools, /mcp/call ║ +║ /api/v1/products/*, /api/v1/purchases/* ║ +╚════════════════════════════════════════════════════════════╝ + `); +}); + +async function shutdown(signal: string) { + logger.info(`Received ${signal}, shutting down...`); + server.close(async () => { + await closePool(); + process.exit(0); + }); + setTimeout(() => process.exit(1), 30000); +} + +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT')); + +export { app }; diff --git a/src/middleware/auth.middleware.ts b/src/middleware/auth.middleware.ts new file mode 100644 index 0000000..769b880 --- /dev/null +++ b/src/middleware/auth.middleware.ts @@ -0,0 +1,127 @@ +/** + * Auth Middleware for MCP Products + * Verifies JWT tokens and sets user context + */ + +import { Request, Response, NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; +import { logger } from '../utils/logger'; + +// JWT configuration (should match mcp-auth config) +const JWT_SECRET = process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production-min-256-bits'; + +// JWT Payload interface +export interface JWTPayload { + sub: string; // user_id + email: string; + tenantId: string; + isOwner: boolean; + iat: number; + exp: number; +} + +// Extend Express Request to include auth info +declare global { + namespace Express { + interface Request { + userId?: string; + tenantId?: string; + userEmail?: string; + isOwner?: boolean; + isAuthenticated?: boolean; + } + } +} + +/** + * Auth middleware that verifies JWT tokens + * If valid, sets userId, tenantId, and userEmail on request + */ +export function authMiddleware(req: Request, res: Response, next: NextFunction): void { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + res.status(401).json({ + error: 'Unauthorized', + code: 'MISSING_TOKEN', + message: 'No authentication token provided', + }); + return; + } + + const token = authHeader.substring(7); + + try { + const decoded = jwt.verify(token, JWT_SECRET) as JWTPayload; + + // Set auth info on request + req.userId = decoded.sub; + req.tenantId = decoded.tenantId; + req.userEmail = decoded.email; + req.isOwner = decoded.isOwner; + req.isAuthenticated = true; + + // Also set tenant ID header for RLS queries + req.headers['x-tenant-id'] = decoded.tenantId; + req.headers['x-user-id'] = decoded.sub; + + next(); + } catch (error) { + if (error instanceof jwt.TokenExpiredError) { + res.status(401).json({ + error: 'Unauthorized', + code: 'TOKEN_EXPIRED', + message: 'Token has expired', + }); + return; + } + + if (error instanceof jwt.JsonWebTokenError) { + res.status(401).json({ + error: 'Unauthorized', + code: 'INVALID_TOKEN', + message: 'Invalid token', + }); + return; + } + + logger.error('Auth middleware error', { error }); + res.status(500).json({ + error: 'Internal server error', + code: 'AUTH_ERROR', + }); + } +} + +/** + * Optional auth middleware + * Sets auth info if token is valid, but allows unauthenticated requests + */ +export function optionalAuthMiddleware(req: Request, _res: Response, next: NextFunction): void { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + req.isAuthenticated = false; + next(); + return; + } + + const token = authHeader.substring(7); + + try { + const decoded = jwt.verify(token, JWT_SECRET) as JWTPayload; + + req.userId = decoded.sub; + req.tenantId = decoded.tenantId; + req.userEmail = decoded.email; + req.isOwner = decoded.isOwner; + req.isAuthenticated = true; + + req.headers['x-tenant-id'] = decoded.tenantId; + req.headers['x-user-id'] = decoded.sub; + } catch { + req.isAuthenticated = false; + } + + next(); +} diff --git a/src/middleware/index.ts b/src/middleware/index.ts new file mode 100644 index 0000000..15b87ac --- /dev/null +++ b/src/middleware/index.ts @@ -0,0 +1,5 @@ +/** + * Middleware exports + */ + +export { authMiddleware, optionalAuthMiddleware } from './auth.middleware'; diff --git a/src/services/product.service.ts b/src/services/product.service.ts new file mode 100644 index 0000000..6b2f8d7 --- /dev/null +++ b/src/services/product.service.ts @@ -0,0 +1,400 @@ +import { Pool } from 'pg'; +import Decimal from 'decimal.js'; +import { getPool, productsConfig } from '../config'; +import { logger, logProductOperation } from '../utils/logger'; +import { + ProductNotFoundError, + ProductNotAvailableError, + mapPostgresError, +} from '../utils/errors'; +import { + Product, + ProductType, + ProductCategory, + ProductListQuery, + PaginatedResult, + mapRowToProduct, +} from '../types/product.types'; + +/** + * Product Service - Catalog management + */ +export class ProductService { + private pool: Pool; + + constructor() { + this.pool = getPool(); + } + + /** + * Get product by ID + */ + async getProductById(productId: string): Promise { + const result = await this.pool.query( + 'SELECT * FROM products.products WHERE id = $1 AND is_active = TRUE', + [productId] + ); + + if (result.rows.length === 0) { + throw new ProductNotFoundError(productId); + } + + return mapRowToProduct(result.rows[0]); + } + + /** + * Get product by SKU + */ + async getProductBySku(sku: string): Promise { + const result = await this.pool.query( + 'SELECT * FROM products.products WHERE sku = $1 AND is_active = TRUE', + [sku] + ); + + if (result.rows.length === 0) { + throw new ProductNotFoundError(sku, 'sku'); + } + + return mapRowToProduct(result.rows[0]); + } + + /** + * Get product by slug + */ + async getProductBySlug(slug: string): Promise { + const result = await this.pool.query( + 'SELECT * FROM products.products WHERE slug = $1 AND is_active = TRUE', + [slug] + ); + + if (result.rows.length === 0) { + throw new ProductNotFoundError(slug, 'slug'); + } + + return mapRowToProduct(result.rows[0]); + } + + /** + * List products with filters and pagination + */ + async listProducts(query: ProductListQuery): Promise> { + const conditions: string[] = ['is_active = TRUE']; + const values: unknown[] = []; + let paramIndex = 1; + + if (query.type) { + conditions.push(`type = $${paramIndex}`); + values.push(query.type); + paramIndex++; + } + + if (query.category) { + conditions.push(`category = $${paramIndex}`); + values.push(query.category); + paramIndex++; + } + + if (query.isVip !== undefined) { + conditions.push(`is_vip = $${paramIndex}`); + values.push(query.isVip); + paramIndex++; + } + + if (query.isFeatured !== undefined) { + conditions.push(`is_featured = $${paramIndex}`); + values.push(query.isFeatured); + paramIndex++; + } + + if (query.search) { + conditions.push(`(name ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`); + values.push(`%${query.search}%`); + paramIndex++; + } + + if (query.minPrice !== undefined) { + conditions.push(`price >= $${paramIndex}`); + values.push(query.minPrice); + paramIndex++; + } + + if (query.maxPrice !== undefined) { + conditions.push(`price <= $${paramIndex}`); + values.push(query.maxPrice); + paramIndex++; + } + + // Check availability window + conditions.push(`(available_from IS NULL OR available_from <= NOW())`); + conditions.push(`(available_until IS NULL OR available_until >= NOW())`); + + const whereClause = conditions.join(' AND '); + const limit = Math.min(query.limit || productsConfig.defaultPageSize, productsConfig.maxPageSize); + const offset = query.offset || 0; + + // Sorting + const sortColumn = query.sortBy || 'sort_order'; + const sortDirection = query.sortOrder || 'asc'; + const orderClause = `ORDER BY ${sortColumn} ${sortDirection}, created_at DESC`; + + // Get total count + const countResult = await this.pool.query( + `SELECT COUNT(*) as total FROM products.products WHERE ${whereClause}`, + values + ); + const total = parseInt(countResult.rows[0].total, 10); + + // Get paginated data + const dataResult = await this.pool.query( + `SELECT * FROM products.products + WHERE ${whereClause} + ${orderClause} + LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + [...values, limit, offset] + ); + + const products = dataResult.rows.map(mapRowToProduct); + + return { + data: products, + total, + limit, + offset, + hasMore: offset + products.length < total, + }; + } + + /** + * Get featured products + */ + async getFeaturedProducts(limit?: number): Promise { + const result = await this.pool.query( + `SELECT * FROM products.products + WHERE is_active = TRUE AND is_featured = TRUE + AND (available_from IS NULL OR available_from <= NOW()) + AND (available_until IS NULL OR available_until >= NOW()) + ORDER BY sort_order ASC + LIMIT $1`, + [limit || productsConfig.featuredLimit] + ); + + return result.rows.map(mapRowToProduct); + } + + /** + * Get products by category + */ + async getProductsByCategory(category: ProductCategory, limit?: number): Promise { + const result = await this.pool.query( + `SELECT * FROM products.products + WHERE is_active = TRUE AND category = $1 + AND (available_from IS NULL OR available_from <= NOW()) + AND (available_until IS NULL OR available_until >= NOW()) + ORDER BY sort_order ASC, is_featured DESC + LIMIT $2`, + [category, limit || productsConfig.defaultPageSize] + ); + + return result.rows.map(mapRowToProduct); + } + + /** + * Get VIP products + */ + async getVipProducts(): Promise { + const result = await this.pool.query( + `SELECT * FROM products.products + WHERE is_active = TRUE AND is_vip = TRUE + AND (available_from IS NULL OR available_from <= NOW()) + AND (available_until IS NULL OR available_until >= NOW()) + ORDER BY sort_order ASC` + ); + + return result.rows.map(mapRowToProduct); + } + + /** + * Get products by type + */ + async getProductsByType(type: ProductType): Promise { + const result = await this.pool.query( + `SELECT * FROM products.products + WHERE is_active = TRUE AND type = $1 + AND (available_from IS NULL OR available_from <= NOW()) + AND (available_until IS NULL OR available_until >= NOW()) + ORDER BY sort_order ASC`, + [type] + ); + + return result.rows.map(mapRowToProduct); + } + + /** + * Get prediction products (for ML marketplace) + */ + async getPredictionProducts(modelId?: string): Promise { + let query = ` + SELECT * FROM products.products + WHERE is_active = TRUE AND category = 'PREDICTION' + AND (available_from IS NULL OR available_from <= NOW()) + AND (available_until IS NULL OR available_until >= NOW()) + `; + + const values: unknown[] = []; + + if (modelId) { + query += ` AND ml_model_id = $1`; + values.push(modelId); + } + + query += ` ORDER BY sort_order ASC, is_featured DESC`; + + const result = await this.pool.query(query, values); + return result.rows.map(mapRowToProduct); + } + + /** + * Get agent access products + */ + async getAgentAccessProducts(agentType?: string): Promise { + let query = ` + SELECT * FROM products.products + WHERE is_active = TRUE AND category = 'AGENT_ACCESS' + AND (available_from IS NULL OR available_from <= NOW()) + AND (available_until IS NULL OR available_until >= NOW()) + `; + + const values: unknown[] = []; + + if (agentType) { + query += ` AND agent_type = $1`; + values.push(agentType); + } + + query += ` ORDER BY sort_order ASC`; + + const result = await this.pool.query(query, values); + return result.rows.map(mapRowToProduct); + } + + /** + * Search products + */ + async searchProducts(searchTerm: string, limit?: number): Promise { + const result = await this.pool.query( + `SELECT * FROM products.products + WHERE is_active = TRUE + AND ( + name ILIKE $1 + OR description ILIKE $1 + OR short_description ILIKE $1 + OR sku ILIKE $1 + ) + AND (available_from IS NULL OR available_from <= NOW()) + AND (available_until IS NULL OR available_until >= NOW()) + ORDER BY + CASE WHEN name ILIKE $1 THEN 0 ELSE 1 END, + sort_order ASC + LIMIT $2`, + [`%${searchTerm}%`, limit || productsConfig.defaultPageSize] + ); + + return result.rows.map(mapRowToProduct); + } + + /** + * Check product availability + */ + async checkAvailability(productId: string): Promise<{ + available: boolean; + reason?: string; + stock?: number; + }> { + const result = await this.pool.query( + `SELECT is_active, stock, available_from, available_until + FROM products.products WHERE id = $1`, + [productId] + ); + + if (result.rows.length === 0) { + return { available: false, reason: 'Product not found' }; + } + + const row = result.rows[0]; + + if (!row.is_active) { + return { available: false, reason: 'Product is not active' }; + } + + if (row.available_from && new Date(row.available_from) > new Date()) { + return { available: false, reason: 'Product not yet available' }; + } + + if (row.available_until && new Date(row.available_until) < new Date()) { + return { available: false, reason: 'Product no longer available' }; + } + + if (row.stock !== null && row.stock <= 0) { + return { available: false, reason: 'Out of stock', stock: 0 }; + } + + return { available: true, stock: row.stock }; + } + + /** + * Decrement stock (for limited products) + */ + async decrementStock(productId: string, quantity: number = 1): Promise { + const result = await this.pool.query( + `UPDATE products.products + SET stock = stock - $1 + WHERE id = $2 AND stock IS NOT NULL AND stock >= $1 + RETURNING stock`, + [quantity, productId] + ); + + if (result.rows.length === 0) { + throw new ProductNotAvailableError(productId, 'Insufficient stock'); + } + + return result.rows[0].stock; + } + + /** + * Get related products + */ + async getRelatedProducts(productId: string, limit: number = 4): Promise { + // Get current product + const product = await this.getProductById(productId); + + // Find related by category and type + const result = await this.pool.query( + `SELECT * FROM products.products + WHERE is_active = TRUE + AND id != $1 + AND (category = $2 OR type = $3) + AND (available_from IS NULL OR available_from <= NOW()) + AND (available_until IS NULL OR available_until >= NOW()) + ORDER BY + CASE WHEN category = $2 AND type = $3 THEN 0 + WHEN category = $2 THEN 1 + WHEN type = $3 THEN 2 + ELSE 3 END, + sort_order ASC + LIMIT $4`, + [productId, product.category, product.type, limit] + ); + + return result.rows.map(mapRowToProduct); + } +} + +// Singleton instance +let productServiceInstance: ProductService | null = null; + +export function getProductService(): ProductService { + if (!productServiceInstance) { + productServiceInstance = new ProductService(); + } + return productServiceInstance; +} diff --git a/src/services/purchase.service.ts b/src/services/purchase.service.ts new file mode 100644 index 0000000..9c88e85 --- /dev/null +++ b/src/services/purchase.service.ts @@ -0,0 +1,552 @@ +import { Pool, PoolClient } from 'pg'; +import Decimal from 'decimal.js'; +import { v4 as uuidv4 } from 'uuid'; +import { getPool, setTenantContext, walletServiceConfig } from '../config'; +import { logger, logPurchase, logError } from '../utils/logger'; +import { + ProductNotFoundError, + ProductNotAvailableError, + PurchaseNotFoundError, + InvalidPurchaseError, + InsufficientStockError, + PaymentRequiredError, + mapPostgresError, +} from '../utils/errors'; +import { getProductService } from './product.service'; +import { + Product, + Purchase, + PurchaseWithProduct, + CreatePurchaseInput, + PurchaseCalculation, + PurchaseType, + DeliveryStatus, + PaginatedResult, + mapRowToPurchase, + mapRowToProduct, +} from '../types/product.types'; + +/** + * Purchase Service - Handles product purchases + */ +export class PurchaseService { + private pool: Pool; + private productService = getProductService(); + + constructor() { + this.pool = getPool(); + } + + /** + * Calculate purchase amounts + */ + async calculatePurchase( + productId: string, + walletBalance?: Decimal, + discountCode?: string + ): Promise { + const product = await this.productService.getProductById(productId); + + let productPrice = product.price; + let discountAmount = new Decimal(0); + + // Apply discount if code provided + if (discountCode) { + const discount = await this.validateDiscountCode(discountCode, productId); + if (discount) { + if (discount.type === 'percentage') { + discountAmount = productPrice.mul(discount.value).div(100); + } else { + discountAmount = new Decimal(discount.value); + } + } + } + + const finalPrice = productPrice.minus(discountAmount); + + // Calculate wallet vs stripe split + let walletAmount = new Decimal(0); + let stripeAmount = finalPrice; + let purchaseType: PurchaseType = 'STRIPE'; + + if (walletBalance && walletBalance.greaterThan(0)) { + if (walletBalance.greaterThanOrEqualTo(finalPrice)) { + // Full wallet payment + walletAmount = finalPrice; + stripeAmount = new Decimal(0); + purchaseType = 'WALLET'; + } else { + // Combined payment + walletAmount = walletBalance; + stripeAmount = finalPrice.minus(walletBalance); + purchaseType = 'COMBINED'; + } + } + + return { + productPrice, + discountAmount, + finalPrice, + walletBalance, + walletAmount, + stripeAmount, + purchaseType, + }; + } + + /** + * Create a purchase + */ + async createPurchase(input: CreatePurchaseInput): Promise { + const client = await this.pool.connect(); + + try { + await client.query('BEGIN'); + await setTenantContext(client, input.tenantId); + + // Get product and check availability + const product = await this.productService.getProductById(input.productId); + const availability = await this.productService.checkAvailability(input.productId); + + if (!availability.available) { + throw new ProductNotAvailableError(input.productId, availability.reason || 'Not available'); + } + + // Calculate purchase amounts + let walletBalance: Decimal | undefined; + if (input.useWallet && input.walletId) { + walletBalance = await this.getWalletBalance(input.walletId, input.tenantId); + } + + const calculation = await this.calculatePurchase( + input.productId, + walletBalance, + input.discountCode + ); + + // Validate payment method + if (calculation.stripeAmount.greaterThan(0) && !input.stripePaymentIntentId) { + throw new PaymentRequiredError( + calculation.stripeAmount.toNumber(), + product.currency + ); + } + + // Debit wallet if needed + let walletTransactionId: string | null = null; + if (calculation.walletAmount.greaterThan(0) && input.walletId) { + walletTransactionId = await this.debitWallet( + input.walletId, + calculation.walletAmount.toNumber(), + input.tenantId, + input.productId, + product.name + ); + } + + // Decrement stock if applicable + if (product.stock !== null) { + await this.productService.decrementStock(input.productId); + } + + // Create purchase record + const result = await client.query( + `INSERT INTO products.purchases ( + tenant_id, user_id, product_id, purchase_type, + product_price, discount_amount, final_price, + wallet_amount, stripe_amount, + wallet_transaction_id, stripe_payment_intent_id, + discount_code, metadata + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + RETURNING *`, + [ + input.tenantId, + input.userId, + input.productId, + calculation.purchaseType, + calculation.productPrice.toString(), + calculation.discountAmount.toString(), + calculation.finalPrice.toString(), + calculation.walletAmount.toString(), + calculation.stripeAmount.toString(), + walletTransactionId, + input.stripePaymentIntentId || null, + input.discountCode || null, + JSON.stringify(input.metadata || {}), + ] + ); + + const purchase = mapRowToPurchase(result.rows[0]); + + await client.query('COMMIT'); + + // Trigger delivery async + this.processDelivery(purchase.id, input.tenantId).catch((err) => { + logError('processDelivery', err, { purchaseId: purchase.id }); + }); + + logPurchase( + purchase.id, + input.userId, + input.productId, + calculation.finalPrice.toNumber(), + { purchaseType: calculation.purchaseType } + ); + + return purchase; + } catch (error) { + await client.query('ROLLBACK'); + logError('createPurchase', error as Error, { input }); + throw mapPostgresError(error); + } finally { + client.release(); + } + } + + /** + * Get purchase by ID + */ + async getPurchaseById(purchaseId: string, tenantId: string): Promise { + const client = await this.pool.connect(); + + try { + await setTenantContext(client, tenantId); + + const result = await client.query( + `SELECT p.*, pr.* + FROM products.purchases p + JOIN products.products pr ON p.product_id = pr.id + WHERE p.id = $1`, + [purchaseId] + ); + + if (result.rows.length === 0) { + throw new PurchaseNotFoundError(purchaseId); + } + + const row = result.rows[0]; + + // Map to purchase with product + const purchase = mapRowToPurchase(row); + const product = mapRowToProduct(row); + + return { ...purchase, product }; + } finally { + client.release(); + } + } + + /** + * Get user's purchase history + */ + async getUserPurchases( + userId: string, + tenantId: string, + limit: number = 50, + offset: number = 0 + ): Promise> { + const client = await this.pool.connect(); + + try { + await setTenantContext(client, tenantId); + + // Get count + const countResult = await client.query( + 'SELECT COUNT(*) as total FROM products.purchases WHERE user_id = $1', + [userId] + ); + const total = parseInt(countResult.rows[0].total, 10); + + // Get purchases with products + const result = await client.query( + `SELECT p.*, + pr.sku as product_sku, pr.name as product_name, pr.slug as product_slug, + pr.type as product_type, pr.category as product_category, + pr.image_url as product_image_url + FROM products.purchases p + JOIN products.products pr ON p.product_id = pr.id + WHERE p.user_id = $1 + ORDER BY p.created_at DESC + LIMIT $2 OFFSET $3`, + [userId, limit, offset] + ); + + const purchases = result.rows.map((row) => { + const purchase = mapRowToPurchase(row); + const product = mapRowToProduct(row); + return { ...purchase, product }; + }); + + return { + data: purchases, + total, + limit, + offset, + hasMore: offset + purchases.length < total, + }; + } finally { + client.release(); + } + } + + /** + * Check if user owns a product + */ + async userOwnsProduct( + userId: string, + productId: string, + tenantId: string + ): Promise { + const client = await this.pool.connect(); + + try { + await setTenantContext(client, tenantId); + + const result = await client.query( + `SELECT 1 FROM products.purchases + WHERE user_id = $1 AND product_id = $2 + AND delivery_status = 'delivered' + LIMIT 1`, + [userId, productId] + ); + + return result.rows.length > 0; + } finally { + client.release(); + } + } + + /** + * Update delivery status + */ + async updateDeliveryStatus( + purchaseId: string, + status: DeliveryStatus, + deliveryData?: Record, + tenantId?: string + ): Promise { + const client = await this.pool.connect(); + + try { + if (tenantId) { + await setTenantContext(client, tenantId); + } + + const result = await client.query( + `UPDATE products.purchases + SET delivery_status = $1, + delivered_at = CASE WHEN $1 = 'delivered' THEN NOW() ELSE delivered_at END, + delivery_data = COALESCE($2, delivery_data) + WHERE id = $3 + RETURNING *`, + [status, deliveryData ? JSON.stringify(deliveryData) : null, purchaseId] + ); + + if (result.rows.length === 0) { + throw new PurchaseNotFoundError(purchaseId); + } + + return mapRowToPurchase(result.rows[0]); + } finally { + client.release(); + } + } + + /** + * Process delivery based on product type + */ + private async processDelivery(purchaseId: string, tenantId: string): Promise { + const purchase = await this.getPurchaseById(purchaseId, tenantId); + const product = purchase.product; + + try { + await this.updateDeliveryStatus(purchaseId, 'processing', undefined, tenantId); + + let deliveryData: Record = {}; + + switch (product.category) { + case 'PREDICTION': + // Create prediction purchase record + deliveryData = await this.deliverPredictions(purchase, product, tenantId); + break; + + case 'AGENT_ACCESS': + // Grant agent access + deliveryData = await this.deliverAgentAccess(purchase, product, tenantId); + break; + + case 'EDUCATION': + // Grant course access + deliveryData = { accessGranted: true, contentType: 'course' }; + break; + + case 'API_ACCESS': + // Generate API key + deliveryData = { apiKeyGenerated: true }; + break; + + default: + deliveryData = { delivered: true }; + } + + await this.updateDeliveryStatus(purchaseId, 'delivered', deliveryData, tenantId); + } catch (error) { + logError('processDelivery', error as Error, { purchaseId }); + await this.updateDeliveryStatus( + purchaseId, + 'failed', + { error: (error as Error).message }, + tenantId + ); + } + } + + /** + * Deliver prediction credits + */ + private async deliverPredictions( + purchase: Purchase, + product: Product, + tenantId: string + ): Promise> { + const client = await this.pool.connect(); + + try { + await setTenantContext(client, tenantId); + + // Create prediction purchase record in ml schema + const result = await client.query( + `INSERT INTO ml.prediction_purchases ( + tenant_id, user_id, source, product_purchase_id, + model_id, model_name, predictions_total, + amount_paid, valid_from, valid_until + ) VALUES ($1, $2, 'INDIVIDUAL', $3, $4, $5, $6, $7, NOW(), NOW() + INTERVAL '30 days') + RETURNING id`, + [ + tenantId, + purchase.userId, + purchase.id, + product.mlModelId, + product.name, + product.predictionCount || 10, + purchase.finalPrice.toString(), + ] + ); + + return { + predictionPurchaseId: result.rows[0].id, + modelId: product.mlModelId, + predictions: product.predictionCount, + validDays: 30, + }; + } finally { + client.release(); + } + } + + /** + * Deliver agent access + */ + private async deliverAgentAccess( + purchase: Purchase, + product: Product, + tenantId: string + ): Promise> { + // This would integrate with the investment/agent system + return { + agentType: product.agentType, + accessGranted: true, + validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), + }; + } + + /** + * Validate discount code + */ + private async validateDiscountCode( + code: string, + productId: string + ): Promise<{ type: 'percentage' | 'fixed'; value: number } | null> { + // TODO: Implement discount code validation from database + // For now, return null (no discount) + return null; + } + + /** + * Get wallet balance (calls wallet service) + */ + private async getWalletBalance(walletId: string, tenantId: string): Promise { + try { + const response = await fetch( + `${walletServiceConfig.baseUrl}/api/v1/wallets/${walletId}/balance`, + { + headers: { + 'X-Tenant-Id': tenantId, + }, + } + ); + + if (!response.ok) { + logger.warn('Failed to get wallet balance', { walletId, status: response.status }); + return new Decimal(0); + } + + const data = await response.json(); + return new Decimal(data.data?.available || 0); + } catch (error) { + logger.error('Wallet service error', { error, walletId }); + return new Decimal(0); + } + } + + /** + * Debit wallet (calls wallet service) + */ + private async debitWallet( + walletId: string, + amount: number, + tenantId: string, + productId: string, + productName: string + ): Promise { + const response = await fetch( + `${walletServiceConfig.baseUrl}/api/v1/wallets/${walletId}/debit`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Tenant-Id': tenantId, + }, + body: JSON.stringify({ + tenantId, + amount, + type: 'PRODUCT_PURCHASE', + description: `Purchase: ${productName}`, + referenceType: 'product', + referenceId: productId, + }), + } + ); + + if (!response.ok) { + const error = await response.json(); + throw new InvalidPurchaseError( + error.error || 'Wallet debit failed', + { walletId, amount } + ); + } + + const data = await response.json(); + return data.data?.transactionId; + } +} + +// Singleton instance +let purchaseServiceInstance: PurchaseService | null = null; + +export function getPurchaseService(): PurchaseService { + if (!purchaseServiceInstance) { + purchaseServiceInstance = new PurchaseService(); + } + return purchaseServiceInstance; +} diff --git a/src/tools/index.ts b/src/tools/index.ts new file mode 100644 index 0000000..0439b2a --- /dev/null +++ b/src/tools/index.ts @@ -0,0 +1,100 @@ +/** + * MCP Products Tools Registry + */ + +// Product tools +export { + product_get, + product_list, + product_featured, + product_by_category, + product_predictions, + product_agents, + product_vip, + product_search, + product_availability, + product_related, + handleProductGet, + handleProductList, + handleProductFeatured, + handleProductByCategory, + handleProductPredictions, + handleProductAgents, + handleProductVip, + handleProductSearch, + handleProductAvailability, + handleProductRelated, + productToolSchemas, +} from './products'; + +// Purchase tools +export { + purchase_calculate, + purchase_create, + purchase_get, + purchase_history, + purchase_check_ownership, + purchase_update_delivery, + handlePurchaseCalculate, + handlePurchaseCreate, + handlePurchaseGet, + handlePurchaseHistory, + handlePurchaseCheckOwnership, + handlePurchaseUpdateDelivery, + purchaseToolSchemas, +} from './purchases'; + +// Combined tool schemas +import { productToolSchemas } from './products'; +import { purchaseToolSchemas } from './purchases'; + +export const allToolSchemas = { + ...productToolSchemas, + ...purchaseToolSchemas, +}; + +// Tool handler registry +import { + handleProductGet, + handleProductList, + handleProductFeatured, + handleProductByCategory, + handleProductPredictions, + handleProductAgents, + handleProductVip, + handleProductSearch, + handleProductAvailability, + handleProductRelated, +} from './products'; + +import { + handlePurchaseCalculate, + handlePurchaseCreate, + handlePurchaseGet, + handlePurchaseHistory, + handlePurchaseCheckOwnership, + handlePurchaseUpdateDelivery, +} from './purchases'; + +export const toolHandlers: Record Promise<{ content: Array<{ type: string; text: string }> }>> = { + // Product tools + product_get: handleProductGet, + product_list: handleProductList, + product_featured: handleProductFeatured, + product_by_category: handleProductByCategory, + product_predictions: handleProductPredictions, + product_agents: handleProductAgents, + product_vip: handleProductVip, + product_search: handleProductSearch, + product_availability: handleProductAvailability, + product_related: handleProductRelated, + // Purchase tools + purchase_calculate: handlePurchaseCalculate, + purchase_create: handlePurchaseCreate, + purchase_get: handlePurchaseGet, + purchase_history: handlePurchaseHistory, + purchase_check_ownership: handlePurchaseCheckOwnership, + purchase_update_delivery: handlePurchaseUpdateDelivery, +}; + +export const toolNames = Object.keys(allToolSchemas); diff --git a/src/tools/products.ts b/src/tools/products.ts new file mode 100644 index 0000000..a032255 --- /dev/null +++ b/src/tools/products.ts @@ -0,0 +1,486 @@ +import { z } from 'zod'; +import { getProductService } from '../services/product.service'; +import { isProductError } from '../utils/errors'; +import { logger } from '../utils/logger'; +import { + Product, + ProductType, + ProductCategory, + PaginatedResult, +} from '../types/product.types'; + +// ============================================================================ +// INPUT SCHEMAS +// ============================================================================ + +export const GetProductInputSchema = z.object({ + productId: z.string().uuid('Invalid product ID').optional(), + sku: z.string().optional(), + slug: z.string().optional(), +}).refine( + (data) => data.productId || data.sku || data.slug, + { message: 'Either productId, sku, or slug must be provided' } +); + +export const ListProductsInputSchema = z.object({ + type: z.enum(['ONE_TIME', 'SUBSCRIPTION', 'VIP'] as const).optional(), + category: z.enum([ + 'PREDICTION', 'EDUCATION', 'CONSULTING', 'AGENT_ACCESS', + 'SIGNAL_PACK', 'API_ACCESS', 'PREMIUM_FEATURE', + ] as const).optional(), + isVip: z.boolean().optional(), + isFeatured: z.boolean().optional(), + search: z.string().optional(), + minPrice: z.number().min(0).optional(), + maxPrice: z.number().min(0).optional(), + limit: z.number().int().min(1).max(100).default(20), + offset: z.number().int().min(0).default(0), + sortBy: z.enum(['price', 'name', 'created_at', 'sort_order'] as const).optional(), + sortOrder: z.enum(['asc', 'desc'] as const).optional(), +}); + +export const GetFeaturedInputSchema = z.object({ + limit: z.number().int().min(1).max(20).default(6), +}); + +export const GetByCategoryInputSchema = z.object({ + category: z.enum([ + 'PREDICTION', 'EDUCATION', 'CONSULTING', 'AGENT_ACCESS', + 'SIGNAL_PACK', 'API_ACCESS', 'PREMIUM_FEATURE', + ] as const), + limit: z.number().int().min(1).max(50).default(20), +}); + +export const GetPredictionProductsInputSchema = z.object({ + modelId: z.string().optional(), +}); + +export const GetAgentProductsInputSchema = z.object({ + agentType: z.enum(['ATLAS', 'ORION', 'NOVA'] as const).optional(), +}); + +export const SearchProductsInputSchema = z.object({ + query: z.string().min(1), + limit: z.number().int().min(1).max(50).default(20), +}); + +export const CheckAvailabilityInputSchema = z.object({ + productId: z.string().uuid('Invalid product ID'), +}); + +export const GetRelatedInputSchema = z.object({ + productId: z.string().uuid('Invalid product ID'), + limit: z.number().int().min(1).max(10).default(4), +}); + +// ============================================================================ +// RESULT TYPES +// ============================================================================ + +interface ToolResult { + success: boolean; + data?: T; + error?: string; + code?: string; +} + +// ============================================================================ +// TOOL IMPLEMENTATIONS +// ============================================================================ + +export async function product_get( + params: z.infer +): Promise> { + try { + const service = getProductService(); + let product: Product; + + if (params.productId) { + product = await service.getProductById(params.productId); + } else if (params.sku) { + product = await service.getProductBySku(params.sku); + } else if (params.slug) { + product = await service.getProductBySlug(params.slug); + } else { + return { success: false, error: 'No identifier provided' }; + } + + return { success: true, data: product }; + } catch (error) { + logger.error('product_get failed', { error, params }); + if (isProductError(error)) { + return { success: false, error: error.message, code: error.code }; + } + return { success: false, error: 'Failed to get product' }; + } +} + +export async function product_list( + params: z.infer +): Promise>> { + try { + const service = getProductService(); + const result = await service.listProducts({ + type: params.type as ProductType | undefined, + category: params.category as ProductCategory | undefined, + isVip: params.isVip, + isFeatured: params.isFeatured, + search: params.search, + minPrice: params.minPrice, + maxPrice: params.maxPrice, + limit: params.limit, + offset: params.offset, + sortBy: params.sortBy, + sortOrder: params.sortOrder, + }); + + return { success: true, data: result }; + } catch (error) { + logger.error('product_list failed', { error, params }); + if (isProductError(error)) { + return { success: false, error: error.message, code: error.code }; + } + return { success: false, error: 'Failed to list products' }; + } +} + +export async function product_featured( + params: z.infer +): Promise> { + try { + const service = getProductService(); + const products = await service.getFeaturedProducts(params.limit); + return { success: true, data: products }; + } catch (error) { + logger.error('product_featured failed', { error, params }); + if (isProductError(error)) { + return { success: false, error: error.message, code: error.code }; + } + return { success: false, error: 'Failed to get featured products' }; + } +} + +export async function product_by_category( + params: z.infer +): Promise> { + try { + const service = getProductService(); + const products = await service.getProductsByCategory( + params.category as ProductCategory, + params.limit + ); + return { success: true, data: products }; + } catch (error) { + logger.error('product_by_category failed', { error, params }); + if (isProductError(error)) { + return { success: false, error: error.message, code: error.code }; + } + return { success: false, error: 'Failed to get products by category' }; + } +} + +export async function product_predictions( + params: z.infer +): Promise> { + try { + const service = getProductService(); + const products = await service.getPredictionProducts(params.modelId); + return { success: true, data: products }; + } catch (error) { + logger.error('product_predictions failed', { error, params }); + if (isProductError(error)) { + return { success: false, error: error.message, code: error.code }; + } + return { success: false, error: 'Failed to get prediction products' }; + } +} + +export async function product_agents( + params: z.infer +): Promise> { + try { + const service = getProductService(); + const products = await service.getAgentAccessProducts(params.agentType); + return { success: true, data: products }; + } catch (error) { + logger.error('product_agents failed', { error, params }); + if (isProductError(error)) { + return { success: false, error: error.message, code: error.code }; + } + return { success: false, error: 'Failed to get agent products' }; + } +} + +export async function product_vip(): Promise> { + try { + const service = getProductService(); + const products = await service.getVipProducts(); + return { success: true, data: products }; + } catch (error) { + logger.error('product_vip failed', { error }); + if (isProductError(error)) { + return { success: false, error: error.message, code: error.code }; + } + return { success: false, error: 'Failed to get VIP products' }; + } +} + +export async function product_search( + params: z.infer +): Promise> { + try { + const service = getProductService(); + const products = await service.searchProducts(params.query, params.limit); + return { success: true, data: products }; + } catch (error) { + logger.error('product_search failed', { error, params }); + if (isProductError(error)) { + return { success: false, error: error.message, code: error.code }; + } + return { success: false, error: 'Failed to search products' }; + } +} + +export async function product_availability( + params: z.infer +): Promise> { + try { + const service = getProductService(); + const result = await service.checkAvailability(params.productId); + return { success: true, data: result }; + } catch (error) { + logger.error('product_availability failed', { error, params }); + if (isProductError(error)) { + return { success: false, error: error.message, code: error.code }; + } + return { success: false, error: 'Failed to check availability' }; + } +} + +export async function product_related( + params: z.infer +): Promise> { + try { + const service = getProductService(); + const products = await service.getRelatedProducts(params.productId, params.limit); + return { success: true, data: products }; + } catch (error) { + logger.error('product_related failed', { error, params }); + if (isProductError(error)) { + return { success: false, error: error.message, code: error.code }; + } + return { success: false, error: 'Failed to get related products' }; + } +} + +// ============================================================================ +// MCP TOOL SCHEMAS +// ============================================================================ + +export const productToolSchemas = { + product_get: { + name: 'product_get', + description: 'Get a product by ID, SKU, or slug', + inputSchema: { + type: 'object', + properties: { + productId: { type: 'string', description: 'Product UUID' }, + sku: { type: 'string', description: 'Product SKU' }, + slug: { type: 'string', description: 'Product slug' }, + }, + required: [], + }, + riskLevel: 'LOW', + }, + + product_list: { + name: 'product_list', + description: 'List products with filters and pagination', + inputSchema: { + type: 'object', + properties: { + type: { type: 'string', enum: ['ONE_TIME', 'SUBSCRIPTION', 'VIP'] }, + category: { type: 'string', enum: ['PREDICTION', 'EDUCATION', 'CONSULTING', 'AGENT_ACCESS', 'SIGNAL_PACK', 'API_ACCESS', 'PREMIUM_FEATURE'] }, + isVip: { type: 'boolean' }, + isFeatured: { type: 'boolean' }, + search: { type: 'string' }, + minPrice: { type: 'number' }, + maxPrice: { type: 'number' }, + limit: { type: 'number', default: 20 }, + offset: { type: 'number', default: 0 }, + sortBy: { type: 'string', enum: ['price', 'name', 'created_at', 'sort_order'] }, + sortOrder: { type: 'string', enum: ['asc', 'desc'] }, + }, + required: [], + }, + riskLevel: 'LOW', + }, + + product_featured: { + name: 'product_featured', + description: 'Get featured products for homepage', + inputSchema: { + type: 'object', + properties: { + limit: { type: 'number', default: 6 }, + }, + required: [], + }, + riskLevel: 'LOW', + }, + + product_by_category: { + name: 'product_by_category', + description: 'Get products by category', + inputSchema: { + type: 'object', + properties: { + category: { type: 'string', enum: ['PREDICTION', 'EDUCATION', 'CONSULTING', 'AGENT_ACCESS', 'SIGNAL_PACK', 'API_ACCESS', 'PREMIUM_FEATURE'] }, + limit: { type: 'number', default: 20 }, + }, + required: ['category'], + }, + riskLevel: 'LOW', + }, + + product_predictions: { + name: 'product_predictions', + description: 'Get ML prediction products (optionally filtered by model)', + inputSchema: { + type: 'object', + properties: { + modelId: { type: 'string', description: 'Filter by ML model ID' }, + }, + required: [], + }, + riskLevel: 'LOW', + }, + + product_agents: { + name: 'product_agents', + description: 'Get Money Manager agent access products', + inputSchema: { + type: 'object', + properties: { + agentType: { type: 'string', enum: ['ATLAS', 'ORION', 'NOVA'] }, + }, + required: [], + }, + riskLevel: 'LOW', + }, + + product_vip: { + name: 'product_vip', + description: 'Get all VIP products', + inputSchema: { type: 'object', properties: {}, required: [] }, + riskLevel: 'LOW', + }, + + product_search: { + name: 'product_search', + description: 'Search products by name, description, or SKU', + inputSchema: { + type: 'object', + properties: { + query: { type: 'string', description: 'Search query' }, + limit: { type: 'number', default: 20 }, + }, + required: ['query'], + }, + riskLevel: 'LOW', + }, + + product_availability: { + name: 'product_availability', + description: 'Check product availability and stock', + inputSchema: { + type: 'object', + properties: { + productId: { type: 'string', description: 'Product UUID' }, + }, + required: ['productId'], + }, + riskLevel: 'LOW', + }, + + product_related: { + name: 'product_related', + description: 'Get related products', + inputSchema: { + type: 'object', + properties: { + productId: { type: 'string', description: 'Product UUID' }, + limit: { type: 'number', default: 4 }, + }, + required: ['productId'], + }, + riskLevel: 'LOW', + }, +}; + +// ============================================================================ +// MCP HANDLERS +// ============================================================================ + +function formatMcpResponse(result: ToolResult): { content: Array<{ type: string; text: string }> } { + return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; +} + +export async function handleProductGet(params: unknown) { + const validated = GetProductInputSchema.parse(params); + const result = await product_get(validated); + return formatMcpResponse(result); +} + +export async function handleProductList(params: unknown) { + const validated = ListProductsInputSchema.parse(params); + const result = await product_list(validated); + return formatMcpResponse(result); +} + +export async function handleProductFeatured(params: unknown) { + const validated = GetFeaturedInputSchema.parse(params); + const result = await product_featured(validated); + return formatMcpResponse(result); +} + +export async function handleProductByCategory(params: unknown) { + const validated = GetByCategoryInputSchema.parse(params); + const result = await product_by_category(validated); + return formatMcpResponse(result); +} + +export async function handleProductPredictions(params: unknown) { + const validated = GetPredictionProductsInputSchema.parse(params); + const result = await product_predictions(validated); + return formatMcpResponse(result); +} + +export async function handleProductAgents(params: unknown) { + const validated = GetAgentProductsInputSchema.parse(params); + const result = await product_agents(validated); + return formatMcpResponse(result); +} + +export async function handleProductVip() { + const result = await product_vip(); + return formatMcpResponse(result); +} + +export async function handleProductSearch(params: unknown) { + const validated = SearchProductsInputSchema.parse(params); + const result = await product_search(validated); + return formatMcpResponse(result); +} + +export async function handleProductAvailability(params: unknown) { + const validated = CheckAvailabilityInputSchema.parse(params); + const result = await product_availability(validated); + return formatMcpResponse(result); +} + +export async function handleProductRelated(params: unknown) { + const validated = GetRelatedInputSchema.parse(params); + const result = await product_related(validated); + return formatMcpResponse(result); +} diff --git a/src/tools/purchases.ts b/src/tools/purchases.ts new file mode 100644 index 0000000..5579856 --- /dev/null +++ b/src/tools/purchases.ts @@ -0,0 +1,354 @@ +import { z } from 'zod'; +import { getPurchaseService } from '../services/purchase.service'; +import { isProductError } from '../utils/errors'; +import { logger } from '../utils/logger'; +import { + Purchase, + PurchaseWithProduct, + PurchaseCalculation, + PaginatedResult, +} from '../types/product.types'; + +// ============================================================================ +// INPUT SCHEMAS +// ============================================================================ + +export const CalculatePurchaseInputSchema = z.object({ + productId: z.string().uuid('Invalid product ID'), + walletBalance: z.number().min(0).optional(), + discountCode: z.string().optional(), +}); + +export const CreatePurchaseInputSchema = z.object({ + tenantId: z.string().uuid('Invalid tenant ID'), + userId: z.string().uuid('Invalid user ID'), + productId: z.string().uuid('Invalid product ID'), + walletId: z.string().uuid('Invalid wallet ID').optional(), + useWallet: z.boolean().default(true), + stripePaymentIntentId: z.string().optional(), + discountCode: z.string().optional(), + metadata: z.record(z.unknown()).optional(), +}); + +export const GetPurchaseInputSchema = z.object({ + tenantId: z.string().uuid('Invalid tenant ID'), + purchaseId: z.string().uuid('Invalid purchase ID'), +}); + +export const GetUserPurchasesInputSchema = z.object({ + tenantId: z.string().uuid('Invalid tenant ID'), + userId: z.string().uuid('Invalid user ID'), + limit: z.number().int().min(1).max(100).default(50), + offset: z.number().int().min(0).default(0), +}); + +export const CheckOwnershipInputSchema = z.object({ + tenantId: z.string().uuid('Invalid tenant ID'), + userId: z.string().uuid('Invalid user ID'), + productId: z.string().uuid('Invalid product ID'), +}); + +export const UpdateDeliveryInputSchema = z.object({ + tenantId: z.string().uuid('Invalid tenant ID'), + purchaseId: z.string().uuid('Invalid purchase ID'), + status: z.enum(['pending', 'processing', 'delivered', 'failed', 'refunded'] as const), + deliveryData: z.record(z.unknown()).optional(), +}); + +// ============================================================================ +// RESULT TYPES +// ============================================================================ + +interface ToolResult { + success: boolean; + data?: T; + error?: string; + code?: string; +} + +// ============================================================================ +// TOOL IMPLEMENTATIONS +// ============================================================================ + +export async function purchase_calculate( + params: z.infer +): Promise> { + try { + const service = getPurchaseService(); + const calculation = await service.calculatePurchase( + params.productId, + params.walletBalance !== undefined ? require('decimal.js').default(params.walletBalance) : undefined, + params.discountCode + ); + + // Convert Decimal to number for JSON serialization + return { + success: true, + data: { + ...calculation, + productPrice: calculation.productPrice.toNumber(), + discountAmount: calculation.discountAmount.toNumber(), + finalPrice: calculation.finalPrice.toNumber(), + walletBalance: calculation.walletBalance?.toNumber(), + walletAmount: calculation.walletAmount.toNumber(), + stripeAmount: calculation.stripeAmount.toNumber(), + } as unknown as PurchaseCalculation, + }; + } catch (error) { + logger.error('purchase_calculate failed', { error, params }); + if (isProductError(error)) { + return { success: false, error: error.message, code: error.code }; + } + return { success: false, error: 'Failed to calculate purchase' }; + } +} + +export async function purchase_create( + params: z.infer +): Promise> { + try { + const service = getPurchaseService(); + const purchase = await service.createPurchase({ + tenantId: params.tenantId, + userId: params.userId, + productId: params.productId, + walletId: params.walletId, + useWallet: params.useWallet, + stripePaymentIntentId: params.stripePaymentIntentId, + discountCode: params.discountCode, + metadata: params.metadata, + }); + + return { success: true, data: purchase }; + } catch (error) { + logger.error('purchase_create failed', { error, params }); + if (isProductError(error)) { + return { success: false, error: error.message, code: error.code }; + } + return { success: false, error: 'Failed to create purchase' }; + } +} + +export async function purchase_get( + params: z.infer +): Promise> { + try { + const service = getPurchaseService(); + const purchase = await service.getPurchaseById(params.purchaseId, params.tenantId); + return { success: true, data: purchase }; + } catch (error) { + logger.error('purchase_get failed', { error, params }); + if (isProductError(error)) { + return { success: false, error: error.message, code: error.code }; + } + return { success: false, error: 'Failed to get purchase' }; + } +} + +export async function purchase_history( + params: z.infer +): Promise>> { + try { + const service = getPurchaseService(); + const result = await service.getUserPurchases( + params.userId, + params.tenantId, + params.limit, + params.offset + ); + return { success: true, data: result }; + } catch (error) { + logger.error('purchase_history failed', { error, params }); + if (isProductError(error)) { + return { success: false, error: error.message, code: error.code }; + } + return { success: false, error: 'Failed to get purchase history' }; + } +} + +export async function purchase_check_ownership( + params: z.infer +): Promise> { + try { + const service = getPurchaseService(); + const owns = await service.userOwnsProduct( + params.userId, + params.productId, + params.tenantId + ); + return { success: true, data: { owns } }; + } catch (error) { + logger.error('purchase_check_ownership failed', { error, params }); + if (isProductError(error)) { + return { success: false, error: error.message, code: error.code }; + } + return { success: false, error: 'Failed to check ownership' }; + } +} + +export async function purchase_update_delivery( + params: z.infer +): Promise> { + try { + const service = getPurchaseService(); + const purchase = await service.updateDeliveryStatus( + params.purchaseId, + params.status, + params.deliveryData, + params.tenantId + ); + return { success: true, data: purchase }; + } catch (error) { + logger.error('purchase_update_delivery failed', { error, params }); + if (isProductError(error)) { + return { success: false, error: error.message, code: error.code }; + } + return { success: false, error: 'Failed to update delivery status' }; + } +} + +// ============================================================================ +// MCP TOOL SCHEMAS +// ============================================================================ + +export const purchaseToolSchemas = { + purchase_calculate: { + name: 'purchase_calculate', + description: 'Calculate purchase amounts including wallet/stripe split', + inputSchema: { + type: 'object', + properties: { + productId: { type: 'string', description: 'Product UUID' }, + walletBalance: { type: 'number', description: 'Available wallet balance' }, + discountCode: { type: 'string', description: 'Discount code to apply' }, + }, + required: ['productId'], + }, + riskLevel: 'LOW', + }, + + purchase_create: { + name: 'purchase_create', + description: 'Create a new purchase. Debits wallet and/or processes Stripe payment.', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string', description: 'Tenant UUID' }, + userId: { type: 'string', description: 'User UUID' }, + productId: { type: 'string', description: 'Product UUID' }, + walletId: { type: 'string', description: 'Wallet UUID for payment' }, + useWallet: { type: 'boolean', description: 'Use wallet balance (default: true)' }, + stripePaymentIntentId: { type: 'string', description: 'Stripe PaymentIntent ID if paying with card' }, + discountCode: { type: 'string', description: 'Discount code' }, + metadata: { type: 'object', description: 'Additional metadata' }, + }, + required: ['tenantId', 'userId', 'productId'], + }, + riskLevel: 'HIGH', + }, + + purchase_get: { + name: 'purchase_get', + description: 'Get purchase details by ID', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string', description: 'Tenant UUID' }, + purchaseId: { type: 'string', description: 'Purchase UUID' }, + }, + required: ['tenantId', 'purchaseId'], + }, + riskLevel: 'LOW', + }, + + purchase_history: { + name: 'purchase_history', + description: 'Get user purchase history with pagination', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string', description: 'Tenant UUID' }, + userId: { type: 'string', description: 'User UUID' }, + limit: { type: 'number', description: 'Results per page (max 100)' }, + offset: { type: 'number', description: 'Offset for pagination' }, + }, + required: ['tenantId', 'userId'], + }, + riskLevel: 'LOW', + }, + + purchase_check_ownership: { + name: 'purchase_check_ownership', + description: 'Check if user owns a specific product', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string', description: 'Tenant UUID' }, + userId: { type: 'string', description: 'User UUID' }, + productId: { type: 'string', description: 'Product UUID' }, + }, + required: ['tenantId', 'userId', 'productId'], + }, + riskLevel: 'LOW', + }, + + purchase_update_delivery: { + name: 'purchase_update_delivery', + description: 'Update purchase delivery status', + inputSchema: { + type: 'object', + properties: { + tenantId: { type: 'string', description: 'Tenant UUID' }, + purchaseId: { type: 'string', description: 'Purchase UUID' }, + status: { type: 'string', enum: ['pending', 'processing', 'delivered', 'failed', 'refunded'] }, + deliveryData: { type: 'object', description: 'Delivery details' }, + }, + required: ['tenantId', 'purchaseId', 'status'], + }, + riskLevel: 'MEDIUM', + }, +}; + +// ============================================================================ +// MCP HANDLERS +// ============================================================================ + +function formatMcpResponse(result: ToolResult): { content: Array<{ type: string; text: string }> } { + return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; +} + +export async function handlePurchaseCalculate(params: unknown) { + const validated = CalculatePurchaseInputSchema.parse(params); + const result = await purchase_calculate(validated); + return formatMcpResponse(result); +} + +export async function handlePurchaseCreate(params: unknown) { + const validated = CreatePurchaseInputSchema.parse(params); + const result = await purchase_create(validated); + return formatMcpResponse(result); +} + +export async function handlePurchaseGet(params: unknown) { + const validated = GetPurchaseInputSchema.parse(params); + const result = await purchase_get(validated); + return formatMcpResponse(result); +} + +export async function handlePurchaseHistory(params: unknown) { + const validated = GetUserPurchasesInputSchema.parse(params); + const result = await purchase_history(validated); + return formatMcpResponse(result); +} + +export async function handlePurchaseCheckOwnership(params: unknown) { + const validated = CheckOwnershipInputSchema.parse(params); + const result = await purchase_check_ownership(validated); + return formatMcpResponse(result); +} + +export async function handlePurchaseUpdateDelivery(params: unknown) { + const validated = UpdateDeliveryInputSchema.parse(params); + const result = await purchase_update_delivery(validated); + return formatMcpResponse(result); +} diff --git a/src/types/product.types.ts b/src/types/product.types.ts new file mode 100644 index 0000000..0ea14b1 --- /dev/null +++ b/src/types/product.types.ts @@ -0,0 +1,235 @@ +import Decimal from 'decimal.js'; + +/** + * Product type enum matching database + */ +export type ProductType = 'ONE_TIME' | 'SUBSCRIPTION' | 'VIP'; + +/** + * Product category enum matching database + */ +export type ProductCategory = + | 'PREDICTION' + | 'EDUCATION' + | 'CONSULTING' + | 'AGENT_ACCESS' + | 'SIGNAL_PACK' + | 'API_ACCESS' + | 'PREMIUM_FEATURE'; + +/** + * Billing interval for subscriptions + */ +export type BillingInterval = 'DAY' | 'WEEK' | 'MONTH' | 'YEAR'; + +/** + * Purchase payment type + */ +export type PurchaseType = 'WALLET' | 'STRIPE' | 'COMBINED'; + +/** + * Delivery status + */ +export type DeliveryStatus = 'pending' | 'processing' | 'delivered' | 'failed' | 'refunded'; + +/** + * Product entity from database + */ +export interface Product { + id: string; + sku: string; + name: string; + slug: string; + description: string | null; + shortDescription: string | null; + type: ProductType; + category: ProductCategory; + price: Decimal; + comparePrice: Decimal | null; + currency: string; + billingInterval: BillingInterval | null; + billingIntervalCount: number | null; + trialDays: number; + features: ProductFeature[]; + limits: Record; + mlModelId: string | null; + predictionType: string | null; + predictionCount: number | null; + agentType: string | null; + stripeProductId: string | null; + stripePriceId: string | null; + imageUrl: string | null; + badge: string | null; + sortOrder: number; + isActive: boolean; + isVip: boolean; + isFeatured: boolean; + availableFrom: Date | null; + availableUntil: Date | null; + stock: number | null; + metadata: Record; + createdAt: Date; + updatedAt: Date; +} + +/** + * Product feature structure + */ +export interface ProductFeature { + name: string; + included?: boolean; + value?: string | number; +} + +/** + * Purchase entity from database + */ +export interface Purchase { + id: string; + tenantId: string; + userId: string; + productId: string; + purchaseType: PurchaseType; + productPrice: Decimal; + discountAmount: Decimal; + finalPrice: Decimal; + walletAmount: Decimal; + stripeAmount: Decimal; + walletTransactionId: string | null; + stripePaymentIntentId: string | null; + deliveryStatus: DeliveryStatus; + deliveredAt: Date | null; + deliveryData: Record; + predictionIds: string[] | null; + discountCode: string | null; + metadata: Record; + createdAt: Date; +} + +/** + * Purchase with product details + */ +export interface PurchaseWithProduct extends Purchase { + product: Product; +} + +/** + * Product listing query + */ +export interface ProductListQuery { + type?: ProductType; + category?: ProductCategory; + isVip?: boolean; + isFeatured?: boolean; + search?: string; + minPrice?: number; + maxPrice?: number; + limit?: number; + offset?: number; + sortBy?: 'price' | 'name' | 'created_at' | 'sort_order'; + sortOrder?: 'asc' | 'desc'; +} + +/** + * Create purchase input + */ +export interface CreatePurchaseInput { + tenantId: string; + userId: string; + productId: string; + walletId?: string; + useWallet?: boolean; + stripePaymentIntentId?: string; + discountCode?: string; + metadata?: Record; +} + +/** + * Purchase calculation result + */ +export interface PurchaseCalculation { + productPrice: Decimal; + discountAmount: Decimal; + finalPrice: Decimal; + walletBalance?: Decimal; + walletAmount: Decimal; + stripeAmount: Decimal; + purchaseType: PurchaseType; +} + +/** + * Paginated result + */ +export interface PaginatedResult { + data: T[]; + total: number; + limit: number; + offset: number; + hasMore: boolean; +} + +/** + * Database row to entity mappers + */ +export function mapRowToProduct(row: Record): Product { + return { + id: row.id as string, + sku: row.sku as string, + name: row.name as string, + slug: row.slug as string, + description: row.description as string | null, + shortDescription: row.short_description as string | null, + type: row.type as ProductType, + category: row.category as ProductCategory, + price: new Decimal(row.price as string), + comparePrice: row.compare_price ? new Decimal(row.compare_price as string) : null, + currency: row.currency as string, + billingInterval: row.billing_interval as BillingInterval | null, + billingIntervalCount: row.billing_interval_count as number | null, + trialDays: row.trial_days as number, + features: (row.features as ProductFeature[]) || [], + limits: (row.limits as Record) || {}, + mlModelId: row.ml_model_id as string | null, + predictionType: row.prediction_type as string | null, + predictionCount: row.prediction_count as number | null, + agentType: row.agent_type as string | null, + stripeProductId: row.stripe_product_id as string | null, + stripePriceId: row.stripe_price_id as string | null, + imageUrl: row.image_url as string | null, + badge: row.badge as string | null, + sortOrder: row.sort_order as number, + isActive: row.is_active as boolean, + isVip: row.is_vip as boolean, + isFeatured: row.is_featured as boolean, + availableFrom: row.available_from ? new Date(row.available_from as string) : null, + availableUntil: row.available_until ? new Date(row.available_until as string) : null, + stock: row.stock as number | null, + metadata: (row.metadata as Record) || {}, + createdAt: new Date(row.created_at as string), + updatedAt: new Date(row.updated_at as string), + }; +} + +export function mapRowToPurchase(row: Record): Purchase { + return { + id: row.id as string, + tenantId: row.tenant_id as string, + userId: row.user_id as string, + productId: row.product_id as string, + purchaseType: row.purchase_type as PurchaseType, + productPrice: new Decimal(row.product_price as string), + discountAmount: new Decimal(row.discount_amount as string), + finalPrice: new Decimal(row.final_price as string), + walletAmount: new Decimal(row.wallet_amount as string), + stripeAmount: new Decimal(row.stripe_amount as string), + walletTransactionId: row.wallet_transaction_id as string | null, + stripePaymentIntentId: row.stripe_payment_intent_id as string | null, + deliveryStatus: row.delivery_status as DeliveryStatus, + deliveredAt: row.delivered_at ? new Date(row.delivered_at as string) : null, + deliveryData: (row.delivery_data as Record) || {}, + predictionIds: row.prediction_ids as string[] | null, + discountCode: row.discount_code as string | null, + metadata: (row.metadata as Record) || {}, + createdAt: new Date(row.created_at as string), + }; +} diff --git a/src/utils/errors.ts b/src/utils/errors.ts new file mode 100644 index 0000000..c010817 --- /dev/null +++ b/src/utils/errors.ts @@ -0,0 +1,180 @@ +/** + * Custom error classes for products operations + */ + +export class ProductError extends Error { + constructor( + message: string, + public code: string, + public statusCode: number = 400, + public details?: Record + ) { + super(message); + this.name = 'ProductError'; + Error.captureStackTrace(this, this.constructor); + } + + toJSON() { + return { + name: this.name, + message: this.message, + code: this.code, + statusCode: this.statusCode, + details: this.details, + }; + } +} + +export class ProductNotFoundError extends ProductError { + constructor(identifier: string, field: string = 'id') { + super( + `Product not found: ${identifier}`, + 'PRODUCT_NOT_FOUND', + 404, + { [field]: identifier } + ); + this.name = 'ProductNotFoundError'; + } +} + +export class ProductNotAvailableError extends ProductError { + constructor(productId: string, reason: string) { + super( + `Product not available: ${reason}`, + 'PRODUCT_NOT_AVAILABLE', + 400, + { productId, reason } + ); + this.name = 'ProductNotAvailableError'; + } +} + +export class InsufficientStockError extends ProductError { + constructor(productId: string, available: number, requested: number) { + super( + `Insufficient stock. Available: ${available}, Requested: ${requested}`, + 'INSUFFICIENT_STOCK', + 400, + { productId, available, requested } + ); + this.name = 'InsufficientStockError'; + } +} + +export class PurchaseNotFoundError extends ProductError { + constructor(purchaseId: string) { + super(`Purchase not found: ${purchaseId}`, 'PURCHASE_NOT_FOUND', 404); + this.name = 'PurchaseNotFoundError'; + } +} + +export class InvalidPurchaseError extends ProductError { + constructor(reason: string, details?: Record) { + super(`Invalid purchase: ${reason}`, 'INVALID_PURCHASE', 400, details); + this.name = 'InvalidPurchaseError'; + } +} + +export class PaymentRequiredError extends ProductError { + constructor(amount: number, currency: string) { + super( + `Payment required: ${amount} ${currency}`, + 'PAYMENT_REQUIRED', + 402, + { amount, currency } + ); + this.name = 'PaymentRequiredError'; + } +} + +export class VipAccessRequiredError extends ProductError { + constructor(productId: string, requiredTier?: string) { + super( + `VIP access required for this product`, + 'VIP_ACCESS_REQUIRED', + 403, + { productId, requiredTier } + ); + this.name = 'VipAccessRequiredError'; + } +} + +export class InvalidDiscountCodeError extends ProductError { + constructor(code: string, reason: string) { + super( + `Invalid discount code: ${reason}`, + 'INVALID_DISCOUNT_CODE', + 400, + { code, reason } + ); + this.name = 'InvalidDiscountCodeError'; + } +} + +export class DeliveryFailedError extends ProductError { + constructor(purchaseId: string, reason: string) { + super( + `Delivery failed: ${reason}`, + 'DELIVERY_FAILED', + 500, + { purchaseId, reason } + ); + this.name = 'DeliveryFailedError'; + } +} + +export class DuplicatePurchaseError extends ProductError { + constructor(productId: string, userId: string) { + super( + `User already owns this product`, + 'DUPLICATE_PURCHASE', + 409, + { productId, userId } + ); + this.name = 'DuplicatePurchaseError'; + } +} + +export class ValidationError extends ProductError { + constructor(message: string, field?: string) { + super(message, 'VALIDATION_ERROR', 400, field ? { field } : undefined); + this.name = 'ValidationError'; + } +} + +export class DatabaseError extends ProductError { + constructor(message: string, originalError?: Error) { + super(message, 'DATABASE_ERROR', 500, { + originalMessage: originalError?.message, + }); + this.name = 'DatabaseError'; + } +} + +export function isProductError(error: unknown): error is ProductError { + return error instanceof ProductError; +} + +export function mapPostgresError(error: unknown): ProductError { + if (error instanceof Error) { + const pgError = error as { code?: string; constraint?: string; detail?: string }; + + if (pgError.code === '23505') { + return new ProductError('Duplicate entry', 'DUPLICATE_ENTRY', 409); + } + + if (pgError.code === '23514') { + return new InvalidPurchaseError('Constraint violation', { constraint: pgError.constraint }); + } + + if (pgError.code === '23503') { + return new ProductError('Referenced entity not found', 'REFERENCE_NOT_FOUND', 404); + } + + if (pgError.code === 'P0001') { + return new ProductError(error.message, 'BUSINESS_RULE_VIOLATION', 400); + } + } + + return new DatabaseError('Database operation failed', error instanceof Error ? error : undefined); +} diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..6e74a4c --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,67 @@ +import winston from 'winston'; +import { serverConfig } from '../config'; + +const { combine, timestamp, printf, colorize, errors } = winston.format; + +const consoleFormat = printf(({ level, message, timestamp, stack, ...meta }) => { + let msg = `${timestamp} [${level}]: ${message}`; + if (Object.keys(meta).length > 0) { + msg += ` ${JSON.stringify(meta)}`; + } + if (stack) { + msg += `\n${stack}`; + } + return msg; +}); + +const jsonFormat = printf(({ level, message, timestamp, ...meta }) => { + return JSON.stringify({ timestamp, level, message, ...meta }); +}); + +export const logger = winston.createLogger({ + level: serverConfig.logLevel, + format: combine( + errors({ stack: true }), + timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }) + ), + defaultMeta: { service: 'mcp-products' }, + transports: [ + new winston.transports.Console({ + format: combine( + serverConfig.nodeEnv === 'development' ? colorize() : winston.format.uncolorize(), + serverConfig.nodeEnv === 'development' ? consoleFormat : jsonFormat + ), + }), + ], +}); + +if (serverConfig.nodeEnv === 'production') { + logger.add(new winston.transports.File({ filename: 'logs/error.log', level: 'error', format: jsonFormat })); + logger.add(new winston.transports.File({ filename: 'logs/combined.log', format: jsonFormat })); +} + +export function logProductOperation( + operation: string, + productId: string, + details: Record = {} +): void { + logger.info(`Product operation: ${operation}`, { operation, productId, ...details }); +} + +export function logPurchase( + purchaseId: string, + userId: string, + productId: string, + amount: number, + details: Record = {} +): void { + logger.info(`Purchase completed`, { purchaseId, userId, productId, amount, ...details }); +} + +export function logError( + context: string, + error: Error, + details: Record = {} +): void { + logger.error(`Error in ${context}`, { context, error: error.message, stack: error.stack, ...details }); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..0671d50 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "moduleResolution": "node", + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +}