Skip to content

feat(bookings): support casuals when computing remaining bookings, allowing for casual bookings#966

Open
choden-dev wants to merge 9 commits intomasterfrom
862-casuals-cant-book
Open

feat(bookings): support casuals when computing remaining bookings, allowing for casual bookings#966
choden-dev wants to merge 9 commits intomasterfrom
862-casuals-cant-book

Conversation

@choden-dev
Copy link
Member

Description

Fixes #862

Main changes

  • Introduce new source of truth for remainingSessionswith GET /me/bookings/remaining
    • Uses a strategy depending on casual vs admin/member
    • Casuals will have 1 session available as long as they haven't booked in the week (see apps/backend/src/data-layer/utils/GameSessionUtils.ts)
    • This is called whenever remaining sessions is needed for the user
  • Make it clear on the bookings confirmation that the user is booking the casual slot (Attendees --> Casual Attendees)
    • image

Background

The computation for totalSessionsLeft shows that remainingSessions is always $0$ for casuals. There are no references to appropriately update the remainingSessions field, except for in the resetAllMemberships method. This implies that the main issue beyond a lack of UX for users with 0 bookings is that casuals must have their remaining sessions manually set before.

Warning

The callback at validateUserRemaningSessions makes the source of truth for the user role remainingSessions, meaning we have two options here:

  1. Accept this and create a different experience for casuals (users with 0 remainingSession)
    a. This might be the only option seeing as sessions need to be manually added to users, and the casuals require a different process (pay if accepted)
  2. Add credits to users every week
    a. This would require a rework of the user roles given the tight coupling between remainingSessionand role

The best option here would be to stop using user.remainingSessions as the source of truth for computing the remaining sessions in both BookFlow and useBookingLimits and rather use a strategy-based approach for the different user roles.

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature or improvement (non-breaking change which adds/modifies functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update

How Has This Been Tested?

Booking as casual user

casual book

Booking as member/admin

Note

This is the same behaviour as before

Booking Process

Member book

After booking

image

Bookings go from 5 --> 4

  • Manual testing (requires screenshots or videos)
  • Integration tests written (requires checks to pass)
  • This does not require tests (please leave a note as to why)

Not in scope

  • There is an off-by-one error for users who use their last booking, as in countAttendees the capacity is determined by counting user roles, while validateUserRemaningSessions ensures that those with 0 bookings are casual, therefore some members will be wrongly counted as casual.
    • Not a blocker for this issue, would likely need to follow-up by attaching an additional field to bookings stating what role the user made the booking as, such as member or casual
  • [FRONTEND] Revamp book page with sessions user has already booked #962 is not in scope for this issue as that is a visual rather than technical change

Checklist before requesting a review

  • My code follows the style guidelines of this project
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • I have added thorough tests that prove my fix is effective and that my feature works
  • Additional issues have been created for any tech debt left in this PR
  • I've requested a review from another user

@choden-dev choden-dev requested a review from jji05 March 8, 2026 04:22
@github-actions
Copy link
Contributor

github-actions bot commented Mar 8, 2026

Preview Deployment Status

✅ Storybook Preview: https://0989b559.uabc-storybook.pages.dev

✅ Frontend Preview: https://ffff6f41.uabc.pages.dev

@choden-dev choden-dev force-pushed the 862-casuals-cant-book branch from b13ce69 to 673f8fc Compare March 8, 2026 04:25
@github-actions
Copy link
Contributor

github-actions bot commented Mar 8, 2026

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 83.91% (🎯 70%)
⬆️ +0.07%
2702 / 3220
🔵 Statements 83.8% (🎯 70%)
⬆️ +0.08%
2767 / 3302
🔵 Functions 78.28% (🎯 80%)
⬆️ +0.06%
645 / 824
🔵 Branches 78.36% (🎯 60%)
🟰 ±0%
1553 / 1982
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
apps/frontend/src/components/client/book/BookClient.tsx 20%
⬇️ -5.00%
0%
🟰 ±0%
0%
🟰 ±0%
20%
⬇️ -5.00%
18-26
apps/frontend/src/components/client/book/BookFlow.tsx 90.69%
🟰 ±0%
66.66%
🟰 ±0%
100%
🟰 ±0%
90.47%
🟰 ±0%
112-115
apps/frontend/src/components/client/user/ProfileSection.tsx 62.96%
⬆️ +1.43%
58.82%
⬆️ +20.36%
50%
🟰 ±0%
65.38%
⬆️ +1.38%
37-38, 42-57, 62
apps/backend/src/app/api/bookings/route.ts 100%
🟰 ±0%
89.28%
⬆️ +0.82%
100%
🟰 ±0%
100%
🟰 ±0%
apps/backend/src/app/api/me/bookings/remaining/route.ts 100% 100% 100% 100%
apps/backend/src/data-layer/utils/GameSessionUtils.ts 96.77%
⬆️ +1.54%
95%
⬆️ +3.34%
100%
🟰 ±0%
96.77%
⬆️ +1.54%
54
packages/ui/src/components/Composite/BookingConfirmation/BookingConfirmation.tsx 100%
🟰 ±0%
61.53%
⬇️ -2.10%
100%
🟰 ±0%
100%
🟰 ±0%
packages/ui/src/components/Composite/SelectACourt/SelectACourt.tsx 0%
🟰 ±0%
0%
🟰 ±0%
0%
🟰 ±0%
0%
🟰 ±0%
82-288
packages/ui/src/components/Composite/UserPanel/UserPanel.tsx 100%
🟰 ±0%
50%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
packages/ui/src/hooks/useBookingLimits/useBookingLimits.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
Generated in workflow #3970 for commit 7761177 by the Vitest Coverage Report Action

@choden-dev choden-dev marked this pull request as ready for review March 8, 2026 04:51
@jji05
Copy link
Member

jji05 commented Mar 8, 2026

@choden-dev Thank you for the PR!

A couple things I'd like to clarify up that I should have left in the original user story!

validateUserRemainingSessions was created as a fail safe due to club admins getting access to payload, in case they'd like to use that instead of the admin dashboard, editing a user's sessions ensured that

  • 0 sessions = casual
  • ≥1 sessions = member

This also meant any db writes would result in a correction of membership status (leaves less work to designate a db request updating it). We want to keep this

As for the warning block,

The callback at validateUserRemaningSessions makes the source of truth for the user role remainingSessions, meaning we have two options here:

Accept this and create a different experience for casuals (users with 0 remainingSession)
a. This might be the only option seeing as sessions need to be manually added to users, and the casuals require a different process (pay if accepted)

The admins initially wanted and quite firm about a -1 remaining sessions system (as this closely aligned with how their flow worked best for a pay as you go system although this was not followed in the frontend). We would want to keep casuals at 0 sessions although visually display a slightly different system, so that they know they are not under the booking credit system (displaying 1 would perhaps be confusing).

TL;DR; The only difference being members use their credits to pay, while casuals are allocated MAX 1 session per week dependent on an existing booking within the week.

Clarifications on the out of scope, I am aware of the first bug and is a P1, I've created a ticket (#967), thank you for reminding me!

Copy link
Member

@jji05 jji05 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work, a couple comments left! Thank you 👍

Comment on lines +49 to 58
const remainingSessionsBasedOnRole = await getRemainingSessions(
userData,
semesterDataService,
bookingDataService,
userDataService,
)

if (remainingSessionsBasedOnRole <= 0)
return NextResponse.json(
{ error: "No remaining sessions" },
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I like the logic here although it seems to be overlapping with standard member logic and could be refined down to calling getAllCurrentWeekBookingsByUserId once for both casuals and regulars. Note that this logic is used again in lines:

const allUpcomingBookings = await bookingDataService.getAllCurrentWeekBookingsByUserId(
req.user.id,
currentSemester,
)
if (allUpcomingBookings.length >= getMaxBookingSize(req.user)) {
return NextResponse.json(
{ error: "Weekly booking limit reached" },
{ status: StatusCodes.FORBIDDEN },
)
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For casuals this would be the correct message for their role?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both of them are correct, in this case, it would be ideal to simplify the logic as the same logic is being checked twice

Copy link
Member Author

@choden-dev choden-dev Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree from an efficiency point of view, but realistically you should not know that the implementation of getRemainingSessions does the check, so they are actually doing different things from a black box perspective (weekly bookings vs getting remaining sessions)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In that case, I'm happy with your implementation

Comment on lines 291 to 314
@@ -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" })
})
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

based on the test description, it should be returning "Session is full for the selected user role". since the logic was changed to:

check remaining sessions (for casual user mock) -> check game session capacities

we need to change the description and add a test to test for the initial test topic

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For casuals this would be the correct message for their role?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, the error is only because booking create mock is using casual user and that causes it to show no remaining sessions. The point of the test was to test for casual capacities

Comment on lines 134 to 171
@@ -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" })
})
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

on second thought, I'd prefer the error message be "Weekly booking limit reached" as it makes more sense for casuals to see a weekly limit reached rather than no remaining sessions (they never have remaining sessions as 1 remaining session indicates "member", but if casuals are displayed "1" too which makes the role system confusing), this is open for discussion.

the proposed logic change would involve only checking remainingSessions for members (essentially casuals bypass the initial remainingSessionsBasedOnRole check and only just check existing weekly bookings over here:

// 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,
)
if (allUpcomingBookings.length >= getMaxBookingSize(req.user)) {
return NextResponse.json(
{ error: "Weekly booking limit reached" },
{ status: StatusCodes.FORBIDDEN },
)
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I disagree, having a role based on another value is confusing and users do not need to know that they become a casual if they have 0 sessions, rather it should be enough to specify that their sessions only apply to the casual slots.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Users need to know that they become casual when they have 0 sessions, this was specified by client as they have a very different booking criteria as far as I am aware.

Copy link
Member Author

@choden-dev choden-dev Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't it quite easy to show the user they are casual without linking it to the sessions? It's important from a business logic perspective but from a UX perspective they only need to know if they are casual/member and the implications of such.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is important business logic as now we display 1 session and if a user tops up, thinking they'd get 3 sessions would be wrong! Hence adding a tooltip would clarify it in a business perspective

Copy link
Member Author

@choden-dev choden-dev Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought this comment you made addresses that? i.e it shouldn't be a backend thing

Comment on lines +71 to +73
const json = await response.json()
expect(typeof json.data.remainingSessions).toBe("number")
})
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this doesn't necessarily make the test correct, please check if the remainingSessions is equal to memberUserMock.remainingSessions

remainingSessions: 5,

Comment on lines +283 to +309
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([])
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tests we write are mocked as little as possible to avoid mocks being inaccurate from actual expected functionality. I would recommend using actual data services to run tests

Comment on lines 40 to +45
numberBookedSessions?: number

/**
* The number of remaining sessions for the user.
*/
remainingSessions: number
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: extra space and more descriptive description

Suggested change
numberBookedSessions?: number
/**
* The number of remaining sessions for the user.
*/
remainingSessions: number
numberBookedSessions?: number
/**
* The number of remaining sessions for the user.
* @remarks This is used instead of `auth` as this provides a more accurate session count
* for casuals by checking their exisiting bookings within the week.
*/
remainingSessions: number

Comment on lines 28 to +77
@@ -63,7 +65,16 @@ export const ProfileSection = memo(({ auth }: { auth: AuthContextValue }) => {
return (
<Container centerContent gap="xl" layerStyle="container">
<Grid gap="xl" templateColumns={{ base: "1fr", lg: "1fr 1.5fr" }} w="full">
<GridItem>{!user ? <UserPanelSkeleton /> : <UserPanel user={user} />}</GridItem>
<GridItem>
{isRemainingSessionsLoading || !user ? (
<UserPanelSkeleton />
) : (
<UserPanel
remainingSessions={remainingSessionsResponse?.data.remainingSessions ?? 0}
user={user}
/>
)}
</GridItem>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would be ideal to not display 1 for casuals as a member could also have 1 and it isn't clear enough of a distinction!

you can leave it as is if you add the Tooltip component over an InfoIcon for casuals so they are aware it is a weekly thing

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FRONTEND] Casuals can’t book

2 participants