Purdue Archery WebsitePurdue Archery Website
Practice check-in used to be a paper sign-in sheet, plus someone manually subtracting from everyone's remaining practices in a spreadsheet. I built a Firebase app that signs members in with the Discord account the club already uses for everything else, checks them into practice with a QR scan, sells and tracks their passes, and lets officers edit the public website without a code deploy. Live at purduearchery.club since 2025.
It started as a paper sign-in sheet
The original problem was a clipboard. A member shows up to practice, an officer writes down their name, and later an officer opens a spreadsheet and subtracts from their remaining practices by hand. It works, in the sense that it does eventually produce a number, but usually only after a month without updates.
The more useful thing I noticed is that the club had already solved identity without meaning to. Every member is on the club Discord. That's where practices get scheduled, where officers post, and where bans happen when someone needs to be removed.
So I didn't build a second identity system. Everything downstream, who's an officer, who's allowed in, whose pass is active, reads off one Discord login.
What started as "replace the paper sign-in sheet" grew into membership, QR check-in, pass sales pulled automatically off order emails, a staff dashboard, and a CMS for the public site: 27 Cloud Functions, about 4,800 lines of server code, and a lot of features I regret nothing about.
Discord already knows who everyone is
- Discord OAuth2 is the only way in, so there's no password system for me to manage or leak. The sign-in handoff never puts a token in a URL: the server gives the browser a one-time code that's good once, for one minute, and gets redeemed inside a transaction.
- Login state mirrors live off a Firestore onSnapshot, so a ban reaches an already open tab within about a second. (Role changes are slower. They refresh the next time that member signs in.)
- Every member gets a digital pass at /my-id: a QR code holding 128 random bits of opaque scan token instead of anything derived from their account.
- Email verification is a 6-digit code sent through Resend, generated with crypto.randomInt, stored salted and hashed, compared with timingSafeEqual, and dead after 15 minutes or 5 wrong guesses. Overkill for an archery club? Probably. It was fun though.
- First-time sign-ins go through a short profile step, just name and email, before landing anywhere else.
- Officer routes are gated by three Discord roles (officer, admin, and faculty advisor) and enforced twice: OfficerRoute in the UI, and again in Firestore security rules, so the UI check is a convenience, not the boundary. The rules also stop members from writing privileged fields, like their own ban flag, by calling Firestore directly.
A session that opens itself
Practices already exist as scheduled events in the club Discord, so I didn't duplicate scheduling anywhere. A sync job pulls them in every 15 minutes and keeps the ones whose names read as a practice ("practice", "range night", "open range", "shoot night"), because nobody needs attendance tracked at a social.
The scheduled practice and what actually happens that night are separate records on purpose, so a stale or missing Discord event never blocks check-in. A session opens itself on the first QR scan of the night, and closes itself 15 minutes after the scheduled end, or 3 hours after that first scan if nothing was scheduled. Officers can end one early too.
There is no start button, which is the most reliable way I know of to stop someone forgetting to press it.
- /scan is the officer check-in screen. It uses the browser's native BarcodeDetector where one exists, and falls back to a self-hosted zxing WebAssembly build where it doesn't (hi, iOS Safari). There's a manual check-in for dead phones and a sell-at-the-door flow for walk-ins.
- Check-in is race-safe: the "is there a live session?" lookup happens inside the same transaction that writes the attendance, so two officers scanning the first two archers at the same instant can't open two sessions.
- A member's attendance history at /my-practices is a Firestore collection-group query that spans both the current layout and the older one, so records written under the previous schema don't just vanish from anyone's history.
- Deleting a practice is a real operation, not a hide. It refunds every day pass spent on it first, then removes the Discord event, then recursively deletes the practice and its attendance, and it refuses outright while a session is still live. Hiding one only pulls it from staff lists and leaves history alone. Two very different buttons.
The receipt is the source of truth
Nobody buys a pass on this site. Purchases happen on TooCOOL, the university's storefront, which leaves the app with an awkward job: find out about money it never touched, reliably, without ever counting the same dollar twice.
- Term and day pass prices and colors come from the same officer-editable content as the public pricing cards, so a pass can't be advertised in one color and issued in another.
- Purchases happen on TooCOOL, and the source of truth for a purchase is its order-confirmation email. A scheduled Cloud Function reads those out of a Gmail inbox every 5 minutes, parses the attached PDF receipt, writes the pass to Firestore, and labels the email "Processed" so it can never be counted twice. The member's confirmation goes out through Resend afterward, so a Resend outage can't stop a purchase from registering.
- Door sales write the pass, the check-in, and the sales-ledger entry in one transaction, so there's no state where someone paid but isn't checked in.
- Rained-out night? Officers can refund every day pass spent on a session without deleting anything, which also cancels the pass that check-in activated. Refunds are transactions guarded by a refundedAt stamp, so two officers double-tapping the same refund only refund it once.
- Officers can also issue or adjust a pass by hand for everything automation can't cover: comps, corrections, and general chaos.
Two systems that can't disagree
Discord stays the single source of truth for bans instead of the site keeping its own copy. Every login checks Discord's ban list and the member's roles in parallel, then mirrors the result into a Firestore flag inside a transaction for real-time lockout.
There is deliberately no unban button anywhere in this app. Unbanning happens in Discord, where the ban actually lives, and the flag clears itself the next time that person signs in.
One extra API round trip per login, in exchange for never having two systems disagree about who's banned. I'll take that trade every time.
- Guild membership syncs every 6 hours into a per-day time series (joins over time, percent of the server with a site account), charted on the staff overview. When Discord's privileged intents aren't available, the chart explains why it's empty instead of just being empty.
- /pass (anyone) and /session (officers only) slash commands, with Discord's request signatures verified using nothing but Node's crypto module. Members can pull up their pass and officers can check live headcount without leaving Discord or exposing anyone's info to the rest of the server.
The line I'd want a second opinion on
When Discord can't answer, the ban check fails open. If Discord's API is down at 7pm on a practice night, a banned member could sign in. The alternative is that nobody signs in, and a gym full of archers stands around while an officer refreshes Discord's status page. Bans are rare and officers are physically in the room, so I picked the failure that keeps practice running.
An admin tool run from a phone
The officer side is a full internal admin tool at /dashboard, tabbed and swipeable on mobile, since officers run most of this from their phones at practice. Every chart on it (bar, line, heatmap, sparkline) and every piece of shared UI (data table, drawer, confirm dialog, toasts, collapsible sections, stat tiles) is hand-built rather than pulled from a charting or component library, so it all matches the app's theme.
- Overview: attendance and membership charts plus the Discord growth stats.
- Members: a searchable, filterable table with CSV export and a per-member drawer for editing pass status, issuing refunds, banning, or permanently deleting an account. Deletion anonymizes instead of erasing: attendance and the pass ledger get re-keyed to one anonymous id, so the club's night counts stay accurate without keeping anyone's identity around.
- Practices: a schedule view with a drawer per practice or session for closing a session early, refunding a rained-out night, or deleting a practice, each with a confirmation that tells you exactly what's about to happen.
- Website: the CMS for the public site.
Letting officers edit the site
The parts of the public site that change every semester used to be hardcoded JSX, which meant a code deploy for something as small as a new officer photo, and nobody without programming experience could update anything. I moved those sections into Firestore documents editable from the dashboard: landing copy and pricing cards, the FAQ, officer boards versioned by year (starting a new year adds a board instead of overwriting the last one), the competitive roster and tournament results by year, the join steps and Discord invite link, and footer/social links.
- Officer uploads get downscaled and re-encoded to WebP in the browser with Canvas before they leave the phone, since officers upload straight from their camera rolls. Committed site assets go through a separate sharp script at build time that emits 480, 960, and 1600px widths (and 4096/8192 for the panorama).
- Every editable section falls back to committed seed content, so a missing document or a failed read renders the last known-good version instead of a blank section. Worst case, the site looks like it did last semester, which beats looking like nothing.
- Read access is public, since a logged-out visitor still needs to see the site, and write access is officer-only, enforced in Firestore security rules.
What a visitor actually sees
- Landing page with an animated hero, a polaroid-style photo pile, and a Google Calendar-style practice and event calendar.
- An interactive 360° panorama of the range on Photo Sphere Viewer, plus range, equipment, officer, competition (results and galleries by year), FAQ, join, and merch/sponsorship pages.
- A contact form. It's the one endpoint anyone on the internet can reach, so it got hardened the hardest: IPs stored only as SHA-256 hashes, transactional rate limits (3 per IP per 10 minutes, 30 site-wide per hour), a honeypot field, and Discord mentions disabled so nobody can use it to @everyone the club server. There's an origin allowlist too, but the code comment is honest about it: that one's a courtesy, not a defense.
- Mobile-tuned details like edge-scroll and swipe gestures, device-tilt effects, viewport-height fixes for mobile browser chrome, and a network-first service worker.



Operations & tooling
- Trip logistics: a Power Automate flow (living outside this repo, in Microsoft land) copies the university's approved-driver SharePoint spreadsheet to Google Drive, and a Cloud Function reads it, parses Excel's serial dates to enforce expiry, and adds or removes the matching Discord role.
- Migration and backfill scripts for schema changes, a slash-command registration script, and a reset for local dev.
- Firestore rules, storage rules, and composite indexes are checked into the repo and deployed from it, not clicked together in the console.
- Server functions lazily import googleapis, so the ones that don't need it don't pay for it in cold starts.
The gap I'm not proud of
Eleven test scripts (about 2,200 lines) run standalone or against the Firestore emulator, covering rate limits, session windows, pass-expiry math, Discord rollups, email templates, and 97 allow/deny assertions against the security rules.
CI builds, CI deploys, and CI runs none of them.
The rules are the only thing standing between a signed-in member and everyone else's data, so those tests should be standing between a bad rules edit and production. Event Pass already works that way. This one hasn't caught up.
Where this goes next
The paper sign-in sheet is gone, and one feature at a time it took the rest of the club with it: membership, payments, attendance, comms, and the public website, all in one place.
The next pieces of work aren't features. They're about making the guarantees match the design. Gate deploys on the rules tests. Move the manual pass edit, currently a read-then-write, into a transaction like everything else that touches money. And actually build scan-token rotation, which the data model was designed for but no function does yet.