huge refactor on web-api and web-app
This commit is contained in:
parent
1d299e52d4
commit
265e10a658
23 changed files with 1545 additions and 1233 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -11,15 +11,22 @@ export const healthRoutes = new Hono();
|
||||||
|
|
||||||
// Basic health check
|
// Basic health check
|
||||||
healthRoutes.get('/', c => {
|
healthRoutes.get('/', c => {
|
||||||
return c.json({
|
logger.debug('Basic health check requested');
|
||||||
|
|
||||||
|
const response = {
|
||||||
status: 'healthy',
|
status: 'healthy',
|
||||||
service: 'web-api',
|
service: 'web-api',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
};
|
||||||
|
|
||||||
|
logger.info('Basic health check successful', { status: response.status });
|
||||||
|
return c.json(response);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Detailed health check with database connectivity
|
// Detailed health check with database connectivity
|
||||||
healthRoutes.get('/detailed', async c => {
|
healthRoutes.get('/detailed', async c => {
|
||||||
|
logger.debug('Detailed health check requested');
|
||||||
|
|
||||||
const health = {
|
const health = {
|
||||||
status: 'healthy',
|
status: 'healthy',
|
||||||
service: 'web-api',
|
service: 'web-api',
|
||||||
|
|
@ -31,6 +38,7 @@ healthRoutes.get('/detailed', async c => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check MongoDB
|
// Check MongoDB
|
||||||
|
logger.debug('Checking MongoDB connectivity');
|
||||||
try {
|
try {
|
||||||
const mongoClient = getMongoDBClient();
|
const mongoClient = getMongoDBClient();
|
||||||
if (mongoClient.connected) {
|
if (mongoClient.connected) {
|
||||||
|
|
@ -38,26 +46,34 @@ healthRoutes.get('/detailed', async c => {
|
||||||
const db = mongoClient.getDatabase();
|
const db = mongoClient.getDatabase();
|
||||||
await db.admin().ping();
|
await db.admin().ping();
|
||||||
health.checks.mongodb = { status: 'healthy', message: 'Connected and responsive' };
|
health.checks.mongodb = { status: 'healthy', message: 'Connected and responsive' };
|
||||||
|
logger.debug('MongoDB health check passed');
|
||||||
} else {
|
} else {
|
||||||
health.checks.mongodb = { status: 'unhealthy', message: 'Not connected' };
|
health.checks.mongodb = { status: 'unhealthy', message: 'Not connected' };
|
||||||
|
logger.warn('MongoDB health check failed - not connected');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
health.checks.mongodb = {
|
health.checks.mongodb = {
|
||||||
status: 'unhealthy',
|
status: 'unhealthy',
|
||||||
message: error instanceof Error ? error.message : 'Unknown error',
|
message: errorMessage,
|
||||||
};
|
};
|
||||||
|
logger.error('MongoDB health check failed', { error: errorMessage });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check PostgreSQL
|
// Check PostgreSQL
|
||||||
|
logger.debug('Checking PostgreSQL connectivity');
|
||||||
try {
|
try {
|
||||||
const postgresClient = getPostgreSQLClient();
|
const postgresClient = getPostgreSQLClient();
|
||||||
await postgresClient.query('SELECT 1');
|
await postgresClient.query('SELECT 1');
|
||||||
health.checks.postgresql = { status: 'healthy', message: 'Connected and responsive' };
|
health.checks.postgresql = { status: 'healthy', message: 'Connected and responsive' };
|
||||||
|
logger.debug('PostgreSQL health check passed');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
health.checks.postgresql = {
|
health.checks.postgresql = {
|
||||||
status: 'unhealthy',
|
status: 'unhealthy',
|
||||||
message: error instanceof Error ? error.message : 'Unknown error',
|
message: errorMessage,
|
||||||
};
|
};
|
||||||
|
logger.error('PostgreSQL health check failed', { error: errorMessage });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Overall status
|
// Overall status
|
||||||
|
|
@ -65,5 +81,19 @@ healthRoutes.get('/detailed', async c => {
|
||||||
health.status = allHealthy ? 'healthy' : 'unhealthy';
|
health.status = allHealthy ? 'healthy' : 'unhealthy';
|
||||||
|
|
||||||
const statusCode = allHealthy ? 200 : 503;
|
const statusCode = allHealthy ? 200 : 503;
|
||||||
|
|
||||||
|
if (allHealthy) {
|
||||||
|
logger.info('Detailed health check successful - all systems healthy', {
|
||||||
|
mongodb: health.checks.mongodb.status,
|
||||||
|
postgresql: health.checks.postgresql.status
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.warn('Detailed health check failed - some systems unhealthy', {
|
||||||
|
mongodb: health.checks.mongodb.status,
|
||||||
|
postgresql: health.checks.postgresql.status,
|
||||||
|
overallStatus: health.status
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return c.json(health, statusCode);
|
return c.json(health, statusCode);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
381
apps/web-api/src/services/exchange.service.ts
Normal file
381
apps/web-api/src/services/exchange.service.ts
Normal file
|
|
@ -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<ExchangeWithMappings[]> {
|
||||||
|
const exchangesQuery = `
|
||||||
|
SELECT
|
||||||
|
e.id,
|
||||||
|
e.code,
|
||||||
|
e.name,
|
||||||
|
e.country,
|
||||||
|
e.currency,
|
||||||
|
e.active,
|
||||||
|
e.visible,
|
||||||
|
e.created_at,
|
||||||
|
e.updated_at,
|
||||||
|
COUNT(pem.id) as provider_mapping_count,
|
||||||
|
COUNT(CASE WHEN pem.active = true THEN 1 END) as active_mapping_count,
|
||||||
|
COUNT(CASE WHEN pem.verified = true THEN 1 END) as verified_mapping_count,
|
||||||
|
STRING_AGG(DISTINCT pem.provider, ', ') as providers
|
||||||
|
FROM exchanges e
|
||||||
|
LEFT JOIN provider_exchange_mappings pem ON e.id = pem.master_exchange_id
|
||||||
|
WHERE e.visible = true
|
||||||
|
GROUP BY e.id, e.code, e.name, e.country, e.currency, e.active, e.visible, e.created_at, e.updated_at
|
||||||
|
ORDER BY e.code
|
||||||
|
`;
|
||||||
|
|
||||||
|
const exchangesResult = await this.postgresClient.query(exchangesQuery);
|
||||||
|
|
||||||
|
// Get all provider mappings
|
||||||
|
const mappingsQuery = `
|
||||||
|
SELECT
|
||||||
|
pem.*,
|
||||||
|
e.code as master_exchange_code,
|
||||||
|
e.name as master_exchange_name
|
||||||
|
FROM provider_exchange_mappings pem
|
||||||
|
JOIN exchanges e ON pem.master_exchange_id = e.id
|
||||||
|
WHERE e.visible = true
|
||||||
|
ORDER BY pem.master_exchange_id, pem.provider, pem.provider_exchange_code
|
||||||
|
`;
|
||||||
|
const mappingsResult = await this.postgresClient.query(mappingsQuery);
|
||||||
|
|
||||||
|
// Group mappings by exchange ID
|
||||||
|
const mappingsByExchange = mappingsResult.rows.reduce((acc, mapping) => {
|
||||||
|
const exchangeId = mapping.master_exchange_id;
|
||||||
|
if (!acc[exchangeId]) {
|
||||||
|
acc[exchangeId] = [];
|
||||||
|
}
|
||||||
|
acc[exchangeId].push(mapping);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, ProviderMapping[]>);
|
||||||
|
|
||||||
|
// Attach mappings to exchanges
|
||||||
|
return exchangesResult.rows.map(exchange => ({
|
||||||
|
...exchange,
|
||||||
|
provider_mappings: mappingsByExchange[exchange.id] || [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getExchangeById(id: string): Promise<{ exchange: Exchange; provider_mappings: ProviderMapping[] } | null> {
|
||||||
|
const exchangeQuery = 'SELECT * FROM exchanges WHERE id = $1 AND visible = true';
|
||||||
|
const exchangeResult = await this.postgresClient.query(exchangeQuery, [id]);
|
||||||
|
|
||||||
|
if (exchangeResult.rows.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mappingsQuery = `
|
||||||
|
SELECT
|
||||||
|
pem.*,
|
||||||
|
e.code as master_exchange_code,
|
||||||
|
e.name as master_exchange_name
|
||||||
|
FROM provider_exchange_mappings pem
|
||||||
|
JOIN exchanges e ON pem.master_exchange_id = e.id
|
||||||
|
WHERE pem.master_exchange_id = $1
|
||||||
|
ORDER BY pem.provider, pem.provider_exchange_code
|
||||||
|
`;
|
||||||
|
const mappingsResult = await this.postgresClient.query(mappingsQuery, [id]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
exchange: exchangeResult.rows[0],
|
||||||
|
provider_mappings: mappingsResult.rows,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createExchange(data: CreateExchangeRequest): Promise<Exchange> {
|
||||||
|
const query = `
|
||||||
|
INSERT INTO exchanges (code, name, country, currency, active, visible)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, true)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.postgresClient.query(query, [
|
||||||
|
data.code,
|
||||||
|
data.name,
|
||||||
|
data.country,
|
||||||
|
data.currency,
|
||||||
|
data.active,
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info('Exchange created', {
|
||||||
|
exchangeId: result.rows[0].id,
|
||||||
|
code: data.code,
|
||||||
|
name: data.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateExchange(id: string, updates: UpdateExchangeRequest): Promise<Exchange | null> {
|
||||||
|
const updateFields = [];
|
||||||
|
const values = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
Object.entries(updates).forEach(([key, value]) => {
|
||||||
|
updateFields.push(`${key} = $${paramIndex++}`);
|
||||||
|
values.push(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
updateFields.push(`updated_at = NOW()`);
|
||||||
|
values.push(id);
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
UPDATE exchanges
|
||||||
|
SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex}
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.postgresClient.query(query, values);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If hiding an exchange, delete its provider mappings
|
||||||
|
if (updates.visible === false) {
|
||||||
|
await this.deleteProviderMappingsByExchangeId(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Exchange updated', { exchangeId: id, updates });
|
||||||
|
|
||||||
|
return result.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider Mappings
|
||||||
|
async getAllProviderMappings(): Promise<ProviderMapping[]> {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
pem.*,
|
||||||
|
e.code as master_exchange_code,
|
||||||
|
e.name as master_exchange_name,
|
||||||
|
e.active as master_exchange_active
|
||||||
|
FROM provider_exchange_mappings pem
|
||||||
|
JOIN exchanges e ON pem.master_exchange_id = e.id
|
||||||
|
WHERE e.visible = true
|
||||||
|
ORDER BY pem.provider, pem.provider_exchange_code
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.postgresClient.query(query);
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProviderMappingsByProvider(provider: string): Promise<ProviderMapping[]> {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
pem.*,
|
||||||
|
e.code as master_exchange_code,
|
||||||
|
e.name as master_exchange_name,
|
||||||
|
e.active as master_exchange_active
|
||||||
|
FROM provider_exchange_mappings pem
|
||||||
|
JOIN exchanges e ON pem.master_exchange_id = e.id
|
||||||
|
WHERE pem.provider = $1 AND e.visible = true
|
||||||
|
ORDER BY pem.provider_exchange_code
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.postgresClient.query(query, [provider]);
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createProviderMapping(data: CreateProviderMappingRequest): Promise<ProviderMapping> {
|
||||||
|
const query = `
|
||||||
|
INSERT INTO provider_exchange_mappings
|
||||||
|
(provider, provider_exchange_code, provider_exchange_name, master_exchange_id,
|
||||||
|
country_code, currency, confidence, active, verified, auto_mapped)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, false)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.postgresClient.query(query, [
|
||||||
|
data.provider,
|
||||||
|
data.provider_exchange_code,
|
||||||
|
data.provider_exchange_name,
|
||||||
|
data.master_exchange_id,
|
||||||
|
data.country_code,
|
||||||
|
data.currency,
|
||||||
|
data.confidence,
|
||||||
|
data.active,
|
||||||
|
data.verified,
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info('Provider mapping created', {
|
||||||
|
provider: data.provider,
|
||||||
|
provider_exchange_code: data.provider_exchange_code,
|
||||||
|
master_exchange_id: data.master_exchange_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProviderMapping(id: string, updates: UpdateProviderMappingRequest): Promise<ProviderMapping | null> {
|
||||||
|
const updateFields = [];
|
||||||
|
const values = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
Object.entries(updates).forEach(([key, value]) => {
|
||||||
|
updateFields.push(`${key} = $${paramIndex++}`);
|
||||||
|
values.push(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
updateFields.push(`updated_at = NOW()`);
|
||||||
|
updateFields.push(`auto_mapped = false`); // Mark as manually managed
|
||||||
|
values.push(id);
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
UPDATE provider_exchange_mappings
|
||||||
|
SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex}
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.postgresClient.query(query, values);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Provider mapping updated', { mappingId: id, updates });
|
||||||
|
|
||||||
|
return result.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteProviderMappingsByExchangeId(exchangeId: string): Promise<number> {
|
||||||
|
const query = 'DELETE FROM provider_exchange_mappings WHERE master_exchange_id = $1';
|
||||||
|
const result = await this.postgresClient.query(query, [exchangeId]);
|
||||||
|
|
||||||
|
logger.info('Deleted provider mappings for hidden exchange', {
|
||||||
|
exchangeId,
|
||||||
|
deletedMappings: result.rowCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.rowCount || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Providers and Statistics
|
||||||
|
async getProviders(): Promise<string[]> {
|
||||||
|
const query = `
|
||||||
|
SELECT DISTINCT provider
|
||||||
|
FROM provider_exchange_mappings
|
||||||
|
ORDER BY provider
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.postgresClient.query(query);
|
||||||
|
return result.rows.map(row => row.provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getExchangeStats(): Promise<ExchangeStats> {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM exchanges WHERE visible = true) as total_exchanges,
|
||||||
|
(SELECT COUNT(*) FROM exchanges WHERE active = true AND visible = true) as active_exchanges,
|
||||||
|
(SELECT COUNT(DISTINCT country) FROM exchanges WHERE visible = true) as countries,
|
||||||
|
(SELECT COUNT(DISTINCT currency) FROM exchanges WHERE visible = true) as currencies,
|
||||||
|
(SELECT COUNT(*) FROM provider_exchange_mappings pem JOIN exchanges e ON pem.master_exchange_id = e.id WHERE e.visible = true) as total_provider_mappings,
|
||||||
|
(SELECT COUNT(*) FROM provider_exchange_mappings pem JOIN exchanges e ON pem.master_exchange_id = e.id WHERE pem.active = true AND e.visible = true) as active_provider_mappings,
|
||||||
|
(SELECT COUNT(*) FROM provider_exchange_mappings pem JOIN exchanges e ON pem.master_exchange_id = e.id WHERE pem.verified = true AND e.visible = true) as verified_provider_mappings,
|
||||||
|
(SELECT COUNT(DISTINCT provider) FROM provider_exchange_mappings pem JOIN exchanges e ON pem.master_exchange_id = e.id WHERE e.visible = true) as providers
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.postgresClient.query(query);
|
||||||
|
return result.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUnmappedProviderExchanges(provider: string): Promise<ProviderExchange[]> {
|
||||||
|
// Get existing mappings for this provider
|
||||||
|
const existingMappingsQuery = `
|
||||||
|
SELECT provider_exchange_code
|
||||||
|
FROM provider_exchange_mappings
|
||||||
|
WHERE provider = $1
|
||||||
|
`;
|
||||||
|
const existingMappings = await this.postgresClient.query(existingMappingsQuery, [provider]);
|
||||||
|
const mappedCodes = new Set(existingMappings.rows.map(row => row.provider_exchange_code));
|
||||||
|
|
||||||
|
const db = this.mongoClient.getDatabase();
|
||||||
|
let providerExchanges: ProviderExchange[] = [];
|
||||||
|
|
||||||
|
switch (provider) {
|
||||||
|
case 'eod': {
|
||||||
|
const eodExchanges = await db.collection('eodExchanges').find({ active: true }).toArray();
|
||||||
|
providerExchanges = eodExchanges
|
||||||
|
.filter(exchange => !mappedCodes.has(exchange.Code))
|
||||||
|
.map(exchange => ({
|
||||||
|
provider_exchange_code: exchange.Code,
|
||||||
|
provider_exchange_name: exchange.Name,
|
||||||
|
country_code: exchange.CountryISO2,
|
||||||
|
currency: exchange.Currency,
|
||||||
|
symbol_count: null,
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ib': {
|
||||||
|
const ibExchanges = await db.collection('ibExchanges').find({}).toArray();
|
||||||
|
providerExchanges = ibExchanges
|
||||||
|
.filter(exchange => !mappedCodes.has(exchange.exchange_id))
|
||||||
|
.map(exchange => ({
|
||||||
|
provider_exchange_code: exchange.exchange_id,
|
||||||
|
provider_exchange_name: exchange.name,
|
||||||
|
country_code: exchange.country_code,
|
||||||
|
currency: null,
|
||||||
|
symbol_count: null,
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'qm': {
|
||||||
|
const qmExchanges = await db.collection('qmExchanges').find({}).toArray();
|
||||||
|
providerExchanges = qmExchanges
|
||||||
|
.filter(exchange => !mappedCodes.has(exchange.exchangeCode))
|
||||||
|
.map(exchange => ({
|
||||||
|
provider_exchange_code: exchange.exchangeCode,
|
||||||
|
provider_exchange_name: exchange.name,
|
||||||
|
country_code: exchange.countryCode,
|
||||||
|
currency: exchange.countryCode === 'CA' ? 'CAD' : 'USD',
|
||||||
|
symbol_count: null,
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
103
apps/web-api/src/types/exchange.types.ts
Normal file
103
apps/web-api/src/types/exchange.types.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
export interface Exchange {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
country: string;
|
||||||
|
currency: string;
|
||||||
|
active: boolean;
|
||||||
|
visible: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExchangeWithMappings extends Exchange {
|
||||||
|
provider_mapping_count: string;
|
||||||
|
active_mapping_count: string;
|
||||||
|
verified_mapping_count: string;
|
||||||
|
providers: string | null;
|
||||||
|
provider_mappings: ProviderMapping[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderMapping {
|
||||||
|
id: string;
|
||||||
|
provider: string;
|
||||||
|
provider_exchange_code: string;
|
||||||
|
provider_exchange_name: string;
|
||||||
|
master_exchange_id: string;
|
||||||
|
country_code: string | null;
|
||||||
|
currency: string | null;
|
||||||
|
confidence: number;
|
||||||
|
active: boolean;
|
||||||
|
verified: boolean;
|
||||||
|
auto_mapped: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
master_exchange_code?: string;
|
||||||
|
master_exchange_name?: string;
|
||||||
|
master_exchange_active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateExchangeRequest {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
country: string;
|
||||||
|
currency: string;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateExchangeRequest {
|
||||||
|
name?: string;
|
||||||
|
active?: boolean;
|
||||||
|
visible?: boolean;
|
||||||
|
country?: string;
|
||||||
|
currency?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateProviderMappingRequest {
|
||||||
|
provider: string;
|
||||||
|
provider_exchange_code: string;
|
||||||
|
provider_exchange_name?: string;
|
||||||
|
master_exchange_id: string;
|
||||||
|
country_code?: string;
|
||||||
|
currency?: string;
|
||||||
|
confidence?: number;
|
||||||
|
active?: boolean;
|
||||||
|
verified?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateProviderMappingRequest {
|
||||||
|
active?: boolean;
|
||||||
|
verified?: boolean;
|
||||||
|
confidence?: number;
|
||||||
|
master_exchange_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderExchange {
|
||||||
|
provider_exchange_code: string;
|
||||||
|
provider_exchange_name: string;
|
||||||
|
country_code: string | null;
|
||||||
|
currency: string | null;
|
||||||
|
symbol_count: number | null;
|
||||||
|
is_mapped?: boolean;
|
||||||
|
mapped_to_exchange_id?: string | null;
|
||||||
|
mapped_to_exchange_code?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExchangeStats {
|
||||||
|
total_exchanges: number;
|
||||||
|
active_exchanges: number;
|
||||||
|
countries: number;
|
||||||
|
currencies: number;
|
||||||
|
total_provider_mappings: number;
|
||||||
|
active_provider_mappings: number;
|
||||||
|
verified_provider_mappings: number;
|
||||||
|
providers: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiResponse<T = any> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
total?: number;
|
||||||
|
}
|
||||||
64
apps/web-api/src/utils/error-handler.ts
Normal file
64
apps/web-api/src/utils/error-handler.ts
Normal file
|
|
@ -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<T>(
|
||||||
|
data: T,
|
||||||
|
message?: string,
|
||||||
|
total?: number
|
||||||
|
): ApiResponse<T> {
|
||||||
|
const response: ApiResponse<T> = {
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (message) {
|
||||||
|
response.message = message;
|
||||||
|
}
|
||||||
|
if (total !== undefined) {
|
||||||
|
response.total = total;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
161
apps/web-api/src/utils/validation.ts
Normal file
161
apps/web-api/src/utils/validation.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"rootDir": "./src",
|
"rootDir": "./src",
|
||||||
"outDir": "./dist"
|
"outDir": "./dist"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import js from '@eslint/js';
|
import js from '@eslint/js';
|
||||||
import tseslint from '@typescript-eslint/eslint-plugin';
|
import tseslint from '@typescript-eslint/eslint-plugin';
|
||||||
import tsParser from '@typescript-eslint/parser';
|
import tsParser from '@typescript-eslint/parser';
|
||||||
|
import react from 'eslint-plugin-react';
|
||||||
import reactHooks from 'eslint-plugin-react-hooks';
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||||
import globals from 'globals';
|
import globals from 'globals';
|
||||||
|
|
@ -13,11 +14,20 @@ export default [
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ['**/*.{ts,tsx}'],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
globals: globals.browser,
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
React: 'readonly',
|
||||||
|
},
|
||||||
parser: tsParser,
|
parser: tsParser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaFeatures: {
|
||||||
|
jsx: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
'@typescript-eslint': tseslint,
|
'@typescript-eslint': tseslint,
|
||||||
|
'react': react,
|
||||||
'react-hooks': reactHooks,
|
'react-hooks': reactHooks,
|
||||||
'react-refresh': reactRefresh,
|
'react-refresh': reactRefresh,
|
||||||
},
|
},
|
||||||
|
|
@ -26,6 +36,23 @@ export default [
|
||||||
...tseslint.configs.recommended.rules,
|
...tseslint.configs.recommended.rules,
|
||||||
...reactHooks.configs.recommended.rules,
|
...reactHooks.configs.recommended.rules,
|
||||||
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
'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',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"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"
|
"type-check": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import {
|
||||||
SortingState,
|
SortingState,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
} from '@tanstack/react-table';
|
} from '@tanstack/react-table';
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import { TableVirtuoso } from 'react-virtuoso';
|
import { TableVirtuoso } from 'react-virtuoso';
|
||||||
|
|
||||||
// Tooltip wrapper for cells that might overflow
|
// Tooltip wrapper for cells that might overflow
|
||||||
|
|
@ -93,10 +93,6 @@ export function DataTable<T>({
|
||||||
getRowCanExpand,
|
getRowCanExpand,
|
||||||
enableColumnResizing: true,
|
enableColumnResizing: true,
|
||||||
columnResizeMode: 'onChange',
|
columnResizeMode: 'onChange',
|
||||||
getCenterTotalSize: () => {
|
|
||||||
// Force table to use full width
|
|
||||||
return '100%';
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
|
@ -139,7 +135,9 @@ export function DataTable<T>({
|
||||||
const index = props['data-index'] as number;
|
const index = props['data-index'] as number;
|
||||||
const item = flatRows[index];
|
const item = flatRows[index];
|
||||||
|
|
||||||
if (!item) return null;
|
if (!item) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (item.type === 'expanded') {
|
if (item.type === 'expanded') {
|
||||||
return (
|
return (
|
||||||
|
|
@ -167,7 +165,7 @@ export function DataTable<T>({
|
||||||
maxWidth: `${cell.column.getSize()}px`,
|
maxWidth: `${cell.column.getSize()}px`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(cell.column.columnDef as any).disableTooltip ? (
|
{(cell.column.columnDef as { disableTooltip?: boolean }).disableTooltip ? (
|
||||||
<div className="truncate overflow-hidden text-ellipsis whitespace-nowrap">
|
<div className="truncate overflow-hidden text-ellipsis whitespace-nowrap">
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,114 +1,55 @@
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, Button } from '@/components/ui';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, Button } from '@/components/ui';
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { CreateExchangeRequest } from '../types';
|
import { CreateExchangeRequest, AddExchangeDialogProps } from '../types';
|
||||||
|
import { validateExchangeForm } from '../utils/validation';
|
||||||
|
import { useFormValidation } from '../hooks/useFormValidation';
|
||||||
|
|
||||||
interface AddExchangeDialogProps {
|
const initialFormData: CreateExchangeRequest = {
|
||||||
isOpen: boolean;
|
code: '',
|
||||||
onClose: () => void;
|
name: '',
|
||||||
onCreateExchange: (request: CreateExchangeRequest) => Promise<any>;
|
country: '',
|
||||||
}
|
currency: '',
|
||||||
|
active: true,
|
||||||
|
};
|
||||||
|
|
||||||
export function AddExchangeDialog({
|
export function AddExchangeDialog({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
onCreateExchange,
|
onCreateExchange,
|
||||||
}: AddExchangeDialogProps) {
|
}: AddExchangeDialogProps) {
|
||||||
const [formData, setFormData] = useState<CreateExchangeRequest>({
|
const {
|
||||||
code: '',
|
formData,
|
||||||
name: '',
|
errors,
|
||||||
country: '',
|
isSubmitting,
|
||||||
currency: '',
|
updateField,
|
||||||
active: true,
|
handleSubmit,
|
||||||
});
|
reset,
|
||||||
const [loading, setLoading] = useState(false);
|
} = useFormValidation(initialFormData, validateExchangeForm);
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
||||||
|
|
||||||
const validateForm = useCallback((): boolean => {
|
const onSubmit = useCallback(
|
||||||
const newErrors: Record<string, string> = {};
|
async (data: CreateExchangeRequest) => {
|
||||||
|
await onCreateExchange({
|
||||||
if (!formData.code.trim()) {
|
...data,
|
||||||
newErrors.code = 'Exchange code is required';
|
code: data.code.toUpperCase(),
|
||||||
} else if (formData.code.length > 10) {
|
country: data.country.toUpperCase(),
|
||||||
newErrors.code = 'Exchange code must be 10 characters or less';
|
currency: data.currency.toUpperCase(),
|
||||||
}
|
});
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[formData, validateForm, onCreateExchange]
|
[onCreateExchange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFormSubmit = useCallback(
|
||||||
|
(e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSubmit(onSubmit, onClose);
|
||||||
|
},
|
||||||
|
[handleSubmit, onSubmit, onClose]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
setFormData({
|
reset();
|
||||||
code: '',
|
|
||||||
name: '',
|
|
||||||
country: '',
|
|
||||||
currency: '',
|
|
||||||
active: true,
|
|
||||||
});
|
|
||||||
setErrors({});
|
|
||||||
onClose();
|
onClose();
|
||||||
}, [onClose]);
|
}, [reset, 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]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||||
|
|
@ -120,7 +61,7 @@ export function AddExchangeDialog({
|
||||||
</p>
|
</p>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleFormSubmit} className="space-y-4">
|
||||||
{/* Exchange Code */}
|
{/* Exchange Code */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="code" className="block text-sm font-medium text-text-primary mb-1">
|
<label htmlFor="code" className="block text-sm font-medium text-text-primary mb-1">
|
||||||
|
|
@ -130,7 +71,7 @@ export function AddExchangeDialog({
|
||||||
id="code"
|
id="code"
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.code}
|
value={formData.code}
|
||||||
onChange={e => handleInputChange('code', e.target.value)}
|
onChange={e => updateField('code', e.target.value)}
|
||||||
placeholder="e.g., NASDAQ, NYSE, TSX"
|
placeholder="e.g., NASDAQ, NYSE, TSX"
|
||||||
className={`w-full px-3 py-2 border rounded-md bg-surface text-text-primary focus:ring-2 focus:ring-primary-500 focus:border-primary-500 font-mono ${
|
className={`w-full px-3 py-2 border rounded-md bg-surface text-text-primary focus:ring-2 focus:ring-primary-500 focus:border-primary-500 font-mono ${
|
||||||
errors.code ? 'border-danger' : 'border-border'
|
errors.code ? 'border-danger' : 'border-border'
|
||||||
|
|
@ -150,7 +91,7 @@ export function AddExchangeDialog({
|
||||||
id="name"
|
id="name"
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={e => handleInputChange('name', e.target.value)}
|
onChange={e => updateField('name', e.target.value)}
|
||||||
placeholder="e.g., NASDAQ Stock Market, New York Stock Exchange"
|
placeholder="e.g., NASDAQ Stock Market, New York Stock Exchange"
|
||||||
className={`w-full px-3 py-2 border rounded-md bg-surface text-text-primary focus:ring-2 focus:ring-primary-500 focus:border-primary-500 ${
|
className={`w-full px-3 py-2 border rounded-md bg-surface text-text-primary focus:ring-2 focus:ring-primary-500 focus:border-primary-500 ${
|
||||||
errors.name ? 'border-danger' : 'border-border'
|
errors.name ? 'border-danger' : 'border-border'
|
||||||
|
|
@ -170,7 +111,7 @@ export function AddExchangeDialog({
|
||||||
id="country"
|
id="country"
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.country}
|
value={formData.country}
|
||||||
onChange={e => handleInputChange('country', e.target.value)}
|
onChange={e => updateField('country', e.target.value)}
|
||||||
placeholder="e.g., US, CA, GB"
|
placeholder="e.g., US, CA, GB"
|
||||||
className={`w-full px-3 py-2 border rounded-md bg-surface text-text-primary focus:ring-2 focus:ring-primary-500 focus:border-primary-500 font-mono ${
|
className={`w-full px-3 py-2 border rounded-md bg-surface text-text-primary focus:ring-2 focus:ring-primary-500 focus:border-primary-500 font-mono ${
|
||||||
errors.country ? 'border-danger' : 'border-border'
|
errors.country ? 'border-danger' : 'border-border'
|
||||||
|
|
@ -190,7 +131,7 @@ export function AddExchangeDialog({
|
||||||
id="currency"
|
id="currency"
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.currency}
|
value={formData.currency}
|
||||||
onChange={e => handleInputChange('currency', e.target.value)}
|
onChange={e => updateField('currency', e.target.value)}
|
||||||
placeholder="e.g., USD, EUR, CAD"
|
placeholder="e.g., USD, EUR, CAD"
|
||||||
className={`w-full px-3 py-2 border rounded-md bg-surface text-text-primary focus:ring-2 focus:ring-primary-500 focus:border-primary-500 font-mono ${
|
className={`w-full px-3 py-2 border rounded-md bg-surface text-text-primary focus:ring-2 focus:ring-primary-500 focus:border-primary-500 font-mono ${
|
||||||
errors.currency ? 'border-danger' : 'border-border'
|
errors.currency ? 'border-danger' : 'border-border'
|
||||||
|
|
@ -207,7 +148,7 @@ export function AddExchangeDialog({
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={formData.active}
|
checked={formData.active}
|
||||||
onChange={e => handleInputChange('active', e.target.checked)}
|
onChange={e => updateField('active', e.target.checked)}
|
||||||
className="rounded"
|
className="rounded"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-text-primary">Active exchange</span>
|
<span className="text-sm text-text-primary">Active exchange</span>
|
||||||
|
|
@ -219,11 +160,11 @@ export function AddExchangeDialog({
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="flex justify-end gap-2 pt-4">
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
<Button type="button" variant="outline" onClick={handleClose} disabled={loading}>
|
<Button type="button" variant="outline" onClick={handleClose} disabled={isSubmitting}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={loading}>
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
{loading ? 'Creating...' : 'Create Exchange'}
|
{isSubmitting ? 'Creating...' : 'Create Exchange'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ export function AddProviderMappingDialog({
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
loadProviders();
|
loadProviders();
|
||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen, loadProviders]);
|
||||||
|
|
||||||
// Load unmapped exchanges when provider changes
|
// Load unmapped exchanges when provider changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -42,7 +42,7 @@ export function AddProviderMappingDialog({
|
||||||
setUnmappedExchanges([]);
|
setUnmappedExchanges([]);
|
||||||
setSelectedProviderExchange('');
|
setSelectedProviderExchange('');
|
||||||
}
|
}
|
||||||
}, [selectedProvider]);
|
}, [selectedProvider, loadUnmappedExchanges]);
|
||||||
|
|
||||||
const loadProviders = useCallback(async () => {
|
const loadProviders = useCallback(async () => {
|
||||||
setProvidersLoading(true);
|
setProvidersLoading(true);
|
||||||
|
|
@ -50,7 +50,7 @@ export function AddProviderMappingDialog({
|
||||||
const providersData = await fetchProviders();
|
const providersData = await fetchProviders();
|
||||||
setProviders(providersData);
|
setProviders(providersData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading providers:', error);
|
// Error loading providers - could add toast notification here
|
||||||
} finally {
|
} finally {
|
||||||
setProvidersLoading(false);
|
setProvidersLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -63,7 +63,7 @@ export function AddProviderMappingDialog({
|
||||||
const exchangesData = await fetchUnmappedProviderExchanges(provider);
|
const exchangesData = await fetchUnmappedProviderExchanges(provider);
|
||||||
setUnmappedExchanges(exchangesData);
|
setUnmappedExchanges(exchangesData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading unmapped exchanges:', error);
|
// Error loading unmapped exchanges - could add toast notification here
|
||||||
} finally {
|
} finally {
|
||||||
setExchangesLoading(false);
|
setExchangesLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -103,7 +103,7 @@ export function AddProviderMappingDialog({
|
||||||
|
|
||||||
await onCreateMapping(request);
|
await onCreateMapping(request);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating provider mapping:', error);
|
// Error creating provider mapping - could add toast notification here
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ export function DeleteExchangeDialog({
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting exchange:', error);
|
// Error deleting exchange - could add toast notification here
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import { DataTable } from '@/components/ui';
|
import { DataTable } from '@/components/ui';
|
||||||
import { PlusIcon, XMarkIcon, CheckIcon, TrashIcon } from '@heroicons/react/24/outline';
|
import { PlusIcon, TrashIcon } from '@heroicons/react/24/outline';
|
||||||
import { ColumnDef } from '@tanstack/react-table';
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
import { useCallback, useMemo, useState, useEffect } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { useExchanges } from '../hooks/useExchanges';
|
import { useExchanges } from '../hooks/useExchanges';
|
||||||
import { Exchange, ProviderMapping } from '../types';
|
import { Exchange, ProviderMapping, EditingCell, AddProviderMappingDialogState, DeleteDialogState } from '../types';
|
||||||
import { AddProviderMappingDialog } from './AddProviderMappingDialog';
|
import { AddProviderMappingDialog } from './AddProviderMappingDialog';
|
||||||
import { DeleteExchangeDialog } from './DeleteExchangeDialog';
|
import { DeleteExchangeDialog } from './DeleteExchangeDialog';
|
||||||
|
import { sortProviderMappings, getProviderMappingColor, formatProviderMapping, formatDate } from '../utils/formatters';
|
||||||
|
|
||||||
export function ExchangesTable() {
|
export function ExchangesTable() {
|
||||||
const {
|
const {
|
||||||
|
|
@ -19,17 +20,10 @@ export function ExchangesTable() {
|
||||||
refetch
|
refetch
|
||||||
} = useExchanges();
|
} = useExchanges();
|
||||||
|
|
||||||
const [editingCell, setEditingCell] = useState<{ id: string; field: string } | null>(null);
|
const [editingCell, setEditingCell] = useState<EditingCell | null>(null);
|
||||||
const [editValue, setEditValue] = useState('');
|
const [editValue, setEditValue] = useState('');
|
||||||
const [addProviderDialog, setAddProviderDialog] = useState<{
|
const [addProviderDialog, setAddProviderDialog] = useState<AddProviderMappingDialogState | null>(null);
|
||||||
exchangeId: string;
|
const [deleteDialog, setDeleteDialog] = useState<DeleteDialogState | null>(null);
|
||||||
exchangeName: string;
|
|
||||||
} | null>(null);
|
|
||||||
const [deleteDialog, setDeleteDialog] = useState<{
|
|
||||||
exchangeId: string;
|
|
||||||
exchangeName: string;
|
|
||||||
providerMappingCount: number;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
const handleCellEdit = useCallback(
|
const handleCellEdit = useCallback(
|
||||||
async (id: string, field: string, value: string) => {
|
async (id: string, field: string, value: string) => {
|
||||||
|
|
@ -246,13 +240,7 @@ export function ExchangesTable() {
|
||||||
|
|
||||||
// Get provider mappings directly from the exchange data
|
// Get provider mappings directly from the exchange data
|
||||||
const mappings = row.original.provider_mappings || [];
|
const mappings = row.original.provider_mappings || [];
|
||||||
|
const sortedMappings = sortProviderMappings(mappings);
|
||||||
// Sort mappings to show active ones first
|
|
||||||
const sortedMappings = [...mappings].sort((a, b) => {
|
|
||||||
if (a.active && !b.active) return -1;
|
|
||||||
if (!a.active && b.active) return 1;
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
|
|
@ -268,9 +256,8 @@ export function ExchangesTable() {
|
||||||
{mappings.length > 0 ? (
|
{mappings.length > 0 ? (
|
||||||
<div className="flex flex-wrap gap-1 text-xs">
|
<div className="flex flex-wrap gap-1 text-xs">
|
||||||
{sortedMappings.slice(0, 3).map((mapping, index) => (
|
{sortedMappings.slice(0, 3).map((mapping, index) => (
|
||||||
<span key={index} className={mapping.active ? 'text-green-500' : 'text-text-muted'}>
|
<span key={index} className={getProviderMappingColor(mapping)}>
|
||||||
<span className={mapping.active ? 'font-bold text-green-500' : 'font-bold text-text-muted'}>{mapping.provider.toLowerCase()}</span>
|
{formatProviderMapping(mapping)}
|
||||||
<span className={mapping.active ? 'text-green-500' : 'text-text-muted'}>({mapping.provider_exchange_code})</span>
|
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{sortedMappings.length > 3 && (
|
{sortedMappings.length > 3 && (
|
||||||
|
|
@ -321,7 +308,7 @@ export function ExchangesTable() {
|
||||||
size: 120,
|
size: 120,
|
||||||
cell: ({ getValue }) => (
|
cell: ({ getValue }) => (
|
||||||
<span className="text-xs text-text-muted">
|
<span className="text-xs text-text-muted">
|
||||||
{new Date(getValue() as string).toLocaleDateString()}
|
{formatDate(getValue() as string)}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
@ -392,7 +379,7 @@ export function ExchangesTable() {
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 mt-1 text-xs text-text-muted">
|
<div className="flex items-center gap-4 mt-1 text-xs text-text-muted">
|
||||||
<span>Confidence: {mapping.confidence}</span>
|
<span>Confidence: {mapping.confidence}</span>
|
||||||
<span>Created: {new Date(mapping.created_at).toLocaleDateString()}</span>
|
<span>Created: {formatDate(mapping.created_at)}</span>
|
||||||
{mapping.auto_mapped && <span className="text-yellow-400">Auto-mapped</span>}
|
{mapping.auto_mapped && <span className="text-yellow-400">Auto-mapped</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,16 @@
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
CreateProviderMappingRequest,
|
|
||||||
CreateExchangeRequest,
|
|
||||||
Exchange,
|
Exchange,
|
||||||
ExchangeDetails,
|
ExchangeDetails,
|
||||||
ExchangeStats,
|
ExchangeStats,
|
||||||
ProviderMapping,
|
ProviderMapping,
|
||||||
ProviderExchange,
|
ProviderExchange,
|
||||||
|
CreateExchangeRequest,
|
||||||
UpdateExchangeRequest,
|
UpdateExchangeRequest,
|
||||||
|
CreateProviderMappingRequest,
|
||||||
UpdateProviderMappingRequest,
|
UpdateProviderMappingRequest,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
import { exchangeApi } from '../services/exchangeApi';
|
||||||
const API_BASE_URL = 'http://localhost:4000/api';
|
|
||||||
|
|
||||||
export function useExchanges() {
|
export function useExchanges() {
|
||||||
const [exchanges, setExchanges] = useState<Exchange[]>([]);
|
const [exchanges, setExchanges] = useState<Exchange[]>([]);
|
||||||
|
|
@ -19,29 +18,13 @@ export function useExchanges() {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const fetchExchanges = useCallback(async () => {
|
const fetchExchanges = useCallback(async () => {
|
||||||
console.log('fetchExchanges called');
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
const data = await exchangeApi.getExchanges();
|
||||||
console.log('Making fetch request to:', `${API_BASE_URL}/exchanges`);
|
setExchanges(data);
|
||||||
const response = await fetch(`${API_BASE_URL}/exchanges`);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch exchanges: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
console.log('API response:', data);
|
|
||||||
|
|
||||||
if (data.success) {
|
|
||||||
console.log('Setting exchanges:', data.data);
|
|
||||||
setExchanges(data.data || []);
|
|
||||||
} else {
|
|
||||||
throw new Error(data.error || 'API returned error status');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching exchanges:', err);
|
// Error fetching exchanges - error state will show in UI
|
||||||
setError(err instanceof Error ? err.message : 'Failed to fetch exchanges');
|
setError(err instanceof Error ? err.message : 'Failed to fetch exchanges');
|
||||||
setExchanges([]);
|
setExchanges([]);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -50,30 +33,13 @@ export function useExchanges() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateExchange = useCallback(
|
const updateExchange = useCallback(
|
||||||
async (id: string, updates: UpdateExchangeRequest) => {
|
async (id: string, updates: UpdateExchangeRequest): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}/exchanges/${id}`, {
|
await exchangeApi.updateExchange(id, updates);
|
||||||
method: 'PATCH',
|
await fetchExchanges(); // Refresh the list
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(updates),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to update exchange: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
if (!result.success) {
|
|
||||||
throw new Error(result.error || 'Failed to update exchange');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh the exchanges list
|
|
||||||
await fetchExchanges();
|
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error updating exchange:', err);
|
// Error updating exchange - error state will show in UI
|
||||||
setError(err instanceof Error ? err.message : 'Failed to update exchange');
|
setError(err instanceof Error ? err.message : 'Failed to update exchange');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -81,43 +47,39 @@ export function useExchanges() {
|
||||||
[fetchExchanges]
|
[fetchExchanges]
|
||||||
);
|
);
|
||||||
|
|
||||||
const fetchExchangeDetails = useCallback(async (id: string): Promise<ExchangeDetails | null> => {
|
const createExchange = useCallback(
|
||||||
try {
|
async (request: CreateExchangeRequest): Promise<Exchange> => {
|
||||||
const response = await fetch(`${API_BASE_URL}/exchanges/${id}`);
|
try {
|
||||||
|
const exchange = await exchangeApi.createExchange(request);
|
||||||
if (!response.ok) {
|
await fetchExchanges(); // Refresh the list
|
||||||
throw new Error(`Failed to fetch exchange details: ${response.statusText}`);
|
return exchange;
|
||||||
|
} catch (err) {
|
||||||
|
// Error creating exchange - error state will show in UI
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to create exchange');
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
[fetchExchanges]
|
||||||
|
);
|
||||||
|
|
||||||
const result = await response.json();
|
const fetchExchangeDetails = useCallback(
|
||||||
if (!result.success) {
|
async (id: string): Promise<ExchangeDetails | null> => {
|
||||||
throw new Error(result.error || 'Failed to fetch exchange details');
|
try {
|
||||||
|
return await exchangeApi.getExchangeById(id);
|
||||||
|
} catch (err) {
|
||||||
|
// Error fetching exchange details - error state will show in UI
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to fetch exchange details');
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
return result.data;
|
[]
|
||||||
} catch (err) {
|
);
|
||||||
console.error('Error fetching exchange details:', err);
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to fetch exchange details');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchStats = useCallback(async (): Promise<ExchangeStats | null> => {
|
const fetchStats = useCallback(async (): Promise<ExchangeStats | null> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}/exchanges/stats/summary`);
|
return await exchangeApi.getExchangeStats();
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch stats: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
if (!result.success) {
|
|
||||||
throw new Error(result.error || 'Failed to fetch stats');
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.data;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching stats:', err);
|
// Error fetching stats - error state will show in UI
|
||||||
setError(err instanceof Error ? err.message : 'Failed to fetch stats');
|
setError(err instanceof Error ? err.message : 'Failed to fetch stats');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -126,24 +88,9 @@ export function useExchanges() {
|
||||||
const fetchProviderMappings = useCallback(
|
const fetchProviderMappings = useCallback(
|
||||||
async (provider?: string): Promise<ProviderMapping[]> => {
|
async (provider?: string): Promise<ProviderMapping[]> => {
|
||||||
try {
|
try {
|
||||||
const url = provider
|
return await exchangeApi.getProviderMappings(provider);
|
||||||
? `${API_BASE_URL}/exchanges/provider-mappings/${provider}`
|
|
||||||
: `${API_BASE_URL}/exchanges/provider-mappings/all`;
|
|
||||||
|
|
||||||
const response = await fetch(url);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch provider mappings: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
if (!result.success) {
|
|
||||||
throw new Error(result.error || 'Failed to fetch provider mappings');
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.data || [];
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching provider mappings:', err);
|
// Error fetching provider mappings - error state will show in UI
|
||||||
setError(err instanceof Error ? err.message : 'Failed to fetch provider mappings');
|
setError(err instanceof Error ? err.message : 'Failed to fetch provider mappings');
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
@ -152,28 +99,12 @@ export function useExchanges() {
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateProviderMapping = useCallback(
|
const updateProviderMapping = useCallback(
|
||||||
async (id: string, updates: UpdateProviderMappingRequest) => {
|
async (id: string, updates: UpdateProviderMappingRequest): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}/exchanges/provider-mappings/${id}`, {
|
await exchangeApi.updateProviderMapping(id, updates);
|
||||||
method: 'PATCH',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(updates),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to update provider mapping: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
if (!result.success) {
|
|
||||||
throw new Error(result.error || 'Failed to update provider mapping');
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error updating provider mapping:', err);
|
// Error updating provider mapping - error state will show in UI
|
||||||
setError(err instanceof Error ? err.message : 'Failed to update provider mapping');
|
setError(err instanceof Error ? err.message : 'Failed to update provider mapping');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -181,49 +112,24 @@ export function useExchanges() {
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const createProviderMapping = useCallback(async (request: CreateProviderMappingRequest) => {
|
const createProviderMapping = useCallback(
|
||||||
try {
|
async (request: CreateProviderMappingRequest): Promise<ProviderMapping | null> => {
|
||||||
const response = await fetch(`${API_BASE_URL}/exchanges/provider-mappings`, {
|
try {
|
||||||
method: 'POST',
|
return await exchangeApi.createProviderMapping(request);
|
||||||
headers: {
|
} catch (err) {
|
||||||
'Content-Type': 'application/json',
|
// Error creating provider mapping - error state will show in UI
|
||||||
},
|
setError(err instanceof Error ? err.message : 'Failed to create provider mapping');
|
||||||
body: JSON.stringify(request),
|
return null;
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to create provider mapping: ${response.statusText}`);
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
const result = await response.json();
|
[]
|
||||||
if (!result.success) {
|
);
|
||||||
throw new Error(result.error || 'Failed to create provider mapping');
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.data;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error creating provider mapping:', err);
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to create provider mapping');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchProviders = useCallback(async (): Promise<string[]> => {
|
const fetchProviders = useCallback(async (): Promise<string[]> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}/exchanges/providers/list`);
|
return await exchangeApi.getProviders();
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch providers: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
if (!result.success) {
|
|
||||||
throw new Error(result.error || 'Failed to fetch providers');
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.data || [];
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching providers:', err);
|
// Error fetching providers - error state will show in UI
|
||||||
setError(err instanceof Error ? err.message : 'Failed to fetch providers');
|
setError(err instanceof Error ? err.message : 'Failed to fetch providers');
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
@ -232,20 +138,9 @@ export function useExchanges() {
|
||||||
const fetchUnmappedProviderExchanges = useCallback(
|
const fetchUnmappedProviderExchanges = useCallback(
|
||||||
async (provider: string): Promise<ProviderExchange[]> => {
|
async (provider: string): Promise<ProviderExchange[]> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}/exchanges/provider-exchanges/unmapped/${provider}`);
|
return await exchangeApi.getUnmappedProviderExchanges(provider);
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch unmapped exchanges: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
if (!result.success) {
|
|
||||||
throw new Error(result.error || 'Failed to fetch unmapped exchanges');
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.data || [];
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching unmapped exchanges:', err);
|
// Error fetching unmapped exchanges - error state will show in UI
|
||||||
setError(err instanceof Error ? err.message : 'Failed to fetch unmapped exchanges');
|
setError(err instanceof Error ? err.message : 'Failed to fetch unmapped exchanges');
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
@ -253,37 +148,7 @@ export function useExchanges() {
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const createExchange = useCallback(async (request: CreateExchangeRequest) => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE_URL}/exchanges`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(request),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to create exchange: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
if (!result.success) {
|
|
||||||
throw new Error(result.error || 'Failed to create exchange');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh the exchanges list
|
|
||||||
await fetchExchanges();
|
|
||||||
return result.data;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error creating exchange:', err);
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to create exchange');
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}, [fetchExchanges]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('useExchanges useEffect triggered');
|
|
||||||
fetchExchanges();
|
fetchExchanges();
|
||||||
}, [fetchExchanges]);
|
}, [fetchExchanges]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { FormErrors } from '../types';
|
||||||
|
|
||||||
|
export function useFormValidation<T>(
|
||||||
|
initialData: T,
|
||||||
|
validateFn: (data: T) => FormErrors
|
||||||
|
) {
|
||||||
|
const [formData, setFormData] = useState<T>(initialData);
|
||||||
|
const [errors, setErrors] = useState<FormErrors>({});
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const updateField = useCallback((field: keyof T, value: T[keyof T]) => {
|
||||||
|
setFormData(prev => ({ ...prev, [field]: value }));
|
||||||
|
|
||||||
|
// Clear error when user starts typing
|
||||||
|
if (errors[field as string]) {
|
||||||
|
setErrors(prev => ({ ...prev, [field as string]: '' }));
|
||||||
|
}
|
||||||
|
}, [errors]);
|
||||||
|
|
||||||
|
const validate = useCallback((): boolean => {
|
||||||
|
const newErrors = validateFn(formData);
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
}, [formData, validateFn]);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setFormData(initialData);
|
||||||
|
setErrors({});
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}, [initialData]);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async (
|
||||||
|
onSubmit: (data: T) => Promise<void>,
|
||||||
|
onSuccess?: () => void,
|
||||||
|
onError?: (error: unknown) => void
|
||||||
|
) => {
|
||||||
|
if (!validate()) return;
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await onSubmit(formData);
|
||||||
|
reset();
|
||||||
|
onSuccess?.();
|
||||||
|
} catch (error) {
|
||||||
|
onError?.(error);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [formData, validate, reset]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
formData,
|
||||||
|
errors,
|
||||||
|
isSubmitting,
|
||||||
|
updateField,
|
||||||
|
validate,
|
||||||
|
reset,
|
||||||
|
handleSubmit,
|
||||||
|
setIsSubmitting,
|
||||||
|
};
|
||||||
|
}
|
||||||
135
apps/web-app/src/features/exchanges/services/exchangeApi.ts
Normal file
135
apps/web-app/src/features/exchanges/services/exchangeApi.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
import {
|
||||||
|
ApiResponse,
|
||||||
|
Exchange,
|
||||||
|
ExchangeDetails,
|
||||||
|
ExchangeStats,
|
||||||
|
ProviderMapping,
|
||||||
|
ProviderExchange,
|
||||||
|
CreateExchangeRequest,
|
||||||
|
UpdateExchangeRequest,
|
||||||
|
CreateProviderMappingRequest,
|
||||||
|
UpdateProviderMappingRequest,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
const API_BASE_URL = 'http://localhost:4000/api';
|
||||||
|
|
||||||
|
class ExchangeApiService {
|
||||||
|
private async request<T>(
|
||||||
|
endpoint: string,
|
||||||
|
options?: RequestInit
|
||||||
|
): Promise<ApiResponse<T>> {
|
||||||
|
const url = `${API_BASE_URL}${endpoint}`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options?.headers,
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.error || 'API request failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exchanges
|
||||||
|
async getExchanges(): Promise<Exchange[]> {
|
||||||
|
const response = await this.request<Exchange[]>('/exchanges');
|
||||||
|
return response.data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getExchangeById(id: string): Promise<ExchangeDetails | null> {
|
||||||
|
const response = await this.request<ExchangeDetails>(`/exchanges/${id}`);
|
||||||
|
return response.data || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createExchange(data: CreateExchangeRequest): Promise<Exchange> {
|
||||||
|
const response = await this.request<Exchange>('/exchanges', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!response.data) {
|
||||||
|
throw new Error('No exchange data returned');
|
||||||
|
}
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateExchange(id: string, data: UpdateExchangeRequest): Promise<Exchange> {
|
||||||
|
const response = await this.request<Exchange>(`/exchanges/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!response.data) {
|
||||||
|
throw new Error('No exchange data returned');
|
||||||
|
}
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider Mappings
|
||||||
|
async getProviderMappings(provider?: string): Promise<ProviderMapping[]> {
|
||||||
|
const endpoint = provider
|
||||||
|
? `/exchanges/provider-mappings/${provider}`
|
||||||
|
: '/exchanges/provider-mappings/all';
|
||||||
|
|
||||||
|
const response = await this.request<ProviderMapping[]>(endpoint);
|
||||||
|
return response.data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async createProviderMapping(data: CreateProviderMappingRequest): Promise<ProviderMapping> {
|
||||||
|
const response = await this.request<ProviderMapping>('/exchanges/provider-mappings', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!response.data) {
|
||||||
|
throw new Error('No provider mapping data returned');
|
||||||
|
}
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProviderMapping(
|
||||||
|
id: string,
|
||||||
|
data: UpdateProviderMappingRequest
|
||||||
|
): Promise<ProviderMapping> {
|
||||||
|
const response = await this.request<ProviderMapping>(`/exchanges/provider-mappings/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!response.data) {
|
||||||
|
throw new Error('No provider mapping data returned');
|
||||||
|
}
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Providers and Utilities
|
||||||
|
async getProviders(): Promise<string[]> {
|
||||||
|
const response = await this.request<string[]>('/exchanges/providers/list');
|
||||||
|
return response.data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUnmappedProviderExchanges(provider: string): Promise<ProviderExchange[]> {
|
||||||
|
const response = await this.request<ProviderExchange[]>(
|
||||||
|
`/exchanges/provider-exchanges/unmapped/${provider}`
|
||||||
|
);
|
||||||
|
return response.data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getExchangeStats(): Promise<ExchangeStats> {
|
||||||
|
const response = await this.request<ExchangeStats>('/exchanges/stats/summary');
|
||||||
|
if (!response.data) {
|
||||||
|
throw new Error('No exchange stats data returned');
|
||||||
|
}
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
export const exchangeApi = new ExchangeApiService();
|
||||||
69
apps/web-app/src/features/exchanges/types/api.types.ts
Normal file
69
apps/web-app/src/features/exchanges/types/api.types.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
// API Response types
|
||||||
|
export interface ApiResponse<T = unknown> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
total?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base entity types
|
||||||
|
export interface BaseEntity {
|
||||||
|
id: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderMapping extends BaseEntity {
|
||||||
|
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;
|
||||||
|
master_exchange_code?: string;
|
||||||
|
master_exchange_name?: string;
|
||||||
|
master_exchange_active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Exchange extends BaseEntity {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
country: string;
|
||||||
|
currency: string;
|
||||||
|
active: boolean;
|
||||||
|
visible: boolean;
|
||||||
|
provider_mapping_count: string;
|
||||||
|
active_mapping_count: string;
|
||||||
|
verified_mapping_count: string;
|
||||||
|
providers: string | null;
|
||||||
|
provider_mappings: ProviderMapping[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExchangeDetails {
|
||||||
|
exchange: Exchange;
|
||||||
|
provider_mappings: ProviderMapping[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderExchange {
|
||||||
|
provider_exchange_code: string;
|
||||||
|
provider_exchange_name: string;
|
||||||
|
country_code: string | null;
|
||||||
|
currency: string | null;
|
||||||
|
symbol_count: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExchangeStats {
|
||||||
|
total_exchanges: string;
|
||||||
|
active_exchanges: string;
|
||||||
|
countries: string;
|
||||||
|
currencies: string;
|
||||||
|
total_provider_mappings: string;
|
||||||
|
active_provider_mappings: string;
|
||||||
|
verified_provider_mappings: string;
|
||||||
|
providers: string;
|
||||||
|
}
|
||||||
43
apps/web-app/src/features/exchanges/types/component.types.ts
Normal file
43
apps/web-app/src/features/exchanges/types/component.types.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
// Component-specific types
|
||||||
|
export interface EditingCell {
|
||||||
|
id: string;
|
||||||
|
field: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddProviderMappingDialogState {
|
||||||
|
exchangeId: string;
|
||||||
|
exchangeName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeleteDialogState {
|
||||||
|
exchangeId: string;
|
||||||
|
exchangeName: string;
|
||||||
|
providerMappingCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormErrors {
|
||||||
|
[key: string]: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dialog props interfaces
|
||||||
|
export interface BaseDialogProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddExchangeDialogProps extends BaseDialogProps {
|
||||||
|
onCreateExchange: (request: import('./request.types').CreateExchangeRequest) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddProviderMappingDialogProps extends BaseDialogProps {
|
||||||
|
exchangeId: string;
|
||||||
|
exchangeName: string;
|
||||||
|
onCreateMapping: (request: import('./request.types').CreateProviderMappingRequest) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeleteExchangeDialogProps extends BaseDialogProps {
|
||||||
|
exchangeId: string;
|
||||||
|
exchangeName: string;
|
||||||
|
providerMappingCount: number;
|
||||||
|
onConfirmDelete: (exchangeId: string) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
@ -1,100 +1,7 @@
|
||||||
export interface ProviderMapping {
|
// Re-export all types from organized files
|
||||||
id: string;
|
export * from './api.types';
|
||||||
provider: string;
|
export * from './request.types';
|
||||||
provider_exchange_code: string;
|
export * from './component.types';
|
||||||
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 Exchange {
|
// Legacy compatibility - can be removed later
|
||||||
id: string;
|
export type ExchangesApiResponse<T = unknown> = import('./api.types').ApiResponse<T>;
|
||||||
code: string;
|
|
||||||
name: string;
|
|
||||||
country: string;
|
|
||||||
currency: string;
|
|
||||||
active: boolean;
|
|
||||||
visible: boolean;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
provider_mapping_count: string;
|
|
||||||
active_mapping_count: string;
|
|
||||||
verified_mapping_count: string;
|
|
||||||
providers: string | null;
|
|
||||||
provider_mappings: ProviderMapping[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExchangeDetails {
|
|
||||||
exchange: Exchange;
|
|
||||||
provider_mappings: ProviderMapping[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExchangesApiResponse {
|
|
||||||
success: boolean;
|
|
||||||
data: Exchange[];
|
|
||||||
total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateExchangeRequest {
|
|
||||||
name?: string;
|
|
||||||
active?: boolean;
|
|
||||||
visible?: boolean;
|
|
||||||
country?: string;
|
|
||||||
currency?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateProviderMappingRequest {
|
|
||||||
active?: boolean;
|
|
||||||
verified?: boolean;
|
|
||||||
confidence?: number;
|
|
||||||
master_exchange_id?: 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 CreateExchangeRequest {
|
|
||||||
code: string;
|
|
||||||
name: string;
|
|
||||||
country: string;
|
|
||||||
currency: string;
|
|
||||||
active?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExchangeStats {
|
|
||||||
total_exchanges: string;
|
|
||||||
active_exchanges: string;
|
|
||||||
countries: string;
|
|
||||||
currencies: string;
|
|
||||||
total_provider_mappings: string;
|
|
||||||
active_provider_mappings: string;
|
|
||||||
verified_provider_mappings: string;
|
|
||||||
providers: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProviderExchange {
|
|
||||||
provider_exchange_code: string;
|
|
||||||
provider_exchange_name: string;
|
|
||||||
country_code: string | null;
|
|
||||||
currency: string | null;
|
|
||||||
symbol_count: number | null;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
35
apps/web-app/src/features/exchanges/types/request.types.ts
Normal file
35
apps/web-app/src/features/exchanges/types/request.types.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
// Request types for API calls
|
||||||
|
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;
|
||||||
|
}
|
||||||
35
apps/web-app/src/features/exchanges/utils/formatters.ts
Normal file
35
apps/web-app/src/features/exchanges/utils/formatters.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { ProviderMapping } from '../types';
|
||||||
|
|
||||||
|
export function formatDate(dateString: string): string {
|
||||||
|
return new Date(dateString).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatProviderMapping(mapping: ProviderMapping): string {
|
||||||
|
return `${mapping.provider.toLowerCase()}(${mapping.provider_exchange_code})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProviderMappingColor(mapping: ProviderMapping): string {
|
||||||
|
return mapping.active ? 'text-green-500' : 'text-text-muted';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortProviderMappings(mappings: ProviderMapping[]): ProviderMapping[] {
|
||||||
|
return [...mappings].sort((a, b) => {
|
||||||
|
// Active mappings first
|
||||||
|
if (a.active && !b.active) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (!a.active && b.active) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then by provider name
|
||||||
|
return a.provider.localeCompare(b.provider);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncateText(text: string, maxLength: number): string {
|
||||||
|
if (text.length <= maxLength) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
return text.substring(0, maxLength) + '...';
|
||||||
|
}
|
||||||
38
apps/web-app/src/features/exchanges/utils/validation.ts
Normal file
38
apps/web-app/src/features/exchanges/utils/validation.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { FormErrors } from '../types';
|
||||||
|
|
||||||
|
export function validateExchangeForm(data: {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
country: string;
|
||||||
|
currency: string;
|
||||||
|
}): FormErrors {
|
||||||
|
const errors: FormErrors = {};
|
||||||
|
|
||||||
|
if (!data.code.trim()) {
|
||||||
|
errors.code = 'Exchange code is required';
|
||||||
|
} else if (data.code.length > 10) {
|
||||||
|
errors.code = 'Exchange code must be 10 characters or less';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.name.trim()) {
|
||||||
|
errors.name = 'Exchange name is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.country.trim()) {
|
||||||
|
errors.country = 'Country is required';
|
||||||
|
} else if (data.country.length !== 2) {
|
||||||
|
errors.country = 'Country must be exactly 2 characters (e.g., US, CA, GB)';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.currency.trim()) {
|
||||||
|
errors.currency = 'Currency is required';
|
||||||
|
} else if (data.currency.length !== 3) {
|
||||||
|
errors.currency = 'Currency must be exactly 3 characters (e.g., USD, EUR, CAD)';
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasValidationErrors(errors: FormErrors): boolean {
|
||||||
|
return Object.keys(errors).length > 0;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue