diff --git a/apps/web-api/src/routes/exchange.routes.ts b/apps/web-api/src/routes/exchange.routes.ts
index 1837e09..3e189fe 100644
--- a/apps/web-api/src/routes/exchange.routes.ts
+++ b/apps/web-api/src/routes/exchange.routes.ts
@@ -1,842 +1,273 @@
/**
- * Exchange management routes
+ * Exchange management routes - Refactored
*/
import { Hono } from 'hono';
import { getLogger } from '@stock-bot/logger';
-import { getPostgreSQLClient } from '@stock-bot/postgres-client';
-import { getMongoDBClient } from '@stock-bot/mongodb-client';
+import { exchangeService } from '../services/exchange.service';
+import {
+ validateCreateExchange,
+ validateUpdateExchange,
+ validateCreateProviderMapping,
+ validateUpdateProviderMapping,
+} from '../utils/validation';
+import { handleError, createSuccessResponse } from '../utils/error-handler';
const logger = getLogger('exchange-routes');
export const exchangeRoutes = new Hono();
// Get all exchanges with provider mapping counts and mappings
exchangeRoutes.get('/', async c => {
+ logger.debug('Getting all exchanges');
try {
- const postgresClient = getPostgreSQLClient();
-
- // First get all exchanges with counts (only visible ones)
- 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 postgresClient.query(exchangesQuery);
-
- // Then 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
- ORDER BY pem.master_exchange_id, pem.provider, pem.provider_exchange_code
- `;
- const mappingsResult = await 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);
-
- // Attach mappings to exchanges
- const exchangesWithMappings = exchangesResult.rows.map(exchange => {
- const mappings = mappingsByExchange[exchange.id] || [];
- logger.info('Exchange mapping debug', {
- exchangeId: exchange.id,
- exchangeCode: exchange.code,
- mappingsCount: mappings.length,
- availableExchangeIds: Object.keys(mappingsByExchange)
- });
- return {
- ...exchange,
- provider_mappings: mappings
- };
- });
-
- return c.json({
- success: true,
- data: exchangesWithMappings,
- total: exchangesWithMappings.length,
- });
+ 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 c.json(
- {
- success: false,
- error: error instanceof Error ? error.message : 'Unknown error',
- },
- 500
- );
+ 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 exchangeId = c.req.param('id');
- const postgresClient = getPostgreSQLClient();
-
- // Get exchange details (only if visible)
- const exchangeQuery = 'SELECT * FROM exchanges WHERE id = $1 AND visible = true';
- const exchangeResult = await postgresClient.query(exchangeQuery, [exchangeId]);
-
- if (exchangeResult.rows.length === 0) {
- return c.json({ success: false, error: 'Exchange not found' }, 404);
+ const result = await exchangeService.getExchangeById(exchangeId);
+
+ if (!result) {
+ logger.warn('Exchange not found', { exchangeId });
+ return c.json(createSuccessResponse(null, 'Exchange not found'), 404);
}
- // Get provider mappings for this exchange
- 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 postgresClient.query(mappingsQuery, [exchangeId]);
-
- return c.json({
- success: true,
- data: {
- exchange: exchangeResult.rows[0],
- provider_mappings: mappingsResult.rows,
- },
+ 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 });
- return c.json(
- {
- success: false,
- error: error instanceof Error ? error.message : 'Unknown error',
- },
- 500
- );
- }
-});
-
-// Update exchange (activate/deactivate, rename, etc.)
-exchangeRoutes.patch('/:id', async c => {
- try {
- const exchangeId = c.req.param('id');
- const body = await c.req.json();
- const postgresClient = getPostgreSQLClient();
-
- const updateFields = [];
- const values = [];
- let paramIndex = 1;
-
- // Build dynamic update query
- if (body.active !== undefined) {
- updateFields.push(`active = $${paramIndex++}`);
- values.push(body.active);
- }
-
- if (body.name !== undefined) {
- updateFields.push(`name = $${paramIndex++}`);
- values.push(body.name);
- }
-
- if (body.country !== undefined) {
- updateFields.push(`country = $${paramIndex++}`);
- values.push(body.country);
- }
-
- if (body.currency !== undefined) {
- updateFields.push(`currency = $${paramIndex++}`);
- values.push(body.currency);
- }
-
- if (body.visible !== undefined) {
- updateFields.push(`visible = $${paramIndex++}`);
- values.push(body.visible);
- }
-
- if (updateFields.length === 0) {
- return c.json({ success: false, error: 'No valid fields to update' }, 400);
- }
-
- updateFields.push(`updated_at = NOW()`);
- values.push(exchangeId);
-
- const query = `
- UPDATE exchanges
- SET ${updateFields.join(', ')}
- WHERE id = $${paramIndex}
- RETURNING *
- `;
-
- const result = await postgresClient.query(query, values);
-
- if (result.rows.length === 0) {
- return c.json({ success: false, error: 'Exchange not found' }, 404);
- }
-
- // If hiding an exchange (visible=false), delete its provider mappings to make them available for remapping
- if (body.visible === false) {
- const deleteMappingsQuery = `
- DELETE FROM provider_exchange_mappings
- WHERE master_exchange_id = $1
- `;
- const mappingsResult = await postgresClient.query(deleteMappingsQuery, [exchangeId]);
-
- logger.info('Deleted provider mappings for hidden exchange', {
- exchangeId,
- deletedMappings: mappingsResult.rowCount
- });
- }
-
- logger.info('Exchange updated', { exchangeId, updates: body });
-
- return c.json({
- success: true,
- data: result.rows[0],
- message: 'Exchange updated successfully',
- });
- } catch (error) {
- logger.error('Failed to update exchange', { error });
- return c.json(
- {
- success: false,
- error: error instanceof Error ? error.message : 'Unknown error',
- },
- 500
- );
- }
-});
-
-// Get all provider mappings
-exchangeRoutes.get('/provider-mappings/all', async c => {
- try {
- const postgresClient = getPostgreSQLClient();
-
- 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
- ORDER BY pem.provider, pem.provider_exchange_code
- `;
-
- const result = await postgresClient.query(query);
-
- return c.json({
- success: true,
- data: result.rows,
- total: result.rows.length,
- });
- } catch (error) {
- logger.error('Failed to get provider mappings', { error });
- return c.json(
- {
- success: false,
- error: error instanceof Error ? error.message : 'Unknown error',
- },
- 500
- );
- }
-});
-
-// Get provider mappings by provider
-exchangeRoutes.get('/provider-mappings/:provider', async c => {
- try {
- const provider = c.req.param('provider');
- const postgresClient = getPostgreSQLClient();
-
- 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
- ORDER BY pem.provider_exchange_code
- `;
-
- const result = await postgresClient.query(query, [provider]);
-
- return c.json({
- success: true,
- data: result.rows,
- total: result.rows.length,
- provider,
- });
- } catch (error) {
- logger.error('Failed to get provider mappings', { error });
- return c.json(
- {
- success: false,
- error: error instanceof Error ? error.message : 'Unknown error',
- },
- 500
- );
- }
-});
-
-// Update provider mapping (activate/deactivate, verify, change confidence)
-exchangeRoutes.patch('/provider-mappings/:id', async c => {
- try {
- const mappingId = c.req.param('id');
- const body = await c.req.json();
- const postgresClient = getPostgreSQLClient();
-
- const updateFields = [];
- const values = [];
- let paramIndex = 1;
-
- // Build dynamic update query
- if (body.active !== undefined) {
- updateFields.push(`active = $${paramIndex++}`);
- values.push(body.active);
- }
-
- if (body.verified !== undefined) {
- updateFields.push(`verified = $${paramIndex++}`);
- values.push(body.verified);
- }
-
- if (body.confidence !== undefined) {
- updateFields.push(`confidence = $${paramIndex++}`);
- values.push(body.confidence);
- }
-
- if (body.master_exchange_id !== undefined) {
- updateFields.push(`master_exchange_id = $${paramIndex++}`);
- values.push(body.master_exchange_id);
- }
-
- if (updateFields.length === 0) {
- return c.json({ success: false, error: 'No valid fields to update' }, 400);
- }
-
- updateFields.push(`updated_at = NOW()`);
- updateFields.push(`auto_mapped = false`); // Mark as manually managed
- values.push(mappingId);
-
- const query = `
- UPDATE provider_exchange_mappings
- SET ${updateFields.join(', ')}
- WHERE id = $${paramIndex}
- RETURNING *
- `;
-
- const result = await postgresClient.query(query, values);
-
- if (result.rows.length === 0) {
- return c.json({ success: false, error: 'Provider mapping not found' }, 404);
- }
-
- logger.info('Provider mapping updated', { mappingId, updates: body });
-
- return c.json({
- success: true,
- data: result.rows[0],
- message: 'Provider mapping updated successfully',
- });
- } catch (error) {
- logger.error('Failed to update provider mapping', { error });
- return c.json(
- {
- success: false,
- error: error instanceof Error ? error.message : 'Unknown error',
- },
- 500
- );
- }
-});
-
-// Create new provider mapping
-exchangeRoutes.post('/provider-mappings', async c => {
- try {
- const body = await c.req.json();
- const postgresClient = getPostgreSQLClient();
-
- const {
- provider,
- provider_exchange_code,
- provider_exchange_name,
- master_exchange_id,
- country_code,
- currency,
- confidence = 1.0,
- active = false,
- verified = false,
- } = body;
-
- if (!provider || !provider_exchange_code || !master_exchange_id) {
- return c.json(
- {
- success: false,
- error: 'Missing required fields: provider, provider_exchange_code, master_exchange_id',
- },
- 400
- );
- }
-
- // Validate and clean currency field (must be 3 characters or null)
- const cleanCurrency = currency && currency.length <= 3 ? currency : null;
-
- 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 postgresClient.query(query, [
- provider,
- provider_exchange_code,
- provider_exchange_name,
- master_exchange_id,
- country_code,
- cleanCurrency,
- confidence,
- active,
- verified,
- ]);
-
- logger.info('Provider mapping created', {
- provider,
- provider_exchange_code,
- master_exchange_id,
- });
-
- return c.json(
- {
- success: true,
- data: result.rows[0],
- message: 'Provider mapping created successfully',
- },
- 201
- );
- } catch (error) {
- logger.error('Failed to create provider mapping', { error });
-
- // Handle unique constraint violations
- if (error instanceof Error && error.message.includes('duplicate key')) {
- return c.json(
- {
- success: false,
- error: 'Provider mapping already exists for this provider and exchange code',
- },
- 409
- );
- }
-
- return c.json(
- {
- success: false,
- error: error instanceof Error ? error.message : 'Unknown error',
- },
- 500
- );
- }
-});
-
-// Get all available providers
-exchangeRoutes.get('/providers/list', async c => {
- try {
- const postgresClient = getPostgreSQLClient();
-
- const query = `
- SELECT DISTINCT provider
- FROM provider_exchange_mappings
- ORDER BY provider
- `;
-
- const result = await postgresClient.query(query);
-
- return c.json({
- success: true,
- data: result.rows.map(row => row.provider),
- });
- } catch (error) {
- logger.error('Failed to get providers list', { error });
- return c.json(
- {
- success: false,
- error: error instanceof Error ? error.message : 'Unknown error',
- },
- 500
- );
- }
-});
-
-// Get all provider exchanges from MongoDB with mapping status
-exchangeRoutes.get('/provider-exchanges/all', async c => {
- try {
- const postgresClient = getPostgreSQLClient();
-
- const mongoClient = getMongoDBClient();
- const db = mongoClient.getDatabase();
-
- // Get all provider exchanges from different MongoDB collections
- const [eodExchanges, ibExchanges, qmExchanges, unifiedExchanges] = await Promise.all([
- db.collection('eodExchanges').find({ active: true }).toArray(),
- db.collection('ibExchanges').find({}).toArray(),
- db.collection('qmExchanges').find({}).toArray(),
- db.collection('exchanges').find({}).toArray(),
- ]);
-
- // Get existing mappings to mark which are already mapped
- const existingMappingsQuery = `
- SELECT provider, provider_exchange_code, master_exchange_id, e.code as master_exchange_code
- FROM provider_exchange_mappings pem
- JOIN exchanges e ON pem.master_exchange_id = e.id
- `;
- const existingMappings = await postgresClient.query(existingMappingsQuery);
- const mappingLookup = new Map();
- existingMappings.rows.forEach(row => {
- mappingLookup.set(`${row.provider}:${row.provider_exchange_code}`, {
- mapped: true,
- master_exchange_id: row.master_exchange_id,
- master_exchange_code: row.master_exchange_code,
- });
- });
-
- // Combine all provider exchanges
- const allProviderExchanges = [];
-
- // EOD exchanges
- eodExchanges.forEach(exchange => {
- const key = `eod:${exchange.Code}`;
- const mapping = mappingLookup.get(key);
- allProviderExchanges.push({
- provider: 'eod',
- provider_exchange_code: exchange.Code,
- provider_exchange_name: exchange.Name,
- country_code: exchange.CountryISO2,
- currency: exchange.Currency,
- symbol_count: null,
- is_mapped: !!mapping,
- mapped_to_exchange_id: mapping?.master_exchange_id || null,
- mapped_to_exchange_code: mapping?.master_exchange_code || null,
- });
- });
-
- // IB exchanges
- ibExchanges.forEach(exchange => {
- const key = `ib:${exchange.exchange_id}`;
- const mapping = mappingLookup.get(key);
- allProviderExchanges.push({
- provider: 'ib',
- provider_exchange_code: exchange.exchange_id,
- provider_exchange_name: exchange.name,
- country_code: exchange.country_code,
- currency: null,
- symbol_count: null,
- is_mapped: !!mapping,
- mapped_to_exchange_id: mapping?.master_exchange_id || null,
- mapped_to_exchange_code: mapping?.master_exchange_code || null,
- });
- });
-
- // QM exchanges
- qmExchanges.forEach(exchange => {
- const key = `qm:${exchange.exchangeCode}`;
- const mapping = mappingLookup.get(key);
- allProviderExchanges.push({
- provider: 'qm',
- provider_exchange_code: exchange.exchangeCode,
- provider_exchange_name: exchange.name,
- country_code: exchange.countryCode,
- currency: exchange.countryCode === 'CA' ? 'CAD' : 'USD',
- symbol_count: null,
- is_mapped: !!mapping,
- mapped_to_exchange_id: mapping?.master_exchange_id || null,
- mapped_to_exchange_code: mapping?.master_exchange_code || null,
- });
- });
-
- // Unified exchanges
- unifiedExchanges.forEach(exchange => {
- const key = `unified:${exchange.sourceCode || exchange.code}`;
- const mapping = mappingLookup.get(key);
- allProviderExchanges.push({
- provider: 'unified',
- provider_exchange_code: exchange.sourceCode || exchange.code,
- provider_exchange_name: exchange.sourceName || exchange.name,
- country_code: null,
- currency: null,
- symbol_count: null,
- is_mapped: !!mapping,
- mapped_to_exchange_id: mapping?.master_exchange_id || null,
- mapped_to_exchange_code: mapping?.master_exchange_code || null,
- });
- });
-
- return c.json({
- success: true,
- data: allProviderExchanges,
- total: allProviderExchanges.length,
- });
- } catch (error) {
- logger.error('Failed to get provider exchanges', { error });
- return c.json(
- {
- success: false,
- error: error instanceof Error ? error.message : 'Unknown error',
- },
- 500
- );
- }
-});
-
-// Get unmapped provider exchanges by provider
-exchangeRoutes.get('/provider-exchanges/unmapped/:provider', async c => {
- try {
- const provider = c.req.param('provider');
- const postgresClient = getPostgreSQLClient();
-
- const mongoClient = getMongoDBClient();
- const db = mongoClient.getDatabase();
-
- // Get existing mappings for this provider
- const existingMappingsQuery = `
- SELECT provider_exchange_code
- FROM provider_exchange_mappings
- WHERE provider = $1
- `;
- const existingMappings = await postgresClient.query(existingMappingsQuery, [provider]);
- const mappedCodes = new Set(existingMappings.rows.map(row => row.provider_exchange_code));
-
- let providerExchanges = [];
-
- 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;
-
- case 'unified':
- const unifiedExchanges = await db.collection('exchanges').find({}).toArray();
- providerExchanges = unifiedExchanges
- .filter(exchange => !mappedCodes.has(exchange.sourceCode || exchange.code))
- .map(exchange => ({
- provider_exchange_code: exchange.sourceCode || exchange.code,
- provider_exchange_name: exchange.sourceName || exchange.name,
- country_code: null,
- currency: null,
- symbol_count: null,
- }));
- break;
-
- default:
- return c.json(
- {
- success: false,
- error: `Unknown provider: ${provider}`,
- },
- 400
- );
- }
-
- return c.json({
- success: true,
- data: providerExchanges,
- total: providerExchanges.length,
- provider,
- });
- } catch (error) {
- logger.error('Failed to get unmapped provider exchanges', { error });
- return c.json(
- {
- success: false,
- error: error instanceof Error ? error.message : 'Unknown error',
- },
- 500
- );
+ 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();
- const postgresClient = getPostgreSQLClient();
-
- const { code, name, country, currency, active = true } = body;
-
- if (!code || !name || !country || !currency) {
- return c.json(
- {
- success: false,
- error: 'Missing required fields: code, name, country, currency',
- },
- 400
- );
- }
-
- // Validate currency is 3 characters
- if (currency.length !== 3) {
- return c.json(
- {
- success: false,
- error: 'Currency must be exactly 3 characters (e.g., USD, EUR, CAD)',
- },
- 400
- );
- }
-
- // Validate country is 2 characters
- if (country.length !== 2) {
- return c.json(
- {
- success: false,
- error: 'Country must be exactly 2 characters (e.g., US, CA, GB)',
- },
- 400
- );
- }
-
- const query = `
- INSERT INTO exchanges (code, name, country, currency, active, visible)
- VALUES ($1, $2, $3, $4, $5, true)
- RETURNING *
- `;
-
- const result = await postgresClient.query(query, [
- code.toUpperCase(),
- name,
- country.toUpperCase(),
- currency.toUpperCase(),
- active,
- ]);
-
- logger.info('Exchange created', {
- exchangeId: result.rows[0].id,
- code,
- name,
+ 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(
- {
- success: true,
- data: result.rows[0],
- message: 'Exchange created successfully',
- },
+ createSuccessResponse(exchange, 'Exchange created successfully'),
201
);
} catch (error) {
logger.error('Failed to create exchange', { error });
+ return handleError(c, error, 'to create exchange');
+ }
+});
- // Handle unique constraint violations
- if (error instanceof Error && error.message.includes('duplicate key')) {
- return c.json(
- {
- success: false,
- error: 'Exchange with this code already exists',
- },
- 409
- );
+// 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(
- {
- success: false,
- error: error instanceof Error ? error.message : 'Unknown error',
- },
- 500
+ 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 postgresClient = getPostgreSQLClient();
-
- const query = `
- SELECT
- (SELECT COUNT(*) FROM exchanges) as total_exchanges,
- (SELECT COUNT(*) FROM exchanges WHERE active = true) as active_exchanges,
- (SELECT COUNT(DISTINCT country) FROM exchanges) as countries,
- (SELECT COUNT(DISTINCT currency) FROM exchanges) as currencies,
- (SELECT COUNT(*) FROM provider_exchange_mappings) as total_provider_mappings,
- (SELECT COUNT(*) FROM provider_exchange_mappings WHERE active = true) as active_provider_mappings,
- (SELECT COUNT(*) FROM provider_exchange_mappings WHERE verified = true) as verified_provider_mappings,
- (SELECT COUNT(DISTINCT provider) FROM provider_exchange_mappings) as providers
- `;
-
- const result = await postgresClient.query(query);
-
- return c.json({
- success: true,
- data: result.rows[0],
- });
+ 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 c.json(
- {
- success: false,
- error: error instanceof Error ? error.message : 'Unknown error',
- },
- 500
- );
+ return handleError(c, error, 'to get exchange statistics');
}
-});
+});
\ No newline at end of file
diff --git a/apps/web-api/src/routes/health.routes.ts b/apps/web-api/src/routes/health.routes.ts
index 217fd92..93ea162 100644
--- a/apps/web-api/src/routes/health.routes.ts
+++ b/apps/web-api/src/routes/health.routes.ts
@@ -11,15 +11,22 @@ export const healthRoutes = new Hono();
// Basic health check
healthRoutes.get('/', c => {
- return c.json({
+ 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',
@@ -31,6 +38,7 @@ healthRoutes.get('/detailed', async c => {
};
// Check MongoDB
+ logger.debug('Checking MongoDB connectivity');
try {
const mongoClient = getMongoDBClient();
if (mongoClient.connected) {
@@ -38,26 +46,34 @@ healthRoutes.get('/detailed', async c => {
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: error instanceof Error ? error.message : 'Unknown error',
+ message: errorMessage,
};
+ logger.error('MongoDB health check failed', { error: errorMessage });
}
// Check PostgreSQL
+ logger.debug('Checking PostgreSQL connectivity');
try {
const postgresClient = getPostgreSQLClient();
await postgresClient.query('SELECT 1');
health.checks.postgresql = { status: 'healthy', message: 'Connected and responsive' };
+ logger.debug('PostgreSQL health check passed');
} catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
health.checks.postgresql = {
status: 'unhealthy',
- message: error instanceof Error ? error.message : 'Unknown error',
+ message: errorMessage,
};
+ logger.error('PostgreSQL health check failed', { error: errorMessage });
}
// Overall status
@@ -65,5 +81,19 @@ healthRoutes.get('/detailed', async c => {
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);
});
diff --git a/apps/web-api/src/services/exchange.service.ts b/apps/web-api/src/services/exchange.service.ts
new file mode 100644
index 0000000..020dec8
--- /dev/null
+++ b/apps/web-api/src/services/exchange.service.ts
@@ -0,0 +1,381 @@
+import { getLogger } from '@stock-bot/logger';
+import { getPostgreSQLClient } from '@stock-bot/postgres-client';
+import { getMongoDBClient } from '@stock-bot/mongodb-client';
+import {
+ Exchange,
+ ExchangeWithMappings,
+ ProviderMapping,
+ CreateExchangeRequest,
+ UpdateExchangeRequest,
+ CreateProviderMappingRequest,
+ UpdateProviderMappingRequest,
+ ProviderExchange,
+ ExchangeStats,
+} from '../types/exchange.types';
+
+const logger = getLogger('exchange-service');
+
+export class ExchangeService {
+ private postgresClient = getPostgreSQLClient();
+ private mongoClient = getMongoDBClient();
+
+ // Exchanges
+ async getAllExchanges(): Promise {
+ 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);
+
+ // 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ // 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;
+ }
+
+ case 'unified': {
+ const unifiedExchanges = await db.collection('exchanges').find({}).toArray();
+ providerExchanges = unifiedExchanges
+ .filter(exchange => !mappedCodes.has(exchange.sourceCode || exchange.code))
+ .map(exchange => ({
+ provider_exchange_code: exchange.sourceCode || exchange.code,
+ provider_exchange_name: exchange.sourceName || exchange.name,
+ country_code: null,
+ currency: null,
+ symbol_count: null,
+ }));
+ break;
+ }
+
+ default:
+ throw new Error(`Unknown provider: ${provider}`);
+ }
+
+ return providerExchanges;
+ }
+}
+
+// Export singleton instance
+export const exchangeService = new ExchangeService();
\ No newline at end of file
diff --git a/apps/web-api/src/types/exchange.types.ts b/apps/web-api/src/types/exchange.types.ts
new file mode 100644
index 0000000..c2ebe07
--- /dev/null
+++ b/apps/web-api/src/types/exchange.types.ts
@@ -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 {
+ success: boolean;
+ data?: T;
+ error?: string;
+ message?: string;
+ total?: number;
+}
\ No newline at end of file
diff --git a/apps/web-api/src/utils/error-handler.ts b/apps/web-api/src/utils/error-handler.ts
new file mode 100644
index 0000000..77787e0
--- /dev/null
+++ b/apps/web-api/src/utils/error-handler.ts
@@ -0,0 +1,64 @@
+import { Context } from 'hono';
+import { getLogger } from '@stock-bot/logger';
+import { ValidationError } from './validation';
+import { ApiResponse } from '../types/exchange.types';
+
+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(
+ data: T,
+ message?: string,
+ total?: number
+): ApiResponse {
+ const response: ApiResponse = {
+ success: true,
+ data,
+ };
+
+ if (message) {
+ response.message = message;
+ }
+ if (total !== undefined) {
+ response.total = total;
+ }
+
+ return response;
+}
\ No newline at end of file
diff --git a/apps/web-api/src/utils/validation.ts b/apps/web-api/src/utils/validation.ts
new file mode 100644
index 0000000..ea0467e
--- /dev/null
+++ b/apps/web-api/src/utils/validation.ts
@@ -0,0 +1,161 @@
+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: any): CreateExchangeRequest {
+ const { code, name, country, currency, active = true } = data;
+
+ 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: any): CreateProviderMappingRequest {
+ const {
+ provider,
+ provider_exchange_code,
+ provider_exchange_name,
+ master_exchange_id,
+ country_code,
+ currency,
+ confidence = 1.0,
+ active = false,
+ verified = false,
+ } = data;
+
+ 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: any): any {
+ const updates: any = {};
+
+ if (data.name !== undefined) {
+ if (typeof data.name !== 'string') {
+ throw new ValidationError('Name must be a string', 'name');
+ }
+ updates.name = data.name.trim();
+ }
+
+ if (data.active !== undefined) {
+ updates.active = Boolean(data.active);
+ }
+
+ if (data.visible !== undefined) {
+ updates.visible = Boolean(data.visible);
+ }
+
+ if (data.country !== undefined) {
+ if (typeof data.country !== 'string' || data.country.length !== 2) {
+ throw new ValidationError('Country must be exactly 2 characters', 'country');
+ }
+ updates.country = data.country.toUpperCase().trim();
+ }
+
+ if (data.currency !== undefined) {
+ if (typeof data.currency !== 'string' || data.currency.length !== 3) {
+ throw new ValidationError('Currency must be exactly 3 characters', 'currency');
+ }
+ updates.currency = data.currency.toUpperCase().trim();
+ }
+
+ if (Object.keys(updates).length === 0) {
+ throw new ValidationError('No valid fields to update');
+ }
+
+ return updates;
+}
+
+export function validateUpdateProviderMapping(data: any): any {
+ const updates: any = {};
+
+ if (data.active !== undefined) {
+ updates.active = Boolean(data.active);
+ }
+
+ if (data.verified !== undefined) {
+ updates.verified = Boolean(data.verified);
+ }
+
+ if (data.confidence !== undefined) {
+ const confidence = Number(data.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 (data.master_exchange_id !== undefined) {
+ if (typeof data.master_exchange_id !== 'string') {
+ throw new ValidationError('Master exchange ID must be a string', 'master_exchange_id');
+ }
+ updates.master_exchange_id = data.master_exchange_id.trim();
+ }
+
+ if (Object.keys(updates).length === 0) {
+ throw new ValidationError('No valid fields to update');
+ }
+
+ return updates;
+}
\ No newline at end of file
diff --git a/apps/web-api/tsconfig.json b/apps/web-api/tsconfig.json
index ed1857d..29e5e92 100644
--- a/apps/web-api/tsconfig.json
+++ b/apps/web-api/tsconfig.json
@@ -1,5 +1,5 @@
{
- "extends": "../../tsconfig.base.json",
+ "extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist"
diff --git a/apps/web-app/eslint.config.js b/apps/web-app/eslint.config.js
index 8a46b1d..2735e25 100644
--- a/apps/web-app/eslint.config.js
+++ b/apps/web-app/eslint.config.js
@@ -1,6 +1,7 @@
import js from '@eslint/js';
import tseslint from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
+import react from 'eslint-plugin-react';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import globals from 'globals';
@@ -13,11 +14,20 @@ export default [
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
- globals: globals.browser,
+ globals: {
+ ...globals.browser,
+ React: 'readonly',
+ },
parser: tsParser,
+ parserOptions: {
+ ecmaFeatures: {
+ jsx: true,
+ },
+ },
},
plugins: {
'@typescript-eslint': tseslint,
+ 'react': react,
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
@@ -26,6 +36,23 @@ export default [
...tseslint.configs.recommended.rules,
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
+
+ // React configuration
+ 'react/react-in-jsx-scope': 'off', // Not needed with React 17+
+ 'react/jsx-uses-react': 'off', // Not needed with React 17+
+
+ // TypeScript specific
+ '@typescript-eslint/no-explicit-any': 'warn', // Allow any but warn
+ '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
+
+ // General ESLint rules
+ 'no-undef': 'off', // TypeScript handles this
+ 'no-console': 'warn',
+ },
+ settings: {
+ react: {
+ version: 'detect',
+ },
},
},
];
diff --git a/apps/web-app/package.json b/apps/web-app/package.json
index f3001b4..278192c 100644
--- a/apps/web-app/package.json
+++ b/apps/web-app/package.json
@@ -6,7 +6,7 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
- "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
+ "lint": "eslint . --report-unused-disable-directives --max-warnings 0",
"type-check": "tsc --noEmit"
},
"dependencies": {
diff --git a/apps/web-app/src/components/ui/DataTable/DataTable.tsx b/apps/web-app/src/components/ui/DataTable/DataTable.tsx
index 1ccf3c0..49f110d 100644
--- a/apps/web-app/src/components/ui/DataTable/DataTable.tsx
+++ b/apps/web-app/src/components/ui/DataTable/DataTable.tsx
@@ -10,7 +10,7 @@ import {
SortingState,
useReactTable,
} from '@tanstack/react-table';
-import { useState, useRef, useEffect } from 'react';
+import { useState, useRef } from 'react';
import { TableVirtuoso } from 'react-virtuoso';
// Tooltip wrapper for cells that might overflow
@@ -93,10 +93,6 @@ export function DataTable({
getRowCanExpand,
enableColumnResizing: true,
columnResizeMode: 'onChange',
- getCenterTotalSize: () => {
- // Force table to use full width
- return '100%';
- },
});
if (loading) {
@@ -139,7 +135,9 @@ export function DataTable({
const index = props['data-index'] as number;
const item = flatRows[index];
- if (!item) return null;
+ if (!item) {
+ return null;
+ }
if (item.type === 'expanded') {
return (
@@ -167,7 +165,7 @@ export function DataTable({
maxWidth: `${cell.column.getSize()}px`,
}}
>
- {(cell.column.columnDef as any).disableTooltip ? (
+ {(cell.column.columnDef as { disableTooltip?: boolean }).disableTooltip ? (
{flexRender(cell.column.columnDef.cell, cell.getContext())}
diff --git a/apps/web-app/src/features/exchanges/components/AddExchangeDialog.tsx b/apps/web-app/src/features/exchanges/components/AddExchangeDialog.tsx
index e28e5f0..3c7ca75 100644
--- a/apps/web-app/src/features/exchanges/components/AddExchangeDialog.tsx
+++ b/apps/web-app/src/features/exchanges/components/AddExchangeDialog.tsx
@@ -1,114 +1,55 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle, Button } from '@/components/ui';
-import { useCallback, useState } from 'react';
-import { CreateExchangeRequest } from '../types';
+import { useCallback } from 'react';
+import { CreateExchangeRequest, AddExchangeDialogProps } from '../types';
+import { validateExchangeForm } from '../utils/validation';
+import { useFormValidation } from '../hooks/useFormValidation';
-interface AddExchangeDialogProps {
- isOpen: boolean;
- onClose: () => void;
- onCreateExchange: (request: CreateExchangeRequest) => Promise;
-}
+const initialFormData: CreateExchangeRequest = {
+ code: '',
+ name: '',
+ country: '',
+ currency: '',
+ active: true,
+};
export function AddExchangeDialog({
isOpen,
onClose,
onCreateExchange,
}: AddExchangeDialogProps) {
- const [formData, setFormData] = useState({
- code: '',
- name: '',
- country: '',
- currency: '',
- active: true,
- });
- const [loading, setLoading] = useState(false);
- const [errors, setErrors] = useState>({});
+ const {
+ formData,
+ errors,
+ isSubmitting,
+ updateField,
+ handleSubmit,
+ reset,
+ } = useFormValidation(initialFormData, validateExchangeForm);
- const validateForm = useCallback((): boolean => {
- const newErrors: Record = {};
-
- if (!formData.code.trim()) {
- newErrors.code = 'Exchange code is required';
- } else if (formData.code.length > 10) {
- newErrors.code = 'Exchange code must be 10 characters or less';
- }
-
- if (!formData.name.trim()) {
- newErrors.name = 'Exchange name is required';
- }
-
- if (!formData.country.trim()) {
- newErrors.country = 'Country is required';
- } else if (formData.country.length !== 2) {
- newErrors.country = 'Country must be exactly 2 characters (e.g., US, CA, GB)';
- }
-
- if (!formData.currency.trim()) {
- newErrors.currency = 'Currency is required';
- } else if (formData.currency.length !== 3) {
- newErrors.currency = 'Currency must be exactly 3 characters (e.g., USD, EUR, CAD)';
- }
-
- setErrors(newErrors);
- return Object.keys(newErrors).length === 0;
- }, [formData]);
-
- const handleSubmit = useCallback(
- async (e: React.FormEvent) => {
- e.preventDefault();
-
- if (!validateForm()) {
- return;
- }
-
- setLoading(true);
- try {
- await onCreateExchange({
- ...formData,
- code: formData.code.toUpperCase(),
- country: formData.country.toUpperCase(),
- currency: formData.currency.toUpperCase(),
- });
-
- // Reset form on success
- setFormData({
- code: '',
- name: '',
- country: '',
- currency: '',
- active: true,
- });
- setErrors({});
- } catch (error) {
- console.error('Error creating exchange:', error);
- } finally {
- setLoading(false);
- }
+ const onSubmit = useCallback(
+ async (data: CreateExchangeRequest) => {
+ await onCreateExchange({
+ ...data,
+ code: data.code.toUpperCase(),
+ country: data.country.toUpperCase(),
+ currency: data.currency.toUpperCase(),
+ });
},
- [formData, validateForm, onCreateExchange]
+ [onCreateExchange]
+ );
+
+ const handleFormSubmit = useCallback(
+ (e: React.FormEvent) => {
+ e.preventDefault();
+ handleSubmit(onSubmit, onClose);
+ },
+ [handleSubmit, onSubmit, onClose]
);
const handleClose = useCallback(() => {
- setFormData({
- code: '',
- name: '',
- country: '',
- currency: '',
- active: true,
- });
- setErrors({});
+ reset();
onClose();
- }, [onClose]);
-
- const handleInputChange = useCallback(
- (field: keyof CreateExchangeRequest, value: string | boolean) => {
- setFormData(prev => ({ ...prev, [field]: value }));
- // Clear error when user starts typing
- if (errors[field]) {
- setErrors(prev => ({ ...prev, [field]: '' }));
- }
- },
- [errors]
- );
+ }, [reset, onClose]);
return (
-