diff --git a/backend/main.py b/backend/main.py index 3678619..bead725 100644 --- a/backend/main.py +++ b/backend/main.py @@ -13,6 +13,7 @@ from routes import invites as invites_routes from routes import auth as auth_routes from routes import meta as meta_routes from routes import availability as availability_routes +from routes import booking as booking_routes from validate_constants import validate_shared_constants @@ -46,4 +47,5 @@ app.include_router(profile_routes.router) app.include_router(invites_routes.router) app.include_router(invites_routes.admin_router) app.include_router(availability_routes.router) +app.include_router(booking_routes.router) app.include_router(meta_routes.router) diff --git a/backend/models.py b/backend/models.py index b90a478..bbe5b91 100644 --- a/backend/models.py +++ b/backend/models.py @@ -269,3 +269,27 @@ class Availability(Base): default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC) ) + + +class Appointment(Base): + """User appointment bookings.""" + __tablename__ = "appointments" + __table_args__ = ( + UniqueConstraint("slot_start", name="uq_appointment_slot_start"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id"), nullable=False, index=True + ) + user: Mapped[User] = relationship("User", foreign_keys=[user_id], lazy="joined") + slot_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) + slot_end: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + note: Mapped[str | None] = mapped_column(String(144), nullable=True) + status: Mapped[AppointmentStatus] = mapped_column( + Enum(AppointmentStatus), nullable=False, default=AppointmentStatus.BOOKED + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(UTC) + ) + cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/backend/routes/booking.py b/backend/routes/booking.py new file mode 100644 index 0000000..1788ac6 --- /dev/null +++ b/backend/routes/booking.py @@ -0,0 +1,197 @@ +"""Booking routes for users to book appointments.""" +from datetime import date, datetime, time, timedelta, timezone + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import select, and_ +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from auth import require_permission +from database import get_db +from models import User, Availability, Appointment, AppointmentStatus, Permission +from schemas import ( + BookableSlot, + AvailableSlotsResponse, + BookingRequest, + AppointmentResponse, +) + + +router = APIRouter(prefix="/api/booking", tags=["booking"]) + +# From shared/constants.json +SLOT_DURATION_MINUTES = 15 +MIN_ADVANCE_DAYS = 1 +MAX_ADVANCE_DAYS = 30 + + +def _get_bookable_date_range() -> tuple[date, date]: + """Get the valid date range for booking (tomorrow to +30 days).""" + today = date.today() + min_date = today + timedelta(days=MIN_ADVANCE_DAYS) + max_date = today + timedelta(days=MAX_ADVANCE_DAYS) + return min_date, max_date + + +def _validate_booking_date(d: date) -> None: + """Validate a date is within the bookable range.""" + min_date, max_date = _get_bookable_date_range() + if d < min_date: + raise HTTPException( + status_code=400, + detail=f"Cannot book for today or past dates. Earliest bookable date: {min_date}", + ) + if d > max_date: + raise HTTPException( + status_code=400, + detail=f"Cannot book more than {MAX_ADVANCE_DAYS} days ahead. Latest bookable: {max_date}", + ) + + +def _expand_availability_to_slots( + availability_slots: list[Availability], + target_date: date, +) -> list[BookableSlot]: + """Expand availability time ranges into 15-minute bookable slots.""" + result: list[BookableSlot] = [] + + for avail in availability_slots: + # Create datetime objects for start and end + current = datetime.combine(target_date, avail.start_time, tzinfo=timezone.utc) + end = datetime.combine(target_date, avail.end_time, tzinfo=timezone.utc) + + # Generate 15-minute slots + while current + timedelta(minutes=SLOT_DURATION_MINUTES) <= end: + slot_end = current + timedelta(minutes=SLOT_DURATION_MINUTES) + result.append(BookableSlot(start_time=current, end_time=slot_end)) + current = slot_end + + return result + + +@router.get("/slots", response_model=AvailableSlotsResponse) +async def get_available_slots( + target_date: date = Query(..., alias="date", description="Date to get slots for"), + db: AsyncSession = Depends(get_db), + _current_user: User = Depends(require_permission(Permission.BOOK_APPOINTMENT)), +) -> AvailableSlotsResponse: + """Get available booking slots for a specific date.""" + _validate_booking_date(target_date) + + # Get availability for this date + result = await db.execute( + select(Availability) + .where(Availability.date == target_date) + .order_by(Availability.start_time) + ) + availability_slots = result.scalars().all() + + if not availability_slots: + return AvailableSlotsResponse(date=target_date, slots=[]) + + # Expand to 15-minute slots + all_slots = _expand_availability_to_slots(availability_slots, target_date) + + # Get existing booked appointments for this date + day_start = datetime.combine(target_date, time.min, tzinfo=timezone.utc) + day_end = datetime.combine(target_date, time.max, tzinfo=timezone.utc) + + result = await db.execute( + select(Appointment.slot_start) + .where( + and_( + Appointment.slot_start >= day_start, + Appointment.slot_start <= day_end, + Appointment.status == AppointmentStatus.BOOKED, + ) + ) + ) + booked_starts = {row[0] for row in result.fetchall()} + + # Filter out already booked slots + available_slots = [ + slot for slot in all_slots + if slot.start_time not in booked_starts + ] + + return AvailableSlotsResponse(date=target_date, slots=available_slots) + + +@router.post("", response_model=AppointmentResponse) +async def create_booking( + request: BookingRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_permission(Permission.BOOK_APPOINTMENT)), +) -> AppointmentResponse: + """Book an appointment slot.""" + slot_date = request.slot_start.date() + _validate_booking_date(slot_date) + + # Validate slot is on 15-minute boundary + if request.slot_start.minute not in (0, 15, 30, 45): + raise HTTPException( + status_code=400, + detail="Slot start time must be on 15-minute boundary", + ) + if request.slot_start.second != 0 or request.slot_start.microsecond != 0: + raise HTTPException( + status_code=400, + detail="Slot start time must not have seconds or microseconds", + ) + + # Verify slot falls within availability + slot_start_time = request.slot_start.time() + slot_end_time = (request.slot_start + timedelta(minutes=SLOT_DURATION_MINUTES)).time() + + result = await db.execute( + select(Availability) + .where( + and_( + Availability.date == slot_date, + Availability.start_time <= slot_start_time, + Availability.end_time >= slot_end_time, + ) + ) + ) + matching_availability = result.scalar_one_or_none() + + if not matching_availability: + raise HTTPException( + status_code=400, + detail="Selected slot is not within available time ranges", + ) + + # Create the appointment + slot_end = request.slot_start + timedelta(minutes=SLOT_DURATION_MINUTES) + appointment = Appointment( + user_id=current_user.id, + slot_start=request.slot_start, + slot_end=slot_end, + note=request.note, + status=AppointmentStatus.BOOKED, + ) + + db.add(appointment) + + try: + await db.commit() + await db.refresh(appointment) + except IntegrityError: + await db.rollback() + raise HTTPException( + status_code=409, + detail="This slot has already been booked. Please select another slot.", + ) + + return AppointmentResponse( + id=appointment.id, + user_id=appointment.user_id, + user_email=current_user.email, + slot_start=appointment.slot_start, + slot_end=appointment.slot_end, + note=appointment.note, + status=appointment.status.value, + created_at=appointment.created_at, + cancelled_at=appointment.cancelled_at, + ) + diff --git a/backend/schemas.py b/backend/schemas.py index 7b2bf73..b79691b 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -183,6 +183,51 @@ class CopyAvailabilityRequest(BaseModel): target_dates: list[date] +# ============================================================================= +# Booking Schemas +# ============================================================================= + +class BookableSlot(BaseModel): + """A bookable 15-minute slot.""" + start_time: datetime + end_time: datetime + + +class AvailableSlotsResponse(BaseModel): + """Response for available slots on a given date.""" + date: date + slots: list[BookableSlot] + + +class BookingRequest(BaseModel): + """Request to book an appointment.""" + slot_start: datetime + note: str | None = None + + @field_validator("note") + @classmethod + def validate_note_length(cls, v: str | None) -> str | None: + if v is not None and len(v) > 144: + raise ValueError("Note must be at most 144 characters") + return v + + +class AppointmentResponse(BaseModel): + """Response model for an appointment.""" + id: int + user_id: int + user_email: str + slot_start: datetime + slot_end: datetime + note: str | None + status: str + created_at: datetime + cancelled_at: datetime | None + + +PaginatedAppointments = PaginatedResponse[AppointmentResponse] + + # ============================================================================= # Meta/Constants Schemas # ============================================================================= diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index f363edf..140a97c 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -149,7 +149,8 @@ async def regular_user(client_factory): password = "password123" async with client_factory.get_db_session() as db: - await create_user_with_roles(db, email, password, [ROLE_REGULAR]) + user = await create_user_with_roles(db, email, password, [ROLE_REGULAR]) + user_id = user.id # Login to get cookies response = await client_factory.post( @@ -162,6 +163,32 @@ async def regular_user(client_factory): "password": password, "cookies": dict(response.cookies), "response": response, + "user": {"id": user_id, "email": email}, + } + + +@pytest.fixture(scope="function") +async def alt_regular_user(client_factory): + """Create a second regular user for tests needing multiple users.""" + email = unique_email("alt_regular") + password = "password123" + + async with client_factory.get_db_session() as db: + user = await create_user_with_roles(db, email, password, [ROLE_REGULAR]) + user_id = user.id + + # Login to get cookies + response = await client_factory.post( + "/api/auth/login", + json={"email": email, "password": password}, + ) + + return { + "email": email, + "password": password, + "cookies": dict(response.cookies), + "response": response, + "user": {"id": user_id, "email": email}, } diff --git a/backend/tests/test_booking.py b/backend/tests/test_booking.py new file mode 100644 index 0000000..e0b82e5 --- /dev/null +++ b/backend/tests/test_booking.py @@ -0,0 +1,449 @@ +""" +Booking API Tests + +Tests for the user booking endpoints. +""" +from datetime import date, timedelta +import pytest + + +def tomorrow() -> date: + return date.today() + timedelta(days=1) + + +def in_days(n: int) -> date: + return date.today() + timedelta(days=n) + + +# ============================================================================= +# Permission Tests +# ============================================================================= + +class TestBookingPermissions: + """Test that only regular users can book appointments.""" + + @pytest.mark.asyncio + async def test_regular_user_can_get_slots(self, client_factory, regular_user, admin_user): + """Regular user can get available slots.""" + # First, admin sets up availability + async with client_factory.create(cookies=admin_user["cookies"]) as admin_client: + await admin_client.put( + "/api/admin/availability", + json={ + "date": str(tomorrow()), + "slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}], + }, + ) + + # Regular user gets slots + async with client_factory.create(cookies=regular_user["cookies"]) as client: + response = await client.get("/api/booking/slots", params={"date": str(tomorrow())}) + + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_regular_user_can_book(self, client_factory, regular_user, admin_user): + """Regular user can book an appointment.""" + # Admin sets up availability + async with client_factory.create(cookies=admin_user["cookies"]) as admin_client: + await admin_client.put( + "/api/admin/availability", + json={ + "date": str(tomorrow()), + "slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}], + }, + ) + + # Regular user books + async with client_factory.create(cookies=regular_user["cookies"]) as client: + response = await client.post( + "/api/booking", + json={"slot_start": f"{tomorrow()}T09:00:00Z", "note": "Test booking"}, + ) + + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_admin_cannot_get_slots(self, client_factory, admin_user): + """Admin cannot access booking slots endpoint.""" + async with client_factory.create(cookies=admin_user["cookies"]) as client: + response = await client.get("/api/booking/slots", params={"date": str(tomorrow())}) + + assert response.status_code == 403 + + @pytest.mark.asyncio + async def test_admin_cannot_book(self, client_factory, admin_user): + """Admin cannot book appointments.""" + # Admin sets up availability first + async with client_factory.create(cookies=admin_user["cookies"]) as client: + await client.put( + "/api/admin/availability", + json={ + "date": str(tomorrow()), + "slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}], + }, + ) + + response = await client.post( + "/api/booking", + json={"slot_start": f"{tomorrow()}T09:00:00Z"}, + ) + + assert response.status_code == 403 + + @pytest.mark.asyncio + async def test_unauthenticated_cannot_get_slots(self, client): + """Unauthenticated user cannot get slots.""" + response = await client.get("/api/booking/slots", params={"date": str(tomorrow())}) + assert response.status_code == 401 + + @pytest.mark.asyncio + async def test_unauthenticated_cannot_book(self, client): + """Unauthenticated user cannot book.""" + response = await client.post( + "/api/booking", + json={"slot_start": f"{tomorrow()}T09:00:00Z"}, + ) + assert response.status_code == 401 + + +# ============================================================================= +# Get Slots Tests +# ============================================================================= + +class TestGetSlots: + """Test getting available booking slots.""" + + @pytest.mark.asyncio + async def test_get_slots_no_availability(self, client_factory, regular_user): + """Returns empty slots when no availability set.""" + async with client_factory.create(cookies=regular_user["cookies"]) as client: + response = await client.get("/api/booking/slots", params={"date": str(tomorrow())}) + + assert response.status_code == 200 + data = response.json() + assert data["date"] == str(tomorrow()) + assert data["slots"] == [] + + @pytest.mark.asyncio + async def test_get_slots_expands_to_15min(self, client_factory, regular_user, admin_user): + """Availability is expanded into 15-minute slots.""" + # Admin sets 1-hour availability + async with client_factory.create(cookies=admin_user["cookies"]) as admin_client: + await admin_client.put( + "/api/admin/availability", + json={ + "date": str(tomorrow()), + "slots": [{"start_time": "09:00:00", "end_time": "10:00:00"}], + }, + ) + + # User gets slots - should be 4 x 15-minute slots + async with client_factory.create(cookies=regular_user["cookies"]) as client: + response = await client.get("/api/booking/slots", params={"date": str(tomorrow())}) + + assert response.status_code == 200 + data = response.json() + assert len(data["slots"]) == 4 + + # Verify times + assert "09:00:00" in data["slots"][0]["start_time"] + assert "09:15:00" in data["slots"][0]["end_time"] + assert "09:15:00" in data["slots"][1]["start_time"] + assert "09:45:00" in data["slots"][3]["start_time"] + assert "10:00:00" in data["slots"][3]["end_time"] + + @pytest.mark.asyncio + async def test_get_slots_excludes_booked(self, client_factory, regular_user, admin_user): + """Already booked slots are excluded from available slots.""" + # Admin sets availability + async with client_factory.create(cookies=admin_user["cookies"]) as admin_client: + await admin_client.put( + "/api/admin/availability", + json={ + "date": str(tomorrow()), + "slots": [{"start_time": "09:00:00", "end_time": "10:00:00"}], + }, + ) + + # User books first slot + async with client_factory.create(cookies=regular_user["cookies"]) as client: + await client.post( + "/api/booking", + json={"slot_start": f"{tomorrow()}T09:00:00Z"}, + ) + + # Get slots again - should have 3 left + response = await client.get("/api/booking/slots", params={"date": str(tomorrow())}) + + assert response.status_code == 200 + data = response.json() + assert len(data["slots"]) == 3 + # First slot should now be 09:15 + assert "09:15:00" in data["slots"][0]["start_time"] + + +# ============================================================================= +# Booking Tests +# ============================================================================= + +class TestCreateBooking: + """Test creating bookings.""" + + @pytest.mark.asyncio + async def test_book_slot_success(self, client_factory, regular_user, admin_user): + """Can successfully book an available slot.""" + # Admin sets availability + async with client_factory.create(cookies=admin_user["cookies"]) as admin_client: + await admin_client.put( + "/api/admin/availability", + json={ + "date": str(tomorrow()), + "slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}], + }, + ) + + # User books + async with client_factory.create(cookies=regular_user["cookies"]) as client: + response = await client.post( + "/api/booking", + json={ + "slot_start": f"{tomorrow()}T09:00:00Z", + "note": "Discussion about project", + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data["user_id"] == regular_user["user"]["id"] + assert data["note"] == "Discussion about project" + assert data["status"] == "booked" + assert "09:00:00" in data["slot_start"] + assert "09:15:00" in data["slot_end"] + + @pytest.mark.asyncio + async def test_book_without_note(self, client_factory, regular_user, admin_user): + """Can book without a note.""" + # Admin sets availability + async with client_factory.create(cookies=admin_user["cookies"]) as admin_client: + await admin_client.put( + "/api/admin/availability", + json={ + "date": str(tomorrow()), + "slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}], + }, + ) + + # User books without note + async with client_factory.create(cookies=regular_user["cookies"]) as client: + response = await client.post( + "/api/booking", + json={"slot_start": f"{tomorrow()}T09:00:00Z"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["note"] is None + + @pytest.mark.asyncio + async def test_cannot_double_book_slot(self, client_factory, regular_user, admin_user, alt_regular_user): + """Cannot book a slot that's already booked.""" + # Admin sets availability + async with client_factory.create(cookies=admin_user["cookies"]) as admin_client: + await admin_client.put( + "/api/admin/availability", + json={ + "date": str(tomorrow()), + "slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}], + }, + ) + + # First user books + async with client_factory.create(cookies=regular_user["cookies"]) as client: + response = await client.post( + "/api/booking", + json={"slot_start": f"{tomorrow()}T09:00:00Z"}, + ) + assert response.status_code == 200 + + # Second user tries to book same slot + async with client_factory.create(cookies=alt_regular_user["cookies"]) as client: + response = await client.post( + "/api/booking", + json={"slot_start": f"{tomorrow()}T09:00:00Z"}, + ) + + assert response.status_code == 409 + assert "already been booked" in response.json()["detail"] + + @pytest.mark.asyncio + async def test_cannot_book_outside_availability(self, client_factory, regular_user, admin_user): + """Cannot book a slot outside of availability.""" + # Admin sets availability for morning only + async with client_factory.create(cookies=admin_user["cookies"]) as admin_client: + await admin_client.put( + "/api/admin/availability", + json={ + "date": str(tomorrow()), + "slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}], + }, + ) + + # User tries to book afternoon slot + async with client_factory.create(cookies=regular_user["cookies"]) as client: + response = await client.post( + "/api/booking", + json={"slot_start": f"{tomorrow()}T14:00:00Z"}, + ) + + assert response.status_code == 400 + assert "not within available" in response.json()["detail"] + + +# ============================================================================= +# Date Validation Tests +# ============================================================================= + +class TestBookingDateValidation: + """Test date validation for bookings.""" + + @pytest.mark.asyncio + async def test_cannot_book_today(self, client_factory, regular_user): + """Cannot book for today (same day).""" + async with client_factory.create(cookies=regular_user["cookies"]) as client: + response = await client.post( + "/api/booking", + json={"slot_start": f"{date.today()}T09:00:00Z"}, + ) + + assert response.status_code == 400 + assert "past" in response.json()["detail"].lower() or "today" in response.json()["detail"].lower() + + @pytest.mark.asyncio + async def test_cannot_book_past_date(self, client_factory, regular_user): + """Cannot book for past date.""" + yesterday = date.today() - timedelta(days=1) + async with client_factory.create(cookies=regular_user["cookies"]) as client: + response = await client.post( + "/api/booking", + json={"slot_start": f"{yesterday}T09:00:00Z"}, + ) + + assert response.status_code == 400 + + @pytest.mark.asyncio + async def test_cannot_book_beyond_30_days(self, client_factory, regular_user): + """Cannot book more than 30 days in advance.""" + too_far = in_days(31) + async with client_factory.create(cookies=regular_user["cookies"]) as client: + response = await client.post( + "/api/booking", + json={"slot_start": f"{too_far}T09:00:00Z"}, + ) + + assert response.status_code == 400 + assert "30" in response.json()["detail"] + + @pytest.mark.asyncio + async def test_cannot_get_slots_today(self, client_factory, regular_user): + """Cannot get slots for today.""" + async with client_factory.create(cookies=regular_user["cookies"]) as client: + response = await client.get("/api/booking/slots", params={"date": str(date.today())}) + + assert response.status_code == 400 + + @pytest.mark.asyncio + async def test_cannot_get_slots_past(self, client_factory, regular_user): + """Cannot get slots for past date.""" + yesterday = date.today() - timedelta(days=1) + async with client_factory.create(cookies=regular_user["cookies"]) as client: + response = await client.get("/api/booking/slots", params={"date": str(yesterday)}) + + assert response.status_code == 400 + + +# ============================================================================= +# Time Validation Tests +# ============================================================================= + +class TestBookingTimeValidation: + """Test time validation for bookings.""" + + @pytest.mark.asyncio + async def test_slot_must_be_15min_boundary(self, client_factory, regular_user, admin_user): + """Slot start time must be on 15-minute boundary.""" + # Admin sets availability + async with client_factory.create(cookies=admin_user["cookies"]) as admin_client: + await admin_client.put( + "/api/admin/availability", + json={ + "date": str(tomorrow()), + "slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}], + }, + ) + + # User tries to book at 09:05 + async with client_factory.create(cookies=regular_user["cookies"]) as client: + response = await client.post( + "/api/booking", + json={"slot_start": f"{tomorrow()}T09:05:00Z"}, + ) + + assert response.status_code == 400 + assert "15-minute" in response.json()["detail"] + + +# ============================================================================= +# Note Validation Tests +# ============================================================================= + +class TestBookingNoteValidation: + """Test note validation for bookings.""" + + @pytest.mark.asyncio + async def test_note_max_length(self, client_factory, regular_user, admin_user): + """Note cannot exceed 144 characters.""" + # Admin sets availability + async with client_factory.create(cookies=admin_user["cookies"]) as admin_client: + await admin_client.put( + "/api/admin/availability", + json={ + "date": str(tomorrow()), + "slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}], + }, + ) + + # User tries to book with long note + long_note = "x" * 145 + async with client_factory.create(cookies=regular_user["cookies"]) as client: + response = await client.post( + "/api/booking", + json={"slot_start": f"{tomorrow()}T09:00:00Z", "note": long_note}, + ) + + assert response.status_code == 422 + + @pytest.mark.asyncio + async def test_note_exactly_144_chars(self, client_factory, regular_user, admin_user): + """Note of exactly 144 characters is allowed.""" + # Admin sets availability + async with client_factory.create(cookies=admin_user["cookies"]) as admin_client: + await admin_client.put( + "/api/admin/availability", + json={ + "date": str(tomorrow()), + "slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}], + }, + ) + + # User books with exactly 144 char note + note = "x" * 144 + async with client_factory.create(cookies=regular_user["cookies"]) as client: + response = await client.post( + "/api/booking", + json={"slot_start": f"{tomorrow()}T09:00:00Z", "note": note}, + ) + + assert response.status_code == 200 + assert response.json()["note"] == note + diff --git a/frontend/app/admin/availability/page.tsx b/frontend/app/admin/availability/page.tsx index c327caa..4f50c25 100644 --- a/frontend/app/admin/availability/page.tsx +++ b/frontend/app/admin/availability/page.tsx @@ -15,9 +15,12 @@ type AvailabilityDay = components["schemas"]["AvailabilityDay"]; type AvailabilityResponse = components["schemas"]["AvailabilityResponse"]; type TimeSlot = components["schemas"]["TimeSlot"]; -// Helper to format date as YYYY-MM-DD +// Helper to format date as YYYY-MM-DD in local timezone function formatDate(d: Date): string { - return d.toISOString().split("T")[0]; + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; } // Helper to get next N days starting from tomorrow diff --git a/frontend/app/generated/api.ts b/frontend/app/generated/api.ts index ff2e906..7c7b754 100644 --- a/frontend/app/generated/api.ts +++ b/frontend/app/generated/api.ts @@ -356,6 +356,46 @@ 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/meta/constants": { parameters: { query?: never; @@ -390,6 +430,39 @@ 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. @@ -411,6 +484,48 @@ export interface components { /** Days */ days: components["schemas"]["AvailabilityDay"][]; }; + /** + * AvailableSlotsResponse + * @description Response for available slots on a given date. + */ + AvailableSlotsResponse: { + /** + * Date + * Format: date + */ + date: string; + /** Slots */ + slots: components["schemas"]["BookableSlot"][]; + }; + /** + * BookableSlot + * @description A bookable 15-minute slot. + */ + BookableSlot: { + /** + * Start Time + * Format: date-time + */ + start_time: string; + /** + * End Time + * Format: date-time + */ + 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. @@ -1304,6 +1419,71 @@ export interface operations { }; }; }; + get_available_slots_api_booking_slots_get: { + parameters: { + query: { + /** @description Date to get slots for */ + date: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AvailableSlotsResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + 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_constants_api_meta_constants_get: { parameters: { query?: never; diff --git a/frontend/e2e/availability.spec.ts b/frontend/e2e/availability.spec.ts index c798968..18a3ea2 100644 --- a/frontend/e2e/availability.spec.ts +++ b/frontend/e2e/availability.spec.ts @@ -45,6 +45,20 @@ function getTomorrowDisplay(): string { return tomorrow.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" }); } +// Helper to get a date string in YYYY-MM-DD format using local timezone +function formatDateLocal(d: Date): string { + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +function getTomorrowDateStr(): string { + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + return formatDateLocal(tomorrow); +} + test.describe("Availability Page - Admin Access", () => { test.beforeEach(async ({ page }) => { await clearAuth(page); @@ -204,9 +218,7 @@ test.describe("Availability API", () => { const authCookie = cookies.find(c => c.name === "auth_token"); if (authCookie) { - const tomorrow = new Date(); - tomorrow.setDate(tomorrow.getDate() + 1); - const dateStr = tomorrow.toISOString().split("T")[0]; + const dateStr = getTomorrowDateStr(); const response = await request.put(`${API_URL}/api/admin/availability`, { headers: { @@ -234,9 +246,7 @@ test.describe("Availability API", () => { const authCookie = cookies.find(c => c.name === "auth_token"); if (authCookie) { - const tomorrow = new Date(); - tomorrow.setDate(tomorrow.getDate() + 1); - const dateStr = tomorrow.toISOString().split("T")[0]; + const dateStr = getTomorrowDateStr(); const response = await request.get( `${API_URL}/api/admin/availability?from=${dateStr}&to=${dateStr}`,