Migración desde trading-platform/apps/mcp-products - Estándar multi-repo v2

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rckrdmrd 2026-01-16 08:33:17 -06:00
commit 2521b63c6d
17 changed files with 3194 additions and 0 deletions

32
.env.example Normal file
View File

@ -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_...

23
Dockerfile Normal file
View File

@ -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"]

81
README.md Normal file
View File

@ -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

51
package.json Normal file
View File

@ -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
}

86
src/config.ts Normal file
View File

@ -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<void> {
if (pool) {
await pool.end();
pool = null;
}
}
// Helper to set tenant context for RLS
export async function setTenantContext(
client: ReturnType<Pool['connect']> extends Promise<infer T> ? T : never,
tenantId: string
): Promise<void> {
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);
}

384
src/index.ts Normal file
View File

@ -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 };

View File

@ -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();
}

5
src/middleware/index.ts Normal file
View File

@ -0,0 +1,5 @@
/**
* Middleware exports
*/
export { authMiddleware, optionalAuthMiddleware } from './auth.middleware';

View File

@ -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<Product> {
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<Product> {
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<Product> {
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<PaginatedResult<Product>> {
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<Product[]> {
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<Product[]> {
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<Product[]> {
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<Product[]> {
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<Product[]> {
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<Product[]> {
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<Product[]> {
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<number> {
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<Product[]> {
// 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;
}

View File

@ -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<PurchaseCalculation> {
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<Purchase> {
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<PurchaseWithProduct> {
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<PaginatedResult<PurchaseWithProduct>> {
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<boolean> {
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<string, unknown>,
tenantId?: string
): Promise<Purchase> {
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<void> {
const purchase = await this.getPurchaseById(purchaseId, tenantId);
const product = purchase.product;
try {
await this.updateDeliveryStatus(purchaseId, 'processing', undefined, tenantId);
let deliveryData: Record<string, unknown> = {};
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<Record<string, unknown>> {
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<Record<string, unknown>> {
// 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<Decimal> {
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<string> {
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;
}

100
src/tools/index.ts Normal file
View File

@ -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<string, (params: unknown) => 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);

486
src/tools/products.ts Normal file
View File

@ -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<T = unknown> {
success: boolean;
data?: T;
error?: string;
code?: string;
}
// ============================================================================
// TOOL IMPLEMENTATIONS
// ============================================================================
export async function product_get(
params: z.infer<typeof GetProductInputSchema>
): Promise<ToolResult<Product>> {
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<typeof ListProductsInputSchema>
): Promise<ToolResult<PaginatedResult<Product>>> {
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<typeof GetFeaturedInputSchema>
): Promise<ToolResult<Product[]>> {
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<typeof GetByCategoryInputSchema>
): Promise<ToolResult<Product[]>> {
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<typeof GetPredictionProductsInputSchema>
): Promise<ToolResult<Product[]>> {
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<typeof GetAgentProductsInputSchema>
): Promise<ToolResult<Product[]>> {
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<ToolResult<Product[]>> {
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<typeof SearchProductsInputSchema>
): Promise<ToolResult<Product[]>> {
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<typeof CheckAvailabilityInputSchema>
): Promise<ToolResult<{ available: boolean; reason?: string; stock?: number }>> {
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<typeof GetRelatedInputSchema>
): Promise<ToolResult<Product[]>> {
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);
}

354
src/tools/purchases.ts Normal file
View File

@ -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<T = unknown> {
success: boolean;
data?: T;
error?: string;
code?: string;
}
// ============================================================================
// TOOL IMPLEMENTATIONS
// ============================================================================
export async function purchase_calculate(
params: z.infer<typeof CalculatePurchaseInputSchema>
): Promise<ToolResult<PurchaseCalculation>> {
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<typeof CreatePurchaseInputSchema>
): Promise<ToolResult<Purchase>> {
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<typeof GetPurchaseInputSchema>
): Promise<ToolResult<PurchaseWithProduct>> {
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<typeof GetUserPurchasesInputSchema>
): Promise<ToolResult<PaginatedResult<PurchaseWithProduct>>> {
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<typeof CheckOwnershipInputSchema>
): Promise<ToolResult<{ owns: boolean }>> {
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<typeof UpdateDeliveryInputSchema>
): Promise<ToolResult<Purchase>> {
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);
}

235
src/types/product.types.ts Normal file
View File

@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
predictionIds: string[] | null;
discountCode: string | null;
metadata: Record<string, unknown>;
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<string, unknown>;
}
/**
* 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<T> {
data: T[];
total: number;
limit: number;
offset: number;
hasMore: boolean;
}
/**
* Database row to entity mappers
*/
export function mapRowToProduct(row: Record<string, unknown>): 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<string, unknown>) || {},
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<string, unknown>) || {},
createdAt: new Date(row.created_at as string),
updatedAt: new Date(row.updated_at as string),
};
}
export function mapRowToPurchase(row: Record<string, unknown>): 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<string, unknown>) || {},
predictionIds: row.prediction_ids as string[] | null,
discountCode: row.discount_code as string | null,
metadata: (row.metadata as Record<string, unknown>) || {},
createdAt: new Date(row.created_at as string),
};
}

180
src/utils/errors.ts Normal file
View File

@ -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<string, unknown>
) {
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<string, unknown>) {
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);
}

67
src/utils/logger.ts Normal file
View File

@ -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<string, unknown> = {}
): void {
logger.info(`Product operation: ${operation}`, { operation, productId, ...details });
}
export function logPurchase(
purchaseId: string,
userId: string,
productId: string,
amount: number,
details: Record<string, unknown> = {}
): void {
logger.info(`Purchase completed`, { purchaseId, userId, productId, amount, ...details });
}
export function logError(
context: string,
error: Error,
details: Record<string, unknown> = {}
): void {
logger.error(`Error in ${context}`, { context, error: error.message, stack: error.stack, ...details });
}

31
tsconfig.json Normal file
View File

@ -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"]
}