From 1d299e52d446185cb0b98d64ecdea1d508391b09 Mon Sep 17 00:00:00 2001
From: Boki
Date: Wed, 18 Jun 2025 09:41:25 -0400
Subject: [PATCH] added ability to add exchanges and a custom delete exchange
dialog
---
apps/web-api/src/routes/exchange.routes.ts | 92 +++++++
.../src/features/exchanges/ExchangesPage.tsx | 50 +++-
.../components/AddExchangeDialog.tsx | 233 ++++++++++++++++++
.../components/DeleteExchangeDialog.tsx | 110 +++++++++
.../exchanges/components/ExchangesTable.tsx | 40 ++-
.../features/exchanges/components/index.ts | 2 +
.../features/exchanges/hooks/useExchanges.ts | 31 +++
.../src/features/exchanges/types/index.ts | 8 +
8 files changed, 550 insertions(+), 16 deletions(-)
create mode 100644 apps/web-app/src/features/exchanges/components/AddExchangeDialog.tsx
create mode 100644 apps/web-app/src/features/exchanges/components/DeleteExchangeDialog.tsx
diff --git a/apps/web-api/src/routes/exchange.routes.ts b/apps/web-api/src/routes/exchange.routes.ts
index 76def4d..1837e09 100644
--- a/apps/web-api/src/routes/exchange.routes.ts
+++ b/apps/web-api/src/routes/exchange.routes.ts
@@ -714,6 +714,98 @@ exchangeRoutes.get('/provider-exchanges/unmapped/:provider', async c => {
}
});
+// Create new exchange
+exchangeRoutes.post('/', async c => {
+ 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,
+ });
+
+ return c.json(
+ {
+ success: true,
+ data: result.rows[0],
+ message: 'Exchange created successfully',
+ },
+ 201
+ );
+ } catch (error) {
+ logger.error('Failed to create exchange', { error });
+
+ // 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
+ );
+ }
+
+ return c.json(
+ {
+ success: false,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ },
+ 500
+ );
+ }
+});
+
// Get exchange statistics
exchangeRoutes.get('/stats/summary', async c => {
try {
diff --git a/apps/web-app/src/features/exchanges/ExchangesPage.tsx b/apps/web-app/src/features/exchanges/ExchangesPage.tsx
index ba588cd..447cec5 100644
--- a/apps/web-app/src/features/exchanges/ExchangesPage.tsx
+++ b/apps/web-app/src/features/exchanges/ExchangesPage.tsx
@@ -3,16 +3,21 @@ import {
CheckCircleIcon,
ExclamationTriangleIcon,
XMarkIcon,
+ PlusIcon,
} from '@heroicons/react/24/outline';
import { useEffect, useState } from 'react';
import { ExchangesTable } from './components/ExchangesTable';
+import { AddExchangeDialog } from './components/AddExchangeDialog';
+import { useExchanges } from './hooks/useExchanges';
export function ExchangesPage() {
+ const { createExchange } = useExchanges();
const [syncing, setSyncing] = useState(false);
const [syncStatus, setSyncStatus] = useState<{
type: 'success' | 'error' | null;
message: string;
}>({ type: null, message: '' });
+ const [showAddDialog, setShowAddDialog] = useState(false);
// Auto-dismiss success messages after 5 seconds
useEffect(() => {
@@ -54,14 +59,23 @@ export function ExchangesPage() {
Configure and manage master exchanges with their data sources and providers.
-
+
+
+
+
{syncing && (
@@ -108,6 +122,26 @@ export function ExchangesPage() {
+
+ setShowAddDialog(false)}
+ onCreateExchange={async (exchangeRequest) => {
+ try {
+ await createExchange(exchangeRequest);
+ setShowAddDialog(false);
+ setSyncStatus({
+ type: 'success',
+ message: `Exchange "${exchangeRequest.code}" created successfully!`,
+ });
+ } catch (error) {
+ setSyncStatus({
+ type: 'error',
+ message: error instanceof Error ? error.message : 'Failed to create exchange',
+ });
+ }
+ }}
+ />
);
}
diff --git a/apps/web-app/src/features/exchanges/components/AddExchangeDialog.tsx b/apps/web-app/src/features/exchanges/components/AddExchangeDialog.tsx
new file mode 100644
index 0000000..e28e5f0
--- /dev/null
+++ b/apps/web-app/src/features/exchanges/components/AddExchangeDialog.tsx
@@ -0,0 +1,233 @@
+import { Dialog, DialogContent, DialogHeader, DialogTitle, Button } from '@/components/ui';
+import { useCallback, useState } from 'react';
+import { CreateExchangeRequest } from '../types';
+
+interface AddExchangeDialogProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onCreateExchange: (request: CreateExchangeRequest) => Promise;
+}
+
+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 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);
+ }
+ },
+ [formData, validateForm, onCreateExchange]
+ );
+
+ const handleClose = useCallback(() => {
+ setFormData({
+ code: '',
+ name: '',
+ country: '',
+ currency: '',
+ active: true,
+ });
+ setErrors({});
+ 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]
+ );
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/apps/web-app/src/features/exchanges/components/DeleteExchangeDialog.tsx b/apps/web-app/src/features/exchanges/components/DeleteExchangeDialog.tsx
new file mode 100644
index 0000000..d7fc095
--- /dev/null
+++ b/apps/web-app/src/features/exchanges/components/DeleteExchangeDialog.tsx
@@ -0,0 +1,110 @@
+import { Dialog, DialogContent, DialogHeader, DialogTitle, Button } from '@/components/ui';
+import { useCallback, useState } from 'react';
+import { TrashIcon, ExclamationTriangleIcon } from '@heroicons/react/24/outline';
+
+interface DeleteExchangeDialogProps {
+ isOpen: boolean;
+ exchangeId: string;
+ exchangeName: string;
+ providerMappingCount: number;
+ onClose: () => void;
+ onConfirmDelete: (exchangeId: string) => Promise;
+}
+
+export function DeleteExchangeDialog({
+ isOpen,
+ exchangeId,
+ exchangeName,
+ providerMappingCount,
+ onClose,
+ onConfirmDelete,
+}: DeleteExchangeDialogProps) {
+ const [loading, setLoading] = useState(false);
+
+ const handleConfirm = useCallback(async () => {
+ setLoading(true);
+ try {
+ const success = await onConfirmDelete(exchangeId);
+ if (success) {
+ onClose();
+ }
+ } catch (error) {
+ console.error('Error deleting exchange:', error);
+ } finally {
+ setLoading(false);
+ }
+ }, [exchangeId, onConfirmDelete, onClose]);
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/apps/web-app/src/features/exchanges/components/ExchangesTable.tsx b/apps/web-app/src/features/exchanges/components/ExchangesTable.tsx
index cc8558e..37d2a44 100644
--- a/apps/web-app/src/features/exchanges/components/ExchangesTable.tsx
+++ b/apps/web-app/src/features/exchanges/components/ExchangesTable.tsx
@@ -5,6 +5,7 @@ import { useCallback, useMemo, useState, useEffect } from 'react';
import { useExchanges } from '../hooks/useExchanges';
import { Exchange, ProviderMapping } from '../types';
import { AddProviderMappingDialog } from './AddProviderMappingDialog';
+import { DeleteExchangeDialog } from './DeleteExchangeDialog';
export function ExchangesTable() {
const {
@@ -24,6 +25,11 @@ export function ExchangesTable() {
exchangeId: string;
exchangeName: string;
} | null>(null);
+ const [deleteDialog, setDeleteDialog] = useState<{
+ exchangeId: string;
+ exchangeName: string;
+ providerMappingCount: number;
+ } | null>(null);
const handleCellEdit = useCallback(
async (id: string, field: string, value: string) => {
@@ -47,14 +53,16 @@ export function ExchangesTable() {
setAddProviderDialog({ exchangeId, exchangeName });
}, []);
- const handleDeleteExchange = useCallback(async (exchangeId: string, exchangeName: string) => {
- if (confirm(`Are you sure you want to delete "${exchangeName}"? This will hide the exchange and make all its provider mappings available for remapping.`)) {
- const success = await updateExchange(exchangeId, { visible: false });
- if (success) {
- // Optionally refresh the list or show a success message
- refetch();
- }
+ const handleDeleteExchange = useCallback((exchangeId: string, exchangeName: string, providerMappingCount: number) => {
+ setDeleteDialog({ exchangeId, exchangeName, providerMappingCount });
+ }, []);
+
+ const handleConfirmDelete = useCallback(async (exchangeId: string) => {
+ const success = await updateExchange(exchangeId, { visible: false });
+ if (success) {
+ refetch();
}
+ return success;
}, [updateExchange, refetch]);
const handleToggleProviderMapping = useCallback(
@@ -292,7 +300,11 @@ export function ExchangesTable() {
Add