Migración desde trading-platform/apps/mcp-vip - Estándar multi-repo v2
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
commit
41952f8985
25
.env.example
Normal file
25
.env.example
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# MCP VIP SERVER CONFIGURATION
|
||||||
|
|
||||||
|
PORT=3092
|
||||||
|
NODE_ENV=development
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_NAME=trading_platform
|
||||||
|
DB_USER=trading_app
|
||||||
|
DB_PASSWORD=your_password
|
||||||
|
DB_SSL=false
|
||||||
|
DB_POOL_MAX=20
|
||||||
|
|
||||||
|
# VIP Config
|
||||||
|
VIP_TRIAL_DAYS=7
|
||||||
|
VIP_GRACE_PERIOD_DAYS=3
|
||||||
|
|
||||||
|
# Wallet Service
|
||||||
|
WALLET_SERVICE_URL=http://localhost:3090
|
||||||
|
|
||||||
|
# Stripe
|
||||||
|
STRIPE_SECRET_KEY=sk_test_...
|
||||||
|
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||||
18
Dockerfile
Normal file
18
Dockerfile
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
COPY src ./src
|
||||||
|
RUN npm run build && npm prune --production
|
||||||
|
|
||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
RUN addgroup -g 1001 -S nodejs && adduser -S vip -u 1001
|
||||||
|
COPY --from=builder --chown=vip:nodejs /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder --chown=vip:nodejs /app/dist ./dist
|
||||||
|
COPY --from=builder --chown=vip:nodejs /app/package.json ./
|
||||||
|
USER vip
|
||||||
|
EXPOSE 3092
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s CMD wget -q --spider http://localhost:3092/health || exit 1
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
68
README.md
Normal file
68
README.md
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
# MCP VIP Server
|
||||||
|
|
||||||
|
VIP Subscription MCP Server for the Trading Platform. Manages Gold, Platinum, Diamond tiers with exclusive ML model access.
|
||||||
|
|
||||||
|
## VIP Tiers
|
||||||
|
|
||||||
|
| Tier | Price/mo | Predictions | Models |
|
||||||
|
|------|----------|-------------|--------|
|
||||||
|
| **Gold** | $199 | 50/mo | AMD, Range |
|
||||||
|
| **Platinum** | $399 | 150/mo | + TPSL, ICT/SMC |
|
||||||
|
| **Diamond** | $999 | Unlimited | + Strategy Ensemble |
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
cp .env.example .env
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Tiers
|
||||||
|
- `GET /api/v1/tiers` - List all tiers
|
||||||
|
- `GET /api/v1/tiers/:tier` - Get tier details
|
||||||
|
|
||||||
|
### Subscriptions
|
||||||
|
- `POST /api/v1/subscriptions` - Create subscription
|
||||||
|
- `GET /api/v1/subscriptions/:id` - Get subscription
|
||||||
|
- `GET /api/v1/users/:userId/subscription` - Get user subscription
|
||||||
|
- `POST /api/v1/subscriptions/:id/cancel` - Cancel
|
||||||
|
- `POST /api/v1/subscriptions/:id/reactivate` - Reactivate
|
||||||
|
- `POST /api/v1/subscriptions/:id/upgrade` - Upgrade tier
|
||||||
|
- `GET /api/v1/subscriptions/:id/usage` - Usage stats
|
||||||
|
- `GET /api/v1/subscriptions/:id/models` - Model access
|
||||||
|
|
||||||
|
### Model Access
|
||||||
|
- `GET /api/v1/users/:userId/access/:modelId` - Check access
|
||||||
|
|
||||||
|
## MCP Tools (13)
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `vip_get_tiers` | List tiers |
|
||||||
|
| `vip_get_tier` | Get tier |
|
||||||
|
| `vip_create_subscription` | Create sub |
|
||||||
|
| `vip_get_subscription` | Get sub |
|
||||||
|
| `vip_cancel_subscription` | Cancel |
|
||||||
|
| `vip_reactivate_subscription` | Reactivate |
|
||||||
|
| `vip_upgrade_tier` | Upgrade |
|
||||||
|
| `vip_check_model_access` | Check access |
|
||||||
|
| `vip_get_model_access` | List access |
|
||||||
|
| `vip_record_model_usage` | Record usage |
|
||||||
|
| `vip_get_usage_stats` | Usage stats |
|
||||||
|
| `vip_update_status` | Update status |
|
||||||
|
| `vip_renew_period` | Renew period |
|
||||||
|
|
||||||
|
## Subscription Status
|
||||||
|
|
||||||
|
- `trialing` - In trial period
|
||||||
|
- `active` - Active and paying
|
||||||
|
- `past_due` - Payment failed
|
||||||
|
- `cancelled` - Cancelled
|
||||||
|
- `expired` - Expired
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
UNLICENSED - Private
|
||||||
42
package.json
Normal file
42
package.json
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "@trading-platform/mcp-vip",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "MCP Server for VIP Subscriptions - Gold, Platinum, Diamond tiers",
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"engines": { "node": ">=18.0.0" },
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
55
src/config.ts
Normal file
55
src/config.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { Pool, PoolConfig } from 'pg';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
export const serverConfig = {
|
||||||
|
port: parseInt(process.env.PORT || '3092', 10),
|
||||||
|
nodeEnv: process.env.NODE_ENV || 'development',
|
||||||
|
logLevel: process.env.LOG_LEVEL || 'info',
|
||||||
|
};
|
||||||
|
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const vipConfig = {
|
||||||
|
trialDays: parseInt(process.env.VIP_TRIAL_DAYS || '7', 10),
|
||||||
|
gracePeriodDays: parseInt(process.env.VIP_GRACE_PERIOD_DAYS || '3', 10),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const walletServiceConfig = {
|
||||||
|
baseUrl: process.env.WALLET_SERVICE_URL || 'http://localhost:3090',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const stripeConfig = {
|
||||||
|
secretKey: process.env.STRIPE_SECRET_KEY || '',
|
||||||
|
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
let pool: Pool | null = null;
|
||||||
|
|
||||||
|
export function getPool(): Pool {
|
||||||
|
if (!pool) {
|
||||||
|
pool = new Pool(dbConfig);
|
||||||
|
pool.on('error', (err) => console.error('DB pool error', err));
|
||||||
|
}
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closePool(): Promise<void> {
|
||||||
|
if (pool) { await pool.end(); pool = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
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]);
|
||||||
|
}
|
||||||
292
src/index.ts
Normal file
292
src/index.ts
Normal file
@ -0,0 +1,292 @@
|
|||||||
|
/**
|
||||||
|
* MCP VIP Server
|
||||||
|
*
|
||||||
|
* VIP Subscription system for trading platform.
|
||||||
|
* Manages Gold, Platinum, Diamond tiers and exclusive model access.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 { allToolSchemas, toolHandlers, toolNames } from './tools';
|
||||||
|
import { authMiddleware } from './middleware';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use(helmet());
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json({ limit: '1mb' }));
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// HEALTH & INFO
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
app.get('/health', async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
await getPool().query('SELECT 1');
|
||||||
|
res.json({ status: 'healthy', service: 'mcp-vip', database: 'connected' });
|
||||||
|
} catch {
|
||||||
|
res.status(503).json({ status: 'unhealthy', service: 'mcp-vip', database: 'disconnected' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/info', (_req: Request, res: Response) => {
|
||||||
|
res.json({ name: 'mcp-vip', version: '1.0.0', tools: toolNames, toolCount: toolNames.length });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MCP 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 handler = toolHandlers[req.params.toolName];
|
||||||
|
if (!handler) {
|
||||||
|
res.status(404).json({ error: `Tool not found: ${req.params.toolName}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
res.json(await handler(req.body));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/mcp/call', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const { name, arguments: args } = req.body;
|
||||||
|
const handler = toolHandlers[name];
|
||||||
|
if (!handler) {
|
||||||
|
res.status(404).json({ error: `Tool not found: ${name}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
res.json(await handler(args || {}));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// REST API ENDPOINTS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Tiers
|
||||||
|
app.get('/api/v1/tiers', async (_req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
res.json(await toolHandlers.vip_get_tiers({}));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/v1/tiers/:tier', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
res.json(await toolHandlers.vip_get_tier({ tier: req.params.tier }));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscriptions - Protected routes
|
||||||
|
const subscriptionsRouter = express.Router();
|
||||||
|
subscriptionsRouter.use(authMiddleware);
|
||||||
|
|
||||||
|
// Create subscription
|
||||||
|
subscriptionsRouter.post('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
res.status(201).json(await toolHandlers.vip_create_subscription({
|
||||||
|
tenantId: req.tenantId,
|
||||||
|
userId: req.userId,
|
||||||
|
...req.body,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get subscription by ID
|
||||||
|
subscriptionsRouter.get('/:subscriptionId', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
res.json(await toolHandlers.vip_get_subscription({
|
||||||
|
tenantId: req.tenantId,
|
||||||
|
subscriptionId: req.params.subscriptionId,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cancel subscription
|
||||||
|
subscriptionsRouter.post('/:subscriptionId/cancel', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
res.json(await toolHandlers.vip_cancel_subscription({
|
||||||
|
tenantId: req.tenantId,
|
||||||
|
subscriptionId: req.params.subscriptionId,
|
||||||
|
cancelledBy: req.userId,
|
||||||
|
...req.body,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reactivate subscription
|
||||||
|
subscriptionsRouter.post('/:subscriptionId/reactivate', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
res.json(await toolHandlers.vip_reactivate_subscription({
|
||||||
|
tenantId: req.tenantId,
|
||||||
|
subscriptionId: req.params.subscriptionId,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Upgrade tier
|
||||||
|
subscriptionsRouter.post('/:subscriptionId/upgrade', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
res.json(await toolHandlers.vip_upgrade_tier({
|
||||||
|
tenantId: req.tenantId,
|
||||||
|
subscriptionId: req.params.subscriptionId,
|
||||||
|
newTier: req.body.newTier,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get usage stats
|
||||||
|
subscriptionsRouter.get('/:subscriptionId/usage', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
res.json(await toolHandlers.vip_get_usage_stats({
|
||||||
|
tenantId: req.tenantId,
|
||||||
|
subscriptionId: req.params.subscriptionId,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get model access
|
||||||
|
subscriptionsRouter.get('/:subscriptionId/models', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
res.json(await toolHandlers.vip_get_model_access({
|
||||||
|
tenantId: req.tenantId,
|
||||||
|
subscriptionId: req.params.subscriptionId,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use('/api/v1/subscriptions', subscriptionsRouter);
|
||||||
|
|
||||||
|
// User-specific endpoints - Protected
|
||||||
|
const userRouter = express.Router();
|
||||||
|
userRouter.use(authMiddleware);
|
||||||
|
|
||||||
|
// Get user's subscription
|
||||||
|
userRouter.get('/:userId/subscription', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
res.json(await toolHandlers.vip_get_subscription({
|
||||||
|
tenantId: req.tenantId,
|
||||||
|
userId: req.params.userId,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check model access
|
||||||
|
userRouter.get('/:userId/access/:modelId', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
res.json(await toolHandlers.vip_check_model_access({
|
||||||
|
tenantId: req.tenantId,
|
||||||
|
userId: req.params.userId,
|
||||||
|
modelId: req.params.modelId,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get current user's subscription (convenience)
|
||||||
|
userRouter.get('/me/subscription', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
res.json(await toolHandlers.vip_get_subscription({
|
||||||
|
tenantId: req.tenantId,
|
||||||
|
userId: req.userId,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check current user's model access
|
||||||
|
userRouter.get('/me/access/:modelId', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
res.json(await toolHandlers.vip_check_model_access({
|
||||||
|
tenantId: req.tenantId,
|
||||||
|
userId: req.userId,
|
||||||
|
modelId: req.params.modelId,
|
||||||
|
}));
|
||||||
|
} 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 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('Unhandled error', { error: err.message, stack: err.stack });
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SERVER STARTUP
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const server = app.listen(serverConfig.port, () => {
|
||||||
|
logger.info(`MCP VIP Server started`, { port: serverConfig.port });
|
||||||
|
console.log(`
|
||||||
|
╔════════════════════════════════════════════════════════════╗
|
||||||
|
║ MCP VIP SERVER ║
|
||||||
|
╠════════════════════════════════════════════════════════════╣
|
||||||
|
║ Port: ${serverConfig.port} ║
|
||||||
|
║ Tools: ${String(toolNames.length).padEnd(12)} ║
|
||||||
|
║ Tiers: GOLD | PLATINUM | DIAMOND ║
|
||||||
|
╠════════════════════════════════════════════════════════════╣
|
||||||
|
║ /api/v1/tiers, /api/v1/subscriptions/* ║
|
||||||
|
╚════════════════════════════════════════════════════════════╝
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const shutdown = async (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 };
|
||||||
99
src/middleware/auth.middleware.ts
Normal file
99
src/middleware/auth.middleware.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* Auth Middleware for MCP VIP
|
||||||
|
* Verifies JWT tokens and sets user context
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production-min-256-bits';
|
||||||
|
|
||||||
|
export interface JWTPayload {
|
||||||
|
sub: string;
|
||||||
|
email: string;
|
||||||
|
tenantId: string;
|
||||||
|
isOwner: boolean;
|
||||||
|
iat: number;
|
||||||
|
exp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace Express {
|
||||||
|
interface Request {
|
||||||
|
userId?: string;
|
||||||
|
tenantId?: string;
|
||||||
|
userEmail?: string;
|
||||||
|
isOwner?: boolean;
|
||||||
|
isAuthenticated?: boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof jwt.TokenExpiredError) {
|
||||||
|
res.status(401).json({ error: 'Unauthorized', code: 'TOKEN_EXPIRED' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (error instanceof jwt.JsonWebTokenError) {
|
||||||
|
res.status(401).json({ error: 'Unauthorized', code: 'INVALID_TOKEN' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('Auth middleware error', { error });
|
||||||
|
res.status(500).json({ error: 'Internal server error', code: 'AUTH_ERROR' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
1
src/middleware/index.ts
Normal file
1
src/middleware/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { authMiddleware, optionalAuthMiddleware } from './auth.middleware';
|
||||||
541
src/services/vip.service.ts
Normal file
541
src/services/vip.service.ts
Normal file
@ -0,0 +1,541 @@
|
|||||||
|
import { Pool } from 'pg';
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
import { getPool, setTenantContext, vipConfig, walletServiceConfig } from '../config';
|
||||||
|
import {
|
||||||
|
VipTier,
|
||||||
|
VipSubscription,
|
||||||
|
SubscriptionWithTier,
|
||||||
|
ModelAccess,
|
||||||
|
VipTierType,
|
||||||
|
SubscriptionStatus,
|
||||||
|
CreateSubscriptionInput,
|
||||||
|
UsageStats,
|
||||||
|
mapRowToTier,
|
||||||
|
mapRowToSubscription,
|
||||||
|
mapRowToModelAccess,
|
||||||
|
} from '../types/vip.types';
|
||||||
|
|
||||||
|
export class VipService {
|
||||||
|
private pool: Pool;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.pool = getPool();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== TIERS ====================
|
||||||
|
|
||||||
|
async getAllTiers(): Promise<VipTier[]> {
|
||||||
|
const result = await this.pool.query(
|
||||||
|
'SELECT * FROM vip.tiers WHERE is_active = TRUE ORDER BY sort_order ASC'
|
||||||
|
);
|
||||||
|
return result.rows.map(mapRowToTier);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTierById(tierId: string): Promise<VipTier | null> {
|
||||||
|
const result = await this.pool.query('SELECT * FROM vip.tiers WHERE id = $1', [tierId]);
|
||||||
|
return result.rows.length > 0 ? mapRowToTier(result.rows[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTierByType(tier: VipTierType): Promise<VipTier | null> {
|
||||||
|
const result = await this.pool.query(
|
||||||
|
'SELECT * FROM vip.tiers WHERE tier = $1 AND is_active = TRUE',
|
||||||
|
[tier]
|
||||||
|
);
|
||||||
|
return result.rows.length > 0 ? mapRowToTier(result.rows[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== SUBSCRIPTIONS ====================
|
||||||
|
|
||||||
|
async createSubscription(input: CreateSubscriptionInput): Promise<SubscriptionWithTier> {
|
||||||
|
const client = await this.pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
await setTenantContext(client, input.tenantId);
|
||||||
|
|
||||||
|
const tier = await this.getTierByType(input.tier);
|
||||||
|
if (!tier) {
|
||||||
|
throw new Error(`Tier not found: ${input.tier}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for existing active subscription
|
||||||
|
const existing = await client.query(
|
||||||
|
`SELECT id FROM vip.subscriptions
|
||||||
|
WHERE tenant_id = $1 AND user_id = $2 AND status IN ('active', 'trialing')`,
|
||||||
|
[input.tenantId, input.userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.rows.length > 0) {
|
||||||
|
throw new Error('User already has an active VIP subscription');
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const trialEnd = input.startTrial
|
||||||
|
? new Date(now.getTime() + vipConfig.trialDays * 24 * 60 * 60 * 1000)
|
||||||
|
: null;
|
||||||
|
const periodEnd = new Date(
|
||||||
|
now.getTime() + (input.interval === 'year' ? 365 : 30) * 24 * 60 * 60 * 1000
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await client.query(
|
||||||
|
`INSERT INTO vip.subscriptions (
|
||||||
|
tenant_id, user_id, tier_id,
|
||||||
|
stripe_subscription_id, stripe_customer_id,
|
||||||
|
status, interval,
|
||||||
|
current_period_start, current_period_end,
|
||||||
|
trial_start, trial_end
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
input.tenantId,
|
||||||
|
input.userId,
|
||||||
|
tier.id,
|
||||||
|
input.stripeSubscriptionId || null,
|
||||||
|
input.stripeCustomerId || null,
|
||||||
|
input.startTrial ? 'trialing' : 'active',
|
||||||
|
input.interval || 'month',
|
||||||
|
now,
|
||||||
|
periodEnd,
|
||||||
|
input.startTrial ? now : null,
|
||||||
|
trialEnd,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const subscription = mapRowToSubscription(result.rows[0]);
|
||||||
|
|
||||||
|
// Grant model access
|
||||||
|
for (const modelId of tier.includedModels) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO vip.model_access (subscription_id, model_id, model_name)
|
||||||
|
VALUES ($1, $2, $3)`,
|
||||||
|
[subscription.id, modelId, modelId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
return { ...subscription, tier };
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSubscription(userId: string, tenantId: string): Promise<SubscriptionWithTier | null> {
|
||||||
|
const client = await this.pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await setTenantContext(client, tenantId);
|
||||||
|
|
||||||
|
const result = await client.query(
|
||||||
|
`SELECT s.*, t.*,
|
||||||
|
s.id as subscription_id, t.id as tier_id
|
||||||
|
FROM vip.subscriptions s
|
||||||
|
JOIN vip.tiers t ON s.tier_id = t.id
|
||||||
|
WHERE s.user_id = $1 AND s.status IN ('active', 'trialing', 'past_due')
|
||||||
|
LIMIT 1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) return null;
|
||||||
|
|
||||||
|
const row = result.rows[0];
|
||||||
|
const subscription = mapRowToSubscription({ ...row, id: row.subscription_id });
|
||||||
|
const tier = mapRowToTier({ ...row, id: row.tier_id });
|
||||||
|
|
||||||
|
return { ...subscription, tier };
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSubscriptionById(subscriptionId: string, tenantId: string): Promise<SubscriptionWithTier | null> {
|
||||||
|
const client = await this.pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await setTenantContext(client, tenantId);
|
||||||
|
|
||||||
|
const result = await client.query(
|
||||||
|
`SELECT s.*, t.*,
|
||||||
|
s.id as subscription_id, t.id as tier_id
|
||||||
|
FROM vip.subscriptions s
|
||||||
|
JOIN vip.tiers t ON s.tier_id = t.id
|
||||||
|
WHERE s.id = $1`,
|
||||||
|
[subscriptionId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) return null;
|
||||||
|
|
||||||
|
const row = result.rows[0];
|
||||||
|
const subscription = mapRowToSubscription({ ...row, id: row.subscription_id });
|
||||||
|
const tier = mapRowToTier({ ...row, id: row.tier_id });
|
||||||
|
|
||||||
|
return { ...subscription, tier };
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelSubscription(
|
||||||
|
subscriptionId: string,
|
||||||
|
tenantId: string,
|
||||||
|
reason?: string,
|
||||||
|
cancelImmediately: boolean = false
|
||||||
|
): Promise<VipSubscription> {
|
||||||
|
const client = await this.pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await setTenantContext(client, tenantId);
|
||||||
|
|
||||||
|
const cancelAt = cancelImmediately ? new Date() : null;
|
||||||
|
const newStatus = cancelImmediately ? 'cancelled' : 'active';
|
||||||
|
|
||||||
|
const result = await client.query(
|
||||||
|
`UPDATE vip.subscriptions
|
||||||
|
SET status = $1,
|
||||||
|
cancel_at = COALESCE($2, current_period_end),
|
||||||
|
cancelled_at = NOW(),
|
||||||
|
cancel_reason = $3
|
||||||
|
WHERE id = $4
|
||||||
|
RETURNING *`,
|
||||||
|
[newStatus, cancelAt, reason || null, subscriptionId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new Error('Subscription not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapRowToSubscription(result.rows[0]);
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async reactivateSubscription(subscriptionId: string, tenantId: string): Promise<VipSubscription> {
|
||||||
|
const client = await this.pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await setTenantContext(client, tenantId);
|
||||||
|
|
||||||
|
const result = await client.query(
|
||||||
|
`UPDATE vip.subscriptions
|
||||||
|
SET status = 'active',
|
||||||
|
cancel_at = NULL,
|
||||||
|
cancelled_at = NULL,
|
||||||
|
cancel_reason = NULL
|
||||||
|
WHERE id = $1 AND status IN ('cancelled', 'past_due')
|
||||||
|
RETURNING *`,
|
||||||
|
[subscriptionId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new Error('Subscription not found or cannot be reactivated');
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapRowToSubscription(result.rows[0]);
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async upgradeTier(
|
||||||
|
subscriptionId: string,
|
||||||
|
newTier: VipTierType,
|
||||||
|
tenantId: string
|
||||||
|
): Promise<SubscriptionWithTier> {
|
||||||
|
const client = await this.pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
await setTenantContext(client, tenantId);
|
||||||
|
|
||||||
|
const tier = await this.getTierByType(newTier);
|
||||||
|
if (!tier) {
|
||||||
|
throw new Error(`Tier not found: ${newTier}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update subscription
|
||||||
|
const result = await client.query(
|
||||||
|
`UPDATE vip.subscriptions SET tier_id = $1 WHERE id = $2 RETURNING *`,
|
||||||
|
[tier.id, subscriptionId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new Error('Subscription not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = mapRowToSubscription(result.rows[0]);
|
||||||
|
|
||||||
|
// Update model access
|
||||||
|
await client.query(
|
||||||
|
'DELETE FROM vip.model_access WHERE subscription_id = $1',
|
||||||
|
[subscriptionId]
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const modelId of tier.includedModels) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO vip.model_access (subscription_id, model_id, model_name)
|
||||||
|
VALUES ($1, $2, $3)`,
|
||||||
|
[subscriptionId, modelId, modelId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
return { ...subscription, tier };
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== MODEL ACCESS ====================
|
||||||
|
|
||||||
|
async checkModelAccess(userId: string, modelId: string, tenantId: string): Promise<boolean> {
|
||||||
|
const client = await this.pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await setTenantContext(client, tenantId);
|
||||||
|
|
||||||
|
const result = await client.query(
|
||||||
|
`SELECT vip.check_vip_access($1, $2) as has_access`,
|
||||||
|
[userId, modelId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0]?.has_access || false;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getModelAccess(subscriptionId: string, tenantId: string): Promise<ModelAccess[]> {
|
||||||
|
const client = await this.pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await setTenantContext(client, tenantId);
|
||||||
|
|
||||||
|
const result = await client.query(
|
||||||
|
`SELECT * FROM vip.model_access WHERE subscription_id = $1`,
|
||||||
|
[subscriptionId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows.map(mapRowToModelAccess);
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async recordModelUsage(
|
||||||
|
subscriptionId: string,
|
||||||
|
modelId: string,
|
||||||
|
tenantId: string
|
||||||
|
): Promise<ModelAccess> {
|
||||||
|
const client = await this.pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await setTenantContext(client, tenantId);
|
||||||
|
|
||||||
|
const result = await client.query(
|
||||||
|
`UPDATE vip.model_access
|
||||||
|
SET predictions_generated = predictions_generated + 1,
|
||||||
|
daily_used = daily_used + 1,
|
||||||
|
monthly_used = monthly_used + 1,
|
||||||
|
last_used_at = NOW()
|
||||||
|
WHERE subscription_id = $1 AND model_id = $2
|
||||||
|
RETURNING *`,
|
||||||
|
[subscriptionId, modelId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new Error('Model access not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also update subscription usage
|
||||||
|
await client.query(
|
||||||
|
`UPDATE vip.subscriptions
|
||||||
|
SET vip_predictions_used = vip_predictions_used + 1
|
||||||
|
WHERE id = $1`,
|
||||||
|
[subscriptionId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return mapRowToModelAccess(result.rows[0]);
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== USAGE ====================
|
||||||
|
|
||||||
|
async getUsageStats(subscriptionId: string, tenantId: string): Promise<UsageStats | null> {
|
||||||
|
const client = await this.pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await setTenantContext(client, tenantId);
|
||||||
|
|
||||||
|
const result = await client.query(
|
||||||
|
`SELECT s.*, t.limits
|
||||||
|
FROM vip.subscriptions s
|
||||||
|
JOIN vip.tiers t ON s.tier_id = t.id
|
||||||
|
WHERE s.id = $1`,
|
||||||
|
[subscriptionId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) return null;
|
||||||
|
|
||||||
|
const row = result.rows[0];
|
||||||
|
const limits = row.limits as { vip_predictions_per_month: number; api_calls_per_month: number };
|
||||||
|
const periodEnd = new Date(row.current_period_end);
|
||||||
|
const now = new Date();
|
||||||
|
const daysRemaining = Math.max(0, Math.ceil((periodEnd.getTime() - now.getTime()) / (24 * 60 * 60 * 1000)));
|
||||||
|
|
||||||
|
const predictionsLimit = limits.vip_predictions_per_month === -1 ? Infinity : limits.vip_predictions_per_month;
|
||||||
|
const apiLimit = limits.api_calls_per_month === -1 ? Infinity : limits.api_calls_per_month;
|
||||||
|
|
||||||
|
return {
|
||||||
|
predictionsUsed: row.vip_predictions_used,
|
||||||
|
predictionsLimit,
|
||||||
|
predictionsRemaining: Math.max(0, predictionsLimit - row.vip_predictions_used),
|
||||||
|
apiCallsUsed: row.api_calls_this_period,
|
||||||
|
apiCallsLimit: apiLimit,
|
||||||
|
apiCallsRemaining: Math.max(0, apiLimit - row.api_calls_this_period),
|
||||||
|
periodStart: new Date(row.current_period_start),
|
||||||
|
periodEnd,
|
||||||
|
daysRemaining,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async incrementApiCalls(subscriptionId: string, tenantId: string): Promise<number> {
|
||||||
|
const client = await this.pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await setTenantContext(client, tenantId);
|
||||||
|
|
||||||
|
const result = await client.query(
|
||||||
|
`UPDATE vip.subscriptions
|
||||||
|
SET api_calls_this_period = api_calls_this_period + 1
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING api_calls_this_period`,
|
||||||
|
[subscriptionId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0]?.api_calls_this_period || 0;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetUsage(subscriptionId: string, tenantId: string): Promise<void> {
|
||||||
|
const client = await this.pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await setTenantContext(client, tenantId);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`UPDATE vip.subscriptions
|
||||||
|
SET vip_predictions_used = 0,
|
||||||
|
api_calls_this_period = 0,
|
||||||
|
last_usage_reset = NOW()
|
||||||
|
WHERE id = $1`,
|
||||||
|
[subscriptionId]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`UPDATE vip.model_access
|
||||||
|
SET daily_used = 0, monthly_used = 0
|
||||||
|
WHERE subscription_id = $1`,
|
||||||
|
[subscriptionId]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== STATUS MANAGEMENT ====================
|
||||||
|
|
||||||
|
async updateStatus(
|
||||||
|
subscriptionId: string,
|
||||||
|
status: SubscriptionStatus,
|
||||||
|
tenantId: string
|
||||||
|
): Promise<VipSubscription> {
|
||||||
|
const client = await this.pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await setTenantContext(client, tenantId);
|
||||||
|
|
||||||
|
const result = await client.query(
|
||||||
|
`UPDATE vip.subscriptions SET status = $1 WHERE id = $2 RETURNING *`,
|
||||||
|
[status, subscriptionId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new Error('Subscription not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapRowToSubscription(result.rows[0]);
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async renewPeriod(
|
||||||
|
subscriptionId: string,
|
||||||
|
tenantId: string,
|
||||||
|
interval: 'month' | 'year' = 'month'
|
||||||
|
): Promise<VipSubscription> {
|
||||||
|
const client = await this.pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await setTenantContext(client, tenantId);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const periodEnd = new Date(
|
||||||
|
now.getTime() + (interval === 'year' ? 365 : 30) * 24 * 60 * 60 * 1000
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await client.query(
|
||||||
|
`UPDATE vip.subscriptions
|
||||||
|
SET status = 'active',
|
||||||
|
current_period_start = NOW(),
|
||||||
|
current_period_end = $1,
|
||||||
|
vip_predictions_used = 0,
|
||||||
|
api_calls_this_period = 0,
|
||||||
|
last_usage_reset = NOW()
|
||||||
|
WHERE id = $2
|
||||||
|
RETURNING *`,
|
||||||
|
[periodEnd, subscriptionId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new Error('Subscription not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset model access usage
|
||||||
|
await client.query(
|
||||||
|
`UPDATE vip.model_access
|
||||||
|
SET daily_used = 0, monthly_used = 0
|
||||||
|
WHERE subscription_id = $1`,
|
||||||
|
[subscriptionId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return mapRowToSubscription(result.rows[0]);
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let vipServiceInstance: VipService | null = null;
|
||||||
|
|
||||||
|
export function getVipService(): VipService {
|
||||||
|
if (!vipServiceInstance) {
|
||||||
|
vipServiceInstance = new VipService();
|
||||||
|
}
|
||||||
|
return vipServiceInstance;
|
||||||
|
}
|
||||||
66
src/tools/index.ts
Normal file
66
src/tools/index.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
export {
|
||||||
|
vip_get_tiers,
|
||||||
|
vip_get_tier,
|
||||||
|
vip_create_subscription,
|
||||||
|
vip_get_subscription,
|
||||||
|
vip_cancel_subscription,
|
||||||
|
vip_reactivate_subscription,
|
||||||
|
vip_upgrade_tier,
|
||||||
|
vip_check_model_access,
|
||||||
|
vip_get_model_access,
|
||||||
|
vip_record_model_usage,
|
||||||
|
vip_get_usage_stats,
|
||||||
|
vip_update_status,
|
||||||
|
vip_renew_period,
|
||||||
|
handleVipGetTiers,
|
||||||
|
handleVipGetTier,
|
||||||
|
handleVipCreateSubscription,
|
||||||
|
handleVipGetSubscription,
|
||||||
|
handleVipCancelSubscription,
|
||||||
|
handleVipReactivateSubscription,
|
||||||
|
handleVipUpgradeTier,
|
||||||
|
handleVipCheckModelAccess,
|
||||||
|
handleVipGetModelAccess,
|
||||||
|
handleVipRecordModelUsage,
|
||||||
|
handleVipGetUsageStats,
|
||||||
|
handleVipUpdateStatus,
|
||||||
|
handleVipRenewPeriod,
|
||||||
|
vipToolSchemas,
|
||||||
|
} from './vip';
|
||||||
|
|
||||||
|
import { vipToolSchemas } from './vip';
|
||||||
|
import {
|
||||||
|
handleVipGetTiers,
|
||||||
|
handleVipGetTier,
|
||||||
|
handleVipCreateSubscription,
|
||||||
|
handleVipGetSubscription,
|
||||||
|
handleVipCancelSubscription,
|
||||||
|
handleVipReactivateSubscription,
|
||||||
|
handleVipUpgradeTier,
|
||||||
|
handleVipCheckModelAccess,
|
||||||
|
handleVipGetModelAccess,
|
||||||
|
handleVipRecordModelUsage,
|
||||||
|
handleVipGetUsageStats,
|
||||||
|
handleVipUpdateStatus,
|
||||||
|
handleVipRenewPeriod,
|
||||||
|
} from './vip';
|
||||||
|
|
||||||
|
export const allToolSchemas = vipToolSchemas;
|
||||||
|
|
||||||
|
export const toolHandlers: Record<string, (params: unknown) => Promise<{ content: Array<{ type: string; text: string }> }>> = {
|
||||||
|
vip_get_tiers: handleVipGetTiers,
|
||||||
|
vip_get_tier: handleVipGetTier,
|
||||||
|
vip_create_subscription: handleVipCreateSubscription,
|
||||||
|
vip_get_subscription: handleVipGetSubscription,
|
||||||
|
vip_cancel_subscription: handleVipCancelSubscription,
|
||||||
|
vip_reactivate_subscription: handleVipReactivateSubscription,
|
||||||
|
vip_upgrade_tier: handleVipUpgradeTier,
|
||||||
|
vip_check_model_access: handleVipCheckModelAccess,
|
||||||
|
vip_get_model_access: handleVipGetModelAccess,
|
||||||
|
vip_record_model_usage: handleVipRecordModelUsage,
|
||||||
|
vip_get_usage_stats: handleVipGetUsageStats,
|
||||||
|
vip_update_status: handleVipUpdateStatus,
|
||||||
|
vip_renew_period: handleVipRenewPeriod,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toolNames = Object.keys(allToolSchemas);
|
||||||
511
src/tools/vip.ts
Normal file
511
src/tools/vip.ts
Normal file
@ -0,0 +1,511 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { getVipService } from '../services/vip.service';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
import {
|
||||||
|
VipTier,
|
||||||
|
VipSubscription,
|
||||||
|
SubscriptionWithTier,
|
||||||
|
ModelAccess,
|
||||||
|
UsageStats,
|
||||||
|
VipTierType,
|
||||||
|
SubscriptionStatus,
|
||||||
|
} from '../types/vip.types';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// INPUT SCHEMAS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const GetTiersInputSchema = z.object({});
|
||||||
|
|
||||||
|
export const GetTierInputSchema = z.object({
|
||||||
|
tierId: z.string().uuid().optional(),
|
||||||
|
tier: z.enum(['GOLD', 'PLATINUM', 'DIAMOND'] as const).optional(),
|
||||||
|
}).refine(data => data.tierId || data.tier, { message: 'Either tierId or tier required' });
|
||||||
|
|
||||||
|
export const CreateSubscriptionInputSchema = z.object({
|
||||||
|
tenantId: z.string().uuid(),
|
||||||
|
userId: z.string().uuid(),
|
||||||
|
tier: z.enum(['GOLD', 'PLATINUM', 'DIAMOND'] as const),
|
||||||
|
interval: z.enum(['month', 'year'] as const).default('month'),
|
||||||
|
stripeSubscriptionId: z.string().optional(),
|
||||||
|
stripeCustomerId: z.string().optional(),
|
||||||
|
startTrial: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const GetSubscriptionInputSchema = z.object({
|
||||||
|
tenantId: z.string().uuid(),
|
||||||
|
userId: z.string().uuid().optional(),
|
||||||
|
subscriptionId: z.string().uuid().optional(),
|
||||||
|
}).refine(data => data.userId || data.subscriptionId, { message: 'Either userId or subscriptionId required' });
|
||||||
|
|
||||||
|
export const CancelSubscriptionInputSchema = z.object({
|
||||||
|
tenantId: z.string().uuid(),
|
||||||
|
subscriptionId: z.string().uuid(),
|
||||||
|
reason: z.string().optional(),
|
||||||
|
cancelImmediately: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ReactivateSubscriptionInputSchema = z.object({
|
||||||
|
tenantId: z.string().uuid(),
|
||||||
|
subscriptionId: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UpgradeTierInputSchema = z.object({
|
||||||
|
tenantId: z.string().uuid(),
|
||||||
|
subscriptionId: z.string().uuid(),
|
||||||
|
newTier: z.enum(['GOLD', 'PLATINUM', 'DIAMOND'] as const),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CheckModelAccessInputSchema = z.object({
|
||||||
|
tenantId: z.string().uuid(),
|
||||||
|
userId: z.string().uuid(),
|
||||||
|
modelId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const GetModelAccessInputSchema = z.object({
|
||||||
|
tenantId: z.string().uuid(),
|
||||||
|
subscriptionId: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const RecordModelUsageInputSchema = z.object({
|
||||||
|
tenantId: z.string().uuid(),
|
||||||
|
subscriptionId: z.string().uuid(),
|
||||||
|
modelId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const GetUsageStatsInputSchema = z.object({
|
||||||
|
tenantId: z.string().uuid(),
|
||||||
|
subscriptionId: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UpdateStatusInputSchema = z.object({
|
||||||
|
tenantId: z.string().uuid(),
|
||||||
|
subscriptionId: z.string().uuid(),
|
||||||
|
status: z.enum(['trialing', 'active', 'past_due', 'cancelled', 'expired'] as const),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const RenewPeriodInputSchema = z.object({
|
||||||
|
tenantId: z.string().uuid(),
|
||||||
|
subscriptionId: z.string().uuid(),
|
||||||
|
interval: z.enum(['month', 'year'] as const).default('month'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// RESULT TYPE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface ToolResult<T = unknown> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TOOL IMPLEMENTATIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function vip_get_tiers(): Promise<ToolResult<VipTier[]>> {
|
||||||
|
try {
|
||||||
|
const service = getVipService();
|
||||||
|
const tiers = await service.getAllTiers();
|
||||||
|
return { success: true, data: tiers };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('vip_get_tiers failed', { error });
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function vip_get_tier(params: z.infer<typeof GetTierInputSchema>): Promise<ToolResult<VipTier>> {
|
||||||
|
try {
|
||||||
|
const service = getVipService();
|
||||||
|
let tier: VipTier | null = null;
|
||||||
|
|
||||||
|
if (params.tierId) {
|
||||||
|
tier = await service.getTierById(params.tierId);
|
||||||
|
} else if (params.tier) {
|
||||||
|
tier = await service.getTierByType(params.tier);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tier) return { success: false, error: 'Tier not found' };
|
||||||
|
return { success: true, data: tier };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('vip_get_tier failed', { error, params });
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function vip_create_subscription(
|
||||||
|
params: z.infer<typeof CreateSubscriptionInputSchema>
|
||||||
|
): Promise<ToolResult<SubscriptionWithTier>> {
|
||||||
|
try {
|
||||||
|
const service = getVipService();
|
||||||
|
const subscription = await service.createSubscription(params);
|
||||||
|
return { success: true, data: subscription };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('vip_create_subscription failed', { error, params });
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function vip_get_subscription(
|
||||||
|
params: z.infer<typeof GetSubscriptionInputSchema>
|
||||||
|
): Promise<ToolResult<SubscriptionWithTier>> {
|
||||||
|
try {
|
||||||
|
const service = getVipService();
|
||||||
|
let subscription: SubscriptionWithTier | null = null;
|
||||||
|
|
||||||
|
if (params.subscriptionId) {
|
||||||
|
subscription = await service.getSubscriptionById(params.subscriptionId, params.tenantId);
|
||||||
|
} else if (params.userId) {
|
||||||
|
subscription = await service.getSubscription(params.userId, params.tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!subscription) return { success: false, error: 'Subscription not found' };
|
||||||
|
return { success: true, data: subscription };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('vip_get_subscription failed', { error, params });
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function vip_cancel_subscription(
|
||||||
|
params: z.infer<typeof CancelSubscriptionInputSchema>
|
||||||
|
): Promise<ToolResult<VipSubscription>> {
|
||||||
|
try {
|
||||||
|
const service = getVipService();
|
||||||
|
const subscription = await service.cancelSubscription(
|
||||||
|
params.subscriptionId,
|
||||||
|
params.tenantId,
|
||||||
|
params.reason,
|
||||||
|
params.cancelImmediately
|
||||||
|
);
|
||||||
|
return { success: true, data: subscription };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('vip_cancel_subscription failed', { error, params });
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function vip_reactivate_subscription(
|
||||||
|
params: z.infer<typeof ReactivateSubscriptionInputSchema>
|
||||||
|
): Promise<ToolResult<VipSubscription>> {
|
||||||
|
try {
|
||||||
|
const service = getVipService();
|
||||||
|
const subscription = await service.reactivateSubscription(params.subscriptionId, params.tenantId);
|
||||||
|
return { success: true, data: subscription };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('vip_reactivate_subscription failed', { error, params });
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function vip_upgrade_tier(
|
||||||
|
params: z.infer<typeof UpgradeTierInputSchema>
|
||||||
|
): Promise<ToolResult<SubscriptionWithTier>> {
|
||||||
|
try {
|
||||||
|
const service = getVipService();
|
||||||
|
const subscription = await service.upgradeTier(
|
||||||
|
params.subscriptionId,
|
||||||
|
params.newTier,
|
||||||
|
params.tenantId
|
||||||
|
);
|
||||||
|
return { success: true, data: subscription };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('vip_upgrade_tier failed', { error, params });
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function vip_check_model_access(
|
||||||
|
params: z.infer<typeof CheckModelAccessInputSchema>
|
||||||
|
): Promise<ToolResult<{ hasAccess: boolean }>> {
|
||||||
|
try {
|
||||||
|
const service = getVipService();
|
||||||
|
const hasAccess = await service.checkModelAccess(params.userId, params.modelId, params.tenantId);
|
||||||
|
return { success: true, data: { hasAccess } };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('vip_check_model_access failed', { error, params });
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function vip_get_model_access(
|
||||||
|
params: z.infer<typeof GetModelAccessInputSchema>
|
||||||
|
): Promise<ToolResult<ModelAccess[]>> {
|
||||||
|
try {
|
||||||
|
const service = getVipService();
|
||||||
|
const access = await service.getModelAccess(params.subscriptionId, params.tenantId);
|
||||||
|
return { success: true, data: access };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('vip_get_model_access failed', { error, params });
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function vip_record_model_usage(
|
||||||
|
params: z.infer<typeof RecordModelUsageInputSchema>
|
||||||
|
): Promise<ToolResult<ModelAccess>> {
|
||||||
|
try {
|
||||||
|
const service = getVipService();
|
||||||
|
const access = await service.recordModelUsage(
|
||||||
|
params.subscriptionId,
|
||||||
|
params.modelId,
|
||||||
|
params.tenantId
|
||||||
|
);
|
||||||
|
return { success: true, data: access };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('vip_record_model_usage failed', { error, params });
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function vip_get_usage_stats(
|
||||||
|
params: z.infer<typeof GetUsageStatsInputSchema>
|
||||||
|
): Promise<ToolResult<UsageStats>> {
|
||||||
|
try {
|
||||||
|
const service = getVipService();
|
||||||
|
const stats = await service.getUsageStats(params.subscriptionId, params.tenantId);
|
||||||
|
if (!stats) return { success: false, error: 'Subscription not found' };
|
||||||
|
return { success: true, data: stats };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('vip_get_usage_stats failed', { error, params });
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function vip_update_status(
|
||||||
|
params: z.infer<typeof UpdateStatusInputSchema>
|
||||||
|
): Promise<ToolResult<VipSubscription>> {
|
||||||
|
try {
|
||||||
|
const service = getVipService();
|
||||||
|
const subscription = await service.updateStatus(
|
||||||
|
params.subscriptionId,
|
||||||
|
params.status,
|
||||||
|
params.tenantId
|
||||||
|
);
|
||||||
|
return { success: true, data: subscription };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('vip_update_status failed', { error, params });
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function vip_renew_period(
|
||||||
|
params: z.infer<typeof RenewPeriodInputSchema>
|
||||||
|
): Promise<ToolResult<VipSubscription>> {
|
||||||
|
try {
|
||||||
|
const service = getVipService();
|
||||||
|
const subscription = await service.renewPeriod(
|
||||||
|
params.subscriptionId,
|
||||||
|
params.tenantId,
|
||||||
|
params.interval
|
||||||
|
);
|
||||||
|
return { success: true, data: subscription };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('vip_renew_period failed', { error, params });
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MCP TOOL SCHEMAS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const vipToolSchemas = {
|
||||||
|
vip_get_tiers: {
|
||||||
|
name: 'vip_get_tiers',
|
||||||
|
description: 'Get all available VIP tiers (Gold, Platinum, Diamond)',
|
||||||
|
inputSchema: { type: 'object', properties: {}, required: [] },
|
||||||
|
riskLevel: 'LOW',
|
||||||
|
},
|
||||||
|
vip_get_tier: {
|
||||||
|
name: 'vip_get_tier',
|
||||||
|
description: 'Get a specific VIP tier by ID or type',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
tierId: { type: 'string' },
|
||||||
|
tier: { type: 'string', enum: ['GOLD', 'PLATINUM', 'DIAMOND'] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
riskLevel: 'LOW',
|
||||||
|
},
|
||||||
|
vip_create_subscription: {
|
||||||
|
name: 'vip_create_subscription',
|
||||||
|
description: 'Create a new VIP subscription for a user',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
tenantId: { type: 'string' },
|
||||||
|
userId: { type: 'string' },
|
||||||
|
tier: { type: 'string', enum: ['GOLD', 'PLATINUM', 'DIAMOND'] },
|
||||||
|
interval: { type: 'string', enum: ['month', 'year'] },
|
||||||
|
stripeSubscriptionId: { type: 'string' },
|
||||||
|
stripeCustomerId: { type: 'string' },
|
||||||
|
startTrial: { type: 'boolean' },
|
||||||
|
},
|
||||||
|
required: ['tenantId', 'userId', 'tier'],
|
||||||
|
},
|
||||||
|
riskLevel: 'HIGH',
|
||||||
|
},
|
||||||
|
vip_get_subscription: {
|
||||||
|
name: 'vip_get_subscription',
|
||||||
|
description: 'Get user VIP subscription by user ID or subscription ID',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
tenantId: { type: 'string' },
|
||||||
|
userId: { type: 'string' },
|
||||||
|
subscriptionId: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['tenantId'],
|
||||||
|
},
|
||||||
|
riskLevel: 'LOW',
|
||||||
|
},
|
||||||
|
vip_cancel_subscription: {
|
||||||
|
name: 'vip_cancel_subscription',
|
||||||
|
description: 'Cancel a VIP subscription',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
tenantId: { type: 'string' },
|
||||||
|
subscriptionId: { type: 'string' },
|
||||||
|
reason: { type: 'string' },
|
||||||
|
cancelImmediately: { type: 'boolean' },
|
||||||
|
},
|
||||||
|
required: ['tenantId', 'subscriptionId'],
|
||||||
|
},
|
||||||
|
riskLevel: 'HIGH',
|
||||||
|
},
|
||||||
|
vip_reactivate_subscription: {
|
||||||
|
name: 'vip_reactivate_subscription',
|
||||||
|
description: 'Reactivate a cancelled VIP subscription',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
tenantId: { type: 'string' },
|
||||||
|
subscriptionId: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['tenantId', 'subscriptionId'],
|
||||||
|
},
|
||||||
|
riskLevel: 'HIGH',
|
||||||
|
},
|
||||||
|
vip_upgrade_tier: {
|
||||||
|
name: 'vip_upgrade_tier',
|
||||||
|
description: 'Upgrade subscription to a higher tier',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
tenantId: { type: 'string' },
|
||||||
|
subscriptionId: { type: 'string' },
|
||||||
|
newTier: { type: 'string', enum: ['GOLD', 'PLATINUM', 'DIAMOND'] },
|
||||||
|
},
|
||||||
|
required: ['tenantId', 'subscriptionId', 'newTier'],
|
||||||
|
},
|
||||||
|
riskLevel: 'HIGH',
|
||||||
|
},
|
||||||
|
vip_check_model_access: {
|
||||||
|
name: 'vip_check_model_access',
|
||||||
|
description: 'Check if user has VIP access to a specific ML model',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
tenantId: { type: 'string' },
|
||||||
|
userId: { type: 'string' },
|
||||||
|
modelId: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['tenantId', 'userId', 'modelId'],
|
||||||
|
},
|
||||||
|
riskLevel: 'LOW',
|
||||||
|
},
|
||||||
|
vip_get_model_access: {
|
||||||
|
name: 'vip_get_model_access',
|
||||||
|
description: 'Get all model access records for a subscription',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
tenantId: { type: 'string' },
|
||||||
|
subscriptionId: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['tenantId', 'subscriptionId'],
|
||||||
|
},
|
||||||
|
riskLevel: 'LOW',
|
||||||
|
},
|
||||||
|
vip_record_model_usage: {
|
||||||
|
name: 'vip_record_model_usage',
|
||||||
|
description: 'Record model usage for a VIP subscription',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
tenantId: { type: 'string' },
|
||||||
|
subscriptionId: { type: 'string' },
|
||||||
|
modelId: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['tenantId', 'subscriptionId', 'modelId'],
|
||||||
|
},
|
||||||
|
riskLevel: 'MEDIUM',
|
||||||
|
},
|
||||||
|
vip_get_usage_stats: {
|
||||||
|
name: 'vip_get_usage_stats',
|
||||||
|
description: 'Get usage statistics for a VIP subscription',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
tenantId: { type: 'string' },
|
||||||
|
subscriptionId: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['tenantId', 'subscriptionId'],
|
||||||
|
},
|
||||||
|
riskLevel: 'LOW',
|
||||||
|
},
|
||||||
|
vip_update_status: {
|
||||||
|
name: 'vip_update_status',
|
||||||
|
description: 'Update subscription status',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
tenantId: { type: 'string' },
|
||||||
|
subscriptionId: { type: 'string' },
|
||||||
|
status: { type: 'string', enum: ['trialing', 'active', 'past_due', 'cancelled', 'expired'] },
|
||||||
|
},
|
||||||
|
required: ['tenantId', 'subscriptionId', 'status'],
|
||||||
|
},
|
||||||
|
riskLevel: 'HIGH',
|
||||||
|
},
|
||||||
|
vip_renew_period: {
|
||||||
|
name: 'vip_renew_period',
|
||||||
|
description: 'Renew subscription period and reset usage',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
tenantId: { type: 'string' },
|
||||||
|
subscriptionId: { type: 'string' },
|
||||||
|
interval: { type: 'string', enum: ['month', 'year'] },
|
||||||
|
},
|
||||||
|
required: ['tenantId', 'subscriptionId'],
|
||||||
|
},
|
||||||
|
riskLevel: 'HIGH',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MCP HANDLERS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function formatMcpResponse(result: ToolResult): { content: Array<{ type: string; text: string }> } {
|
||||||
|
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handleVipGetTiers = async () => formatMcpResponse(await vip_get_tiers());
|
||||||
|
export const handleVipGetTier = async (p: unknown) => formatMcpResponse(await vip_get_tier(GetTierInputSchema.parse(p)));
|
||||||
|
export const handleVipCreateSubscription = async (p: unknown) => formatMcpResponse(await vip_create_subscription(CreateSubscriptionInputSchema.parse(p)));
|
||||||
|
export const handleVipGetSubscription = async (p: unknown) => formatMcpResponse(await vip_get_subscription(GetSubscriptionInputSchema.parse(p)));
|
||||||
|
export const handleVipCancelSubscription = async (p: unknown) => formatMcpResponse(await vip_cancel_subscription(CancelSubscriptionInputSchema.parse(p)));
|
||||||
|
export const handleVipReactivateSubscription = async (p: unknown) => formatMcpResponse(await vip_reactivate_subscription(ReactivateSubscriptionInputSchema.parse(p)));
|
||||||
|
export const handleVipUpgradeTier = async (p: unknown) => formatMcpResponse(await vip_upgrade_tier(UpgradeTierInputSchema.parse(p)));
|
||||||
|
export const handleVipCheckModelAccess = async (p: unknown) => formatMcpResponse(await vip_check_model_access(CheckModelAccessInputSchema.parse(p)));
|
||||||
|
export const handleVipGetModelAccess = async (p: unknown) => formatMcpResponse(await vip_get_model_access(GetModelAccessInputSchema.parse(p)));
|
||||||
|
export const handleVipRecordModelUsage = async (p: unknown) => formatMcpResponse(await vip_record_model_usage(RecordModelUsageInputSchema.parse(p)));
|
||||||
|
export const handleVipGetUsageStats = async (p: unknown) => formatMcpResponse(await vip_get_usage_stats(GetUsageStatsInputSchema.parse(p)));
|
||||||
|
export const handleVipUpdateStatus = async (p: unknown) => formatMcpResponse(await vip_update_status(UpdateStatusInputSchema.parse(p)));
|
||||||
|
export const handleVipRenewPeriod = async (p: unknown) => formatMcpResponse(await vip_renew_period(RenewPeriodInputSchema.parse(p)));
|
||||||
178
src/types/vip.types.ts
Normal file
178
src/types/vip.types.ts
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
import Decimal from 'decimal.js';
|
||||||
|
|
||||||
|
export type VipTierType = 'GOLD' | 'PLATINUM' | 'DIAMOND';
|
||||||
|
|
||||||
|
export type SubscriptionStatus = 'trialing' | 'active' | 'past_due' | 'cancelled' | 'expired';
|
||||||
|
|
||||||
|
export interface TierBenefit {
|
||||||
|
name: string;
|
||||||
|
included?: boolean;
|
||||||
|
value?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TierLimits {
|
||||||
|
vip_predictions_per_month: number;
|
||||||
|
api_calls_per_month: number;
|
||||||
|
exclusive_models: string[];
|
||||||
|
priority_support?: boolean;
|
||||||
|
custom_alerts?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VipTier {
|
||||||
|
id: string;
|
||||||
|
tier: VipTierType;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
tagline: string | null;
|
||||||
|
priceMonthly: Decimal;
|
||||||
|
priceYearly: Decimal | null;
|
||||||
|
currency: string;
|
||||||
|
stripeProductId: string | null;
|
||||||
|
stripePriceMonthlyId: string | null;
|
||||||
|
stripePriceYearlyId: string | null;
|
||||||
|
benefits: TierBenefit[];
|
||||||
|
limits: TierLimits;
|
||||||
|
includedModels: string[];
|
||||||
|
color: string | null;
|
||||||
|
icon: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
isPopular: boolean;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VipSubscription {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
userId: string;
|
||||||
|
tierId: string;
|
||||||
|
stripeSubscriptionId: string | null;
|
||||||
|
stripeCustomerId: string | null;
|
||||||
|
status: SubscriptionStatus;
|
||||||
|
interval: string;
|
||||||
|
currentPeriodStart: Date | null;
|
||||||
|
currentPeriodEnd: Date | null;
|
||||||
|
trialStart: Date | null;
|
||||||
|
trialEnd: Date | null;
|
||||||
|
cancelAt: Date | null;
|
||||||
|
cancelledAt: Date | null;
|
||||||
|
cancelReason: string | null;
|
||||||
|
vipPredictionsUsed: number;
|
||||||
|
apiCallsThisPeriod: number;
|
||||||
|
lastUsageReset: Date;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionWithTier extends VipSubscription {
|
||||||
|
tier: VipTier;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelAccess {
|
||||||
|
id: string;
|
||||||
|
subscriptionId: string;
|
||||||
|
modelId: string;
|
||||||
|
modelName: string | null;
|
||||||
|
predictionsGenerated: number;
|
||||||
|
lastUsedAt: Date | null;
|
||||||
|
dailyLimit: number | null;
|
||||||
|
monthlyLimit: number | null;
|
||||||
|
dailyUsed: number;
|
||||||
|
monthlyUsed: number;
|
||||||
|
grantedAt: Date;
|
||||||
|
expiresAt: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSubscriptionInput {
|
||||||
|
tenantId: string;
|
||||||
|
userId: string;
|
||||||
|
tier: VipTierType;
|
||||||
|
interval?: 'month' | 'year';
|
||||||
|
stripeSubscriptionId?: string;
|
||||||
|
stripeCustomerId?: string;
|
||||||
|
startTrial?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageStats {
|
||||||
|
predictionsUsed: number;
|
||||||
|
predictionsLimit: number;
|
||||||
|
predictionsRemaining: number;
|
||||||
|
apiCallsUsed: number;
|
||||||
|
apiCallsLimit: number;
|
||||||
|
apiCallsRemaining: number;
|
||||||
|
periodStart: Date;
|
||||||
|
periodEnd: Date;
|
||||||
|
daysRemaining: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapRowToTier(row: Record<string, unknown>): VipTier {
|
||||||
|
return {
|
||||||
|
id: row.id as string,
|
||||||
|
tier: row.tier as VipTierType,
|
||||||
|
name: row.name as string,
|
||||||
|
description: row.description as string | null,
|
||||||
|
tagline: row.tagline as string | null,
|
||||||
|
priceMonthly: new Decimal(row.price_monthly as string),
|
||||||
|
priceYearly: row.price_yearly ? new Decimal(row.price_yearly as string) : null,
|
||||||
|
currency: row.currency as string,
|
||||||
|
stripeProductId: row.stripe_product_id as string | null,
|
||||||
|
stripePriceMonthlyId: row.stripe_price_monthly_id as string | null,
|
||||||
|
stripePriceYearlyId: row.stripe_price_yearly_id as string | null,
|
||||||
|
benefits: (row.benefits as TierBenefit[]) || [],
|
||||||
|
limits: (row.limits as TierLimits) || { vip_predictions_per_month: 0, api_calls_per_month: 0, exclusive_models: [] },
|
||||||
|
includedModels: (row.included_models as string[]) || [],
|
||||||
|
color: row.color as string | null,
|
||||||
|
icon: row.icon as string | null,
|
||||||
|
sortOrder: row.sort_order as number,
|
||||||
|
isPopular: row.is_popular as boolean,
|
||||||
|
isActive: row.is_active as boolean,
|
||||||
|
createdAt: new Date(row.created_at as string),
|
||||||
|
updatedAt: new Date(row.updated_at as string),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapRowToSubscription(row: Record<string, unknown>): VipSubscription {
|
||||||
|
return {
|
||||||
|
id: row.id as string,
|
||||||
|
tenantId: row.tenant_id as string,
|
||||||
|
userId: row.user_id as string,
|
||||||
|
tierId: row.tier_id as string,
|
||||||
|
stripeSubscriptionId: row.stripe_subscription_id as string | null,
|
||||||
|
stripeCustomerId: row.stripe_customer_id as string | null,
|
||||||
|
status: row.status as SubscriptionStatus,
|
||||||
|
interval: row.interval as string,
|
||||||
|
currentPeriodStart: row.current_period_start ? new Date(row.current_period_start as string) : null,
|
||||||
|
currentPeriodEnd: row.current_period_end ? new Date(row.current_period_end as string) : null,
|
||||||
|
trialStart: row.trial_start ? new Date(row.trial_start as string) : null,
|
||||||
|
trialEnd: row.trial_end ? new Date(row.trial_end as string) : null,
|
||||||
|
cancelAt: row.cancel_at ? new Date(row.cancel_at as string) : null,
|
||||||
|
cancelledAt: row.cancelled_at ? new Date(row.cancelled_at as string) : null,
|
||||||
|
cancelReason: row.cancel_reason as string | null,
|
||||||
|
vipPredictionsUsed: row.vip_predictions_used as number,
|
||||||
|
apiCallsThisPeriod: row.api_calls_this_period as number,
|
||||||
|
lastUsageReset: new Date(row.last_usage_reset as string),
|
||||||
|
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 mapRowToModelAccess(row: Record<string, unknown>): ModelAccess {
|
||||||
|
return {
|
||||||
|
id: row.id as string,
|
||||||
|
subscriptionId: row.subscription_id as string,
|
||||||
|
modelId: row.model_id as string,
|
||||||
|
modelName: row.model_name as string | null,
|
||||||
|
predictionsGenerated: row.predictions_generated as number,
|
||||||
|
lastUsedAt: row.last_used_at ? new Date(row.last_used_at as string) : null,
|
||||||
|
dailyLimit: row.daily_limit as number | null,
|
||||||
|
monthlyLimit: row.monthly_limit as number | null,
|
||||||
|
dailyUsed: row.daily_used as number,
|
||||||
|
monthlyUsed: row.monthly_used as number,
|
||||||
|
grantedAt: new Date(row.granted_at as string),
|
||||||
|
expiresAt: row.expires_at ? new Date(row.expires_at as string) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
28
src/utils/logger.ts
Normal file
28
src/utils/logger.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import winston from 'winston';
|
||||||
|
import { serverConfig } from '../config';
|
||||||
|
|
||||||
|
const { combine, timestamp, printf, colorize, errors } = winston.format;
|
||||||
|
|
||||||
|
const consoleFormat = printf(({ level, message, timestamp, ...meta }) => {
|
||||||
|
let msg = `${timestamp} [${level}]: ${message}`;
|
||||||
|
if (Object.keys(meta).length > 0) msg += ` ${JSON.stringify(meta)}`;
|
||||||
|
return msg;
|
||||||
|
});
|
||||||
|
|
||||||
|
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-vip' },
|
||||||
|
transports: [
|
||||||
|
new winston.transports.Console({
|
||||||
|
format: combine(
|
||||||
|
serverConfig.nodeEnv === 'development' ? colorize() : winston.format.uncolorize(),
|
||||||
|
consoleFormat
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
19
tsconfig.json
Normal file
19
tsconfig.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"moduleResolution": "node"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user