diff --git a/backend/models.py b/backend/models.py index 3a52d0f..4184720 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,4 +1,3 @@ -import uuid from datetime import UTC, date, datetime, time from enum import Enum as PyEnum from typing import TypedDict @@ -17,7 +16,6 @@ from sqlalchemy import ( UniqueConstraint, select, ) -from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -330,20 +328,9 @@ class Exchange(Base): """Bitcoin exchange trades booked by users.""" __tablename__ = "exchanges" - # Note: No unique constraint on slot_start to allow cancelled bookings - # to be replaced. Application-level check in create_exchange ensures only - # one BOOKED trade per slot. For existing databases, manually drop the - # constraint: ALTER TABLE exchanges DROP CONSTRAINT IF EXISTS - # uq_exchange_slot_start; + __table_args__ = (UniqueConstraint("slot_start", name="uq_exchange_slot_start"),) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - public_id: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), - nullable=False, - unique=True, - index=True, - default=uuid.uuid4, - ) user_id: Mapped[int] = mapped_column( Integer, ForeignKey("users.id"), nullable=False, index=True ) diff --git a/backend/routes/exchange.py b/backend/routes/exchange.py index 67efb5c..086128c 100644 --- a/backend/routes/exchange.py +++ b/backend/routes/exchange.py @@ -1,6 +1,5 @@ """Exchange routes for Bitcoin trading.""" -import uuid from datetime import UTC, date, datetime, time, timedelta from fastapi import APIRouter, Depends, HTTPException, Query @@ -168,7 +167,6 @@ def _to_exchange_response( email = user_email if user_email is not None else exchange.user.email return ExchangeResponse( id=exchange.id, - public_id=str(exchange.public_id), user_id=exchange.user_id, user_email=email, slot_start=exchange.slot_start, @@ -398,8 +396,7 @@ async def create_exchange( status_code=400, detail=( f"You already have a trade booked on {slot_date.strftime('%Y-%m-%d')}. " - f"Only one trade per day is allowed. " - f"Trade ID: {existing_trade.public_id}" + f"Only one trade per day is allowed. Trade ID: {existing_trade.id}" ), ) @@ -515,26 +512,6 @@ async def create_exchange( # Calculate sats amount based on agreed price sats_amount = calculate_sats_amount(request.eur_amount, agreed_price) - # Check if slot is already booked (only consider BOOKED status, not cancelled) - slot_booked_query = select(Exchange).where( - and_( - Exchange.slot_start == request.slot_start, - Exchange.status == ExchangeStatus.BOOKED, - ) - ) - slot_booked_result = await db.execute(slot_booked_query) - slot_booked = slot_booked_result.scalar_one_or_none() - - if slot_booked: - slot_str = request.slot_start.strftime("%Y-%m-%d %H:%M") - raise HTTPException( - status_code=409, - detail=( - f"This slot at {slot_str} UTC has already been booked. " - "Select another slot." - ), - ) - # Create the exchange exchange = Exchange( user_id=current_user.id, @@ -555,14 +532,12 @@ async def create_exchange( try: await db.commit() await db.refresh(exchange) - except IntegrityError as e: + except IntegrityError: await db.rollback() - # This should rarely happen now since we check explicitly above, - # but keep it for other potential integrity violations raise HTTPException( status_code=409, - detail="Database constraint violation. Please try again.", - ) from e + detail="This slot has already been booked. Select another slot.", + ) from None return _to_exchange_response(exchange, current_user.email) @@ -590,32 +565,9 @@ async def get_my_trades( return [_to_exchange_response(ex, current_user.email) for ex in exchanges] -@trades_router.get("/{public_id}", response_model=ExchangeResponse) -async def get_my_trade( - public_id: uuid.UUID, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(require_permission(Permission.VIEW_OWN_EXCHANGES)), -) -> ExchangeResponse: - """Get a specific trade by public ID. User can only access their own trades.""" - result = await db.execute( - select(Exchange).where( - and_(Exchange.public_id == public_id, Exchange.user_id == current_user.id) - ) - ) - exchange = result.scalar_one_or_none() - - if not exchange: - raise HTTPException( - status_code=404, - detail="Trade not found or you don't have permission to view it.", - ) - - return _to_exchange_response(exchange, current_user.email) - - -@trades_router.post("/{public_id}/cancel", response_model=ExchangeResponse) +@trades_router.post("/{exchange_id}/cancel", response_model=ExchangeResponse) async def cancel_my_trade( - public_id: uuid.UUID, + exchange_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(require_permission(Permission.CANCEL_OWN_EXCHANGE)), ) -> ExchangeResponse: @@ -624,14 +576,14 @@ async def cancel_my_trade( result = await db.execute( select(Exchange) .options(joinedload(Exchange.user)) - .where(Exchange.public_id == public_id) + .where(Exchange.id == exchange_id) ) exchange = result.scalar_one_or_none() if not exchange: raise HTTPException( status_code=404, - detail="Trade not found", + detail=f"Trade {exchange_id} not found", ) # Verify ownership @@ -676,7 +628,6 @@ def _to_admin_exchange_response(exchange: Exchange) -> AdminExchangeResponse: user = exchange.user return AdminExchangeResponse( id=exchange.id, - public_id=str(exchange.public_id), user_id=exchange.user_id, user_email=user.email, user_contact=ExchangeUserContact( @@ -786,9 +737,11 @@ async def get_past_trades( return [_to_admin_exchange_response(ex) for ex in exchanges] -@admin_trades_router.post("/{public_id}/complete", response_model=AdminExchangeResponse) +@admin_trades_router.post( + "/{exchange_id}/complete", response_model=AdminExchangeResponse +) async def complete_trade( - public_id: uuid.UUID, + exchange_id: int, db: AsyncSession = Depends(get_db), _current_user: User = Depends(require_permission(Permission.COMPLETE_EXCHANGE)), ) -> AdminExchangeResponse: @@ -797,14 +750,14 @@ async def complete_trade( result = await db.execute( select(Exchange) .options(joinedload(Exchange.user)) - .where(Exchange.public_id == public_id) + .where(Exchange.id == exchange_id) ) exchange = result.scalar_one_or_none() if not exchange: raise HTTPException( status_code=404, - detail="Trade not found", + detail=f"Trade {exchange_id} not found", ) # Check slot has passed @@ -830,9 +783,11 @@ async def complete_trade( return _to_admin_exchange_response(exchange) -@admin_trades_router.post("/{public_id}/no-show", response_model=AdminExchangeResponse) +@admin_trades_router.post( + "/{exchange_id}/no-show", response_model=AdminExchangeResponse +) async def mark_no_show( - public_id: uuid.UUID, + exchange_id: int, db: AsyncSession = Depends(get_db), _current_user: User = Depends(require_permission(Permission.COMPLETE_EXCHANGE)), ) -> AdminExchangeResponse: @@ -841,14 +796,14 @@ async def mark_no_show( result = await db.execute( select(Exchange) .options(joinedload(Exchange.user)) - .where(Exchange.public_id == public_id) + .where(Exchange.id == exchange_id) ) exchange = result.scalar_one_or_none() if not exchange: raise HTTPException( status_code=404, - detail="Trade not found", + detail=f"Trade {exchange_id} not found", ) # Check slot has passed @@ -874,9 +829,9 @@ async def mark_no_show( return _to_admin_exchange_response(exchange) -@admin_trades_router.post("/{public_id}/cancel", response_model=AdminExchangeResponse) +@admin_trades_router.post("/{exchange_id}/cancel", response_model=AdminExchangeResponse) async def admin_cancel_trade( - public_id: uuid.UUID, + exchange_id: int, db: AsyncSession = Depends(get_db), _current_user: User = Depends(require_permission(Permission.CANCEL_ANY_EXCHANGE)), ) -> AdminExchangeResponse: @@ -885,14 +840,14 @@ async def admin_cancel_trade( result = await db.execute( select(Exchange) .options(joinedload(Exchange.user)) - .where(Exchange.public_id == public_id) + .where(Exchange.id == exchange_id) ) exchange = result.scalar_one_or_none() if not exchange: raise HTTPException( status_code=404, - detail="Trade not found", + detail=f"Trade {exchange_id} not found", ) # Check status is BOOKED diff --git a/backend/schemas.py b/backend/schemas.py index 9818387..259c07e 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -205,8 +205,7 @@ class ExchangeRequest(BaseModel): class ExchangeResponse(BaseModel): """Response model for an exchange trade.""" - id: int # Keep for backward compatibility, but prefer public_id - public_id: str # UUID as string + id: int user_id: int user_email: str slot_start: datetime @@ -237,8 +236,7 @@ class ExchangeUserContact(BaseModel): class AdminExchangeResponse(BaseModel): """Response model for admin exchange view (includes user contact).""" - id: int # Keep for backward compatibility, but prefer public_id - public_id: str # UUID as string + id: int user_id: int user_email: str user_contact: ExchangeUserContact diff --git a/backend/tests/test_exchange.py b/backend/tests/test_exchange.py index e3fc9b7..d023fa5 100644 --- a/backend/tests/test_exchange.py +++ b/backend/tests/test_exchange.py @@ -615,13 +615,10 @@ class TestUserTrades: ): """Returns user's trades.""" target_date = await setup_availability_and_price(client_factory, admin_user) - target_date_2 = await setup_availability_and_price( - client_factory, admin_user, target_date=in_days(2) - ) with mock_price_fetcher(20000.0): async with client_factory.create(cookies=regular_user["cookies"]) as client: - # Book two trades on different days + # Book two trades await client.post( "/api/exchange", json={ @@ -634,7 +631,7 @@ class TestUserTrades: await client.post( "/api/exchange", json={ - "slot_start": f"{target_date_2}T10:00:00Z", + "slot_start": f"{target_date}T10:00:00Z", "direction": "sell", "bitcoin_transfer_method": "lightning", "eur_amount": 20000, @@ -649,82 +646,6 @@ class TestUserTrades: assert len(data) == 2 -class TestGetMyTrade: - """Test getting a single trade by ID.""" - - @pytest.mark.asyncio - async def test_get_my_trade_success(self, client_factory, regular_user, admin_user): - """Can get own trade by ID.""" - target_date = await setup_availability_and_price(client_factory, admin_user) - - with mock_price_fetcher(20000.0): - async with client_factory.create(cookies=regular_user["cookies"]) as client: - # Create a trade - create_response = await client.post( - "/api/exchange", - json={ - "slot_start": f"{target_date}T09:00:00Z", - "direction": "buy", - "bitcoin_transfer_method": "onchain", - "eur_amount": 10000, - }, - ) - assert create_response.status_code == 200 - public_id = create_response.json()["public_id"] - - # Get the trade - get_response = await client.get(f"/api/trades/{public_id}") - - assert get_response.status_code == 200 - data = get_response.json() - assert data["public_id"] == public_id - assert data["direction"] == "buy" - assert data["bitcoin_transfer_method"] == "onchain" - - @pytest.mark.asyncio - async def test_cannot_get_other_user_trade( - self, client_factory, regular_user, alt_regular_user, admin_user - ): - """Cannot get another user's trade.""" - target_date = await setup_availability_and_price(client_factory, admin_user) - - with mock_price_fetcher(20000.0): - # First user creates a trade - async with client_factory.create(cookies=regular_user["cookies"]) as client: - create_response = await client.post( - "/api/exchange", - json={ - "slot_start": f"{target_date}T09:00:00Z", - "direction": "buy", - "bitcoin_transfer_method": "onchain", - "eur_amount": 10000, - }, - ) - assert create_response.status_code == 200 - public_id = create_response.json()["public_id"] - - # Second user tries to get it - async with client_factory.create( - cookies=alt_regular_user["cookies"] - ) as client: - get_response = await client.get(f"/api/trades/{public_id}") - - assert get_response.status_code == 404 - - @pytest.mark.asyncio - async def test_get_nonexistent_trade_returns_404( - self, client_factory, regular_user - ): - """Getting a nonexistent trade returns 404.""" - async with client_factory.create(cookies=regular_user["cookies"]) as client: - # Use a valid UUID format but non-existent - response = await client.get( - "/api/trades/00000000-0000-0000-0000-000000000000" - ) - - assert response.status_code == 404 - - class TestCancelTrade: """Test cancelling trades.""" @@ -745,10 +666,10 @@ class TestCancelTrade: "eur_amount": 10000, }, ) - public_id = book_response.json()["public_id"] + trade_id = book_response.json()["id"] # Cancel - response = await client.post(f"/api/trades/{public_id}/cancel") + response = await client.post(f"/api/trades/{trade_id}/cancel") assert response.status_code == 200 data = response.json() @@ -775,7 +696,7 @@ class TestCancelTrade: }, ) assert book_response.status_code == 200 - public_id = book_response.json()["public_id"] + trade_id = book_response.json()["id"] # Verify the slot is NOT available slots_response = await client.get( @@ -787,7 +708,7 @@ class TestCancelTrade: assert f"{target_date}T09:00:00Z" not in slot_starts # Cancel the trade - cancel_response = await client.post(f"/api/trades/{public_id}/cancel") + cancel_response = await client.post(f"/api/trades/{trade_id}/cancel") assert cancel_response.status_code == 200 # Verify the slot IS available again @@ -818,11 +739,11 @@ class TestCancelTrade: "eur_amount": 10000, }, ) - public_id = book_response.json()["public_id"] + trade_id = book_response.json()["id"] # Second user tries to cancel (no mock needed for this) async with client_factory.create(cookies=alt_regular_user["cookies"]) as client: - response = await client.post(f"/api/trades/{public_id}/cancel") + response = await client.post(f"/api/trades/{trade_id}/cancel") assert response.status_code == 403 @@ -830,9 +751,7 @@ class TestCancelTrade: async def test_cannot_cancel_nonexistent_trade(self, client_factory, regular_user): """Returns 404 for non-existent trade.""" async with client_factory.create(cookies=regular_user["cookies"]) as client: - response = await client.post( - "/api/trades/00000000-0000-0000-0000-000000000000/cancel" - ) + response = await client.post("/api/trades/99999/cancel") assert response.status_code == 404 @@ -855,11 +774,11 @@ class TestCancelTrade: "eur_amount": 10000, }, ) - public_id = book_response.json()["public_id"] - await client.post(f"/api/trades/{public_id}/cancel") + trade_id = book_response.json()["id"] + await client.post(f"/api/trades/{trade_id}/cancel") # Try to cancel again - response = await client.post(f"/api/trades/{public_id}/cancel") + response = await client.post(f"/api/trades/{trade_id}/cancel") assert response.status_code == 400 assert "cancelled_by_user" in response.json()["detail"] @@ -889,11 +808,11 @@ class TestCancelTrade: db.add(exchange) await db.commit() await db.refresh(exchange) - public_id = exchange.public_id + trade_id = exchange.id # User tries to cancel async with client_factory.create(cookies=regular_user["cookies"]) as client: - response = await client.post(f"/api/trades/{public_id}/cancel") + response = await client.post(f"/api/trades/{trade_id}/cancel") assert response.status_code == 400 assert "already passed" in response.json()["detail"] @@ -1013,11 +932,11 @@ class TestAdminCompleteTrade: db.add(exchange) await db.commit() await db.refresh(exchange) - public_id = exchange.public_id + trade_id = exchange.id # Admin completes async with client_factory.create(cookies=admin_user["cookies"]) as client: - response = await client.post(f"/api/admin/trades/{public_id}/complete") + response = await client.post(f"/api/admin/trades/{trade_id}/complete") assert response.status_code == 200 data = response.json() @@ -1043,11 +962,11 @@ class TestAdminCompleteTrade: "eur_amount": 10000, }, ) - public_id = book_response.json()["public_id"] + trade_id = book_response.json()["id"] # Admin tries to complete async with client_factory.create(cookies=admin_user["cookies"]) as client: - response = await client.post(f"/api/admin/trades/{public_id}/complete") + response = await client.post(f"/api/admin/trades/{trade_id}/complete") assert response.status_code == 400 assert "not yet started" in response.json()["detail"] @@ -1076,11 +995,11 @@ class TestAdminCompleteTrade: db.add(exchange) await db.commit() await db.refresh(exchange) - public_id = exchange.public_id + trade_id = exchange.id # Regular user tries to complete async with client_factory.create(cookies=regular_user["cookies"]) as client: - response = await client.post(f"/api/admin/trades/{public_id}/complete") + response = await client.post(f"/api/admin/trades/{trade_id}/complete") assert response.status_code == 403 @@ -1108,11 +1027,11 @@ class TestAdminCompleteTrade: db.add(exchange) await db.commit() await db.refresh(exchange) - public_id = exchange.public_id + trade_id = exchange.id # Regular user tries to mark as no-show async with client_factory.create(cookies=regular_user["cookies"]) as client: - response = await client.post(f"/api/admin/trades/{public_id}/no-show") + response = await client.post(f"/api/admin/trades/{trade_id}/no-show") assert response.status_code == 403 @@ -1144,11 +1063,11 @@ class TestAdminNoShowTrade: db.add(exchange) await db.commit() await db.refresh(exchange) - public_id = exchange.public_id + trade_id = exchange.id # Admin marks no-show async with client_factory.create(cookies=admin_user["cookies"]) as client: - response = await client.post(f"/api/admin/trades/{public_id}/no-show") + response = await client.post(f"/api/admin/trades/{trade_id}/no-show") assert response.status_code == 200 data = response.json() @@ -1177,11 +1096,11 @@ class TestAdminCancelTrade: "eur_amount": 10000, }, ) - public_id = book_response.json()["public_id"] + trade_id = book_response.json()["id"] # Admin cancels async with client_factory.create(cookies=admin_user["cookies"]) as client: - response = await client.post(f"/api/admin/trades/{public_id}/cancel") + response = await client.post(f"/api/admin/trades/{trade_id}/cancel") assert response.status_code == 200 data = response.json() @@ -1206,10 +1125,10 @@ class TestAdminCancelTrade: "eur_amount": 10000, }, ) - public_id = book_response.json()["public_id"] + trade_id = book_response.json()["id"] # User tries admin cancel - response = await client.post(f"/api/admin/trades/{public_id}/cancel") + response = await client.post(f"/api/admin/trades/{trade_id}/cancel") assert response.status_code == 403 diff --git a/frontend/app/admin/trades/page.tsx b/frontend/app/admin/trades/page.tsx index 6817a25..55b5629 100644 --- a/frontend/app/admin/trades/page.tsx +++ b/frontend/app/admin/trades/page.tsx @@ -35,9 +35,9 @@ export default function AdminTradesPage() { const [error, setError] = useState(null); // Action state - use Set to track multiple concurrent actions - const [actioningIds, setActioningIds] = useState>(new Set()); + const [actioningIds, setActioningIds] = useState>(new Set()); const [confirmAction, setConfirmAction] = useState<{ - id: string; + id: number; type: "complete" | "no_show" | "cancel"; } | null>(null); @@ -99,16 +99,16 @@ export default function AdminTradesPage() { } }, [user, isAuthorized, fetchUpcomingTrades, fetchPastTrades]); - const handleAction = async (publicId: string, action: "complete" | "no_show" | "cancel") => { + const handleAction = async (tradeId: number, action: "complete" | "no_show" | "cancel") => { // Add this trade to the set of actioning trades - setActioningIds((prev) => new Set(prev).add(publicId)); + setActioningIds((prev) => new Set(prev).add(tradeId)); setError(null); try { const endpoint = action === "no_show" - ? `/api/admin/trades/${publicId}/no-show` - : `/api/admin/trades/${publicId}/${action}`; + ? `/api/admin/trades/${tradeId}/no-show` + : `/api/admin/trades/${tradeId}/${action}`; await api.post(endpoint, {}); // Refetch trades - errors from fetch are informational, not critical @@ -124,7 +124,7 @@ export default function AdminTradesPage() { // Remove this trade from the set of actioning trades setActioningIds((prev) => { const next = new Set(prev); - next.delete(publicId); + next.delete(tradeId); return next; }); } @@ -298,18 +298,18 @@ export default function AdminTradesPage() { {/* Actions */}
- {confirmAction?.id === trade.public_id ? ( + {confirmAction?.id === trade.id ? ( <> ); })}
- {/* Warning for existing trade on selected date */} - {existingTradeOnSelectedDate && ( -
-
- You already have a trade booked on this day. You can only book one trade per day. -
- -
- )} - {/* Available Slots */} - {selectedDate && !existingTradeOnSelectedDate && ( + {selectedDate && (

Available Slots for{" "} @@ -704,109 +584,83 @@ export default function ExchangePage() { )}

)} + + {/* Confirm Booking */} + {selectedSlot && ( +
+

Confirm Trade

+
+
+ Time: + + {formatTime(selectedSlot.start_time)} - {formatTime(selectedSlot.end_time)} + +
+
+ Direction: + + {direction === "buy" ? "Buy BTC" : "Sell BTC"} + +
+
+ EUR: + {formatEur(eurAmount)} +
+
+ BTC: + + + +
+
+ Rate: + {formatPrice(agreedPrice)}/BTC +
+
+ Payment: + + {direction === "buy" ? "Receive via " : "Send via "} + {bitcoinTransferMethod === "onchain" ? "Onchain" : "Lightning"} + +
+
+ +
+ + +
+
+ )} )} - - {/* Step 2: Booking (Compressed when step 3 is active) */} - {wizardStep === "confirmation" && ( -
-
- Appointment - -
-
- - {selectedDate?.toLocaleDateString("en-US", { - weekday: "short", - month: "short", - day: "numeric", - })} - - - - {selectedSlot && formatTime(selectedSlot.start_time)} -{" "} - {selectedSlot && formatTime(selectedSlot.end_time)} - -
-
- )} - - {/* Step 3: Confirmation */} - {wizardStep === "confirmation" && selectedSlot && ( -
-

Confirm Trade

-
-
- Time: - - {formatTime(selectedSlot.start_time)} - {formatTime(selectedSlot.end_time)} - -
-
- Direction: - - {direction === "buy" ? "Buy BTC" : "Sell BTC"} - -
-
- EUR: - {formatEur(eurAmount)} -
-
- BTC: - - - -
-
- Rate: - {formatPrice(agreedPrice)}/BTC -
-
- Payment: - - {direction === "buy" ? "Receive via " : "Send via "} - {bitcoinTransferMethod === "onchain" ? "Onchain" : "Lightning"} - -
-
- -
- - -
-
- )} ); @@ -1049,33 +903,6 @@ const styles: Record = { padding: "1rem 1.5rem", marginBottom: "1.5rem", }, - compressedBookingCard: { - background: "rgba(255, 255, 255, 0.03)", - border: "1px solid rgba(255, 255, 255, 0.08)", - borderRadius: "12px", - padding: "1rem 1.5rem", - marginBottom: "1.5rem", - }, - compressedBookingHeader: { - display: "flex", - justifyContent: "space-between", - alignItems: "center", - marginBottom: "0.5rem", - }, - compressedBookingTitle: { - fontFamily: "'DM Sans', system-ui, sans-serif", - fontSize: "0.875rem", - color: "rgba(255, 255, 255, 0.5)", - }, - compressedBookingDetails: { - display: "flex", - alignItems: "center", - gap: "0.75rem", - flexWrap: "wrap", - fontFamily: "'DM Sans', system-ui, sans-serif", - fontSize: "1rem", - color: "#fff", - }, summaryHeader: { display: "flex", justifyContent: "space-between", @@ -1171,18 +998,6 @@ const styles: Record = { borderRadius: "6px", border: "1px solid rgba(251, 146, 60, 0.2)", }, - errorLink: { - marginTop: "0.75rem", - paddingTop: "0.75rem", - borderTop: "1px solid rgba(255, 255, 255, 0.1)", - }, - errorLinkAnchor: { - fontFamily: "'DM Sans', system-ui, sans-serif", - color: "#a78bfa", - textDecoration: "none", - fontWeight: 500, - fontSize: "0.9rem", - }, section: { marginBottom: "2rem", }, @@ -1219,10 +1034,6 @@ const styles: Record = { background: "rgba(255, 255, 255, 0.01)", border: "1px solid rgba(255, 255, 255, 0.04)", }, - dateButtonHasTrade: { - border: "1px solid rgba(251, 146, 60, 0.5)", - background: "rgba(251, 146, 60, 0.1)", - }, dateWeekday: { color: "#fff", fontWeight: 500, @@ -1233,11 +1044,6 @@ const styles: Record = { color: "rgba(255, 255, 255, 0.5)", fontSize: "0.8rem", }, - dateWarning: { - fontSize: "0.7rem", - marginTop: "0.25rem", - opacity: 0.8, - }, slotGrid: { display: "flex", flexWrap: "wrap", diff --git a/frontend/app/generated/api.ts b/frontend/app/generated/api.ts index df523cf..700803a 100644 --- a/frontend/app/generated/api.ts +++ b/frontend/app/generated/api.ts @@ -416,27 +416,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/trades/{public_id}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get My Trade - * @description Get a specific trade by public ID. User can only access their own trades. - */ - get: operations["get_my_trade_api_trades__public_id__get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/trades/{public_id}/cancel": { + "/api/trades/{exchange_id}/cancel": { parameters: { query?: never; header?: never; @@ -449,7 +429,7 @@ export interface paths { * Cancel My Trade * @description Cancel one of the current user's exchanges. */ - post: operations["cancel_my_trade_api_trades__public_id__cancel_post"]; + post: operations["cancel_my_trade_api_trades__exchange_id__cancel_post"]; delete?: never; options?: never; head?: never; @@ -501,7 +481,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/admin/trades/{public_id}/complete": { + "/api/admin/trades/{exchange_id}/complete": { parameters: { query?: never; header?: never; @@ -514,14 +494,14 @@ export interface paths { * Complete Trade * @description Mark a trade as completed. Only possible after slot time has passed. */ - post: operations["complete_trade_api_admin_trades__public_id__complete_post"]; + post: operations["complete_trade_api_admin_trades__exchange_id__complete_post"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/admin/trades/{public_id}/no-show": { + "/api/admin/trades/{exchange_id}/no-show": { parameters: { query?: never; header?: never; @@ -534,14 +514,14 @@ export interface paths { * Mark No Show * @description Mark a trade as no-show. Only possible after slot time has passed. */ - post: operations["mark_no_show_api_admin_trades__public_id__no_show_post"]; + post: operations["mark_no_show_api_admin_trades__exchange_id__no_show_post"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/admin/trades/{public_id}/cancel": { + "/api/admin/trades/{exchange_id}/cancel": { parameters: { query?: never; header?: never; @@ -554,7 +534,7 @@ export interface paths { * Admin Cancel Trade * @description Cancel any trade (admin only). */ - post: operations["admin_cancel_trade_api_admin_trades__public_id__cancel_post"]; + post: operations["admin_cancel_trade_api_admin_trades__exchange_id__cancel_post"]; delete?: never; options?: never; head?: never; @@ -595,8 +575,6 @@ export interface components { AdminExchangeResponse: { /** Id */ id: number; - /** Public Id */ - public_id: string; /** User Id */ user_id: number; /** User Email */ @@ -782,8 +760,6 @@ export interface components { ExchangeResponse: { /** Id */ id: number; - /** Public Id */ - public_id: string; /** User Id */ user_id: number; /** User Email */ @@ -1709,43 +1685,12 @@ export interface operations { }; }; }; - get_my_trade_api_trades__public_id__get: { + cancel_my_trade_api_trades__exchange_id__cancel_post: { parameters: { query?: never; header?: never; path: { - public_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ExchangeResponse"]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; - cancel_my_trade_api_trades__public_id__cancel_post: { - parameters: { - query?: never; - header?: never; - path: { - public_id: string; + exchange_id: number; }; cookie?: never; }; @@ -1825,12 +1770,12 @@ export interface operations { }; }; }; - complete_trade_api_admin_trades__public_id__complete_post: { + complete_trade_api_admin_trades__exchange_id__complete_post: { parameters: { query?: never; header?: never; path: { - public_id: string; + exchange_id: number; }; cookie?: never; }; @@ -1856,12 +1801,12 @@ export interface operations { }; }; }; - mark_no_show_api_admin_trades__public_id__no_show_post: { + mark_no_show_api_admin_trades__exchange_id__no_show_post: { parameters: { query?: never; header?: never; path: { - public_id: string; + exchange_id: number; }; cookie?: never; }; @@ -1887,12 +1832,12 @@ export interface operations { }; }; }; - admin_cancel_trade_api_admin_trades__public_id__cancel_post: { + admin_cancel_trade_api_admin_trades__exchange_id__cancel_post: { parameters: { query?: never; header?: never; path: { - public_id: string; + exchange_id: number; }; cookie?: never; }; diff --git a/frontend/app/trades/[id]/page.tsx b/frontend/app/trades/[id]/page.tsx deleted file mode 100644 index db773c6..0000000 --- a/frontend/app/trades/[id]/page.tsx +++ /dev/null @@ -1,300 +0,0 @@ -"use client"; - -import { useEffect, useState, CSSProperties } from "react"; -import { useParams, useRouter } from "next/navigation"; -import { Permission } from "../../auth-context"; -import { api } from "../../api"; -import { Header } from "../../components/Header"; -import { SatsDisplay } from "../../components/SatsDisplay"; -import { useRequireAuth } from "../../hooks/useRequireAuth"; -import { components } from "../../generated/api"; -import { formatDateTime } from "../../utils/date"; -import { formatEur, getTradeStatusDisplay } from "../../utils/exchange"; -import { - layoutStyles, - typographyStyles, - bannerStyles, - badgeStyles, - buttonStyles, - tradeCardStyles, -} from "../../styles/shared"; - -type ExchangeResponse = components["schemas"]["ExchangeResponse"]; - -export default function TradeDetailPage() { - const router = useRouter(); - const params = useParams(); - const publicId = params?.id as string | undefined; - - const { user, isLoading, isAuthorized } = useRequireAuth({ - requiredPermission: Permission.VIEW_OWN_EXCHANGES, - fallbackRedirect: "/", - }); - - const [trade, setTrade] = useState(null); - const [isLoadingTrade, setIsLoadingTrade] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - if (!user || !isAuthorized || !publicId) return; - - const fetchTrade = async () => { - try { - setIsLoadingTrade(true); - setError(null); - const data = await api.get(`/api/trades/${publicId}`); - setTrade(data); - } catch (err) { - console.error("Failed to fetch trade:", err); - setError( - "Failed to load trade. It may not exist or you may not have permission to view it." - ); - } finally { - setIsLoadingTrade(false); - } - }; - - fetchTrade(); - }, [user, isAuthorized, publicId]); - - if (isLoading || isLoadingTrade) { - return ( -
-
Loading...
-
- ); - } - - if (!isAuthorized) { - return null; - } - - if (error || !trade) { - return ( -
-
-
-

Trade Details

- {error &&
{error}
} - -
-
- ); - } - - const status = getTradeStatusDisplay(trade.status); - const isBuy = trade.direction === "buy"; - - return ( -
-
-
-
-

Trade Details

- -
- -
-
-

Trade Information

-
-
- Status: - - {status.text} - -
-
- Time: - {formatDateTime(trade.slot_start)} -
-
- Direction: - - {isBuy ? "BUY BTC" : "SELL BTC"} - -
-
- Payment Method: - - {isBuy - ? `Receive via ${trade.bitcoin_transfer_method === "onchain" ? "Onchain" : "Lightning"}` - : `Send via ${trade.bitcoin_transfer_method === "onchain" ? "Onchain" : "Lightning"}`} - -
-
-
- -
-

Amounts

-
-
- EUR Amount: - {formatEur(trade.eur_amount)} -
-
- Bitcoin Amount: - - - -
-
-
- -
-

Pricing

-
-
- Market Price: - - € - {trade.market_price_eur.toLocaleString("de-DE", { - maximumFractionDigits: 0, - })} - /BTC - -
-
- Agreed Price: - - € - {trade.agreed_price_eur.toLocaleString("de-DE", { - maximumFractionDigits: 0, - })} - /BTC - -
-
- Premium: - {trade.premium_percentage}% -
-
-
- -
-

Timestamps

-
-
- Created: - {formatDateTime(trade.created_at)} -
- {trade.cancelled_at && ( -
- Cancelled: - {formatDateTime(trade.cancelled_at)} -
- )} - {trade.completed_at && ( -
- Completed: - {formatDateTime(trade.completed_at)} -
- )} -
-
- - {trade.status === "booked" && ( -
- -
- )} -
-
-
- ); -} - -const styles: Record = { - content: { - flex: 1, - padding: "2rem", - maxWidth: "800px", - margin: "0 auto", - width: "100%", - }, - header: { - display: "flex", - justifyContent: "space-between", - alignItems: "center", - marginBottom: "2rem", - }, - tradeDetailCard: { - background: "rgba(255, 255, 255, 0.03)", - border: "1px solid rgba(255, 255, 255, 0.08)", - borderRadius: "12px", - padding: "2rem", - }, - detailSection: { - marginBottom: "2rem", - }, - sectionTitle: { - fontFamily: "'DM Sans', system-ui, sans-serif", - fontSize: "1.1rem", - fontWeight: 600, - color: "#fff", - marginBottom: "1rem", - }, - detailGrid: { - display: "flex", - flexDirection: "column", - gap: "1rem", - }, - detailRow: { - display: "flex", - justifyContent: "space-between", - alignItems: "center", - padding: "0.75rem 0", - borderBottom: "1px solid rgba(255, 255, 255, 0.05)", - }, - detailLabel: { - fontFamily: "'DM Sans', system-ui, sans-serif", - fontSize: "0.9rem", - color: "rgba(255, 255, 255, 0.6)", - }, - detailValue: { - fontFamily: "'DM Sans', system-ui, sans-serif", - fontSize: "0.9rem", - color: "#fff", - fontWeight: 500, - }, - actionSection: { - marginTop: "2rem", - paddingTop: "2rem", - borderTop: "1px solid rgba(255, 255, 255, 0.1)", - }, -}; diff --git a/frontend/app/trades/page.tsx b/frontend/app/trades/page.tsx index 0eff03f..9d27bd0 100644 --- a/frontend/app/trades/page.tsx +++ b/frontend/app/trades/page.tsx @@ -1,7 +1,6 @@ "use client"; import { useEffect, useState, useCallback, CSSProperties } from "react"; -import { useRouter } from "next/navigation"; import { Permission } from "../auth-context"; import { api } from "../api"; import { Header } from "../components/Header"; @@ -22,7 +21,6 @@ import { type ExchangeResponse = components["schemas"]["ExchangeResponse"]; export default function TradesPage() { - const router = useRouter(); const { user, isLoading, isAuthorized } = useRequireAuth({ requiredPermission: Permission.VIEW_OWN_EXCHANGES, fallbackRedirect: "/", @@ -30,8 +28,8 @@ export default function TradesPage() { const [trades, setTrades] = useState([]); const [isLoadingTrades, setIsLoadingTrades] = useState(true); - const [cancellingId, setCancellingId] = useState(null); - const [confirmCancelId, setConfirmCancelId] = useState(null); + const [cancellingId, setCancellingId] = useState(null); + const [confirmCancelId, setConfirmCancelId] = useState(null); const [error, setError] = useState(null); const fetchTrades = useCallback(async () => { @@ -52,12 +50,12 @@ export default function TradesPage() { } }, [user, isAuthorized, fetchTrades]); - const handleCancel = async (publicId: string) => { - setCancellingId(publicId); + const handleCancel = async (tradeId: number) => { + setCancellingId(tradeId); setError(null); try { - await api.post(`/api/trades/${publicId}/cancel`, {}); + await api.post(`/api/trades/${tradeId}/cancel`, {}); await fetchTrades(); setConfirmCancelId(null); } catch (err) { @@ -174,54 +172,34 @@ export default function TradesPage() { -
- {trade.status === "booked" && ( - <> - {confirmCancelId === trade.public_id ? ( - <> - - - - ) : ( + {trade.status === "booked" && ( +
+ {confirmCancelId === trade.id ? ( + <> + - )} - - )} - -
+ + ) : ( + + )} +
+ )} ); @@ -243,10 +221,7 @@ export default function TradesPage() { return (
{formatDateTime(trade.slot_start)} @@ -280,26 +255,16 @@ export default function TradesPage() {
-
- - {status.text} - - -
+ + {status.text} +
); })} @@ -351,15 +316,4 @@ const styles: Record = { color: "#a78bfa", textDecoration: "none", }, - viewDetailsButton: { - fontFamily: "'DM Sans', system-ui, sans-serif", - padding: "0.35rem 0.75rem", - fontSize: "0.75rem", - background: "rgba(167, 139, 250, 0.15)", - border: "1px solid rgba(167, 139, 250, 0.3)", - borderRadius: "6px", - color: "#a78bfa", - cursor: "pointer", - transition: "all 0.2s", - }, };