Repository navigation
fix: mid-tournament audit fixes — seating integrity, live-state correctness, abuse/ops hardening - #136
Merged
Conversation
added 9 commits
June 30, 2026 17:46
…tracking Adds an admin-only analytics dashboard (/admin/analytics) tracking user engagement across all pools, plus the event-logging layer that feeds it. - AnalyticsEvent model + enum (audit-style plain-id FKs, no relations/cascade, indexed by day/type/user) — additive and non-breaking, mirroring the PrizeAward/Feedback precedent. Migration + regenerated client included. - logEvent: best-effort write that never throws or breaks the triggering action (mirrors notifyPool). Instrumented at sign-in/up, pool create, pool join (code + invite, new members only), bracket submit, chat message, reaction. - Pure, unit-tested rollups (UTC day bucketing, DAU/WAU/MAU, type breakdown, top pools, stickiness) split from the DB query layer. - Dashboard gated by getTournamentAdmin (fail-closed), custom Tailwind bar charts (no new deps), linked from the admin page. MAU/stickiness fetch window covers the full rolling 30d boundary so late-day actives aren't clipped.
… the bracket slot The answer key stores third-place advancers as an ordered list whose order drives R32 seating (resolveR32Slots backtracks in list order). A standings re-save with the same eight teams in a different order silently re-seated the live bracket — orphaning recorded winners, breaking pair-map fixture mapping, and corrupting every seed-derived surface (this happened in prod mid-R32). - setGroupStandings now preserves the stored order when the submitted set is unchanged (mergeThirdAdvance), and rejects any seating that contradicts a recorded knockout winner (findKnockoutSeatingConflict). - setKnockoutResultLocked now orients API-reported scores/pens to the bracket slot (orientScoresToSlot) before mirroring, matching the live-row path — the FINAL row could previously flip against winnerCode whenever the provider's home differed from the bracket's home (exactly the pens ties in play now).
…oards - liveLeaders now skips matches the answer key already decided, so the projected-points layer can't pay a match twice during the whistle-to-feed reconcile window (the official total already contains it). - getLiveMatches filters out answer-key-decided matches, and statusOf treats a known winner as FINAL over a Result row stuck at LIVE — a missed poll could previously pin a finished match on the live card forever.
- resolveGamePhase gains the COMPLETE transition (last kickoff + 6h settle): MD3 was reported LOCKED_LIVE forever, keeping a 'Live now' slide on the home carousel five days after the game ended; knockout/full-bracket got the same treatment for after the final. - isEarlyBuilderOpen now closes at the R32 kickoff instead of staying true for the rest of the tournament. - md3DateRange (and gameStateLine's day labels) are framed in DISPLAY_TZ: the UTC framing printed 'June 24–28' beside catalog copy saying '(June 24–27)'.
…a, cron - Feedback keyed its anonymous rate limit on the leftmost X-Forwarded-For hop (client-spoofable → unlimited 3MB screenshot submissions). Shared clientIpFromForwardedFor now takes the edge-appended rightmost hop; the auth route reuses it. - Giphy proxy gains a per-user rate limit (shared API key quota). - claimEntriesForUser takes the same per-pool advisory lock as joinPool/acceptInvite, closing the count→create race past the FREE member cap on the primary enrollment path. - pollOddsExtras gains a DB-backed 20h throttle (extrasPollDue): the cron's in-memory daily bucket resets on every restart/redeploy, re-spending 4+ metered credits of a 500/mo quota per restart. - cron.mjs fetches time out at 30s so a draining web service can't stall the sequential job loop (and live-score polling) for ~5 minutes. - CRON_SECRET requires 16+ chars (guards result-mutating routes; prod is 48); CI/dev example values updated to match.
- getKnockoutChallengeProfile now requires board membership (which carries the verified-email anti-Sybil gate): it previously rendered gated entries' full picks with a fabricated rank, and its pick-share denominators now match the public board population. - MD3 form/breakdown pin kickoff day/time labels to DISPLAY_TZ (the form was a hydration mismatch, the breakdown rendered UTC times and split late fixtures across wrong day headers). - Chat pins its own scroll box instead of scrollIntoView, which scrolled every ancestor and yanked live match pages down to the embedded chat on mount. - R32Countdown seeds its passed state from the clock so a past kickoff no longer flashes a stale countdown banner on first paint.
… knockout pick'em Main moved substantially since this branch was cut: - #124 seats R32 third-place slots via FIFA's official Annex C table, fixing the seating root cause properly (order-independent for a complete set). The branch's mergeThirdAdvance order-preservation is therefore redundant and is dropped; findKnockoutSeatingConflict is kept and reframed — it still guards the remaining hazard (a standings edit that re-seats a match whose result is already recorded), with its test rewritten to a group-placement scenario. - #125 repurposes MATCH_DAY_3_PICKEM into the knockout-long daily pick'em, so the COMPLETE phase now keys off the Final kickoff + settle for all three formats; hero-slide and featuredGame expectations follow main's new semantics, plus a new post-final empty-carousel test. The Eastern-timezone date framing is re-applied to main's spanLabel/koPickemDateRange. - #130's chat scroll fix supersedes this branch's — took main's Chat.tsx. - The FINAL score/pens orientation fix, stale-LIVE guards, projection double-count fix, rate-limit/claim/odds/cron hardening, and MD3 timezone pins all still apply and carry over unchanged.
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.
Context
Full-codebase audit (2026-07-02, mid-R32), reconciled with main after #124/#125/#130 landed.
Correction from the original description: the audit's headline seating finding was real in the audited checkout, but main's #124 had already root-fixed it via FIFA's Annex C placement table before this PR — my "confirmed in prod" verification ran the audited branch's old resolver against prod data, so it overstated live impact. The thirdAdvance data reorder I applied to prod was a harmless no-op under Annex C (same set → same table row), and the recomputes were idempotent. This PR now keeps only the guards that still add value on top of #124.
Changes (post-reconciliation)
Seating & result integrity
setGroupStandingsrejects any edit whose re-seated bracket contradicts a recorded knockout winner (findKnockoutSeatingConflict) — Annex C fixes third-place seating, but a group-placement or advancing-set correction after a result is recorded can still orphan it and break team-pair fixture mapping. (The order-preservation helper from the first draft was dropped as redundant under Annex C.)orientScoresToSlot) — the live path orients, the FINAL path didn't, so the final scoreline/shootout could flip againstwinnerCodewhenever the provider's home differed from the bracket's home.Live-state correctness
resolveGamePhasegains COMPLETE (Final kickoff + 6h settle) for all three formats — every phase previously terminated in a forever-"live" state; the hero carousel now empties to the static hero after the tournament.isEarlyBuilderOpencloses at the R32 kickoff.md3DateRange/koPickemDateRange) framed inDISPLAY_TZ— UTC framing printed "June 24–28" for the June 24–27 group slate.Abuse & ops hardening
claimEntriesForUsertakes the per-pool advisory lock (closes the FREE-cap count→create race on the primary enrollment path).cron.mjsfetches time out at 30s (a hung draining web service stalled the sequential job loop ~5 min).CRON_SECRETmin length 16 (prod is 48; CI + CLAUDE.md dev values updated —.env.examplestill sayschange-me, worth a manual bump).Challenge & UI
DISPLAY_TZ(hydration mismatch / UTC day headers); R32 countdown no longer flashes when past. (Chat scroll: superseded by fix(ui): chat behavior + a11y (PR 5/6 of UI review) #130, took main's.)Test plan
tsc --noEmit,eslint,vitest run— 92 files, 713 tests green;next buildcompiles all routespoll-odds-extraslogs forskipped: trueon cron restartsDeliberately not in this PR (audit findings needing product/schema decisions)
Stripe out-of-order event guard; analytics retention + SQL aggregation; Prisma pool sizing; seed-on-deploy reverting admin edits; MD3 prize eligibility contradiction + goal-delta tiebreak fairness (note: #125 made the pick'em prize-free, which may moot these); push-token ownership check; chat attachment origin allowlist; APNs retry.