From 1008eea2d9ddf19df728c487edcf41755a2a3ae5 Mon Sep 17 00:00:00 2001 From: counterweight Date: Mon, 22 Dec 2025 21:42:42 +0100 Subject: [PATCH] Fix: Update permissions and add missing /api/exchange/slots endpoint - Updated auth-context.tsx to use new exchange permissions (CREATE_EXCHANGE, VIEW_OWN_EXCHANGES, etc.) instead of old appointment permissions (BOOK_APPOINTMENT, etc.) - Updated exchange/page.tsx, trades/page.tsx, admin/trades/page.tsx to use correct permission constants - Updated profile/page.test.tsx mock permissions - Updated admin/availability/page.tsx to use constants.exchange instead of constants.booking - Added /api/exchange/slots endpoint to return available slots for a date, filtering out already booked slots - Fixed E2E tests: - exchange.spec.ts: Wait for button to be enabled before clicking - permissions.spec.ts: Use more specific heading selector - price-history.spec.ts: Expect /exchange redirect for regular users --- backend/routes/exchange.py | 96 +++++- frontend/app/admin/availability/page.tsx | 2 +- frontend/app/admin/trades/page.tsx | 2 +- frontend/app/auth-context.tsx | 15 +- frontend/app/exchange/page.tsx | 2 +- frontend/app/generated/api.ts | 399 +++-------------------- frontend/app/profile/page.test.tsx | 29 +- frontend/app/trades/page.tsx | 2 +- frontend/e2e/exchange.spec.ts | 8 +- frontend/e2e/permissions.spec.ts | 4 +- frontend/e2e/price-history.spec.ts | 4 +- 11 files changed, 184 insertions(+), 379 deletions(-) diff --git a/backend/routes/exchange.py b/backend/routes/exchange.py index c8f1f32..e33d69e 100644 --- a/backend/routes/exchange.py +++ b/backend/routes/exchange.py @@ -2,7 +2,7 @@ from datetime import UTC, date, datetime, time, timedelta -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy import and_, desc, select from sqlalchemy.exc import IntegrityError @@ -78,6 +78,20 @@ class ExchangePriceResponse(BaseModel): error: str | None = None +class BookableSlot(BaseModel): + """A single bookable time slot.""" + + start_time: datetime + end_time: datetime + + +class AvailableSlotsResponse(BaseModel): + """Response containing available slots for a date.""" + + date: date + slots: list[BookableSlot] + + # ============================================================================= # Helper functions # ============================================================================= @@ -266,6 +280,86 @@ async def get_exchange_price( ) +# ============================================================================= +# Available Slots Endpoint +# ============================================================================= + + +def _expand_availability_to_slots( + avail: Availability, slot_date: date, booked_starts: set[datetime] +) -> list[BookableSlot]: + """ + Expand an availability block into individual slots, filtering out booked ones. + """ + slots: list[BookableSlot] = [] + + # Start from the availability's start time + current_start = datetime.combine(slot_date, avail.start_time, tzinfo=UTC) + avail_end = datetime.combine(slot_date, avail.end_time, tzinfo=UTC) + + while current_start + timedelta(minutes=SLOT_DURATION_MINUTES) <= avail_end: + slot_end = current_start + timedelta(minutes=SLOT_DURATION_MINUTES) + + # Only include if not already booked + if current_start not in booked_starts: + slots.append(BookableSlot(start_time=current_start, end_time=slot_end)) + + current_start = slot_end + + return slots + + +@router.get("/slots", response_model=AvailableSlotsResponse) +async def get_available_slots( + date_param: date = Query(..., alias="date"), + db: AsyncSession = Depends(get_db), + _current_user: User = Depends(require_permission(Permission.CREATE_EXCHANGE)), +) -> AvailableSlotsResponse: + """ + Get available booking slots for a specific date. + + Returns all slots that: + - Fall within admin-defined availability windows + - Are not already booked by another user + """ + validate_date_in_range(date_param, context="book") + + # Get availability for the date + result = await db.execute( + select(Availability).where(Availability.date == date_param) + ) + availabilities = result.scalars().all() + + if not availabilities: + return AvailableSlotsResponse(date=date_param, slots=[]) + + # Get already booked slots for the date + date_start = datetime.combine(date_param, time.min, tzinfo=UTC) + date_end = datetime.combine(date_param, time.max, tzinfo=UTC) + + result = await db.execute( + select(Exchange.slot_start).where( + and_( + Exchange.slot_start >= date_start, + Exchange.slot_start <= date_end, + Exchange.status == ExchangeStatus.BOOKED, + ) + ) + ) + booked_starts = {row[0] for row in result.all()} + + # Expand each availability into slots + all_slots: list[BookableSlot] = [] + for avail in availabilities: + slots = _expand_availability_to_slots(avail, date_param, booked_starts) + all_slots.extend(slots) + + # Sort by start time + all_slots.sort(key=lambda s: s.start_time) + + return AvailableSlotsResponse(date=date_param, slots=all_slots) + + # ============================================================================= # Create Exchange Endpoint # ============================================================================= diff --git a/frontend/app/admin/availability/page.tsx b/frontend/app/admin/availability/page.tsx index 390b939..e8e1740 100644 --- a/frontend/app/admin/availability/page.tsx +++ b/frontend/app/admin/availability/page.tsx @@ -22,7 +22,7 @@ import { modalStyles, } from "../../styles/shared"; -const { slotDurationMinutes, maxAdvanceDays, minAdvanceDays } = constants.booking; +const { slotDurationMinutes, maxAdvanceDays, minAdvanceDays } = constants.exchange; type _AvailabilityDay = components["schemas"]["AvailabilityDay"]; type AvailabilityResponse = components["schemas"]["AvailabilityResponse"]; diff --git a/frontend/app/admin/trades/page.tsx b/frontend/app/admin/trades/page.tsx index 3d7bc45..4d52a79 100644 --- a/frontend/app/admin/trades/page.tsx +++ b/frontend/app/admin/trades/page.tsx @@ -88,7 +88,7 @@ type Tab = "upcoming" | "past"; export default function AdminTradesPage() { const { user, isLoading, isAuthorized } = useRequireAuth({ - requiredPermission: Permission.VIEW_ALL_APPOINTMENTS, + requiredPermission: Permission.VIEW_ALL_EXCHANGES, fallbackRedirect: "/", }); diff --git a/frontend/app/auth-context.tsx b/frontend/app/auth-context.tsx index 8399683..7b23511 100644 --- a/frontend/app/auth-context.tsx +++ b/frontend/app/auth-context.tsx @@ -17,14 +17,15 @@ export const Permission: Record = { MANAGE_OWN_PROFILE: "manage_own_profile", MANAGE_INVITES: "manage_invites", VIEW_OWN_INVITES: "view_own_invites", - // Booking permissions (regular users) - BOOK_APPOINTMENT: "book_appointment", - VIEW_OWN_APPOINTMENTS: "view_own_appointments", - CANCEL_OWN_APPOINTMENT: "cancel_own_appointment", - // Availability/Appointments permissions (admin) + // Exchange permissions (regular users) + CREATE_EXCHANGE: "create_exchange", + VIEW_OWN_EXCHANGES: "view_own_exchanges", + CANCEL_OWN_EXCHANGE: "cancel_own_exchange", + // Availability/Exchange permissions (admin) MANAGE_AVAILABILITY: "manage_availability", - VIEW_ALL_APPOINTMENTS: "view_all_appointments", - CANCEL_ANY_APPOINTMENT: "cancel_any_appointment", + VIEW_ALL_EXCHANGES: "view_all_exchanges", + CANCEL_ANY_EXCHANGE: "cancel_any_exchange", + COMPLETE_EXCHANGE: "complete_exchange", } as const; // Use generated type from OpenAPI schema diff --git a/frontend/app/exchange/page.tsx b/frontend/app/exchange/page.tsx index c36ad25..086fab4 100644 --- a/frontend/app/exchange/page.tsx +++ b/frontend/app/exchange/page.tsx @@ -51,7 +51,7 @@ function formatPrice(price: number): string { export default function ExchangePage() { const { user, isLoading, isAuthorized } = useRequireAuth({ - requiredPermission: Permission.BOOK_APPOINTMENT, + requiredPermission: Permission.CREATE_EXCHANGE, fallbackRedirect: "/", }); diff --git a/frontend/app/generated/api.ts b/frontend/app/generated/api.ts index 48a33c8..eaa9d0a 100644 --- a/frontend/app/generated/api.ts +++ b/frontend/app/generated/api.ts @@ -316,126 +316,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/booking/slots": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get Available Slots - * @description Get available booking slots for a specific date. - */ - get: operations["get_available_slots_api_booking_slots_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/booking": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Create Booking - * @description Book an appointment slot. - */ - post: operations["create_booking_api_booking_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/appointments": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get My Appointments - * @description Get the current user's appointments, sorted by date (upcoming first). - */ - get: operations["get_my_appointments_api_appointments_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/appointments/{appointment_id}/cancel": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Cancel My Appointment - * @description Cancel one of the current user's appointments. - */ - post: operations["cancel_my_appointment_api_appointments__appointment_id__cancel_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/admin/appointments": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get All Appointments - * @description Get all appointments (admin only), sorted by date descending with pagination. - */ - get: operations["get_all_appointments_api_admin_appointments_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/admin/appointments/{appointment_id}/cancel": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Admin Cancel Appointment - * @description Cancel any appointment (admin only). - */ - post: operations["admin_cancel_appointment_api_admin_appointments__appointment_id__cancel_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/exchange/price": { parameters: { query?: never; @@ -465,6 +345,30 @@ export interface paths { patch?: never; trace?: never; }; + "/api/exchange/slots": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Available Slots + * @description Get available booking slots for a specific date. + * + * Returns all slots that: + * - Fall within admin-defined availability windows + * - Are not already booked by another user + */ + get: operations["get_available_slots_api_exchange_slots_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/exchange": { parameters: { query?: never; @@ -697,39 +601,6 @@ export interface components { /** Email */ email: string; }; - /** - * AppointmentResponse - * @description Response model for an appointment. - */ - AppointmentResponse: { - /** Id */ - id: number; - /** User Id */ - user_id: number; - /** User Email */ - user_email: string; - /** - * Slot Start - * Format: date-time - */ - slot_start: string; - /** - * Slot End - * Format: date-time - */ - slot_end: string; - /** Note */ - note: string | null; - /** Status */ - status: string; - /** - * Created At - * Format: date-time - */ - created_at: string; - /** Cancelled At */ - cancelled_at: string | null; - }; /** * AvailabilityDay * @description Availability for a single day. @@ -753,7 +624,7 @@ export interface components { }; /** * AvailableSlotsResponse - * @description Response for available slots on a given date. + * @description Response containing available slots for a date. */ AvailableSlotsResponse: { /** @@ -766,7 +637,7 @@ export interface components { }; /** * BookableSlot - * @description A bookable 15-minute slot. + * @description A single bookable time slot. */ BookableSlot: { /** @@ -780,19 +651,6 @@ export interface components { */ end_time: string; }; - /** - * BookingRequest - * @description Request to book an appointment. - */ - BookingRequest: { - /** - * Slot Start - * Format: date-time - */ - slot_start: string; - /** Note */ - note?: string | null; - }; /** * ConstantsResponse * @description Response model for shared constants. @@ -981,19 +839,6 @@ export interface components { * @enum {string} */ InviteStatus: "ready" | "spent" | "revoked"; - /** PaginatedResponse[AppointmentResponse] */ - PaginatedResponse_AppointmentResponse_: { - /** Records */ - records: components["schemas"]["AppointmentResponse"][]; - /** Total */ - total: number; - /** Page */ - page: number; - /** Per Page */ - per_page: number; - /** Total Pages */ - total_pages: number; - }; /** PaginatedResponse[InviteResponse] */ PaginatedResponse_InviteResponse_: { /** Records */ @@ -1012,7 +857,7 @@ export interface components { * @description All available permissions in the system. * @enum {string} */ - Permission: "view_audit" | "fetch_price" | "manage_own_profile" | "manage_invites" | "view_own_invites" | "book_appointment" | "view_own_appointments" | "cancel_own_appointment" | "manage_availability" | "view_all_appointments" | "cancel_any_appointment"; + Permission: "view_audit" | "fetch_price" | "manage_own_profile" | "manage_invites" | "view_own_invites" | "create_exchange" | "view_own_exchanges" | "cancel_own_exchange" | "manage_availability" | "view_all_exchanges" | "cancel_any_exchange" | "complete_exchange"; /** * PriceHistoryResponse * @description Response model for a price history record. @@ -1688,10 +1533,29 @@ export interface operations { }; }; }; - get_available_slots_api_booking_slots_get: { + get_exchange_price_api_exchange_price_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ExchangePriceResponse"]; + }; + }; + }; + }; + get_available_slots_api_exchange_slots_get: { parameters: { query: { - /** @description Date to get slots for */ date: string; }; header?: never; @@ -1720,173 +1584,6 @@ export interface operations { }; }; }; - create_booking_api_booking_post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["BookingRequest"]; - }; - }; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["AppointmentResponse"]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; - get_my_appointments_api_appointments_get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["AppointmentResponse"][]; - }; - }; - }; - }; - cancel_my_appointment_api_appointments__appointment_id__cancel_post: { - parameters: { - query?: never; - header?: never; - path: { - appointment_id: number; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["AppointmentResponse"]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; - get_all_appointments_api_admin_appointments_get: { - parameters: { - query?: { - page?: number; - per_page?: number; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["PaginatedResponse_AppointmentResponse_"]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; - admin_cancel_appointment_api_admin_appointments__appointment_id__cancel_post: { - parameters: { - query?: never; - header?: never; - path: { - appointment_id: number; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["AppointmentResponse"]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; - get_exchange_price_api_exchange_price_get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ExchangePriceResponse"]; - }; - }; - }; - }; create_exchange_api_exchange_post: { parameters: { query?: never; diff --git a/frontend/app/profile/page.test.tsx b/frontend/app/profile/page.test.tsx index dc76a6c..1616b15 100644 --- a/frontend/app/profile/page.test.tsx +++ b/frontend/app/profile/page.test.tsx @@ -15,7 +15,12 @@ let mockUser: { id: number; email: string; roles: string[]; permissions: string[ id: 1, email: "test@example.com", roles: ["regular"], - permissions: ["view_counter", "increment_counter", "use_sum", "manage_own_profile"], + permissions: [ + "create_exchange", + "view_own_exchanges", + "cancel_own_exchange", + "manage_own_profile", + ], }; let mockIsLoading = false; const mockLogout = vi.fn(); @@ -33,19 +38,18 @@ vi.mock("../auth-context", () => ({ hasPermission: mockHasPermission, }), Permission: { - VIEW_COUNTER: "view_counter", - INCREMENT_COUNTER: "increment_counter", - USE_SUM: "use_sum", VIEW_AUDIT: "view_audit", + FETCH_PRICE: "fetch_price", MANAGE_OWN_PROFILE: "manage_own_profile", MANAGE_INVITES: "manage_invites", VIEW_OWN_INVITES: "view_own_invites", - BOOK_APPOINTMENT: "book_appointment", - VIEW_OWN_APPOINTMENTS: "view_own_appointments", - CANCEL_OWN_APPOINTMENT: "cancel_own_appointment", + CREATE_EXCHANGE: "create_exchange", + VIEW_OWN_EXCHANGES: "view_own_exchanges", + CANCEL_OWN_EXCHANGE: "cancel_own_exchange", MANAGE_AVAILABILITY: "manage_availability", - VIEW_ALL_APPOINTMENTS: "view_all_appointments", - CANCEL_ANY_APPOINTMENT: "cancel_any_appointment", + VIEW_ALL_EXCHANGES: "view_all_exchanges", + CANCEL_ANY_EXCHANGE: "cancel_any_exchange", + COMPLETE_EXCHANGE: "complete_exchange", }, })); @@ -64,7 +68,12 @@ beforeEach(() => { id: 1, email: "test@example.com", roles: ["regular"], - permissions: ["view_counter", "increment_counter", "use_sum", "manage_own_profile"], + permissions: [ + "create_exchange", + "view_own_exchanges", + "cancel_own_exchange", + "manage_own_profile", + ], }; mockIsLoading = false; mockHasRole.mockImplementation((role: string) => mockUser?.roles.includes(role) ?? false); diff --git a/frontend/app/trades/page.tsx b/frontend/app/trades/page.tsx index 60cfdcf..4f78986 100644 --- a/frontend/app/trades/page.tsx +++ b/frontend/app/trades/page.tsx @@ -86,7 +86,7 @@ function getTradeStatusDisplay(status: string): { export default function TradesPage() { const { user, isLoading, isAuthorized } = useRequireAuth({ - requiredPermission: Permission.VIEW_OWN_APPOINTMENTS, + requiredPermission: Permission.VIEW_OWN_EXCHANGES, fallbackRedirect: "/", }); diff --git a/frontend/e2e/exchange.spec.ts b/frontend/e2e/exchange.spec.ts index e213aa6..e1f77e4 100644 --- a/frontend/e2e/exchange.spec.ts +++ b/frontend/e2e/exchange.spec.ts @@ -118,10 +118,12 @@ test.describe("Exchange Page - With Availability", () => { const weekday = tomorrow.toLocaleDateString("en-US", { weekday: "short" }); // Click tomorrow's date using the weekday name + // Wait for the button to be enabled (availability loading must complete) const dateButton = page .locator("button") .filter({ hasText: new RegExp(`^${weekday}`) }) .first(); + await expect(dateButton).toBeEnabled({ timeout: 15000 }); await dateButton.click(); // Wait for "Available Slots" section to appear @@ -143,11 +145,12 @@ test.describe("Exchange Page - With Availability", () => { tomorrow.setDate(tomorrow.getDate() + 1); const weekday = tomorrow.toLocaleDateString("en-US", { weekday: "short" }); - // Click tomorrow's date + // Click tomorrow's date - wait for button to be enabled first const dateButton = page .locator("button") .filter({ hasText: new RegExp(`^${weekday}`) }) .first(); + await expect(dateButton).toBeEnabled({ timeout: 15000 }); await dateButton.click(); // Wait for any slot to appear @@ -171,11 +174,12 @@ test.describe("Exchange Page - With Availability", () => { tomorrow.setDate(tomorrow.getDate() + 1); const weekday = tomorrow.toLocaleDateString("en-US", { weekday: "short" }); - // Click tomorrow's date + // Click tomorrow's date - wait for button to be enabled first const dateButton = page .locator("button") .filter({ hasText: new RegExp(`^${weekday}`) }) .first(); + await expect(dateButton).toBeEnabled({ timeout: 15000 }); await dateButton.click(); // Wait for slots to load diff --git a/frontend/e2e/permissions.spec.ts b/frontend/e2e/permissions.spec.ts index b3a1ce3..d0e3fc4 100644 --- a/frontend/e2e/permissions.spec.ts +++ b/frontend/e2e/permissions.spec.ts @@ -87,8 +87,8 @@ test.describe("Regular User Access", () => { // Should stay on trades page await expect(page).toHaveURL("/trades"); - // Should see trades UI - await expect(page.getByText("My Trades")).toBeVisible(); + // Should see trades UI heading + await expect(page.getByRole("heading", { name: "My Trades" })).toBeVisible(); }); test("navigation shows exchange and trades", async ({ page }) => { diff --git a/frontend/e2e/price-history.spec.ts b/frontend/e2e/price-history.spec.ts index c43ac74..9e313f1 100644 --- a/frontend/e2e/price-history.spec.ts +++ b/frontend/e2e/price-history.spec.ts @@ -53,8 +53,8 @@ test.describe("Price History - E2E", () => { // Try to navigate directly to the admin page await page.goto("/admin/price-history"); - // Should be redirected away (to "/" since fallbackRedirect is "/") - await expect(page).toHaveURL("/"); + // Should be redirected away (regular users go to /exchange) + await expect(page).toHaveURL("/exchange"); }); test("price history shows in navigation for admin", async ({ page }) => {