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 086128c..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,9 +590,32 @@ async def get_my_trades( return [_to_exchange_response(ex, current_user.email) for ex in exchanges] -@trades_router.post("/{exchange_id}/cancel", response_model=ExchangeResponse) +@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) 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: @@ -576,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 @@ -628,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( @@ -737,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: @@ -750,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 @@ -783,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: @@ -796,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 @@ -829,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: @@ -840,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 d023fa5..e3fc9b7 100644 --- a/backend/tests/test_exchange.py +++ b/backend/tests/test_exchange.py @@ -615,10 +615,13 @@ 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 + # Book two trades on different days await client.post( "/api/exchange", json={ @@ -631,7 +634,7 @@ class TestUserTrades: await client.post( "/api/exchange", json={ - "slot_start": f"{target_date}T10:00:00Z", + "slot_start": f"{target_date_2}T10:00:00Z", "direction": "sell", "bitcoin_transfer_method": "lightning", "eur_amount": 20000, @@ -646,6 +649,82 @@ 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.""" @@ -666,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() @@ -696,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( @@ -708,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 @@ -739,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 @@ -751,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 @@ -774,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"] @@ -808,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"] @@ -932,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() @@ -962,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"] @@ -995,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 @@ -1027,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 @@ -1063,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() @@ -1096,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() @@ -1125,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{" "} @@ -584,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"} + +
+
+ +
+ + +
+
+ )} ); @@ -903,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", @@ -998,6 +1171,18 @@ 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", }, @@ -1034,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, @@ -1044,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 new file mode 100644 index 0000000..db773c6 --- /dev/null +++ b/frontend/app/trades/[id]/page.tsx @@ -0,0 +1,300 @@ +"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 9d27bd0..0eff03f 100644 --- a/frontend/app/trades/page.tsx +++ b/frontend/app/trades/page.tsx @@ -1,6 +1,7 @@ "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"; @@ -21,6 +22,7 @@ import { type ExchangeResponse = components["schemas"]["ExchangeResponse"]; export default function TradesPage() { + const router = useRouter(); const { user, isLoading, isAuthorized } = useRequireAuth({ requiredPermission: Permission.VIEW_OWN_EXCHANGES, fallbackRedirect: "/", @@ -28,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 () => { @@ -50,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) { @@ -172,34 +174,54 @@ export default function TradesPage() { - {trade.status === "booked" && ( -
- {confirmCancelId === trade.id ? ( - <> +
+ {trade.status === "booked" && ( + <> + {confirmCancelId === trade.public_id ? ( + <> + + + + ) : ( - - - ) : ( - - )} -
- )} + )} + + )} + +
); @@ -221,7 +243,10 @@ export default function TradesPage() { return (
{formatDateTime(trade.slot_start)} @@ -255,16 +280,26 @@ export default function TradesPage() {
- - {status.text} - +
+ + {status.text} + + +
); })} @@ -316,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", + }, };