From 1234fc7384ca9f3891bf648e96ae25b44a03e01e Mon Sep 17 00:00:00 2001 From: Fable Integrator Date: Fri, 10 Jul 2026 15:05:35 -0400 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20feature-flow=20sweep=20=E2=80=94=204?= =?UTF-8?q?7=20confirmed=20broken/stranded=20flows=20fixed=20across=20golf?= =?UTF-8?q?,=20baseball,=20and=20Bridge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An exhaustive per-cluster flow trace (every control -> action -> write -> revalidate -> consuming surface) found 148 candidate defects; 47 survived adversarial confirmation (28 P0 / 19 P1) and are fixed here by 17 verified packets plus lead hand-repairs. Highest impact: - Baseball pending joiners un-stranded: approve/reject flow built into the roster Status board (require_coach_approval defaults true on every prod team; joiners previously vanished into 'pending' with no UI anywhere). - Built-but-dead controls wired: player-notes edit/delete, lineup Share + a saved-lineups panel (load/edit/delete), remove-player + add-existing- player + jersey/position edits, class-schedule UI. - Roster/leaderboard stats read-model now merges current-season box-score stats over the legacy CSV aggregates (players stop reading 'Needs Data'). - Silent data loss closed: travel link-to-event edit (schema dropped event_id), recurring events now invite attendees + notify (create/edit/ delete, via after()), imports honesty (deprecated-table writes surfaced), task-reminder cron now actually matches the reminders users set. - Messaging: unread badges can finally clear (participant-checked SECURITY DEFINER RPC replaces an RLS-blocked update), broadcast dedupe no longer swallows new announcements into old threads by title. - Qualifiers: full lifecycle now reachable — edit details, auto in_progress on first round, auto completed when every entrant is done (incl. the single-entrant num_rounds=1 fall-through) plus a view-time reconcile for deadline-passed qualifiers; num_rounds caps submissions. - Notifications: quiet-mode preference actually enforced, CoachHelm pushes route through their real preference key, in-app receipts surfaced. - Bridge work log reads PR lifecycle from fields the GitHub API returns. - Practice-block visibility enforced by RLS (staff_only/restricted no longer readable or rendered player-side). DB: four replay-safe migrations (settings-audit reconcile + capability- scoped INSERT policy, decision-log reconcile, practice-blocks visibility RLS, mark_golf_messages_read RPC with anon revoked) applied to prod and verified via pg_policy/information_schema/proacl. Contracts updated with the new truth: feature-registry qualifiers manifest (+2, counts 419->421), stat-layer manifest grandfathers the roster aggregates bridging shim, golf observability wraps reconcileQualifierStatus. Residual (tracked, not closed): savePartialRound can still collide on two same-course/same-day in-progress rounds — full fix is client-generated round ids. Gates: tsc, eslint --max-warnings 0, FULL vitest suite (19,641 tests, 1,949 files), production build — all green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01J2E46URHkhCnEQSYDtZLJX --- src/app/actions/messages.ts | 204 +++--- .../dashboard/academics/AcademicsClient.tsx | 106 ++- .../academics/ClassScheduleModal.tsx | 341 +++++++++ .../dashboard/compare/CompareClient.tsx | 95 ++- .../__tests__/search-recruitability.test.ts | 200 ++++++ .../(dashboard)/dashboard/compare/actions.ts | 237 +++++++ .../(dashboard)/dashboard/lift/page.tsx | 8 +- .../dashboard/roster/RosterClient.tsx | 199 +++++- .../dashboard/roster/RosterFairway.tsx | 222 +++++- .../dashboard/roster/RosterMemberActions.tsx | 497 +++++++++++++ .../dashboard/roster/SavedLineupsPanel.tsx | 207 ++++++ .../dashboard/watchlist/WatchlistClient.tsx | 39 +- .../__tests__/academics-coach-gating.test.ts | 125 ++++ .../assign-player-membership-fields.test.ts | 136 ++++ .../decision-log-column-reconcile.test.ts | 113 +++ .../__tests__/dev-plans-coach-gating.test.ts | 143 ++++ .../imports-event-grain-guard.test.ts | 124 ++++ .../__tests__/imports-registry.test.ts | 34 + src/app/baseball/actions/academics.ts | 27 +- src/app/baseball/actions/decision-room.ts | 50 +- src/app/baseball/actions/dev-plans.ts | 21 +- src/app/baseball/actions/imports.ts | 659 +++++++++++++++++- .../actions/practice-planner-capability.ts | 40 ++ src/app/baseball/actions/practice.ts | 6 +- src/app/baseball/actions/program-settings.ts | 21 +- src/app/baseball/actions/roster.ts | 296 +++++++- .../dashboard/qualifiers/[id]/edit/error.tsx | 23 + .../qualifiers/[id]/edit/loading.tsx | 5 + .../dashboard/qualifiers/[id]/edit/page.tsx | 62 ++ .../dashboard/qualifiers/[id]/page.tsx | 7 +- .../(dashboard)/dashboard/recruiting/page.tsx | 28 + .../rounds/[id]/review/CoachNotesSection.tsx | 124 ++++ .../__tests__/CoachNotesSection.test.tsx | 94 +++ .../dashboard/rounds/[id]/review/page.tsx | 26 +- .../__tests__/golf-save-partial-round.test.ts | 176 +++++ .../__tests__/recurring-events.test.ts | 12 + src/app/golf/actions/development.ts | 106 ++- src/app/golf/actions/golf.ts | 362 ++++++++-- src/app/golf/actions/message-attachments.ts | 38 + src/app/golf/actions/recruiting.ts | 14 +- src/app/golf/actions/recurring-events.ts | 429 +++++++++++- src/app/golf/actions/round-reviews.ts | 36 +- src/app/golf/actions/tasks.ts | 51 +- src/app/golf/actions/travel.ts | 13 +- .../(dashboard)/dashboard/lift/page.tsx | 1 + .../import-center/ImportWizardClient.tsx | 5 + .../player-profile/PlayerNotesSection.tsx | 217 ++++-- .../player-profile/PlayerProfileClient.tsx | 71 +- .../PracticePlannerClient.tsx | 62 +- .../practice-planner/PracticePrintExport.tsx | 7 +- .../practice-planner/PracticeRecapPanel.tsx | 19 +- .../__tests__/PracticeRecapPanel.test.tsx | 98 +++ src/components/coach/lineup/LineupBuilder.tsx | 79 ++- .../pages/calendar/FairwayEventCard.tsx | 18 +- .../pages/calendar/FairwayMonthGrid.tsx | 11 +- .../pages/coachhelm/FairwayStatsCockpit.tsx | 2 +- .../fairway/pages/coachhelm/FocusAreaCard.tsx | 28 +- .../pages/coachhelm/PlayersGridView.tsx | 98 ++- .../pages/qualifiers/FairwayEditQualifier.tsx | 405 +++++++++++ .../qualifiers/FairwayQualifierDetail.tsx | 21 +- .../FairwayQualifierLeaderboard.tsx | 61 +- .../qualifiers/FairwayQualifyingWorkspace.tsx | 79 +++ .../pages/roster/FairwayJoinRequests.test.tsx | 95 +++ .../pages/roster/FairwayJoinRequests.tsx | 27 +- .../FairwaySettingsCoachingIntelligence.tsx | 23 + .../golf/calendar/PremiumCalendarClient.tsx | 21 +- .../lifting/players/PlayerLiftHomeClient.tsx | 28 +- .../__tests__/PlayerLiftHomeClient.test.tsx | 129 ++++ .../coverage-contract.foundation.test.ts | 8 +- .../admin/__tests__/feature-registry.test.ts | 8 +- .../__tests__/github-pr-timeline.test.ts | 87 +++ src/lib/admin/feature-registry.ts | 2 + src/lib/admin/github-pr-timeline.ts | 42 +- src/lib/baseball/nav-registry.ts | 11 + .../__tests__/roster-aggregates-merge.test.ts | 98 +++ .../read-models/roster-aggregates-merge.ts | 80 +++ src/lib/baseball/read-models/roster.ts | 26 +- src/lib/baseball/stat-layer-manifest.ts | 13 + src/lib/coachhelm/v3/qualifying/loader.ts | 2 + .../coachhelm/v3/qualifying/player-notify.ts | 81 +++ src/lib/coachhelm/v3/qualifying/service.ts | 13 + src/lib/coachhelm/v3/qualifying/types.ts | 5 + src/lib/notifications/golf-message-fanout.ts | 109 +++ src/lib/notifications/push.ts | 45 +- src/lib/types/database.ts | 42 +- ...confirm-selection-chat.integration.test.ts | 1 + src/test/coachhelm/v3/player-notify.test.ts | 130 ++++ src/test/coachhelm/v3/travel-brief.test.ts | 1 + .../settings-coaching-intelligence.test.tsx | 50 +- src/test/golf/actions/development.test.ts | 146 +++- src/test/lib/notifications/push.test.ts | 97 ++- ...ll_settings_audit_log_column_reconcile.sql | 119 ++++ ...0_baseball_decision_log_kind_reconcile.sql | 138 ++++ ...aseball_practice_blocks_visibility_rls.sql | 89 +++ ...0710160000_mark_golf_messages_read_rpc.sql | 53 ++ 95 files changed, 8368 insertions(+), 489 deletions(-) create mode 100644 src/app/baseball/(dashboard)/dashboard/academics/ClassScheduleModal.tsx create mode 100644 src/app/baseball/(dashboard)/dashboard/compare/__tests__/search-recruitability.test.ts create mode 100644 src/app/baseball/(dashboard)/dashboard/roster/RosterMemberActions.tsx create mode 100644 src/app/baseball/(dashboard)/dashboard/roster/SavedLineupsPanel.tsx create mode 100644 src/app/baseball/actions/__tests__/academics-coach-gating.test.ts create mode 100644 src/app/baseball/actions/__tests__/assign-player-membership-fields.test.ts create mode 100644 src/app/baseball/actions/__tests__/decision-log-column-reconcile.test.ts create mode 100644 src/app/baseball/actions/__tests__/dev-plans-coach-gating.test.ts create mode 100644 src/app/baseball/actions/__tests__/imports-event-grain-guard.test.ts create mode 100644 src/app/baseball/actions/practice-planner-capability.ts create mode 100644 src/app/golf/(dashboard)/dashboard/qualifiers/[id]/edit/error.tsx create mode 100644 src/app/golf/(dashboard)/dashboard/qualifiers/[id]/edit/loading.tsx create mode 100644 src/app/golf/(dashboard)/dashboard/qualifiers/[id]/edit/page.tsx create mode 100644 src/app/golf/(dashboard)/dashboard/rounds/[id]/review/CoachNotesSection.tsx create mode 100644 src/app/golf/(dashboard)/dashboard/rounds/[id]/review/__tests__/CoachNotesSection.test.tsx create mode 100644 src/app/golf/actions/__tests__/golf-save-partial-round.test.ts create mode 100644 src/components/baseball/practice-planner/__tests__/PracticeRecapPanel.test.tsx create mode 100644 src/components/fairway/pages/qualifiers/FairwayEditQualifier.tsx create mode 100644 src/components/fairway/pages/roster/FairwayJoinRequests.test.tsx create mode 100644 src/components/lifting/players/__tests__/PlayerLiftHomeClient.test.tsx create mode 100644 src/lib/admin/__tests__/github-pr-timeline.test.ts create mode 100644 src/lib/baseball/read-models/__tests__/roster-aggregates-merge.test.ts create mode 100644 src/lib/baseball/read-models/roster-aggregates-merge.ts create mode 100644 src/lib/coachhelm/v3/qualifying/player-notify.ts create mode 100644 src/lib/notifications/golf-message-fanout.ts create mode 100644 src/test/coachhelm/v3/player-notify.test.ts create mode 100644 supabase/migrations/20260710020000_baseball_settings_audit_log_column_reconcile.sql create mode 100644 supabase/migrations/20260710031500_baseball_decision_log_kind_reconcile.sql create mode 100644 supabase/migrations/20260710150000_baseball_practice_blocks_visibility_rls.sql create mode 100644 supabase/migrations/20260710160000_mark_golf_messages_read_rpc.sql diff --git a/src/app/actions/messages.ts b/src/app/actions/messages.ts index 8af3b4fee..bdf2f6d4d 100644 --- a/src/app/actions/messages.ts +++ b/src/app/actions/messages.ts @@ -10,9 +10,7 @@ import { logSecurityEvent, } from '@/lib/validation/server-action-validator'; import { MessageSchemas } from '@/lib/validation/action-schemas'; -import { notifyNewMessage } from '@/lib/notifications'; -import { sendPushNotification } from '@/lib/notifications/push'; -import { createAdminClient } from '@/lib/supabase/admin'; +import { notifyGolfMessageRecipients } from '@/lib/notifications/golf-message-fanout'; import { logServerError } from '@/lib/server-error-logger'; import { resolveCoachTeamIdWithCookie } from '@/lib/golf/resolve-team-server'; import { getCoachTeamSwitchContext } from '@/lib/golf/resolve-team'; @@ -384,12 +382,36 @@ export async function markMessagesAsRead({ // so this write is what clears the 1:1 badge and stays reconciled with that count. // last_read_at already committed above, so a failure here is non-fatal: log it but // still revalidate + report success so the badge isn't stuck on a transient error. - const messagesTable = sport === 'golf' ? 'golf_messages' : 'baseball_messages'; - const { error: messagesError } = await supabase - .from(messagesTable as any) - .update({ read: true }) - .eq('conversation_id', conversationId) - .neq('sender_id', user.id); + // + // Golf-only wrinkle: `golf_messages_update_v2` is `USING (sender_id = + // auth.uid()) WITH CHECK (sender_id = auth.uid())` — mutually exclusive + // with the `sender_id != viewer` filter this write needs, so a plain + // client-side update here always affects 0 rows on golf_messages (the 1:1 + // badge could never clear). Route golf through the participant-checked + // `mark_golf_messages_read` SECURITY DEFINER RPC instead (see migration + // 20260710160000_mark_golf_messages_read_rpc.sql), which bypasses that + // sender-only policy from inside a function that itself verifies the + // caller is a participant. Baseball's `baseball_messages_update`/ + // `_update_read` policies already permit any conversation participant + // (not sender-only) to flip the column, so the direct update keeps + // working there unchanged. If the RPC migration hasn't been applied yet, + // this degrades gracefully: the call errors, is logged as non-fatal below + // (same as any other messagesError), and last_read_at (committed above) + // still clears the group-chat badge. + let messagesError: { message: string } | null = null; + if (sport === 'golf') { + const { error } = await (supabase as any).rpc('mark_golf_messages_read', { + p_conversation_id: conversationId, + }); + messagesError = error; + } else { + const { error } = await supabase + .from('baseball_messages' as any) + .update({ read: true }) + .eq('conversation_id', conversationId) + .neq('sender_id', user.id); + messagesError = error; + } if (messagesError) { await logServerError(`[Messages] Failed to mark messages as read: ${messagesError instanceof Error ? messagesError.message : String(messagesError)}`, { action: 'messages.markMessagesAsRead' }); @@ -424,88 +446,12 @@ async function sendGolfMessageImpl(conversationId: string, content: string) { const result = await sendMessage({ conversationId, content, sport: 'golf', createNotifications: false }); if (result.success) { - // Send email notifications to other participants (fire-and-forget) - try { - const supabase = await createClient(); - const { data: { user } } = await supabase.auth.getUser(); - - if (user) { - // Get other participants' user IDs - const { data: otherParticipants } = await (supabase as any) - .from('golf_conversation_participants') - .select('user_id') - .eq('conversation_id', conversationId) - .neq('user_id', user.id) as { data: { user_id: string }[] | null }; - - if (otherParticipants && otherParticipants.length > 0) { - const recipientUserIds = otherParticipants.map(p => p.user_id); - - // Batch every lookup into ONE round-trip of parallel queries: sender name - // (coach → player → email fallback) and all recipient emails via a single - // .in() instead of sequential per-recipient fetches. - const [ - { data: senderCoach }, - { data: senderPlayer }, - { data: senderUser }, - { data: recipientProfiles }, - ] = await Promise.all([ - supabase.from('golf_coaches').select('full_name').eq('user_id', user.id).maybeSingle(), - supabase.from('golf_players').select('first_name, last_name').eq('user_id', user.id).maybeSingle(), - supabase.from('users').select('email').eq('id', user.id).maybeSingle(), - supabase.from('users').select('id, email').in('id', recipientUserIds), - ]); - - const senderName = senderCoach?.full_name - || (senderPlayer ? `${senderPlayer.first_name || ''} ${senderPlayer.last_name || ''}`.trim() : '') - || senderUser?.email - || 'Someone'; - const preview = content.length > 80 ? content.substring(0, 80) + '…' : content; - - if (recipientProfiles) { - // Email notifications - await Promise.allSettled( - recipientProfiles.map(r => - r.email - ? notifyNewMessage(r.id, r.email, senderName, preview, conversationId, 'golf') - : Promise.resolve() - ) - ); - - // Push notifications — carry the conversation id so the push payload - // deep-links straight to the thread that fired it (P260). - await Promise.allSettled( - recipientProfiles.map(r => - sendPushNotification('new_message', r.id, { - senderName, - preview, - conversationId, - }) - ) - ); + const supabase = await createClient(); + const { data: { user } } = await supabase.auth.getUser(); - // In-app notifications (golf_calendar_notifications) - // P260: deep-link to the conversation that fired the notification via - // ?conversation= (NotificationCenter does router.push(action_url), - // and FairwayMessages pre-selects from this param). Mirrors the existing - // ?event= / ?task= deep-link convention used elsewhere in this codebase. - const inAppNotifs = recipientProfiles.map(r => ({ - user_id: r.id, - notification_type: 'message', - title: `Message from ${senderName}`, - message: preview, - action_url: `/golf/dashboard/messages?conversation=${conversationId}`, - })); - // Use admin client to bypass RLS — inserting notifications for other users - const adminClient = createAdminClient(); - await (adminClient as any) - .from('golf_calendar_notifications') - .insert(inAppNotifs); - } - } - } - } catch (notifErr) { - // Never block message delivery on notification failure - await logServerError(`[sendGolfMessage] Notification error (non-fatal): ${notifErr instanceof Error ? notifErr.message : String(notifErr)}`, { action: 'messages.sendGolfMessage' }); + if (user) { + const preview = content.length > 80 ? content.substring(0, 80) + '…' : content; + await notifyGolfMessageRecipients(conversationId, user.id, preview); } } @@ -570,7 +516,7 @@ async function createGolfTeamBroadcastImpl({ teamId, title, selectedPlayerIds, -}: CreateTeamBroadcastOptions): Promise<{ conversationId: string } | { error: string }> { +}: CreateTeamBroadcastOptions): Promise<{ conversationId: string; reused?: boolean } | { error: string }> { try { const supabase = await createClient(); @@ -631,18 +577,78 @@ async function createGolfTeamBroadcastImpl({ throw new Error('No players with accounts found on this team'); } - // Check if a team broadcast with this title already exists - const { data: existingConv } = await supabase + // Check if a team broadcast with this title + audience already exists. + // Dedupe by (team_id, title, participant set) instead of title alone — + // title-only dedupe (the prior behavior) silently reused a STALE + // conversation whenever a coach reused a title (the sheet's own + // TITLE_SUGGESTIONS chips actively encourage this — "Practice" for the + // JV squad after already using "Practice" for varsity) for a DIFFERENT + // audience, discarding the freshly-selected selectedPlayerIds entirely. + const desiredParticipantIds = [...new Set([user.id, ...playerUserIds])].sort(); + + const { data: existingConvs } = await supabase .from('golf_conversations') .select('id') .eq('team_id', teamId) .eq('is_team_chat', true) .eq('title', title) - .single(); + .order('created_at', { ascending: true }); - if (existingConv) { - // Return existing conversation instead of creating duplicate - return { conversationId: existingConv.id }; + if (existingConvs && existingConvs.length > 0) { + for (const candidate of existingConvs) { + const { data: candidateParticipants } = await supabase + .from('golf_conversation_participants') + .select('user_id') + .eq('conversation_id', candidate.id); + + const candidateIds = [...new Set((candidateParticipants ?? []).map((p) => p.user_id))].sort(); + const sameAudience = + candidateIds.length === desiredParticipantIds.length && + candidateIds.every((id, i) => id === desiredParticipantIds[i]); + + if (sameAudience) { + // Exact audience match — reuse in place, nothing to reconcile. + return { conversationId: candidate.id, reused: true }; + } + } + + // A broadcast with this title exists, but for a DIFFERENT audience + // than the one just selected. Reuse the oldest such thread (rather + // than fragmenting into yet another same-titled conversation) but + // reconcile membership first: add anyone newly selected who isn't + // already a participant, so the message the coach is about to send + // actually reaches every recipient they just chose. Never remove + // existing participants here — golf messaging never does destructive + // participant writes. + const reuseTarget = existingConvs[0]; + if (reuseTarget) { + const { data: existingParticipants } = await supabase + .from('golf_conversation_participants') + .select('user_id') + .eq('conversation_id', reuseTarget.id); + + const existingIds = new Set((existingParticipants ?? []).map((p) => p.user_id)); + const missingIds = desiredParticipantIds.filter((id) => !existingIds.has(id)); + + if (missingIds.length > 0) { + const { error: syncError } = await supabase + .from('golf_conversation_participants') + .insert( + missingIds.map((userId) => ({ + conversation_id: reuseTarget.id, + user_id: userId, + joined_at: new Date().toISOString(), + })), + ); + + if (syncError) { + await logServerError(`[Broadcast] Failed to sync participants on reuse: ${syncError instanceof Error ? syncError.message : String(syncError)}`, { action: 'messages.createGolfTeamBroadcast' }); + throw new Error(`Failed to update broadcast recipients: ${syncError.message}`); + } + } + + return { conversationId: reuseTarget.id, reused: true }; + } } // Create new group conversation @@ -700,7 +706,7 @@ async function createGolfTeamBroadcastImpl({ revalidatePath('/golf/dashboard/messages'); - return { conversationId }; + return { conversationId, reused: false }; } catch (err) { return formatSafeErrorResponse(err); } @@ -714,7 +720,7 @@ const observedCreateGolfTeamBroadcast = withAdminObserved( export async function createGolfTeamBroadcast( options: CreateTeamBroadcastOptions, -): Promise<{ conversationId: string } | { error: string }> { +): Promise<{ conversationId: string; reused?: boolean } | { error: string }> { return observedCreateGolfTeamBroadcast(options); } diff --git a/src/app/baseball/(dashboard)/dashboard/academics/AcademicsClient.tsx b/src/app/baseball/(dashboard)/dashboard/academics/AcademicsClient.tsx index 8dacdab0f..ab1da4e58 100644 --- a/src/app/baseball/(dashboard)/dashboard/academics/AcademicsClient.tsx +++ b/src/app/baseball/(dashboard)/dashboard/academics/AcademicsClient.tsx @@ -8,11 +8,14 @@ import { Avatar } from '@/components/ui/avatar'; import { PageLoading } from '@/components/ui/loading'; import { Input } from '@/components/ui/input'; import { Select } from '@/components/ui/select'; -import { IconEdit } from '@/components/icons'; +import { IconEdit, IconBook, IconShieldAlert } from '@/components/icons'; import { useAuth } from '@/hooks/use-auth'; import { useTeamStore } from '@/stores/team-store'; import { getFullName } from '@/lib/utils'; +import { useToast } from '@/components/ui/sonner'; import { getTeamAcademics, upsertPlayerAcademics } from '@/app/baseball/actions/academics'; +import { runClassConflictDetection } from '@/app/baseball/actions/video-classes'; +import { ClassScheduleModal } from './ClassScheduleModal'; import { SectionMasthead, EditorsLetter, @@ -42,6 +45,7 @@ interface StudentAthlete { is_eligible: boolean | null; academic_standing: 'good' | 'warning' | 'probation' | null; eligibility_id: string | null; + class_count: number; } interface EditValues { @@ -112,6 +116,7 @@ function SkeletonCard() { export default function AcademicsPage() { const { loading: authLoading } = useAuth(); const { selectedTeamId } = useTeamStore(); + const { showToast } = useToast(); const [students, setStudents] = useState([]); const [loading, setLoading] = useState(true); @@ -125,6 +130,8 @@ export default function AcademicsPage() { academic_standing: null, }); const [saving, setSaving] = useState(false); + const [classesStudent, setClassesStudent] = useState(null); + const [checkingConflicts, setCheckingConflicts] = useState(false); const fetchStudentAthletes = useCallback(async () => { if (!selectedTeamId) return; @@ -155,6 +162,7 @@ export default function AcademicsPage() { is_eligible: row.is_eligible, academic_standing: row.academic_standing, eligibility_id: row.eligibility_id, + class_count: row.class_count, })); setStudents(transformed); @@ -234,6 +242,35 @@ export default function AcademicsPage() { setEditValues({ gpa: null, credits_completed: null, credits_required: null, is_eligible: true, academic_standing: null }); }; + // Re-derives class-vs-obligation conflicts for the whole team from whatever + // class schedules are currently on file (via the Classes editor below) and + // surfaces them into the Signal Inbox — see runClassConflictDetection + // (video-classes.ts). Without this trigger the engine never runs, so + // baseball_class_conflicts stays empty even after classes are entered. + const handleCheckConflicts = async () => { + setCheckingConflicts(true); + try { + const result = await runClassConflictDetection(); + if (!result.success) { + showToast(result.error ?? 'Could not check class conflicts.', 'error'); + return; + } + const { detected = 0, hard = 0 } = result.stats ?? {}; + if (detected === 0) { + showToast('No class conflicts found.', 'success'); + } else { + showToast( + `Found ${detected} class conflict${detected === 1 ? '' : 's'} (${hard} hard) — sent to the Signal Inbox.`, + 'success', + ); + } + } catch (err) { + showToast(err instanceof Error ? err.message : 'Could not check class conflicts.', 'error'); + } finally { + setCheckingConflicts(false); + } + }; + if (authLoading) { return ; } @@ -375,7 +412,22 @@ export default function AcademicsPage() { return (
- + + + {checkingConflicts ? 'Checking…' : 'Check Class Conflicts'} + + } + >

Track student-athlete academic progress and eligibility

@@ -540,14 +592,25 @@ export default function AcademicsPage() {
) : ( - +
+ + +
)} @@ -685,9 +748,15 @@ export default function AcademicsPage() { ) : ( - +
+ + +
)} @@ -698,6 +767,17 @@ export default function AcademicsPage() { + + {classesStudent && ( + setClassesStudent(null)} + playerId={classesStudent.player_id} + teamId={selectedTeamId ?? ''} + playerName={getFullName(classesStudent.first_name, classesStudent.last_name)} + onChanged={() => void fetchStudentAthletes()} + /> + )} ); } diff --git a/src/app/baseball/(dashboard)/dashboard/academics/ClassScheduleModal.tsx b/src/app/baseball/(dashboard)/dashboard/academics/ClassScheduleModal.tsx new file mode 100644 index 000000000..30b72f967 --- /dev/null +++ b/src/app/baseball/(dashboard)/dashboard/academics/ClassScheduleModal.tsx @@ -0,0 +1,341 @@ +'use client'; + +// ============================================================================= +// ClassScheduleModal — minimal class-schedule editor for a single +// student-athlete, opened from AcademicsClient's roster table/cards. +// +// WHY THIS EXISTS: getPlayerClasses/addPlayerClass/updatePlayerClass/ +// deletePlayerClass (src/app/baseball/actions/academics.ts) were fully built +// server-side but had zero UI callers — a coach could never enter a player's +// class schedule, so the class-conflict engine (class-conflict-engine.ts, +// triggered via runClassConflictDetection) never had any input data and +// baseball_class_conflicts stayed empty forever, which the Practice Planner +// and Decision Room read as "verified clean" rather than "never populated." +// This wires the existing, already-gated (assertPlayerClassAccess: coach with +// can_view_academics + team membership) CRUD into a real UI. +// ============================================================================= + +import { useState, useEffect, useCallback } from 'react'; +import { Modal, ConfirmModal } from '@/components/ui/modal'; +import { Button, IconButton } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { IconPlus, IconEdit, IconTrash } from '@/components/icons'; +import { useToast } from '@/components/ui/sonner'; +import { + getPlayerClasses, + addPlayerClass, + updatePlayerClass, + deletePlayerClass, + type BaseballPlayerClass, +} from '@/app/baseball/actions/academics'; + +const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + +interface ClassScheduleModalProps { + open: boolean; + onClose: () => void; + playerId: string; + teamId: string; + playerName: string; + /** Lets the parent refresh its per-player class_count after a change. */ + onChanged?: () => void; +} + +interface ClassFormState { + id?: string; + class_name: string; + instructor: string; + days: string[]; + start_time: string; + end_time: string; + building: string; + room: string; + credits: string; + notes: string; +} + +const EMPTY_FORM: ClassFormState = { + class_name: '', + instructor: '', + days: [], + start_time: '', + end_time: '', + building: '', + room: '', + credits: '', + notes: '', +}; + +function formatSchedule(cls: BaseballPlayerClass): string { + const days = cls.days && cls.days.length > 0 ? cls.days.join('/') : 'No days set'; + const time = cls.start_time && cls.end_time ? `${cls.start_time}–${cls.end_time}` : 'No time set'; + return `${days} · ${time}`; +} + +export function ClassScheduleModal({ + open, + onClose, + playerId, + teamId, + playerName, + onChanged, +}: ClassScheduleModalProps) { + const { showToast } = useToast(); + const [classes, setClasses] = useState([]); + const [loading, setLoading] = useState(true); + const [showForm, setShowForm] = useState(false); + const [form, setForm] = useState(EMPTY_FORM); + const [saving, setSaving] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleting, setDeleting] = useState(false); + + const fetchClasses = useCallback(async () => { + setLoading(true); + const result = await getPlayerClasses(playerId); + if (result.success) { + setClasses(result.data ?? []); + } else { + showToast(result.error, 'error'); + } + setLoading(false); + }, [playerId, showToast]); + + useEffect(() => { + if (open) { + setShowForm(false); + setForm(EMPTY_FORM); + void fetchClasses(); + } + // Only re-fetch when the modal opens for a (possibly new) player. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, playerId]); + + const toggleDay = (day: string) => { + setForm((f) => ({ + ...f, + days: f.days.includes(day) ? f.days.filter((d) => d !== day) : [...f.days, day], + })); + }; + + const startAdd = () => { + setForm(EMPTY_FORM); + setShowForm(true); + }; + + const startEdit = (cls: BaseballPlayerClass) => { + setForm({ + id: cls.id, + class_name: cls.class_name, + instructor: cls.instructor ?? '', + days: cls.days ?? [], + start_time: cls.start_time ?? '', + end_time: cls.end_time ?? '', + building: cls.building ?? '', + room: cls.room ?? '', + credits: cls.credits !== null && cls.credits !== undefined ? String(cls.credits) : '', + notes: cls.notes ?? '', + }); + setShowForm(true); + }; + + const handleSave = async () => { + if (!form.class_name.trim()) { + showToast('Class name is required.', 'error'); + return; + } + setSaving(true); + + const basePayload = { + class_name: form.class_name.trim(), + instructor: form.instructor.trim() || undefined, + days: form.days.length > 0 ? form.days : undefined, + start_time: form.start_time || undefined, + end_time: form.end_time || undefined, + building: form.building.trim() || undefined, + room: form.room.trim() || undefined, + credits: form.credits ? Number(form.credits) : undefined, + notes: form.notes.trim() || undefined, + }; + + const result = form.id + ? await updatePlayerClass(form.id, basePayload) + : await addPlayerClass(playerId, { ...basePayload, team_id: teamId }); + + setSaving(false); + + if (!result.success) { + showToast(result.error, 'error'); + return; + } + + showToast(form.id ? 'Class updated.' : 'Class added.', 'success'); + setShowForm(false); + setForm(EMPTY_FORM); + await fetchClasses(); + onChanged?.(); + }; + + const handleDelete = async () => { + if (!deleteTarget) return; + setDeleting(true); + const result = await deletePlayerClass(deleteTarget.id); + setDeleting(false); + + if (!result.success) { + showToast(result.error, 'error'); + return; + } + + showToast('Class deleted.', 'success'); + setDeleteTarget(null); + await fetchClasses(); + onChanged?.(); + }; + + return ( + <> + +
+ {loading ? ( +
+ {[1, 2].map((i) => ( +
+ ))} +
+ ) : showForm ? ( +
+ setForm({ ...form, class_name: e.target.value })} + aria-label="Class name" + /> + setForm({ ...form, instructor: e.target.value })} + aria-label="Instructor" + /> +
+ {DAYS.map((day) => { + const active = form.days.includes(day); + return ( + + ); + })} +
+
+ setForm({ ...form, start_time: e.target.value })} + aria-label="Start time" + /> + setForm({ ...form, end_time: e.target.value })} + aria-label="End time" + /> +
+
+ setForm({ ...form, building: e.target.value })} + aria-label="Building" + /> + setForm({ ...form, room: e.target.value })} + aria-label="Room" + /> +
+ setForm({ ...form, credits: e.target.value })} + aria-label="Credits" + /> +
+ + +
+
+ ) : classes.length === 0 ? ( +
+

No classes on file yet.

+ +
+ ) : ( +
+ {classes.map((cls) => ( +
+
+

{cls.class_name}

+

{formatSchedule(cls)}

+ {(cls.building || cls.room) && ( +

+ {[cls.building, cls.room].filter(Boolean).join(' ')} +

+ )} +
+
+ startEdit(cls)}> + + + setDeleteTarget(cls)}> + + +
+
+ ))} + +
+ )} +
+ + + setDeleteTarget(null)} + onConfirm={handleDelete} + title="Delete this class?" + description={deleteTarget ? `Remove "${deleteTarget.class_name}" from ${playerName}'s schedule.` : undefined} + confirmText="Delete" + variant="danger" + isLoading={deleting} + /> + + ); +} diff --git a/src/app/baseball/(dashboard)/dashboard/compare/CompareClient.tsx b/src/app/baseball/(dashboard)/dashboard/compare/CompareClient.tsx index aa996a6d8..d91e94f04 100644 --- a/src/app/baseball/(dashboard)/dashboard/compare/CompareClient.tsx +++ b/src/app/baseball/(dashboard)/dashboard/compare/CompareClient.tsx @@ -4,10 +4,17 @@ // CompareClient — the coach's recruit side-by-side comparison surface, // migrated onto "The Living Annual" kit (Lane 2 · THE WAR ROOM, clay ink — // spec §6 P3 #10 "Decision Room / Compare Overlay"). PRESENTATION ONLY: the -// URL-driven player-id state, the player fetch/search effects, and add/remove -// handlers are unchanged — only the page chrome (masthead, skeleton, empty -// state) moved to the kit. `PlayerComparison` renders the actual comparison -// table and is out of scope for this pass. +// URL-driven player-id state and add/remove handler shapes are unchanged — +// only the page chrome (masthead, skeleton, empty state) moved to the kit. +// `PlayerComparison` renders the actual comparison table and is out of +// scope for this pass. +// EXCEPTION (P0 fix): the player fetch/search effects no longer query +// `baseball_players` directly from the client — they call the gated +// `getComparablePlayers`/`searchRecruitablePlayers`/`canAddPlayerToCompare` +// server actions (./actions.ts) instead. The old direct queries had no +// privacy/recruitability filtering at all — a private, college, or +// off-territory player's full profile (and a bookmarkable +// `/compare?players=` URL for one) had zero eligibility check. // ============================================================================= import { Suspense, useState, useEffect, useRef } from 'react'; @@ -20,9 +27,10 @@ import { Skeleton } from '@/components/ui/skeleton'; import { ReadModelStateNotice } from '@/components/baseball/ReadModelStateNotice'; import { PlayerComparison } from '@/components/features/player-comparison'; import { IconSearch, IconPlus, IconX, IconUsers } from '@/components/icons'; -import { createClient } from '@/lib/supabase/client'; +import { useToast } from '@/components/ui/sonner'; import { cn, getFullName } from '@/lib/utils'; import { SectionMasthead, PaperCard, InkBadge, EditorsLetter, Eyebrow } from '@/components/baseball/living-annual'; +import { searchRecruitablePlayers, getComparablePlayers, canAddPlayerToCompare } from './actions'; import type { Player } from '@/lib/types'; const PAGE_SHELL = 'mx-auto w-full max-w-[1536px] px-4 py-8 sm:px-6'; @@ -47,14 +55,14 @@ function useDebounce(value: T, delay: number): T { function CompareContent() { const router = useRouter(); const searchParams = useSearchParams(); + const { showToast } = useToast(); const [players, setPlayers] = useState([]); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [searchResults, setSearchResults] = useState([]); const [searching, setSearching] = useState(false); - const supabaseRef = useRef(createClient()); - const supabase = supabaseRef.current; + const [addingPlayerId, setAddingPlayerId] = useState(null); // Debounce the search box so every keystroke doesn't fire a query; the input // itself stays fully responsive since `searchQuery` updates immediately. const debouncedSearchQuery = useDebounce(searchQuery, 300); @@ -77,16 +85,17 @@ function CompareContent() { setLoading(true); setLoadError(null); try { - const { data, error } = await supabase - .from('baseball_players') - .select('*') - .in('id', playerIds); - - if (error) throw error; + // Server-gated (P0 fix): getComparablePlayers re-applies the same + // recruitability checks as the search below (private/college/ + // off-territory/own-roster excluded) so a bookmarked + // `/compare?players=` URL can't bypass them and pull in a + // player's full profile with zero eligibility check. + const data = await getComparablePlayers(playerIds); // Supabase/Postgres does not guarantee `.in()` results match the // order of the id list, so re-sort fetched players to match the - // URL's player order (falling back to omitting any id not found). + // URL's player order (falling back to omitting any id not found, + // including ids the recruitability gate excluded). const byId = new Map((data || []).map((player) => [player.id, player])); const ordered = playerIds .map((id) => byId.get(id)) @@ -101,7 +110,7 @@ function CompareContent() { } fetchPlayers(); - // eslint-disable-next-line react-hooks/exhaustive-deps -- `supabase` is stable (module-scope) and `playerIds` is recomputed each render from `searchParams`; adding it would loop. + // eslint-disable-next-line react-hooks/exhaustive-deps -- `playerIds` is recomputed each render from `searchParams`; adding it would loop. }, [searchParams]); const MAX_PLAYERS = 4; @@ -137,21 +146,12 @@ function CompareContent() { (async () => { try { - let queryBuilder = supabase - .from('baseball_players') - .select('*') - .eq('recruiting_activated', true) - .or(`first_name.ilike.%${query}%,last_name.ilike.%${query}%,high_school_name.ilike.%${query}%`) - .limit(10); - - // Only add NOT IN filter if there are playerIds - if (playerIds.length > 0) { - queryBuilder = queryBuilder.not('id', 'in', `(${playerIds.join(',')})`); - } + // Server-gated (P0 fix): searchRecruitablePlayers excludes private + // profiles, college players, off-territory players, and the + // coach's own roster — the raw client-side query here previously + // applied none of those checks (only `recruiting_activated`). + const data = await searchRecruitablePlayers(query, playerIds); - const { data, error } = await queryBuilder; - - if (error) throw error; if (searchRequestRef.current !== requestId) return; // stale — a newer search is in flight setSearchResults(data || []); } catch (error) { @@ -164,14 +164,31 @@ function CompareContent() { } } })(); - // eslint-disable-next-line react-hooks/exhaustive-deps -- `supabase` is stable (module-scope); `playerIds` is recomputed fresh from `searchParams` every render (see the fetchPlayers effect above), so listing it here would re-run this on every render. Adding a player already clears `searchQuery`/`searchResults` directly via `addPlayer`, so re-running on `playerIds` alone isn't needed. + // eslint-disable-next-line react-hooks/exhaustive-deps -- `playerIds` is recomputed fresh from `searchParams` every render (see the fetchPlayers effect above), so listing it here would re-run this on every render. Adding a player already clears `searchQuery`/`searchResults` directly via `addPlayer`, so re-running on `playerIds` alone isn't needed. }, [debouncedSearchQuery, canAddMore]); - const addPlayer = (player: Player) => { - const newIds = [...playerIds, player.id]; - router.push(`/baseball/dashboard/compare?players=${newIds.join(',')}`); - setSearchQuery(''); - setSearchResults([]); + const addPlayer = async (player: Player) => { + // Defense-in-depth (P0 fix): the search results above are already + // recruitability-filtered, but this stops an ineligible id from ever + // landing in the `?players=` URL — mirroring Watchlist's addToWatchlist + // server-side gate — instead of relying on it silently vanishing from + // render once getComparablePlayers re-validates on the next fetch. + setAddingPlayerId(player.id); + try { + const { allowed } = await canAddPlayerToCompare(player.id); + if (!allowed) { + showToast('This player is not available for recruiting', 'error'); + return; + } + const newIds = [...playerIds, player.id]; + router.push(`/baseball/dashboard/compare?players=${newIds.join(',')}`); + setSearchQuery(''); + setSearchResults([]); + } catch { + showToast('Failed to add player', 'error'); + } finally { + setAddingPlayerId(null); + } }; const removePlayer = (playerId: string) => { @@ -270,10 +287,12 @@ function CompareContent() {
{searchResults.map((player) => { const name = getFullName(player.first_name, player.last_name); + const isAddingThis = addingPlayerId === player.id; return (
- + {isAddingThis ? ( + Adding… + ) : ( + + )} ); })} diff --git a/src/app/baseball/(dashboard)/dashboard/compare/__tests__/search-recruitability.test.ts b/src/app/baseball/(dashboard)/dashboard/compare/__tests__/search-recruitability.test.ts new file mode 100644 index 000000000..1086ef60f --- /dev/null +++ b/src/app/baseball/(dashboard)/dashboard/compare/__tests__/search-recruitability.test.ts @@ -0,0 +1,200 @@ +// ============================================================================= +// src/app/baseball/(dashboard)/dashboard/compare/__tests__/search-recruitability.test.ts +// +// P0 privacy/recruitability fix — Compare's "Add Players to Compare" search +// (and Watchlist's identical quick-add search, which now calls the same +// `searchRecruitablePlayers` action) previously ran a bare +// `.eq('recruiting_activated', true)` query with NO other exclusions. +// This locks in that the gated action excludes: +// 1. profile_visibility = 'private' players +// 2. player_type = 'college' players +// 3. players not on a discoverable HS/showcase/JUCO team +// 4. players on the coach's own roster +// while still surfacing an eligible player, mirroring the exact rule set +// `getDiscoverPlayers`/`assertCoachCanRecruitPlayer` already enforce +// elsewhere (discover-privacy.test.ts covers those call sites). +// ============================================================================= + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const ELIGIBLE_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const PRIVATE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; +const ROSTER_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; +const COLLEGE_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'; + +function playerRow(id: string, playerType: string) { + return { + id, + first_name: 'Test', + last_name: 'Player', + avatar_url: null, + primary_position: null, + grad_year: 2027, + high_school_name: 'Test High', + recruiting_activated: true, + player_type: playerType, + }; +} + +const playersRows = [ + playerRow(ELIGIBLE_ID, 'high_school'), + playerRow(PRIVATE_ID, 'high_school'), + playerRow(ROSTER_ID, 'high_school'), + playerRow(COLLEGE_ID, 'college'), +]; + +/** + * Behaviorally applies the filters `searchRecruitablePlayersImpl` chains + * onto `baseball_players` — `.eq('recruiting_activated', true)`, + * `.neq('player_type', 'college')`, and `.not('id', 'in', ...)` — so the + * test verifies the actual filtered output, not just call args. `.select`, + * `.or`, and `.in` (the JUCO player_type carve-out, unused by this + * fixture's 'college' coach type) are no-op passthroughs. + */ +function makePlayersChain(rows: typeof playersRows) { + const state: { neqPlayerType?: string; excludeIds?: string[]; limit?: number } = {}; + const chain: Record = {}; + chain.select = vi.fn(() => chain); + chain.or = vi.fn(() => chain); + chain.eq = vi.fn(() => chain); // recruiting_activated=true — every fixture row already satisfies this + chain.in = vi.fn(() => chain); + chain.neq = vi.fn((col: string, val: string) => { + if (col === 'player_type') state.neqPlayerType = val; + return chain; + }); + chain.limit = vi.fn((n: number) => { + state.limit = n; + return chain; + }); + chain.not = vi.fn((col: string, op: string, val: string) => { + if (col === 'id' && op === 'in') { + state.excludeIds = String(val).replace(/^\(|\)$/g, '').split(',').filter(Boolean); + } + return chain; + }); + const resolve = () => { + let result = rows; + if (state.neqPlayerType !== undefined) { + result = result.filter((r) => r.player_type !== state.neqPlayerType); + } + if (state.excludeIds) { + const exc = state.excludeIds; + result = result.filter((r) => !exc.includes(r.id)); + } + if (state.limit !== undefined) result = result.slice(0, state.limit); + return result; + }; + // `queryBuilder` is awaited directly (no terminal `.range()`/`.single()` + // call) — awaiting a plain non-thenable object resolves to itself, so + // destructuring `{ data, error }` off it reads these getters. + Object.defineProperty(chain, 'data', { get: () => resolve(), configurable: true }); + Object.defineProperty(chain, 'error', { get: () => null, configurable: true }); + return chain; +} + +const from = vi.fn((_table: string) => ({})); + +vi.mock('@/lib/supabase/server', () => ({ + createClient: vi.fn(async () => ({ + auth: { getUser: vi.fn(async () => ({ data: { user: { id: 'user-1' } } })) }, + from, + })), +})); +vi.mock('@/lib/server-error-logger', () => ({ + logServerError: vi.fn(async () => {}), + logServerException: vi.fn(async () => {}), +})); + +import { searchRecruitablePlayers } from '@/app/baseball/(dashboard)/dashboard/compare/actions'; + +describe('searchRecruitablePlayers — recruitability gating', () => { + beforeEach(() => { + vi.clearAllMocks(); + from.mockImplementation((table: string) => { + switch (table) { + case 'baseball_coaches': + return { + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + single: vi.fn(async () => ({ + data: { id: 'coach-1', coach_type: 'college' }, + error: null, + })), + })), + })), + }; + case 'baseball_team_coach_staff': + // Coach's own team. + return { + select: vi.fn(() => ({ + eq: vi.fn(async () => ({ data: [{ team_id: 'team-own' }], error: null })), + })), + }; + case 'organizations': + return { + select: vi.fn(() => ({ + in: vi.fn(async () => ({ data: [{ id: 'org-disco' }], error: null })), + })), + }; + case 'baseball_teams': + return { + select: vi.fn(() => ({ + in: vi.fn(async () => ({ data: [{ id: 'team-disco' }], error: null })), + })), + }; + case 'baseball_team_members': + return { + select: vi.fn(() => ({ + // team_id is 'team-own' (coach roster) vs 'team-disco' + // (discoverable team) — ROSTER_ID is deliberately on BOTH, so + // the test proves the own-roster exclusion actually removes a + // player that would otherwise be discoverable. + in: vi.fn(async (_col: string, teamIds: readonly string[]) => { + if (teamIds.includes('team-own')) { + return { data: [{ player_id: ROSTER_ID }], error: null }; + } + if (teamIds.includes('team-disco')) { + return { + data: [ + { player_id: ELIGIBLE_ID }, + { player_id: PRIVATE_ID }, + { player_id: ROSTER_ID }, + { player_id: COLLEGE_ID }, + ], + error: null, + }; + } + return { data: [], error: null }; + }), + })), + }; + case 'baseball_player_settings': + return { + select: vi.fn(() => ({ + eq: vi.fn(async () => ({ data: [{ player_id: PRIVATE_ID }], error: null })), + })), + }; + case 'baseball_players': + return makePlayersChain(playersRows); + default: + return {}; + } + }); + }); + + it('excludes private, college, and own-roster players; keeps the eligible one', async () => { + const results = await searchRecruitablePlayers('Test', []); + const ids = results.map((p) => p.id); + + expect(ids).toContain(ELIGIBLE_ID); + expect(ids).not.toContain(PRIVATE_ID); + expect(ids).not.toContain(COLLEGE_ID); + expect(ids).not.toContain(ROSTER_ID); + expect(ids).toHaveLength(1); + }); + + it('also excludes ids passed in excludeIds (already-added players)', async () => { + const results = await searchRecruitablePlayers('Test', [ELIGIBLE_ID]); + expect(results.map((p) => p.id)).not.toContain(ELIGIBLE_ID); + }); +}); diff --git a/src/app/baseball/(dashboard)/dashboard/compare/actions.ts b/src/app/baseball/(dashboard)/dashboard/compare/actions.ts index e04ff72a5..b7ce3b0f9 100644 --- a/src/app/baseball/(dashboard)/dashboard/compare/actions.ts +++ b/src/app/baseball/(dashboard)/dashboard/compare/actions.ts @@ -3,6 +3,9 @@ import { withAdminObserved } from '@/lib/admin/observed-action'; import { createClient } from '@/lib/supabase/server'; import { revalidatePath } from 'next/cache'; +import { assertCoachCanRecruitPlayer } from '@/lib/baseball/recruitability'; +import type { CoachType } from '@/app/baseball/actions/discover'; +import type { Player } from '@/lib/types'; interface SaveComparisonParams { name: string; @@ -153,6 +156,222 @@ async function getSavedComparisonsImpl() { return { comparisons: (comparisons || []) as SavedComparison[] }; } +// ============================================================================= +// Recruitability-gated search + fetch (P0 fix). `getDiscoverPlayers` in +// discover.ts already computes these exact 3 exclusion sets (private +// profiles, non-discoverable-team players, coach's own roster) plus +// `player_type != 'college'` — none of it was reused here, so Compare's +// "Add Players to Compare" search (and Watchlist's identical quick-add +// search) surfaced EVERY recruiting_activated player with zero other +// checks, and the URL-driven `?players=` fetch had no checks at all. The +// discover.ts helpers are module-private (not exported), so the same +// set-computation shape is reproduced locally rather than duplicating a +// per-candidate `assertCoachCanRecruitPlayer` DB round-trip for every +// search result. (sharedComponentRequest: export those 3 helpers from +// discover.ts so this becomes a straight import instead of a 3rd copy.) +// ============================================================================= + +async function getActiveCoachForRecruiting( + supabase: Awaited>, +): Promise<{ id: string; coachType: CoachType } | null> { + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return null; + + const { data: coach } = await supabase + .from('baseball_coaches') + .select('id, coach_type') + .eq('user_id', user.id) + .single(); + + if (!coach) return null; + + return { id: coach.id as string, coachType: coach.coach_type as CoachType }; +} + +async function getPrivatePlayerIdsForCompare( + supabase: Awaited>, +): Promise> { + const { data, error } = await supabase + .from('baseball_player_settings') + .select('player_id') + .eq('profile_visibility', 'private'); + + if (error) return new Set(); + return new Set((data ?? []).map((s) => s.player_id).filter(Boolean)); +} + +async function getDiscoverableTeamPlayerIdsForCompare( + supabase: Awaited>, +): Promise> { + const { data: orgs } = await supabase + .from('organizations') + .select('id') + .in('type', ['high_school', 'showcase', 'juco']); + + if (!orgs?.length) return new Set(); + const orgIds = orgs.map((o) => o.id); + + const { data: teams } = await supabase + .from('baseball_teams') + .select('id') + .in('organization_id', orgIds); + + if (!teams?.length) return new Set(); + const teamIds = teams.map((t) => t.id); + + const { data: members } = await supabase + .from('baseball_team_members') + .select('player_id') + .in('team_id', teamIds); + + return new Set((members ?? []).map((m) => m.player_id).filter(Boolean)); +} + +async function getCoachRosterPlayerIdsForCompare( + supabase: Awaited>, + coachId: string, +): Promise> { + const { data: staffEntries } = await supabase + .from('baseball_team_coach_staff') + .select('team_id') + .eq('coach_id', coachId); + + const teamIds = staffEntries?.map((e) => e.team_id) ?? []; + if (teamIds.length === 0) return new Set(); + + const { data: rosterPlayers } = await supabase + .from('baseball_team_members') + .select('player_id') + .in('team_id', teamIds); + + return new Set((rosterPlayers ?? []).map((p) => p.player_id).filter(Boolean)); +} + +/** + * Search recruitable players by name/school/high-school for the Compare + * page's "Add Players to Compare" box AND Watchlist's quick-add search + * (identical pattern, same gap, shared here on purpose). Excludes private + * profiles, college players, players off any discoverable HS/showcase/JUCO + * team, the coach's own roster, and (for JUCO coaches) other JUCO players — + * the same rule set `getDiscoverPlayers`/`assertCoachCanRecruitPlayer` + * already enforce elsewhere. + */ +async function searchRecruitablePlayersImpl( + query: string, + excludeIds: string[], +): Promise { + if (!query || query.length < 2) return []; + + const supabase = await createClient(); + const coach = await getActiveCoachForRecruiting(supabase); + if (!coach) return []; + + // HS/showcase coaches can't recruit anyone (same gate assertCoachCanRecruitPlayer applies). + if (coach.coachType === 'high_school' || coach.coachType === 'showcase') { + return []; + } + + const [privateIds, discoverableIds, rosterIds] = await Promise.all([ + getPrivatePlayerIdsForCompare(supabase), + getDiscoverableTeamPlayerIdsForCompare(supabase), + getCoachRosterPlayerIdsForCompare(supabase, coach.id), + ]); + + let queryBuilder = supabase + .from('baseball_players') + .select('*') + .eq('recruiting_activated', true) + .neq('player_type', 'college') + .or(`first_name.ilike.%${query}%,last_name.ilike.%${query}%,high_school_name.ilike.%${query}%`) + .limit(30); // over-fetch — the set-based exclusions below narrow this to <=10 + + if (coach.coachType === 'juco') { + // JUCO coaches can't recruit other JUCO players. + queryBuilder = queryBuilder.in('player_type', ['high_school', 'showcase'] as const); + } + + if (excludeIds.length > 0) { + queryBuilder = queryBuilder.not('id', 'in', `(${excludeIds.join(',')})`); + } + + const { data, error } = await queryBuilder; + if (error) { + console.error('Error searching recruitable players:', error); + return []; + } + + return (data ?? []) + .filter((player) => + discoverableIds.has(player.id) && + !privateIds.has(player.id) && + !rosterIds.has(player.id), + ) + .slice(0, 10) as Player[]; +} + +/** + * Fetch full player rows for the Compare page's URL-driven `?players=` list. + * Previously a bare client-side `.in('id', ids)` query with ZERO filters — + * not even `recruiting_activated` — so a bookmarked `/compare?players=` + * URL could permanently render a private/college/off-territory player's + * full profile regardless of the search-box filter. Gated identically to + * the search above. + */ +async function getComparablePlayersImpl(ids: string[]): Promise { + if (!ids || ids.length === 0) return []; + + const supabase = await createClient(); + const coach = await getActiveCoachForRecruiting(supabase); + if (!coach) return []; + + if (coach.coachType === 'high_school' || coach.coachType === 'showcase') { + return []; + } + + const [privateIds, discoverableIds, rosterIds] = await Promise.all([ + getPrivatePlayerIdsForCompare(supabase), + getDiscoverableTeamPlayerIdsForCompare(supabase), + getCoachRosterPlayerIdsForCompare(supabase, coach.id), + ]); + + const { data, error } = await supabase + .from('baseball_players') + .select('*') + .in('id', ids); + + if (error) { + console.error('Error fetching comparable players:', error); + return []; + } + + return (data ?? []).filter((player) => + player.recruiting_activated && + player.player_type !== 'college' && + !(coach.coachType === 'juco' && player.player_type === 'juco') && + discoverableIds.has(player.id) && + !privateIds.has(player.id) && + !rosterIds.has(player.id), + ) as Player[]; +} + +/** + * Defense-in-depth gate before a player id is accepted into the Compare + * page's `players=` query param — mirrors Watchlist's `addToWatchlist` + * gate. The search results above are already filtered, but this stops an + * ineligible id from ever landing in the URL (rather than silently + * vanishing from render once `getComparablePlayers` re-validates it on + * next fetch, which would desync the "N / 4 players" counter from what's + * actually shown). + */ +async function canAddPlayerToCompareImpl(playerId: string): Promise<{ allowed: boolean }> { + const supabase = await createClient(); + const coach = await getActiveCoachForRecruiting(supabase); + if (!coach) return { allowed: false }; + + const result = await assertCoachCanRecruitPlayer(supabase, coach.id, coach.coachType, playerId); + return { allowed: result.allowed }; +} + export const saveComparison = withAdminObserved( 'saveComparison', { sport: 'baseball', feature: 'baseball_compare', featureArea: 'baseball-compare' }, @@ -170,3 +389,21 @@ export const getSavedComparisons = withAdminObserved( { sport: 'baseball', feature: 'baseball_compare', featureArea: 'baseball-compare' }, getSavedComparisonsImpl, ); + +export const searchRecruitablePlayers = withAdminObserved( + 'searchRecruitablePlayers', + { sport: 'baseball', feature: 'baseball_compare', featureArea: 'baseball-compare' }, + searchRecruitablePlayersImpl, +); + +export const getComparablePlayers = withAdminObserved( + 'getComparablePlayers', + { sport: 'baseball', feature: 'baseball_compare', featureArea: 'baseball-compare' }, + getComparablePlayersImpl, +); + +export const canAddPlayerToCompare = withAdminObserved( + 'canAddPlayerToCompare', + { sport: 'baseball', feature: 'baseball_compare', featureArea: 'baseball-compare' }, + canAddPlayerToCompareImpl, +); diff --git a/src/app/baseball/(dashboard)/dashboard/lift/page.tsx b/src/app/baseball/(dashboard)/dashboard/lift/page.tsx index 0e5c31e5a..41050e54c 100644 --- a/src/app/baseball/(dashboard)/dashboard/lift/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/lift/page.tsx @@ -136,7 +136,12 @@ export default async function PlayerLiftPage() { if (!athleteCtx) { return (
- +
); } @@ -167,6 +172,7 @@ export default async function PlayerLiftPage() { upcoming={upcoming} recent={recent} readinessSubmittedToday={readinessSubmittedToday} + basePath="/baseball/dashboard" /> ('roster'); const [, setSavingLineup] = useState(false); + const [editingLineup, setEditingLineup] = useState<{ + id: string; + name: string; + positions: LineupSlot[]; + } | null>(null); + const [lineupsRefreshKey, setLineupsRefreshKey] = useState(0); const { showToast } = useToast(); // Filters @@ -236,16 +252,33 @@ export function RosterClient({ teamId: serverTeamId, initialModel }: RosterClien .select('*') .eq('team_id', resolvedTeamId) as { data: BaseballPlayerAggregates[] | null; error: unknown }; + let aggMap: Record = {}; if (!aggError && aggregatesData) { - const aggMap: Record = {}; aggregatesData.forEach((agg) => { aggMap[agg.player_id] = agg; }); - setAggregates(aggMap); } else if (aggError) { setAggregatesWarning(true); } + // Merge box-score-canonical season stats over the legacy aggregates row — + // mirrors getRoster's server read model so a client-side refetch (after a + // roster mutation below) never regresses a player back to em-dash/0 stats + // that ARE available from the box-score pipeline (#roster-stat-staleness). + const currentSeasonYear = new Date().getFullYear(); + const { data: seasonStatsData, error: seasonStatsError } = await supabase + .from('baseball_player_season_stats') + .select('player_id, avg, obp, slg, ops, g, last_updated') + .eq('team_id', resolvedTeamId) + .eq('season_year', currentSeasonYear); + + if (!seasonStatsError && seasonStatsData) { + aggMap = mergeSeasonStatsIntoAggregates(aggMap, seasonStatsData, resolvedTeamId); + } else if (seasonStatsError) { + setAggregatesWarning(true); + } + + setAggregates(aggMap); setLoading(false); } @@ -405,6 +438,126 @@ export function RosterClient({ teamId: serverTeamId, initialModel }: RosterClien exportRosterCSV(exportData); }; + // ── Roster mutation handlers ──────────────────────────────────────────── + // removePlayerFromTeam / assignPlayerToTeam / approvePendingMember / + // rejectPendingMember were all fully built, capability-gated server actions + // with NO reachable UI (#roster-actions-unwired, #pending-joiners-stuck). + // Every handler here toasts the outcome, then calls fetchRosterWithAggregates() + // (not router.refresh()) so the change appears immediately — router.refresh() + // would refetch server props but NOT re-derive the `roster`/`aggregates` + // state above, which is only ever seeded once from `initialModel` on mount. + async function handleRemoveMember(playerId: string, playerName: string) { + try { + const result = await removePlayerFromTeam({ playerId }); + if (result.success) { + showToast(`${playerName} removed from team`, 'success'); + await fetchRosterWithAggregates(); + } else { + showToast(result.error || 'Failed to remove player', 'error'); + } + return result; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to remove player'; + showToast(message, 'error'); + return { success: false, error: message }; + } + } + + async function handleApproveMember(memberId: string) { + try { + const result = await approvePendingMember({ memberId }); + if (result.success) { + showToast('Join request approved', 'success'); + await fetchRosterWithAggregates(); + } else { + showToast(result.error || 'Failed to approve join request', 'error'); + } + return result; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to approve join request'; + showToast(message, 'error'); + return { success: false, error: message }; + } + } + + async function handleRejectMember(memberId: string) { + try { + const result = await rejectPendingMember({ memberId }); + if (result.success) { + showToast('Join request declined', 'success'); + await fetchRosterWithAggregates(); + } else { + showToast(result.error || 'Failed to decline join request', 'error'); + } + return result; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to decline join request'; + showToast(message, 'error'); + return { success: false, error: message }; + } + } + + async function handleAssignPlayer(input: { + playerId: string; + jerseyNumber: number | null; + position: string | null; + }) { + try { + const result = await assignPlayerToTeam(input); + if (result.success) { + showToast('Roster updated', 'success'); + await fetchRosterWithAggregates(); + } else { + showToast(result.error || 'Failed to update player', 'error'); + } + return result; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to update player'; + showToast(message, 'error'); + return { success: false, error: message }; + } + } + + async function handleSearchPlayers(query: string) { + try { + return await searchAssignablePlayers({ query }); + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Search failed. Please try again.', + }; + } + } + + // ── Saved-lineup edit session ──────────────────────────────────────────── + // getTeamLineups only returns bare {order, playerId} positions; hydrate them + // against the roster we already have loaded so LineupBuilder can render + // names/positions/jerseys immediately without another round trip. + function handleEditLineup(lineup: { id: string; name: string; positions: Array<{ order: number; playerId: string }> }) { + const byPlayerId = new Map(roster.map((m) => [m.player.id, m])); + const positions: LineupSlot[] = lineup.positions.map((p) => { + const member = byPlayerId.get(p.playerId); + return { + order: p.order, + player: member + ? { + id: member.player.id, + first_name: member.player.first_name, + last_name: member.player.last_name, + primary_position: member.player.primary_position, + jersey_number: member.jersey_number, + avatar_url: member.player.avatar_url, + } + : null, + }; + }); + setEditingLineup({ id: lineup.id, name: lineup.name, positions }); + } + + function handleNewLineup() { + setEditingLineup(null); + } + // Stats summary const rosterStats = useMemo(() => { const total = roster.length; @@ -518,16 +671,25 @@ export function RosterClient({ teamId: serverTeamId, initialModel }: RosterClien } setSavingLineup(true); try { - // saveLineup returns { success: false, error } for validation - // failures (duplicate orders, > 9 players, missing coach) without - // throwing, so check the result before claiming success. - const result = await saveLineup({ - teamId: resolvedTeamId, - name: name || 'Untitled Lineup', - positions, - }); + // saveLineup/updateLineup return { success: false, error } for + // validation failures (duplicate orders, > 9 players, missing + // coach) without throwing, so check the result before claiming + // success. Editing a saved lineup (editingLineup set) updates it + // in place instead of always inserting a new row. + const result = editingLineup + ? await updateLineup(editingLineup.id, { name: name || 'Untitled Lineup', positions }) + : await saveLineup({ + teamId: resolvedTeamId, + name: name || 'Untitled Lineup', + positions, + }); if (result.success) { - showToast('Lineup saved successfully', 'success'); + showToast( + editingLineup ? 'Lineup updated successfully' : 'Lineup saved successfully', + 'success', + ); + setEditingLineup(null); + setLineupsRefreshKey((k) => k + 1); } else { showToast(result.error || 'Failed to save lineup', 'error'); } @@ -540,6 +702,19 @@ export function RosterClient({ teamId: serverTeamId, initialModel }: RosterClien setSavingLineup(false); } }} + onRemoveMember={handleRemoveMember} + onApproveMember={handleApproveMember} + onRejectMember={handleRejectMember} + onAssignPlayer={handleAssignPlayer} + onSearchPlayers={handleSearchPlayers} + showAssignModal={showAssignModal} + onOpenAssignModal={() => setShowAssignModal(true)} + onCloseAssignModal={() => setShowAssignModal(false)} + teamId={resolvedTeamId} + editingLineup={editingLineup} + onEditLineup={handleEditLineup} + onNewLineup={handleNewLineup} + lineupsRefreshKey={lineupsRefreshKey} onInvite={() => setShowInviteModal(true)} showInviteModal={showInviteModal} onCloseInvite={() => setShowInviteModal(false)} diff --git a/src/app/baseball/(dashboard)/dashboard/roster/RosterFairway.tsx b/src/app/baseball/(dashboard)/dashboard/roster/RosterFairway.tsx index d48ea828a..792664ca3 100644 --- a/src/app/baseball/(dashboard)/dashboard/roster/RosterFairway.tsx +++ b/src/app/baseball/(dashboard)/dashboard/roster/RosterFairway.tsx @@ -22,7 +22,7 @@ * `` — never a yellow/amber box, never a fabricated zero. * ========================================================================== */ -import { useMemo, type ComponentProps } from 'react'; +import { useMemo, type ComponentProps, type ReactNode } from 'react'; import { ArrowDown, ArrowUp, Download, UserPlus } from 'lucide-react'; import { Button as PlainButton } from '@/components/ui/button'; import { Segmented, Select, SearchField, Button, SkeletonCard } from '@/components/fairway'; @@ -46,6 +46,14 @@ import { LineupBuilder } from '@/components/coach/lineup/LineupBuilder'; import { InviteModal } from '@/components/coach/InviteModal'; import type { BaseballPlayerAggregates } from '@/lib/types'; import type { RosterBoardMember } from '@/components/baseball/roster'; +import type { AssignablePlayerResult } from '@/app/baseball/actions/roster'; +import { + RosterRowMenu, + PendingMemberActions, + AssignPlayerModal, + type RosterActionOutcome, +} from './RosterMemberActions'; +import { SavedLineupsPanel, type SavedLineupSlot } from './SavedLineupsPanel'; import type { TeamMember, RosterSurface, SortField, SortDirection } from './RosterClient'; // LineupBuilder / InviteModal own their prop types; borrow them so we never @@ -54,7 +62,7 @@ type LineupProps = ComponentProps; const EM_DASH = '—'; -const POSITIONS = ['C', '1B', '2B', '3B', 'SS', 'OF', 'LHP', 'RHP', 'UTL']; +export const POSITIONS = ['C', '1B', '2B', '3B', 'SS', 'OF', 'LHP', 'RHP', 'UTL']; const GRAD_YEARS = [2025, 2026, 2027, 2028, 2029, 2030]; const STATUS_OPTIONS = [ @@ -119,10 +127,18 @@ function RosterWall({ members, aggregates, onSelect, + onAssignPlayer, + onRemoveMember, }: { members: TeamMember[]; aggregates: Record; onSelect: (playerId: string) => void; + onAssignPlayer: (input: { + playerId: string; + jerseyNumber: number | null; + position: string | null; + }) => Promise; + onRemoveMember: (playerId: string, playerName: string) => Promise; }) { // The team's OPS leader (min two qualifiers so a lone data point is never // crowned) — renders in lane green, the same "leads the column" treatment @@ -140,18 +156,33 @@ function RosterWall({
- {members.map((member, i) => ( - - onSelect(member.player.id)} - /> - - ))} + {members.map((member, i) => { + const playerName = `${member.player.first_name ?? ''} ${member.player.last_name ?? ''}`.trim() || 'this player'; + return ( + +
+
+ onSelect(member.player.id)} + /> +
+ +
+
+ ); + })}
@@ -209,8 +240,18 @@ function groupByDevelopment(members: RosterBoardMember[]): Record 0; + // A player with no captured sessions genuinely has "Needs Data" — but a + // player WITH real (box-score-canonical) sessions and no computed trend + // signal is not "needs data", they're just untrended. Bucketing them into + // "no-data" was dumping every box-score-tracked player there regardless + // of how much real performance data existed (#roster-stat-staleness). + // "Holding Steady" is the honest default absent a trend signal. + if (!hasData) { + groups['no-data']!.push(m); + continue; + } const trend = m.aggregates?.recent_trend ?? null; - const key = !hasData || trend == null ? 'no-data' : (groups[trend] ? trend : 'no-data'); + const key = trend && groups[trend] ? trend : 'stable'; groups[key]!.push(m); } return groups; @@ -230,10 +271,13 @@ function TriageColumn({ label, members, onSelect, + renderTrailing, }: { label: string; members: RosterBoardMember[]; onSelect: (playerId: string) => void; + /** Optional per-row trailing content (e.g. the pending column's Approve/Decline). */ + renderTrailing?: (member: RosterBoardMember) => ReactNode; }) { return ( @@ -248,14 +292,19 @@ function TriageColumn({
{members.map((m, i) => ( - onSelect(m.playerId)} - /> +
+
+ onSelect(m.playerId)} + /> +
+ {renderTrailing ? renderTrailing(m) : null} +
))}
@@ -268,15 +317,23 @@ function TriageBoard({ columns, groups, onSelect, + renderTrailing, }: { columns: { key: string; label: string }[]; groups: Record; onSelect: (playerId: string) => void; + renderTrailing?: (columnKey: string, member: RosterBoardMember) => ReactNode; }) { return (
{columns.map((c) => ( - + renderTrailing(c.key, m) : undefined} + /> ))}
); @@ -316,9 +373,30 @@ export interface RosterFairwayProps { // handlers onSelectPlayer: (playerId: string) => void; onExport: () => void; + // roster mutations (removePlayerFromTeam / assignPlayerToTeam / pending approve-reject) + onRemoveMember: (playerId: string, playerName: string) => Promise; + onApproveMember: (memberId: string) => Promise; + onRejectMember: (memberId: string) => Promise; + onAssignPlayer: (input: { + playerId: string; + jerseyNumber: number | null; + position: string | null; + }) => Promise; + onSearchPlayers: (query: string) => Promise<{ success: boolean; data?: AssignablePlayerResult[]; error?: string }>; + showAssignModal: boolean; + onOpenAssignModal: () => void; + onCloseAssignModal: () => void; // lineup (types borrowed from LineupBuilder) + teamId: string | null; lineupRoster: LineupProps['roster']; onSaveLineup: LineupProps['onSave']; + editingLineup: { id: string; name: string; positions: LineupProps['initialLineup'] } | null; + // Matches SavedLineupsPanel's onEdit — bare {order, playerId} rows (what + // getTeamLineups returns); RosterClient hydrates these into full LineupSlot + // objects (with player details) before storing them as `editingLineup`. + onEditLineup: (lineup: { id: string; name: string; positions: SavedLineupSlot[] }) => void; + onNewLineup: () => void; + lineupsRefreshKey: number; // invite modal onInvite: () => void; showInviteModal: boolean; @@ -362,8 +440,21 @@ export function RosterFairway(props: RosterFairwayProps) { aggregatesWarning, onSelectPlayer, onExport, + onRemoveMember, + onApproveMember, + onRejectMember, + onAssignPlayer, + onSearchPlayers, + showAssignModal, + onOpenAssignModal, + onCloseAssignModal, + teamId, lineupRoster, onSaveLineup, + editingLineup, + onEditLineup, + onNewLineup, + lineupsRefreshKey, onInvite, showInviteModal, onCloseInvite, @@ -387,9 +478,14 @@ export function RosterFairway(props: RosterFairwayProps) { title="Roster" ink="team" actions={ - +
+ + +
} >
@@ -413,8 +509,30 @@ export function RosterFairway(props: RosterFairwayProps) { )} {activeView === 'lineup' ? ( -
- +
+ + {editingLineup && ( +
+

+ Editing {editingLineup.name} +

+ + Start a new lineup + +
+ )} +
) : ( <> @@ -570,11 +688,44 @@ export function RosterFairway(props: RosterFairwayProps) { /> ) ) : rosterSurface === 'cards' ? ( - + ) : rosterSurface === 'position' ? ( ) : rosterSurface === 'status' ? ( - + { + const playerName = `${member.firstName ?? ''} ${member.lastName ?? ''}`.trim() || 'this player'; + if (columnKey === 'pending') { + return ( + + ); + } + return ( + + ); + }} + /> ) : ( )} @@ -590,6 +741,15 @@ export function RosterFairway(props: RosterFairwayProps) { onClose={onCloseInvite} /> )} + + {showAssignModal && ( + + )}
); } diff --git a/src/app/baseball/(dashboard)/dashboard/roster/RosterMemberActions.tsx b/src/app/baseball/(dashboard)/dashboard/roster/RosterMemberActions.tsx new file mode 100644 index 000000000..51daa0a72 --- /dev/null +++ b/src/app/baseball/(dashboard)/dashboard/roster/RosterMemberActions.tsx @@ -0,0 +1,497 @@ +'use client'; + +/** + * ============================================================================ + * RosterMemberActions — the roster row's write surface. + * ---------------------------------------------------------------------------- + * Closes three confirmed feature-sweep gaps on top of RosterFairway (which + * stays presentation-only): removePlayerFromTeam / assignPlayerToTeam / + * approvePendingMember / rejectPendingMember all existed as real, capability- + * gated server actions with NO reachable UI. RosterClient owns the actual + * mutation calls + toasts (matching its existing onSaveLineup pattern); these + * components only own their own popover/modal-open + form-local state and + * call back into RosterClient via props. + * + * • PendingMemberActions — Approve / Decline for the Status board's + * "Awaiting Join" column (the pending-joiner gap — every new joiner + * defaults to status='pending' and had no coach control to clear it). + * • RosterRowMenu — kebab menu on the roster wall row: Edit jersey/position + * (assignPlayerToTeam upsert) + Remove from team (removePlayerFromTeam). + * • AssignPlayerModal — "Add existing player": search by name/email, then + * assignPlayerToTeam for a mid-season transfer / manual add. + * ========================================================================== */ + +import { useEffect, useState } from 'react'; +import { Select, Input, Button as FairwayButton, IconButton, PopoverPanel, ModalShell, SearchField } from '@/components/fairway'; +import { IconMoreVertical, IconEdit, IconTrash, IconCheck, IconX } from '@/components/icons'; +import type { AssignablePlayerResult } from '@/app/baseball/actions/roster'; +import { POSITIONS } from './RosterFairway'; + +export type RosterActionOutcome = { success: boolean; error?: string }; + +/** Parse a jersey-number input field into a validated `number | null`. */ +function parseJersey(raw: string): { value: number | null; error?: string } { + const trimmed = raw.trim(); + if (trimmed === '') return { value: null }; + const n = Number(trimmed); + if (!Number.isFinite(n) || n < 0 || n > 99 || !Number.isInteger(n)) { + return { value: null, error: 'Enter a jersey number between 0 and 99.' }; + } + return { value: n }; +} + +// ── Pending row — Approve / Decline ───────────────────────────────────────── + +export function PendingMemberActions({ + memberId, + playerName, + onApprove, + onReject, +}: { + memberId: string; + playerName: string; + onApprove: (memberId: string) => Promise; + onReject: (memberId: string) => Promise; +}) { + const [pending, setPending] = useState<'approve' | 'reject' | null>(null); + + async function handle(kind: 'approve' | 'reject') { + if (pending) return; + setPending(kind); + try { + await (kind === 'approve' ? onApprove(memberId) : onReject(memberId)); + } finally { + setPending(null); + } + } + + return ( + // Rendered as a SIBLING of the row's (not nested inside + // it), so these buttons never bubble into the row's own onClick-to-profile + // navigation — no stopPropagation needed. +
+ handle('approve')} + aria-label={`Approve ${playerName}'s join request`} + > + + Approve + + handle('reject')} + aria-label={`Decline ${playerName}'s join request`} + className="text-fw-danger hover:bg-fw-danger-bg hover:text-fw-danger" + > + + Decline + +
+ ); +} + +// ── Roster wall row — Edit jersey/position + Remove from team ───────────── + +const POSITION_SELECT_OPTIONS = [ + { value: 'none', label: 'No position set' }, + ...POSITIONS.map((p) => ({ value: p, label: p })), +]; + +export function RosterRowMenu({ + playerId, + playerName, + jerseyNumber, + position, + onAssign, + onRemove, +}: { + playerId: string; + playerName: string; + jerseyNumber: number | null; + position: string | null; + onAssign: (input: { + playerId: string; + jerseyNumber: number | null; + position: string | null; + }) => Promise; + onRemove: (playerId: string, playerName: string) => Promise; +}) { + const [menuOpen, setMenuOpen] = useState(false); + const [editOpen, setEditOpen] = useState(false); + const [removeOpen, setRemoveOpen] = useState(false); + const [jerseyValue, setJerseyValue] = useState(jerseyNumber != null ? String(jerseyNumber) : ''); + const [positionValue, setPositionValue] = useState(position ?? ''); + const [saving, setSaving] = useState(false); + const [removing, setRemoving] = useState(false); + const [error, setError] = useState(null); + + function openEdit() { + setMenuOpen(false); + setJerseyValue(jerseyNumber != null ? String(jerseyNumber) : ''); + setPositionValue(position ?? ''); + setError(null); + setEditOpen(true); + } + + async function handleSaveEdit() { + const parsed = parseJersey(jerseyValue); + if (parsed.error) { + setError(parsed.error); + return; + } + setSaving(true); + setError(null); + const result = await onAssign({ + playerId, + jerseyNumber: parsed.value, + position: positionValue || null, + }); + setSaving(false); + if (result.success) { + setEditOpen(false); + } else { + setError(result.error ?? 'Failed to update player.'); + } + } + + async function handleRemove() { + setRemoving(true); + const result = await onRemove(playerId, playerName); + setRemoving(false); + if (result.success) { + setRemoveOpen(false); + } + // Failure is toasted by the caller — the modal simply stays open so the + // coach can retry or cancel. + } + + return ( + // Rendered as a SIBLING of the row's (not nested inside + // it), so this menu never bubbles into the row's own onClick-to-profile + // navigation — no stopPropagation needed. +
+ + + + } + > + + + Edit jersey / position + + + { + setMenuOpen(false); + setRemoveOpen(true); + }} + className="text-fw-danger hover:bg-fw-danger-bg hover:text-fw-danger" + > + + Remove from team + + + + {/* ---- EDIT JERSEY / POSITION ---- */} + { + setEditOpen(o); + if (!o) setError(null); + }} + size="sm" + title="Edit jersey & position" + description={`Update ${playerName}'s roster details.`} + > + +
+
+ + setJerseyValue(e.target.value)} + disabled={saving} + placeholder="e.g. 24" + /> +
+
+ + setJerseyValue(e.target.value)} + disabled={saving} + placeholder="e.g. 24" + /> +
+
+ +