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.

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.