# Best & Fairest Weekly 3-2-1 best-and-fairest voting for sporting clubs. Each round, the coach and observers privately pick their best three players (3 points for best, 2 for second, 1 for third). Admins see **who** has voted — never what — and the running tally is visible only to people explicitly granted access, so the winner stays a surprise until count night. The service at [bestandfairest.app](https://bestandfairest.app) is run by Michael Josem. He does it for free; hopefully you find it useful. There is no uptime commitment and no support agreement — see [For the committee](docs/manual/committee.md#what-it-costs-and-who-runs-it). **Using it rather than working on it?** The [manual](docs/MANUAL.md) is organised by who you are — running an award, voting on the panel, voting with a code, count night, the committee, and troubleshooting. This README is organised by feature and aimed at whoever is working on the code; it is served as plain text at `/readme.txt`. Deploying and looking after the service is a separate document: [`docs/OPERATIONS.md`](docs/OPERATIONS.md). Design doc: [`docs/DESIGN.md`](docs/DESIGN.md). ## How the roles work - **Admin** — sets up the award (club, format, roster, rounds), invites the voting panel, and chases stragglers via the participation grid. - **Voter** — submits a sealed 3-2-1 each round; can edit until the round closes; never sees anyone else's ballot. - **Tally access** — a separate, explicit grant. Being an admin is deliberately *not* enough to see the count; every tally view is audit-logged. One person can hold different roles on different awards, and a club can run any number of awards at once (one per team/division/season). ## Runs with zero secrets With no environment variables set, the app runs in **demo mode**: an in-memory Demo FC with two awards, seeded ballots, and you signed in as the demo admin. Every screen is clickable with no setup. ```bash npm install npm run dev # open http://localhost:3000 ``` ## Going live Copy the environment template and fill it in as you provision each service: ```bash cp .env.example .env.local ``` 1. **Supabase** — create a project (eu-west-2 is closest to the Isle of Man), run `supabase/schema.sql` in the SQL editor, then set `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_ANON_KEY`. In Auth → URL Configuration, set the Site URL to your domain and allowlist `http://localhost:3000` for development. 2. **Google OAuth** — in Google Cloud Console create a project, configure and **publish** the OAuth consent screen (unpublished apps cap at 100 test users), and create a Web OAuth client whose authorised redirect URI is `https://.supabase.co/auth/v1/callback`. Paste the client ID/secret into Supabase → Auth → Providers → Google. 3. **Vercel** — import the repo; set the env vars in **production scope only** so PR preview deployments stay in safe demo mode; attach the custom domain; deploy. Then confirm the production domain appears in both the Supabase and Google redirect allowlists. 4. **Backups** — Supabase free tier has none. `.github/workflows/backup.yml` runs a weekly `pg_dump` once you add a `SUPABASE_DB_URL` repo secret (use the session-pooler URI), or upgrade to Supabase Pro. 5. **Resend** (optional) — set `RESEND_API_KEY` and `EMAIL_FROM` to turn on the three emails described below. Verify the sending domain (SPF/DKIM) first or they land in spam. Without a key everything still works; nothing is sent, and the screens say so. 6. **Signing in with a code** (optional) — a code emailed to an address, for people who have no Google account or would rather not use one. Add `email` to `NEXT_PUBLIC_AUTH_PROVIDERS` to offer it. It needs four Supabase Auth settings first, including **both** email templates containing `{{ .Token }}` — the stock ones have only a link, so without that edit there is no code to type. All four, and the checks to run before switching it on, are in [`docs/OPERATIONS.md`](docs/OPERATIONS.md); the templates are in [`supabase/auth-emails/`](supabase/auth-emails/) and the reasoning is in [`docs/PLAN-EMAIL-SIGN-IN.md`](docs/PLAN-EMAIL-SIGN-IN.md). Panel invites are matched by email: inviting someone emails them a link to sign in, and when they do so with that address the membership attaches automatically (`claim_my_invites` in the schema). An admin can send the invitation again from the panel page if it never got acted on. ## Emails the app sends Three, all to one recipient and all best-effort — nothing in the app fails because a message didn't get out. - **Panel invitations**, when someone is added to an award, telling them which address to sign in with. Resendable, with a six-hour cooldown. - **Vote reminders**, sent by an admin from the participation page to voters who haven't submitted for an open round. Same cooldown. They carry nothing about how anyone voted. - **Automatic reminders**, below: the same email, sent by the clock rather than by a person, once per voter per round when a round is within a day of closing. - **Round summaries**, below: the standings, to people with tally access who asked for them, once a round is counted. - **Vote receipts**, below. Invitations and reminders carry a `Reply-To` of the admin who triggered them, so "sorry, away this week" reaches a human at the club rather than an unread sending address. That deliberately discloses that one admin's address to someone they just contacted. Receipts have no `Reply-To`: they are caused by their own recipient. Anything the app emails on someone's behalf is recorded in `audit_log`, which is both where the cooldown looks and the record of who contacted whom. ## The automatic reminder Every other email here is caused by somebody clicking something. This one is caused by the clock: when a round is within 24 hours of closing, anyone on the panel who has not filed a ballot gets one reminder. Once per voter per round, ever — a reminder that arrives twice is not twice as effective, it is a reason to ignore the next one. The awkward part is that the app cannot do this by itself: with nobody signed in there is no session, and the obvious fix is the service-role key that the security review deliberately removed. Instead, `POST /api/nudges` is called hourly by `.github/workflows/nudges.yml` carrying a shared secret, and every privileged read behind it goes through two narrow database functions that demand that secret. Between them they can find who owes a ballot on a round closing soon and write their own audit line, and they can do nothing else — no ballot, no tally, no other club. A leaked nudge secret is therefore worth: the email addresses of voters with a round closing in the next day, and the ability to send them a reminder they were about to get anyway. That is a real cost and a much smaller one than a key which reads every ballot in every club. Setup is four steps, in `.github/workflows/nudges.yml`. Until the secret is set in all three places — Vercel, the repository, and the database — the endpoint returns 503 and the workflow skips, which is the correct behaviour for a feature nobody has turned on. ## Chasing the stragglers by group chat The emailed reminder is automatic and goes to each person on their own. This is the other one: on the participation grid, beside "Remind all", a written reminder to paste into the club's WhatsApp group — the round, how many are still to vote, the deadline in the club's own time zone, and a link that survives sign-in. It says how many have not voted, never who. The number is what gets the last two in; naming them is the coach's call to make in their own words, not something the app should put in their mouth in front of a chat that may be wider than the panel. Nothing in it says anything about how anybody voted. There is no WhatsApp integration behind this, deliberately. Sending through the WhatsApp Business Platform means a Meta business verification, a message template approved in advance and re-approved whenever the wording changes, a recorded opt-in per recipient, a Business Solution Provider contract, a per-message fee — and this app holding every panel member's mobile number, which is a new category of personal data with its own consent record and its own erasure path. The group chat reaches the same people in the same place for none of that. ## The round summary Once a round is counted — when the last panellist files, or when the round closes if someone never does — the standings go by email to the people entitled to them. Once per person per round, ever. **It follows `can_view_tally`, not admin.** That being an admin is deliberately not enough to see the count is the spine of this product, and an email to every admin would be a back door around it wearing a convenience's clothes. On top of that it is opt-in per person, chosen on the award page by the person themselves: the database function that sets the flag touches one column on one row, and only for someone who already has tally access. This is the one place the count leaves the building, and the copy says so. Inside the app a view is a deliberate act against a gate, and it is logged; in an inbox it is forwarded, quoted in a reply, and previewed on a lock screen in front of the player it is about. So the subject names the round and never the leader, the body says plainly what the reader is now holding, and every send is written to the audit log as `round_summary_sent` — marked notable there, and deliberately *not* counted as a tally view, because inflating the one number worth watching with sends nobody performed would make it less useful. It runs on the same hourly schedule as the reminders, through the same endpoint and the same secret. The counts it reads are aggregated in Postgres first, and the standings are computed by the same function the tally screen uses, so an emailed result cannot disagree with the one in the app. ## Vote receipts When a voter submits or changes a ballot, they get an email listing exactly what they voted, with a link back to change it while the round is open. An edit sends a fresh receipt that supersedes the earlier one. The receipt has exactly one recipient — the voter — with no cc, bcc, or admin copy, because only the voter is ever allowed to see their own picks. Sending is best-effort: the ballot is saved before the mail is attempted, so a mail failure can never lose a vote. Receipts are skipped entirely in demo mode, whose voters are fictional addresses. ## Fixtures and voting windows A round is a fixture — opponent, venue, match date — plus a voting window. Give it an opening and closing time on `/awards/[id]/rounds` and it opens and closes itself: there is no stored status, and no scheduler either. A round is upcoming before `opens_at`, open between the two, and closed after `closes_at`, worked out from the clock every time anyone asks. The database applies the same rule inside `submit_ballot`, so a deadline is a deadline and not a greyed-out button. "Open voting now" and "Close voting now" are not a separate mechanism; they set the window to this moment. Reopening a closed round clears the closing time and is recorded in the audit log, exactly as it was before. Only an admin can move a window — RLS on `rounds` sees to that — so a voter cannot extend their own deadline. Deadlines belong to the club rather than to whoever is reading the screen: each award carries an IANA time zone, picked up from the browser when the award is created and changeable on the fixtures page. Windows are stored as absolute instants, so changing the zone relabels them and never moves one someone has already been told about. Once a round has an opponent, everything says so — "Round 7 v Rushen" on every screen, in count night, and in the reminder emails, which now carry the closing time as well. ## Running it for a league A league best and fairest is three hundred players across ten clubs, and two of them called J. Kelly. `players.club` is what makes that workable: paste each club's list separately, name the club as you go, and every screen that lists a player shows it — the tally, count night, the winner card, the public page, the live view, the archive, the printed count, the CSVs and the vote receipts. Null for a single-club award, which is every award that exists today, and nothing about those changes. The ballot groups by club, as a plain ``. A club award lists thirty names and a flat dropdown is right; a league lists three hundred. Native rather than a filter box, because that form carries no client JavaScript — it is opened on a borrowed phone at the ground — and because on iOS an optgroup groups the wheel picker. Nothing else needed building. A round holds as many ballots as there are matches in it, so a league round is a round and its five matches are five ballots; umpires vote with one-use codes or printed slips; and the count, the sealed tally and the public page never cared how many clubs the roster spanned. What this is **not**: club-by-club logins, a fixture grid across ten grounds, or each club seeing only its own players. A league runs its award the way a club does — one award, one admin, one roster. Devolving administration to member clubs would be a different product. ## Building a roster The roster page takes a pasted team list in whatever shape the club has it: tab-separated out of a spreadsheet, `7. Juan Kermode`, `Juan Kermode, 7`, bare names, bullets, with headers and blank lines mixed in. It shows what it understood as you type — the parser is a pure function shared by the browser preview and the server, so what you approve is exactly what gets stored. A squad can also be brought forward from any other award you run, which is how next season starts. Both routes skip anyone already on the roster. Players can be renamed and renumbered at any time, including mid-season: ballots reference a player's id, not their name, so correcting a spelling cannot change a result. Someone who leaves is deactivated rather than deleted, which keeps their past votes in the count. ## Passing it on Every award page offers two links that open the setup form with the tedious parts already in it. One is for the next team along at the same club and carries the club name, season and scoring format, so the person running it has nothing to type but their team's name. The other is for a coach at another club and carries only the format — their club name is theirs to write. Neither link grants anything. Following one creates a separate award with its own admin, panel and roster; it does not join, or give any sight of, the award it came from. A shared link survives sign-in. Landing on a prefilled form while signed out used to mean signing in and arriving at a bare home page with the prefill gone, which is not one tap. The intended destination now rides along in a short-lived cookie — kept on our own origin, so the identity provider never sees it — and is validated both when it is stored and when it is used, since a "where to go next" value is exactly what an open redirect is made of. The same machinery means an invited voter following a reminder email while signed out lands on the ballot rather than the home page. Both links live permanently in the award's settings. Once a round has closed, the award page also *offers* them — with a paragraph to send, because the missing piece was never the URL. Nobody wants to compose the message, and a bare link in a group chat reads like spam even from a friend. The app writes the sentence; the coach sends it. There is no share button, nothing goes out on anybody's behalf, and there is no email version — a growth email is not one of the things this app sends. The prompt waits for a closed round rather than appearing during setup, because a coach with an empty roster has nothing to vouch for. It waits without reading any ballots, since that is tally-gated and logged. The club half disappears once the person already runs more than one award there. And one "don't ask me again" — `award_members.dismissed_referral`, per person — ends it for good. Nothing records whether a message was sent or acted on, and the dismissal is not audit-logged. Knowing which award a signup came from would be mildly interesting and is not worth becoming software that follows people between clubs. ## Count night `/awards/[id]/count-night` is the presentation-evening reveal, behind the same gate as the tally: a dark, full-screen view that steps from a title card through each round's votes and the standings after it, ending on the winner. Arrow keys, space and page up/down all advance it, so a presentation clicker works without any setup. The whole count is computed in one pass on the server, so stepping is instant, the evening survives the club's wifi giving up halfway through, and the audit log records one tally view for the night rather than one per round. ### Following it from the back of the room An admin can hand the club a link — `/live/` — that follows the reveal: people at the presentation watch on their phones, and people who could not make it watch from home. It shows exactly the rounds the projector has read out and not one more, because everything past the step the admin has reached is simply not in the data the page receives. It polls every five seconds, and stops while the tab is in somebody's pocket. Three rules keep it from becoming the side channel the sealed tally exists to prevent: - **Nothing is live until an admin starts it**, from the award page. Ending it takes the page down, and a later night gets a new code. - **It cannot be started while a round is open for voting.** The database refuses outright. A count that can still be added to is not one to broadcast: a voter who has seen the standings is no longer voting on what they saw on the pitch. - **Only aggregates leave** — (round, player, rank, count, weight), the same shape the public page and the archive are built from. A live view cannot say who voted for whom any more than they can. It reaches its winner through the same ranking code and the same minimum-games rule as the screen at the front of the room, and says out loud why a player topping the table is not the one holding the trophy. The first version did not, and named a different winner from the projector — a mistake worth naming because it is the only one this feature can make that would embarrass a club in public. ## Taking a person out, and taking a season down Votes are opinions about named people, and some of those people are children, so "take my child off this" has an answer inside the app. **Remove somebody from the roster** (`erase_player()`) takes their name and squad number and any unused voting code carrying their id. Their votes stay, and the count does not move: those votes are the panel's record of what it saw on a Saturday, and deleting them would shift every other player's position and restate a season the club announced months ago. The row keeps its votes as "Removed player 1". The audit line records the act and not the person — writing the name into the log would put back exactly what was taken out. The limit is on the screen rather than papered over: the club still remembers who wore 7. What this does is stop the app being the thing that holds it. Somebody nobody ever voted for and who never appeared on a team sheet is deleted outright. There is nothing to preserve, and an admin who added a name twice wants the row gone. **Delete a season** (`delete_award()`) removes the award, its rounds, ballots, picks, voting codes, late passes, team sheets, roster, panel and audit log — and the club too, if that was its last award. The award's own name has to be typed back, checked in the database as well as in the form, because a confirmation that lives only in a browser is one an API client never has to make. Take the CSVs and the printable count first; nothing brings a season back. Two things had to be fixed before an award could be deleted at all, and both were latent bugs rather than decisions. The cascade into `award_members` tripped `guard_last_admin()`, so no award could be deleted by anybody, including the table owner; that rule now stands down for an award that is already gone, and for nothing else. And the cascade could not reach the players regardless — `ballot_picks.player_id` is `NO ACTION` so a voted-for player cannot be deleted out from under the count — so the deletion order is explicit: rounds, then players, then the award. There is no automatic purging. A retention timer that deletes a club's history on a schedule, driven by a cron holding a shared secret, is a bigger risk than the one it answers, and completed awards are kept on purpose: the history is half the point. Deletion is something a person does, deliberately, to a named thing. ## Remarkable moments The count knows things nobody was ever told: that this is the third round running somebody has polled, that a player has just opened their season, that every voter put the same name first. Four such moments are computed in the same round-by-round walk count night makes, so a moment cannot disagree with the count it is a moment in. They appear on count night, on the running tally, on the season archive, and in the weekly post. That list is not arbitrary: "third round in a row" is a statement about the rounds before this one, so a moment is the sealed count talking, and it goes only where the count already goes. Never a ballot, never the participation grid, never a screen a voter sees without tally access. In the weekly post it is consistent with that feature's one-round rule rather than an exception to it — a streak says who polled in rounds the club has already published, not who is leading the award. Nothing is invented and nothing is nearly true. A streak is consecutive counted rounds, not "three of the last four"; a clean sweep needs more than one ballot to sweep; a first vote is a first in this award. In the season's first round nobody is told they have opened their account, because everybody has. ## The weekly votes post Plenty of clubs post the 3-2-1 after every game. `awards.weekly_post` turns that on for an award — off by default, audit-logged both ways — and the running tally then carries a written post under each counted round, ready to paste into WhatsApp or a club Facebook page. The app publishes nothing itself. It writes the paragraph; a person still pastes it, and the box is editable so the wording and the credit line at the bottom are the club's to change. Building it needed one column and no new function: the post is assembled from ballots read through `award_ballots()`, which already requires `can_view_tally` and already logs a tally view, so whoever can copy a post could already read the votes. Two rules decide what it can contain: - **One round's votes, never the standings.** The running tally is the sealed half of the product, and the round-summary email tells its reader to keep it to themselves until count night — the same app cannot then put a Copy button under the same table. What happened in one game is the club's to publish; who is leading the award is count night's to say. - **Nothing while a round is open.** A post appears only once the round has closed. Publishing a result people can still vote on tells whoever has not filed yet what everyone else thought. An open round says that in place of its post, so the rule is visible rather than mysterious. When any voter's ballot is weighted the post says so, because a total ending in a half otherwise reads as a bug to everyone in the group chat. ## Who the club thanks `awards.sponsor` is a name a club types — "The Creek Inn" — and the app supplies the words "sponsored by". It appears on four screens: count night, the winner card, the page the room follows the count on, and the club's public page. A sibling trophy inherits it, since four trophies on one night usually share a sponsor. Where it does **not** appear is the point: - **Never on a ballot.** Somebody deciding who was best on the ground should not be looking at an advertiser while they do it, and an award whose voting form carries a sponsor's name is one whose result can be questioned for free. Every screen it appears on is one where a result is announced; none is one where a result is decided. `verify-hardening.sql` holds the half of this rule the schema can hold — `casual_ballot_context()` builds the whole form a stranger opens with a code, and it must never return a sponsor. - **Never in an email.** Every message this app sends is functional. An advertisement inside one changes what it is, to the reader and to the filters deciding whether it arrives. It is a name, not a logo and not a link. No image, because there is no upload path in this app and advertising would be an odd reason to build the first one. No link, because a link is the first component of an ad network: it wants click counting, then moderation, then somebody to notice when the URL dies. Not audit-logged — it carries no personal data and no votes, so it sits with the club clock rather than with publishing a season. ## The honour board A club's public page used to be empty until it had run a season here, which made the artefact this app asks clubs to share worth nothing for eight months. `honours` is the club's own record — season, award, winner, and anything they want to add — pasted in from the Word document or read off the board in the clubhouse, and on the page the day they arrive. `public_club()` resolves on a published season **or** a board, which is the whole point. Entries sit in their own section, labelled as the club's own record. They carry no votes, no count and no link to a tally, because there are none — somebody remembered them. Nothing promotes one into a counted award. The board belongs to the club rather than to an award, and every team at the club shares one. With no club account yet, whoever administers any award there may edit it — the same rule `create_award()` uses to decide whether a club is already yours. ## Next season `roll_over_award()` makes a new award with the same name, carrying the squad, the panel with their roles and weights, the scoring, the club clock, the sponsor and the minimum-games rule. Last season is untouched. It is not the sibling-award copy with different arguments. A sibling is another trophy in the same season; this is the same trophy in the next one: fixture windows do not come across (next season's calendar does not exist, and last season's would arrive eleven months closed), only the active squad comes (an inactive player has left), and the minimum-games rule does come (a constitution does not change between seasons). ## The club's public page `/c/` is a page anyone can read: the club's name, and every award it has chosen to publish, most recent season first, with the winner and the placings behind them. It is the artefact a club links to from its own site, and the only page in the app with no sign-in and no membership check. Two rules make that safe, and both live in the database rather than in a screen: - **Only a finished award can be published.** A check constraint enforces `published implies status = 'complete'`, so a running count cannot be put on a public page even by writing to the API directly — and a published award cannot be moved back to active while it is still up. The public page must never become the side channel that tells a voter how the count is going. - **Nothing public is derived from a ballot.** The reader functions aggregate in Postgres to (player, rank, how many) before anything leaves, so the page is built from counts that carry no ballot id and no user id. The standings are then computed by the same function the private tally uses, so the two can never disagree. Publishing a result does reveal how the votes fell — that is what a result is. On a small panel the aggregate narrows what the individual ballots could have been; with two voters, unanimity is visible. That is a property of announcing a winner at all, not a leak, but it is worth knowing before publishing an award with a two-person panel. Winners appear under the names on the club's roster. A club that wants "Juan K." on a public page types that on the roster — which keeps the decision with the people who know the players, rather than in a display setting that is easy to get wrong. Publishing is off by default, is a deliberate act by an admin, and both publishing and unpublishing are recorded in the audit log. ## The manual on the site `docs/MANUAL.md` and the six guides under `docs/manual/` are served at `/help`, one page each, public and prerendered. This README is served at `/readme.txt` as plain text. **The Markdown stays the source of truth.** The pages read those files and render them; nothing is copied into the app and nothing is generated, so the version on the site and the version in the repository cannot disagree. The files are read at build — these pages have no data behind them — and traced into the deployment anyway, so a page that Next ever decides to render on demand still works. Links are rewritten on the way out (`webHref` in `src/lib/manual.ts`). A document links to its neighbours by relative path because it has to keep working when read on GitHub; on the site those become routes, and the ones with no route — this README, the roadmaps, the threat model — become links back to the repository rather than dead ends. Heading anchors are slugged by GitHub's rule so the contents list inside each document keeps working in both places. `docs/OPERATIONS.md` is not part of the manual and is not served. Deploying this is a different job with a different reader, and it is not a job any club using bestandfairest.app has. The Markdown reader is `src/lib/markdown.ts`: headings, paragraphs, lists, quotes, rules, tables, and four kinds of inline markup. It does not have to read anybody's Markdown, only these files, which is what makes writing one cheaper than depending on one. **It produces a tree, never HTML** — the renderer hands React a structure and lets React escape it, so there is no `dangerouslySetInnerHTML` anywhere in the path and no hand-written escaping to get wrong. ## How the count works Every published award gets a plain public page at `/c//how/`, linked from the club page beside the award and carried on the winner card as a QR code. It explains the scoring, who voted, who could win, how the tally was kept and what happens when a ballot has to be set aside — all of it built from that award's own rules rather than from a template. It carries no vote, no total and no winner. Every input is a rule the club chose before the season started, and all of them are already visible in the result. Two standing invariants hold it there: the reader it is built from lists published awards only, and returns no vote and no player. The claims about sealing are deliberately specific, because the vague version is not true. Seeing the running tally is a permission granted to a named person, and administering the award is not enough on its own. Every look is recorded by the database in the same operation that reads the votes, so the votes cannot be read without leaving a mark. The wording lives in `src/lib/how-it-works.ts` rather than in the page, because every sentence about sealing and logging is a statement about how the database behaves and has to be revisited when that behaviour does. ## QR codes and posters Every issued voting code prints with a QR square beside it, opening the ballot with the code already in it. **The code stays printed next to the square** — a phone that will not scan, a code read out over a tannoy and somebody who simply prefers typing all still work. That is also why the encoder uses error correction level M and not something heavier: a damaged code costs a scan, not a vote. There is no square on the paper slips. A slip's code is deliberately not redeemable online — that is what `voting_tokens.paper` is for, and there is a standing invariant that says so — and a square that opened a page which then refused the code would be worse than no square. `/awards//poster` prints posters for the clubhouse: the club's public page, the live count while one is running, and the link that starts an award for the next team along. There is deliberately no "scan here to vote" poster, because a vote is a single-use code: one square on a wall is either a vote anybody can cast all night or a vote only the first person to look gets to cast. The voting version is a square per code on the codes sheet. The encoder is in `src/lib/qr.ts` — byte mode, level M, versions 1 to 10, no dependency. QR is ISO 18004 and has not changed since 2006. It is verified by decoding rather than by comparison, because two reference encoders disagree with each other: a QR code is not uniquely determined by its payload, so the only test that means anything is whether a reader gets the payload back, including with part of the code blotted out. ## Several trophies on one night Clubs hand out four or five: Best & Fairest, Most Improved, Coaches' Award, Players' Player. Nothing ever stopped them running four awards here — what stopped them was setting up the same roster, the same panel and the same twenty-two fixtures once per trophy. **Another trophy for this team** on any award you run copies the roster, the panel with their roles, the scoring format, the time zone and the voting switches into a new award in the same club. Fixtures are a checkbox, because the answer differs by trophy: a coaches' award votes round by round like the main one, and a most-improved is one vote at the end of the year. It happens in a single database function, so a sibling cannot half-exist. The same club row, deliberately, rather than a new club of the same name — the public page is per club, and two Peel FCs would split a hall of fame in half. Your awards are then grouped by club and season, and the last screen of count night lists the others running the same night, so one evening walks from trophy to trophy without anyone hunting for a URL in the dark. Listed as a menu rather than a sequence: a club's trophies have no true order, and calling one "next" would invent one. ## Players' Player The award players care most about, and the one the panel model cannot describe: thirty voters rather than three, none of whom has an account. Same mechanism as the crowd codes below, with the recipients known — one code per player on the roster, per round, labelled with their name. Knowing who holds a code buys two things an anonymous one cannot. The participation page can say *which* players still owe a ballot rather than only how many. And a player's own name comes off the list they choose from, because a players' player award is what the squad thinks of each other and a vote for yourself is not that. Both the form and the database enforce it. What it does not buy is any way to see how a player voted. A ballot cast with a code has no user against it, so the tally shows the picks and cannot attribute them — the coach sees who has voted and never what, exactly as with the panel. Issuing is idempotent: pressing the button again after everyone holds a code issues nothing and logs nothing, so nobody ends up with two votes. ## Votes from outside the panel Some clubs run their award on the umpires' votes, or the crowd's. Off by default, and turning it on is a decision about what the award *is*: a panel votes on what it watched for, a crowd votes on who it enjoyed. Both are real awards and they are not the same one, which is why the public page and the last screen of count night say when a result came from both. An admin issues codes per round — ten characters, printed or read out, in an alphabet with no I, L, O or U so nothing has to be spelled twice across a windy oval. One code buys one ballot at `/v/`, with no sign-in, and is spent as the vote is written. Two people racing the same slip of paper get one ballot between them; the second is told the code is used. An open link would have been easier and is not a vote. One screenshot in a group chat and the club champion is decided by whoever shares fastest. What is given up, said plainly: a crowd ballot cannot be attributed to a person, because there is no person. The audit log says "a ballot cast with a voting code", which is the truth and is less than a name. Codes are stored as written rather than hashed — an admin has to be able to reprint the sheet, they are holding the printed copy anyway, and a code is worth nothing once its round shuts. Crowd ballots count in the tally and never appear in the panel grid, which asks whether a named person voted. The participation page asks the only question that can honestly be asked about them instead: how many of the twelve came back. ## Corrections and late votes Two things clubs need that used to have no proper answer, both on the participation page under **Corrections**. **Setting a ballot aside.** An admin can rule a ballot invalid — someone who was not at the game, a submission made in error. It stops counting immediately and everywhere: the tally, the round summary, the public page, the exports. It is never deleted, only marked, because this is a product about being able to recount and a count you cannot reconstruct is worse than one you can see was set aside. The admin does not see what the ballot said — it is addressed by round and voter, not by content, and being an admin is still not enough to read a ballot. **The voter is emailed every time**, and that is load-bearing rather than polite. An admin who *also* holds tally access could otherwise void a ballot and watch the standings move, which would tell them exactly what that person voted. Nothing in the schema can prevent that. What stops it being a quiet trick is that the voter is told, with the reason, and the audit log names who did it. See `docs/THREAT-MODEL.md`. **Letting one late vote in.** Reopening a round works but is a blunt instrument: it hands everybody another go and another chance to change their mind. A late pass is one person, one round, one submission — granted by an admin, spent as it is used, and logged. The round stays closed to everyone else. A fresh submission also supersedes a void, so "set aside, then let them file a replacement" is two clicks. ## Taking the count away Two exports, both offered from the tally page and both gated exactly like it: reading the ballots writes an audit entry, so a file that walks out of the building leaves the same line behind as a screen that does not. `/awards/[id]/print` is the full count on paper — standings, then every round — for club records, the committee, and whoever engraves the trophy. Deliberately a page rather than a generated PDF: every browser prints to PDF already, and a server-side renderer would be the heaviest dependency in the project, added to reproduce a button the reader is holding. The print rules live in `globals.css` and drop the navigation, the footer and the dark palette. `/awards/[id]/export` gives CSV — `?sheet=rounds` for the long form, one row per player per round, which is what pivots. Both are aggregated. The store hands back whole ballots because the tally needs them, but no screen shows who voted for whom and an export must not become the first place that appears. Names go through a formula guard on the way out. A spreadsheet treats a cell beginning `=`, `+`, `-` or `@` as something to evaluate, so a player entered as `=HYPERLINK(...)` would run on open, and correct CSV quoting does not prevent it. Those cells get a leading apostrophe, which Excel and Numbers hide and Sheets shows — a visible cost to the rare name that starts with a hyphen, and the right side of that trade. This is also why the roster does not sanitise names on the way *in*: a name is the club's text and stays as typed, and the escaping belongs where the data crosses into something that executes it. ## The winner card `/awards/[id]/winner-card` renders the result as a PNG to post — club, award, winner, votes and the runners-up — with `?format=square` for Instagram. It carries "Run your team's at bestandfairest.app", because a picture cannot be clicked and that line is the only way anyone gets from it to a setup form. The last screen of count night says the same thing, quietly, to a room with the whole club in it. It is offered from the tally page and from the final screen of count night. It is gated like the tally, not public: the app should never be what leaks a winner before the club announces it, so an admin downloads the image and posts it when they are ready. Rendering it reads the ballots and is logged as a tally view, which is why nothing previews it automatically. ## The audit log Every sensitive act is recorded and admins can read it at `/awards/[id]/audit`: who looked at the running tally, who reopened a closed round, and every invitation or reminder the app sent on someone's behalf. Tally views are written by the database inside `award_ballots()` as the votes are read, so the count cannot be looked at without leaving the line behind. The log is append-only for everyone — the policy accepts inserts, never updates or deletes, and each row must name whoever wrote it. ## Security Three documents are on record. `docs/SECURITY-REVIEW.md` is the first review, from 18 August: what was attacked, what held, what was fixed. It did the structural work — freezing the scoring format once ballots exist, refusing to delete a round holding votes, requiring a confirmed address before an invite attaches, and removing the service-role key. `docs/SECURITY-REVIEW-2.md` is the second, from 21 August, in three passes that deliberately use different methods: systematic coverage, then attacker economics, then chains and primitives. Six findings fixed, four left open with the reasoning written down. Its conclusion is that the weak part is not the application — it is the account recovery paths around it. `docs/THREAT-MODEL.md` covers the infrastructure adversarially: the cheapest routes to compromise, what each stolen credential is worth, and what would go unnoticed. ### On a phone, and with a keyboard Voting happens on a phone in a car park, so the floor is tested rather than claimed: every page is clean under axe (WCAG 2.0, 2.1 and 2.2, A and AA, plus best practice), nothing overflows sideways at 360px or at 200% text zoom, a ballot can be filled in and submitted with the keyboard alone, and every element that takes focus draws a visible ring. Buttons, fields and anything you tick have a 44px floor — 24px is the standard, 44px is the size a thumb is — and the first thing a keyboard finds on any page is a link straight to the content. The palette carries two golds rather than one, because the same accent has to be legible as text on a white page and on count night's near-black screen, and no single value does both. ### The season's story When an award is finished, an admin can email the whole panel a recap: who won and by how much, who spent the most rounds on top, the round after which the winner was never headed, the biggest single round, and the final standings. Sent by hand rather than automatically, so it cannot arrive before the club has announced the result. Every figure in it is true or absent. A season with no turning point does not get one invented, a shared award is not described as a win, and a lead only "changes hands" when somebody who was leading stops leading — a player drawing level has joined the lead, not taken it. It is built from the same aggregate record as the archive rather than from ballots, so sending one needs no tally access and logs no tally view. ### Voting on paper Plenty of clubs already vote on the bus home, and telling them to change before they can use this is telling them not to use it. So `/awards//paper` prints numbered slips, and gives you a screen to type them up afterwards — text boxes rather than dropdowns, because you are working through a bag of them. Type a squad number, a surname or a full name; if two players could match you are asked which, never given whichever the sort order put first. A printed code is not a secret — it is on paper, in a bag, on a bus — so it authorises nothing on its own. It cannot be redeemed online, it will not even open the voting form, and entering it needs an award admin. Entry still needs the round open: an admin who needs longer moves the window, which is visible and recorded. Whoever types the slips in has read them. That is what paper is, and the app records it rather than pretending otherwise: every slip entered is in the audit log with a name against it, and it counts as a slip rather than as any panel member's ballot. ### Knowing whether it is being used `/operator` shows whoever runs the service how much of it is being used: clubs, awards, people, rounds, ballots, how many clubs opened a round in the last thirty days. Gated on `OPERATOR_EMAILS` — an allowlist in the environment, not a flag in the database, so the privilege lives in deployment config and nobody holding a database session can grant it to themselves. Empty by default, and the page 404s for everyone else. It is the only screen that sees across clubs, so the reach is fenced by the return type rather than by trust: the function behind it returns numbers and nothing else, so no club name, player, email, ballot, standing or winner can reach the page even if somebody later wanted one there. Aggregate only — "412 ballots this month" is a metric; "Peel FC has 412 ballots" is a customer list, and a club did not sign up to appear on one. Every view writes a line in the audit log, from inside the function where the caller cannot skip it. The person who can see the most is the one whose looking should leave a record. ### Weighted voters Some clubs' rules give one voter more say than another — the coach's ballot counting double, a guest observer's counting half. Set it per voter on the panel, in halves and no finer. Weight belongs to the member rather than the ballot, and a ballot cast with an issued code always weighs one: there is no member behind it to weight. Counts and weights stay separate everywhere, so "6 ballots counted" keeps meaning six people even when they are worth nine. A weighted count says so — on the tally, on the archive, and on the club's public page if the award is published. Leave it alone and every ballot counts once, which is what nearly every club wants; with every weight at 1 the arithmetic is identical to an unweighted count. ### Finishing a season, and the archive After count night an admin **finishes the season** — typed to confirm, because it cannot be undone. That does two things: it lets the award go on the club's public page, and it opens the season's record at `/awards//archive` to everyone on the panel, not just to tally-holders. The archive is aggregates and never ballots — (round, player, rank, count), the same shape the public page uses — so it cannot say who voted for whom. Reading an actual ballot still goes through the tally, still needs `can_view_tally`, and is still logged. A finished award cannot be reopened, and that guard is what makes the rest safe: otherwise an admin could finish the award, let the panel read the running count, and un-finish it. Voting is unaffected either way, since rounds open and close on their own timestamps. ### Handing an award on Committees turn over every year, so the panel has a **Hand this award on** section: pick who is taking it, optionally take yourself off the panel entirely, and it happens in one act — they become the admin and you step back in the same transaction. The roster, the rounds and every vote stay exactly as they are, and somebody who leaves keeps every ballot they cast. Two rules make it safe. An award must always keep at least one admin **who has signed in** — an invitation sitting in an inbox cannot administer anything — so the successor picker only offers people who have. And setting up next season under the same club name reuses the club, rather than making a second one that would split the hall of fame in half. ### Player season cards Every name on a club's public page links to that player's own season at `/c//p/`: what they polled round by round, their best game, where they finished, and a PNG to post. It is the piece most likely to travel, because it is the only page in the app about one person rather than about a club. Same gate as the public page — published and complete — which is what keeps it from being a side channel: a running count cannot be published, so this can never show a live tally to a voter mid-season. Votes are aggregated to (round, rank, count) in the database, so the page is built from something that cannot say who voted for whom. The picture carries its own caveats. A player short of the club's minimum who topped the count would otherwise share an image reading "16 votes, 1st of 5" and nothing else, so the card says they were not eligible and why. ### Games played and eligibility Most clubs' rules require a minimum number of games before a player can win. Set the minimum on **Games played**, tick who played each round, and every screen that names a winner applies it. What it deliberately does not do is touch the count. A player short of the minimum still polled what they polled and is still shown at the position they finished, marked rather than moved or hidden; the rule decides only who is handed the trophy, and the tally, count night, the printed count and the public page all say so where it applies. Moving somebody down the table would be a lie about the votes. A minimum with no team sheets behind it is not applied at all — otherwise every player would sit on zero games and the award would have no eligible winner. The screens say so rather than quietly naming nobody. ### Rate limiting The doors anyone can walk up to are throttled: opening the casual voting page, casting a casual ballot, creating an award, and sending an invitation. Counted in Postgres rather than in memory, because the app is serverless and an in-process counter forgets on every cold start and never sees what the other instances saw. Two properties are deliberate. It stores a salted digest of the caller and never an address, so a copy of the `rate_limits` table says how often somebody acted and not who they were — there is no column anywhere in this app holding an IP. And it fails open: if the database is unwell, requests go through, because what this protects is spend rather than secrets and every path behind it is still covered by row-level security. The salt is `NUDGE_SECRET` where one is set. Without it the limiter still works, but somebody who knew your address could work out your bucket and spend your allowance for you, so it is one more reason to set that variable. If you are running this against an existing database, apply the migrations in `supabase/migrations/` in order: `0001_security_hardening.sql`, `0002_fixtures.sql`, `0003_public_pages.sql`, `0004_automatic_nudges.sql`, `0005_round_summaries.sql`, `0006_void_and_late.sql`, `0007_casual_voters.sql`, `0008_players_player.sql`, `0009_sibling_awards.sql`, `0010_rate_limits.sql`, then `0011_games_played.sql`, `0012_player_season.sql`, then `0013_handover.sql`, then `0014_season_archive.sql`, then `0015_weighted_voters.sql`, then `0016_service_metrics.sql`, then `0017_paper_slips.sql`, then `0018_live_count.sql`, then `0019_weekly_post.sql`, then `0020_erasure.sql`, then `0021_referral_prompt.sql`, then `0022_sponsor.sql`, then `0023_league.sql`, then `0024_honours.sql`, then `0025_rollover.sql`. All are safe to run twice, and each one goes in **before** the code that needs it is deployed. For backups, create the read-only role in `supabase/backup-role.sql` rather than handing the workflow a superuser password. ### The model, in one paragraph Row Level Security is the real permission system; the app's queries are conveniences. Ballots are readable only by their author; all ballot writes go through the `submit_ballot` database function, which revalidates every rule (voter on the panel, round open, correct number of distinct, active players). Admins read submission *existence* via `award_ballot_status`; vote *content* is only readable via `award_ballots`, which requires `can_view_tally` and writes an audit row per view. Points are derived from the award's format at count time, never stored, so the tally is always recountable. ## Stack Next.js (App Router) + React + TypeScript + Tailwind CSS v4; Supabase (Postgres + Auth, OAuth-only sign-in); Resend for invitations, reminders and receipts; Zod for input validation. No AI, no trackers, no other services — and the operator dashboard below does not make that sentence untrue: counting your own rows is not tracking. Nothing follows a person, nothing leaves for a third party, and none of the numbers can be tied back to anybody. British English in user-facing copy. ## Provenance Designed and bootstrapped on the Manx-Voice repo branch `claude/best-fairest-award-system-dlf0ls`, then extracted here. This repository is the app's home; connect Vercel to this repo.