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 ( @@ -120,7 +61,7 @@ export function AddExchangeDialog({

-
+ {/* Exchange Code */}