diff --git a/backend/routes/booking.py b/backend/routes/booking.py index 9421445..770d2da 100644 --- a/backend/routes/booking.py +++ b/backend/routes/booking.py @@ -2,7 +2,7 @@ from datetime import date, datetime, time, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy import select, and_ +from sqlalchemy import select, and_, func from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession @@ -14,6 +14,7 @@ from schemas import ( AvailableSlotsResponse, BookingRequest, AppointmentResponse, + PaginatedAppointments, ) from shared_constants import SLOT_DURATION_MINUTES, MIN_ADVANCE_DAYS, MAX_ADVANCE_DAYS @@ -199,13 +200,6 @@ async def create_booking( appointments_router = APIRouter(prefix="/api/appointments", tags=["appointments"]) -async def _get_user_email(db: AsyncSession, user_id: int) -> str: - """Get user email by ID.""" - result = await db.execute(select(User.email).where(User.id == user_id)) - email = result.scalar_one_or_none() - return email or "unknown" - - @appointments_router.get("", response_model=list[AppointmentResponse]) async def get_my_appointments( db: AsyncSession = Depends(get_db), @@ -296,34 +290,52 @@ async def cancel_my_appointment( admin_appointments_router = APIRouter(prefix="/api/admin/appointments", tags=["admin-appointments"]) -@admin_appointments_router.get("", response_model=list[AppointmentResponse]) +@admin_appointments_router.get("", response_model=PaginatedAppointments) async def get_all_appointments( + page: int = Query(1, ge=1), + per_page: int = Query(10, ge=1, le=100), db: AsyncSession = Depends(get_db), _current_user: User = Depends(require_permission(Permission.VIEW_ALL_APPOINTMENTS)), -) -> list[AppointmentResponse]: - """Get all appointments (admin only), sorted by date descending.""" +) -> PaginatedAppointments: + """Get all appointments (admin only), sorted by date descending with pagination.""" + # Get total count + count_result = await db.execute(select(func.count(Appointment.id))) + total = count_result.scalar() or 0 + total_pages = (total + per_page - 1) // per_page if total > 0 else 1 + + # Get paginated appointments (user relationship is eager-loaded via lazy="joined") + offset = (page - 1) * per_page result = await db.execute( select(Appointment) .order_by(Appointment.slot_start.desc()) + .offset(offset) + .limit(per_page) ) appointments = result.scalars().all() - responses = [] - for apt in appointments: - user_email = await _get_user_email(db, apt.user_id) - responses.append(AppointmentResponse( + # Build responses using the eager-loaded user relationship + records = [ + AppointmentResponse( id=apt.id, user_id=apt.user_id, - user_email=user_email, + user_email=apt.user.email, # Uses eager-loaded relationship slot_start=apt.slot_start, slot_end=apt.slot_end, note=apt.note, status=apt.status.value, created_at=apt.created_at, cancelled_at=apt.cancelled_at, - )) + ) + for apt in appointments + ] - return responses + return PaginatedAppointments( + records=records, + total=total, + page=page, + per_page=per_page, + total_pages=total_pages, + ) @admin_appointments_router.post("/{appointment_id}/cancel", response_model=AppointmentResponse) @@ -363,12 +375,10 @@ async def admin_cancel_appointment( await db.commit() await db.refresh(appointment) - user_email = await _get_user_email(db, appointment.user_id) - return AppointmentResponse( id=appointment.id, user_id=appointment.user_id, - user_email=user_email, + user_email=appointment.user.email, # Uses eager-loaded relationship slot_start=appointment.slot_start, slot_end=appointment.slot_end, note=appointment.note, diff --git a/backend/tests/test_booking.py b/backend/tests/test_booking.py index 86e6cc7..d06c921 100644 --- a/backend/tests/test_booking.py +++ b/backend/tests/test_booking.py @@ -716,8 +716,13 @@ class TestAdminViewAppointments: assert response.status_code == 200 data = response.json() - assert len(data) >= 1 - assert any(apt["note"] == "Test" for apt in data) + # Paginated response + assert "records" in data + assert "total" in data + assert "page" in data + assert "per_page" in data + assert len(data["records"]) >= 1 + assert any(apt["note"] == "Test" for apt in data["records"]) @pytest.mark.asyncio async def test_regular_user_cannot_view_all_appointments(self, client_factory, regular_user): diff --git a/frontend/app/admin/appointments/page.tsx b/frontend/app/admin/appointments/page.tsx index 11b4b93..3ed8d62 100644 --- a/frontend/app/admin/appointments/page.tsx +++ b/frontend/app/admin/appointments/page.tsx @@ -9,6 +9,7 @@ import { useRequireAuth } from "../../hooks/useRequireAuth"; import { components } from "../../generated/api"; type AppointmentResponse = components["schemas"]["AppointmentResponse"]; +type PaginatedAppointments = components["schemas"]["PaginatedResponse_AppointmentResponse_"]; // Helper to format datetime function formatDateTime(isoString: string): string { @@ -200,8 +201,9 @@ export default function AdminAppointmentsPage() { const fetchAppointments = useCallback(async () => { try { - const data = await api.get("/api/admin/appointments"); - setAppointments(data); + // Fetch with large per_page to get all appointments for now + const data = await api.get("/api/admin/appointments?per_page=100"); + setAppointments(data.records); } catch (err) { console.error("Failed to fetch appointments:", err); setError("Failed to load appointments"); diff --git a/frontend/app/generated/api.ts b/frontend/app/generated/api.ts index 3a396d0..da9949e 100644 --- a/frontend/app/generated/api.ts +++ b/frontend/app/generated/api.ts @@ -445,7 +445,7 @@ export interface paths { }; /** * Get All Appointments - * @description Get all appointments (admin only), sorted by date descending. + * @description Get all appointments (admin only), sorted by date descending with pagination. */ get: operations["get_all_appointments_api_admin_appointments_get"]; put?: never; @@ -704,6 +704,19 @@ export interface components { /** Revoked At */ revoked_at: string | null; }; + /** 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[CounterRecordResponse] */ PaginatedResponse_CounterRecordResponse_: { /** Records */ @@ -1617,7 +1630,10 @@ export interface operations { }; get_all_appointments_api_admin_appointments_get: { parameters: { - query?: never; + query?: { + page?: number; + per_page?: number; + }; header?: never; path?: never; cookie?: never; @@ -1630,7 +1646,16 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AppointmentResponse"][]; + "application/json": components["schemas"]["PaginatedResponse_AppointmentResponse_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; }; }; };