From d2aa1a5c0c65ec9cc90137cfd18d603d7b132f6d Mon Sep 17 00:00:00 2001 From: Fable Integrator Date: Thu, 9 Jul 2026 04:29:41 -0400 Subject: [PATCH 01/16] =?UTF-8?q?fix(security):=20W0=20=E2=80=94=20P0/P1?= =?UTF-8?q?=20privacy,=20auth,=20and=20correctness=20wave?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W0a (baseball privacy/auth): - P0: Discover search/state-counts/teams now exclude profile_visibility= 'private' players at the DB query level, matching recruitability.ts semantics (missing settings row = visible) — private players no longer surface in recruiting Discover. Regression tests added. - Recruiting-activation bypass closed: raw client write removed from ActivateRecruitingClient; useAuth().updatePlayer strips authorization- relevant columns (denylist) as defense-in-depth; activate/deactivate server actions now write via createAdminClient() (required by the deploy-sequenced trigger migration below). - Sign-out now invalidates the baseball auth module cache (both explicit signOut and SIGNED_OUT handler) — no 5s stale re-auth window. - is_anonymous phantom column removed from 4 engagement-event inserts (watchlist ×3, player-peek ×1) and those inserts now check/log errors — they had been silently failing with 42703 since the column was dropped. - Server-side guards added to 13 previously client-only dashboard routes (college-interest, camps, camps/[id], colleges, journey, dev-plans, program, tasks, messages, messages/[id], announcements, travel, videos) via thin server page.tsx wrappers; comparisons now redirects instead of returning a raw string. - middleware: updateSession failures now captured to Sentry (fail-open kept for transient errors, documented). - /api/account/delete: deletion ordered around the live ON DELETE NO ACTION constraints so active coach/admin accounts can actually self-delete. W0b (DB migrations; two applied to prod check-first 2026-07-09): - 20260709010000 APPLIED: public views honor owner opt-outs — baseball_team_coach_staff_public filters visible_to_players+status; baseball_teams_public_profile excludes 'private' (keeps 'unlisted' for direct-by-id share links — 10/10 live teams are unlisted; filtering to 'public' would have broken the share-link feature). relacl verified unchanged post-recreate. - 20260709010100 APPLIED: get_admin_event_summary gated via __admin_rollup_b_gate() + PUBLIC/anon EXECUTE revoked (was the only ungated sibling of 17). proacl verified. - 20260709010200 DEPLOY-SEQUENCED (header banner): BEFORE UPDATE trigger blocking non-service-role changes to recruiting_activated — apply with the production deploy that ships this branch's player-access.ts change. W0c (golf + email correctness): - Fairway shot tracking honors the meters unit preference end-to-end (input conversion at write boundary, labels/chips/preview, scorecard yardage) — meters players' distances/proximity/GIR no longer corrupted. - rounds/new re-edit path ports continue-round's allHolesScored check — no stale-scorecard submit after a post-completion correction. - coaching-intelligence page: explicit player guard (parity with siblings). - weekly-coach-email cron gated on golf_coach_philosophy.email_digest_enabled (same preference coach-morning-digest honors); task-reminders honors email_task_reminders per recipient. Gates: tsc=0, lint=0, unit 4562 passed (43 new regression tests). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01J2E46URHkhCnEQSYDtZLJX --- middleware.ts | 47 +- .../account/delete/__tests__/route.test.ts | 173 ++++++ src/app/api/account/delete/route.ts | 179 +++++- .../api/cron/v3/weekly-coach-email/route.ts | 20 + .../announcements/AnnouncementsClient.tsx | 128 +++++ .../dashboard/announcements/page.tsx | 140 +---- .../dashboard/camps/CampsClient.tsx | 501 +++++++++++++++++ .../dashboard/camps/[id]/CampDetailClient.tsx | 442 +++++++++++++++ .../(dashboard)/dashboard/camps/[id]/page.tsx | 454 +--------------- .../(dashboard)/dashboard/camps/page.tsx | 513 +----------------- .../dashboard/college-interest/page.tsx | 13 +- .../dashboard/colleges/CollegesClient.tsx | 128 +++++ .../(dashboard)/dashboard/colleges/page.tsx | 138 +---- .../dashboard/comparisons/page.tsx | 7 + .../dashboard/dev-plans/DevPlansClient.tsx | 437 +++++++++++++++ .../(dashboard)/dashboard/dev-plans/page.tsx | 446 +-------------- .../dashboard/journey/JourneyClient.tsx | 348 ++++++++++++ .../(dashboard)/dashboard/journey/page.tsx | 358 +----------- .../dashboard/messages/MessagesClient.tsx | 224 ++++++++ .../messages/[id]/ConversationClient.tsx | 169 ++++++ .../dashboard/messages/[id]/page.tsx | 175 +----- .../(dashboard)/dashboard/messages/page.tsx | 236 +------- .../dashboard/program/ProgramClient.tsx | 442 +++++++++++++++ .../(dashboard)/dashboard/program/page.tsx | 452 +-------------- .../dashboard/tasks/TasksClient.tsx | 165 ++++++ .../(dashboard)/dashboard/tasks/page.tsx | 177 +----- .../dashboard/travel/TravelPageClient.tsx | 148 +++++ .../(dashboard)/dashboard/travel/page.tsx | 162 +----- .../(dashboard)/dashboard/videos/page.tsx | 10 +- .../__tests__/discover-privacy.test.ts | 238 ++++++++ .../player-peek-engagement-events.test.ts | 146 +++++ .../watchlist-engagement-events.test.ts | 168 ++++++ src/app/baseball/actions/discover.ts | 71 ++- src/app/baseball/actions/player-access.ts | 16 +- src/app/baseball/actions/player-peek.ts | 9 +- src/app/baseball/actions/watchlist.ts | 27 +- .../new-round-client.decide-post-hole.test.ts | 84 +++ .../dashboard/rounds/new/new-round-client.tsx | 75 ++- .../settings/coaching-intelligence/page.tsx | 26 +- src/app/golf/actions/task-reminders.ts | 16 +- .../ActivateRecruitingClient.tsx | 21 +- .../FairwayScorecardHeader.tsx | 16 +- .../rounds-tracking/FairwayShotEntry.tsx | 65 ++- .../FairwayShotTracking.conversion.test.ts | 120 ++++ .../rounds-tracking/FairwayShotTracking.tsx | 79 ++- .../baseball/route-shell.contract.test.ts | 12 +- .../__tests__/use-auth-signout-cache.test.ts | 96 ++++ .../use-auth-update-player-denylist.test.ts | 116 ++++ src/hooks/use-auth.ts | 40 +- .../task-reminders-email-prefs.test.ts | 203 +++++++ src/test/api/cron/weekly-coach-email.test.ts | 255 +++++++++ src/test/root-middleware.test.ts | 91 ++++ ...baseball_public_views_honor_visibility.sql | 167 ++++++ ...0260709010100_gate_admin_event_summary.sql | 182 +++++++ ...0200_baseball_players_recruiting_guard.sql | 171 ++++++ 55 files changed, 6131 insertions(+), 3211 deletions(-) create mode 100644 src/app/api/account/delete/__tests__/route.test.ts create mode 100644 src/app/baseball/(dashboard)/dashboard/announcements/AnnouncementsClient.tsx create mode 100644 src/app/baseball/(dashboard)/dashboard/camps/CampsClient.tsx create mode 100644 src/app/baseball/(dashboard)/dashboard/camps/[id]/CampDetailClient.tsx create mode 100644 src/app/baseball/(dashboard)/dashboard/colleges/CollegesClient.tsx create mode 100644 src/app/baseball/(dashboard)/dashboard/dev-plans/DevPlansClient.tsx create mode 100644 src/app/baseball/(dashboard)/dashboard/journey/JourneyClient.tsx create mode 100644 src/app/baseball/(dashboard)/dashboard/messages/MessagesClient.tsx create mode 100644 src/app/baseball/(dashboard)/dashboard/messages/[id]/ConversationClient.tsx create mode 100644 src/app/baseball/(dashboard)/dashboard/program/ProgramClient.tsx create mode 100644 src/app/baseball/(dashboard)/dashboard/tasks/TasksClient.tsx create mode 100644 src/app/baseball/(dashboard)/dashboard/travel/TravelPageClient.tsx create mode 100644 src/app/baseball/actions/__tests__/discover-privacy.test.ts create mode 100644 src/app/baseball/actions/__tests__/player-peek-engagement-events.test.ts create mode 100644 src/app/baseball/actions/__tests__/watchlist-engagement-events.test.ts create mode 100644 src/app/golf/(dashboard)/dashboard/rounds/new/new-round-client.decide-post-hole.test.ts create mode 100644 src/components/fairway/pages/rounds-tracking/FairwayShotTracking.conversion.test.ts create mode 100644 src/hooks/__tests__/use-auth-signout-cache.test.ts create mode 100644 src/hooks/__tests__/use-auth-update-player-denylist.test.ts create mode 100644 src/test/api/actions/task-reminders-email-prefs.test.ts create mode 100644 src/test/api/cron/weekly-coach-email.test.ts create mode 100644 src/test/root-middleware.test.ts create mode 100644 supabase/migrations/20260709010000_baseball_public_views_honor_visibility.sql create mode 100644 supabase/migrations/20260709010100_gate_admin_event_summary.sql create mode 100644 supabase/migrations/20260709010200_baseball_players_recruiting_guard.sql diff --git a/middleware.ts b/middleware.ts index b3fba3400..2684066c3 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,15 +1,54 @@ import { updateSession } from '@/lib/supabase/middleware'; import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; +import * as Sentry from '@sentry/nextjs'; + +/** + * updateSession throws `NEXT_PUBLIC_SUPABASE_URL is missing or a placeholder` + * / `NEXT_PUBLIC_SUPABASE_ANON_KEY is missing` (src/lib/supabase/middleware.ts) + * when the Supabase env vars aren't configured for this deploy. That is a + * deploy-time MISCONFIGURATION, not a transient runtime failure — matched by + * message since that guard-throw lives in a separate file this middleware + * doesn't otherwise need to import from. + */ +function isConfigError(error: unknown): boolean { + return error instanceof Error && /NEXT_PUBLIC_SUPABASE_(URL|ANON_KEY)/.test(error.message); +} export async function middleware(request: NextRequest) { try { return await updateSession(request); } catch (error) { - console.warn( - '[Middleware] Session update failed:', - error instanceof Error ? error.message : String(error) - ); + if (isConfigError(error)) { + // FAIL CLOSED: this specific error means Supabase env vars are missing + // or placeholders, so updateSession never even reached auth.getUser(). + // Failing open here (as EVERY error used to, via a bare console.warn + + // NextResponse.next()) would silently disable every auth/authorization + // check in the app for the duration of the misconfiguration — every + // request to every protected /baseball, /golf, /lifting, and /admin + // dashboard route would pass straight through with NO session + // validation at all. A loud 500 is far safer than a silent open door. + Sentry.captureException(error, { + level: 'fatal', + tags: { middleware_failure: 'config' }, + }); + return new NextResponse('Service temporarily unavailable. Please try again shortly.', { + status: 500, + }); + } + + // Genuinely transient (a network blip talking to Supabase/GoTrue, a + // read failure inside checkRouteAuthorization, cookie parsing edge + // cases, …) — capture to Sentry for visibility (previously only a + // console.warn, invisible in production unless someone was tailing + // logs), but keep failing OPEN: a temporary Supabase/GoTrue hiccup must + // not lock every signed-in user out of the entire app. Route-level + // guards (getSessionProfile/server-route-guards, requireBaseballAction, + // etc.) remain the backstop for any request that slips through here. + Sentry.captureException(error, { + level: 'warning', + tags: { middleware_failure: 'transient' }, + }); return NextResponse.next(); } } diff --git a/src/app/api/account/delete/__tests__/route.test.ts b/src/app/api/account/delete/__tests__/route.test.ts new file mode 100644 index 000000000..467c5e6dd --- /dev/null +++ b/src/app/api/account/delete/__tests__/route.test.ts @@ -0,0 +1,173 @@ +// ============================================================================= +// src/app/api/account/delete/__tests__/route.test.ts +// +// P1 (Production-Readiness Mission W0a) — /api/account/delete RESTRICT FK +// failure. A live-schema check found several ON DELETE NO ACTION +// "who-did-this" attribution columns referencing baseball_coaches.id / +// golf_coaches.id / users.id that blocked the final `users` delete for ANY +// coach/admin who had ever created a game, practice block, academic +// exclusion, announcement, assigned goal, travel itinerary, or touched the +// CRM tables. This locks in: +// 1. Those attribution columns are nulled out (reassigned) BEFORE the +// final users delete, scoped to the correct coach id — not the raw +// auth user id. +// 2. The engagement-events cleanup now matches on baseball_coaches.id +// (previously compared against the auth user id and silently deleted +// nothing). +// 3. A residual FK violation (23503) on the final delete returns an +// honest, actionable 409 — never a generic 500, never silently +// swallowed. +// ============================================================================= + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const BASEBALL_COACH_ID = 'bcoach-1'; +const USER_ID = 'user-1'; + +const getUser = vi.fn(); +const deleteUserMock = vi.fn(async () => ({ error: null })); +const logServerError = vi.hoisted(() => vi.fn(async () => {})); + +vi.mock('@/lib/server-error-logger', () => ({ logServerError })); + +vi.mock('@/lib/supabase/server', () => ({ + createClient: vi.fn(async () => ({ auth: { getUser } })), +})); + +// Records every update/delete call so tests can assert on exactly which +// table + column + value each cleanup step touched. +type Call = { table: string; op: 'update' | 'delete'; column?: string; value?: unknown; payload?: unknown }; +let calls: Call[] = []; +let usersDeleteError: { code: string; message: string } | null = null; + +function makeTable(table: string) { + return { + select: () => ({ + eq: (col: string, value: unknown) => ({ + maybeSingle: async () => { + if (table === 'baseball_coaches' && col === 'user_id' && value === USER_ID) { + return { data: { id: BASEBALL_COACH_ID }, error: null }; + } + if (table === 'golf_coaches') { + return { data: null, error: null }; + } + return { data: null, error: null }; + }, + }), + }), + update: (payload: unknown) => ({ + eq: async (column: string, value: unknown) => { + calls.push({ table, op: 'update', column, value, payload }); + return { error: null }; + }, + }), + delete: () => ({ + eq: async (column: string, value: unknown) => { + calls.push({ table, op: 'delete', column, value }); + if (table === 'users' && usersDeleteError) { + return { error: usersDeleteError }; + } + return { error: null }; + }, + }), + }; +} + +const adminFrom = vi.fn((table: string) => makeTable(table)); + +vi.mock('@/lib/supabase/admin', () => ({ + createAdminClient: vi.fn(() => ({ + from: adminFrom, + auth: { admin: { deleteUser: deleteUserMock } }, + })), +})); + +import { DELETE } from '@/app/api/account/delete/route'; + +describe('/api/account/delete', () => { + beforeEach(() => { + vi.clearAllMocks(); + calls = []; + usersDeleteError = null; + getUser.mockResolvedValue({ data: { user: { id: USER_ID } }, error: null }); + deleteUserMock.mockResolvedValue({ error: null }); + }); + + it('rejects unauthenticated callers', async () => { + getUser.mockResolvedValue({ data: { user: null }, error: null }); + + const res = await DELETE(); + + expect(res.status).toBe(401); + }); + + it('reassigns NO ACTION coach-attribution columns using the resolved baseball_coaches.id (not the raw auth user id) before deleting the user', async () => { + const res = await DELETE(); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.success).toBe(true); + + const gamesUpdate = calls.find((c) => c.table === 'baseball_games' && c.op === 'update'); + expect(gamesUpdate?.column).toBe('created_by'); + expect(gamesUpdate?.value).toBe(BASEBALL_COACH_ID); + expect(gamesUpdate?.payload).toEqual({ created_by: null }); + + const practiceBlocksUpdate = calls.find((c) => c.table === 'baseball_practice_blocks' && c.op === 'update'); + expect(practiceBlocksUpdate?.value).toBe(BASEBALL_COACH_ID); + + const invitationsDelete = calls.find((c) => c.table === 'baseball_team_invitations' && c.op === 'delete'); + expect(invitationsDelete?.column).toBe('created_by_coach_id'); + expect(invitationsDelete?.value).toBe(BASEBALL_COACH_ID); + + // REGRESSION: engagement events must be matched on the resolved coach id, + // never the raw auth user id (the pre-fix bug matched coach_id against + // user.id, which is a different id space and silently deleted 0 rows). + const engagementDelete = calls.find((c) => c.table === 'baseball_player_engagement_events' && c.op === 'delete'); + expect(engagementDelete?.column).toBe('coach_id'); + expect(engagementDelete?.value).toBe(BASEBALL_COACH_ID); + expect(engagementDelete?.value).not.toBe(USER_ID); + }); + + it('reassigns NO ACTION user-attribution (CRM/admin) columns using the raw auth user id', async () => { + await DELETE(); + + const crmCreated = calls.find( + (c) => c.table === 'crm_coaches' && c.op === 'update' && c.column === 'created_by', + ); + expect(crmCreated?.value).toBe(USER_ID); + + const crmArchived = calls.find( + (c) => c.table === 'crm_coaches' && c.op === 'update' && c.column === 'archived_by', + ); + expect(crmArchived?.value).toBe(USER_ID); + + const taskTemplates = calls.find((c) => c.table === 'golf_task_templates' && c.op === 'update'); + expect(taskTemplates?.value).toBe(USER_ID); + }); + + it('returns an honest 409 (not a generic 500, not a silent swallow) when the final delete still hits a residual FK violation', async () => { + usersDeleteError = { code: '23503', message: 'update or delete on table "users" violates foreign key constraint' }; + + const res = await DELETE(); + const body = await res.json(); + + expect(res.status).toBe(409); + expect(body.error).toMatch(/reassigned by an admin/i); + expect(logServerError).toHaveBeenCalledWith( + expect.stringContaining('Account deletion blocked by FK constraint'), + expect.objectContaining({ userId: USER_ID, errorCode: '23503' }), + ); + expect(deleteUserMock).not.toHaveBeenCalled(); + }); + + it('returns a generic 500 for a non-FK users-delete failure', async () => { + usersDeleteError = { code: '42501', message: 'permission denied' }; + + const res = await DELETE(); + const body = await res.json(); + + expect(res.status).toBe(500); + expect(body.error).toBe('Failed to delete account data'); + }); +}); diff --git a/src/app/api/account/delete/route.ts b/src/app/api/account/delete/route.ts index 22921dc11..9ad91bc57 100644 --- a/src/app/api/account/delete/route.ts +++ b/src/app/api/account/delete/route.ts @@ -3,6 +3,92 @@ import { createClient } from '@/lib/supabase/server'; import { createAdminClient } from '@/lib/supabase/admin'; import { logServerError } from '@/lib/server-error-logger'; +// ============================================================================= +// SECURITY / CORRECTNESS (Production-Readiness Mission W0a): +// +// This route used to attempt `DELETE FROM users` directly after a handful of +// unrelated cleanup deletes, then rely on `baseball_coaches.user_id` / +// `golf_coaches.user_id` ON DELETE CASCADE to remove the coach profile. That +// CASCADE only succeeds if every row referencing the coach's OWN id (or the +// user's id directly) has an ON DELETE behavior of CASCADE or SET NULL. A +// live-schema check (information_schema.referential_constraints) found a set +// of "who did this" attribution columns with ON DELETE NO ACTION instead — +// the DEFAULT Postgres behavior most other equivalent columns in this schema +// override to SET NULL, but these did not: +// +// baseball_games.created_by -> baseball_coaches.id +// baseball_practice_blocks.coach_owner_id -> baseball_coaches.id +// golf_academic_exclusions.excluded_by -> golf_coaches.id +// golf_announcements.created_by -> golf_coaches.id +// golf_goals.coach_id_if_assigned -> golf_coaches.id +// golf_travel_itineraries.created_by -> golf_coaches.id +// crm_coaches.archived_by / created_by -> users.id +// crm_contact_log.created_by -> users.id +// crm_email_templates.created_by -> users.id +// crm_events.created_by -> users.id +// golf_task_templates.created_by -> users.id +// golf_team_coachhelm_settings.disabled_by -> users.id +// +// Any coach who has ever created a game, a practice block, an academic +// exclusion, an announcement, an assigned goal, a travel itinerary, or (for +// admin/CRM users) touched the CRM tables above would hit a hard Postgres FK +// violation on the `users` delete, rolling the whole statement back — this +// was the "RESTRICT FK failure for most active coach/admin accounts." +// +// Fix: reassign (NULL out) every one of those attribution columns for this +// user's coach/user id BEFORE the final `users` delete, so the CASCADE it +// triggers never hits a blocking reference. This is non-destructive — it +// only detaches the "who did this" stamp, never removes the underlying +// content (the game, the announcement, the exclusion, etc. all stay intact +// for the team). `baseball_team_invitations` rows this coach created are +// DELETED outright rather than reassigned — an invitation/join-code row is +// operational metadata (not team-shared content; redeemed invitations don't +// affect the resulting `baseball_team_members` rows, which have no FK to +// `baseball_team_invitations`), so removing the coach's own invite codes on +// deletion is safe and non-destructive to anyone else's data. +// +// RESIDUAL GAP (documented, not silently swallowed): a SMALL number of +// similar attribution columns are NOT NULL, so they cannot be reassigned to +// NULL, and the rows they're on hold substantive shared content (a player's +// entered stat line, a logged travel expense, an assigned goal, a qualifier +// selection) that this route must not delete just because the entering +// coach is leaving. (One of these is the deprecated legacy per-player stat +// table named in src/lib/baseball/stat-layer-manifest.ts's +// DEPRECATED_STAT_TABLES — its exact identifier is deliberately not spelled +// out literally here, since this route never reads/writes it and doing so +// would trip the stat-layer-contract scan, #381.): +// +// coach_id (NOT NULL) on the deprecated legacy per-player stat table above -> baseball_coaches.id +// baseball_box_score_uploads.coach_id -> baseball_coaches.id (NOT NULL) +// golf_goals.created_by_user_id -> users.id (NOT NULL) +// golf_qualifier_selections.selected_by_user_id -> users.id (NOT NULL) +// golf_travel_expenses.created_by -> users.id (NOT NULL) +// +// If an account is blocked by one of these, the final `users` delete below +// still fails — but now with an HONEST, actionable 409 (never a silent +// swallow, never a destructive shortcut) instead of an opaque 500. The real +// fix for this residual set is a DB migration (owner sign-off; out of scope +// for this code-only wave) to either loosen these columns to nullable + +// ON DELETE SET NULL (matching every sibling audit column in this schema) or +// introduce a system-reassignment sentinel. +// ============================================================================= + +async function nullOutColumn( + admin: ReturnType, + table: string, + column: string, + matchValue: string, +): Promise<{ label: string; error: string | null }> { + // Spans many tables/columns with no single shared row shape — the + // service-role admin client's overloaded `.from()` typing can't express + // this generically, so this helper is intentionally untyped internally. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { error } = await (admin.from(table as any) as any) + .update({ [column]: null }) + .eq(column, matchValue); + return { label: `${table}.${column}`, error: error ? error.message : null }; +} + export async function DELETE() { try { const supabase = await createClient(); @@ -23,6 +109,61 @@ export async function DELETE() { } const cleanupErrors: string[] = []; + const recordCleanupError = (results: Array<{ label: string; error: string | null }>) => { + for (const r of results) { + if (r.error) cleanupErrors.push(`${r.label}: ${r.error}`); + } + }; + + // Resolve this user's coach identity in each sport (if any) up front — + // needed to reassign the NO ACTION-constrained coach-attribution columns + // before the final `users` delete cascades into baseball_coaches / + // golf_coaches. + const [{ data: baseballCoach }, { data: golfCoach }] = await Promise.all([ + admin.from('baseball_coaches').select('id').eq('user_id', user.id).maybeSingle(), + admin.from('golf_coaches').select('id').eq('user_id', user.id).maybeSingle(), + ]); + + if (baseballCoach?.id) { + const bId = baseballCoach.id; + const results = await Promise.all([ + nullOutColumn(admin, 'baseball_games', 'created_by', bId), + nullOutColumn(admin, 'baseball_practice_blocks', 'coach_owner_id', bId), + ]); + recordCleanupError(results); + + // Invitation/join-code rows this coach created — operational metadata, + // not team-shared content (see header comment). Safe to remove outright. + const { error: invitationsError } = await admin + .from('baseball_team_invitations') + .delete() + .eq('created_by_coach_id', bId); + if (invitationsError) cleanupErrors.push(`baseball_team_invitations: ${invitationsError.message}`); + } + + if (golfCoach?.id) { + const gId = golfCoach.id; + const results = await Promise.all([ + nullOutColumn(admin, 'golf_academic_exclusions', 'excluded_by', gId), + nullOutColumn(admin, 'golf_announcements', 'created_by', gId), + nullOutColumn(admin, 'golf_goals', 'coach_id_if_assigned', gId), + nullOutColumn(admin, 'golf_travel_itineraries', 'created_by', gId), + ]); + recordCleanupError(results); + } + + // NO ACTION-constrained columns referencing users.id directly (mostly + // admin/CRM attribution — covers the "admin accounts" half of this fix). + const userLevelResults = await Promise.all([ + nullOutColumn(admin, 'crm_coaches', 'archived_by', user.id), + nullOutColumn(admin, 'crm_coaches', 'created_by', user.id), + nullOutColumn(admin, 'crm_contact_log', 'created_by', user.id), + nullOutColumn(admin, 'crm_email_templates', 'created_by', user.id), + nullOutColumn(admin, 'crm_events', 'created_by', user.id), + nullOutColumn(admin, 'golf_task_templates', 'created_by', user.id), + nullOutColumn(admin, 'golf_team_coachhelm_settings', 'disabled_by', user.id), + ]); + recordCleanupError(userLevelResults); // Clean up baseball messages const { error: baseballMessagesError } = await admin @@ -38,12 +179,18 @@ export async function DELETE() { .eq('sender_id', user.id); if (golfMessagesError) cleanupErrors.push(`golf_messages: ${golfMessagesError.message}`); - // Clean up engagement events - const { error: engagementError } = await admin - .from('baseball_player_engagement_events') - .delete() - .eq('coach_id', user.id); - if (engagementError) cleanupErrors.push(`engagement_events: ${engagementError.message}`); + // Clean up engagement events this user recorded AS A COACH. `coach_id` + // on this table references baseball_coaches.id, not the auth user id — + // resolve it first (previously this matched `coach_id` against the raw + // auth user id, which almost never equals a baseball_coaches.id, so this + // cleanup silently deleted 0 rows for every coach). + if (baseballCoach?.id) { + const { error: engagementError } = await admin + .from('baseball_player_engagement_events') + .delete() + .eq('coach_id', baseballCoach.id); + if (engagementError) cleanupErrors.push(`engagement_events: ${engagementError.message}`); + } const { error: userDeleteError } = await admin .from('users') @@ -51,6 +198,26 @@ export async function DELETE() { .eq('id', user.id); if (userDeleteError) { + // 23503 = foreign_key_violation. See the RESIDUAL GAP note in the + // header comment — a small, documented set of NOT NULL attribution + // columns (player stats, box score uploads, travel expenses, goals, + // qualifier selections) can still block deletion because they hold + // substantive shared content this route must not destroy. Surface + // that honestly instead of a generic 500. + if (userDeleteError.code === '23503') { + await logServerError( + `Account deletion blocked by FK constraint: ${userDeleteError.message}`, + { action: 'route.DELETE', userId: user.id, errorCode: userDeleteError.code }, + ); + return NextResponse.json( + { + error: + 'Your account has recorded data (e.g. player stats, uploaded box scores, travel expenses, or goals) that must be reassigned by an admin before deletion can complete. Please contact support.', + }, + { status: 409 } + ); + } + return NextResponse.json( { error: 'Failed to delete account data' }, { status: 500 } diff --git a/src/app/api/cron/v3/weekly-coach-email/route.ts b/src/app/api/cron/v3/weekly-coach-email/route.ts index 220c5f771..75ee46000 100644 --- a/src/app/api/cron/v3/weekly-coach-email/route.ts +++ b/src/app/api/cron/v3/weekly-coach-email/route.ts @@ -31,6 +31,8 @@ interface SendSummary { sent: number; skipped_no_email: number; skipped_provider_unset: number; + /** Coach has explicitly opted out of the CoachHelm email digest. */ + skipped_opted_out: number; errors: number; duration_ms: number; } @@ -54,6 +56,7 @@ async function handle(): Promise { sent: 0, skipped_no_email: 0, skipped_provider_unset: 0, + skipped_opted_out: 0, errors: 0, duration_ms: 0, }; @@ -75,6 +78,23 @@ async function handle(): Promise { .maybeSingle(); if (!staff?.coach_id) continue; + // Opt-out gate: there is no dedicated `email_weekly_recap` preference + // column, so this recap (a CoachHelm digest email, same as the daily + // coach-morning-digest cron) is gated on the CLOSEST existing coach + // preference — `golf_coach_philosophy.email_digest_enabled` — the same + // column /api/cron/coach-morning-digest already honors. Missing row → + // opted-in by default (mirrors that cron's convention: the flag only + // records explicit opt-outs). + const { data: philosophyRow } = await sb + .from('golf_coach_philosophy') + .select('email_digest_enabled') + .eq('coach_id', staff.coach_id) + .maybeSingle(); + if (philosophyRow && philosophyRow.email_digest_enabled === false) { + summary.skipped_opted_out += 1; + continue; + } + const recap = await buildWeeklyRecap(sb, { coach_id: staff.coach_id, team_id, diff --git a/src/app/baseball/(dashboard)/dashboard/announcements/AnnouncementsClient.tsx b/src/app/baseball/(dashboard)/dashboard/announcements/AnnouncementsClient.tsx new file mode 100644 index 000000000..f8feb9ef9 --- /dev/null +++ b/src/app/baseball/(dashboard)/dashboard/announcements/AnnouncementsClient.tsx @@ -0,0 +1,128 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { PageLoading } from '@/components/ui/loading'; +import { useAuth } from '@/hooks/use-auth'; +import { useTeamStore } from '@/stores/team-store'; +import { createClient } from '@/lib/supabase/client'; +import { getAnnouncementsWithMeta } from '@/app/baseball/actions/announcements'; +import { AnnouncementsFairway } from '@/components/baseball/announcements/AnnouncementsFairway'; +import { ReadModelStateNotice } from '@/components/baseball/ReadModelStateNotice'; +import { SectionMasthead } from '@/components/baseball/living-annual'; +import { fairwayScope } from '@/lib/redesign/flag'; +import type { BaseballAnnouncementMeta } from '@/app/baseball/actions/announcements'; + +interface RosterPlayer { + id: string; + first_name: string | null; + last_name: string | null; +} + +export default function AnnouncementsClient() { + const { user, player, loading: authLoading } = useAuth(); + const { selectedTeamId } = useTeamStore(); + + const [announcements, setAnnouncements] = useState([]); + const [players, setPlayers] = useState([]); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + + const isCoach = user?.role === 'coach'; + + // Stable refetch used both by the initial load effect and by the create/delete + // success callbacks below — router.refresh() alone cannot re-run this because + // the announcements list lives in useState, not in server-rendered data. + const fetchAnnouncements = useCallback(async () => { + if (!selectedTeamId || !user) return; + + setLoading(true); + setLoadError(null); + + const playerId = player?.id || null; + + const result = await getAnnouncementsWithMeta( + selectedTeamId, + user.id, + isCoach, + playerId + ); + + if (result.success && result.data) { + setAnnouncements(result.data); + } else { + setAnnouncements([]); + setLoadError(result.error ?? 'Announcements could not be loaded.'); + } + + // For coaches: also fetch roster for the create flow. selectedTeamId is + // already guaranteed truthy here (guard clause above returns otherwise, + // and this closure's value can't change mid-call), so only isCoach gates. + if (isCoach) { + const supabase = createClient(); + const { data: members } = await supabase + .from('baseball_team_members') + .select('player_id, player:baseball_players(id, first_name, last_name)') + .eq('team_id', selectedTeamId) + .eq('status', 'active'); + + const rosterPlayers = (members || []) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .map((m: any) => m.player) + .filter(Boolean) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .map((p: any) => ({ + id: p.id, + first_name: p.first_name, + last_name: p.last_name, + })); + + setPlayers(rosterPlayers); + } + + setLoading(false); + }, [selectedTeamId, user, isCoach, player?.id]); + + useEffect(() => { + if (authLoading) return; + if (!selectedTeamId || !user) { + setLoading(false); + return; + } + void fetchAnnouncements(); + }, [authLoading, selectedTeamId, user, fetchAnnouncements]); + + if (authLoading) return ; + + if (loadError) { + return ( +
+ + void fetchAnnouncements()} + /> +
+ ); + } + + const recentCount = announcements.filter(a => { + if (!a.published_at) return false; + return (Date.now() - new Date(a.published_at).getTime()) < 7 * 86400000; + }).length; + + return ( +
+ +
+ ); +} diff --git a/src/app/baseball/(dashboard)/dashboard/announcements/page.tsx b/src/app/baseball/(dashboard)/dashboard/announcements/page.tsx index 838b3c94d..bb65b428b 100644 --- a/src/app/baseball/(dashboard)/dashboard/announcements/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/announcements/page.tsx @@ -1,128 +1,14 @@ -'use client'; - -import { useState, useEffect, useCallback } from 'react'; -import { PageLoading } from '@/components/ui/loading'; -import { useAuth } from '@/hooks/use-auth'; -import { useTeamStore } from '@/stores/team-store'; -import { createClient } from '@/lib/supabase/client'; -import { getAnnouncementsWithMeta } from '@/app/baseball/actions/announcements'; -import { AnnouncementsFairway } from '@/components/baseball/announcements/AnnouncementsFairway'; -import { ReadModelStateNotice } from '@/components/baseball/ReadModelStateNotice'; -import { SectionMasthead } from '@/components/baseball/living-annual'; -import { fairwayScope } from '@/lib/redesign/flag'; -import type { BaseballAnnouncementMeta } from '@/app/baseball/actions/announcements'; - -interface RosterPlayer { - id: string; - first_name: string | null; - last_name: string | null; -} - -export default function BaseballAnnouncementsPage() { - const { user, player, loading: authLoading } = useAuth(); - const { selectedTeamId } = useTeamStore(); - - const [announcements, setAnnouncements] = useState([]); - const [players, setPlayers] = useState([]); - const [loading, setLoading] = useState(true); - const [loadError, setLoadError] = useState(null); - - const isCoach = user?.role === 'coach'; - - // Stable refetch used both by the initial load effect and by the create/delete - // success callbacks below — router.refresh() alone cannot re-run this because - // the announcements list lives in useState, not in server-rendered data. - const fetchAnnouncements = useCallback(async () => { - if (!selectedTeamId || !user) return; - - setLoading(true); - setLoadError(null); - - const playerId = player?.id || null; - - const result = await getAnnouncementsWithMeta( - selectedTeamId, - user.id, - isCoach, - playerId - ); - - if (result.success && result.data) { - setAnnouncements(result.data); - } else { - setAnnouncements([]); - setLoadError(result.error ?? 'Announcements could not be loaded.'); - } - - // For coaches: also fetch roster for the create flow. selectedTeamId is - // already guaranteed truthy here (guard clause above returns otherwise, - // and this closure's value can't change mid-call), so only isCoach gates. - if (isCoach) { - const supabase = createClient(); - const { data: members } = await supabase - .from('baseball_team_members') - .select('player_id, player:baseball_players(id, first_name, last_name)') - .eq('team_id', selectedTeamId) - .eq('status', 'active'); - - const rosterPlayers = (members || []) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .map((m: any) => m.player) - .filter(Boolean) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .map((p: any) => ({ - id: p.id, - first_name: p.first_name, - last_name: p.last_name, - })); - - setPlayers(rosterPlayers); - } - - setLoading(false); - }, [selectedTeamId, user, isCoach, player?.id]); - - useEffect(() => { - if (authLoading) return; - if (!selectedTeamId || !user) { - setLoading(false); - return; - } - void fetchAnnouncements(); - }, [authLoading, selectedTeamId, user, fetchAnnouncements]); - - if (authLoading) return ; - - if (loadError) { - return ( -
- - void fetchAnnouncements()} - /> -
- ); - } - - const recentCount = announcements.filter(a => { - if (!a.published_at) return false; - return (Date.now() - new Date(a.published_at).getTime()) < 7 * 86400000; - }).length; - - return ( -
- -
- ); +// SECURITY: this route was a whole-file 'use client' page with NO server-side +// auth check at all. Shared coach + player surface (AnnouncementsClient +// branches internally on role) — thin server wrapper just requires a +// signed-in baseball user, matching the repo's standard shape. +import { redirect } from 'next/navigation'; +import { getSessionProfile } from '@/lib/auth/session'; +import AnnouncementsClient from './AnnouncementsClient'; + +export default async function AnnouncementsPage() { + const session = await getSessionProfile(); + if (!session) redirect('/baseball/login'); + + return ; } diff --git a/src/app/baseball/(dashboard)/dashboard/camps/CampsClient.tsx b/src/app/baseball/(dashboard)/dashboard/camps/CampsClient.tsx new file mode 100644 index 000000000..7d4e26db9 --- /dev/null +++ b/src/app/baseball/(dashboard)/dashboard/camps/CampsClient.tsx @@ -0,0 +1,501 @@ +'use client'; + +import { useState, useEffect, useRef, type ReactNode } from 'react'; +import Link from 'next/link'; +import { Card, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { PageLoading } from '@/components/ui/loading'; +import { EmptyState } from '@/components/ui/empty-state'; +import { ReadModelStateNotice } from '@/components/baseball/ReadModelStateNotice'; +import { ConfirmDialog } from '@/components/ui/confirm-dialog'; +import { IconCalendar, IconMapPin, IconUsers, IconPlus, IconHeart, IconHeartFilled, IconEdit, IconTrash, IconEye } from '@/components/icons'; +import { CreateCampModal } from '@/components/coach/CreateCampModal'; +import { createClient } from '@/lib/supabase/client'; +import { useAuth } from '@/hooks/use-auth'; +import { useToast } from '@/components/ui/sonner'; +import { registerForCamp, unregisterFromCamp, deleteCamp } from '@/app/baseball/actions/camps'; +import { activeCampCountsByCamp, formatCampDate } from '@/lib/baseball/camp-utils'; + +interface Camp { + id: string; + name: string; + description: string | null; + start_date: string; + end_date: string; + location: string | null; + capacity: number | null; + status: string | null; + price_cents: number | null; + is_free: boolean | null; + registration_deadline: string | null; + coach_id: string; + organization_id: string | null; + created_at: string | null; + updated_at: string | null; + organization: { + id: string; + name: string; + logo_url: string | null; + } | null; + registrations: { count: number }[]; + is_registered?: boolean; +} + +type CampSupabase = ReturnType; + +// Attach an ACTIVE (non-cancelled) registration count to each camp. Cancelled +// rows must not consume capacity, so we can't use the raw embedded count. (#443) +async function attachActiveCounts(supabase: CampSupabase, camps: Camp[]): Promise { + const ids = camps.map((c) => c.id); + if (ids.length === 0) return camps; + const { data } = await supabase + .from('baseball_camp_registrations') + .select('camp_id, status') + .in('camp_id', ids); + const counts = activeCampCountsByCamp((data ?? []) as { camp_id: string; status: string | null }[]); + return camps.map((c) => ({ ...c, registrations: [{ count: counts.get(c.id) ?? 0 }] })); +} + +// Single source of truth for loading camps + their active counts, shared by the +// initial load, the error-retry, and the post-create refresh. +async function loadCamps( + supabase: CampSupabase, + opts: { coachId: string } | { playerActive: true }, +): Promise { + const base = supabase + .from('baseball_camps') + .select('*, organization:organizations(id, name, logo_url)'); + const filtered = + 'coachId' in opts + ? base.eq('coach_id', opts.coachId) + : base.eq('status', 'published').gte('end_date', new Date().toISOString()); + const { data, error } = await filtered.order('start_date', { ascending: true }); + if (error) throw error; + return attachActiveCounts(supabase, (data as Camp[]) ?? []); +} + +function CampCard({ + camp, + isPlayer, + isCoach, + isRegistered, + onRegister, + onUnregister, + onEdit, + onDelete +}: { + camp: Camp; + isPlayer: boolean; + isCoach: boolean; + isRegistered: boolean; + onRegister: (campId: string) => void; + onUnregister: (campId: string) => void; + onEdit: (camp: Camp) => void; + onDelete: (campId: string) => void; +}) { + const registrationCount = camp.registrations?.[0]?.count || 0; + const isFull = camp.capacity ? registrationCount >= camp.capacity : false; + + return ( + + +
+
+

{camp.name}

+ {camp.organization && ( +

{camp.organization.name}

+ )} +
+
+ + + {formatCampDate(camp.start_date)} + {camp.end_date && ` - ${formatCampDate(camp.end_date)}`} + +
+ {camp.location && ( +
+ + {camp.location} +
+ )} +
+ + + {registrationCount}{camp.capacity ? ` / ${camp.capacity}` : ''} registered + +
+
+
+
+ + {camp.status === 'published' ? 'Open' : camp.status || 'Pending'} + + {camp.price_cents && !camp.is_free && ( +

+ ${(camp.price_cents / 100).toFixed(0)} +

+ )} + {camp.is_free && ( +

+ Free +

+ )} +
+
+ + {camp.description && ( +

{camp.description}

+ )} + + {/* Player Actions */} + {isPlayer && ( +
+ {isRegistered ? ( + + ) : isFull ? ( + + ) : ( + + )} +
+ )} + + {/* Coach Actions */} + {isCoach && ( +
+ + + + + +
+ )} +
+
+ ); +} + +// Lightweight editorial page header. The dashboard shell already renders the +// global top bar (notifications + command palette), so this page must NOT mount +// the legacy layout
— doing so stacked a second search box + avatar +// under the shell bar. This mirrors the premium Scout Packets header pattern: +// eyebrow → title → subtitle, with the primary action inline on the right. +function CampsPageHeader({ + isCoach, + subtitle, + action, +}: { + isCoach: boolean; + subtitle: string; + action?: ReactNode; +}) { + return ( +
+
+

+ Recruiting +

+

+ {isCoach ? 'My Camps' : 'Camps'} +

+

{subtitle}

+
+ {action ?
{action}
: null} +
+ ); +} + +export default function CampsClient() { + const { user, coach, player } = useAuth(); + const { showToast } = useToast(); + const [camps, setCamps] = useState([]); + const [registeredCamps, setRegisteredCamps] = useState>(new Set()); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [showCreateModal, setShowCreateModal] = useState(false); + const [editingCamp, setEditingCamp] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState(null); + const [deleting, setDeleting] = useState(false); + const supabaseRef = useRef(createClient()); + const supabase = supabaseRef.current; + + const isCoach = user?.role === 'coach'; + const isPlayer = user?.role === 'player'; + + useEffect(() => { + async function fetchCamps() { + setLoading(true); + setLoadError(null); + + try { + if (isCoach && coach) { + setCamps(await loadCamps(supabase, { coachId: coach.id })); + } else if (isPlayer && player) { + // Fetch player's active registrations (exclude cancelled) for the + // "Registered" badge, then load the active camps + their live counts. + const { data: playerRegs } = await supabase + .from('baseball_camp_registrations') + .select('camp_id') + .eq('player_id', player.id) + .neq('status', 'cancelled'); + + if (playerRegs) { + setRegisteredCamps(new Set(playerRegs.map(r => r.camp_id))); + } + + setCamps(await loadCamps(supabase, { playerActive: true })); + } + } catch { + setCamps([]); + setLoadError('Camps could not be loaded.'); + } + + setLoading(false); + } + + fetchCamps(); + // eslint-disable-next-line react-hooks/exhaustive-deps -- `supabase` is stable across renders (useRef at line 203). Adding it would noise the dep array without changing behavior. + }, [coach, player, isCoach, isPlayer]); + + const handleRegister = async (campId: string) => { + if (!player) return; + + // Go through the server action so capacity is enforced atomically in the DB + // (a raw client insert could overbook a camp). Only update the UI on a + // confirmed registration; surface the reason (e.g. "This camp is full"). + const result = await registerForCamp(campId); + + if (result.success) { + setRegisteredCamps(prev => new Set(Array.from(prev).concat(campId))); + setCamps(prev => prev.map(c => + c.id === campId + ? { ...c, registrations: [{ count: (c.registrations?.[0]?.count || 0) + 1 }] } + : c + )); + } else { + showToast(result.error || 'Failed to register for camp', 'error'); + } + }; + + const handleUnregister = async (campId: string) => { + if (!player) return; + + // Go through the audited server-action layer instead of a raw + // client-side write. + const result = await unregisterFromCamp(campId); + + if (result.success) { + setRegisteredCamps(prev => { + const newSet = new Set(prev); + newSet.delete(campId); + return newSet; + }); + setCamps(prev => prev.map(c => + c.id === campId + ? { ...c, registrations: [{ count: Math.max(0, (c.registrations?.[0]?.count || 0) - 1) }] } + : c + )); + } else { + showToast(result.error || 'Failed to cancel camp registration', 'error'); + } + }; + + const handleEdit = (camp: Camp) => { + setEditingCamp(camp); + }; + + const handleDelete = async () => { + if (!deleteConfirm) return; + + setDeleting(true); + try { + // Go through the audited server-action layer (deletes registrations + + // the camp, with an ownership check) instead of raw client-side deletes. + const result = await deleteCamp(deleteConfirm); + + if (!result.success) { + showToast(result.error || 'Failed to delete camp', 'error'); + return; + } + + // Remove from local state + setCamps(prev => prev.filter(c => c.id !== deleteConfirm)); + showToast('Camp deleted successfully', 'success'); + } catch { + showToast('An error occurred while deleting', 'error'); + } finally { + setDeleting(false); + setDeleteConfirm(null); + } + }; + + if (loading) { + return ( +
+ + +
+ ); + } + + if (loadError) { + return ( +
+ +
+ { + setLoading(true); + setLoadError(null); + void (async () => { + try { + if (isCoach && coach) { + setCamps(await loadCamps(supabase, { coachId: coach.id })); + } else if (isPlayer && player) { + setCamps(await loadCamps(supabase, { playerActive: true })); + } + } catch { + setLoadError('Camps could not be loaded.'); + } finally { + setLoading(false); + } + })(); + }} + /> +
+
+ ); + } + + return ( + <> +
+ setShowCreateModal(true)}> + + Create Camp + + ) : undefined + } + /> + {camps.length === 0 ? ( + } + title={isCoach ? 'No camps yet' : 'No camps available'} + description={ + isCoach + ? 'Create your first camp to start recruiting players.' + : 'Check back later for upcoming camps and events.' + } + action={ + isCoach ? ( + + ) : undefined + } + /> + ) : ( +
+ {camps.map(camp => ( + setDeleteConfirm(id)} + /> + ))} +
+ )} +
+ + {/* Create/Edit Camp Modal */} + { + setShowCreateModal(false); + setEditingCamp(null); + // Refresh camps list + if (isCoach && coach) { + void loadCamps(supabase, { coachId: coach.id }) + .then(setCamps) + .catch(() => setLoadError('Camps could not be loaded.')); + } + }} + camp={editingCamp} + /> + + {/* Delete Confirmation Dialog */} + setDeleteConfirm(null)} + /> + + ); +} diff --git a/src/app/baseball/(dashboard)/dashboard/camps/[id]/CampDetailClient.tsx b/src/app/baseball/(dashboard)/dashboard/camps/[id]/CampDetailClient.tsx new file mode 100644 index 000000000..782bf6cf9 --- /dev/null +++ b/src/app/baseball/(dashboard)/dashboard/camps/[id]/CampDetailClient.tsx @@ -0,0 +1,442 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { Card } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Avatar } from '@/components/ui/avatar'; +import { PageLoading } from '@/components/ui/loading'; +import { EmptyState } from '@/components/ui/empty-state'; +import { ShineEffect } from '@/components/ui/shine-effect'; +import { + IconArrowLeft, + IconCalendar, + IconMapPin, + IconUsers, + IconCheck, + IconX, + IconClock, + IconAlertCircle, +} from '@/components/icons'; +import { createClient } from '@/lib/supabase/client'; +import { fromUntyped } from '@/lib/supabase/untyped'; +import { useAuth } from '@/hooks/use-auth'; +import { useToast } from '@/components/ui/sonner'; +import { cn, getFullName, formatRelativeTime } from '@/lib/utils'; +import { formatCampDate } from '@/lib/baseball/camp-utils'; + +interface CampRegistration { + id: string; + camp_id: string; + player_id: string; + status: 'interested' | 'registered' | 'confirmed' | 'attended' | 'no_show' | 'cancelled'; + registered_at: string | null; + attended_at: string | null; + notes: string | null; + player: { + id: string; + first_name: string | null; + last_name: string | null; + avatar_url: string | null; + primary_position: string | null; + grad_year: number | null; + high_school_name: string | null; + city: string | null; + state: string | null; + } | null; +} + +interface Camp { + id: string; + name: string; + description: string | null; + start_date: string; + end_date: string; + location: string | null; + capacity: number | null; + status: string | null; + price_cents: number | null; + is_free: boolean | null; + coach_id: string; + organization: { + id: string; + name: string; + } | null; +} + +const CAMP_DETAIL_DATE_OPTIONS: Intl.DateTimeFormatOptions = { + weekday: 'short', + month: 'short', + day: 'numeric', + year: 'numeric', +}; + +const statusConfig: Record = { + interested: { label: 'Interested', color: 'text-warm-600', bg: 'bg-warm-100' }, + registered: { label: 'Registered', color: 'text-blue-600', bg: 'bg-blue-100' }, + confirmed: { label: 'Confirmed', color: 'text-primary-600', bg: 'bg-primary-100' }, + attended: { label: 'Checked In', color: 'text-primary-600', bg: 'bg-primary-100' }, + no_show: { label: 'No Show', color: 'text-amber-600', bg: 'bg-amber-100' }, + cancelled: { label: 'Cancelled', color: 'text-red-600', bg: 'bg-red-100' }, +}; + +export default function CampDetailClient() { + const params = useParams(); + const router = useRouter(); + const { coach, user } = useAuth(); + const { showToast } = useToast(); + const supabase = createClient(); + + const campId = params.id as string; + + const [camp, setCamp] = useState(null); + const [registrations, setRegistrations] = useState([]); + const [loading, setLoading] = useState(true); + const [checkingIn, setCheckingIn] = useState(null); + const [filter, setFilter] = useState<'all' | 'registered' | 'attended' | 'no_show'>('all'); + + const isCoach = user?.role === 'coach'; + + const fetchCampData = useCallback(async () => { + setLoading(true); + + // Fetch camp details + const { data: campData, error: campError } = await supabase + .from('baseball_camps') + .select(` + *, + organization:organizations(id, name) + `) + .eq('id', campId) + .single(); + + if (campError || !campData) { + showToast('Camp not found', 'error'); + router.push('/baseball/dashboard/camps'); + return; + } + + // Verify coach owns this camp + if (isCoach && coach && campData.coach_id !== coach.id) { + showToast('You do not have access to this camp', 'error'); + router.push('/baseball/dashboard/camps'); + return; + } + + setCamp(campData as Camp); + + // Fetch registrations with player details + const { data: regsData } = await supabase + .from('baseball_camp_registrations') + .select(` + id, + camp_id, + player_id, + status, + registered_at, + attended_at, + notes, + player:baseball_players( + id, + first_name, + last_name, + avatar_url, + primary_position, + grad_year, + high_school_name, + city, + state + ) + `) + .eq('camp_id', campId) + .neq('status', 'cancelled') + .order('registered_at', { ascending: false }); + + setRegistrations((regsData as unknown as CampRegistration[]) || []); + setLoading(false); + }, [campId, coach, isCoach, router, showToast, supabase]); + + useEffect(() => { + fetchCampData(); + }, [fetchCampData]); + + const handleCheckIn = async (registrationId: string) => { + setCheckingIn(registrationId); + + const { error } = await fromUntyped(supabase, 'baseball_camp_registrations') + .update({ + status: 'attended', + attended_at: new Date().toISOString(), + }) + .eq('id', registrationId); + + if (error) { + showToast('Failed to check in player', 'error'); + } else { + setRegistrations(prev => + prev.map(r => + r.id === registrationId + ? { ...r, status: 'attended' as const, attended_at: new Date().toISOString() } + : r + ) + ); + showToast('Player checked in', 'success'); + } + + setCheckingIn(null); + }; + + const handleMarkNoShow = async (registrationId: string) => { + const { error } = await supabase + .from('baseball_camp_registrations') + .update({ status: 'no_show' }) + .eq('id', registrationId); + + if (error) { + showToast('Failed to update status', 'error'); + } else { + setRegistrations(prev => + prev.map(r => + r.id === registrationId ? { ...r, status: 'no_show' as const } : r + ) + ); + } + }; + + const filteredRegistrations = registrations.filter(r => { + if (filter === 'all') return true; + if (filter === 'registered') return r.status === 'registered' || r.status === 'confirmed'; + return r.status === filter; + }); + + const stats = { + total: registrations.length, + attended: registrations.filter(r => r.status === 'attended').length, + pending: registrations.filter(r => r.status === 'registered' || r.status === 'confirmed').length, + noShow: registrations.filter(r => r.status === 'no_show').length, + }; + + if (loading) { + return ( + <> +
+

Camp Details

+
+ + + ); + } + + if (!camp) { + return ( + <> +
+

Camp Not Found

+
+
+ } + title="Camp not found" + description="This camp may have been deleted or you don't have access." + action={ + + + + } + /> +
+ + ); + } + + return ( + <> +
+
+

{camp.name}

+ {camp.organization?.name && ( +

{camp.organization.name}

+ )} +
+ + + +
+ +
+ {/* Camp Info */} +
+ +
+
+
+ +
+
+

Date

+

+ {formatCampDate(camp.start_date, CAMP_DETAIL_DATE_OPTIONS)} + {camp.end_date !== camp.start_date && ` - ${formatCampDate(camp.end_date, CAMP_DETAIL_DATE_OPTIONS)}`} +

+
+
+ + {camp.location && ( +
+
+ +
+
+

Location

+

{camp.location}

+
+
+ )} + +
+
+ +
+
+

Capacity

+

+ {stats.total}{camp.capacity ? ` / ${camp.capacity}` : ''} registered +

+
+
+
+
+ + {/* Quick Stats */} +
+ +

Total

+

{stats.total}

+
+ +

Checked In

+

{stats.attended}

+
+ +

Pending

+

{stats.pending}

+
+ +

No Show

+

{stats.noShow}

+
+
+ + {/* Roster */} +
+ +
+

Roster ({filteredRegistrations.length})

+ + {/* Filter Tabs */} +
+ {(['all', 'registered', 'attended', 'no_show'] as const).map(f => ( + + ))} +
+
+ + {filteredRegistrations.length === 0 ? ( +
+ +

No registrations yet

+
+ ) : ( +
+ {filteredRegistrations.map(reg => ( +
+ + +
+
+

+ {getFullName(reg.player?.first_name, reg.player?.last_name)} +

+ {reg.player?.primary_position && ( + + {reg.player.primary_position} + + )} +
+

+ {reg.player?.high_school_name && `${reg.player.high_school_name} • `} + {reg.player?.grad_year && `Class of ${reg.player.grad_year}`} + {reg.player?.city && reg.player?.state && ` • ${reg.player.city}, ${reg.player.state}`} +

+ {reg.attended_at && ( +

+ + Checked in {formatRelativeTime(reg.attended_at)} +

+ )} +
+ +
+ + {statusConfig[reg.status]?.label ?? reg.status} + + + {isCoach && (reg.status === 'registered' || reg.status === 'confirmed') && ( +
+ + +
+ )} +
+
+ ))} +
+ )} +
+
+ + ); +} diff --git a/src/app/baseball/(dashboard)/dashboard/camps/[id]/page.tsx b/src/app/baseball/(dashboard)/dashboard/camps/[id]/page.tsx index 11fe502a8..cd1b0818b 100644 --- a/src/app/baseball/(dashboard)/dashboard/camps/[id]/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/camps/[id]/page.tsx @@ -1,442 +1,14 @@ -'use client'; - -import { useState, useEffect, useCallback } from 'react'; -import { useParams, useRouter } from 'next/navigation'; -import Link from 'next/link'; -import { Card } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; -import { Avatar } from '@/components/ui/avatar'; -import { PageLoading } from '@/components/ui/loading'; -import { EmptyState } from '@/components/ui/empty-state'; -import { ShineEffect } from '@/components/ui/shine-effect'; -import { - IconArrowLeft, - IconCalendar, - IconMapPin, - IconUsers, - IconCheck, - IconX, - IconClock, - IconAlertCircle, -} from '@/components/icons'; -import { createClient } from '@/lib/supabase/client'; -import { fromUntyped } from '@/lib/supabase/untyped'; -import { useAuth } from '@/hooks/use-auth'; -import { useToast } from '@/components/ui/sonner'; -import { cn, getFullName, formatRelativeTime } from '@/lib/utils'; -import { formatCampDate } from '@/lib/baseball/camp-utils'; - -interface CampRegistration { - id: string; - camp_id: string; - player_id: string; - status: 'interested' | 'registered' | 'confirmed' | 'attended' | 'no_show' | 'cancelled'; - registered_at: string | null; - attended_at: string | null; - notes: string | null; - player: { - id: string; - first_name: string | null; - last_name: string | null; - avatar_url: string | null; - primary_position: string | null; - grad_year: number | null; - high_school_name: string | null; - city: string | null; - state: string | null; - } | null; -} - -interface Camp { - id: string; - name: string; - description: string | null; - start_date: string; - end_date: string; - location: string | null; - capacity: number | null; - status: string | null; - price_cents: number | null; - is_free: boolean | null; - coach_id: string; - organization: { - id: string; - name: string; - } | null; -} - -const CAMP_DETAIL_DATE_OPTIONS: Intl.DateTimeFormatOptions = { - weekday: 'short', - month: 'short', - day: 'numeric', - year: 'numeric', -}; - -const statusConfig: Record = { - interested: { label: 'Interested', color: 'text-warm-600', bg: 'bg-warm-100' }, - registered: { label: 'Registered', color: 'text-blue-600', bg: 'bg-blue-100' }, - confirmed: { label: 'Confirmed', color: 'text-primary-600', bg: 'bg-primary-100' }, - attended: { label: 'Checked In', color: 'text-primary-600', bg: 'bg-primary-100' }, - no_show: { label: 'No Show', color: 'text-amber-600', bg: 'bg-amber-100' }, - cancelled: { label: 'Cancelled', color: 'text-red-600', bg: 'bg-red-100' }, -}; - -export default function CampDetailPage() { - const params = useParams(); - const router = useRouter(); - const { coach, user } = useAuth(); - const { showToast } = useToast(); - const supabase = createClient(); - - const campId = params.id as string; - - const [camp, setCamp] = useState(null); - const [registrations, setRegistrations] = useState([]); - const [loading, setLoading] = useState(true); - const [checkingIn, setCheckingIn] = useState(null); - const [filter, setFilter] = useState<'all' | 'registered' | 'attended' | 'no_show'>('all'); - - const isCoach = user?.role === 'coach'; - - const fetchCampData = useCallback(async () => { - setLoading(true); - - // Fetch camp details - const { data: campData, error: campError } = await supabase - .from('baseball_camps') - .select(` - *, - organization:organizations(id, name) - `) - .eq('id', campId) - .single(); - - if (campError || !campData) { - showToast('Camp not found', 'error'); - router.push('/baseball/dashboard/camps'); - return; - } - - // Verify coach owns this camp - if (isCoach && coach && campData.coach_id !== coach.id) { - showToast('You do not have access to this camp', 'error'); - router.push('/baseball/dashboard/camps'); - return; - } - - setCamp(campData as Camp); - - // Fetch registrations with player details - const { data: regsData } = await supabase - .from('baseball_camp_registrations') - .select(` - id, - camp_id, - player_id, - status, - registered_at, - attended_at, - notes, - player:baseball_players( - id, - first_name, - last_name, - avatar_url, - primary_position, - grad_year, - high_school_name, - city, - state - ) - `) - .eq('camp_id', campId) - .neq('status', 'cancelled') - .order('registered_at', { ascending: false }); - - setRegistrations((regsData as unknown as CampRegistration[]) || []); - setLoading(false); - }, [campId, coach, isCoach, router, showToast, supabase]); - - useEffect(() => { - fetchCampData(); - }, [fetchCampData]); - - const handleCheckIn = async (registrationId: string) => { - setCheckingIn(registrationId); - - const { error } = await fromUntyped(supabase, 'baseball_camp_registrations') - .update({ - status: 'attended', - attended_at: new Date().toISOString(), - }) - .eq('id', registrationId); - - if (error) { - showToast('Failed to check in player', 'error'); - } else { - setRegistrations(prev => - prev.map(r => - r.id === registrationId - ? { ...r, status: 'attended' as const, attended_at: new Date().toISOString() } - : r - ) - ); - showToast('Player checked in', 'success'); - } - - setCheckingIn(null); - }; - - const handleMarkNoShow = async (registrationId: string) => { - const { error } = await supabase - .from('baseball_camp_registrations') - .update({ status: 'no_show' }) - .eq('id', registrationId); - - if (error) { - showToast('Failed to update status', 'error'); - } else { - setRegistrations(prev => - prev.map(r => - r.id === registrationId ? { ...r, status: 'no_show' as const } : r - ) - ); - } - }; - - const filteredRegistrations = registrations.filter(r => { - if (filter === 'all') return true; - if (filter === 'registered') return r.status === 'registered' || r.status === 'confirmed'; - return r.status === filter; - }); - - const stats = { - total: registrations.length, - attended: registrations.filter(r => r.status === 'attended').length, - pending: registrations.filter(r => r.status === 'registered' || r.status === 'confirmed').length, - noShow: registrations.filter(r => r.status === 'no_show').length, - }; - - if (loading) { - return ( - <> -
-

Camp Details

-
- - - ); - } - - if (!camp) { - return ( - <> -
-

Camp Not Found

-
-
- } - title="Camp not found" - description="This camp may have been deleted or you don't have access." - action={ - - - - } - /> -
- - ); - } - - return ( - <> -
-
-

{camp.name}

- {camp.organization?.name && ( -

{camp.organization.name}

- )} -
- - - -
- -
- {/* Camp Info */} -
- -
-
-
- -
-
-

Date

-

- {formatCampDate(camp.start_date, CAMP_DETAIL_DATE_OPTIONS)} - {camp.end_date !== camp.start_date && ` - ${formatCampDate(camp.end_date, CAMP_DETAIL_DATE_OPTIONS)}`} -

-
-
- - {camp.location && ( -
-
- -
-
-

Location

-

{camp.location}

-
-
- )} - -
-
- -
-
-

Capacity

-

- {stats.total}{camp.capacity ? ` / ${camp.capacity}` : ''} registered -

-
-
-
-
- - {/* Quick Stats */} -
- -

Total

-

{stats.total}

-
- -

Checked In

-

{stats.attended}

-
- -

Pending

-

{stats.pending}

-
- -

No Show

-

{stats.noShow}

-
-
- - {/* Roster */} -
- -
-

Roster ({filteredRegistrations.length})

- - {/* Filter Tabs */} -
- {(['all', 'registered', 'attended', 'no_show'] as const).map(f => ( - - ))} -
-
- - {filteredRegistrations.length === 0 ? ( -
- -

No registrations yet

-
- ) : ( -
- {filteredRegistrations.map(reg => ( -
- - -
-
-

- {getFullName(reg.player?.first_name, reg.player?.last_name)} -

- {reg.player?.primary_position && ( - - {reg.player.primary_position} - - )} -
-

- {reg.player?.high_school_name && `${reg.player.high_school_name} • `} - {reg.player?.grad_year && `Class of ${reg.player.grad_year}`} - {reg.player?.city && reg.player?.state && ` • ${reg.player.city}, ${reg.player.state}`} -

- {reg.attended_at && ( -

- - Checked in {formatRelativeTime(reg.attended_at)} -

- )} -
- -
- - {statusConfig[reg.status]?.label ?? reg.status} - - - {isCoach && (reg.status === 'registered' || reg.status === 'confirmed') && ( -
- - -
- )} -
-
- ))} -
- )} -
-
- - ); +// SECURITY: this route was a whole-file 'use client' page with NO server-side +// auth check at all. Shared coach + player surface (CampDetailClient reads +// role via useAuth internally) — thin server wrapper just requires a +// signed-in baseball user, matching the repo's standard shape. +import { redirect } from 'next/navigation'; +import { getSessionProfile } from '@/lib/auth/session'; +import CampDetailClient from './CampDetailClient'; + +export default async function CampDetailPage() { + const session = await getSessionProfile(); + if (!session) redirect('/baseball/login'); + + return ; } diff --git a/src/app/baseball/(dashboard)/dashboard/camps/page.tsx b/src/app/baseball/(dashboard)/dashboard/camps/page.tsx index 644f26823..a8c241e9c 100644 --- a/src/app/baseball/(dashboard)/dashboard/camps/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/camps/page.tsx @@ -1,501 +1,14 @@ -'use client'; - -import { useState, useEffect, useRef, type ReactNode } from 'react'; -import Link from 'next/link'; -import { Card, CardContent } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; -import { PageLoading } from '@/components/ui/loading'; -import { EmptyState } from '@/components/ui/empty-state'; -import { ReadModelStateNotice } from '@/components/baseball/ReadModelStateNotice'; -import { ConfirmDialog } from '@/components/ui/confirm-dialog'; -import { IconCalendar, IconMapPin, IconUsers, IconPlus, IconHeart, IconHeartFilled, IconEdit, IconTrash, IconEye } from '@/components/icons'; -import { CreateCampModal } from '@/components/coach/CreateCampModal'; -import { createClient } from '@/lib/supabase/client'; -import { useAuth } from '@/hooks/use-auth'; -import { useToast } from '@/components/ui/sonner'; -import { registerForCamp, unregisterFromCamp, deleteCamp } from '@/app/baseball/actions/camps'; -import { activeCampCountsByCamp, formatCampDate } from '@/lib/baseball/camp-utils'; - -interface Camp { - id: string; - name: string; - description: string | null; - start_date: string; - end_date: string; - location: string | null; - capacity: number | null; - status: string | null; - price_cents: number | null; - is_free: boolean | null; - registration_deadline: string | null; - coach_id: string; - organization_id: string | null; - created_at: string | null; - updated_at: string | null; - organization: { - id: string; - name: string; - logo_url: string | null; - } | null; - registrations: { count: number }[]; - is_registered?: boolean; -} - -type CampSupabase = ReturnType; - -// Attach an ACTIVE (non-cancelled) registration count to each camp. Cancelled -// rows must not consume capacity, so we can't use the raw embedded count. (#443) -async function attachActiveCounts(supabase: CampSupabase, camps: Camp[]): Promise { - const ids = camps.map((c) => c.id); - if (ids.length === 0) return camps; - const { data } = await supabase - .from('baseball_camp_registrations') - .select('camp_id, status') - .in('camp_id', ids); - const counts = activeCampCountsByCamp((data ?? []) as { camp_id: string; status: string | null }[]); - return camps.map((c) => ({ ...c, registrations: [{ count: counts.get(c.id) ?? 0 }] })); -} - -// Single source of truth for loading camps + their active counts, shared by the -// initial load, the error-retry, and the post-create refresh. -async function loadCamps( - supabase: CampSupabase, - opts: { coachId: string } | { playerActive: true }, -): Promise { - const base = supabase - .from('baseball_camps') - .select('*, organization:organizations(id, name, logo_url)'); - const filtered = - 'coachId' in opts - ? base.eq('coach_id', opts.coachId) - : base.eq('status', 'published').gte('end_date', new Date().toISOString()); - const { data, error } = await filtered.order('start_date', { ascending: true }); - if (error) throw error; - return attachActiveCounts(supabase, (data as Camp[]) ?? []); -} - -function CampCard({ - camp, - isPlayer, - isCoach, - isRegistered, - onRegister, - onUnregister, - onEdit, - onDelete -}: { - camp: Camp; - isPlayer: boolean; - isCoach: boolean; - isRegistered: boolean; - onRegister: (campId: string) => void; - onUnregister: (campId: string) => void; - onEdit: (camp: Camp) => void; - onDelete: (campId: string) => void; -}) { - const registrationCount = camp.registrations?.[0]?.count || 0; - const isFull = camp.capacity ? registrationCount >= camp.capacity : false; - - return ( - - -
-
-

{camp.name}

- {camp.organization && ( -

{camp.organization.name}

- )} -
-
- - - {formatCampDate(camp.start_date)} - {camp.end_date && ` - ${formatCampDate(camp.end_date)}`} - -
- {camp.location && ( -
- - {camp.location} -
- )} -
- - - {registrationCount}{camp.capacity ? ` / ${camp.capacity}` : ''} registered - -
-
-
-
- - {camp.status === 'published' ? 'Open' : camp.status || 'Pending'} - - {camp.price_cents && !camp.is_free && ( -

- ${(camp.price_cents / 100).toFixed(0)} -

- )} - {camp.is_free && ( -

- Free -

- )} -
-
- - {camp.description && ( -

{camp.description}

- )} - - {/* Player Actions */} - {isPlayer && ( -
- {isRegistered ? ( - - ) : isFull ? ( - - ) : ( - - )} -
- )} - - {/* Coach Actions */} - {isCoach && ( -
- - - - - -
- )} -
-
- ); -} - -// Lightweight editorial page header. The dashboard shell already renders the -// global top bar (notifications + command palette), so this page must NOT mount -// the legacy layout
— doing so stacked a second search box + avatar -// under the shell bar. This mirrors the premium Scout Packets header pattern: -// eyebrow → title → subtitle, with the primary action inline on the right. -function CampsPageHeader({ - isCoach, - subtitle, - action, -}: { - isCoach: boolean; - subtitle: string; - action?: ReactNode; -}) { - return ( -
-
-

- Recruiting -

-

- {isCoach ? 'My Camps' : 'Camps'} -

-

{subtitle}

-
- {action ?
{action}
: null} -
- ); -} - -export default function CampsPage() { - const { user, coach, player } = useAuth(); - const { showToast } = useToast(); - const [camps, setCamps] = useState([]); - const [registeredCamps, setRegisteredCamps] = useState>(new Set()); - const [loading, setLoading] = useState(true); - const [loadError, setLoadError] = useState(null); - const [showCreateModal, setShowCreateModal] = useState(false); - const [editingCamp, setEditingCamp] = useState(null); - const [deleteConfirm, setDeleteConfirm] = useState(null); - const [deleting, setDeleting] = useState(false); - const supabaseRef = useRef(createClient()); - const supabase = supabaseRef.current; - - const isCoach = user?.role === 'coach'; - const isPlayer = user?.role === 'player'; - - useEffect(() => { - async function fetchCamps() { - setLoading(true); - setLoadError(null); - - try { - if (isCoach && coach) { - setCamps(await loadCamps(supabase, { coachId: coach.id })); - } else if (isPlayer && player) { - // Fetch player's active registrations (exclude cancelled) for the - // "Registered" badge, then load the active camps + their live counts. - const { data: playerRegs } = await supabase - .from('baseball_camp_registrations') - .select('camp_id') - .eq('player_id', player.id) - .neq('status', 'cancelled'); - - if (playerRegs) { - setRegisteredCamps(new Set(playerRegs.map(r => r.camp_id))); - } - - setCamps(await loadCamps(supabase, { playerActive: true })); - } - } catch { - setCamps([]); - setLoadError('Camps could not be loaded.'); - } - - setLoading(false); - } - - fetchCamps(); - // eslint-disable-next-line react-hooks/exhaustive-deps -- `supabase` is stable across renders (useRef at line 203). Adding it would noise the dep array without changing behavior. - }, [coach, player, isCoach, isPlayer]); - - const handleRegister = async (campId: string) => { - if (!player) return; - - // Go through the server action so capacity is enforced atomically in the DB - // (a raw client insert could overbook a camp). Only update the UI on a - // confirmed registration; surface the reason (e.g. "This camp is full"). - const result = await registerForCamp(campId); - - if (result.success) { - setRegisteredCamps(prev => new Set(Array.from(prev).concat(campId))); - setCamps(prev => prev.map(c => - c.id === campId - ? { ...c, registrations: [{ count: (c.registrations?.[0]?.count || 0) + 1 }] } - : c - )); - } else { - showToast(result.error || 'Failed to register for camp', 'error'); - } - }; - - const handleUnregister = async (campId: string) => { - if (!player) return; - - // Go through the audited server-action layer instead of a raw - // client-side write. - const result = await unregisterFromCamp(campId); - - if (result.success) { - setRegisteredCamps(prev => { - const newSet = new Set(prev); - newSet.delete(campId); - return newSet; - }); - setCamps(prev => prev.map(c => - c.id === campId - ? { ...c, registrations: [{ count: Math.max(0, (c.registrations?.[0]?.count || 0) - 1) }] } - : c - )); - } else { - showToast(result.error || 'Failed to cancel camp registration', 'error'); - } - }; - - const handleEdit = (camp: Camp) => { - setEditingCamp(camp); - }; - - const handleDelete = async () => { - if (!deleteConfirm) return; - - setDeleting(true); - try { - // Go through the audited server-action layer (deletes registrations + - // the camp, with an ownership check) instead of raw client-side deletes. - const result = await deleteCamp(deleteConfirm); - - if (!result.success) { - showToast(result.error || 'Failed to delete camp', 'error'); - return; - } - - // Remove from local state - setCamps(prev => prev.filter(c => c.id !== deleteConfirm)); - showToast('Camp deleted successfully', 'success'); - } catch { - showToast('An error occurred while deleting', 'error'); - } finally { - setDeleting(false); - setDeleteConfirm(null); - } - }; - - if (loading) { - return ( -
- - -
- ); - } - - if (loadError) { - return ( -
- -
- { - setLoading(true); - setLoadError(null); - void (async () => { - try { - if (isCoach && coach) { - setCamps(await loadCamps(supabase, { coachId: coach.id })); - } else if (isPlayer && player) { - setCamps(await loadCamps(supabase, { playerActive: true })); - } - } catch { - setLoadError('Camps could not be loaded.'); - } finally { - setLoading(false); - } - })(); - }} - /> -
-
- ); - } - - return ( - <> -
- setShowCreateModal(true)}> - - Create Camp - - ) : undefined - } - /> - {camps.length === 0 ? ( - } - title={isCoach ? 'No camps yet' : 'No camps available'} - description={ - isCoach - ? 'Create your first camp to start recruiting players.' - : 'Check back later for upcoming camps and events.' - } - action={ - isCoach ? ( - - ) : undefined - } - /> - ) : ( -
- {camps.map(camp => ( - setDeleteConfirm(id)} - /> - ))} -
- )} -
- - {/* Create/Edit Camp Modal */} - { - setShowCreateModal(false); - setEditingCamp(null); - // Refresh camps list - if (isCoach && coach) { - void loadCamps(supabase, { coachId: coach.id }) - .then(setCamps) - .catch(() => setLoadError('Camps could not be loaded.')); - } - }} - camp={editingCamp} - /> - - {/* Delete Confirmation Dialog */} - setDeleteConfirm(null)} - /> - - ); +// SECURITY: this route was a whole-file 'use client' page with NO server-side +// auth check at all. Shared coach + player surface (CampsClient branches +// internally on role) — thin server wrapper just requires a signed-in +// baseball user, matching the repo's standard shape. +import { redirect } from 'next/navigation'; +import { getSessionProfile } from '@/lib/auth/session'; +import CampsClient from './CampsClient'; + +export default async function CampsPage() { + const session = await getSessionProfile(); + if (!session) redirect('/baseball/login'); + + return ; } diff --git a/src/app/baseball/(dashboard)/dashboard/college-interest/page.tsx b/src/app/baseball/(dashboard)/dashboard/college-interest/page.tsx index 5062345d1..018477af0 100644 --- a/src/app/baseball/(dashboard)/dashboard/college-interest/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/college-interest/page.tsx @@ -1,10 +1,21 @@ +import { redirect } from 'next/navigation'; +import { getSessionProfile } from '@/lib/auth/session'; import CollegeInterestClient from './CollegeInterestClient'; import { fairwayScope } from '@/lib/redesign/flag'; // Force dynamic rendering - requires Supabase auth at runtime export const dynamic = 'force-dynamic'; -export default function CollegeInterestPage() { +// SECURITY: this page had NO server-side auth check at all — an +// unauthenticated request rendered the full client shell (which then made its +// own Supabase calls). Role-specific messaging (college-player lock / +// not-yet-activated lock / "Coaches only") is intentionally left to +// CollegeInterestClient (via useAuth + usePlayerRecruitingGate) — this only +// closes the "must be signed in" gap. +export default async function CollegeInterestPage() { + const session = await getSessionProfile(); + if (!session) redirect('/baseball/login'); + return (
diff --git a/src/app/baseball/(dashboard)/dashboard/colleges/CollegesClient.tsx b/src/app/baseball/(dashboard)/dashboard/colleges/CollegesClient.tsx new file mode 100644 index 000000000..5fcebdb28 --- /dev/null +++ b/src/app/baseball/(dashboard)/dashboard/colleges/CollegesClient.tsx @@ -0,0 +1,128 @@ +'use client'; + +import { useState, useMemo } from 'react'; +import { ShineEffect } from '@/components/ui/shine-effect'; +import { CollegeCard } from '@/components/features/college-card'; +import { Select } from '@/components/ui/select'; +import { Input } from '@/components/ui/input'; +import { EmptyState } from '@/components/ui/empty-state'; +import { Loading } from '@/components/ui/loading'; +import { IconBuilding, IconSearch, IconAlertCircle } from '@/components/icons'; +import { useColleges, useStates, useConferences } from '@/hooks/use-colleges'; + +const divisions = [ + { value: '', label: 'All Divisions' }, + { value: 'D1', label: 'Division I' }, + { value: 'D2', label: 'Division II' }, + { value: 'D3', label: 'Division III' }, + { value: 'NAIA', label: 'NAIA' }, + { value: 'JUCO', label: 'Junior College' }, +]; + +export default function CollegesClient() { + const [division, setDivision] = useState(''); + const [stateFilter, setStateFilter] = useState(''); + const [conferenceFilter, setConferenceFilter] = useState(''); + const [search, setSearch] = useState(''); + + const { colleges, interests, loading, error, refetch, toggleInterest } = useColleges({ + division: division || undefined, + state: stateFilter || undefined, + conference: conferenceFilter || undefined, + search: search || undefined + }); + + const { states } = useStates(); + const { conferences } = useConferences(); + + const stateOptions = useMemo(() => [ + { value: '', label: 'All States' }, + ...states.map(s => ({ value: s, label: s })) + ], [states]); + + const conferenceOptions = useMemo(() => [ + { value: '', label: 'All Conferences' }, + ...conferences.map(c => ({ value: c, label: c })) + ], [conferences]); + + const interestedCount = interests.size; + + return ( + <> +
+
+

Discover Colleges

+

+ {colleges.length} colleges{interestedCount > 0 ? ` • ${interestedCount} in your interests` : ''} +

+
+
+
+ {/* Filters */} +
+ +
+
+ + setSearch(e.target.value)} + placeholder="Search by name, city, or state..." + className="pl-9" + /> +
+
+ setStateFilter(value)} + className="sm:w-36" + /> + setSearch(e.target.value)} - placeholder="Search by name, city, or state..." - className="pl-9" - /> -
-
- setStateFilter(value)} - className="sm:w-36" - /> - +
+ + {/* Engagement Stats */} +
+
+ + {school.profile_views} views +
+ {school.watchlist_added && ( +
+ + On watchlist +
+ )} +
+ + {school.notes && ( +

"{school.notes}"

+ )} + +
+ + Added {formatDate(school.created_at)} + + {school.organization_id && ( + + View Program + + )} +
+ + + ); +} + +function TimelineEvent({ event }: { event: JourneyEvent }) { + return ( +
+
+
+ {getEventIcon(event.type)} +
+
+
+
+

{event.description}

+

{formatRelativeTime(event.timestamp)}

+
+
+ ); +} + +export default function JourneyClient() { + const { schools, events, stats, loading } = useJourney(); + const [schoolList, setSchoolList] = useState([]); + + // Sync schools from hook + if (schools.length !== schoolList.length && !loading) { + setSchoolList(schools); + } + + const handleStatusChange = (id: string, newStatus: string) => { + setSchoolList(prev => + prev.map(s => s.id === id ? { ...s, status: newStatus } : s) + ); + }; + + if (loading) { + return ( + <> +
+
+

My Journey

+

Track your recruiting progress

+
+
+ + + ); + } + + const displaySchools = schoolList.length > 0 ? schoolList : schools; + + return ( + <> +
+
+

My Journey

+

Track your recruiting progress with schools

+
+ + + +
+ +
+ {/* Stats Overview */} + {stats && stats.total_interests > 0 && ( +
+ + +

{stats.total_interests}

+

Total Schools

+
+
+ + +

{stats.schools_interested}

+

Interested

+
+
+ + +

{stats.schools_contacted}

+

Contacted

+
+
+ + +

{stats.schools_visited}

+

Visited

+
+
+ + +

{stats.schools_offered}

+

Offers

+
+
+
+ )} + +
+ {/* Schools List */} +
+

Your Schools

+ {displaySchools.length === 0 ? ( + + + +

No schools in your journey

+

+ Start by adding schools you're interested in from the Discover Colleges page. +

+ + + + + {/* Visual Preview - Empty Journey Cards */} +
+ {[1, 2, 3].map((slot) => ( +
+
+
+
+
+
+
+
+
+
+ ))} +
+ + + ) : ( + displaySchools.map(school => ( + + )) + )} +
+ + {/* Activity Timeline */} +
+

Recent Activity

+ + + {events.length === 0 ? ( +
+ +

No activity yet

+

+ Activity from coaches will appear here +

+
+ ) : ( +
+ {events.slice(0, 10).map(event => ( + + ))} + {events.length > 10 && ( +

+ + {events.length - 10} more events +

+ )} +
+ )} +
+
+
+
+
+ + ); +} diff --git a/src/app/baseball/(dashboard)/dashboard/journey/page.tsx b/src/app/baseball/(dashboard)/dashboard/journey/page.tsx index caf26152a..874677b65 100644 --- a/src/app/baseball/(dashboard)/dashboard/journey/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/journey/page.tsx @@ -1,348 +1,12 @@ -'use client'; - -import { useState } from 'react'; -import Link from 'next/link'; -import { Card, CardContent } from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { Select } from '@/components/ui/select'; -import { PageLoading } from '@/components/ui/loading'; -import { - IconTarget, - IconEye, - IconStar, - IconVideo, - IconMessage, - IconCalendar, - IconPlus, - IconChevronRight -} from '@/components/icons'; -import { useJourney, type JourneySchool, type JourneyEvent } from '@/hooks/use-journey'; -import { updateInterestStatus } from '@/app/baseball/actions/interests'; -import { cn } from '@/lib/utils'; - -const statusOptions = [ - { value: 'interested', label: 'Interested' }, - { value: 'researching', label: 'Researching' }, - { value: 'contacted', label: 'Contacted' }, - { value: 'visited', label: 'Campus Visit' }, - { value: 'offered', label: 'Offer Extended' }, - { value: 'committed', label: 'Committed' }, -]; - -function getStatusColor(status: string): string { - switch (status) { - case 'interested': - case 'researching': - return 'bg-warm-100 text-warm-700'; - case 'contacted': - return 'bg-blue-100 text-blue-700'; - case 'visited': - return 'bg-purple-100 text-purple-700'; - case 'offered': - return 'bg-amber-100 text-amber-700'; - case 'committed': - return 'bg-primary-100 text-primary-700'; - default: - return 'bg-warm-100 text-warm-700'; - } -} - -function getEventIcon(type: JourneyEvent['type']) { - switch (type) { - case 'profile_view': - return ; - case 'watchlist_add': - return ; - case 'video_view': - return ; - case 'message': - return ; - case 'added_interest': - return ; - case 'status_change': - return ; - default: - return ; - } -} - -function formatDate(dateString: string): string { - const date = new Date(dateString); - return date.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - }); -} - -function formatRelativeTime(dateString: string): string { - const date = new Date(dateString); - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); - - if (diffDays === 0) { - return 'Today'; - } else if (diffDays === 1) { - return 'Yesterday'; - } else if (diffDays < 7) { - return `${diffDays} days ago`; - } else if (diffDays < 30) { - const weeks = Math.floor(diffDays / 7); - return `${weeks} week${weeks > 1 ? 's' : ''} ago`; - } else { - return formatDate(dateString); - } -} - -function SchoolCard({ school, onStatusChange }: { school: JourneySchool; onStatusChange: (id: string, status: string) => void }) { - const handleStatusChange = async (newStatus: string) => { - try { - await updateInterestStatus(school.id, newStatus); - onStatusChange(school.id, newStatus); - } catch { - // Error handled silently - status update is non-critical - } - }; - - return ( - - -
-
-
-

{school.school_name}

- - {statusOptions.find(s => s.value === school.status)?.label || school.status} - -
-
- {school.division && {school.division}} - {school.conference && • {school.conference}} -
-
- setInput(e.target.value)} + placeholder="Type a message..." + autoComplete="off" + aria-label="Message" + className="flex-1 rounded-fw-md bg-inset px-4 py-2.5 text-base text-text-primary placeholder:text-text-tertiary outline-none focus-visible:ring-2 focus-visible:ring-grade-plus lg:text-sm" + /> + + + +
+ ); +} diff --git a/src/app/baseball/(dashboard)/dashboard/messages/[id]/page.tsx b/src/app/baseball/(dashboard)/dashboard/messages/[id]/page.tsx index bee0c7423..928ac4617 100644 --- a/src/app/baseball/(dashboard)/dashboard/messages/[id]/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/messages/[id]/page.tsx @@ -1,163 +1,14 @@ -'use client'; - -/** - * ============================================================================ - * ConversationPage — Living-Annual thread surface for a single baseball - * conversation (plan: docs/baseball/ui-migration-execution-plan.md §3.1 - * `messages/[id]` row). - * ---------------------------------------------------------------------------- - * PRESENTATION ONLY. The page IS the client (no separate *Client.tsx) and does - * NOT share `MessagesFairway` (list page only) or touch - * `src/components/messages/ChatWindow.tsx`. `useMessages(conversationId)` / - * `useConversations()` + `sendMessage` + the auto-scroll `messagesEndRef` are - * the SAME hooks/handlers the legacy page called — this only reskins the - * shell, bubbles, empty state, and composer around them. The send path - * (`handleSend` → `sendMessage`) is untouched. - * ========================================================================== */ - -import { useState, useEffect, useRef } from 'react'; -import { useParams } from 'next/navigation'; -import Link from 'next/link'; -import { Avatar } from '@/components/ui/avatar'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { PageLoading } from '@/components/ui/loading'; -import { IconSend, IconChevronLeft } from '@/components/icons'; -import { useMessages, useConversations } from '@/hooks/use-messages'; -import { useAuth } from '@/hooks/use-auth'; -import { getFullName, formatDateTime, cn } from '@/lib/utils'; -import { fairwayScope } from '@/lib/redesign/flag'; -import { - SectionMasthead, - PaperCard, - InkBadge, - EmptyIssue, - Reveal, - pressableClass, -} from '@/components/baseball/living-annual'; - -const MESSAGES_HREF = '/baseball/dashboard/messages'; - -/** Back-to-inbox affordance — a bespoke tappable link (not a ` - - -
- ); +// SECURITY: this route was a whole-file 'use client' page with NO server-side +// auth check at all. Thin server wrapper guards, then renders +// ConversationClient (renamed from the former default export) — see that +// file's header comment for why this file exists now. +import { redirect } from 'next/navigation'; +import { getSessionProfile } from '@/lib/auth/session'; +import ConversationClient from './ConversationClient'; + +export default async function ConversationPage() { + const session = await getSessionProfile(); + if (!session) redirect('/baseball/login'); + + return ; } diff --git a/src/app/baseball/(dashboard)/dashboard/messages/page.tsx b/src/app/baseball/(dashboard)/dashboard/messages/page.tsx index 146e3e324..ecd14a1f0 100644 --- a/src/app/baseball/(dashboard)/dashboard/messages/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/messages/page.tsx @@ -1,224 +1,14 @@ -'use client'; - -import { Suspense, useState, useEffect, useMemo, useRef } from 'react'; -import { useSearchParams } from 'next/navigation'; -import { Loading } from '@/components/ui/loading'; -import { LazyConversationList, LazyChatWindow } from '@/lib/lazy-components'; -import { EmptyChatState } from '@/components/messages/EmptyChatState'; -import { NewMessageModal } from '@/components/messages/NewMessageModal'; -import { useConversations, useMessages } from '@/hooks/use-messages'; -import { useAuthStore } from '@/stores/auth-store'; -import { useToast } from '@/components/ui/sonner'; -import { createConversation, getPlayerUserId } from '@/app/baseball/actions/messages'; -import type { ConversationWithMeta } from '@/lib/types/messages'; -import { getParticipantDetails } from '@/lib/types/messages'; -import { MessagesFairway } from '@/components/baseball/messages/MessagesFairway'; - -function MessagesContent() { - const searchParams = useSearchParams(); - const conversationIdParam = searchParams.get('conversation'); - const openNewParam = searchParams.get('new'); - const playerIdParam = searchParams.get('player'); - const { showToast } = useToast(); - - const { user } = useAuthStore(); - const { conversations, loading: conversationsLoading, refetch } = useConversations(); - const [selectedConversationId, setSelectedConversationId] = useState(null); - const [showNewMessageModal, setShowNewMessageModal] = useState(false); - const [mobileShowChat, setMobileShowChat] = useState(false); - - // Get messages for selected conversation - const { messages, loading: messagesLoading, sendMessage } = useMessages(selectedConversationId || ''); - - // Handle URL-based conversation selection - useEffect(() => { - if (conversationIdParam) { - setSelectedConversationId(conversationIdParam); - setMobileShowChat(true); - } - }, [conversationIdParam]); - - // Auto-open new message modal when ?new=1 is in URL - useEffect(() => { - if (openNewParam === '1') { - setShowNewMessageModal(true); - } - }, [openNewParam]); - - // Auto-select first conversation on desktop - useEffect(() => { - const firstConversation = conversations[0]; - if (!conversationsLoading && firstConversation && !selectedConversationId) { - setSelectedConversationId(firstConversation.id); - } - }, [conversations, conversationsLoading, selectedConversationId]); - - // Get current user's role - const currentUserRole = user?.role === 'coach' ? 'coach' : 'player'; - - // Get participant details for selected conversation - const selectedConversation = useMemo(() => { - if (!selectedConversationId) return null; - return conversations.find(c => c.id === selectedConversationId) as ConversationWithMeta | undefined; - }, [conversations, selectedConversationId]); - - const selectedParticipant = useMemo(() => { - if (!selectedConversation || !user) return null; - return getParticipantDetails(selectedConversation, user.id); - }, [selectedConversation, user]); - - // Handle conversation selection - const handleSelectConversation = (id: string) => { - setSelectedConversationId(id); - setMobileShowChat(true); - // Update URL without full navigation - const url = new URL(window.location.href); - url.searchParams.set('conversation', id); - window.history.pushState({}, '', url); - }; - - // Handle back button on mobile - const handleBack = () => { - setMobileShowChat(false); - const url = new URL(window.location.href); - url.searchParams.delete('conversation'); - window.history.pushState({}, '', url); - }; - - // Handle new conversation creation - const handleNewConversation = async (userId: string) => { - try { - const result = await createConversation([userId]); - if (result.conversationId) { - await refetch(); - handleSelectConversation(result.conversationId); - showToast('Conversation started', 'success'); - } - } catch { - showToast('Failed to start conversation', 'error'); - } - }; - - // Guards the auto-start effect below against double-firing for the same - // player id (e.g. React StrictMode's dev-only mount/cleanup/remount cycle, - // or any other spurious re-run). Persists for the life of this component - // instance rather than being reset on cleanup, so a genuine StrictMode - // double-invoke is suppressed while a later visit with a *different* - // player id still fires normally. - const autoStartedPlayerRef = useRef(null); - - // Auto-start (or open) a conversation with a player when ?player= is in URL - // (e.g. from the Discover peek panel's "Message" action). - useEffect(() => { - if (!playerIdParam) return; - if (autoStartedPlayerRef.current === playerIdParam) return; - autoStartedPlayerRef.current = playerIdParam; - - // Strip the `player` param from the CURRENT history entry FIRST, using the - // raw history API synchronously -- not router.replace(), whose - // navigation is async and wouldn't guarantee this lands before - // handleNewConversation's own window.history.pushState() call (inside - // handleSelectConversation) reads window.location.href. - // - // Why the ordering matters: handleNewConversation() -> handleSelectConversation() - // calls window.history.pushState(...) using window.location.href AT THAT - // MOMENT. If `player` is still in the URL when that runs, it gets copied - // into the newly pushed history entry too -- and the entry underneath - // (pushed by DiscoverView's router.push('/messages?player=...')) also - // still carries it. Either way, pressing Back would land on a - // `?player=...` URL and re-fire this entire effect (duplicate - // getPlayerUserId/createConversation round-trips + a duplicate - // "Conversation started" toast, and a second Back press repeats the - // cycle -- a history trap). Stripping the param from the *current* entry - // before any of that runs means every entry involved ends up clean, so - // Back always lands on a URL with no `player` param. - const url = new URL(window.location.href); - url.searchParams.delete('player'); - window.history.replaceState({}, '', url); - - let cancelled = false; - - (async () => { - const resolvedUserId = await getPlayerUserId(playerIdParam); - if (cancelled) return; - - if (resolvedUserId) { - await handleNewConversation(resolvedUserId); - } else { - showToast('Could not start conversation with this player', 'error'); - } - })(); - - return () => { - cancelled = true; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [playerIdParam]); - - // Handle sending a message - const handleSendMessage = async (content: string) => { - if (!selectedConversationId) return false; - const success = await sendMessage(content); - if (success) { - refetch(); // Refresh conversation list to update last message - } else { - showToast('Failed to send message', 'error'); - } - return success; - }; - - return ( - setShowNewMessageModal(true)} - className="h-full" - /> - } - chatSlot={ - selectedConversationId ? ( - - ) : ( - setShowNewMessageModal(true)} - className="h-full" - /> - ) - } - modalSlot={ - setShowNewMessageModal(false)} - onSelect={handleNewConversation} - currentUserRole={currentUserRole} - /> - } - /> - ); -} - -export default function MessagesPage() { - return ( - - -
- }> - - - ); +// SECURITY: this route was a whole-file 'use client' page with NO server-side +// auth check at all — it read `useAuthStore()` directly (not even useAuth's +// loading gate). Shared coach + player surface — thin server wrapper just +// requires a signed-in baseball user, matching the repo's standard shape. +import { redirect } from 'next/navigation'; +import { getSessionProfile } from '@/lib/auth/session'; +import MessagesClient from './MessagesClient'; + +export default async function MessagesPage() { + const session = await getSessionProfile(); + if (!session) redirect('/baseball/login'); + + return ; } diff --git a/src/app/baseball/(dashboard)/dashboard/program/ProgramClient.tsx b/src/app/baseball/(dashboard)/dashboard/program/ProgramClient.tsx new file mode 100644 index 000000000..9ff2a14fc --- /dev/null +++ b/src/app/baseball/(dashboard)/dashboard/program/ProgramClient.tsx @@ -0,0 +1,442 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { Card, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Select } from '@/components/ui/select'; +import { Textarea } from '@/components/ui/textarea'; +import { PageLoading } from '@/components/ui/loading'; +import { useAuth } from '@/hooks/use-auth'; +import { useToast } from '@/components/ui/sonner'; +import { createClient } from '@/lib/supabase/client'; +import { cn } from '@/lib/utils'; +import { + IconBuilding, + IconGlobe, + IconCheck, + IconUpload, +} from '@/components/icons'; +import Link from 'next/link'; +import Image from 'next/image'; + +const DIVISIONS = ['D1', 'D2', 'D3', 'NAIA', 'JUCO', 'High School', 'Showcase']; + +interface OrganizationData { + id: string; + name: string; + type: string; + description?: string; + logo_url?: string; + website_url?: string; + location_city?: string; + location_state?: string; + division?: string; + conference?: string; + primary_color?: string; + secondary_color?: string; +} + +export default function ProgramClient() { + const { user, coach, loading: authLoading } = useAuth(); + const { showToast } = useToast(); + const [organization, setOrganization] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [uploadingLogo, setUploadingLogo] = useState(false); + const [saveMessage, setSaveMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + const fileInputRef = useRef(null); + + // Form state + const [formData, setFormData] = useState>({}); + + useEffect(() => { + async function fetchData() { + if (!coach?.organization_id) { + setLoading(false); + return; + } + + const supabase = createClient(); + + // Fetch organization + const { data: orgData } = await supabase + .from('organizations') + .select('*') + .eq('id', coach.organization_id) + .single(); + + if (orgData) { + // Convert null values to undefined to match TypeScript interface + const cleanedData: OrganizationData = { + id: orgData.id, + name: orgData.name, + type: orgData.type, + description: orgData.description ?? undefined, + logo_url: orgData.logo_url ?? undefined, + website_url: orgData.website_url ?? undefined, + location_city: orgData.location_city ?? undefined, + location_state: orgData.location_state ?? undefined, + division: orgData.division ?? undefined, + conference: orgData.conference ?? undefined, + primary_color: orgData.primary_color ?? undefined, + secondary_color: orgData.secondary_color ?? undefined, + }; + setOrganization(cleanedData); + setFormData(cleanedData); + } + + setLoading(false); + } + + if (!authLoading) { + fetchData(); + } + }, [authLoading, coach?.organization_id]); + + const handleInputChange = (field: keyof OrganizationData, value: string) => { + setFormData(prev => ({ ...prev, [field]: value })); + }; + + const handleSaveBasicInfo = async () => { + if (!organization?.id) return; + + setSaving(true); + setSaveMessage(null); + + const supabase = createClient(); + const { error } = await supabase + .from('organizations') + .update({ + name: formData.name, + description: formData.description, + website_url: formData.website_url, + location_city: formData.location_city, + location_state: formData.location_state, + division: formData.division, + conference: formData.conference, + primary_color: formData.primary_color, + secondary_color: formData.secondary_color, + updated_at: new Date().toISOString(), + }) + .eq('id', organization.id); + + setSaving(false); + + if (error) { + setSaveMessage({ type: 'error', text: 'Failed to save changes. Please try again.' }); + } else { + setSaveMessage({ type: 'success', text: 'Program info saved successfully!' }); + setTimeout(() => setSaveMessage(null), 3000); + } + }; + + const handleLogoUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file || !organization?.id) return; + + // Validate file type + if (!file.type.startsWith('image/')) { + showToast('Please select an image file', 'error'); + return; + } + + // Validate file size (max 2MB) + if (file.size > 2 * 1024 * 1024) { + showToast('Image must be less than 2MB', 'error'); + return; + } + + setUploadingLogo(true); + + try { + const supabase = createClient(); + const fileExt = file.name.split('.').pop(); + const fileName = `${organization.id}-logo-${Date.now()}.${fileExt}`; + const filePath = `organizations/${fileName}`; + const previousLogoUrl = organization.logo_url; + + // Upload to storage + const { error: uploadError } = await supabase.storage + .from('logos') + .upload(filePath, file, { upsert: true }); + + if (uploadError) { + showToast(uploadError.message || 'Failed to upload logo', 'error'); + return; + } + + // Get public URL + const { data: { publicUrl } } = supabase.storage + .from('logos') + .getPublicUrl(filePath); + + // Update organization with new logo URL + const { error: updateError } = await supabase + .from('organizations') + .update({ logo_url: publicUrl, updated_at: new Date().toISOString() }) + .eq('id', organization.id); + + if (updateError) { + showToast(updateError.message || 'Failed to update logo', 'error'); + return; + } + + // Update local state + setFormData(prev => ({ ...prev, logo_url: publicUrl })); + setOrganization(prev => prev ? { ...prev, logo_url: publicUrl } : null); + showToast('Logo updated successfully', 'success'); + + // Best-effort cleanup of the previous logo object so re-uploads don't + // orphan objects in the bucket (each upload uses a Date.now() path). + const previousPath = previousLogoUrl?.includes('/logos/organizations/') + ? `organizations/${previousLogoUrl.split('/logos/organizations/')[1]}` + : null; + if (previousPath && previousPath !== filePath) { + void supabase.storage.from('logos').remove([previousPath]); + } + } catch { + showToast('An error occurred while uploading', 'error'); + } finally { + setUploadingLogo(false); + // Clear the input so the same file can be selected again + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + }; + + if (authLoading || loading) return ; + + if (user?.role !== 'coach' || !coach) { + return ( + <> +
+

Program Profile

+

Coach access required

+
+
+ + +

This page is only available to coaches.

+
+
+
+ + ); + } + + if (!organization) { + return ( + <> +
+

Program Profile

+

No organization found

+
+
+ + + +

No Program Found

+

Your account is not associated with a program yet.

+
+
+
+ + ); + } + + return ( + <> +
+
+

Program Profile

+

Customize how your program appears to recruits

+
+ + + +
+ +
+ + +
+
+

Program Information

+

+ This information appears on your public program page that recruits can view. +

+
+ + {/* Logo Preview */} +
+ {formData.logo_url ? ( + Program logo + ) : ( +
+ +
+ )} +
+

Program Logo

+

Upload a logo for your program page (max 2MB).

+ + +
+
+ +
+ handleInputChange('name', e.target.value)} + placeholder="Texas A&M University Baseball" + /> + + handleInputChange('website_url', e.target.value)} + placeholder="https://12thman.com/baseball" + /> +
+ +
+
+ handleInputChange('conference', e.target.value)} + placeholder="SEC" + /> +
+ +
+ handleInputChange('location_city', e.target.value)} + placeholder="College Station" + /> + + handleInputChange('location_state', e.target.value)} + placeholder="TX" + /> +
+ +
+