diff --git a/apps/backend/src/app/api/bookings/route.test.ts b/apps/backend/src/app/api/bookings/route.test.ts index a126c6021..6e3fc2831 100644 --- a/apps/backend/src/app/api/bookings/route.test.ts +++ b/apps/backend/src/app/api/bookings/route.test.ts @@ -34,15 +34,15 @@ describe("/api/bookings", async () => { const gameSessionDataService = new GameSessionDataService() const userDataService = new UserDataService() - describe("POST", () => { - const now = new Date() + const now = new Date() - const currentSemesterCreateMock: CreateSemesterData = { - ...semesterCreateMock, - startDate: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1)).toISOString(), - endDate: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 4, 0)).toISOString(), - } + const currentSemesterCreateMock: CreateSemesterData = { + ...semesterCreateMock, + startDate: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1)).toISOString(), + endDate: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 4, 0)).toISOString(), + } + describe("POST", () => { it("should return a 401 if token is missing", async () => { const res = await POST(createMockNextRequest()) expect(res.status).toBe(StatusCodes.UNAUTHORIZED) @@ -105,6 +105,11 @@ describe("/api/bookings", async () => { futureGameSessionCreateMock, ) + // Ensure member has enough remaining sessions so we hit the weekly limit, not "No remaining sessions" + await userDataService.updateUser(memberUserMock.id, { + remainingSessions: 10, + }) + await bookingDataService.createBooking({ ...bookingCreateMock, gameSession: gameSession.id, @@ -126,7 +131,7 @@ describe("/api/bookings", async () => { expect(await res.json()).toStrictEqual({ error: "Weekly booking limit reached" }) }) - it("should return a 403 if a member has reached their max number of bookings per week", async () => { + it("should return a 403 if a casual has reached their max number of bookings per week", async () => { cookieStore.set(AUTH_COOKIE_NAME, casualToken) const currentSemester = await semesterDataService.createSemester(currentSemesterCreateMock) @@ -162,7 +167,7 @@ describe("/api/bookings", async () => { const res = await POST(req) expect(res.status).toBe(StatusCodes.FORBIDDEN) - expect(await res.json()).toStrictEqual({ error: "Weekly booking limit reached" }) + expect(await res.json()).toStrictEqual({ error: "No remaining sessions" }) }) it("should call the booking confirmation email service", async () => { @@ -198,13 +203,18 @@ describe("/api/bookings", async () => { ) }) - it("should return a 409 if the user has already made a booking for the session", async () => { - cookieStore.set(AUTH_COOKIE_NAME, casualToken) - const gameSession = await gameSessionDataService.createGameSession(gameSessionCreateMock) + it("should return a 409 if a member user has already made a booking for the session", async () => { + cookieStore.set(AUTH_COOKIE_NAME, memberToken) + const currentSemester = await semesterDataService.createSemester(currentSemesterCreateMock) + const gameSession = await gameSessionDataService.createGameSession({ + ...gameSessionCreateMock, + semester: currentSemester, + }) + await bookingDataService.createBooking({ ...bookingCreateMock, gameSession: gameSession.id, - user: casualUserMock, + user: memberUserMock, }) const req = createMockNextRequest("/api/bookings", "POST", { @@ -219,13 +229,25 @@ describe("/api/bookings", async () => { it("should return a 403 if the user has no remaining sessions", async () => { cookieStore.set(AUTH_COOKIE_NAME, casualToken) - const gameSession = await gameSessionDataService.createGameSession(gameSessionCreateMock) - await userDataService.updateUser(casualUserMock.id, { - remainingSessions: -1, + const currentSemester = await semesterDataService.createSemester(currentSemesterCreateMock) + const gameSession = await gameSessionDataService.createGameSession({ + ...gameSessionCreateMock, + semester: currentSemester, + }) + const gameSession2 = await gameSessionDataService.createGameSession({ + ...gameSessionCreateMock, + semester: currentSemester, + }) + + // Create a booking on a different session so getRemainingSessions returns 0 for casual + await bookingDataService.createBooking({ + ...bookingCreateMock, + gameSession: gameSession.id, + user: casualUserMock, }) const req = createMockNextRequest("", "POST", { - gameSession, + gameSession: gameSession2, playerLevel: PlayLevel.beginner, } satisfies CreateBookingRequest) const res = await POST(req) @@ -268,7 +290,11 @@ describe("/api/bookings", async () => { it("should return a 403 if the user is a casual and has exceeded booking capacity", async () => { cookieStore.set(AUTH_COOKIE_NAME, casualToken) - const gameSession = await gameSessionDataService.createGameSession(gameSessionCreateMock) + const currentSemester = await semesterDataService.createSemester(currentSemesterCreateMock) + const gameSession = await gameSessionDataService.createGameSession({ + ...gameSessionCreateMock, + semester: currentSemester, + }) await bookingDataService.createBooking({ ...bookingCreateMock, gameSession: gameSession.id, @@ -284,12 +310,16 @@ describe("/api/bookings", async () => { const res = await POST(req) expect(res.status).toBe(StatusCodes.FORBIDDEN) - expect(await res.json()).toStrictEqual({ error: "Session is full for the selected user role" }) + expect(await res.json()).toStrictEqual({ error: "No remaining sessions" }) }) it("should a 403 if the user is a member and has exceeded booking capacity", async () => { cookieStore.set(AUTH_COOKIE_NAME, memberToken) - const gameSession = await gameSessionDataService.createGameSession(gameSessionCreateMock) + const currentSemester = await semesterDataService.createSemester(currentSemesterCreateMock) + const gameSession = await gameSessionDataService.createGameSession({ + ...gameSessionCreateMock, + semester: currentSemester, + }) await bookingDataService.createBooking({ ...bookingCreateMock, gameSession: gameSession.id, diff --git a/apps/backend/src/app/api/bookings/route.ts b/apps/backend/src/app/api/bookings/route.ts index d3d1263db..d78371ee9 100644 --- a/apps/backend/src/app/api/bookings/route.ts +++ b/apps/backend/src/app/api/bookings/route.ts @@ -1,5 +1,6 @@ import { CreateBookingRequestSchema, + GameBookingStrategy, getMaxBookingSize, MembershipType, type RequestWithUser, @@ -14,6 +15,7 @@ import BookingDataService from "@/data-layer/services/BookingDataService" import GameSessionDataService from "@/data-layer/services/GameSessionDataService" import SemesterDataService from "@/data-layer/services/SemesterDataService" import UserDataService from "@/data-layer/services/UserDataService" +import { getRemainingSessions } from "@/data-layer/utils/GameSessionUtils" class RouteWrapper { @Security("jwt") @@ -44,16 +46,27 @@ class RouteWrapper { // Refetch user data as JWT stored data could be outdated const userData = await userDataService.getUserById(req.user.id) - if ((userData.remainingSessions ?? 0) <= -1) + const remainingSessionsBasedOnRole = await getRemainingSessions( + userData, + semesterDataService, + bookingDataService, + userDataService, + ) + + if (remainingSessionsBasedOnRole <= 0) return NextResponse.json( { error: "No remaining sessions" }, { status: StatusCodes.FORBIDDEN }, ) + const bookingStrategy = + userData.role === MembershipType.member + ? GameBookingStrategy.MEMBER + : GameBookingStrategy.CASUAL if ( - (userData.role === MembershipType.casual && + (bookingStrategy === GameBookingStrategy.CASUAL && bookings.length >= gameSession.casualCapacity) || - (userData.role === MembershipType.member && bookings.length >= gameSession.capacity) + (bookingStrategy === GameBookingStrategy.MEMBER && bookings.length >= gameSession.capacity) ) return NextResponse.json( { error: "Session is full for the selected user role" }, @@ -72,6 +85,7 @@ class RouteWrapper { // Check if the user's booking limit has been reached const currentSemester = await semesterDataService.getCurrentSemester() + // Important: this controls the weekly booking limit for both casual and member users, as it counts all bookings regardless of user role. const allUpcomingBookings = await bookingDataService.getAllCurrentWeekBookingsByUserId( req.user.id, currentSemester, @@ -91,14 +105,21 @@ class RouteWrapper { await MailService.sendBookingConfirmation(newBooking) - const newRemainingSessions = (userData.remainingSessions ?? 0) - 1 - // Demote user to casual if session count is lower than or equal to 0 - await userDataService.updateUser(req.user.id, { - remainingSessions: newRemainingSessions, - role: newRemainingSessions <= 0 ? MembershipType.casual : req.user.role, - }) - - return NextResponse.json({ data: newBooking }, { status: StatusCodes.CREATED }) + switch (bookingStrategy) { + case GameBookingStrategy.CASUAL: + // Casual bookings do not affect remaining sessions or membership status + return NextResponse.json({ data: newBooking }, { status: StatusCodes.CREATED }) + case GameBookingStrategy.MEMBER: { + const newRemainingSessions = (userData.remainingSessions ?? 0) - 1 + // Demote user to casual if session count is lower than or equal to 0 + await userDataService.updateUser(req.user.id, { + remainingSessions: newRemainingSessions, + role: newRemainingSessions <= 0 ? MembershipType.casual : req.user.role, + }) + + return NextResponse.json({ data: newBooking }, { status: StatusCodes.CREATED }) + } + } } catch (error) { if (error instanceof NotFound) { return NextResponse.json( diff --git a/apps/backend/src/app/api/me/bookings/remaining/route.test.ts b/apps/backend/src/app/api/me/bookings/remaining/route.test.ts new file mode 100644 index 000000000..78fc65960 --- /dev/null +++ b/apps/backend/src/app/api/me/bookings/remaining/route.test.ts @@ -0,0 +1,95 @@ +import { AUTH_COOKIE_NAME } from "@repo/shared" +import { getReasonPhrase, StatusCodes } from "http-status-codes" +import { cookies } from "next/headers" +import BookingDataService from "@/data-layer/services/BookingDataService" +import GameSessionDataService from "@/data-layer/services/GameSessionDataService" +import SemesterDataService from "@/data-layer/services/SemesterDataService" +import { createMockNextRequest } from "@/test-config/backend-utils" +import { bookingCreateMock } from "@/test-config/mocks/Booking.mock" +import { gameSessionCreateMock } from "@/test-config/mocks/GameSession.mock" +import { semesterCreateMock } from "@/test-config/mocks/Semester.mock" +import { casualToken, memberToken } from "@/test-config/vitest.setup" +import { GET } from "./route" + +describe("/api/me/bookings/remaining", async () => { + const cookieStore = await cookies() + const bookingDataService = new BookingDataService() + const gameSessionDataService = new GameSessionDataService() + const semesterDataService = new SemesterDataService() + + beforeEach(async () => { + const currentDate = new Date() + const startDate = new Date(currentDate.getTime() - 24 * 60 * 60 * 1000) // 1 day ago + const endDate = new Date(currentDate.getTime() + 24 * 60 * 60 * 1000) // 1 day from now + + await semesterDataService.createSemester({ + ...semesterCreateMock, + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + }) + }) + + describe("GET", () => { + it("should return 1 remaining session for a casual user with no bookings this week", async () => { + cookieStore.set(AUTH_COOKIE_NAME, casualToken) + + const response = await GET(createMockNextRequest("/api/me/bookings/remaining")) + + expect(response.status).toBe(StatusCodes.OK) + const json = await response.json() + expect(json.data.remainingSessions).toBe(1) + }) + + it("should return 0 remaining sessions for a casual user with a booking this week", async () => { + cookieStore.set(AUTH_COOKIE_NAME, casualToken) + + const now = new Date() + const gameSession = await gameSessionDataService.createGameSession({ + ...gameSessionCreateMock, + startTime: new Date(now.getTime() + 24 * 60 * 60 * 1000).toISOString(), + endTime: new Date(now.getTime() + 24 * 60 * 60 * 1000).toISOString(), + }) + + await bookingDataService.createBooking({ + ...bookingCreateMock, + gameSession, + }) + + const response = await GET(createMockNextRequest("/api/me/bookings/remaining")) + + expect(response.status).toBe(StatusCodes.OK) + const json = await response.json() + expect(json.data.remainingSessions).toBe(0) + }) + + it("should return remaining sessions from user object for a member user", async () => { + cookieStore.set(AUTH_COOKIE_NAME, memberToken) + + const response = await GET(createMockNextRequest("/api/me/bookings/remaining")) + + expect(response.status).toBe(StatusCodes.OK) + const json = await response.json() + expect(typeof json.data.remainingSessions).toBe("number") + }) + + it("should return 500 and handle unexpected errors", async () => { + cookieStore.set(AUTH_COOKIE_NAME, casualToken) + + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + const mockGetCurrentSemester = vi + .spyOn(SemesterDataService.prototype, "getCurrentSemester") + .mockRejectedValueOnce(new Error("Database error")) + + const response = await GET(createMockNextRequest("/api/me/bookings/remaining")) + + expect(response.status).toBe(StatusCodes.INTERNAL_SERVER_ERROR) + const json = await response.json() + expect(json.error).toBe(getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR)) + expect(consoleErrorSpy).toHaveBeenCalled() + expect(mockGetCurrentSemester).toHaveBeenCalled() + + consoleErrorSpy.mockRestore() + mockGetCurrentSemester.mockRestore() + }) + }) +}) diff --git a/apps/backend/src/app/api/me/bookings/remaining/route.ts b/apps/backend/src/app/api/me/bookings/remaining/route.ts new file mode 100644 index 000000000..c56c8c2ef --- /dev/null +++ b/apps/backend/src/app/api/me/bookings/remaining/route.ts @@ -0,0 +1,34 @@ +import type { RequestWithUser } from "@repo/shared" +import { getReasonPhrase, StatusCodes } from "http-status-codes" +import { NextResponse } from "next/server" +import { Security } from "@/business-layer/middleware/Security" +import BookingDataService from "@/data-layer/services/BookingDataService" +import SemesterDataService from "@/data-layer/services/SemesterDataService" +import UserDataService from "@/data-layer/services/UserDataService" +import { getRemainingSessions } from "@/data-layer/utils/GameSessionUtils" + +class RouteWrapper { + @Security("jwt") + static async GET(req: RequestWithUser) { + try { + return NextResponse.json({ + data: { + remainingSessions: await getRemainingSessions( + req.user, + new SemesterDataService(), + new BookingDataService(), + new UserDataService(), + ), + }, + }) + } catch (error) { + console.error(error) + return NextResponse.json( + { error: getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR) }, + { status: StatusCodes.INTERNAL_SERVER_ERROR }, + ) + } + } +} + +export const { GET } = RouteWrapper diff --git a/apps/backend/src/data-layer/utils/GameSessionUtils.test.ts b/apps/backend/src/data-layer/utils/GameSessionUtils.test.ts index eb738c784..931e5e3d7 100644 --- a/apps/backend/src/data-layer/utils/GameSessionUtils.test.ts +++ b/apps/backend/src/data-layer/utils/GameSessionUtils.test.ts @@ -1,10 +1,14 @@ import { MembershipType } from "@repo/shared" import { casualUserMock, memberUserMock } from "@repo/shared/mocks" import type { User } from "@repo/shared/payload-types" +import type { Mock } from "vitest" +import type BookingDataService from "@/data-layer/services/BookingDataService" +import type SemesterDataService from "@/data-layer/services/SemesterDataService" +import type UserDataService from "@/data-layer/services/UserDataService" import { bookingMock } from "@/test-config/mocks/Booking.mock" import { gameSessionMock } from "@/test-config/mocks/GameSession.mock" import { gameSessionScheduleMock } from "@/test-config/mocks/GameSessionSchedule.mock" -import { countAttendees, getSessionProperties } from "./GameSessionUtils" +import { countAttendees, getRemainingSessions, getSessionProperties } from "./GameSessionUtils" describe("GameSessionUtils", () => { describe("getSessionProperties", () => { @@ -275,4 +279,126 @@ describe("GameSessionUtils", () => { }) }) }) + + describe("getRemainingSessions", () => { + let mockSemesterDataService: { + getCurrentSemester: Mock + } + let mockBookingDataService: { + getAllCurrentWeekBookingsByUserId: Mock + } + let mockUserDataService: { + getUserById: Mock + } + + beforeEach(() => { + mockSemesterDataService = { + getCurrentSemester: vi.fn(), + } + mockBookingDataService = { + getAllCurrentWeekBookingsByUserId: vi.fn(), + } + mockUserDataService = { + getUserById: vi.fn(), + } + }) + + it("should return 1 for casual user with no existing bookings", async () => { + const user = { id: casualUserMock.id, role: MembershipType.casual, remainingSessions: null } + mockSemesterDataService.getCurrentSemester.mockResolvedValue({ id: "semester-1" }) + mockBookingDataService.getAllCurrentWeekBookingsByUserId.mockResolvedValue([]) + + const result = await getRemainingSessions( + user, + mockSemesterDataService as unknown as SemesterDataService, + mockBookingDataService as unknown as BookingDataService, + mockUserDataService as unknown as UserDataService, + ) + + expect(result).toBe(1) + expect(mockSemesterDataService.getCurrentSemester).toHaveBeenCalled() + expect(mockBookingDataService.getAllCurrentWeekBookingsByUserId).toHaveBeenCalledWith( + user.id, + { id: "semester-1" }, + ) + }) + + it("should return 0 for casual user with existing bookings", async () => { + const user = { id: casualUserMock.id, role: MembershipType.casual, remainingSessions: null } + mockSemesterDataService.getCurrentSemester.mockResolvedValue({ id: "semester-1" }) + mockBookingDataService.getAllCurrentWeekBookingsByUserId.mockResolvedValue([bookingMock]) + + const result = await getRemainingSessions( + user, + mockSemesterDataService as unknown as SemesterDataService, + mockBookingDataService as unknown as BookingDataService, + mockUserDataService as unknown as UserDataService, + ) + + expect(result).toBe(0) + }) + + it("should return remainingSessions for member user", async () => { + const user = { id: memberUserMock.id, role: MembershipType.member, remainingSessions: 5 } + mockUserDataService.getUserById.mockResolvedValue({ remainingSessions: 5 }) + + const result = await getRemainingSessions( + user, + mockSemesterDataService as unknown as SemesterDataService, + mockBookingDataService as unknown as BookingDataService, + mockUserDataService as unknown as UserDataService, + ) + + expect(result).toBe(5) + expect(mockSemesterDataService.getCurrentSemester).not.toHaveBeenCalled() + expect(mockUserDataService.getUserById).toHaveBeenCalledWith(user.id) + }) + + it("should return 0 for member user with null remainingSessions", async () => { + const user = { id: memberUserMock.id, role: MembershipType.member, remainingSessions: null } + mockUserDataService.getUserById.mockResolvedValue({ remainingSessions: null }) + + const result = await getRemainingSessions( + user, + mockSemesterDataService as unknown as SemesterDataService, + mockBookingDataService as unknown as BookingDataService, + mockUserDataService as unknown as UserDataService, + ) + + expect(result).toBe(0) + }) + + it("should return 0 for member user with undefined remainingSessions", async () => { + const user = { + id: memberUserMock.id, + role: MembershipType.member, + remainingSessions: undefined, + } + mockUserDataService.getUserById.mockResolvedValue({ remainingSessions: undefined }) + + const result = await getRemainingSessions( + user, + mockSemesterDataService as unknown as SemesterDataService, + mockBookingDataService as unknown as BookingDataService, + mockUserDataService as unknown as UserDataService, + ) + + expect(result).toBe(0) + }) + + it("should return remainingSessions for admin user", async () => { + const user = { id: memberUserMock.id, role: MembershipType.admin, remainingSessions: 3 } + mockUserDataService.getUserById.mockResolvedValue({ remainingSessions: 3 }) + + const result = await getRemainingSessions( + user, + mockSemesterDataService as unknown as SemesterDataService, + mockBookingDataService as unknown as BookingDataService, + mockUserDataService as unknown as UserDataService, + ) + + expect(result).toBe(3) + expect(mockUserDataService.getUserById).toHaveBeenCalledWith(user.id) + }) + }) }) diff --git a/apps/backend/src/data-layer/utils/GameSessionUtils.ts b/apps/backend/src/data-layer/utils/GameSessionUtils.ts index 00ed2e8ad..3e3d0820b 100644 --- a/apps/backend/src/data-layer/utils/GameSessionUtils.ts +++ b/apps/backend/src/data-layer/utils/GameSessionUtils.ts @@ -1,5 +1,13 @@ -import { isGameSessionScheduleObject, isUserObject, MembershipType } from "@repo/shared" +import { + GameBookingStrategy, + isGameSessionScheduleObject, + isUserObject, + MembershipType, +} from "@repo/shared" import type { Booking, GameSession } from "@repo/shared/payload-types" +import type BookingDataService from "@/data-layer/services/BookingDataService" +import type SemesterDataService from "@/data-layer/services/SemesterDataService" +import type UserDataService from "@/data-layer/services/UserDataService" /** * Extract session properties with fallback logic from GameSession @@ -63,3 +71,34 @@ export const countAttendees = (bookings: Booking[]) => { return counts } + +export const getRemainingSessions = async ( + user: Pick< + { id: string; role: string; remainingSessions?: number | null }, + "id" | "role" | "remainingSessions" + >, + semesterDataService: SemesterDataService, + bookingDataService: BookingDataService, + userDataService: UserDataService, +): Promise => { + const strategy: GameBookingStrategy = + user.role === "casual" ? GameBookingStrategy.CASUAL : GameBookingStrategy.MEMBER + + switch (strategy) { + case GameBookingStrategy.CASUAL: { + const currentSemester = await semesterDataService.getCurrentSemester() + const upcomingBookings = await bookingDataService.getAllCurrentWeekBookingsByUserId( + user.id, + currentSemester, + ) + if (upcomingBookings.length > 0) { + return 0 + } + return 1 + } + case GameBookingStrategy.MEMBER: { + const userData = await userDataService.getUserById(user.id) + return userData.remainingSessions ?? 0 + } + } +} diff --git a/apps/frontend/src/components/client/book/BookClient.tsx b/apps/frontend/src/components/client/book/BookClient.tsx index afd5f51bc..79e52cf2b 100644 --- a/apps/frontend/src/components/client/book/BookClient.tsx +++ b/apps/frontend/src/components/client/book/BookClient.tsx @@ -4,12 +4,20 @@ import { Heading } from "@repo/ui/components/Primitive" import { CircleAlertIcon } from "@yamada-ui/lucide" import { Container, EmptyState, VStack } from "@yamada-ui/react" import { RoleGuard } from "@/context/RoleWrappers" +import { useMyRemainingSessions } from "@/services/bookings/BookingQueries" import { mapGameSessionsToSessionItems } from "@/services/game-session/GameSessionAdapter" import { useGetCurrentAvailableGameSessions } from "@/services/game-session/GameSessionQueries" import { BookFlow } from "./BookFlow" export const BookClient = () => { - const { data, isError, error, isLoading } = useGetCurrentAvailableGameSessions() + const { + data, + isError, + error, + isLoading: isLoadingCurrentAvailableGameSessions, + } = useGetCurrentAvailableGameSessions() + const { isLoading: isLoadingRemainingSessions, data: remainingSessions } = + useMyRemainingSessions() const sessions = data.availableSessions ? mapGameSessionsToSessionItems(data.availableSessions) @@ -39,7 +47,7 @@ export const BookClient = () => { > {(auth) => ( - {isLoading ? ( + {isLoadingCurrentAvailableGameSessions || isLoadingRemainingSessions ? ( Book a court @@ -52,7 +60,12 @@ export const BookClient = () => { title="Could not load sessions" /> ) : ( - + )} )} diff --git a/apps/frontend/src/components/client/book/BookFlow.test.tsx b/apps/frontend/src/components/client/book/BookFlow.test.tsx index 46e37a769..68550ccf2 100644 --- a/apps/frontend/src/components/client/book/BookFlow.test.tsx +++ b/apps/frontend/src/components/client/book/BookFlow.test.tsx @@ -64,7 +64,7 @@ describe("", () => { }) it("should show empty state when no bookings are available", () => { - render(, { + render(, { wrapper: withNuqsTestingAdapter(), }) expect(screen.getByText("No bookings found")).toBeInTheDocument() @@ -80,7 +80,7 @@ describe("", () => { remainingSessions: 0, }, } - render(, { + render(, { wrapper: withNuqsTestingAdapter(), }) expect(screen.getByText("No remaining sessions")).toBeInTheDocument() @@ -89,18 +89,24 @@ describe("", () => { }) it("should handle play level selection and move to next step", async () => { - const { user } = render(, { - wrapper: withNuqsTestingAdapter(), - }) + const { user } = render( + , + { + wrapper: withNuqsTestingAdapter(), + }, + ) const beginnerButton = screen.getByRole("button", { name: /beginner/i }) await user.click(beginnerButton) expect(screen.getByText(/select a court/i)).toBeInTheDocument() }) it("should handle court selection and move to confirmation step", async () => { - const { user } = render(, { - wrapper: withNuqsTestingAdapter(), - }) + const { user } = render( + , + { + wrapper: withNuqsTestingAdapter(), + }, + ) const beginnerButton = screen.getByRole("button", { name: /beginner/i }) await user.click(beginnerButton) const firstDateLabel = formatDateWithOrdinal(mockSessions[0].date) @@ -112,9 +118,12 @@ describe("", () => { }) it("should handle back navigation from select court to play level", async () => { - const { user } = render(, { - wrapper: withNuqsTestingAdapter(), - }) + const { user } = render( + , + { + wrapper: withNuqsTestingAdapter(), + }, + ) const beginnerButton = screen.getByRole("button", { name: /beginner/i }) await user.click(beginnerButton) const backButton = screen.getByRole("button", { name: "Back" }) @@ -125,9 +134,12 @@ describe("", () => { }) it("should handle back navigation from confirmation to select court", async () => { - const { user } = render(, { - wrapper: withNuqsTestingAdapter(), - }) + const { user } = render( + , + { + wrapper: withNuqsTestingAdapter(), + }, + ) const beginnerButton = screen.getByRole("button", { name: /beginner/i }) await user.click(beginnerButton) const firstDateLabel = formatDateWithOrdinal(mockSessions[0].date) @@ -156,9 +168,12 @@ describe("", () => { switchPopup: vi.fn(), }, }) - const { user } = render(, { - wrapper: withNuqsTestingAdapter(), - }) + const { user } = render( + , + { + wrapper: withNuqsTestingAdapter(), + }, + ) const beginnerButton = screen.getByRole("button", { name: /beginner/i }) await user.click(beginnerButton) const firstDateLabel = formatDateWithOrdinal(mockSessions[0].date) @@ -172,9 +187,12 @@ describe("", () => { }) it("should render booking confirmation with correct data", async () => { - const { user } = render(, { - wrapper: withNuqsTestingAdapter(), - }) + const { user } = render( + , + { + wrapper: withNuqsTestingAdapter(), + }, + ) const beginnerButton = screen.getByRole("button", { name: /beginner/i }) await user.click(beginnerButton) const firstDateLabel = formatDateWithOrdinal(mockSessions[0].date) @@ -192,7 +210,7 @@ describe("", () => { role: MembershipType.casual, }, } - render(, { + render(, { wrapper: withNuqsTestingAdapter(), }) expect(screen.getByText(/beginner/i)).toBeInTheDocument() @@ -206,7 +224,7 @@ describe("", () => { role: MembershipType.admin, }, } - render(, { + render(, { wrapper: withNuqsTestingAdapter(), }) expect(screen.getByText(/beginner/i)).toBeInTheDocument() diff --git a/apps/frontend/src/components/client/book/BookFlow.tsx b/apps/frontend/src/components/client/book/BookFlow.tsx index a28db4f86..2e2350e2e 100644 --- a/apps/frontend/src/components/client/book/BookFlow.tsx +++ b/apps/frontend/src/components/client/book/BookFlow.tsx @@ -38,12 +38,22 @@ type BookFlowProps = { * The number of already booked sessions by the user. */ numberBookedSessions?: number + + /** + * The number of remaining sessions for the user. + */ + remainingSessions: number } /** * The main component for the booking flow. */ -export const BookFlow: FC = ({ auth, sessions, numberBookedSessions }) => { +export const BookFlow: FC = ({ + auth, + sessions, + numberBookedSessions, + remainingSessions, +}) => { const bookingFlowReducer = createBookingFlowReducer(sessions) const [state, dispatch] = useReducer(bookingFlowReducer, initialState) const notice = useNotice() @@ -135,7 +145,7 @@ export const BookFlow: FC = ({ auth, sessions, numberBookedSessio ) } - if (auth.user.remainingSessions === 0) { + if (remainingSessions <= 0) { return ( = ({ auth, sessions, numberBookedSessio numberBookedSessions={numberBookedSessions} onBack={handleBack} onNext={handleSelectCourt} + remainingSessions={remainingSessions} sessions={sessions} user={auth.user} /> @@ -173,6 +184,7 @@ export const BookFlow: FC = ({ auth, sessions, numberBookedSessio numberBookedSessions={numberBookedSessions} onBack={handleBack} onConfirm={handleConfirmBooking} + remainingSessions={remainingSessions} user={auth.user} /> ) : null} diff --git a/apps/frontend/src/components/client/user/ProfileSection.test.tsx b/apps/frontend/src/components/client/user/ProfileSection.test.tsx index 163e20a9a..a99023d08 100644 --- a/apps/frontend/src/components/client/user/ProfileSection.test.tsx +++ b/apps/frontend/src/components/client/user/ProfileSection.test.tsx @@ -14,6 +14,35 @@ vi.mock("@/context/AuthContext", () => ({ vi.mock("@/services/bookings/BookingQueries", () => ({ useMyBookings: vi.fn(), + useMyRemainingSessions: vi.fn(), +})) + +vi.mock("@/services/admin/bookings/AdminBookingMutations", () => ({ + useDeleteBooking: vi.fn().mockReturnValue({ + mutateAsync: vi.fn(), + isPending: false, + }), +})) + +vi.mock("@/services/auth/AuthMutations", () => ({ + useUpdateSelfMutation: vi.fn().mockReturnValue({ + mutateAsync: vi.fn(), + mutate: vi.fn(), + error: null, + data: undefined, + reset: vi.fn(), + variables: undefined, + status: "idle", + isError: false, + isIdle: true, + isPending: false, + isSuccess: false, + context: undefined, + failureCount: 0, + failureReason: null, + isPaused: false, + submittedAt: 0, + }), })) const mockAuth: AuthContextValue = { @@ -43,6 +72,10 @@ describe("", () => { isLoading: false, isError: false, } as never) + vi.mocked(bookingQueries.useMyRemainingSessions).mockReturnValue({ + data: { data: { remainingSessions: 0 } }, + isLoading: false, + } as never) }) it("renders profile details and additional info panels", () => { @@ -169,4 +202,87 @@ describe("", () => { expect(mutateAsync).toHaveBeenCalled() }) }) + it("shows remaining sessions from API response", () => { + vi.mocked(bookingQueries.useMyRemainingSessions).mockReturnValue({ + data: { data: { remainingSessions: 1 } }, + isLoading: false, + } as never) + + render(, { + wrapper: queryClientProvider, + }) + + expect(screen.getByText(/1/)).toBeInTheDocument() + }) + + it("shows 0 remaining sessions when API returns 0", () => { + vi.mocked(bookingQueries.useMyRemainingSessions).mockReturnValue({ + data: { data: { remainingSessions: 0 } }, + isLoading: false, + } as never) + + render(, { + wrapper: queryClientProvider, + }) + + expect(screen.getByText(/0/)).toBeInTheDocument() + }) + + it("shows remainingSessions from API for non-casual role", () => { + const memberAuth: AuthContextValue = { + ...mockAuth, + user: { + ...casualUserMock, + role: "member" as never, + remainingSessions: 5, + }, + } + + vi.mocked(bookingQueries.useMyRemainingSessions).mockReturnValue({ + data: { data: { remainingSessions: 5 } }, + isLoading: false, + } as never) + + render(, { + wrapper: queryClientProvider, + }) + + expect(screen.getByText(/5/)).toBeInTheDocument() + }) + + it("shows 0 remaining sessions when API response is missing", () => { + const memberAuth: AuthContextValue = { + ...mockAuth, + user: { + ...casualUserMock, + role: "member" as never, + remainingSessions: undefined as never, + }, + } + + vi.mocked(bookingQueries.useMyRemainingSessions).mockReturnValue({ + data: undefined, + isLoading: false, + } as never) + + render(, { + wrapper: queryClientProvider, + }) + + expect(screen.getByText(/0/)).toBeInTheDocument() + }) + + it("renders skeleton when remaining sessions are loading", () => { + vi.mocked(bookingQueries.useMyRemainingSessions).mockReturnValue({ + data: undefined, + isLoading: true, + } as never) + + render(, { + wrapper: queryClientProvider, + }) + + // UserPanelSkeleton is rendered when isRemainingSessionsLoading is true + expect(screen.queryByText(/remaining/i)).not.toBeInTheDocument() + }) }) diff --git a/apps/frontend/src/components/client/user/ProfileSection.tsx b/apps/frontend/src/components/client/user/ProfileSection.tsx index 370ba18c9..444652f64 100644 --- a/apps/frontend/src/components/client/user/ProfileSection.tsx +++ b/apps/frontend/src/components/client/user/ProfileSection.tsx @@ -20,12 +20,14 @@ import { Container, Grid, GridItem, useDisclosure, useNotice } from "@yamada-ui/ import { memo, useState } from "react" import { useDeleteBooking } from "@/services/admin/bookings/AdminBookingMutations" import { useUpdateSelfMutation } from "@/services/auth/AuthMutations" -import { useMyBookings } from "@/services/bookings/BookingQueries" +import { useMyBookings, useMyRemainingSessions } from "@/services/bookings/BookingQueries" export const ProfileSection = memo(({ auth }: { auth: AuthContextValue }) => { const { user } = auth const { data: bookings, isLoading: isBookingsLoading, isError: isBookingsError } = useMyBookings() const updateSelfMutation = useUpdateSelfMutation() + const { data: remainingSessionsResponse, isLoading: isRemainingSessionsLoading } = + useMyRemainingSessions() const deleteBookingMutation = useDeleteBooking() const notice = useNotice() const [bookingId, setBookingId] = useState(null) @@ -63,7 +65,16 @@ export const ProfileSection = memo(({ auth }: { auth: AuthContextValue }) => { return ( - {!user ? : } + + {isRemainingSessionsLoading || !user ? ( + + ) : ( + + )} + {isBookingsLoading || !user ? ( diff --git a/apps/frontend/src/services/bookings/BookingQueries.ts b/apps/frontend/src/services/bookings/BookingQueries.ts index c838e8ecd..89e182846 100644 --- a/apps/frontend/src/services/bookings/BookingQueries.ts +++ b/apps/frontend/src/services/bookings/BookingQueries.ts @@ -19,3 +19,14 @@ export function useMyBookings() { enabled: !!token, }) } + +export function useMyRemainingSessions() { + const { token } = useAuth() + return useQuery({ + queryKey: [QueryKeys.BOOKINGS_QUERY_KEY, QueryKeys.MY_REMAINING_SESSIONS_QUERY_KEY], + queryFn: async () => { + return await BookingService.getRemainingSessions(token) + }, + enabled: !!token, + }) +} diff --git a/apps/frontend/src/services/bookings/BookingService.ts b/apps/frontend/src/services/bookings/BookingService.ts index b8c267b76..647b21484 100644 --- a/apps/frontend/src/services/bookings/BookingService.ts +++ b/apps/frontend/src/services/bookings/BookingService.ts @@ -2,6 +2,7 @@ import { type CreateBookingRequest, CreateBookingResponseSchema, GetBookingsResponseSchema, + GetRemainingSessionsResponseSchema, type UpdateBookingRequest, UpdateBookingResponseSchema, } from "@repo/shared" @@ -25,6 +26,18 @@ const BookingService = { return ApiClient.throwIfError(response) }, + getRemainingSessions: async (token: string | null) => { + const response = await apiClient.get( + "/api/me/bookings/remaining", + GetRemainingSessionsResponseSchema, + { + requiresAuth: true, + token, + }, + ) + return ApiClient.throwIfError(response) + }, + /** * Creates a new booking. * diff --git a/apps/frontend/src/services/index.ts b/apps/frontend/src/services/index.ts index 60c499ae5..292e21064 100644 --- a/apps/frontend/src/services/index.ts +++ b/apps/frontend/src/services/index.ts @@ -12,6 +12,7 @@ export enum QueryKeys { TOS_QUERY_KEY = "tos", ABOUT_US_INFO_QUERY_KEY = "about-us-info", MY_BOOKINGS_QUERY_KEY = "my-bookings", + MY_REMAINING_SESSIONS_QUERY_KEY = "my-remaining-sessions", BOOKINGS_QUERY_KEY = "bookings", LOCATION_BUBBLE_QUERY_KEY = "location-bubble", ONBOARDING = "onboarding", diff --git a/packages/shared/src/schemas/booking.ts b/packages/shared/src/schemas/booking.ts index 8a3101911..bae08e758 100644 --- a/packages/shared/src/schemas/booking.ts +++ b/packages/shared/src/schemas/booking.ts @@ -27,6 +27,12 @@ export const GetBookingsResponseSchema = z.object({ data: z.array(BookingSchema), }) +export const GetRemainingSessionsResponseSchema = z.object({ + data: z.object({ + remainingSessions: z.number(), + }), +}) + export const SelectACourtFormDataSchema = z.object({ bookingTimes: z.array(z.string()).min(1, "Please select at least one session"), }) diff --git a/packages/shared/src/types/enums.ts b/packages/shared/src/types/enums.ts index d83c7627b..f8f144099 100644 --- a/packages/shared/src/types/enums.ts +++ b/packages/shared/src/types/enums.ts @@ -81,3 +81,8 @@ export enum GameSessionStatus { UPCOMING = "Upcoming", PAST = "Past", } + +export enum GameBookingStrategy { + MEMBER = "member", + CASUAL = "casual", +} diff --git a/packages/ui/src/components/Composite/BookingConfirmation/BookingConfirmation.stories.tsx b/packages/ui/src/components/Composite/BookingConfirmation/BookingConfirmation.stories.tsx index ab83216a4..377f44889 100644 --- a/packages/ui/src/components/Composite/BookingConfirmation/BookingConfirmation.stories.tsx +++ b/packages/ui/src/components/Composite/BookingConfirmation/BookingConfirmation.stories.tsx @@ -20,6 +20,7 @@ type Story = StoryObj export const Default: Story = { args: { + remainingSessions: 2, bookingData: [ { date: "Tuesday 24/06/25", @@ -35,7 +36,6 @@ export const Default: Story = { ], user: { role: MembershipType.member, - remainingSessions: 2, }, }, } @@ -64,6 +64,7 @@ export const CasualMember: Story = { export const CustomTitle: Story = { args: { + remainingSessions: 2, bookingData: [ { date: "Thursday 26/06/25", @@ -79,7 +80,6 @@ export const CustomTitle: Story = { ], user: { role: MembershipType.member, - remainingSessions: 2, }, title: "Confirm Your Booking", }, diff --git a/packages/ui/src/components/Composite/BookingConfirmation/BookingConfirmation.test.tsx b/packages/ui/src/components/Composite/BookingConfirmation/BookingConfirmation.test.tsx index 7eac1ea94..17384a2e8 100644 --- a/packages/ui/src/components/Composite/BookingConfirmation/BookingConfirmation.test.tsx +++ b/packages/ui/src/components/Composite/BookingConfirmation/BookingConfirmation.test.tsx @@ -22,6 +22,7 @@ const defaultProps = { }, onBack: vi.fn(), onConfirm: vi.fn(), + remainingSessions: 6, } describe("", () => { diff --git a/packages/ui/src/components/Composite/BookingConfirmation/BookingConfirmation.tsx b/packages/ui/src/components/Composite/BookingConfirmation/BookingConfirmation.tsx index 8c7eee110..7d0bbbdf1 100644 --- a/packages/ui/src/components/Composite/BookingConfirmation/BookingConfirmation.tsx +++ b/packages/ui/src/components/Composite/BookingConfirmation/BookingConfirmation.tsx @@ -71,6 +71,7 @@ export interface BookingConfirmationProps { * The number of sessions the user has already booked this week. */ numberBookedSessions?: number + remainingSessions: number } /** @@ -106,9 +107,11 @@ export const BookingConfirmation = memo( title = "Booking Confirmation", loading, numberBookedSessions = 0, + remainingSessions, }) => { const { sessionsLabel } = useBookingLimits({ user, + remainingSessions, selectedCount: bookingData.length, alreadyBookedCount: numberBookedSessions, }) @@ -138,7 +141,7 @@ export const BookingConfirmation = memo( descriptionProps: { fontSize: "md", fontWeight: "medium" }, }, { - term: "Attendees", + term: user.role === "casual" ? "Casual Attendees" : "Attendees", description: user.role === MembershipType.casual ? `${booking.casualAttendees} / ${booking.casualCapacity}` diff --git a/packages/ui/src/components/Composite/SelectACourt/SelectACourt.stories.tsx b/packages/ui/src/components/Composite/SelectACourt/SelectACourt.stories.tsx index dfaa8a5c0..0857bddfe 100644 --- a/packages/ui/src/components/Composite/SelectACourt/SelectACourt.stories.tsx +++ b/packages/ui/src/components/Composite/SelectACourt/SelectACourt.stories.tsx @@ -20,9 +20,9 @@ type Story = StoryObj export const MemberUser: Story = { args: { + remainingSessions: 2, user: { role: MembershipType.member, - remainingSessions: 2, }, title: "Select Your Sessions", sessions: [ @@ -95,9 +95,9 @@ export const MemberUser: Story = { export const CasualUser: Story = { args: { + remainingSessions: 2, user: { role: MembershipType.casual, - remainingSessions: 2, }, title: "Choose Your Session", sessions: [ diff --git a/packages/ui/src/components/Composite/SelectACourt/SelectACourt.tsx b/packages/ui/src/components/Composite/SelectACourt/SelectACourt.tsx index 68526dcd4..32b9f2ea9 100644 --- a/packages/ui/src/components/Composite/SelectACourt/SelectACourt.tsx +++ b/packages/ui/src/components/Composite/SelectACourt/SelectACourt.tsx @@ -73,6 +73,10 @@ export interface SelectACourtProps { * The number of already booked sessions by the user. */ numberBookedSessions?: number + /** + * Remaining sessions for the user. + */ + remainingSessions: number } export const SelectACourt = memo( @@ -85,6 +89,7 @@ export const SelectACourt = memo( onNext, initialBookingTimes = [], numberBookedSessions = 0, + remainingSessions, }) => { const { control, handleSubmit, watch } = useForm({ defaultValues: { bookingTimes: initialBookingTimes }, @@ -95,6 +100,7 @@ export const SelectACourt = memo( const { isMember, maxBookings, sessionsLabel } = useBookingLimits({ user, + remainingSessions, selectedCount: selectedSessions.length, alreadyBookedCount: numberBookedSessions, }) diff --git a/packages/ui/src/components/Composite/UserPanel/UserPanel.test.tsx b/packages/ui/src/components/Composite/UserPanel/UserPanel.test.tsx index 90aeb191c..6a6572fd7 100644 --- a/packages/ui/src/components/Composite/UserPanel/UserPanel.test.tsx +++ b/packages/ui/src/components/Composite/UserPanel/UserPanel.test.tsx @@ -6,7 +6,7 @@ import { UserPanel } from "./UserPanel" describe("UserPanel", () => { it("should re-export the Select component and check if Select exists", () => { expect(UserPanel).toBeDefined() - expect(isValidElement()).toBeTruthy() + expect(isValidElement()).toBeTruthy() }) it("should have correct displayName", () => { @@ -14,7 +14,9 @@ describe("UserPanel", () => { }) it("renders user information correctly", () => { - render() + render( + , + ) expect( screen.getByText(`${memberUserMock.firstName} ${memberUserMock.lastName}`), diff --git a/packages/ui/src/components/Composite/UserPanel/UserPanel.tsx b/packages/ui/src/components/Composite/UserPanel/UserPanel.tsx index be030da85..f3c482479 100644 --- a/packages/ui/src/components/Composite/UserPanel/UserPanel.tsx +++ b/packages/ui/src/components/Composite/UserPanel/UserPanel.tsx @@ -29,6 +29,10 @@ export interface UserPanelProps extends CardProps { * The user to display */ user: User + /** + * The number of remaining sessions for the user + */ + remainingSessions: number /** * Props for the edit icon button * @@ -70,8 +74,13 @@ export interface UserPanelProps extends CardProps { * }} * /> */ -export const UserPanel: FC = ({ user, iconButtonProps, ...props }) => { - const { firstName, lastName, role, email, phoneNumber, remainingSessions, image } = user +export const UserPanel: FC = ({ + user, + iconButtonProps, + remainingSessions, + ...props +}) => { + const { firstName, lastName, role, email, phoneNumber, image } = user return ( { useBookingLimits({ user: memberUser, selectedCount: 1, + remainingSessions: memberUser.remainingSessions, }), ) @@ -39,6 +40,7 @@ describe("useBookingLimits", () => { useBookingLimits({ user: casualUser, selectedCount: 0, + remainingSessions: casualUser.remainingSessions, }), ) @@ -55,6 +57,7 @@ describe("useBookingLimits", () => { useBookingLimits({ user: adminUser, selectedCount: 2, + remainingSessions: adminUser.remainingSessions, }), ) @@ -71,6 +74,7 @@ describe("useBookingLimits", () => { useBookingLimits({ user: { ...memberUser, remainingSessions: 0 }, selectedCount: 0, + remainingSessions: 0, }), ) @@ -85,6 +89,7 @@ describe("useBookingLimits", () => { useBookingLimits({ user: { ...memberUser, remainingSessions: null }, selectedCount: 0, + remainingSessions: 0, }), ) @@ -100,6 +105,7 @@ describe("useBookingLimits", () => { user: memberUser, selectedCount: 1, alreadyBookedCount: 1, + remainingSessions: memberUser.remainingSessions, }), ) @@ -115,6 +121,7 @@ describe("useBookingLimits", () => { user: casualUser, selectedCount: 2, alreadyBookedCount: 0, + remainingSessions: casualUser.remainingSessions, }), ) diff --git a/packages/ui/src/hooks/useBookingLimits/useBookingLimits.ts b/packages/ui/src/hooks/useBookingLimits/useBookingLimits.ts index 5f34369d5..3185f0939 100644 --- a/packages/ui/src/hooks/useBookingLimits/useBookingLimits.ts +++ b/packages/ui/src/hooks/useBookingLimits/useBookingLimits.ts @@ -18,6 +18,10 @@ interface UseBookingLimitsOptions { * The number of sessions already booked this week. */ alreadyBookedCount?: number + /** + * The number of remaining sessions for the user. + */ + remainingSessions: number } /** @@ -58,14 +62,16 @@ interface UseBookingLimitsReturn { * * @example * const limits = useBookingLimits({ - * user: { role: MembershipType.member, remainingSessions: 5 }, + * user: { role: MembershipType.member }, * selectedCount: 1, // currently selecting 1 + * remainingSessions: 5, // has 5 remaining sessions * alreadyBookedCount: 1, // already booked 1 this week * }) * // limits.sessionsLabel = "0 / 2 this week • 4 total remaining" */ export const useBookingLimits = ({ user, + remainingSessions, selectedCount, alreadyBookedCount = 0, }: UseBookingLimitsOptions): UseBookingLimitsReturn => { @@ -87,8 +93,8 @@ export const useBookingLimits = ({ ) const totalSessionsLeft = useMemo( - () => Math.max(0, (user.remainingSessions ?? 0) - selectedCount), - [user.remainingSessions, selectedCount], + () => Math.max(0, remainingSessions - selectedCount), + [remainingSessions, selectedCount], ) const maxBookings = useMemo(