Give members a dashboard, and the club a way to run GSoC mentorship - #5
Merged
abhinavjha0239 merged 2 commits intoAug 31, 2026
Conversation
Joining used to end on a read-only card in the right-hand column of /join: you filled in seven fields, it told you they were saved, and there was nothing else. Everything the club wanted a member to do next had nowhere to live. READ THE BATCH OUT OF THE ADDRESS. Every member signs in as abhinav.23bcs10045@sst.scaler.com — 2023-27, branch BCS, roll 10045 — so the form no longer asks for what it already knows. lib/batch.ts parses it on read and NOTHING is stored: the rules pin the address to request.auth.token.email, so a derived value cannot be forged and cannot drift out of step with the document beside it. That deleted the two regexes and the "Unparsed" bucket the organisers' dashboard used to guess with. It also means batch is not queryable, which is stated where it matters. THREE QUESTIONS, NOT SEVEN. year_branch, level, programs and programs_other are gone. The first is in the address; the second was a self-assessment made before somebody had met the club that nothing acted on; the last two were a preference nobody read, now replaced by enrolling in a programme, which is a decision with a consequence. THREE ROUTES INSTEAD OF FOUR STATES. /join is the door, /onboarding asks the three questions, /dashboard is where a member lives. Each ships as static HTML before auth resolves, so every one of them paints a neutral card until it knows who you are — treating "still checking" as "signed out" flashes a sign-in prompt at every returning member. MENTORSHIP. Organisers publish mentors from /admin; members pick a first preference and then either a second or "just my first". Exactly one of those two, enforced in both directions, because "I only want Priya" and "I have not decided" must not look the same to whoever pairs the cohort. No capacity: a slot counter turns choosing a mentor into a race. Nothing here allocates — preferences are counted and listed, and the pairing stays a human decision. mentors/ is the first collection a client may write that is not its own row. It is admin-only and validated anyway; the widening is acceptable because a mentor entry is published, organiser-authored, non-personal copy, and the same argument deliberately does not extend to admins/. A SHELL OF ITS OWN. The signed-in routes carried the marketing nav — six links arguing the case for joining, ending in a filled button pointing at the page you were already on. They are a route group now, with a header that says who you are signed in as. Route groups rather than a pathname check: the decision is made at build time, so no app page ships somebody else's chrome and strips it after hydration. Verified with 101 assertions in scripts/e2e-auth.mjs driving a real browser against both emulators, 80 in scripts/rules-emulator.mjs executing the rules as five different people, and the QA sweep clean across 80 combinations. Two bugs came out of the driving rather than the reading: /join navigated away while signInWithPopup was still finalising, orphaning the Google window about one run in three, and the app header's wordmark was a 33x28 tap target on mobile.
`join shows the sign-in step to a signed-out reader` asserted one of the two states that page legitimately has. With a Firebase config a signed-out reader gets the sign-in card; without one the gate says plainly there is nothing to sign in to. `.env.local` is gitignored and never exists on a runner, so CI only ever saw the second — and this line had been red on every push to main since at least 23 August, reporting a broken join page that was not broken. A permanently red CI is worse than no CI: it trains everybody to ignore the one signal that would have caught something real. Four of the last five runs on main are failures, all of them this. The assertion now accepts either settled state and still refuses the one that matters — a page stuck on "checking your sign-in", which is what a dead bundle or a botched redirect looks like. The two new routes were written this way from the start; this brings the original into line. Verified both ways: 101 pass with .env.local present, and 101 pass with it moved aside, which is how the runner sees it. lint, qa and browsers are clean unconfigured too.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
Joining used to end on a read-only card in the right-hand column of
/join— seven fields, a "details saved" message, and nothing else. This gives members somewhere to land, and gives the club the machinery to actually run its GSoC cohort.Sign in on
/join→ answer three questions on/onboarding→ land on/dashboard, where you can enrol in GSoC mentorship by ranking mentors an organiser has published.The four pieces
The batch comes out of the address. Every member signs in as
abhinav.23bcs10045@sst.scaler.com— 2023–27, branch BCS, roll 10045 — soweb/lib/batch.tsparses it and the form stops asking. Nothing derived is stored: the rules pin the address torequest.auth.token.email, so a value read from it cannot be forged and cannot drift out of step with the document beside it. That deletedyearOf(),branchOf()and the "Unparsed" bucket the organisers' dashboard used to guess with. The cost is that batch is not queryable — noted inFIREBASE.mdand in the file.BRANCH_LABELSships empty on purpose: nobody has confirmed whatbcsexpands to, so it renders asBCSrather than a guess. One line to fill in when the club confirms the codes.Three questions, not seven.
year_branch,level,programsandprograms_otherare gone. The first is in the address. The second was a self-assessment made before somebody had met the club, that nothing acted on and that stopped being true a fortnight later. The last two were a preference nobody read — replaced by enrolling in a programme, which is a decision with a consequence.Mentorship. Organisers publish mentors from
/admin(add, edit, hide, delete) and members pick a first preference, then either a second or "just my first preference". Exactly one of those two, enforced in the rules in both directions — because "I only want Priya" and "I have not decided" must not look identical to whoever pairs the cohort. No capacity: a slot counter turns choosing a mentor into a race, which is a bad first experience of a club whose whole argument is that it is not a competition. Nothing allocates — preferences are listed and counted, and the pairing stays a human decision.The signed-in routes have their own shell. They were carrying the marketing nav: six links arguing the case for joining, ending in a filled button pointing at the page you were already on. They are a route group now. Route groups rather than a
usePathname()check, so the decision is made at build time — a client-side check ships the marketing nav to every app page and strips it after hydration, flashing somebody else's chrome on the one surface that should feel built for the person signed into it.Two things worth reviewing carefully
mentors/is the first collection a client may write that is not its own row. Admin-only, and validated anyway. It is acceptable because a mentor entry is published, organiser-authored, non-personal copy — the worst a stolen admin session can do there is deface a list. The same argument deliberately does not extend toadmins/, where every client write stays denied; appointing an admin is the one privilege escalation this model would otherwise allow. The reasoning is in thefirestore.rulesheader.Deleting a mentor is guarded in the client, not the rules. Firestore rules cannot express "no document in another collection references this one" — that needs a query. So the guard is in
AdminMentors.tsx, which already has every enrolment in memory, and the button is replaced by "3 students picked this mentor — hide instead" rather than disabled. Getting it wrong is cosmetic (an id where a name should be) rather than a disclosure, which is why it is enforced one level up.Verification
npm run typecheck/buildnpm run rulesnpm run rules:emulatornpm run e2e:authnpm run smokenpm run qanpm run browsersfunctions: npm testBoth emulator suites grew rather than being loosened.
smokenow asserts the shell split both ways — marketing routes must have the nav and the Join button, app routes must not — so a signed-in route that grows a Join button fails.Two bugs came out of driving it rather than reading it.
/joinwas navigating away whilesignInWithPopupwas still finalising, orphaning the Google popup about one run in three; and the app header's wordmark was a 33×28 tap target on mobile, caught by the QA sweep. A third was a latent test bug: the e2e's Firestore clear was fire-and-forget where only Auth was verified, so leftovers from a previous run failed six unrelated assertions at once.Known gaps, stated rather than hidden
qaandsmokerun signed out, so on/onboardingand/dashboardthey only examine the "sign in first" card. The signed-in UI has been checked by hand in both themes but is not covered by the automated contrast/tap sweep. The fix is a small a11y pass insidee2e-auth.mjs, which already has an authenticated browser open.enrollments/{uid}holds one enrolment forever. When the next GSoC cycle opens, this year's preferences are still there with nothing to distinguish them. Cheapest fix while the collection is empty: acyclefield and a{uid}__{cycle}key.lib/mentorship.ts.pathis kept on the profile but never asked for — captured from the?path=links every CTA already uses and shown back on the dashboard. Say the word and I will drop it and the preselect assertions with it.On CONTRIBUTING's "one change per PR"
This is four things, and by that rule it should be four PRs. They are interdependent enough that three of them would not be mergeable alone — the dashboard exists to hold the mentorship card, and the app shell exists because the routes do. It is one commit for the same reason: splitting heavily-overlapping files (
firestore.rules,e2e-auth.mjs) into logical commits would produce commits that do not build individually. Happy to break it up if you would rather review it in pieces.Before merging
firebase deploy --only firestore:rules— until that runs,mentorsandenrollmentsdo not exist in production and every write is denied by the catch-all. Everything above was verified against the emulator.🤖 Generated with Claude Code