Saturnalia Admin Console
Live
Society · Web

Saturnalia Admin Console

Lead Frontend Engineer · Saturnalia 2025 - Thapar Institute · 2025

React 19 + Vite back office for running the Saturnalia festival, sitting over the Firebase-backed platform. Twenty routes cover events and their registrations, individual and team entries, transactions and bills, coupons, banners, the merch store, accommodation, campus-ambassador applications, non-host users and account-deletion requests, plus a bulk email sender and a CSV export built in the browser behind a field picker. Venues are placed on an embedded Google Map through @react-google-maps/api. Client-side route gating over Firebase auth reads its role off the signed-in user's Firestore document, so the guards are a usability layer and the real enforcement lives in Firestore rules and the backend.

Built with
ReactReact
TypeScriptTypeScript
ViteVite
FirebaseFirebase
Google Maps APIGoogle Maps API
React RouterReact Router
React BootstrapReact Bootstrap
Project Details

STATUS
Live
ROLE

Lead Frontend Engineer

ORGANISATION

Saturnalia 2025 - Thapar Institute

YEAR

2025

TYPE

Society · Web

TAGS
Admin Dashboard
Operations
Festival
Frontend

A festival back office is not one application. Finance wants transaction reconciliation, outreach wants campus-ambassador applications, and an event coordinator wants the registration list for their own event and nothing else, all of it reading the same Firestore project. This console is the operator-facing half of the Saturnalia platform: twenty routes over that shared data, each gated by what the signed-in admin is actually allowed to touch.

The reason it cannot be one screen with tabs is that these three audiences disagree about what a row means. To finance, a payment is a line item that has to reconcile against a bank statement. To a coordinator, the same payment is the reason a particular team is allowed to walk into their venue. To outreach, it is evidence that a referral code worked. Building one console over one dataset means every screen has to answer its own question from records that were shaped for somebody else's.

The permission model

Access is decided in two places. ProtectedRoute wraps every route and checks the user's role against an allow list, rendering an insufficient-permissions modal instead of a blank page. Seven roles exist (admin, event-admin, event-coordinator, outreach-admin, outreach-member, finance, and a catch-all everyone), with admin short-circuited as a superuser. The finer decisions live in permissions.ts, where a coordinator's rights are resolved per record rather than per route: canEditEvent and canManageRegistrations walk the event's coordinators array and match user IDs, so a coordinator sees only the events they run, and the bulk mailer offers only those events as targets.

The route table reads as a map of the organisation. User management, banners and user detail are admin-only. Bills, transactions and the financial report open to admin and finance. Venues belong to the two event roles plus outreach-admin; campus records and ambassador applications belong to outreach; coupons are shared between outreach-admin and event-admin; the store is admin and event-admin. The events list and any event's detail page are open to every signed-in admin, because the record-level checks inside them do the real work.

What the console covers

  • Event authoring with custom registration fields in five types (text, number, link, select, multi-select), team size bounds, coordinator assignment, separate host and non-host registration fees, and fixed or per-head payment.
  • Registration review for individual and team entries, with a status email fired on each approve or reject decision.
  • CSV export through a field picker that groups columns into user, status, team, payment and the event's own custom fields, then queries the matching Firestore collection and builds the file in the browser.
  • Bulk email with a live HTML preview, sent in batches of 12 to match the backend's instance count and wrapped in Promise.allSettled so one bad address does not take down the batch.
  • Financial reporting that splits transactions into five buckets (cultural events, cultural accommodation, technical events, technical accommodation, store) with totals, success and failure counts, and averages per bucket.
  • Merch store management covering per-size price modifiers, per-size stock, total versus current inventory, and a per-user quantity cap.
  • Venue placement on an embedded Google Map through @react-google-maps/api, centred on campus, storing the clicked lat/lng against the venue record.
  • Coupon management over a model with percentage or flat discounts, a cap on percentage discounts, a minimum cart value, validity dates, separate host and non-host college eligibility, a total usage limit and a per-user one, and a list of events it applies to.
  • Campus and ambassador records for the outreach team, plus accommodation, coupons, banners, non-host users, and account-deletion requests.

One payment, several products

The transaction record is the most revealing document in the project. It carries the merchant order id and the gateway's own order id, every amount in paisa, and a four-way split of original amount, discount, transaction fees and final amount. Underneath that sits paymentIntentions, which breaks a single payment into up to three things at once: an event registration, an accommodation booking with a per-day map and a list of affected users, and a food booking with the same shape. One card tap at the checkout can be a team's entry fee plus two nights for four people plus meals.

That is why the financial report is not a GROUP BY. Splitting a payment into cultural and technical means deciding what an accommodation charge belongs to, and accommodation is bought by a person, not by an event. The rule the report settles on is that an accommodation component counts as technical if any user attached to it, the payer or any of the affected users listed inside the intention, is registered for a technical event, and cultural otherwise. It is a judgement call rather than a derivation, and it is written down in one place instead of being argued about per spreadsheet.

Authoring an event

An event is a wide document: five categories, three registration types including an external-link passthrough, four separate datetimes for start, end, listing and registration deadline, host and non-host fee fields, team size bounds, visibility and featured flags, an optional venue reference, a same-college restriction and an approval requirement.

The custom fields are a discriminated union rather than a bag of strings, so a number field carries its own min and max, a select carries its options, and the export field picker and the registration renderer both read the same type. Prizes, rules and the long description are markdown, and they are sanitised on the way into Firestore rather than on the way out: DOMPurify strips every HTML tag, then a regex collapses any markdown link pointing at javascript:, data: or vbscript: down to its own link text. Sanitising at write time means the stored value is safe for anything that reads it later, including tools that are not this console.

How it ships

A push to main triggers a GitHub Actions job that clones the repository, deletes the .git directory, installs a pinned Vercel CLI and deploys to production. vercel.json rewrites every path to / so client-side routing survives a hard refresh on a deep link, which matters when the whole console is deep links.

Scale and authorship

Roughly 23,600 lines of TypeScript and TSX, concentrated in a few screens that carry the real operational weight: the email sender is 2,082 lines, the transactions page 1,870, the event detail view 1,622. Every backend call refreshes the Firebase ID token before sending rather than reusing a cached one, which is the sort of thing that only matters when a volunteer leaves a tab open across a three-day event.

Two things worth being honest about. The role that drives every gate is read client-side from the signed-in user's Firestore document, so the route guards are a usability layer and the real enforcement has to live in Firestore rules and in the backend. And while the store endpoints authenticate with a bearer token, the file-upload and email endpoints authenticate with a shared password read from a build-time environment variable, which means it ships inside the bundle.

I wrote 53 of the repository's 98 commits between 11 September and 20 November 2025, the largest share of any contributor, and shipped it for the November festival.

Project Details

STATUS
Live
ROLE

Lead Frontend Engineer

ORGANISATION

Saturnalia 2025 - Thapar Institute

YEAR

2025

TYPE

Society · Web

TAGS
Admin Dashboard
Operations
Festival
Frontend