Projects

Lounge Booking

A Telegram Mini App for booking the three shared lounges in my residential college at NUS. About 130 residents use it, and for a while a bug broke it for 40% of them.

The app: a floor switcher for Levels 9 to 11, upcoming bookings for Level 9, a month calendar and a My Bookings list
Pick a floor, see what's already taken, then pick a date. Nobody signs up: the name at the top comes from Telegram.

Why I built it

Garuda House, my house at the College of Alice & Peter Tan (CAPT), has shared lounges on Levels 9, 10 and 11. People need to see what's free and book a slot without clashing with someone else. The app belongs to Garuda Techs, the house committee's tech team, which I'm part of.

I made it a Telegram Mini App because that's where residents already talk to each other. It opens inside the chat and Telegram already knows who you are, so there's no sign-up or password, and every booking comes with a name attached.

What it does

  • Each lounge has its own calendar and bookings.
  • You can book several hours in a row at once, and the server checks for overlaps on that floor.
  • Consecutive hours show up as one block, like 12:00–15:00.
  • Each floor has a strip of upcoming bookings, so you can see what's taken at a glance.
  • Admins can cancel bookings, and the list of admins lives in an environment variable.

I'm the main developer. From mid-February I reworked the first version into the one residents use now, fixed the bugs below, and built the interface and the admin tools. 46 of the 54 commits in the repository are mine. It runs on Node.js, Express and SQLite, hosted on Railway.

Decisions

One row per hour

Each booked hour is its own row in the database, which keeps the overlap check simple because an hour is either taken or it isn't. The interface merges consecutive hours so people see 12:00–15:00 instead of three separate entries.

Admins in an environment variable

Committee members change. Adding or removing an admin is a variable change on Railway, with no code change or redeploy.

SQLite on a volume

For one house's bookings, a separate database server would just be one more thing to run. The SQLite file sits on a Railway volume so it survives restarts and redeploys.

What went wrong

The app couldn't find 40% of users' bookings

Telegram user IDs can be bigger than 2,147,483,647, the largest 32-bit integer. The SQLite driver sends a JavaScript number to the database as an integer if it fits in 32 bits, and as a floating-point number if it doesn't. The ID column was text, so SQLite stored the big IDs as 6123456789.0. Everyone whose ID was below that threshold was fine.

The "my bookings" lookup used the ID from the URL, 6123456789, which never matched. I fixed it by turning every ID into a plain string wherever it comes into the backend (String(id).split('.')[0] when creating a user, looking up bookings and cancelling). There's a longer write-up in a note.

The rate limiter was blocking the wrong people

Behind Railway's proxy, every request looked like it came from the proxy instead of from each resident, which threw off the per-user rate limit. Setting Express to trust the proxy's forwarded address fixed it.

Identity came from the client

The early version took the user's Telegram ID from the request, so someone could send a hand-made request and act as another resident. In August a teammate moved identity to the server. Every request now carries Telegram's signed initData, and the server checks the signature before trusting it. User-supplied names are now escaped too, which closed a stored XSS hole.

What I learned

IDs shouldn't be numbers. You never do maths on a Telegram ID, so it should be a string from the moment it arrives. This bug also only shows up for IDs above one threshold, so whether you'd ever notice it depends on whose accounts you test with.

In a Mini App, the server should check what Telegram signed instead of trusting whatever ID the client sends.

A lot of what broke only exists in production: the proxy, restarts and the volume. None of that was on my laptop.