01Jun. 2026Lead developer. Team capstoneShipped
CLS Eventat
Pick a seat, get a code, walk in.
A seat booking and QR check-in platform for events at the College of Life Sciences, Kuwait University. Visitors pick specific seats from a map of the hall and receive a QR code; an administrator scans it at the door. Firestore transactions make double booking preventable, and individual seats can be designated for men or for women per event.
My contribution
All application code: the booking transaction, the seat map and its custom layout runtime, gender-split seat rules, QR generation and check-in, the Cloud Functions email queue, and the Firestore data model and security rules. Requirements and system design were worked out with teammates, who also carried the capstone report and presentation.
- JavaScript
- Cloud Firestore
- Firebase Auth
- Cloud Functions
- Firebase Hosting
- Chart.js
The problem
College events were booked over WhatsApp and a spreadsheet. That holds until two people claim the same seat, and it holds badly for events that seat men and women separately, which several of ours do.
The separation was the part no off-the-shelf tool handled. Every booking product I looked at offered a "choose a section" dropdown, which is a different thing: it asks the attendee to know which section they belong in and trusts them to answer honestly. I wanted a picture of the hall in which a female attendee simply cannot click a seat reserved for men, because the seat is drawn as unavailable to her.
The second half was the door. A booking list nobody checks at the entrance is a document, not a system. Whatever I built had to close the loop between reserving a seat and actually sitting in it.
The constraints
The brief required plain HTML, CSS and JavaScript. No React, no Vue, no TypeScript, no build step. That was not my choice and it shaped a great deal of what follows, most of it badly.
A capstone deadline that did not move, and a team: requirements and the system design were worked out together, and I wrote the code.
One college, one administrator. There was never a second tenant to design for, and I chose to believe that rather than build for a future that was not coming.
And the check-in half had to run on a phone at a door, without anyone installing anything.
What I built
An administrator defines a hall as a grid of seats, creates an event against that hall, optionally marks individual seats male or female for that event, and uploads a poster. Anyone can browse and book, either signed in or as a guest with a name and an email.
Every reservation gets a QR code, delivered on screen and by email. At the door, an administrator opens the check-in page, scans with the phone camera, and marks seats attended. Check-in is per seat, so a party of four can arrive in two pairs.
Admins get a per-event dashboard with filters, a CSV export, and charts.
Halls that are not rectangles are handled by a custom layout script fetched from Storage and executed in the browser. More on that below; it is the decision I am least comfortable with.
What this is and is not
This runs one college's events. It is not a ticketing platform: no payments, no pricing, no refunds, no waitlist.
There is no multi-tenancy. One project serves one college, and the only administrative role
is super_admin, so there are no organiser accounts with scoped permissions. An
administrator can do everything or nothing.
There is no mobile app. The scanner is the browser camera API on a phone, which means HTTPS, a permission the user can decline, and a manual token field for when they do.
Capacity planning stops at a flat seat limit per event. Gender quotas are stored and validated when an event is saved, and then never read again by anything that books a seat.
It is deployed and reachable. A QA pass is still open, and the failures I know about are below rather than hidden.
The technical decisions
| Decision | Reasoning |
|---|---|
| The seat lock is a document ID, not a field | reservationSeatDocId() builds every reservationSeats ID as `${eventId}__${seatId}`. The transaction calls tx.get() on exactly those IDs, so each seat is one contended document in the transaction's read set and Firestore retries when a competing booking touches it. Had seat identity been a field found by where(), there would be nothing to read transactionally and two simultaneous bookings would both see nothing and both write |
| Guests are real Firebase users, signed in anonymously | ensureAnonymousSession() calls signInAnonymously() before a guest can book, so every security rule can require request.auth != null and compare userId to request.auth.uid. One write path and one set of rules instead of a second unauthenticated path to audit |
A seat is taken only when its reservation is confirmed |
Cancelling flips a status rather than deleting rows, so seats are released without destroying the record of who had them. The check-in flag also blocks cancellation, so a seat cannot be given up after its holder has walked through the door |
| An unknown reservation ID is treated as confirmed | The non-transactional query that opens the transaction is a snapshot, and a booking can land after it. If a seat slot references a reservation the snapshot never saw, the code assumes it is real and refuses. Guessing the other way loses somebody their seat |
| The confirmation email is queued in Firestore, not sent by the client | The browser writes to emailQueue as pending; a Cloud Functions trigger moves it through processing to sent or failed, recording lastError. Queuing sits in its own try/catch, so a mail outage cannot fail a reservation that Firestore has already committed |
| The QR encodes JSON, not just the token | {reservationId, eventId, qrToken}. Check-in looks the reservation up by document ID and then compares the stored token to the scanned one. Lookup by ID needs only get permission, where querying by token would need list permission over the whole collection. The comparison is what proves the scanner holds the real code rather than a guessed ID |
| Two QR libraries for one format | QRious draws the code on screen; qrcode renders a PNG inside the Cloud Function for the email, because mail clients will not run canvas. Redundant-looking and unavoidable |
| The admin route guard is not the security boundary | admin-guard.js redirects non-admins, and every privileged operation is gated again by rules checking a super_admin claim. The guard exists only to stop an admin page flashing its contents before failing |
| Password reset goes through my own Cloud Function | The built-in Firebase email drops users on a Google-hosted page mid-flow. Mine generates the link, extracts the oobCode, and mails a branded message pointing back at the app. It returns {sent: false} for unknown addresses rather than throwing, so it cannot be used to test whether somebody has an account |
Custom hall layouts are JavaScript, executed with new Function |
Not every hall is a rectangle. The layout runtime fetches a script from Storage and runs it in the visitor's browser. That is arbitrary code execution, guarded only by a Storage rule limiting writes under hall-layouts/ to super_admin. It was the wrong call and I have written it down as a decision because it was one |
The hardest thing
Firestore has transactions, which I assumed meant the double-booking problem was solved for me. It was not, because of a limitation I did not know about until I hit it: the compat SDK cannot run a query inside a transaction. It can read documents by ID and nothing else.
That is a genuine problem when the question you need to ask is "which seats for this event are already taken", because that is a query. My first instinct was to query before opening the transaction and pass the result in, which is the same read-then-write race I would have had without a transaction at all, just wearing one.
What the code does instead is treat the query as a discovery mechanism rather than a
decision. A non-transactional read finds which reservation IDs are in play for the event.
Then one Promise.all issues transactional reads for all of them at once — the event, every
referenced reservation, and the eventId__seatId slot for each seat being booked. Those
reads are what the decision is made from, and they are what Firestore watches for conflicts.
The remaining hole is a booking that lands between the query and the transactional reads.
Its seat slot will exist, but its reservation ID will not appear in the snapshot, so the
status lookup finds nothing. The code treats nothing as confirmed and refuses the
booking. It is the pessimistic answer, and it is the right one: refusing a seat that was
actually free costs somebody a reselect, where granting a seat that was actually taken
costs two people the same chair at the same event.
The shape of the answer — make the thing you need to lock addressable by ID, so that a system which can only read by ID can still lock it — is the part I expect to reuse.
What I would do differently
Reservation documents are readable by any signed-in user, and anonymous sign-in is open.
The rule is allow get: if signedIn(), because the booking transaction genuinely has to
read other people's reservations to learn whether a seat is still confirmed. The consequence
is that attendee name, email, gender and QR token are readable by anyone willing to create
an anonymous session, which is anyone. The fix is to move booking status onto the seat rows
so the transaction never needs to open a reservation it does not own. I did not do it, and
it is the first thing I would change.
The custom layout runtime should not execute fetched JavaScript. A declarative layout format the runtime interprets would have done the same job without handing arbitrary code to every visitor's browser. It held only because there is exactly one administrator.
Gender quotas should either be enforced or removed. Right now they are validated on save and ignored on booking, which is worse than not having them, because the admin interface implies a constraint the system does not apply.
Expired events close only when an administrator opens the events page. There is no
scheduled function, so an event past its end time stays open until somebody happens to
load the admin view. A scheduled Cloud Function is a small piece of work I kept deferring.
The security rules are not in the repository. They are gitignored and applied from a local working copy, so cloning the project does not give you something deployable. That was caution about publishing rules for a live system, and the result is a repository that cannot stand on its own.
What came of it
It is deployed, reachable, and was accepted as the capstone deliverable.
The more useful outcome is the one I did not expect. I had built a seat booking system before, in 2025, on Google Apps Script and a spreadsheet, where the concurrency primitive was a script lock and the whole thing ran for one afternoon. Coming back to the same problem with real infrastructure, the interesting part turned out to be identical: find the thing two people can contend for, make it a single addressable object, and force every writer through it. The technology changed completely and the shape of the answer did not.
The other lesson is about frameworks, from the other direction. Being told to use none meant every page re-fetches the SDK from a CDN, there is no bundling, no tree-shaking, and shared behaviour is held together by discipline rather than by a compiler. I understand what a build step buys far better for having spent six months without one.