Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions src/app/actions/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { notifyNewMessage } from '@/lib/notifications';
import { sendPushNotification } from '@/lib/notifications/push';
import { createAdminClient } from '@/lib/supabase/admin';
import { logServerError } from '@/lib/server-error-logger';
import { maybeCaptureRlsDenial } from '@/lib/admin/rls-denial';
import { resolveCoachTeamIdWithCookie } from '@/lib/golf/resolve-team-server';
import { getCoachTeamSwitchContext } from '@/lib/golf/resolve-team';
import { withAdminObserved } from '@/lib/admin/observed-action';
Expand Down Expand Up @@ -148,6 +149,14 @@ export async function sendMessage({
hint: messageError.hint,
},
});
maybeCaptureRlsDenial(messageError, {
table: messagesTable,
verb: 'insert',
action: 'messages.sendMessage',
feature: sport === 'golf' ? 'messaging' : 'baseball_messages',
sport,
userId: user.id,
});
throw new Error(`Failed to send message: ${messageError.message}`);
}

Expand Down Expand Up @@ -305,6 +314,14 @@ export async function createConversation({
insertData,
},
});
maybeCaptureRlsDenial(convError, {
table: conversationsTable,
verb: 'insert',
action: 'messages.createConversation',
feature: sport === 'golf' ? 'messaging' : 'baseball_messages',
sport,
userId: user.id,
});
throw new Error(`Failed to create conversation: ${convError?.message || 'Unknown error'}`);
}

Expand Down Expand Up @@ -332,6 +349,14 @@ export async function createConversation({
userId: user.id,
},
});
maybeCaptureRlsDenial(participantsError, {
table: participantsTable,
verb: 'insert',
action: 'messages.createConversation',
feature: sport === 'golf' ? 'messaging' : 'baseball_messages',
sport,
userId: user.id,
});
await supabase.from(conversationsTable as any).delete().eq('id', conversationId);
throw new Error(`Failed to add participants: ${participantsError.message}`);
}
Expand Down Expand Up @@ -375,6 +400,14 @@ export async function markMessagesAsRead({

if (participantError) {
await logServerError(`[Messages] Failed to update last_read_at: ${participantError instanceof Error ? participantError.message : String(participantError)}`, { action: 'messages.markMessagesAsRead' });
maybeCaptureRlsDenial(participantError, {
table: participantsTable,
verb: 'update',
action: 'messages.markMessagesAsRead',
feature: sport === 'golf' ? 'messaging' : 'baseball_messages',
sport,
userId: user.id,
});
throw new Error('Failed to mark messages as read');
}

Expand All @@ -393,6 +426,14 @@ export async function markMessagesAsRead({

if (messagesError) {
await logServerError(`[Messages] Failed to mark messages as read: ${messagesError instanceof Error ? messagesError.message : String(messagesError)}`, { action: 'messages.markMessagesAsRead' });
maybeCaptureRlsDenial(messagesError, {
table: messagesTable,
verb: 'update',
action: 'messages.markMessagesAsRead',
feature: sport === 'golf' ? 'messaging' : 'baseball_messages',
sport,
userId: user.id,
});
}

revalidatePath(`/${sport}/dashboard/messages/${conversationId}`);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// =============================================================================
// DecisionRoomPage — BaseballUnauthorizedError must redirect, not raw-throw.
//
// getDecisionRoomData (withBaseballAction) independently re-resolves auth, so
// a session that expires between the page's own supabase.auth.getUser() check
// and this call throws BaseballUnauthorizedError. Before this fix, that error
// propagated straight out of the Server Component render to error.tsx and the
// error tracker (Sentry/Vercel) — the same class of bug fixed on the
// scout-packet/preview and scout-packets pages. This test pins the fix: the
// unauthorized case now redirects to /baseball/login (preserving returnTo),
// while any OTHER thrown error (a real failure for a signed-in, authorized
// coach) still propagates so error.tsx keeps handling genuine failures.
// =============================================================================

import { describe, it, expect, vi, beforeEach } from 'vitest';

const mocks = vi.hoisted(() => ({
getUser: vi.fn(async () => ({ data: { user: { id: 'coach-1' } } })),
getDecisionRoomData: vi.fn(),
redirect: vi.fn((path: string) => {
throw new Error(`REDIRECT:${path}`);
}),
}));

vi.mock('next/navigation', () => ({ redirect: mocks.redirect }));

vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(async () => ({
auth: { getUser: mocks.getUser },
})),
}));
Comment on lines +27 to +31

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check for an existing shared Supabase test client convention.
fd . src/test -e ts -e tsx
rg -n "createClient" src/test -A3 -B3
rg -n "supabase" src/test -il

Repository: njrini99-code/helmv3

Length of output: 50376


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- src/test/fixtures/fake-supabase.ts ---'
cat -n src/test/fixtures/fake-supabase.ts | sed -n '1,220p'

echo
echo '--- src/app/baseball/(dashboard)/dashboard/decision-room/__tests__/page.test.tsx ---'
cat -n 'src/app/baseball/(dashboard)/dashboard/decision-room/__tests__/page.test.tsx' | sed -n '1,220p'

echo
echo '--- nearby shared-test-client usage in baseball dashboard tests ---'
rg -n "fake-supabase|createFakeSupabase|createClient: vi\.fn\(\s*async|toBeTruthy\(\)" src/app/baseball src/test -g '*.test.ts' -g '*.test.tsx' -A2 -B2

Repository: njrini99-code/helmv3

Length of output: 50377


Use the shared Supabase fixture here

  • Replace the inline createClient mock at src/app/baseball/(dashboard)/dashboard/decision-room/__tests__/page.test.tsx:27-31 with createFakeSupabase from src/test/fixtures/fake-supabase.ts:1-20; this page test only needs auth.getUser(), and the shared fixture is the repo convention.
  • Tighten the success case at src/app/baseball/(dashboard)/dashboard/decision-room/__tests__/page.test.tsx:84-88; toBeTruthy() on the returned element is too weak—assert the rendered output or passed props instead.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/baseball/`(dashboard)/dashboard/decision-room/__tests__/page.test.tsx
around lines 27 - 31, Replace the inline Supabase mock in the decision-room page
test with the shared createFakeSupabase fixture, configuring auth.getUser as
needed while preserving the existing mock behavior. In the successful render
test, replace the weak toBeTruthy assertion with a specific assertion on the
rendered output or the props passed to the returned element.

Source: Path instructions


vi.mock('@/app/baseball/actions/decision-room', () => ({
getDecisionRoomData: mocks.getDecisionRoomData,
}));

// Mocked locally (not the real with-baseball-action module) so the page's
// `instanceof BaseballUnauthorizedError` check runs against the SAME class
// reference this test constructs errors with — no need to pull in the real
// module's Sentry/Supabase/capability dependency graph just to reach one
// error class. Defined inside vi.hoisted since vi.mock factories are hoisted
// above normal top-level declarations.
const { FakeBaseballUnauthorizedError } = vi.hoisted(() => ({
FakeBaseballUnauthorizedError: class FakeBaseballUnauthorizedError extends Error {
readonly status = 401;
constructor(message = 'You must be signed in.') {
super(message);
this.name = 'BaseballUnauthorizedError';
}
},
}));
vi.mock('@/lib/baseball/with-baseball-action', () => ({
BaseballUnauthorizedError: FakeBaseballUnauthorizedError,
}));
Comment on lines +43 to +54

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the duplicated FakeBaseballUnauthorizedError boilerplate into a shared test helper.

This exact ~12-line class + vi.mock('@/lib/baseball/with-baseball-action', ...) block is copy-pasted verbatim across every page test in this cohort (imports, integrations, permissions, roles, season, teams). Since vi.mock factories can safely reference values imported from a separate module (regular ES imports are resolved before module evaluation, unlike local const which hits the hoisting TDZ), this can be centralized.

♻️ Proposed shared helper
// src/test/baseball/fake-unauthorized-error.ts
export class FakeBaseballUnauthorizedError extends Error {
  readonly status = 401;
  constructor(message = 'You must be signed in.') {
    super(message);
    this.name = 'BaseballUnauthorizedError';
  }
}
+import { FakeBaseballUnauthorizedError } from '`@/test/baseball/fake-unauthorized-error`';
-const { FakeBaseballUnauthorizedError } = vi.hoisted(() => ({
-  FakeBaseballUnauthorizedError: class FakeBaseballUnauthorizedError extends Error {
-    readonly status = 401;
-    constructor(message = 'You must be signed in.') {
-      super(message);
-      this.name = 'BaseballUnauthorizedError';
-    }
-  },
-}));
 vi.mock('`@/lib/baseball/with-baseball-action`', () => ({
   BaseballUnauthorizedError: FakeBaseballUnauthorizedError,
 }));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/baseball/`(dashboard)/dashboard/decision-room/__tests__/page.test.tsx
around lines 43 - 54, Extract the duplicated FakeBaseballUnauthorizedError class
into a shared test helper module, preserving its status, default message, and
name. Update each affected page test to import the helper and have the
vi.mock('`@/lib/baseball/with-baseball-action`', ...) factory reference the
imported class, removing the local vi.hoisted boilerplate.


vi.mock('@/components/baseball/staff-decision-room/StaffDecisionRoomClient', () => ({
StaffDecisionRoomClient: () => null,
}));

import DecisionRoomPage from '../page';

describe('DecisionRoomPage — BaseballUnauthorizedError redirects instead of raw-throwing', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getUser.mockResolvedValue({ data: { user: { id: 'coach-1' } } });
});

it('redirects to /baseball/login (with returnTo) when the session expired between the auth check and the data fetch', async () => {
mocks.getDecisionRoomData.mockRejectedValue(new FakeBaseballUnauthorizedError());

await expect(DecisionRoomPage()).rejects.toThrow(
'REDIRECT:/baseball/login?returnTo=' +
encodeURIComponent('/baseball/dashboard/decision-room'),
);
});

it('re-throws any OTHER error (a real failure for a signed-in, authorized coach) instead of redirecting', async () => {
mocks.getDecisionRoomData.mockRejectedValue(new Error('decision room query failed'));

await expect(DecisionRoomPage()).rejects.toThrow('decision room query failed');
expect(mocks.redirect).not.toHaveBeenCalled();
});

it('renders normally when the data fetch succeeds', async () => {
mocks.getDecisionRoomData.mockResolvedValue({ items: [] });

const element = await DecisionRoomPage();
expect(element).toBeTruthy();
expect(mocks.redirect).not.toHaveBeenCalled();
});
});
16 changes: 13 additions & 3 deletions src/app/baseball/(dashboard)/dashboard/decision-room/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import { redirect } from 'next/navigation';

import { createClient } from '@/lib/supabase/server';
import { getDecisionRoomData } from '@/app/baseball/actions/decision-room';
import { BaseballUnauthorizedError } from '@/lib/baseball/with-baseball-action';
import { redirectOnUnauthorized } from '@/lib/baseball/redirect-on-unauthorized';
import { StaffDecisionRoomClient } from '@/components/baseball/staff-decision-room/StaffDecisionRoomClient';
import { fairwayScope } from '@/lib/redesign/flag';

Expand All @@ -50,9 +52,17 @@ export default async function DecisionRoomPage() {
}

// getDecisionRoomData resolves the active team + viewer capability and
// enforces auth/context server-side. Any failure (no active team, no coach
// role, missing capability) surfaces through error.tsx.
const data = await getDecisionRoomData();
// enforces auth/context server-side, independently re-resolving auth
// (withBaseballAction). A session that expires in the narrow window between
// the check above and this call throws BaseballUnauthorizedError, which
// must redirect to login rather than raw-throw to error.tsx/Sentry. Any
// OTHER failure (no active team, no coach role, missing capability) is a
// genuine failure and keeps propagating to error.tsx.
const data = await redirectOnUnauthorized(
() => getDecisionRoomData(),
(error) => error instanceof BaseballUnauthorizedError,
'/baseball/dashboard/decision-room',
);

return (
<div className={fairwayScope('min-h-full')}>
Expand Down
23 changes: 23 additions & 0 deletions src/app/baseball/(dashboard)/dashboard/operations/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
'use client';

import { RouteErrorBoundary } from '@/components/errors';

export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<RouteErrorBoundary
error={error}
reset={reset}
route="/baseball/dashboard/operations"
component="OperationsPage"
title="Couldn't load Operations"
message="We couldn't load the team logistics hub. Please try again."
homePath="/baseball/dashboard/roster"
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
'use client';

import { RouteErrorBoundary } from '@/components/errors';

export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<RouteErrorBoundary
error={error}
reset={reset}
route="/baseball/dashboard/performance/builder"
component="LiftBuilderPage"
title="Couldn't load the Lift Builder"
message="We couldn't load the lift planning tools. Please try again."
homePath="/baseball/dashboard/performance"
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// =============================================================================
// CoachScoutPacketPreviewPage — BaseballUnauthorizedError must redirect, not
// raw-throw.
//
// getScoutPacketPreview (withBaseballAction) independently re-resolves auth,
// so a session that expires between the page's own getActiveBaseballContext()
// check and this call throws BaseballUnauthorizedError. Before this fix, that
// error propagated straight out of the Server Component render to error.tsx
// and the error tracker (Sentry/Vercel) — the same class of bug fixed on the
// baseball announcements page (ROOT CAUSE 3). This test pins the fix: the
// unauthenticated case now redirects to /baseball/login (preserving
// returnTo), while any OTHER thrown error (a real failure for a signed-in,
// authorized coach) still propagates so error.tsx keeps handling genuine
// failures.
// =============================================================================

import { describe, it, expect, vi, beforeEach } from 'vitest';

const mocks = vi.hoisted(() => ({
getActiveBaseballContext: vi.fn(),
getScoutPacketPreview: vi.fn(),
redirect: vi.fn((path: string) => {
throw new Error(`REDIRECT:${path}`);
}),
notFound: vi.fn(() => {
throw new Error('NOT_FOUND');
}),
}));

vi.mock('next/navigation', () => ({
redirect: mocks.redirect,
notFound: mocks.notFound,
}));

vi.mock('@/lib/baseball/active-context', () => ({
getActiveBaseballContext: mocks.getActiveBaseballContext,
}));

vi.mock('@/app/baseball/actions/scout-packet', () => ({
getScoutPacketPreview: mocks.getScoutPacketPreview,
}));

// Mocked locally (not the real with-baseball-action module) so the page's
// `instanceof BaseballUnauthorizedError` check runs against the SAME class
// reference this test constructs errors with — no need to pull in the real
// module's Sentry/Supabase/capability dependency graph just to reach one
// error class. Defined inside vi.hoisted since vi.mock factories are hoisted
// above normal top-level declarations.
const { FakeBaseballUnauthorizedError } = vi.hoisted(() => ({
FakeBaseballUnauthorizedError: class FakeBaseballUnauthorizedError extends Error {
readonly status = 401;
constructor(message = 'You must be signed in.') {
super(message);
this.name = 'BaseballUnauthorizedError';
}
},
}));
vi.mock('@/lib/baseball/with-baseball-action', () => ({
BaseballUnauthorizedError: FakeBaseballUnauthorizedError,
}));

vi.mock('@/components/baseball/passport/ScoutPacketView', () => ({
ScoutPacketView: () => null,
}));

import CoachScoutPacketPreviewPage from '../page';

const COACH_CONTEXT = {
activeTeamId: 'team-1',
activeRole: 'coach' as const,
activeCoachId: 'coach-1',
activePlayerId: null,
};

describe('CoachScoutPacketPreviewPage — BaseballUnauthorizedError redirects instead of raw-throwing', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getActiveBaseballContext.mockResolvedValue(COACH_CONTEXT);
});

it('redirects to /baseball/login (with returnTo) when the session expired between the context check and the preview fetch', async () => {
mocks.getScoutPacketPreview.mockRejectedValue(new FakeBaseballUnauthorizedError());

await expect(CoachScoutPacketPreviewPage({ params: Promise.resolve({ id: 'player-1' }) })).rejects.toThrow(
'REDIRECT:/baseball/login?returnTo=' +
encodeURIComponent('/baseball/dashboard/players/player-1/scout-packet/preview'),
);
});

it('re-throws any OTHER error (a real failure for a signed-in, authorized coach) instead of redirecting', async () => {
mocks.getScoutPacketPreview.mockRejectedValue(new Error('assembly failed'));

await expect(
CoachScoutPacketPreviewPage({ params: Promise.resolve({ id: 'player-1' }) }),
).rejects.toThrow('assembly failed');
expect(mocks.redirect).not.toHaveBeenCalled();
});

it('renders normally when the preview fetch succeeds', async () => {
mocks.getScoutPacketPreview.mockResolvedValue({ ok: true });

const element = await CoachScoutPacketPreviewPage({
params: Promise.resolve({ id: 'player-1' }),
});
expect(element).toBeTruthy();
expect(mocks.redirect).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import Link from 'next/link';

import { getActiveBaseballContext } from '@/lib/baseball/active-context';
import { getScoutPacketPreview } from '@/app/baseball/actions/scout-packet';
import { BaseballUnauthorizedError } from '@/lib/baseball/with-baseball-action';
import { ScoutPacketView } from '@/components/baseball/passport/ScoutPacketView';
import { IconArrowLeft } from '@/components/icons';

Expand All @@ -33,7 +34,27 @@ export default async function CoachScoutPacketPreviewPage({ params }: PageProps)
if (context.activeRole !== 'coach') redirect('/baseball/player/passport');

// Capability is enforced inside getScoutPacketPreview (can_export_reports).
const model = await getScoutPacketPreview(id);
//
// getScoutPacketPreview independently re-resolves auth (withBaseballAction),
// so a session that expires in the narrow window between the context check
// above and this call throws BaseballUnauthorizedError here. Left uncaught,
// that raw-throws through this Server Component's render straight to
// error.tsx and the error tracker (Sentry/Vercel) instead of the honest
// "please sign in again" redirect — the same class of bug fixed on the
// baseball announcements page. Redirect on that specific case; any other
// thrown error (e.g. a real capability failure for a signed-in coach) is a
// genuine failure and should keep propagating to error.tsx.
let model: Awaited<ReturnType<typeof getScoutPacketPreview>>;
try {
model = await getScoutPacketPreview(id);
} catch (error) {
if (error instanceof BaseballUnauthorizedError) {
redirect(
`/baseball/login?returnTo=${encodeURIComponent(`/baseball/dashboard/players/${id}/scout-packet/preview`)}`,
);
}
throw error;
}
Comment on lines +37 to +57

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the target page and shared helper.
git ls-files 'src/app/baseball/**/preview/page.tsx' 'src/lib/baseball/**' | sed -n '1,200p'
printf '\n--- helper references ---\n'
rg -n "redirectOnUnauthorized|BaseballUnauthorizedError|getScoutPacketPreview|withBaseballAction" src/app/baseball src/lib/baseball -g '!**/node_modules/**' | sed -n '1,240p'

Repository: njrini99-code/helmv3

Length of output: 32977


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the target page and the helper implementation/call sites.
sed -n '1,220p' src/app/baseball/(dashboard)/dashboard/players/[id]/scout-packet/preview/page.tsx
printf '\n--- helper ---\n'
sed -n '1,220p' src/lib/baseball/redirect-on-unauthorized.ts
printf '\n--- similar pages ---\n'
rg -n "redirectOnUnauthorized\(" src/app/baseball/(dashboard)/dashboard -g 'page.tsx' -g '!**/node_modules/**' | sed -n '1,240p'

Repository: njrini99-code/helmv3

Length of output: 211


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- target file ---'
sed -n '1,220p' 'src/app/baseball/(dashboard)/dashboard/players/[id]/scout-packet/preview/page.tsx'

echo
echo '--- helper file candidates ---'
git ls-files 'src/lib/baseball/*redirect*' 'src/lib/baseball/**/redirect*' | sed -n '1,120p'

echo
echo '--- helper definition/calls ---'
rg -n "redirectOnUnauthorized|BaseballUnauthorizedError" src/lib/baseball src/app/baseball -g '!**/node_modules/**' | sed -n '1,240p'

Repository: njrini99-code/helmv3

Length of output: 31817


Use redirectOnUnauthorized here. src/app/baseball/(dashboard)/dashboard/players/[id]/scout-packet/preview/page.tsx:37-57 duplicates the same BaseballUnauthorizedError → login redirect path already used elsewhere in this baseball flow; switch to the shared helper to keep redirect behavior consistent.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/app/baseball/`(dashboard)/dashboard/players/[id]/scout-packet/preview/page.tsx
around lines 37 - 57, Replace the local BaseballUnauthorizedError catch and
manual login redirect in the page’s getScoutPacketPreview handling with the
shared redirectOnUnauthorized helper, matching its usage elsewhere in the
baseball flow while preserving propagation of unrelated errors.


return (
<div className="min-h-dvh bg-cream-100">
Expand Down
Loading
Loading