refactored monorepo for more projects

This commit is contained in:
Boki 2025-06-22 23:48:01 -04:00
parent 4632c174dc
commit 9492f1b15e
180 changed files with 1438 additions and 424 deletions

View file

@ -0,0 +1,34 @@
/**
* Service Container Setup for Web API
* Configures dependency injection for the web API service
*/
import type { IServiceContainer } from '@stock-bot/handlers';
import { getLogger } from '@stock-bot/logger';
import type { AppConfig } from '@stock-bot/config';
const logger = getLogger('web-api-container');
/**
* Configure the service container for web API workloads
*/
export function setupServiceContainer(
config: AppConfig,
container: IServiceContainer
): IServiceContainer {
logger.info('Configuring web API service container...');
// Web API specific configuration
// This service mainly reads data, so smaller pool sizes are fine
const poolSizes = {
mongodb: config.environment === 'production' ? 20 : 10,
postgres: config.environment === 'production' ? 30 : 15,
cache: config.environment === 'production' ? 20 : 10,
};
logger.info('Web API pool sizes configured', poolSizes);
// The container is already configured with connections
// Just return it with our logging
return container;
}

View file

@ -0,0 +1,78 @@
/**
* Stock Bot Web API
* Simplified entry point using ServiceApplication framework
*/
import { initializeStockConfig } from '@stock-bot/stock-config';
import {
ServiceApplication,
createServiceContainerFromConfig,
initializeServices as initializeAwilixServices,
} from '@stock-bot/di';
import { getLogger } from '@stock-bot/logger';
// Local imports
import { createRoutes } from './routes/create-routes';
import { setupServiceContainer } from './container-setup';
// Initialize configuration with service-specific overrides
const config = initializeStockConfig('webApi');
console.log('Web API Service Configuration:', JSON.stringify(config, null, 2));
// Create service application
const app = new ServiceApplication(
config,
{
serviceName: 'web-api',
enableHandlers: false, // Web API doesn't use handlers
enableScheduledJobs: false, // Web API doesn't use scheduled jobs
corsConfig: {
origin: ['http://localhost:4200', 'http://localhost:3000', 'http://localhost:3002'],
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'],
credentials: true,
},
serviceMetadata: {
version: '1.0.0',
description: 'Stock Bot REST API',
endpoints: {
health: '/health',
exchanges: '/api/exchanges',
},
},
},
{
// Custom lifecycle hooks
onContainerReady: (container) => {
// Setup service-specific configuration
const enhancedContainer = setupServiceContainer(config, container);
return enhancedContainer;
},
onStarted: (port) => {
const logger = getLogger('web-api');
logger.info('Web API service startup initiated with ServiceApplication framework');
},
}
);
// Container factory function
async function createContainer(config: any) {
const container = createServiceContainerFromConfig(config, {
enableQuestDB: false, // Web API doesn't need QuestDB
enableMongoDB: true,
enablePostgres: true,
enableCache: true,
enableQueue: false, // Web API doesn't need queue processing
enableBrowser: false, // Web API doesn't need browser
enableProxy: false, // Web API doesn't need proxy
});
await initializeAwilixServices(container);
return container;
}
// Start the service
app.start(createContainer, createRoutes).catch(error => {
const logger = getLogger('web-api');
logger.fatal('Failed to start web API service', { error });
process.exit(1);
});

View file

@ -0,0 +1,23 @@
/**
* Route factory for web API service
* Creates routes with access to the service container
*/
import { Hono } from 'hono';
import type { IServiceContainer } from '@stock-bot/handlers';
import { createHealthRoutes } from './health.routes';
import { createExchangeRoutes } from './exchange.routes';
export function createRoutes(container: IServiceContainer): Hono {
const app = new Hono();
// Create routes with container
const healthRoutes = createHealthRoutes(container);
const exchangeRoutes = createExchangeRoutes(container);
// Mount routes
app.route('/health', healthRoutes);
app.route('/api/exchanges', exchangeRoutes);
return app;
}

View file

@ -0,0 +1,262 @@
/**
* Exchange management routes - Refactored
*/
import { Hono } from 'hono';
import { getLogger } from '@stock-bot/logger';
import type { IServiceContainer } from '@stock-bot/handlers';
import { createExchangeService } from '../services/exchange.service';
import { createSuccessResponse, handleError } from '../utils/error-handler';
import {
validateCreateExchange,
validateCreateProviderMapping,
validateUpdateExchange,
validateUpdateProviderMapping,
} from '../utils/validation';
const logger = getLogger('exchange-routes');
export function createExchangeRoutes(container: IServiceContainer) {
const exchangeRoutes = new Hono();
const exchangeService = createExchangeService(container);
// Get all exchanges with provider mapping counts and mappings
exchangeRoutes.get('/', async c => {
logger.debug('Getting all exchanges');
try {
const exchanges = await exchangeService.getAllExchanges();
logger.info('Successfully retrieved exchanges', { count: exchanges.length });
return c.json(createSuccessResponse(exchanges, undefined, exchanges.length));
} catch (error) {
logger.error('Failed to get exchanges', { error });
return handleError(c, error, 'to get exchanges');
}
});
// Get exchange by ID with detailed provider mappings
exchangeRoutes.get('/:id', async c => {
const exchangeId = c.req.param('id');
logger.debug('Getting exchange by ID', { exchangeId });
try {
const result = await exchangeService.getExchangeById(exchangeId);
if (!result) {
logger.warn('Exchange not found', { exchangeId });
return c.json(createSuccessResponse(null, 'Exchange not found'), 404);
}
logger.info('Successfully retrieved exchange details', {
exchangeId,
exchangeCode: result.exchange.code,
mappingCount: result.provider_mappings.length,
});
return c.json(createSuccessResponse(result));
} catch (error) {
logger.error('Failed to get exchange details', { error, exchangeId });
return handleError(c, error, 'to get exchange details');
}
});
// Create new exchange
exchangeRoutes.post('/', async c => {
logger.debug('Creating new exchange');
try {
const body = await c.req.json();
logger.debug('Received exchange creation request', { requestBody: body });
const validatedData = validateCreateExchange(body);
logger.debug('Exchange data validated successfully', { validatedData });
const exchange = await exchangeService.createExchange(validatedData);
logger.info('Exchange created successfully', {
exchangeId: exchange.id,
code: exchange.code,
name: exchange.name,
});
return c.json(createSuccessResponse(exchange, 'Exchange created successfully'), 201);
} catch (error) {
logger.error('Failed to create exchange', { error });
return handleError(c, error, 'to create exchange');
}
});
// Update exchange (activate/deactivate, rename, etc.)
exchangeRoutes.patch('/:id', async c => {
const exchangeId = c.req.param('id');
logger.debug('Updating exchange', { exchangeId });
try {
const body = await c.req.json();
logger.debug('Received exchange update request', { exchangeId, updates: body });
const validatedUpdates = validateUpdateExchange(body);
logger.debug('Exchange update data validated', { exchangeId, validatedUpdates });
const exchange = await exchangeService.updateExchange(exchangeId, validatedUpdates);
if (!exchange) {
logger.warn('Exchange not found for update', { exchangeId });
return c.json(createSuccessResponse(null, 'Exchange not found'), 404);
}
logger.info('Exchange updated successfully', {
exchangeId,
code: exchange.code,
updates: validatedUpdates,
});
// Log special actions
if (validatedUpdates.visible === false) {
logger.warn('Exchange marked as hidden - provider mappings will be deleted', {
exchangeId,
code: exchange.code,
});
}
return c.json(createSuccessResponse(exchange, 'Exchange updated successfully'));
} catch (error) {
logger.error('Failed to update exchange', { error, exchangeId });
return handleError(c, error, 'to update exchange');
}
});
// Get all provider mappings
exchangeRoutes.get('/provider-mappings/all', async c => {
logger.debug('Getting all provider mappings');
try {
const mappings = await exchangeService.getAllProviderMappings();
logger.info('Successfully retrieved all provider mappings', { count: mappings.length });
return c.json(createSuccessResponse(mappings, undefined, mappings.length));
} catch (error) {
logger.error('Failed to get provider mappings', { error });
return handleError(c, error, 'to get provider mappings');
}
});
// Get provider mappings by provider
exchangeRoutes.get('/provider-mappings/:provider', async c => {
const provider = c.req.param('provider');
logger.debug('Getting provider mappings by provider', { provider });
try {
const mappings = await exchangeService.getProviderMappingsByProvider(provider);
logger.info('Successfully retrieved provider mappings', { provider, count: mappings.length });
return c.json(createSuccessResponse(mappings, undefined, mappings.length));
} catch (error) {
logger.error('Failed to get provider mappings', { error, provider });
return handleError(c, error, 'to get provider mappings');
}
});
// Update provider mapping (activate/deactivate, verify, change confidence)
exchangeRoutes.patch('/provider-mappings/:id', async c => {
const mappingId = c.req.param('id');
logger.debug('Updating provider mapping', { mappingId });
try {
const body = await c.req.json();
logger.debug('Received provider mapping update request', { mappingId, updates: body });
const validatedUpdates = validateUpdateProviderMapping(body);
logger.debug('Provider mapping update data validated', { mappingId, validatedUpdates });
const mapping = await exchangeService.updateProviderMapping(mappingId, validatedUpdates);
if (!mapping) {
logger.warn('Provider mapping not found for update', { mappingId });
return c.json(createSuccessResponse(null, 'Provider mapping not found'), 404);
}
logger.info('Provider mapping updated successfully', {
mappingId,
provider: mapping.provider,
providerExchangeCode: mapping.provider_exchange_code,
updates: validatedUpdates,
});
return c.json(createSuccessResponse(mapping, 'Provider mapping updated successfully'));
} catch (error) {
logger.error('Failed to update provider mapping', { error, mappingId });
return handleError(c, error, 'to update provider mapping');
}
});
// Create new provider mapping
exchangeRoutes.post('/provider-mappings', async c => {
logger.debug('Creating new provider mapping');
try {
const body = await c.req.json();
logger.debug('Received provider mapping creation request', { requestBody: body });
const validatedData = validateCreateProviderMapping(body);
logger.debug('Provider mapping data validated successfully', { validatedData });
const mapping = await exchangeService.createProviderMapping(validatedData);
logger.info('Provider mapping created successfully', {
mappingId: mapping.id,
provider: mapping.provider,
providerExchangeCode: mapping.provider_exchange_code,
masterExchangeId: mapping.master_exchange_id,
});
return c.json(createSuccessResponse(mapping, 'Provider mapping created successfully'), 201);
} catch (error) {
logger.error('Failed to create provider mapping', { error });
return handleError(c, error, 'to create provider mapping');
}
});
// Get all available providers
exchangeRoutes.get('/providers/list', async c => {
logger.debug('Getting providers list');
try {
const providers = await exchangeService.getProviders();
logger.info('Successfully retrieved providers list', { count: providers.length, providers });
return c.json(createSuccessResponse(providers));
} catch (error) {
logger.error('Failed to get providers list', { error });
return handleError(c, error, 'to get providers list');
}
});
// Get unmapped provider exchanges by provider
exchangeRoutes.get('/provider-exchanges/unmapped/:provider', async c => {
const provider = c.req.param('provider');
logger.debug('Getting unmapped provider exchanges', { provider });
try {
const exchanges = await exchangeService.getUnmappedProviderExchanges(provider);
logger.info('Successfully retrieved unmapped provider exchanges', {
provider,
count: exchanges.length,
});
return c.json(createSuccessResponse(exchanges, undefined, exchanges.length));
} catch (error) {
logger.error('Failed to get unmapped provider exchanges', { error, provider });
return handleError(c, error, 'to get unmapped provider exchanges');
}
});
// Get exchange statistics
exchangeRoutes.get('/stats/summary', async c => {
logger.debug('Getting exchange statistics');
try {
const stats = await exchangeService.getExchangeStats();
logger.info('Successfully retrieved exchange statistics', { stats });
return c.json(createSuccessResponse(stats));
} catch (error) {
logger.error('Failed to get exchange statistics', { error });
return handleError(c, error, 'to get exchange statistics');
}
});
return exchangeRoutes;
}

View file

@ -0,0 +1,111 @@
/**
* Health check routes factory
*/
import { Hono } from 'hono';
import { getLogger } from '@stock-bot/logger';
import type { IServiceContainer } from '@stock-bot/handlers';
const logger = getLogger('health-routes');
export function createHealthRoutes(container: IServiceContainer) {
const healthRoutes = new Hono();
// Basic health check
healthRoutes.get('/', c => {
logger.debug('Basic health check requested');
const response = {
status: 'healthy',
service: 'web-api',
timestamp: new Date().toISOString(),
};
logger.info('Basic health check successful', { status: response.status });
return c.json(response);
});
// Detailed health check with database connectivity
healthRoutes.get('/detailed', async c => {
logger.debug('Detailed health check requested');
const health = {
status: 'healthy',
service: 'web-api',
timestamp: new Date().toISOString(),
checks: {
mongodb: { status: 'unknown', message: '' },
postgresql: { status: 'unknown', message: '' },
},
};
// Check MongoDB
logger.debug('Checking MongoDB connectivity');
try {
const mongoClient = container.mongodb;
if (mongoClient && mongoClient.connected) {
// Try a simple operation
const db = mongoClient.getDatabase();
await db.admin().ping();
health.checks.mongodb = { status: 'healthy', message: 'Connected and responsive' };
logger.debug('MongoDB health check passed');
} else {
health.checks.mongodb = { status: 'unhealthy', message: 'Not connected' };
logger.warn('MongoDB health check failed - not connected');
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
health.checks.mongodb = {
status: 'unhealthy',
message: errorMessage,
};
logger.error('MongoDB health check failed', { error: errorMessage });
}
// Check PostgreSQL
logger.debug('Checking PostgreSQL connectivity');
try {
const postgresClient = container.postgres;
if (postgresClient) {
await postgresClient.query('SELECT 1');
health.checks.postgresql = { status: 'healthy', message: 'Connected and responsive' };
logger.debug('PostgreSQL health check passed');
} else {
health.checks.postgresql = { status: 'unhealthy', message: 'PostgreSQL client not available' };
logger.warn('PostgreSQL health check failed - client not available');
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
health.checks.postgresql = {
status: 'unhealthy',
message: errorMessage,
};
logger.error('PostgreSQL health check failed', { error: errorMessage });
}
// Overall status
const allHealthy = Object.values(health.checks).every(check => check.status === 'healthy');
health.status = allHealthy ? 'healthy' : 'unhealthy';
const statusCode = allHealthy ? 200 : 503;
if (allHealthy) {
logger.info('Detailed health check successful - all systems healthy', {
mongodb: health.checks.mongodb.status,
postgresql: health.checks.postgresql.status,
});
} else {
logger.warn('Detailed health check failed - some systems unhealthy', {
mongodb: health.checks.mongodb.status,
postgresql: health.checks.postgresql.status,
overallStatus: health.status,
});
}
return c.json(health, statusCode);
});
return healthRoutes;
}
// Export legacy routes for backward compatibility during migration
export const healthRoutes = createHealthRoutes({} as IServiceContainer);

View file

@ -0,0 +1,5 @@
/**
* Routes index - exports all route modules
*/
export { createExchangeRoutes } from './exchange.routes';
export { healthRoutes } from './health.routes';

View file

@ -0,0 +1,383 @@
import { getLogger } from '@stock-bot/logger';
import type { IServiceContainer } from '@stock-bot/handlers';
import {
CreateExchangeRequest,
CreateProviderMappingRequest,
Exchange,
ExchangeStats,
ExchangeWithMappings,
ProviderExchange,
ProviderMapping,
UpdateExchangeRequest,
UpdateProviderMappingRequest,
} from '../types/exchange.types';
const logger = getLogger('exchange-service');
export class ExchangeService {
constructor(private container: IServiceContainer) {}
private get postgresClient() {
return this.container.postgres;
}
private get mongoClient() {
return this.container.mongodb;
}
// Exchanges
async getAllExchanges(): Promise<ExchangeWithMappings[]> {
const exchangesQuery = `
SELECT
e.id,
e.code,
e.name,
e.country,
e.currency,
e.active,
e.visible,
e.created_at,
e.updated_at,
COUNT(pem.id) as provider_mapping_count,
COUNT(CASE WHEN pem.active = true THEN 1 END) as active_mapping_count,
COUNT(CASE WHEN pem.verified = true THEN 1 END) as verified_mapping_count,
STRING_AGG(DISTINCT pem.provider, ', ') as providers
FROM exchanges e
LEFT JOIN provider_exchange_mappings pem ON e.id = pem.master_exchange_id
WHERE e.visible = true
GROUP BY e.id, e.code, e.name, e.country, e.currency, e.active, e.visible, e.created_at, e.updated_at
ORDER BY e.code
`;
const exchangesResult = await this.postgresClient.query(exchangesQuery);
// Get all provider mappings
const mappingsQuery = `
SELECT
pem.*,
e.code as master_exchange_code,
e.name as master_exchange_name
FROM provider_exchange_mappings pem
JOIN exchanges e ON pem.master_exchange_id = e.id
WHERE e.visible = true
ORDER BY pem.master_exchange_id, pem.provider, pem.provider_exchange_code
`;
const mappingsResult = await this.postgresClient.query(mappingsQuery);
// Group mappings by exchange ID
const mappingsByExchange = mappingsResult.rows.reduce(
(acc, mapping) => {
const exchangeId = mapping.master_exchange_id;
if (!acc[exchangeId]) {
acc[exchangeId] = [];
}
acc[exchangeId].push(mapping);
return acc;
},
{} as Record<string, ProviderMapping[]>
);
// Attach mappings to exchanges
return exchangesResult.rows.map(exchange => ({
...exchange,
provider_mappings: mappingsByExchange[exchange.id] || [],
}));
}
async getExchangeById(
id: string
): Promise<{ exchange: Exchange; provider_mappings: ProviderMapping[] } | null> {
const exchangeQuery = 'SELECT * FROM exchanges WHERE id = $1 AND visible = true';
const exchangeResult = await this.postgresClient.query(exchangeQuery, [id]);
if (exchangeResult.rows.length === 0) {
return null;
}
const mappingsQuery = `
SELECT
pem.*,
e.code as master_exchange_code,
e.name as master_exchange_name
FROM provider_exchange_mappings pem
JOIN exchanges e ON pem.master_exchange_id = e.id
WHERE pem.master_exchange_id = $1
ORDER BY pem.provider, pem.provider_exchange_code
`;
const mappingsResult = await this.postgresClient.query(mappingsQuery, [id]);
return {
exchange: exchangeResult.rows[0],
provider_mappings: mappingsResult.rows,
};
}
async createExchange(data: CreateExchangeRequest): Promise<Exchange> {
const query = `
INSERT INTO exchanges (code, name, country, currency, active, visible)
VALUES ($1, $2, $3, $4, $5, true)
RETURNING *
`;
const result = await this.postgresClient.query(query, [
data.code,
data.name,
data.country,
data.currency,
data.active,
]);
logger.info('Exchange created', {
exchangeId: result.rows[0].id,
code: data.code,
name: data.name,
});
return result.rows[0];
}
async updateExchange(id: string, updates: UpdateExchangeRequest): Promise<Exchange | null> {
const updateFields = [];
const values = [];
let paramIndex = 1;
Object.entries(updates).forEach(([key, value]) => {
updateFields.push(`${key} = $${paramIndex++}`);
values.push(value);
});
updateFields.push(`updated_at = NOW()`);
values.push(id);
const query = `
UPDATE exchanges
SET ${updateFields.join(', ')}
WHERE id = $${paramIndex}
RETURNING *
`;
const result = await this.postgresClient.query(query, values);
if (result.rows.length === 0) {
return null;
}
// If hiding an exchange, delete its provider mappings
if (updates.visible === false) {
await this.deleteProviderMappingsByExchangeId(id);
}
logger.info('Exchange updated', { exchangeId: id, updates });
return result.rows[0];
}
// Provider Mappings
async getAllProviderMappings(): Promise<ProviderMapping[]> {
const query = `
SELECT
pem.*,
e.code as master_exchange_code,
e.name as master_exchange_name,
e.active as master_exchange_active
FROM provider_exchange_mappings pem
JOIN exchanges e ON pem.master_exchange_id = e.id
WHERE e.visible = true
ORDER BY pem.provider, pem.provider_exchange_code
`;
const result = await this.postgresClient.query(query);
return result.rows;
}
async getProviderMappingsByProvider(provider: string): Promise<ProviderMapping[]> {
const query = `
SELECT
pem.*,
e.code as master_exchange_code,
e.name as master_exchange_name,
e.active as master_exchange_active
FROM provider_exchange_mappings pem
JOIN exchanges e ON pem.master_exchange_id = e.id
WHERE pem.provider = $1 AND e.visible = true
ORDER BY pem.provider_exchange_code
`;
const result = await this.postgresClient.query(query, [provider]);
return result.rows;
}
async createProviderMapping(data: CreateProviderMappingRequest): Promise<ProviderMapping> {
const query = `
INSERT INTO provider_exchange_mappings
(provider, provider_exchange_code, provider_exchange_name, master_exchange_id,
country_code, currency, confidence, active, verified, auto_mapped)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, false)
RETURNING *
`;
const result = await this.postgresClient.query(query, [
data.provider,
data.provider_exchange_code,
data.provider_exchange_name,
data.master_exchange_id,
data.country_code,
data.currency,
data.confidence,
data.active,
data.verified,
]);
logger.info('Provider mapping created', {
provider: data.provider,
provider_exchange_code: data.provider_exchange_code,
master_exchange_id: data.master_exchange_id,
});
return result.rows[0];
}
async updateProviderMapping(
id: string,
updates: UpdateProviderMappingRequest
): Promise<ProviderMapping | null> {
const updateFields = [];
const values = [];
let paramIndex = 1;
Object.entries(updates).forEach(([key, value]) => {
updateFields.push(`${key} = $${paramIndex++}`);
values.push(value);
});
updateFields.push(`updated_at = NOW()`);
updateFields.push(`auto_mapped = false`); // Mark as manually managed
values.push(id);
const query = `
UPDATE provider_exchange_mappings
SET ${updateFields.join(', ')}
WHERE id = $${paramIndex}
RETURNING *
`;
const result = await this.postgresClient.query(query, values);
if (result.rows.length === 0) {
return null;
}
logger.info('Provider mapping updated', { mappingId: id, updates });
return result.rows[0];
}
async deleteProviderMappingsByExchangeId(exchangeId: string): Promise<number> {
const query = 'DELETE FROM provider_exchange_mappings WHERE master_exchange_id = $1';
const result = await this.postgresClient.query(query, [exchangeId]);
logger.info('Deleted provider mappings for hidden exchange', {
exchangeId,
deletedMappings: result.rowCount,
});
return result.rowCount || 0;
}
// Providers and Statistics
async getProviders(): Promise<string[]> {
const query = `
SELECT DISTINCT provider
FROM provider_exchange_mappings
ORDER BY provider
`;
const result = await this.postgresClient.query(query);
return result.rows.map(row => row.provider);
}
async getExchangeStats(): Promise<ExchangeStats> {
const query = `
SELECT
(SELECT COUNT(*) FROM exchanges WHERE visible = true) as total_exchanges,
(SELECT COUNT(*) FROM exchanges WHERE active = true AND visible = true) as active_exchanges,
(SELECT COUNT(DISTINCT country) FROM exchanges WHERE visible = true) as countries,
(SELECT COUNT(DISTINCT currency) FROM exchanges WHERE visible = true) as currencies,
(SELECT COUNT(*) FROM provider_exchange_mappings pem JOIN exchanges e ON pem.master_exchange_id = e.id WHERE e.visible = true) as total_provider_mappings,
(SELECT COUNT(*) FROM provider_exchange_mappings pem JOIN exchanges e ON pem.master_exchange_id = e.id WHERE pem.active = true AND e.visible = true) as active_provider_mappings,
(SELECT COUNT(*) FROM provider_exchange_mappings pem JOIN exchanges e ON pem.master_exchange_id = e.id WHERE pem.verified = true AND e.visible = true) as verified_provider_mappings,
(SELECT COUNT(DISTINCT provider) FROM provider_exchange_mappings pem JOIN exchanges e ON pem.master_exchange_id = e.id WHERE e.visible = true) as providers
`;
const result = await this.postgresClient.query(query);
return result.rows[0];
}
async getUnmappedProviderExchanges(provider: string): Promise<ProviderExchange[]> {
// Get existing mappings for this provider
const existingMappingsQuery = `
SELECT provider_exchange_code
FROM provider_exchange_mappings
WHERE provider = $1
`;
const existingMappings = await this.postgresClient.query(existingMappingsQuery, [provider]);
const mappedCodes = new Set(existingMappings.rows.map(row => row.provider_exchange_code));
const db = this.mongoClient.getDatabase();
let providerExchanges: ProviderExchange[] = [];
switch (provider) {
case 'eod': {
const eodExchanges = await db.collection('eodExchanges').find({ active: true }).toArray();
providerExchanges = eodExchanges
.filter(exchange => !mappedCodes.has(exchange.Code))
.map(exchange => ({
provider_exchange_code: exchange.Code,
provider_exchange_name: exchange.Name,
country_code: exchange.CountryISO2,
currency: exchange.Currency,
symbol_count: null,
}));
break;
}
case 'ib': {
const ibExchanges = await db.collection('ibExchanges').find({}).toArray();
providerExchanges = ibExchanges
.filter(exchange => !mappedCodes.has(exchange.exchange_id))
.map(exchange => ({
provider_exchange_code: exchange.exchange_id,
provider_exchange_name: exchange.name,
country_code: exchange.country_code,
currency: null,
symbol_count: null,
}));
break;
}
case 'qm': {
const qmExchanges = await db.collection('qmExchanges').find({}).toArray();
providerExchanges = qmExchanges
.filter(exchange => !mappedCodes.has(exchange.exchangeCode))
.map(exchange => ({
provider_exchange_code: exchange.exchangeCode,
provider_exchange_name: exchange.name,
country_code: exchange.countryCode,
currency: exchange.countryCode === 'CA' ? 'CAD' : 'USD',
symbol_count: null,
}));
break;
}
default:
throw new Error(`Unknown provider: ${provider}`);
}
return providerExchanges;
}
}
// Export function to create service instance with container
export function createExchangeService(container: IServiceContainer): ExchangeService {
return new ExchangeService(container);
}

View file

@ -0,0 +1,103 @@
export interface Exchange {
id: string;
code: string;
name: string;
country: string;
currency: string;
active: boolean;
visible: boolean;
created_at: string;
updated_at: string;
}
export interface ExchangeWithMappings extends Exchange {
provider_mapping_count: string;
active_mapping_count: string;
verified_mapping_count: string;
providers: string | null;
provider_mappings: ProviderMapping[];
}
export interface ProviderMapping {
id: string;
provider: string;
provider_exchange_code: string;
provider_exchange_name: string;
master_exchange_id: string;
country_code: string | null;
currency: string | null;
confidence: number;
active: boolean;
verified: boolean;
auto_mapped: boolean;
created_at: string;
updated_at: string;
master_exchange_code?: string;
master_exchange_name?: string;
master_exchange_active?: boolean;
}
export interface CreateExchangeRequest {
code: string;
name: string;
country: string;
currency: string;
active?: boolean;
}
export interface UpdateExchangeRequest {
name?: string;
active?: boolean;
visible?: boolean;
country?: string;
currency?: string;
}
export interface CreateProviderMappingRequest {
provider: string;
provider_exchange_code: string;
provider_exchange_name?: string;
master_exchange_id: string;
country_code?: string;
currency?: string;
confidence?: number;
active?: boolean;
verified?: boolean;
}
export interface UpdateProviderMappingRequest {
active?: boolean;
verified?: boolean;
confidence?: number;
master_exchange_id?: string;
}
export interface ProviderExchange {
provider_exchange_code: string;
provider_exchange_name: string;
country_code: string | null;
currency: string | null;
symbol_count: number | null;
is_mapped?: boolean;
mapped_to_exchange_id?: string | null;
mapped_to_exchange_code?: string | null;
}
export interface ExchangeStats {
total_exchanges: number;
active_exchanges: number;
countries: number;
currencies: number;
total_provider_mappings: number;
active_provider_mappings: number;
verified_provider_mappings: number;
providers: number;
}
export interface ApiResponse<T = unknown> {
success: boolean;
data?: T;
error?: string;
message?: string;
total?: number;
}

View file

@ -0,0 +1,64 @@
import { Context } from 'hono';
import { getLogger } from '@stock-bot/logger';
import { ApiResponse } from '../types/exchange.types';
import { ValidationError } from './validation';
const logger = getLogger('error-handler');
export function handleError(c: Context, error: unknown, operation: string): Response {
logger.error(`Failed ${operation}`, { error });
// Handle validation errors
if (error instanceof ValidationError) {
const response: ApiResponse = {
success: false,
error: error.message,
};
return c.json(response, 400);
}
// Handle database constraint violations
if (error instanceof Error && error.message.includes('duplicate key')) {
const response: ApiResponse = {
success: false,
error: 'Resource already exists with this unique identifier',
};
return c.json(response, 409);
}
// Handle not found errors
if (error instanceof Error && error.message.includes('not found')) {
const response: ApiResponse = {
success: false,
error: error.message,
};
return c.json(response, 404);
}
// Generic error response
const response: ApiResponse = {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
};
return c.json(response, 500);
}
export function createSuccessResponse<T>(
data: T,
message?: string,
total?: number
): ApiResponse<T> {
const response: ApiResponse<T> = {
success: true,
data,
};
if (message) {
response.message = message;
}
if (total !== undefined) {
response.total = total;
}
return response;
}

View file

@ -0,0 +1,181 @@
import { CreateExchangeRequest, CreateProviderMappingRequest } from '../types/exchange.types';
export class ValidationError extends Error {
constructor(
message: string,
public field?: string
) {
super(message);
this.name = 'ValidationError';
}
}
export function validateCreateExchange(data: unknown): CreateExchangeRequest {
if (!data || typeof data !== 'object') {
throw new ValidationError('Invalid data provided', 'data');
}
const { code, name, country, currency, active = true } = data as Record<string, unknown>;
if (!code || typeof code !== 'string') {
throw new ValidationError('Exchange code is required', 'code');
}
if (!name || typeof name !== 'string') {
throw new ValidationError('Exchange name is required', 'name');
}
if (!country || typeof country !== 'string') {
throw new ValidationError('Country is required', 'country');
}
if (!currency || typeof currency !== 'string') {
throw new ValidationError('Currency is required', 'currency');
}
if (code.length > 10) {
throw new ValidationError('Exchange code must be 10 characters or less', 'code');
}
if (country.length !== 2) {
throw new ValidationError('Country must be exactly 2 characters (e.g., US, CA, GB)', 'country');
}
if (currency.length !== 3) {
throw new ValidationError(
'Currency must be exactly 3 characters (e.g., USD, EUR, CAD)',
'currency'
);
}
return {
code: code.toUpperCase().trim(),
name: name.trim(),
country: country.toUpperCase().trim(),
currency: currency.toUpperCase().trim(),
active: Boolean(active),
};
}
export function validateCreateProviderMapping(data: unknown): CreateProviderMappingRequest {
if (!data || typeof data !== 'object') {
throw new ValidationError('Invalid data provided', 'data');
}
const {
provider,
provider_exchange_code,
provider_exchange_name,
master_exchange_id,
country_code,
currency,
confidence = 1.0,
active = false,
verified = false,
} = data as Record<string, unknown>;
if (!provider || typeof provider !== 'string') {
throw new ValidationError('Provider is required', 'provider');
}
if (!provider_exchange_code || typeof provider_exchange_code !== 'string') {
throw new ValidationError('Provider exchange code is required', 'provider_exchange_code');
}
if (!master_exchange_id || typeof master_exchange_id !== 'string') {
throw new ValidationError('Master exchange ID is required', 'master_exchange_id');
}
// Validate currency is 3 characters or null
const cleanCurrency = currency && currency.length <= 3 ? currency : null;
return {
provider: provider.trim(),
provider_exchange_code: provider_exchange_code.trim(),
provider_exchange_name: provider_exchange_name?.trim(),
master_exchange_id: master_exchange_id.trim(),
country_code: country_code?.trim() || null,
currency: cleanCurrency,
confidence: Number(confidence),
active: Boolean(active),
verified: Boolean(verified),
};
}
export function validateUpdateExchange(data: unknown): Record<string, unknown> {
if (!data || typeof data !== 'object') {
throw new ValidationError('Invalid data provided', 'data');
}
const typedData = data as Record<string, unknown>;
const updates: Record<string, unknown> = {};
if (typedData.name !== undefined) {
if (typeof typedData.name !== 'string') {
throw new ValidationError('Name must be a string', 'name');
}
updates.name = typedData.name.trim();
}
if (typedData.active !== undefined) {
updates.active = Boolean(typedData.active);
}
if (typedData.visible !== undefined) {
updates.visible = Boolean(typedData.visible);
}
if (typedData.country !== undefined) {
if (typeof typedData.country !== 'string' || typedData.country.length !== 2) {
throw new ValidationError('Country must be exactly 2 characters', 'country');
}
updates.country = typedData.country.toUpperCase().trim();
}
if (typedData.currency !== undefined) {
if (typeof typedData.currency !== 'string' || typedData.currency.length !== 3) {
throw new ValidationError('Currency must be exactly 3 characters', 'currency');
}
updates.currency = typedData.currency.toUpperCase().trim();
}
if (Object.keys(updates).length === 0) {
throw new ValidationError('No valid fields to update');
}
return updates;
}
export function validateUpdateProviderMapping(data: unknown): Record<string, unknown> {
if (!data || typeof data !== 'object') {
throw new ValidationError('Invalid data provided', 'data');
}
const typedData = data as Record<string, unknown>;
const updates: Record<string, unknown> = {};
if (typedData.active !== undefined) {
updates.active = Boolean(typedData.active);
}
if (typedData.verified !== undefined) {
updates.verified = Boolean(typedData.verified);
}
if (typedData.confidence !== undefined) {
const confidence = Number(typedData.confidence);
if (isNaN(confidence) || confidence < 0 || confidence > 1) {
throw new ValidationError('Confidence must be a number between 0 and 1', 'confidence');
}
updates.confidence = confidence;
}
if (typedData.master_exchange_id !== undefined) {
if (typeof typedData.master_exchange_id !== 'string') {
throw new ValidationError('Master exchange ID must be a string', 'master_exchange_id');
}
updates.master_exchange_id = typedData.master_exchange_id.trim();
}
if (Object.keys(updates).length === 0) {
throw new ValidationError('No valid fields to update');
}
return updates;
}