Session 17: Audit response — checkout 401 fix, hero prop 404 fix, Slate parsing fix, ALL tab cascade isolation, cookie/nav/footer/autocomplete polish (1438 tests)
This commit is contained in:
+147
-1
@@ -4,7 +4,153 @@
|
||||
2026-06-11
|
||||
|
||||
## Current Phase
|
||||
SHIP BUILD v16.0 — Live hero prop + sport-scoped markets + launch polish (Session 16)
|
||||
SHIP BUILD v17.0 — Audit response: checkout 401, hero 404, Slate parsing, polish (Session 17)
|
||||
|
||||
## Session 17 (2026-06-12) — SHIPPED
|
||||
|
||||
A platform audit from a signed-in / signed-out walkthrough flagged
|
||||
12 issues. This session traced each to root cause and shipped fixes.
|
||||
Stripe is live with real products + webhooks; the symptoms audited
|
||||
were code-side, not Stripe-side.
|
||||
|
||||
### FIX 1 — Checkout 401 "User profile not found" [CRITICAL]
|
||||
|
||||
`src/middleware/auth.js` 401'd authenticated users whose `auth.users`
|
||||
row had no matching `public.users` profile. Signup writes to
|
||||
`auth.users` automatically; the application-side row never landed
|
||||
for SSO callbacks and legacy accounts that pre-dated the trigger.
|
||||
|
||||
Fix: when `.single()` returns PostgREST's `PGRST116` ("no rows"), the
|
||||
middleware now upserts a default `{id, email, tier:'free'}` row and
|
||||
re-reads. Idempotent under concurrent requests. Distinct 401 message
|
||||
(`User profile creation failed`) when the upsert itself fails — lets
|
||||
the operator separate missing-row recovery from real DB outages in
|
||||
logs. 9 tests cover happy path, missing row → upsert, message-only
|
||||
PGRST116 detection, upsert error, post-upsert empty re-read, and
|
||||
non-PGRST116 errors NOT triggering an upsert.
|
||||
|
||||
### FIX 2 — Hero prop 404 [CRITICAL]
|
||||
|
||||
`web/src/app/api/hero-prop/route.ts` shipped Session 16 with BOTH
|
||||
`dynamic = 'force-dynamic'` AND `revalidate = 900`. Next.js App
|
||||
Router silently 404s on this conflict. Removed `revalidate`. The
|
||||
15-minute cache still works via the existing `Cache-Control:
|
||||
s-maxage=900` response header.
|
||||
|
||||
### FIX 3 — WNBA games not surfacing [HIGH]
|
||||
|
||||
`Slate.tsx`'s `groupByGame` skipped every prop because
|
||||
`Number.isFinite(r.line)` failed on the actual Express response shape.
|
||||
Express's `groupProps` returns props with `lines: [{ book, line,
|
||||
over_odds, under_odds }]`, but the Slate expected a flat `line:
|
||||
number`. Every WNBA / NBA / MLB prop was filtered out.
|
||||
|
||||
Fix: added a `pickLine()` unwrapper that prefers the flat `r.line`
|
||||
when present (legacy callers + test fixtures) and otherwise picks the
|
||||
first numeric line out of `r.lines[]`. The Slate now correctly
|
||||
surfaces game cards for any sport with a populated `lines` array.
|
||||
|
||||
### FIX 4 — ALL tab error cascade [HIGH]
|
||||
|
||||
`Slate.tsx`'s cascade surfaced a top-level error whenever ANY single
|
||||
sport rejected — even when the other sports succeeded with empty
|
||||
data. Reworked to track per-sport failures separately and only show
|
||||
the top-level banner when EVERY attempted sport rejected. Failed-
|
||||
but-attempted sports get appended to the existing footer "endpoint
|
||||
not configured" line.
|
||||
|
||||
### FIX 5 — Cookie consent visibility [HIGH — Legal]
|
||||
|
||||
Root cause was visual overlap, not the component's logic:
|
||||
`BottomTabBar` and `CookieConsent` both `position: fixed; bottom: 0`,
|
||||
and BottomTabBar's 64px height visually obscured the banner.
|
||||
Resolved transitively by FIX 7 — anonymous visitors no longer see
|
||||
BottomTabBar, so the cookie banner has the bottom of the viewport to
|
||||
itself on first visit.
|
||||
|
||||
### FIX 6 — Scan autocomplete silent failure [MEDIUM]
|
||||
|
||||
The dropdown logic was correct — the silent failure happened when
|
||||
`/api/players/search` returned `{ players: [] }` (NBA service down,
|
||||
or no spelling match). Added a visible "no players matched" state
|
||||
when the search has run but returned empty, so users get feedback.
|
||||
|
||||
### FIX 7 — Mobile bottom nav auth gate [MEDIUM]
|
||||
|
||||
`BottomTabBar.tsx` rendered for all users on all eligible routes.
|
||||
Anonymous visitors on `/pricing` saw Home/Read/Parlay/Ledger/Profile —
|
||||
all auth-gated destinations that would 401 on click. Gated behind
|
||||
`useAuth()` with a `loading || !user` early-return. Also fixed FIX 5
|
||||
transitively.
|
||||
|
||||
### FIX 8 — Footer support email + stale copy [LOW]
|
||||
|
||||
Added `Support` link (mailto:support@vyndr.app) to the Legal column.
|
||||
Removed `(test mode while we onboard founders)` from Pricing.tsx —
|
||||
Stripe is live. Replaced with "First 100 users lock $14.99/mo
|
||||
Analyst for life."
|
||||
|
||||
### FIX 9 — Sentry zero events [MEDIUM]
|
||||
|
||||
Code wiring is correct in both backend (`initSentry()` + `setupExpress
|
||||
ErrorHandler` mounted) and frontend (`SentryInit` reads
|
||||
`NEXT_PUBLIC_SENTRY_DSN`). Audit found zero events because the DSN
|
||||
env vars aren't set in Coolify. Code-level no-op; documented as a
|
||||
Coolify env action.
|
||||
|
||||
### FIX 10 — Read counter visibility [LOW]
|
||||
|
||||
Quota pill appeared in the global Nav across every page. Restricted
|
||||
to `/scan` and `/dashboard` (the surfaces where it acts as quota
|
||||
context next to the scan action) via a pathname check in `Nav.tsx`.
|
||||
|
||||
### FIX 11 — Profile page [NO-OP]
|
||||
|
||||
Audit reported "no profile page exists." Verified: it does, at
|
||||
`web/src/app/profile/page.tsx` (196 lines, includes email, tier,
|
||||
subscription_status, subscription_end, founder_pricing,
|
||||
cancel_at_period_end). Audit looked at a stale build.
|
||||
|
||||
### FIX 12 — Tonight's slate landing preview [MEDIUM]
|
||||
|
||||
`web/src/components/TonightsSlate.tsx` (new) — game-count strip
|
||||
mounted between `Hero` and `LivePropsStrip`. Fetches the three
|
||||
sport-odds proxies in parallel, dedupes games by (away, home, time),
|
||||
renders "X NBA · Y WNBA · Z MLB games being graded right now." with
|
||||
a signup CTA. Hides itself when every sport returns zero.
|
||||
|
||||
### Tests added (Session 17)
|
||||
| Suite | Tests |
|
||||
|----------------------------------------|-------|
|
||||
| `tests/unit/requireAuth.test.js` | 9 |
|
||||
| **Session 17 total** | **9** |
|
||||
|
||||
### Quality gates
|
||||
- `npm test`: **1438 / 1438 passing** (1429 + 9 new), 111 suites, 0 regressions
|
||||
- `web/npm run build`: clean — `/api/hero-prop` now compiles to `ƒ`
|
||||
(was silently 404'd in production by the conflicting directives)
|
||||
- License audit: third-party deps remain permissive
|
||||
|
||||
### Honest verification status
|
||||
|
||||
Build + tests verified. I CANNOT verify the following on the live
|
||||
site from here — they need a deploy + re-audit smoke test:
|
||||
- Checkout 401 ↔ actual Supabase row creation under load
|
||||
- Hero prop endpoint returning JSON in production
|
||||
- WNBA Slate tab actually showing games
|
||||
- Cookie banner visible on first incognito load
|
||||
- Mobile bottom nav truly absent for signed-out visitors
|
||||
|
||||
### Coolify follow-ups (operator action)
|
||||
|
||||
1. Set `SENTRY_DSN` and `NEXT_PUBLIC_SENTRY_DSN` env vars to enable
|
||||
server-side and browser-side error capture. Currently unset →
|
||||
Sentry dashboard sees zero events even when 503/401 errors occur.
|
||||
2. The Session 16 sport-scoped markets fix is in code; the
|
||||
`NODE_OPTIONS=--require /app/data/patch.js` workaround can be
|
||||
dropped from the web service env after this deploy.
|
||||
|
||||
---
|
||||
|
||||
## Session 16 (2026-06-11) — SHIPPED
|
||||
|
||||
|
||||
@@ -570,3 +570,17 @@
|
||||
{"ts":"2026-06-11T22:05:06.923Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-11T22:05:06.923Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-11T22:05:07.154Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-12T00:25:39.490Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||
{"ts":"2026-06-12T00:25:39.576Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||
{"ts":"2026-06-12T00:25:39.688Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||
{"ts":"2026-06-12T00:25:39.817Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||
{"ts":"2026-06-12T00:25:39.817Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-12T00:25:39.817Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-12T00:25:39.907Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-12T00:44:18.285Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||
{"ts":"2026-06-12T00:44:18.894Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||
{"ts":"2026-06-12T00:44:18.894Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-12T00:44:18.894Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-12T00:44:18.956Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-12T00:44:19.233Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||
{"ts":"2026-06-12T00:44:19.324Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||
|
||||
+70
-7
@@ -1,5 +1,28 @@
|
||||
const { getSupabaseServiceClient } = require('../utils/supabase');
|
||||
|
||||
/**
|
||||
* Session 17 — `requireAuth` was 401'ing legitimate users whose Supabase
|
||||
* auth account had never had a matching row inserted into the
|
||||
* `public.users` table. Signup writes to `auth.users` automatically;
|
||||
* the corresponding row in `public.users` was supposed to land via a
|
||||
* trigger or a post-signup handler, but for SSO callbacks and any
|
||||
* legacy account that pre-dated the trigger the row was simply
|
||||
* missing. The audit found this: checkout 401'd as "User profile not
|
||||
* found" for valid sessions.
|
||||
*
|
||||
* The fix is an on-demand upsert. When the select misses, we insert a
|
||||
* default row (tier='free') keyed by the Supabase auth user's id +
|
||||
* email, then re-select. This is idempotent — concurrent requests
|
||||
* land on the upsert's existing-row branch and re-read the same row.
|
||||
*
|
||||
* If the insert ITSELF fails (genuine DB issue, not a missing row),
|
||||
* we 401 with a distinct message ('User profile creation failed') so
|
||||
* the operator can distinguish missing-row recovery from real DB
|
||||
* outages in logs.
|
||||
*/
|
||||
|
||||
const PROFILE_COLUMNS = 'id, email, tier, scan_count, scan_reset_date, founder_status, grace_period_until, stripe_customer_id';
|
||||
|
||||
async function requireAuth(req, res, next) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
@@ -14,17 +37,57 @@ async function requireAuth(req, res, next) {
|
||||
return res.status(401).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
|
||||
// Fetch user profile from our users table. Session 9 added
|
||||
// `grace_period_until` + `stripe_customer_id` to the select so the
|
||||
// grace-period middleware can read them off `req.user` without a
|
||||
// second round-trip. Both fields default to null when absent so
|
||||
// pre-Stripe users behave identically to before.
|
||||
const { data: profile, error: profileError } = await supabase
|
||||
// First read. PostgREST returns `error.code === 'PGRST116'` when
|
||||
// `.single()` gets zero rows — that's the signal we use to trigger
|
||||
// the on-demand upsert below. Other errors (network, permission)
|
||||
// bubble up as a real 401.
|
||||
let { data: profile, error: profileError } = await supabase
|
||||
.from('users')
|
||||
.select('id, email, tier, scan_count, scan_reset_date, founder_status, grace_period_until, stripe_customer_id')
|
||||
.select(PROFILE_COLUMNS)
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
// Session 17 — if no row exists, create one on the fly. The
|
||||
// `auth.users` row already exists (we just resolved a token against
|
||||
// it), so we're not creating an identity — we're filling in the
|
||||
// app-side row that should have been created at signup time but
|
||||
// wasn't (SSO callbacks, legacy accounts pre-trigger, etc.).
|
||||
// Detect PostgREST's "single requested, zero rows returned" via
|
||||
// either the canonical error code (preferred) or the message
|
||||
// pattern Supabase emits when the code field is stripped by an
|
||||
// older client. Canonical message:
|
||||
// "JSON object requested, multiple (or no) rows returned"
|
||||
const isMissingRow = profileError && (
|
||||
profileError.code === 'PGRST116'
|
||||
|| /\(or no\) rows/i.test(profileError.message || '')
|
||||
|| /^no rows$/i.test(profileError.message || '')
|
||||
);
|
||||
if (!profile && isMissingRow) {
|
||||
const defaultRow = {
|
||||
id: user.id,
|
||||
email: user.email || null,
|
||||
tier: 'free',
|
||||
scan_count: 0,
|
||||
scan_reset_date: new Date().toISOString().slice(0, 10),
|
||||
founder_status: false,
|
||||
};
|
||||
const { error: upsertErr } = await supabase
|
||||
.from('users')
|
||||
.upsert(defaultRow, { onConflict: 'id' });
|
||||
if (upsertErr) {
|
||||
console.warn('[requireAuth] users-row upsert failed:', upsertErr.message);
|
||||
return res.status(401).json({ error: 'User profile creation failed' });
|
||||
}
|
||||
// Re-select. Use the same .single() shape so test mocks keep working.
|
||||
const reread = await supabase
|
||||
.from('users')
|
||||
.select(PROFILE_COLUMNS)
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
profile = reread.data;
|
||||
profileError = reread.error;
|
||||
}
|
||||
|
||||
if (profileError || !profile) {
|
||||
return res.status(401).json({ error: 'User profile not found' });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
// Session 17 — requireAuth's on-demand users-row upsert path.
|
||||
//
|
||||
// The audit found that authenticated users with valid Supabase auth
|
||||
// tokens were getting 401 "User profile not found" on /api/stripe/
|
||||
// checkout because their corresponding row in public.users had never
|
||||
// been inserted. The fix: when `.single()` on the users select
|
||||
// returns PGRST116 ("no rows"), upsert a default row and re-read.
|
||||
//
|
||||
// These tests build a chainable Supabase fake that lets us steer the
|
||||
// select to PGRST116 / success and assert which path requireAuth
|
||||
// takes.
|
||||
|
||||
const { requireAuth } = require('../../src/middleware/auth');
|
||||
|
||||
function makeFake({ getUserResult, selectResult, selectAfterUpsertResult, upsertError = null }) {
|
||||
// The middleware calls `supabase.from('users')` multiple times in
|
||||
// the missing-profile recovery path (initial select, then upsert,
|
||||
// then reread). State that distinguishes "first select" from
|
||||
// "select-after-upsert" has to live on the supabase fake, not on
|
||||
// a fresh-per-call `from()` proxy. We track `hasUpserted` and
|
||||
// route `.single()` to the appropriate result.
|
||||
const upserts = [];
|
||||
let hasUpserted = false;
|
||||
|
||||
const supabase = {
|
||||
auth: { getUser: () => Promise.resolve(getUserResult) },
|
||||
from() {
|
||||
const proxy = {
|
||||
select() { return proxy; },
|
||||
eq() { return proxy; },
|
||||
single() {
|
||||
return Promise.resolve(hasUpserted ? selectAfterUpsertResult : selectResult);
|
||||
},
|
||||
upsert(row, opts) {
|
||||
upserts.push({ row, opts });
|
||||
if (!upsertError) hasUpserted = true;
|
||||
return Promise.resolve({ data: null, error: upsertError });
|
||||
},
|
||||
};
|
||||
return proxy;
|
||||
},
|
||||
_upserts: upserts,
|
||||
};
|
||||
return supabase;
|
||||
}
|
||||
|
||||
let mockSupabaseFake;
|
||||
jest.mock('../../src/utils/supabase', () => ({
|
||||
getSupabaseServiceClient: () => mockSupabaseFake,
|
||||
}));
|
||||
|
||||
function runMiddleware(req) {
|
||||
return new Promise((resolve) => {
|
||||
const res = {
|
||||
_status: null,
|
||||
_body: null,
|
||||
status(code) { this._status = code; return this; },
|
||||
json(body) { this._body = body; resolve({ status: this._status, body, fellThrough: false }); return this; },
|
||||
};
|
||||
requireAuth(req, res, () => resolve({ status: 200, body: null, fellThrough: true, req }));
|
||||
});
|
||||
}
|
||||
|
||||
const VALID_USER = { id: 'user-1', email: 'a@b.com' };
|
||||
const EXISTING_PROFILE = {
|
||||
id: 'user-1', email: 'a@b.com', tier: 'free', scan_count: 0,
|
||||
scan_reset_date: '2026-06-12', founder_status: false,
|
||||
grace_period_until: null, stripe_customer_id: null,
|
||||
};
|
||||
|
||||
describe('requireAuth — pre-Session 17 behavior preserved', () => {
|
||||
test('valid token + existing profile → next() called with req.user set', async () => {
|
||||
mockSupabaseFake = makeFake({
|
||||
getUserResult: { data: { user: VALID_USER }, error: null },
|
||||
selectResult: { data: EXISTING_PROFILE, error: null },
|
||||
});
|
||||
const req = { headers: { authorization: 'Bearer good-token' } };
|
||||
const out = await runMiddleware(req);
|
||||
expect(out.fellThrough).toBe(true);
|
||||
expect(req.user.id).toBe('user-1');
|
||||
expect(req.user.tier).toBe('free');
|
||||
expect(mockSupabaseFake._upserts).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('missing Authorization header → 401', async () => {
|
||||
mockSupabaseFake = makeFake({
|
||||
getUserResult: { data: { user: null }, error: { message: 'no' } },
|
||||
selectResult: { data: null, error: null },
|
||||
});
|
||||
const out = await runMiddleware({ headers: {} });
|
||||
expect(out.status).toBe(401);
|
||||
expect(out.body.error).toMatch(/Authentication required/);
|
||||
});
|
||||
|
||||
test('non-Bearer scheme → 401', async () => {
|
||||
mockSupabaseFake = makeFake({
|
||||
getUserResult: { data: { user: null }, error: { message: 'no' } },
|
||||
selectResult: { data: null, error: null },
|
||||
});
|
||||
const out = await runMiddleware({ headers: { authorization: 'Basic xyz' } });
|
||||
expect(out.status).toBe(401);
|
||||
});
|
||||
|
||||
test('Supabase getUser error → 401 invalid token', async () => {
|
||||
mockSupabaseFake = makeFake({
|
||||
getUserResult: { data: { user: null }, error: { message: 'token expired' } },
|
||||
selectResult: { data: null, error: null },
|
||||
});
|
||||
const out = await runMiddleware({ headers: { authorization: 'Bearer bad' } });
|
||||
expect(out.status).toBe(401);
|
||||
expect(out.body.error).toMatch(/Invalid or expired/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requireAuth — Session 17 missing-profile recovery', () => {
|
||||
test('missing users row (PGRST116) → upsert default + retry succeeds', async () => {
|
||||
mockSupabaseFake = makeFake({
|
||||
getUserResult: { data: { user: VALID_USER }, error: null },
|
||||
selectResult: { data: null, error: { code: 'PGRST116', message: 'no rows' } },
|
||||
selectAfterUpsertResult: { data: EXISTING_PROFILE, error: null },
|
||||
});
|
||||
const req = { headers: { authorization: 'Bearer good' } };
|
||||
const out = await runMiddleware(req);
|
||||
expect(out.fellThrough).toBe(true);
|
||||
expect(req.user.id).toBe('user-1');
|
||||
expect(mockSupabaseFake._upserts).toHaveLength(1);
|
||||
const u = mockSupabaseFake._upserts[0];
|
||||
expect(u.row.id).toBe('user-1');
|
||||
expect(u.row.email).toBe('a@b.com');
|
||||
expect(u.row.tier).toBe('free');
|
||||
expect(u.row.scan_count).toBe(0);
|
||||
expect(u.row.founder_status).toBe(false);
|
||||
expect(u.opts.onConflict).toBe('id');
|
||||
});
|
||||
|
||||
test('PGRST116 detected by message text (no code field)', async () => {
|
||||
// Some Supabase client versions surface the error sans code field.
|
||||
mockSupabaseFake = makeFake({
|
||||
getUserResult: { data: { user: VALID_USER }, error: null },
|
||||
selectResult: { data: null, error: { message: 'JSON object requested, multiple (or no) rows returned' } },
|
||||
selectAfterUpsertResult: { data: EXISTING_PROFILE, error: null },
|
||||
});
|
||||
const out = await runMiddleware({ headers: { authorization: 'Bearer good' } });
|
||||
expect(out.fellThrough).toBe(true);
|
||||
expect(mockSupabaseFake._upserts).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('upsert itself fails → 401 with distinct message', async () => {
|
||||
mockSupabaseFake = makeFake({
|
||||
getUserResult: { data: { user: VALID_USER }, error: null },
|
||||
selectResult: { data: null, error: { code: 'PGRST116', message: 'no rows' } },
|
||||
selectAfterUpsertResult: { data: EXISTING_PROFILE, error: null },
|
||||
upsertError: { message: 'check_violation' },
|
||||
});
|
||||
const out = await runMiddleware({ headers: { authorization: 'Bearer good' } });
|
||||
expect(out.status).toBe(401);
|
||||
expect(out.body.error).toMatch(/User profile creation failed/);
|
||||
});
|
||||
|
||||
test('post-upsert reread still empty → 401 User profile not found', async () => {
|
||||
mockSupabaseFake = makeFake({
|
||||
getUserResult: { data: { user: VALID_USER }, error: null },
|
||||
selectResult: { data: null, error: { code: 'PGRST116', message: 'no rows' } },
|
||||
selectAfterUpsertResult: { data: null, error: null },
|
||||
});
|
||||
const out = await runMiddleware({ headers: { authorization: 'Bearer good' } });
|
||||
expect(out.status).toBe(401);
|
||||
expect(out.body.error).toMatch(/User profile not found/);
|
||||
});
|
||||
|
||||
test('non-PGRST116 select error → 401 immediately (no upsert)', async () => {
|
||||
mockSupabaseFake = makeFake({
|
||||
getUserResult: { data: { user: VALID_USER }, error: null },
|
||||
selectResult: { data: null, error: { code: '42501', message: 'permission denied' } },
|
||||
selectAfterUpsertResult: { data: EXISTING_PROFILE, error: null },
|
||||
});
|
||||
const out = await runMiddleware({ headers: { authorization: 'Bearer good' } });
|
||||
expect(out.status).toBe(401);
|
||||
// Critically, no upsert attempt — we don't recover from a real DB error.
|
||||
expect(mockSupabaseFake._upserts).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -1,11 +1,16 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
// Session 17 — Next.js App Router refuses to compile a route that
|
||||
// exports BOTH `dynamic = 'force-dynamic'` AND `revalidate`. The two
|
||||
// modes are mutually exclusive: force-dynamic skips static
|
||||
// generation; revalidate gates ISR. Session 16 shipped both, which
|
||||
// silently broke the route at build time (production audit found a
|
||||
// hard 404 on /api/hero-prop).
|
||||
//
|
||||
// We keep `force-dynamic` (we want the random-prop pick to vary per
|
||||
// request) and emit the 15-minute cache via the response's
|
||||
// Cache-Control header — Coolify's reverse proxy honors it.
|
||||
export const dynamic = 'force-dynamic';
|
||||
// Cache the response for 15 minutes (server-side) so cold visitors
|
||||
// don't trigger a fresh grade on every page load. The cache header
|
||||
// is what most CDNs / Coolify reverse proxies honor; Next.js itself
|
||||
// already opts into dynamic rendering via `dynamic = 'force-dynamic'`.
|
||||
export const revalidate = 900;
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
const HERO_FETCH_TIMEOUT_MS = 6000;
|
||||
|
||||
@@ -4,6 +4,11 @@ import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import Hero from '@/components/Hero';
|
||||
// Session 17 — game-count strip mounted between the hero and the
|
||||
// existing LivePropsStrip. Shows "X NBA · Y WNBA · Z MLB games
|
||||
// being graded right now" with a signup CTA. Hides itself when
|
||||
// every sport returns zero (off-hours / upstream outages).
|
||||
import TonightsSlate from '@/components/TonightsSlate';
|
||||
import LivePropsStrip from '@/components/LivePropsStrip';
|
||||
import Features from '@/components/Features';
|
||||
import HowItWorks from '@/components/HowItWorks';
|
||||
@@ -34,6 +39,7 @@ export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<Hero />
|
||||
<TonightsSlate />
|
||||
<LivePropsStrip />
|
||||
<Features />
|
||||
<HowItWorks />
|
||||
|
||||
@@ -340,6 +340,22 @@ export default function ScanPage() {
|
||||
}}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{/* Session 17 — show "no results" when the search ran but
|
||||
returned nothing. Audit reported a silent dropdown failure;
|
||||
this gives the user feedback when the upstream player
|
||||
service is offline or the spelling didn't match. */}
|
||||
{playerQuery.trim().length >= 2 && playerSuggestions.length === 0 && playerQuery !== selectedPlayer && (
|
||||
<div
|
||||
className="surface-elevated"
|
||||
style={{
|
||||
position: 'absolute', top: '100%', left: 0, right: 0,
|
||||
marginTop: 4, zIndex: 20, padding: 12,
|
||||
fontSize: 12, color: 'var(--text-tertiary)',
|
||||
}}
|
||||
>
|
||||
No {sport} players matched “{playerQuery}”. Check spelling or try a partial name.
|
||||
</div>
|
||||
)}
|
||||
{playerSuggestions.length > 0 && playerQuery !== selectedPlayer && (
|
||||
<div
|
||||
className="surface-elevated"
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useParlay } from '@/contexts/ParlayContext';
|
||||
// Session 17 — gate the mobile bottom nav behind authentication.
|
||||
// Anonymous visitors landing on /pricing previously saw the full
|
||||
// Home/Read/Parlay/Ledger/Profile bar before signing up — confusing
|
||||
// surface area, and it visually overlapped the cookie-consent banner
|
||||
// (both pinned to bottom: 0).
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
const TABS = [
|
||||
{ id: 'home', label: 'Home', href: '/dashboard', icon: HomeIcon },
|
||||
@@ -17,8 +23,15 @@ const HIDE_ON = new Set(['/login', '/signup', '/auth/callback', '/']);
|
||||
export default function BottomTabBar() {
|
||||
const pathname = usePathname() || '/';
|
||||
const { open, legCount } = useParlay();
|
||||
// Session 17 — bar hidden for anonymous visitors. The bar's
|
||||
// destinations (Ledger, Profile, Parlay) all require auth anyway;
|
||||
// pre-auth visitors just see broken links. Authentication state
|
||||
// resolves async; while `loading` is true we err on the side of
|
||||
// hiding the bar to avoid a flash for signed-out users.
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
if (HIDE_ON.has(pathname)) return null;
|
||||
if (loading || !user) return null;
|
||||
|
||||
return (
|
||||
<nav
|
||||
|
||||
@@ -10,6 +10,9 @@ const LEGAL_LINKS = [
|
||||
{ label: 'Terms', href: '/terms' },
|
||||
{ label: 'Privacy', href: '/privacy' },
|
||||
{ label: 'Responsible Gambling', href: '/responsible-gambling' },
|
||||
// Session 17 — support contact in the legal column. Audit found
|
||||
// the platform had no support surface at all.
|
||||
{ label: 'Support', href: 'mailto:support@vyndr.app' },
|
||||
];
|
||||
|
||||
import Wordmark from '@/components/Wordmark';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import Wordmark from '@/components/Wordmark';
|
||||
import NotificationBell from '@/components/NotificationBell';
|
||||
@@ -10,6 +11,13 @@ import { useT } from '@/contexts/LocaleContext';
|
||||
export default function Nav() {
|
||||
const { user, tier, scansRemaining, signOut } = useAuth();
|
||||
const t = useT();
|
||||
const pathname = usePathname() || '';
|
||||
// Session 17 — read counter sits in the global nav, but the audit
|
||||
// flagged it as noise outside the scan flow. Restrict it to /scan
|
||||
// and /dashboard (where the slate-scan lives) so it acts as a
|
||||
// quota indicator next to the action it gates, not a chrome pill.
|
||||
const showReadCounter = pathname === '/scan' || pathname.startsWith('/scan/')
|
||||
|| pathname === '/dashboard' || pathname.startsWith('/dashboard/');
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
@@ -104,7 +112,7 @@ export default function Nav() {
|
||||
|
||||
{user ? (
|
||||
<div style={{ position: 'relative', display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||
{scansRemaining != null && tier === 'free' && (
|
||||
{showReadCounter && scansRemaining != null && tier === 'free' && (
|
||||
<span
|
||||
className="mono"
|
||||
style={{
|
||||
|
||||
@@ -322,7 +322,7 @@ export default function Pricing() {
|
||||
</div>
|
||||
|
||||
<p style={{ textAlign: 'center', fontSize: 13, color: 'var(--text-tertiary)', marginTop: 32 }}>
|
||||
Cancel anytime. No contracts. Card / Apple Pay / Google Pay — payments processed by Stripe (test mode while we onboard founders).
|
||||
Cancel anytime. No contracts. Card / Apple Pay / Google Pay — payments processed by Stripe. First 100 users lock $14.99/mo Analyst for life.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -60,12 +60,26 @@ const FETCH_URLS: Record<Exclude<SlateTab, 'all'>, string[] | null> = {
|
||||
soccer: ['/api/odds/soccer/wc'],
|
||||
};
|
||||
|
||||
// Session 17 — Express `/api/odds/{sport}` returns props in the
|
||||
// GROUPED shape produced by `src/routes/odds.js#groupProps`:
|
||||
// { player, stat_type, home_team, away_team, game_time,
|
||||
// lines: [{ book, line, over_odds, under_odds }] }
|
||||
// not a flat `line`/`direction`/`book` per prop. Pre-Session 17 the
|
||||
// Slate assumed flat — every prop got filtered out by the
|
||||
// `Number.isFinite(r.line)` check, which is why WNBA (the only
|
||||
// active sport at audit time) showed "No games published yet."
|
||||
//
|
||||
// RawProp now mirrors both shapes; the unwrapper below picks the
|
||||
// best available line out of the `lines[]` array when present.
|
||||
interface RawProp {
|
||||
player?: string;
|
||||
stat_type?: string;
|
||||
// Flat-shape fields (pre-Session 17 contract — still tolerated)
|
||||
line?: number;
|
||||
direction?: 'over' | 'under';
|
||||
book?: string;
|
||||
// Grouped-shape fields (actual Express response since Session 7+)
|
||||
lines?: Array<{ book?: string; line?: number; over_odds?: number; under_odds?: number }>;
|
||||
game_time?: string;
|
||||
home_team?: string;
|
||||
away_team?: string;
|
||||
@@ -77,6 +91,35 @@ interface OddsResponse {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Pick the most useful single line out of a grouped prop. Preference:
|
||||
// 1. A line marked `direction: over` (matches the default scan flow)
|
||||
// 2. The first numeric line in the array
|
||||
// 3. The flat-shape `line` field if present (legacy callers)
|
||||
function pickLine(r: RawProp): { line: number; direction: 'over' | 'under'; book: string } | null {
|
||||
// Flat shape wins when present — preserves the older test fixtures.
|
||||
if (Number.isFinite(r.line)) {
|
||||
return {
|
||||
line: r.line as number,
|
||||
direction: (r.direction as 'over' | 'under') || 'over',
|
||||
book: r.book || 'draftkings',
|
||||
};
|
||||
}
|
||||
if (Array.isArray(r.lines)) {
|
||||
const first = r.lines.find((l) => Number.isFinite(l.line));
|
||||
if (first && Number.isFinite(first.line)) {
|
||||
// The grouped response doesn't carry a per-line direction —
|
||||
// each line has both over/under odds. Default to `over` since
|
||||
// that's the default scan direction.
|
||||
return {
|
||||
line: first.line as number,
|
||||
direction: 'over',
|
||||
book: first.book || 'draftkings',
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface SlateGame {
|
||||
sport: SlateSport;
|
||||
homeTeam: string;
|
||||
@@ -90,7 +133,10 @@ interface SlateGame {
|
||||
function groupByGame(rawProps: RawProp[], sport: SlateSport): SlateGame[] {
|
||||
const games = new Map<string, SlateGame>();
|
||||
for (const r of rawProps) {
|
||||
if (!r.player || !r.stat_type || r.line == null) continue;
|
||||
if (!r.player || !r.stat_type) continue;
|
||||
// Session 17 — unwrap the grouped `lines[]` shape from Express.
|
||||
const lineInfo = pickLine(r);
|
||||
if (!lineInfo) continue;
|
||||
const home = r.home_team || '?';
|
||||
const away = r.away_team || '?';
|
||||
const time = r.game_time || '';
|
||||
@@ -107,9 +153,9 @@ function groupByGame(rawProps: RawProp[], sport: SlateSport): SlateGame[] {
|
||||
games.get(key)!.props.push({
|
||||
player: r.player,
|
||||
stat_type: r.stat_type,
|
||||
line: Number(r.line),
|
||||
direction: (r.direction as PropRowProp['direction']) || 'over',
|
||||
book: r.book,
|
||||
line: lineInfo.line,
|
||||
direction: lineInfo.direction,
|
||||
book: lineInfo.book,
|
||||
});
|
||||
}
|
||||
// Sort each game's props by player + stat for stable rendering.
|
||||
@@ -183,24 +229,45 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
|
||||
if (!r.ok) throw new Error(body?.error || `HTTP ${r.status}`);
|
||||
return { sport, body };
|
||||
})
|
||||
.catch((err) => {
|
||||
// Re-throw so allSettled catches it, but attach the
|
||||
// sport so the per-sport error-tracking below can
|
||||
// surface "Soccer odds unavailable" without blanking
|
||||
// the rest of the slate.
|
||||
const e = err instanceof Error ? err : new Error(String(err));
|
||||
(e as Error & { _vyndrSport?: SlateSport })._vyndrSport = sport;
|
||||
throw e;
|
||||
})
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const allGames: SlateGame[] = [];
|
||||
let firstError: string | null = null;
|
||||
const failedSports: SlateSport[] = [];
|
||||
const sportsAttempted = new Set<SlateSport>(sportsToFetch.map((s) => s.sport));
|
||||
const sportsThatSucceeded = new Set<SlateSport>();
|
||||
for (const r of results) {
|
||||
if (r.status === 'fulfilled') {
|
||||
sportsThatSucceeded.add(r.value.sport);
|
||||
const grouped = groupByGame(r.value.body.props || [], r.value.sport);
|
||||
allGames.push(...grouped);
|
||||
} else if (!firstError) {
|
||||
firstError = r.reason instanceof Error ? r.reason.message : 'Odds fetch failed';
|
||||
} else {
|
||||
const failed = (r.reason as Error & { _vyndrSport?: SlateSport })._vyndrSport;
|
||||
if (failed && !failedSports.includes(failed)) failedSports.push(failed);
|
||||
}
|
||||
}
|
||||
|
||||
setGames(allGames);
|
||||
setUnsupportedSports(unsupported);
|
||||
if (allGames.length === 0 && firstError) setFetchError(firstError);
|
||||
setUnsupportedSports([...unsupported, ...failedSports.filter((s) => !sportsThatSucceeded.has(s))]);
|
||||
|
||||
// Session 17 — only surface a top-level error when EVERY sport
|
||||
// attempted in this tab failed. Partial successes (NBA ok,
|
||||
// soccer 503) silently drop the failed sport's row and surface
|
||||
// it via the existing "endpoint not configured" footer note.
|
||||
if (sportsAttempted.size > 0 && sportsThatSucceeded.size === 0) {
|
||||
const firstError = results.find((r) => r.status === 'rejected') as PromiseRejectedResult | undefined;
|
||||
setFetchError(firstError ? (firstError.reason as Error).message : 'Odds fetch failed');
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Tonight's Slate — anonymous-landing game-count strip (Session 17).
|
||||
*
|
||||
* Client component (the landing page is itself a client component
|
||||
* because it uses `useAuth` for the post-signin redirect). Fetches
|
||||
* game counts for NBA / WNBA / MLB through the existing Next.js
|
||||
* proxies on mount, dedupes by (away, home, time) triple, renders
|
||||
* "X NBA · Y WNBA · Z MLB games being graded right now." with a
|
||||
* signup CTA.
|
||||
*
|
||||
* Hides itself entirely when every sport returns zero — better
|
||||
* silent than misleading. The counts come from the same proxies The
|
||||
* Slate uses, so post-signup the user sees the same numbers.
|
||||
*
|
||||
* Co-exists with `LivePropsStrip` (which shows a few actual graded
|
||||
* props). This component is the executive-summary line above it.
|
||||
*/
|
||||
|
||||
interface OddsResponse {
|
||||
sport?: string;
|
||||
props?: Array<{ home_team?: string; away_team?: string; game_time?: string }>;
|
||||
}
|
||||
|
||||
const FETCH_TIMEOUT_MS = 4000;
|
||||
|
||||
async function countGamesForSport(sport: string): Promise<number> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(`/api/odds/${sport}`, {
|
||||
signal: controller.signal,
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!res.ok) return 0;
|
||||
const body = (await res.json().catch(() => null)) as OddsResponse | null;
|
||||
if (!body || !Array.isArray(body.props)) return 0;
|
||||
const games = new Set<string>();
|
||||
for (const p of body.props) {
|
||||
if (!p.home_team || !p.away_team) continue;
|
||||
games.add(`${p.away_team}__${p.home_team}__${p.game_time || ''}`);
|
||||
}
|
||||
return games.size;
|
||||
} catch {
|
||||
return 0;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export default function TonightsSlate() {
|
||||
const [counts, setCounts] = useState<{ nba: number; wnba: number; mlb: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
(async () => {
|
||||
const [nba, wnba, mlb] = await Promise.all([
|
||||
countGamesForSport('nba'),
|
||||
countGamesForSport('wnba'),
|
||||
countGamesForSport('mlb'),
|
||||
]);
|
||||
if (alive) setCounts({ nba, wnba, mlb });
|
||||
})();
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
|
||||
if (!counts) return null;
|
||||
|
||||
const total = counts.nba + counts.wnba + counts.mlb;
|
||||
if (total === 0) return null;
|
||||
|
||||
const segments: string[] = [];
|
||||
if (counts.nba > 0) segments.push(`${counts.nba} NBA`);
|
||||
if (counts.wnba > 0) segments.push(`${counts.wnba} WNBA`);
|
||||
if (counts.mlb > 0) segments.push(`${counts.mlb} MLB`);
|
||||
|
||||
return (
|
||||
<section
|
||||
style={{
|
||||
padding: '20px 24px',
|
||||
borderTop: '1px solid var(--border)',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
background: 'var(--bg-surface)',
|
||||
}}
|
||||
aria-label="Tonight's slate summary"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: 1100,
|
||||
margin: '0 auto',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 16,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<p
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: 'var(--text-tertiary)',
|
||||
letterSpacing: '0.12em',
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
Tonight's Slate
|
||||
</p>
|
||||
<p style={{ fontSize: 17, fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||
{segments.join(' · ')} games being graded right now.
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="/signup"
|
||||
className="btn-primary"
|
||||
style={{
|
||||
padding: '10px 18px',
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
Sign up to grade these →
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user