From 4be45f8f7c2a93c1465b023e7e7ac6533e5d1198 Mon Sep 17 00:00:00 2001 From: counterweight Date: Tue, 23 Dec 2025 17:03:51 +0100 Subject: [PATCH] lots of stuff --- backend/models.py | 15 +- backend/routes/exchange.py | 78 ++++--- backend/schemas.py | 6 +- backend/tests/test_exchange.py | 69 +++--- frontend/app/admin/trades/page.tsx | 28 +-- frontend/app/exchange/page.tsx | 326 +++++++++++++++++++++-------- frontend/app/generated/api.ts | 87 ++++++-- frontend/app/trades/[id]/page.tsx | 10 +- frontend/app/trades/page.tsx | 130 +++++++----- 9 files changed, 513 insertions(+), 236 deletions(-) diff --git a/backend/models.py b/backend/models.py index 4184720..3a52d0f 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,3 +1,4 @@ +import uuid from datetime import UTC, date, datetime, time from enum import Enum as PyEnum from typing import TypedDict @@ -16,6 +17,7 @@ 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 @@ -328,9 +330,20 @@ class Exchange(Base): """Bitcoin exchange trades booked by users.""" __tablename__ = "exchanges" - __table_args__ = (UniqueConstraint("slot_start", name="uq_exchange_slot_start"),) + # 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; 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 c7fe4f5..67efb5c 100644 --- a/backend/routes/exchange.py +++ b/backend/routes/exchange.py @@ -1,5 +1,6 @@ """Exchange routes for Bitcoin trading.""" +import uuid from datetime import UTC, date, datetime, time, timedelta from fastapi import APIRouter, Depends, HTTPException, Query @@ -167,6 +168,7 @@ 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, @@ -396,7 +398,8 @@ 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. Trade ID: {existing_trade.id}" + f"Only one trade per day is allowed. " + f"Trade ID: {existing_trade.public_id}" ), ) @@ -512,6 +515,26 @@ 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, @@ -532,12 +555,14 @@ async def create_exchange( try: await db.commit() await db.refresh(exchange) - except IntegrityError: + except IntegrityError as e: 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="This slot has already been booked. Select another slot.", - ) from None + detail="Database constraint violation. Please try again.", + ) from e return _to_exchange_response(exchange, current_user.email) @@ -565,16 +590,16 @@ async def get_my_trades( return [_to_exchange_response(ex, current_user.email) for ex in exchanges] -@trades_router.get("/{exchange_id}", response_model=ExchangeResponse) +@trades_router.get("/{public_id}", response_model=ExchangeResponse) async def get_my_trade( - exchange_id: int, + 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 ID. User can only access their own trades.""" + """Get a specific trade by public ID. User can only access their own trades.""" result = await db.execute( select(Exchange).where( - and_(Exchange.id == exchange_id, Exchange.user_id == current_user.id) + and_(Exchange.public_id == public_id, Exchange.user_id == current_user.id) ) ) exchange = result.scalar_one_or_none() @@ -588,9 +613,9 @@ async def get_my_trade( return _to_exchange_response(exchange, current_user.email) -@trades_router.post("/{exchange_id}/cancel", response_model=ExchangeResponse) +@trades_router.post("/{public_id}/cancel", response_model=ExchangeResponse) async def cancel_my_trade( - exchange_id: int, + public_id: uuid.UUID, db: AsyncSession = Depends(get_db), current_user: User = Depends(require_permission(Permission.CANCEL_OWN_EXCHANGE)), ) -> ExchangeResponse: @@ -599,14 +624,14 @@ async def cancel_my_trade( result = await db.execute( select(Exchange) .options(joinedload(Exchange.user)) - .where(Exchange.id == exchange_id) + .where(Exchange.public_id == public_id) ) exchange = result.scalar_one_or_none() if not exchange: raise HTTPException( status_code=404, - detail=f"Trade {exchange_id} not found", + detail="Trade not found", ) # Verify ownership @@ -651,6 +676,7 @@ 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( @@ -760,11 +786,9 @@ async def get_past_trades( return [_to_admin_exchange_response(ex) for ex in exchanges] -@admin_trades_router.post( - "/{exchange_id}/complete", response_model=AdminExchangeResponse -) +@admin_trades_router.post("/{public_id}/complete", response_model=AdminExchangeResponse) async def complete_trade( - exchange_id: int, + public_id: uuid.UUID, db: AsyncSession = Depends(get_db), _current_user: User = Depends(require_permission(Permission.COMPLETE_EXCHANGE)), ) -> AdminExchangeResponse: @@ -773,14 +797,14 @@ async def complete_trade( result = await db.execute( select(Exchange) .options(joinedload(Exchange.user)) - .where(Exchange.id == exchange_id) + .where(Exchange.public_id == public_id) ) exchange = result.scalar_one_or_none() if not exchange: raise HTTPException( status_code=404, - detail=f"Trade {exchange_id} not found", + detail="Trade not found", ) # Check slot has passed @@ -806,11 +830,9 @@ async def complete_trade( return _to_admin_exchange_response(exchange) -@admin_trades_router.post( - "/{exchange_id}/no-show", response_model=AdminExchangeResponse -) +@admin_trades_router.post("/{public_id}/no-show", response_model=AdminExchangeResponse) async def mark_no_show( - exchange_id: int, + public_id: uuid.UUID, db: AsyncSession = Depends(get_db), _current_user: User = Depends(require_permission(Permission.COMPLETE_EXCHANGE)), ) -> AdminExchangeResponse: @@ -819,14 +841,14 @@ async def mark_no_show( result = await db.execute( select(Exchange) .options(joinedload(Exchange.user)) - .where(Exchange.id == exchange_id) + .where(Exchange.public_id == public_id) ) exchange = result.scalar_one_or_none() if not exchange: raise HTTPException( status_code=404, - detail=f"Trade {exchange_id} not found", + detail="Trade not found", ) # Check slot has passed @@ -852,9 +874,9 @@ async def mark_no_show( return _to_admin_exchange_response(exchange) -@admin_trades_router.post("/{exchange_id}/cancel", response_model=AdminExchangeResponse) +@admin_trades_router.post("/{public_id}/cancel", response_model=AdminExchangeResponse) async def admin_cancel_trade( - exchange_id: int, + public_id: uuid.UUID, db: AsyncSession = Depends(get_db), _current_user: User = Depends(require_permission(Permission.CANCEL_ANY_EXCHANGE)), ) -> AdminExchangeResponse: @@ -863,14 +885,14 @@ async def admin_cancel_trade( result = await db.execute( select(Exchange) .options(joinedload(Exchange.user)) - .where(Exchange.id == exchange_id) + .where(Exchange.public_id == public_id) ) exchange = result.scalar_one_or_none() if not exchange: raise HTTPException( status_code=404, - detail=f"Trade {exchange_id} not found", + detail="Trade not found", ) # Check status is BOOKED diff --git a/backend/schemas.py b/backend/schemas.py index 259c07e..9818387 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -205,7 +205,8 @@ class ExchangeRequest(BaseModel): class ExchangeResponse(BaseModel): """Response model for an exchange trade.""" - id: int + id: int # Keep for backward compatibility, but prefer public_id + public_id: str # UUID as string user_id: int user_email: str slot_start: datetime @@ -236,7 +237,8 @@ class ExchangeUserContact(BaseModel): class AdminExchangeResponse(BaseModel): """Response model for admin exchange view (includes user contact).""" - id: int + id: int # Keep for backward compatibility, but prefer public_id + public_id: str # UUID as string user_id: int user_email: str user_contact: ExchangeUserContact diff --git a/backend/tests/test_exchange.py b/backend/tests/test_exchange.py index 390d5e3..e3fc9b7 100644 --- a/backend/tests/test_exchange.py +++ b/backend/tests/test_exchange.py @@ -670,14 +670,14 @@ class TestGetMyTrade: }, ) assert create_response.status_code == 200 - trade_id = create_response.json()["id"] + public_id = create_response.json()["public_id"] # Get the trade - get_response = await client.get(f"/api/trades/{trade_id}") + get_response = await client.get(f"/api/trades/{public_id}") assert get_response.status_code == 200 data = get_response.json() - assert data["id"] == trade_id + assert data["public_id"] == public_id assert data["direction"] == "buy" assert data["bitcoin_transfer_method"] == "onchain" @@ -701,13 +701,13 @@ class TestGetMyTrade: }, ) assert create_response.status_code == 200 - trade_id = create_response.json()["id"] + 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/{trade_id}") + get_response = await client.get(f"/api/trades/{public_id}") assert get_response.status_code == 404 @@ -717,7 +717,10 @@ class TestGetMyTrade: ): """Getting a nonexistent trade returns 404.""" async with client_factory.create(cookies=regular_user["cookies"]) as client: - response = await client.get("/api/trades/99999") + # Use a valid UUID format but non-existent + response = await client.get( + "/api/trades/00000000-0000-0000-0000-000000000000" + ) assert response.status_code == 404 @@ -742,10 +745,10 @@ class TestCancelTrade: "eur_amount": 10000, }, ) - trade_id = book_response.json()["id"] + public_id = book_response.json()["public_id"] # Cancel - response = await client.post(f"/api/trades/{trade_id}/cancel") + response = await client.post(f"/api/trades/{public_id}/cancel") assert response.status_code == 200 data = response.json() @@ -772,7 +775,7 @@ class TestCancelTrade: }, ) assert book_response.status_code == 200 - trade_id = book_response.json()["id"] + public_id = book_response.json()["public_id"] # Verify the slot is NOT available slots_response = await client.get( @@ -784,7 +787,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/{trade_id}/cancel") + cancel_response = await client.post(f"/api/trades/{public_id}/cancel") assert cancel_response.status_code == 200 # Verify the slot IS available again @@ -815,11 +818,11 @@ class TestCancelTrade: "eur_amount": 10000, }, ) - trade_id = book_response.json()["id"] + public_id = book_response.json()["public_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/{trade_id}/cancel") + response = await client.post(f"/api/trades/{public_id}/cancel") assert response.status_code == 403 @@ -827,7 +830,9 @@ 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/99999/cancel") + response = await client.post( + "/api/trades/00000000-0000-0000-0000-000000000000/cancel" + ) assert response.status_code == 404 @@ -850,11 +855,11 @@ class TestCancelTrade: "eur_amount": 10000, }, ) - trade_id = book_response.json()["id"] - await client.post(f"/api/trades/{trade_id}/cancel") + public_id = book_response.json()["public_id"] + await client.post(f"/api/trades/{public_id}/cancel") # Try to cancel again - response = await client.post(f"/api/trades/{trade_id}/cancel") + response = await client.post(f"/api/trades/{public_id}/cancel") assert response.status_code == 400 assert "cancelled_by_user" in response.json()["detail"] @@ -884,11 +889,11 @@ class TestCancelTrade: db.add(exchange) await db.commit() await db.refresh(exchange) - trade_id = exchange.id + public_id = exchange.public_id # User tries to cancel async with client_factory.create(cookies=regular_user["cookies"]) as client: - response = await client.post(f"/api/trades/{trade_id}/cancel") + response = await client.post(f"/api/trades/{public_id}/cancel") assert response.status_code == 400 assert "already passed" in response.json()["detail"] @@ -1008,11 +1013,11 @@ class TestAdminCompleteTrade: db.add(exchange) await db.commit() await db.refresh(exchange) - trade_id = exchange.id + public_id = exchange.public_id # Admin completes async with client_factory.create(cookies=admin_user["cookies"]) as client: - response = await client.post(f"/api/admin/trades/{trade_id}/complete") + response = await client.post(f"/api/admin/trades/{public_id}/complete") assert response.status_code == 200 data = response.json() @@ -1038,11 +1043,11 @@ class TestAdminCompleteTrade: "eur_amount": 10000, }, ) - trade_id = book_response.json()["id"] + public_id = book_response.json()["public_id"] # Admin tries to complete async with client_factory.create(cookies=admin_user["cookies"]) as client: - response = await client.post(f"/api/admin/trades/{trade_id}/complete") + response = await client.post(f"/api/admin/trades/{public_id}/complete") assert response.status_code == 400 assert "not yet started" in response.json()["detail"] @@ -1071,11 +1076,11 @@ class TestAdminCompleteTrade: db.add(exchange) await db.commit() await db.refresh(exchange) - trade_id = exchange.id + public_id = exchange.public_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/{trade_id}/complete") + response = await client.post(f"/api/admin/trades/{public_id}/complete") assert response.status_code == 403 @@ -1103,11 +1108,11 @@ class TestAdminCompleteTrade: db.add(exchange) await db.commit() await db.refresh(exchange) - trade_id = exchange.id + public_id = exchange.public_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/{trade_id}/no-show") + response = await client.post(f"/api/admin/trades/{public_id}/no-show") assert response.status_code == 403 @@ -1139,11 +1144,11 @@ class TestAdminNoShowTrade: db.add(exchange) await db.commit() await db.refresh(exchange) - trade_id = exchange.id + public_id = exchange.public_id # Admin marks no-show async with client_factory.create(cookies=admin_user["cookies"]) as client: - response = await client.post(f"/api/admin/trades/{trade_id}/no-show") + response = await client.post(f"/api/admin/trades/{public_id}/no-show") assert response.status_code == 200 data = response.json() @@ -1172,11 +1177,11 @@ class TestAdminCancelTrade: "eur_amount": 10000, }, ) - trade_id = book_response.json()["id"] + public_id = book_response.json()["public_id"] # Admin cancels async with client_factory.create(cookies=admin_user["cookies"]) as client: - response = await client.post(f"/api/admin/trades/{trade_id}/cancel") + response = await client.post(f"/api/admin/trades/{public_id}/cancel") assert response.status_code == 200 data = response.json() @@ -1201,10 +1206,10 @@ class TestAdminCancelTrade: "eur_amount": 10000, }, ) - trade_id = book_response.json()["id"] + public_id = book_response.json()["public_id"] # User tries admin cancel - response = await client.post(f"/api/admin/trades/{trade_id}/cancel") + response = await client.post(f"/api/admin/trades/{public_id}/cancel") assert response.status_code == 403 diff --git a/frontend/app/admin/trades/page.tsx b/frontend/app/admin/trades/page.tsx index 55b5629..6817a25 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: number; + id: string; type: "complete" | "no_show" | "cancel"; } | null>(null); @@ -99,16 +99,16 @@ export default function AdminTradesPage() { } }, [user, isAuthorized, fetchUpcomingTrades, fetchPastTrades]); - const handleAction = async (tradeId: number, action: "complete" | "no_show" | "cancel") => { + const handleAction = async (publicId: string, action: "complete" | "no_show" | "cancel") => { // Add this trade to the set of actioning trades - setActioningIds((prev) => new Set(prev).add(tradeId)); + setActioningIds((prev) => new Set(prev).add(publicId)); setError(null); try { const endpoint = action === "no_show" - ? `/api/admin/trades/${tradeId}/no-show` - : `/api/admin/trades/${tradeId}/${action}`; + ? `/api/admin/trades/${publicId}/no-show` + : `/api/admin/trades/${publicId}/${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(tradeId); + next.delete(publicId); return next; }); } @@ -298,18 +298,18 @@ export default function AdminTradesPage() { {/* Actions */}
- {confirmAction?.id === trade.id ? ( + {confirmAction?.id === trade.public_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 && ( + {selectedDate && !existingTradeOnSelectedDate && (

Available Slots for{" "} @@ -618,83 +704,109 @@ 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"} + +
+
+ +
+ + +
+
+ )} ); @@ -937,6 +1049,33 @@ 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", @@ -1080,6 +1219,10 @@ 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, @@ -1090,6 +1233,11 @@ 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 700803a..df523cf 100644 --- a/frontend/app/generated/api.ts +++ b/frontend/app/generated/api.ts @@ -416,7 +416,27 @@ export interface paths { patch?: never; trace?: never; }; - "/api/trades/{exchange_id}/cancel": { + "/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": { parameters: { query?: never; header?: never; @@ -429,7 +449,7 @@ export interface paths { * Cancel My Trade * @description Cancel one of the current user's exchanges. */ - post: operations["cancel_my_trade_api_trades__exchange_id__cancel_post"]; + post: operations["cancel_my_trade_api_trades__public_id__cancel_post"]; delete?: never; options?: never; head?: never; @@ -481,7 +501,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/admin/trades/{exchange_id}/complete": { + "/api/admin/trades/{public_id}/complete": { parameters: { query?: never; header?: never; @@ -494,14 +514,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__exchange_id__complete_post"]; + post: operations["complete_trade_api_admin_trades__public_id__complete_post"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/admin/trades/{exchange_id}/no-show": { + "/api/admin/trades/{public_id}/no-show": { parameters: { query?: never; header?: never; @@ -514,14 +534,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__exchange_id__no_show_post"]; + post: operations["mark_no_show_api_admin_trades__public_id__no_show_post"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/admin/trades/{exchange_id}/cancel": { + "/api/admin/trades/{public_id}/cancel": { parameters: { query?: never; header?: never; @@ -534,7 +554,7 @@ export interface paths { * Admin Cancel Trade * @description Cancel any trade (admin only). */ - post: operations["admin_cancel_trade_api_admin_trades__exchange_id__cancel_post"]; + post: operations["admin_cancel_trade_api_admin_trades__public_id__cancel_post"]; delete?: never; options?: never; head?: never; @@ -575,6 +595,8 @@ export interface components { AdminExchangeResponse: { /** Id */ id: number; + /** Public Id */ + public_id: string; /** User Id */ user_id: number; /** User Email */ @@ -760,6 +782,8 @@ export interface components { ExchangeResponse: { /** Id */ id: number; + /** Public Id */ + public_id: string; /** User Id */ user_id: number; /** User Email */ @@ -1685,12 +1709,43 @@ export interface operations { }; }; }; - cancel_my_trade_api_trades__exchange_id__cancel_post: { + get_my_trade_api_trades__public_id__get: { parameters: { query?: never; header?: never; path: { - exchange_id: number; + 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; }; cookie?: never; }; @@ -1770,12 +1825,12 @@ export interface operations { }; }; }; - complete_trade_api_admin_trades__exchange_id__complete_post: { + complete_trade_api_admin_trades__public_id__complete_post: { parameters: { query?: never; header?: never; path: { - exchange_id: number; + public_id: string; }; cookie?: never; }; @@ -1801,12 +1856,12 @@ export interface operations { }; }; }; - mark_no_show_api_admin_trades__exchange_id__no_show_post: { + mark_no_show_api_admin_trades__public_id__no_show_post: { parameters: { query?: never; header?: never; path: { - exchange_id: number; + public_id: string; }; cookie?: never; }; @@ -1832,12 +1887,12 @@ export interface operations { }; }; }; - admin_cancel_trade_api_admin_trades__exchange_id__cancel_post: { + admin_cancel_trade_api_admin_trades__public_id__cancel_post: { parameters: { query?: never; header?: never; path: { - exchange_id: number; + public_id: string; }; cookie?: never; }; diff --git a/frontend/app/trades/[id]/page.tsx b/frontend/app/trades/[id]/page.tsx index a5a1cd2..db773c6 100644 --- a/frontend/app/trades/[id]/page.tsx +++ b/frontend/app/trades/[id]/page.tsx @@ -24,7 +24,7 @@ type ExchangeResponse = components["schemas"]["ExchangeResponse"]; export default function TradeDetailPage() { const router = useRouter(); const params = useParams(); - const tradeId = params?.id ? parseInt(params.id as string, 10) : null; + const publicId = params?.id as string | undefined; const { user, isLoading, isAuthorized } = useRequireAuth({ requiredPermission: Permission.VIEW_OWN_EXCHANGES, @@ -36,13 +36,13 @@ export default function TradeDetailPage() { const [error, setError] = useState(null); useEffect(() => { - if (!user || !isAuthorized || !tradeId) return; + if (!user || !isAuthorized || !publicId) return; const fetchTrade = async () => { try { setIsLoadingTrade(true); setError(null); - const data = await api.get(`/api/trades/${tradeId}`); + const data = await api.get(`/api/trades/${publicId}`); setTrade(data); } catch (err) { console.error("Failed to fetch trade:", err); @@ -55,7 +55,7 @@ export default function TradeDetailPage() { }; fetchTrade(); - }, [user, isAuthorized, tradeId]); + }, [user, isAuthorized, publicId]); if (isLoading || isLoadingTrade) { return ( @@ -221,7 +221,7 @@ export default function TradeDetailPage() { return; } try { - await api.post(`/api/trades/${trade.id}/cancel`, {}); + await api.post(`/api/trades/${trade.public_id}/cancel`, {}); router.push("/trades"); } catch (err) { setError(err instanceof Error ? err.message : "Failed to cancel trade"); diff --git a/frontend/app/trades/page.tsx b/frontend/app/trades/page.tsx index bcb6d62..0eff03f 100644 --- a/frontend/app/trades/page.tsx +++ b/frontend/app/trades/page.tsx @@ -30,8 +30,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 +52,12 @@ export default function TradesPage() { } }, [user, isAuthorized, fetchTrades]); - const handleCancel = async (tradeId: number) => { - setCancellingId(tradeId); + const handleCancel = async (publicId: string) => { + setCancellingId(publicId); setError(null); try { - await api.post(`/api/trades/${tradeId}/cancel`, {}); + await api.post(`/api/trades/${publicId}/cancel`, {}); await fetchTrades(); setConfirmCancelId(null); } catch (err) { @@ -115,14 +115,7 @@ export default function TradesPage() { const status = getTradeStatusDisplay(trade.status); const isBuy = trade.direction === "buy"; return ( -
router.push(`/trades/${trade.id}`)} - > +
@@ -181,34 +174,54 @@ export default function TradesPage() {
- {trade.status === "booked" && ( -
- {confirmCancelId === trade.id ? ( - <> +
+ {trade.status === "booked" && ( + <> + {confirmCancelId === trade.public_id ? ( + <> + + + + ) : ( - - - ) : ( - - )} -
- )} + )} + + )} + +
); @@ -233,9 +246,7 @@ export default function TradesPage() { style={{ ...tradeCardStyles.tradeCard, ...styles.tradeCardPast, - cursor: "pointer", }} - onClick={() => router.push(`/trades/${trade.id}`)} >
{formatDateTime(trade.slot_start)} @@ -269,16 +280,26 @@ export default function TradesPage() {
- - {status.text} - +
+ + {status.text} + + +
); })} @@ -330,4 +351,15 @@ 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", + }, };