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:
commit
2521b63c6d
32
.env.example
Normal file
32
.env.example
Normal 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
23
Dockerfile
Normal 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
81
README.md
Normal 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
51
package.json
Normal 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
86
src/config.ts
Normal 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
384
src/index.ts
Normal 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 };
|
||||||
127
src/middleware/auth.middleware.ts
Normal file
127
src/middleware/auth.middleware.ts
Normal 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
5
src/middleware/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* Middleware exports
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { authMiddleware, optionalAuthMiddleware } from './auth.middleware';
|
||||||
400
src/services/product.service.ts
Normal file
400
src/services/product.service.ts
Normal 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;
|
||||||
|
}
|
||||||
552
src/services/purchase.service.ts
Normal file
552
src/services/purchase.service.ts
Normal 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
100
src/tools/index.ts
Normal 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
486
src/tools/products.ts
Normal 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
354
src/tools/purchases.ts
Normal 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
235
src/types/product.types.ts
Normal 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
180
src/utils/errors.ts
Normal 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
67
src/utils/logger.ts
Normal 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
31
tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user