diff --git a/.coderabbit/semgrep/helmv3.yml b/.coderabbit/semgrep/helmv3.yml index 095d65d3e..9b67011d3 100644 --- a/.coderabbit/semgrep/helmv3.yml +++ b/.coderabbit/semgrep/helmv3.yml @@ -105,6 +105,18 @@ rules: requireAdmin(...) ... } + # Same house-helper allowance, second name: the golf CRM actions + # (src/app/golf/actions/crm-*.ts) each define a local `getAuthedClient()` + # that wraps createClient() + auth.getUser() + an Unauthorized throw, + # returning { supabase, user }. Functionally identical to requireAdmin() + # above — verified 2026-07-16 that every definition follows that exact + # shape and the call is not used anywhere outside those files. + - pattern-not-inside: | + export async function $F(...) { + ... + getAuthedClient(...) + ... + } # --------------------------------------------------------------------------- # DATA-LOSS — DELETE-then-INSERT in a save/sync/submit path diff --git a/.env.example b/.env.example index e2a0df2a9..aabb5ed8c 100644 --- a/.env.example +++ b/.env.example @@ -218,6 +218,27 @@ VERCEL_API_TOKEN=your-vercel-api-token-here VERCEL_PROJECT_ID=your-vercel-project-id VERCEL_TEAM_ID=your-vercel-team-id +# ----------------------------------------------------------------------------- +# Helm Bridge — edge→node error-capture bridge (SERVER-ONLY) +# ----------------------------------------------------------------------------- +# Shared secret checked by src/app/api/internal/log-auth-failure/route.ts and +# src/app/api/internal/log-server-error/route.ts. The edge runtime can't talk +# to Supabase directly (createAdminClient is not edge-safe), so proxy.ts and +# the edge branch of onRequestError (src/instrumentation.ts) fire-and-forget a +# POST to these routes instead, authenticated with this header value. Unset = +# both routes silently no-op (no error, no Bridge row) rather than 401-spam. +# Generate with: openssl rand -hex 32 +INTERNAL_LOG_KEY= + +# Opt-in: persist error_logs/admin_events rows from Vercel PREVIEW deployments +# too (production always persists regardless of this flag; see +# shouldPersistAdminTables() in src/lib/telemetry-gate.ts). Off by default — +# preview builds are usually feature branches mid-development and would +# otherwise flood the Bridge with in-progress-work noise. Set to '1' to +# rehearse the full pipeline (e.g. Bridge error-tracking QA) on a preview URL +# before it reaches production. +ADMIN_EVENTS_CAPTURE_PREVIEW= + # ----------------------------------------------------------------------------- # Helm Bridge Feedback Intake (Ben + Leah -> GitHub Issues) # ----------------------------------------------------------------------------- diff --git a/src/app/admin/errors/page.tsx b/src/app/admin/errors/page.tsx index a194f7e60..921ffb7ec 100644 --- a/src/app/admin/errors/page.tsx +++ b/src/app/admin/errors/page.tsx @@ -22,7 +22,7 @@ export const dynamic = 'force-dynamic'; const CHIP_SETS: Array<{ param: 'sport' | 'severity' | 'source' | 'window'; values: string[] }> = [ { param: 'sport', values: ['golf', 'baseball', 'shared'] }, - { param: 'severity', values: ['critical', 'error', 'warning'] }, + { param: 'severity', values: ['critical', 'error', 'warning', 'info'] }, { param: 'source', values: ['server_action', 'rls_denial', 'auth', 'cron', 'client'] }, { param: 'window', values: ['24', '168'] }, ]; diff --git a/src/app/api/baseball/staff/context/route.ts b/src/app/api/baseball/staff/context/route.ts index b34eedb38..6b7ed457a 100644 --- a/src/app/api/baseball/staff/context/route.ts +++ b/src/app/api/baseball/staff/context/route.ts @@ -41,6 +41,7 @@ import { BASEBALL_CAPABILITY_KEYS, type BaseballCapabilityMap, } from '@/lib/baseball/capabilities'; +import { logServerException } from '@/lib/server-error-logger'; /** Always re-resolve per request — capabilities are auth- and cookie-dependent. */ export const dynamic = 'force-dynamic'; @@ -141,8 +142,19 @@ export async function POST(request: NextRequest): Promise { }; return NextResponse.json(payload, { status: 200 }); - } catch { + } catch (error) { // Never leak DB errors; fail closed with a non-2xx + zero-capability body. + await logServerException(error, { + action: 'baseballStaffContextApi.post', + route: '/api/baseball/staff/context', + url: request.url, + source: 'route_handler', + sport: 'baseball', + featureArea: 'baseball_staff_capabilities', + handled: false, + statusCode: 500, + teamId: requestedTeamId, + }); return NextResponse.json(failClosedPayload(requestedTeamId), { status: 500 }); } } diff --git a/src/app/api/calendar/coach/[token]/route.ts b/src/app/api/calendar/coach/[token]/route.ts index 6b9ff22c8..afb298898 100644 --- a/src/app/api/calendar/coach/[token]/route.ts +++ b/src/app/api/calendar/coach/[token]/route.ts @@ -15,6 +15,7 @@ import { createAdminClient } from '@/lib/supabase/admin'; import { generateCoachCalendar, convertToICalEvent } from '@/lib/calendar/ical'; import { getValidTimezone, DEFAULT_TIMEZONE } from '@/lib/calendar/timezone'; import { addMonths, format } from 'date-fns'; +import { logServerException } from '@/lib/server-error-logger'; interface CoachTeamAuthRpcClient { rpc( @@ -28,7 +29,7 @@ interface CoachStaffRow { } export async function GET( - _request: NextRequest, + request: NextRequest, { params }: { params: Promise<{ token: string }> } ) { try { @@ -171,7 +172,17 @@ export async function GET( 'Cache-Control': 'no-cache, no-store, must-revalidate', }, }); - } catch { + } catch (error) { + await logServerException(error, { + action: 'calendarCoachFeedApi.get', + route: '/api/calendar/coach/[token]', + url: request.url, + source: 'route_handler', + sport: 'golf', + featureArea: 'calendar', + handled: false, + statusCode: 500, + }); return new NextResponse('Internal server error', { status: 500 }); } } diff --git a/src/app/api/calendar/feeds/[token]/route.ts b/src/app/api/calendar/feeds/[token]/route.ts index 4f92687ff..4d3f1d422 100644 --- a/src/app/api/calendar/feeds/[token]/route.ts +++ b/src/app/api/calendar/feeds/[token]/route.ts @@ -2,6 +2,7 @@ import { createAdminClient } from '@/lib/supabase/admin'; import { NextRequest, NextResponse } from 'next/server'; import { getValidTimezone, DEFAULT_TIMEZONE } from '@/lib/calendar/timezone'; import { fetchAllRowsResult } from '@/lib/supabase/fetch-all-rows'; +import { logServerError, logServerException } from '@/lib/server-error-logger'; /** * Calendar Feed API Route @@ -193,7 +194,7 @@ function generateICal(events: CalendarFeedEvent[], feedName: string, timezone: s // ============================================================================ export async function GET( - _request: NextRequest, + request: NextRequest, { params }: { params: Promise<{ token: string }> } ) { try { @@ -289,6 +290,16 @@ export async function GET( }, undefined, { table: 'golf_events', action: 'calendarFeed', feature: 'calendar_events', sport: 'golf' }); if (eventsError) { + await logServerError(`Calendar feed events query failed: ${eventsError.message}`, { + action: 'calendarFeedApi.get.eventsQuery', + route: '/api/calendar/feeds/[token]', + url: request.url, + source: 'route_handler', + sport: 'golf', + featureArea: 'calendar', + statusCode: 500, + extra: { teamId, feedType: typedFeed.feed_type }, + }, 'error'); return new NextResponse('Failed to fetch events', { status: 500 }); } @@ -312,7 +323,17 @@ export async function GET( 'Cache-Control': 'no-cache, no-store, must-revalidate', }, }); - } catch { + } catch (error) { + await logServerException(error, { + action: 'calendarFeedApi.get', + route: '/api/calendar/feeds/[token]', + url: request.url, + source: 'route_handler', + sport: 'golf', + featureArea: 'calendar', + handled: false, + statusCode: 500, + }); return new NextResponse('Internal server error', { status: 500 }); } } diff --git a/src/app/api/crm/google-calendar/sync/route.ts b/src/app/api/crm/google-calendar/sync/route.ts index 5a5eaeaea..0ea7001d7 100644 --- a/src/app/api/crm/google-calendar/sync/route.ts +++ b/src/app/api/crm/google-calendar/sync/route.ts @@ -281,7 +281,7 @@ function buildEventDescription(event: Record): string { * GET /api/crm/google-calendar/sync * Sync all pending events to Google Calendar */ -export async function GET(_request: NextRequest) { +export async function GET(request: NextRequest) { try { const supabase = await createClient(); const { data: { user } } = await supabase.auth.getUser(); @@ -307,6 +307,16 @@ export async function GET(_request: NextRequest) { .limit(50); if (error) { + await logServerError(`CRM Google Calendar batch sync events fetch failed: ${error.message}`, { + action: 'googleCalendarSyncApi.get.eventsFetch', + route: '/api/crm/google-calendar/sync', + url: request.url, + source: 'route_handler', + sport: 'golf', + featureArea: 'crm_google_calendar_sync', + userId: user.id, + statusCode: 500, + }, 'error'); return NextResponse.json({ error: 'Failed to fetch events' }, { status: 500 }); } @@ -364,6 +374,25 @@ export async function GET(_request: NextRequest) { } } + if (results.failed > 0) { + // Roll-up, not one log per event — the loop above can touch up to 50 + // events per run and per-event logging would flood error_logs/Sentry. + await logServerError(`CRM Google Calendar batch sync: ${results.failed} of ${events?.length ?? 0} events failed`, { + action: 'googleCalendarSyncApi.get.batchSync', + route: '/api/crm/google-calendar/sync', + url: request.url, + source: 'route_handler', + sport: 'golf', + featureArea: 'crm_google_calendar_sync', + userId: user.id, + metadata: { + failedCount: results.failed, + syncedCount: results.synced, + firstError: results.errors[0] ?? null, + }, + }, 'warning'); + } + // Update last sync timestamp await supabase .from('crm_google_calendar_tokens') diff --git a/src/app/api/cron/admin-digest/route.ts b/src/app/api/cron/admin-digest/route.ts index 1be5ef252..afa7c0be7 100644 --- a/src/app/api/cron/admin-digest/route.ts +++ b/src/app/api/cron/admin-digest/route.ts @@ -1,5 +1,6 @@ import { NextResponse, type NextRequest } from 'next/server'; import { createAdminClient } from '@/lib/supabase/admin'; +import { logServerError } from '@/lib/server-error-logger'; import { recordJobRun } from '@/lib/admin/job-log'; import { fetchSentryIssues } from '@/lib/admin/sentry-api'; import { fetchTriageQueue, groupAppErrorEvents, type AppTriageEventRow } from '@/lib/admin/data/triage'; @@ -84,6 +85,15 @@ export async function GET(req: NextRequest) { }; const result = await sendOpsDigest(buildDigestEmail(data)); + if (!result.sent && !result.skipped) { + // A real send failure (not "ops transport unconfigured" — that's + // skipped=true and expected in dev/preview). + await logServerError( + `admin-digest send failed: ${result.reason ?? 'unknown'}`, + { action: 'cron.admin-digest', source: 'cron' }, + 'error', + ); + } return NextResponse.json({ ok: true, ...result, reds: reds.length }); }); } diff --git a/src/app/api/cron/process-sequences/route.ts b/src/app/api/cron/process-sequences/route.ts index 1ccfc1fcd..cc4be7d3b 100644 --- a/src/app/api/cron/process-sequences/route.ts +++ b/src/app/api/cron/process-sequences/route.ts @@ -104,7 +104,7 @@ export async function GET(request: Request) { const message = err instanceof Error ? err.message : String(err); await logServerError( `[cron.process-sequences] unexpected error: ${message}`, - { action: 'cron.process_sequences' }, + { action: 'cron.process_sequences', source: 'cron' }, 'error', ); return NextResponse.json({ error: message }, { status: 500 }); @@ -179,9 +179,15 @@ async function tick(): Promise<{ enrollments.map((e) => processEnrollment(client, e)), ); + let rejected = 0; + const rejectionSamples: string[] = []; for (const r of settled) { if (r.status !== 'fulfilled') { failed += 1; + rejected += 1; + if (rejectionSamples.length < 3) { + rejectionSamples.push(r.reason instanceof Error ? r.reason.message : String(r.reason)); + } continue; } if (r.value.outcome === 'sent') sent += 1; @@ -190,6 +196,22 @@ async function tick(): Promise<{ else if (r.value.outcome === 'failed') failed += 1; } + // Single roll-up (not one log per rejection) so a bad batch doesn't flood + // the admin feed — the per-enrollment try/catch inside processEnrollment + // already covers expected failure modes; a rejection here means something + // escaped that (e.g. a thrown DB error), so it's still worth surfacing. + if (rejected > 0) { + await logServerError( + `[cron.process-sequences] ${rejected} enrollment(s) rejected out of ${enrollments.length}`, + { + action: 'cron.process_sequences.batch', + source: 'cron', + metadata: { rejected, total: enrollments.length, samples: rejectionSamples }, + }, + 'warning', + ); + } + return { candidates: enrollments.length, sent, stopped, completed, failed }; } @@ -331,7 +353,7 @@ async function processEnrollment( if (!apiKey) { await logServerError( 'process-sequences cron is missing RESEND_API_KEY', - { action: 'cron.process_sequences.missing_api_key' }, + { action: 'cron.process_sequences.missing_api_key', source: 'cron' }, 'critical', ); return { outcome: 'failed' }; @@ -388,6 +410,7 @@ async function processEnrollment( `[cron.process-sequences] Resend send failed (${res.status}): ${text}`, { action: 'cron.process_sequences.send_failed', + source: 'cron', extra: { enrollment_id: enrollment.id, coach_id: coach.id, diff --git a/src/app/api/cron/v3/genome-backfill/route.ts b/src/app/api/cron/v3/genome-backfill/route.ts index 5edb3230e..7899f0b8a 100644 --- a/src/app/api/cron/v3/genome-backfill/route.ts +++ b/src/app/api/cron/v3/genome-backfill/route.ts @@ -13,6 +13,7 @@ import { createAdminClient } from '@/lib/supabase/admin'; import { logServerError } from '@/lib/server-error-logger'; import { requireCronAuth } from '@/lib/cron/auth'; import { computeGenomeForPlayer } from '@/lib/coachhelm/v3/genome/orchestrator'; +import { recordJobRun } from '@/lib/admin/job-log'; export const runtime = 'nodejs'; export const maxDuration = 300; @@ -29,23 +30,33 @@ interface BackfillSummary { export async function GET(req: NextRequest) { const unauthorized = requireCronAuth(req); if (unauthorized) return unauthorized; - return handle(); + return recordJobRun('v3-genome-backfill-oneshot', () => handle()); } export async function POST(req: NextRequest) { const unauthorized = requireCronAuth(req); if (unauthorized) return unauthorized; - return handle(); + return recordJobRun('v3-genome-backfill-oneshot', () => handle()); } async function handle(): Promise { const startedAt = Date.now(); const supabase = createAdminClient(); - const { data: members } = await supabase + const { data: members, error: membersErr } = await supabase .from('golf_team_members') .select('player_id') .eq('status', 'active'); + if (membersErr) { + // Not logged here: this route is wrapped in recordJobRun (job-log.ts), + // which already writes a "Cron failed" Bridge event for any >=400 + // response — logging again here would double-write error_logs/ + // admin_events/Sentry for the same failure. + return NextResponse.json( + { error: membersErr.message, duration_ms: Date.now() - startedAt }, + { status: 500 }, + ); + } const playerIds = Array.from(new Set((members ?? []).map((m) => m.player_id))); let computed = 0; @@ -61,16 +72,30 @@ async function handle(): Promise { errors += 1; await logServerError( `genome-backfill exception for ${pid}: ${err instanceof Error ? err.message : String(err)}`, - { action: 'cron.v3.genome-backfill' }, + { action: 'cron.v3.genome-backfill', source: 'cron' }, ); } } - return NextResponse.json({ + const body = { total_players: playerIds.length, computed, null_only: nullOnly, errors, duration_ms: Date.now() - startedAt, - } satisfies BackfillSummary); + } satisfies BackfillSummary; + + // Total failure: every player errored and nothing computed. A partial + // failure (some computed, some errored) still returns 200 — the summary + // body carries the error count for the caller to inspect. + if (playerIds.length > 0 && errors > 0 && computed === 0) { + // Not logged here: this route is wrapped in recordJobRun (job-log.ts), + // which already writes a "Cron failed" Bridge event for any >=400 + // response — logging again here would double-write error_logs/ + // admin_events/Sentry for the same failure. Per-player exceptions are + // still logged individually above, in the loop. + return NextResponse.json(body, { status: 500 }); + } + + return NextResponse.json(body); } diff --git a/src/app/api/cron/v3/genome-nightly/route.ts b/src/app/api/cron/v3/genome-nightly/route.ts index 1fc7b52bf..9ce39f407 100644 --- a/src/app/api/cron/v3/genome-nightly/route.ts +++ b/src/app/api/cron/v3/genome-nightly/route.ts @@ -62,7 +62,7 @@ async function handle(): Promise { // unique key for correct page boundaries — the actual "stalest first" // ordering is re-derived client-side below via computedAtByPlayer, so // ordering by the table's own primary key (player_id) is safe. - const { data: existingGenomes } = await fetchAllRowsResult<{ player_id: string; computed_at: string }>( + const { data: existingGenomes, error: genomesErr } = await fetchAllRowsResult<{ player_id: string; computed_at: string }>( (from, to) => supabase .from('golf_player_genome') @@ -72,12 +72,19 @@ async function handle(): Promise { undefined, { table: 'golf_player_genome', action: 'cron.v3.genome-nightly', sport: 'golf' }, ); + if (genomesErr) { + // Not logged here: this route is wrapped in recordJobRun (job-log.ts), + // which already writes a "Cron failed" Bridge event for any >=400 + // response — logging again here would double-write error_logs/ + // admin_events/Sentry for the same failure. + return NextResponse.json({ success: false, error: genomesErr.message }, { status: 500 }); + } const computedAtByPlayer = new Map(); for (const g of existingGenomes ?? []) { computedAtByPlayer.set(g.player_id, g.computed_at); } - const { data: members } = await fetchAllRowsResult<{ player_id: string }>( + const { data: members, error: membersErr } = await fetchAllRowsResult<{ player_id: string }>( (from, to) => supabase .from('golf_team_members') @@ -88,6 +95,13 @@ async function handle(): Promise { undefined, { table: 'golf_team_members', action: 'cron.v3.genome-nightly', sport: 'golf' }, ); + if (membersErr) { + // Not logged here: this route is wrapped in recordJobRun (job-log.ts), + // which already writes a "Cron failed" Bridge event for any >=400 + // response — logging again here would double-write error_logs/ + // admin_events/Sentry for the same failure. + return NextResponse.json({ success: false, error: membersErr.message }, { status: 500 }); + } const allPlayerIds = Array.from(new Set((members ?? []).map((m) => m.player_id))); // Sort: never-computed (no entry in map) come first, then by ascending @@ -116,7 +130,7 @@ async function handle(): Promise { } catch (err) { await logServerError( `genome-nightly compute exception for ${pid}: ${err instanceof Error ? err.message : String(err)}`, - { action: 'cron.v3.genome-nightly' }, + { action: 'cron.v3.genome-nightly', source: 'cron' }, ); per_player.push({ player_id: pid, diff --git a/src/app/api/cron/v3/ingest-sync/route.ts b/src/app/api/cron/v3/ingest-sync/route.ts index 3678f07a6..205876433 100644 --- a/src/app/api/cron/v3/ingest-sync/route.ts +++ b/src/app/api/cron/v3/ingest-sync/route.ts @@ -19,6 +19,7 @@ import { requireCronAuth } from '@/lib/cron/auth'; import { getAdapter } from '@/lib/coachhelm/v3/ingest/registry'; import { nextConnectionState, INGEST_PROVIDERS } from '@/lib/coachhelm/v3/ingest/types'; import type { IngestConnection, IngestProvider } from '@/lib/coachhelm/v3/ingest/types'; +import { recordJobRun } from '@/lib/admin/job-log'; export const runtime = 'nodejs'; export const maxDuration = 300; @@ -37,12 +38,12 @@ interface CronSummary { export async function GET(req: NextRequest) { const unauthorized = requireCronAuth(req); if (unauthorized) return unauthorized; - return handle(); + return recordJobRun('v3-ingest-sync', () => handle()); } export async function POST(req: NextRequest) { const unauthorized = requireCronAuth(req); if (unauthorized) return unauthorized; - return handle(); + return recordJobRun('v3-ingest-sync', () => handle()); } async function handle(): Promise { @@ -58,10 +59,20 @@ async function handle(): Promise { duration_ms: 0, }; - const { data: connections } = await sb + const { data: connections, error: connectionsErr } = await sb .from('golf_ingest_connections') .select('player_id, provider, access_token_encrypted, refresh_token_encrypted, expires_at, last_synced_at, state') .eq('state', 'active'); + if (connectionsErr) { + // Not logged here: this route is wrapped in recordJobRun (job-log.ts), + // which already writes a "Cron failed" Bridge event for any >=400 + // response — logging again here would double-write error_logs/ + // admin_events/Sentry for the same failure. + return NextResponse.json( + { success: false, error: connectionsErr.message, duration_ms: Date.now() - startedAt }, + { status: 500 }, + ); + } const rows = (connections ?? []) as Array; summary.connections_considered = rows.length; @@ -108,7 +119,7 @@ async function handle(): Promise { summary.errors += 1; await logServerError( `ingest-sync ${conn.provider}/${conn.player_id}: ${err instanceof Error ? err.message : String(err)}`, - { action: 'cron.v3.ingest-sync' }, + { action: 'cron.v3.ingest-sync', source: 'cron' }, ); await sb.from('golf_ingest_sync_log').insert({ player_id: conn.player_id, diff --git a/src/app/api/cron/v3/standing-backfill/route.ts b/src/app/api/cron/v3/standing-backfill/route.ts index 9a26d50b4..9ff68bf52 100644 --- a/src/app/api/cron/v3/standing-backfill/route.ts +++ b/src/app/api/cron/v3/standing-backfill/route.ts @@ -79,6 +79,7 @@ async function handle(): Promise { if (error) { await logServerError(`standing-backfill team-select: ${error.message}`, { action: 'cron.v3.standing-backfill.team-select', + source: 'cron', }); return NextResponse.json( { error: 'team-select failed', duration_ms: Date.now() - startedAt }, @@ -89,7 +90,7 @@ async function handle(): Promise { } catch (err) { await logServerError( `standing-backfill team-select exception: ${err instanceof Error ? err.message : String(err)}`, - { action: 'cron.v3.standing-backfill.team-select' }, + { action: 'cron.v3.standing-backfill.team-select', source: 'cron' }, ); return NextResponse.json( { error: 'team-select exception', duration_ms: Date.now() - startedAt }, @@ -114,15 +115,22 @@ async function handle(): Promise { if (error) { await logServerError( `standing-backfill RPC chunk ${chunksProcessed}: ${error.message ?? 'unknown'}`, - { action: 'cron.v3.standing-backfill.rpc' }, + { action: 'cron.v3.standing-backfill.rpc', source: 'cron' }, + ); + // Total failure: no chunk completed before this error (equivalent to + // errors > 0 && computed === 0 for this file's per-chunk shape). A + // failure after some chunks already succeeded stays 200 — partial + // progress is real progress, and the summary body carries the error. + return NextResponse.json( + { + total_teams_processed: chunksProcessed * TEAMS_PER_CHUNK, + chunks_processed: chunksProcessed, + rows_upserted_by_metric: rowsByMetric, + duration_ms: Date.now() - startedAt, + error: error.message ?? 'rpc-error', + } satisfies BackfillSummary, + chunksProcessed === 0 ? { status: 500 } : undefined, ); - return NextResponse.json({ - total_teams_processed: chunksProcessed * TEAMS_PER_CHUNK, - chunks_processed: chunksProcessed, - rows_upserted_by_metric: rowsByMetric, - duration_ms: Date.now() - startedAt, - error: error.message ?? 'rpc-error', - } satisfies BackfillSummary); } for (const row of (data ?? []) as Array<{ metric_id: string; rows_upserted: number }>) { rowsByMetric[row.metric_id] = @@ -143,7 +151,7 @@ async function handle(): Promise { } else { await logServerError( `standing-backfill round-RPC chunk ${chunksProcessed}: ${roundResult.error.message ?? 'unknown'}`, - { action: 'cron.v3.standing-backfill.round-rpc' }, + { action: 'cron.v3.standing-backfill.round-rpc', source: 'cron' }, ); } @@ -160,21 +168,24 @@ async function handle(): Promise { } else { await logServerError( `standing-backfill shot-RPC chunk ${chunksProcessed}: ${shotResult.error.message ?? 'unknown'}`, - { action: 'cron.v3.standing-backfill.shot-rpc' }, + { action: 'cron.v3.standing-backfill.shot-rpc', source: 'cron' }, ); } } catch (err) { await logServerError( `standing-backfill RPC chunk ${chunksProcessed} exception: ${err instanceof Error ? err.message : String(err)}`, - { action: 'cron.v3.standing-backfill.rpc' }, + { action: 'cron.v3.standing-backfill.rpc', source: 'cron' }, + ); + return NextResponse.json( + { + total_teams_processed: chunksProcessed * TEAMS_PER_CHUNK, + chunks_processed: chunksProcessed, + rows_upserted_by_metric: rowsByMetric, + duration_ms: Date.now() - startedAt, + error: err instanceof Error ? err.message : String(err), + } satisfies BackfillSummary, + chunksProcessed === 0 ? { status: 500 } : undefined, ); - return NextResponse.json({ - total_teams_processed: chunksProcessed * TEAMS_PER_CHUNK, - chunks_processed: chunksProcessed, - rows_upserted_by_metric: rowsByMetric, - duration_ms: Date.now() - startedAt, - error: err instanceof Error ? err.message : String(err), - } satisfies BackfillSummary); } chunksProcessed += 1; } 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 d65fccd0e..099bb5e4c 100644 --- a/src/app/api/cron/v3/weekly-coach-email/route.ts +++ b/src/app/api/cron/v3/weekly-coach-email/route.ts @@ -84,11 +84,18 @@ async function handle(): Promise { // All teams that have at least one active member. Paginated — platform-wide // fetch with no filter can exceed the PostgREST 1000-row cap once the team // count grows (mirrors event-reminders' use of fetchAllRowsResult). - const { data: teams } = await fetchAllRowsResult<{ id: string }>( + const { data: teams, error: teamsErr } = await fetchAllRowsResult<{ id: string }>( (from, to) => sb.from('golf_teams').select('id').order('id', { ascending: true }).range(from, to), undefined, { table: 'golf_teams', action: 'cron.v3.weekly-coach-email', sport: 'golf' }, ); + if (teamsErr) { + // Not logged here: this route is wrapped in recordJobRun (job-log.ts), + // which already writes a "Cron failed" Bridge event for any >=400 + // response — logging again here would double-write error_logs/ + // admin_events/Sentry for the same failure. + return NextResponse.json({ success: false, error: teamsErr.message }, { status: 500 }); + } const teamIds = (teams ?? []).map((t) => t.id); summary.teams_considered = teamIds.length; @@ -171,7 +178,7 @@ async function handle(): Promise { summary.errors += 1; await logServerError( `weekly-coach-email failure ${team_id}: ${err instanceof Error ? err.message : String(err)}`, - { action: 'cron.v3.weekly-coach-email' }, + { action: 'cron.v3.weekly-coach-email', source: 'cron' }, ); } } diff --git a/src/app/api/golf/players/[playerId]/putt-tendencies/route.ts b/src/app/api/golf/players/[playerId]/putt-tendencies/route.ts index 5622c13a3..070a57063 100644 --- a/src/app/api/golf/players/[playerId]/putt-tendencies/route.ts +++ b/src/app/api/golf/players/[playerId]/putt-tendencies/route.ts @@ -1,6 +1,8 @@ import { createClient } from '@/lib/supabase/server'; import { NextRequest, NextResponse } from 'next/server'; import type { PlayerPuttTendencies } from '@/lib/types/golf'; +import { withRouteHandler } from '@/lib/api/with-route-handler'; +import { logServerError } from '@/lib/server-error-logger'; const EMPTY_TENDENCIES: PlayerPuttTendencies = { playerId: '', @@ -27,11 +29,13 @@ interface PuttDetailRow { made: boolean; } -export async function GET( - _request: NextRequest, - { params }: { params: Promise<{ playerId: string }> } -) { - try { +export const GET = withRouteHandler( + 'golfPuttTendenciesApi.get', + { source: 'route_handler', sport: 'golf', featureArea: 'golf_putt_tendencies' }, + async ( + request: NextRequest, + { params }: { params: Promise<{ playerId: string }> } + ) => { const { playerId } = await params; const supabase = await createClient(); @@ -96,7 +100,18 @@ export async function GET( .eq('shot.player_id', playerId) as { data: (PuttDetailRow & { shot: { player_id: string } })[] | null; error: { message: string } | null }; if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }); + await logServerError(`Putt tendencies query failed: ${error.message}`, { + action: 'golfPuttTendenciesApi.get.query', + route: '/api/golf/players/[playerId]/putt-tendencies', + url: request.url, + source: 'route_handler', + sport: 'golf', + featureArea: 'golf_putt_tendencies', + userId: user.id, + statusCode: 500, + extra: { playerId }, + }, 'error'); + return NextResponse.json({ error: 'Failed to load putt tendencies' }, { status: 500 }); } const rows = puttRows || []; @@ -180,10 +195,5 @@ export async function GET( }; return NextResponse.json(tendencies); - } catch (error) { - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Internal server error' }, - { status: 500 } - ); - } -} + }, +); diff --git a/src/app/api/internal/log-server-error/route.ts b/src/app/api/internal/log-server-error/route.ts new file mode 100644 index 000000000..d66a08471 --- /dev/null +++ b/src/app/api/internal/log-server-error/route.ts @@ -0,0 +1,99 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { logServerException } from '@/lib/server-error-logger'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +/** + * Edge→node capture bridge for the edge-runtime branch of onRequestError + * (src/instrumentation.ts). The edge runtime cannot import + * server-error-logger directly (createAdminClient is not edge-safe), so it + * fires-and-forgets a POST here. Shared-secret header, tiny payload, + * best-effort semantics — mirrors log-auth-failure/route.ts. + */ + +const inMemoryWindow = new Map(); + +function overLimit(ip: string): boolean { + const now = Date.now(); + const entry = inMemoryWindow.get(ip); + if (!entry || now > entry.resetAt) { + inMemoryWindow.set(ip, { count: 1, resetAt: now + 60_000 }); + return false; + } + entry.count += 1; + return entry.count > 30; +} + +// Mirrors the routeType→source mapping in src/instrumentation.ts. Kept as a +// small local copy (not shared) so this route stays import-light — it must +// not pull in the same module graph the edge runtime avoids. +function mapRouteTypeToSource( + routeType: unknown, +): 'server_component' | 'route_handler' | 'server_action' | 'request_hook' { + switch (routeType) { + case 'route': + return 'route_handler'; + case 'action': + return 'server_action'; + case 'proxy': + case 'middleware': + return 'request_hook'; + default: + return 'server_component'; + } +} + +interface RelayedErrorBody { + message?: unknown; + stack?: unknown; + name?: unknown; + route?: unknown; + routeType?: unknown; + routerKind?: unknown; + method?: unknown; +} + +export async function POST(request: NextRequest) { + const expected = process.env.INTERNAL_LOG_KEY; + if (!expected || request.headers.get('x-internal-log-key') !== expected) { + return NextResponse.json({ ok: false }, { status: 401 }); + } + const ip = request.headers.get('x-forwarded-for') ?? 'unknown'; + if (overLimit(ip)) return NextResponse.json({ ok: false }, { status: 429 }); + + let body: RelayedErrorBody = {}; + try { + body = await request.json(); + } catch { + // tolerate malformed bodies — best-effort telemetry + } + + const message = typeof body.message === 'string' ? body.message.slice(0, 2000) : 'edge runtime request error'; + const stack = typeof body.stack === 'string' ? body.stack.slice(0, 8000) : null; + const name = typeof body.name === 'string' ? body.name.slice(0, 200) : 'Error'; + const route = typeof body.route === 'string' ? body.route.slice(0, 300) : null; + const routeType = typeof body.routeType === 'string' ? body.routeType.slice(0, 50) : null; + const routerKind = typeof body.routerKind === 'string' ? body.routerKind.slice(0, 50) : null; + const method = typeof body.method === 'string' ? body.method.slice(0, 10) : null; + + const relayedError = new Error(message); + relayedError.name = name; + if (stack) relayedError.stack = stack; + + await logServerException( + relayedError, + { + action: route ?? 'edge.onRequestError', + route, + source: mapRouteTypeToSource(routeType), + handled: false, + statusCode: 500, + runtime: 'edge', + metadata: { routerKind, routeType, method }, + }, + 'error', + ); + + return new NextResponse(null, { status: 204 }); +} diff --git a/src/app/api/log-error/route.ts b/src/app/api/log-error/route.ts index 69dcccbb7..52cd74929 100644 --- a/src/app/api/log-error/route.ts +++ b/src/app/api/log-error/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; +import * as Sentry from '@sentry/nextjs'; import { withRateLimit } from '@/lib/middleware/rate-limit'; import { RATE_LIMITS } from '@/lib/rate-limit'; import { createAdminClient } from '@/lib/supabase/admin'; @@ -208,7 +209,11 @@ export async function POST(request: NextRequest) { } return NextResponse.json({ success: true }); - } catch { + } catch (error) { + // This route IS the error-reporting pipeline — a failure here has no + // downstream logger to fall back on, so it must self-report directly. + console.error('[log-error route] Failed to persist client error report', error); + Sentry.captureException(error, { tags: { component: 'log-error-route' } }); return NextResponse.json({ success: false }, { status: 500 }); } } diff --git a/src/app/baseball/(dashboard)/dashboard/roster/RosterClient.tsx b/src/app/baseball/(dashboard)/dashboard/roster/RosterClient.tsx index dff3abf12..7643dd495 100644 --- a/src/app/baseball/(dashboard)/dashboard/roster/RosterClient.tsx +++ b/src/app/baseball/(dashboard)/dashboard/roster/RosterClient.tsx @@ -19,6 +19,7 @@ import { searchAssignablePlayers, } from '@/app/baseball/actions/roster'; import { useToast } from '@/components/ui/sonner'; +import { logError } from '@/lib/error-logging'; import type { RosterBoardMember } from '@/components/baseball/roster'; import type { BaseballPlayerAggregates } from '@/lib/types'; import type { RosterReadModel } from '@/lib/baseball/read-models/roster'; @@ -452,11 +453,21 @@ export function RosterClient({ teamId: serverTeamId, initialModel }: RosterClien await fetchRosterWithAggregates(); } else { showToast(result.error || 'Failed to remove player', 'error'); + logError( + new Error(result.error || 'Failed to remove player'), + { component: 'RosterClient', action: 'handleRemoveMember', sport: 'baseball' }, + 'high' + ); } return result; } catch (error) { const message = error instanceof Error ? error.message : 'Failed to remove player'; showToast(message, 'error'); + logError( + error instanceof Error ? error : new Error(message), + { component: 'RosterClient', action: 'handleRemoveMember', sport: 'baseball' }, + 'high' + ); return { success: false, error: message }; } } @@ -469,11 +480,21 @@ export function RosterClient({ teamId: serverTeamId, initialModel }: RosterClien await fetchRosterWithAggregates(); } else { showToast(result.error || 'Failed to approve join request', 'error'); + logError( + new Error(result.error || 'Failed to approve join request'), + { component: 'RosterClient', action: 'handleApproveMember', sport: 'baseball' }, + 'high' + ); } return result; } catch (error) { const message = error instanceof Error ? error.message : 'Failed to approve join request'; showToast(message, 'error'); + logError( + error instanceof Error ? error : new Error(message), + { component: 'RosterClient', action: 'handleApproveMember', sport: 'baseball' }, + 'high' + ); return { success: false, error: message }; } } @@ -486,11 +507,21 @@ export function RosterClient({ teamId: serverTeamId, initialModel }: RosterClien await fetchRosterWithAggregates(); } else { showToast(result.error || 'Failed to decline join request', 'error'); + logError( + new Error(result.error || 'Failed to decline join request'), + { component: 'RosterClient', action: 'handleRejectMember', sport: 'baseball' }, + 'high' + ); } return result; } catch (error) { const message = error instanceof Error ? error.message : 'Failed to decline join request'; showToast(message, 'error'); + logError( + error instanceof Error ? error : new Error(message), + { component: 'RosterClient', action: 'handleRejectMember', sport: 'baseball' }, + 'high' + ); return { success: false, error: message }; } } @@ -507,11 +538,21 @@ export function RosterClient({ teamId: serverTeamId, initialModel }: RosterClien await fetchRosterWithAggregates(); } else { showToast(result.error || 'Failed to update player', 'error'); + logError( + new Error(result.error || 'Failed to update player'), + { component: 'RosterClient', action: 'handleAssignPlayer', sport: 'baseball' }, + 'high' + ); } return result; } catch (error) { const message = error instanceof Error ? error.message : 'Failed to update player'; showToast(message, 'error'); + logError( + error instanceof Error ? error : new Error(message), + { component: 'RosterClient', action: 'handleAssignPlayer', sport: 'baseball' }, + 'high' + ); return { success: false, error: message }; } } @@ -520,9 +561,15 @@ export function RosterClient({ teamId: serverTeamId, initialModel }: RosterClien try { return await searchAssignablePlayers({ query }); } catch (error) { + const message = error instanceof Error ? error.message : 'Search failed. Please try again.'; + logError( + error instanceof Error ? error : new Error(message), + { component: 'RosterClient', action: 'handleSearchPlayers', sport: 'baseball' }, + 'medium' + ); return { success: false, - error: error instanceof Error ? error.message : 'Search failed. Please try again.', + error: message, }; } } @@ -690,11 +737,19 @@ export function RosterClient({ teamId: serverTeamId, initialModel }: RosterClien setLineupsRefreshKey((k) => k + 1); } else { showToast(result.error || 'Failed to save lineup', 'error'); + logError( + new Error(result.error || 'Failed to save lineup'), + { component: 'RosterClient', action: 'onSaveLineup', sport: 'baseball' }, + 'high' + ); } } catch (error) { - showToast( - error instanceof Error ? error.message : 'Failed to save lineup', - 'error', + const message = error instanceof Error ? error.message : 'Failed to save lineup'; + showToast(message, 'error'); + logError( + error instanceof Error ? error : new Error(message), + { component: 'RosterClient', action: 'onSaveLineup', sport: 'baseball' }, + 'high' ); } finally { setSavingLineup(false); diff --git a/src/app/baseball/actions/auth.ts b/src/app/baseball/actions/auth.ts index bf4138283..d72f3f170 100644 --- a/src/app/baseball/actions/auth.ts +++ b/src/app/baseball/actions/auth.ts @@ -18,6 +18,7 @@ import { } from '@/lib/auth/account-lockout'; import { validatePassword } from '@/lib/auth/password-validation'; import { sanitizeAuthError } from '@/lib/db-error'; +import { logSignup, logLogin, logSecurityEvent } from '@/lib/admin-logger'; import { logServerError } from '@/lib/server-error-logger'; import { getAppBaseUrl } from '@/lib/app-base-url'; @@ -122,6 +123,13 @@ async function loginActionImpl( locked: lockoutResult.locked, }); + // Log failed login attempt (fire-and-forget) + logSecurityEvent( + `Failed login attempt: ${normalizedEmail}`, + lockoutResult.locked ? 'warning' : 'info', + { email: normalizedEmail, ip, remainingAttempts: lockoutResult.remainingAttempts, sport: 'baseball' } + ).catch(() => {}); + // Return appropriate error message if (lockoutResult.locked && lockoutResult.lockedUntil) { return { @@ -156,6 +164,9 @@ async function loginActionImpl( ip, }); + // Log successful login event (fire-and-forget) + logLogin(data.user.id, normalizedEmail, { ip, userAgent, sport: 'baseball' }).catch(() => {}); + // Get user role and profile status to determine redirect const { data: userData } = await supabase .from('users') @@ -370,6 +381,9 @@ async function signupActionImpl( } } + // Log signup event (fire-and-forget) + logSignup(data.user.id, normalizedEmail, role, { ip, sport: 'baseball' }).catch(() => {}); + // Redirect based on role - coaches go to onboarding, players go to player onboarding const redirectTo = role === 'coach' ? '/baseball/coach-onboarding' @@ -448,6 +462,10 @@ async function requestPasswordResetActionImpl( ip, }); + // Log password-reset request (fire-and-forget) — mirrors golf auth's + // admin-feed capture so reset requests are visible for both sports. + logSecurityEvent('Password reset requested', 'info', { email: normalizedEmail, sport: 'baseball' }).catch(() => {}); + // Generic response - don't reveal if email exists return { success: true, diff --git a/src/app/baseball/actions/coachhelm-actions.ts b/src/app/baseball/actions/coachhelm-actions.ts index f1f41ea31..d14f47afb 100644 --- a/src/app/baseball/actions/coachhelm-actions.ts +++ b/src/app/baseball/actions/coachhelm-actions.ts @@ -36,6 +36,7 @@ import { revalidatePath } from 'next/cache'; import { createClient } from '@/lib/supabase/server'; import { withBaseballAction } from '@/lib/baseball/with-baseball-action'; import { sweepActionOutcomes } from '@/lib/baseball/coachhelm/outcome-sweep'; +import { logServerException } from '@/lib/server-error-logger'; import { buildActionOutcomeSeed, primaryMetricOfInsightMetadata, @@ -211,7 +212,13 @@ export const recordActionOutcomes = withBaseballAction( revalidatePath('/baseball/dashboard/command-center'); revalidatePath('/baseball/dashboard/decision-room'); return { success: true, evaluated, measured }; - } catch { + } catch (error) { + await logServerException(error, { + action: 'recordActionOutcomes', + sport: 'baseball', + source: 'server_action', + teamId: ctx.targetTeamId, + }); return { success: false, evaluated: 0, measured: 0, error: 'Could not record action outcomes.' }; } }, diff --git a/src/app/baseball/actions/postgame.ts b/src/app/baseball/actions/postgame.ts index 841f7f773..533337117 100644 --- a/src/app/baseball/actions/postgame.ts +++ b/src/app/baseball/actions/postgame.ts @@ -55,6 +55,7 @@ import { sweepActionOutcomes, type OutcomeSweepClient, } from '@/lib/baseball/coachhelm/outcome-sweep'; +import { logServerException } from '@/lib/server-error-logger'; const POSTGAME_PATH = '/baseball/dashboard/postgame'; const PRACTICE_PATH = '/baseball/dashboard/practice'; @@ -255,7 +256,12 @@ export const generatePostgameReview = withBaseballAction( }); revalidatePath('/baseball/dashboard/decision-room'); revalidatePath('/baseball/dashboard/command-center'); - } catch { + } catch (error) { + void logServerException( + error, + { action: 'postgame.sweepActionOutcomes', sport: 'baseball', source: 'server_action', skipSentry: true }, + 'warning', + ); // Swallow — the review is the primary deliverable; the ledger refreshes // again on the nightly cron. } diff --git a/src/app/baseball/error.tsx b/src/app/baseball/error.tsx new file mode 100644 index 000000000..d12d02d3d --- /dev/null +++ b/src/app/baseball/error.tsx @@ -0,0 +1,23 @@ +'use client'; + +import { RouteErrorBoundary } from '@/components/errors'; + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( + + ); +} diff --git a/src/app/error.tsx b/src/app/error.tsx index 589a7bbce..be0fb0a48 100644 --- a/src/app/error.tsx +++ b/src/app/error.tsx @@ -1,8 +1,6 @@ 'use client'; -import { useEffect } from 'react'; -import Link from 'next/link'; -import { Button } from '@/components/ui/button'; +import { RouteErrorBoundary } from '@/components/errors'; export default function Error({ error, @@ -11,57 +9,15 @@ export default function Error({ error: Error & { digest?: string }; reset: () => void; }) { - useEffect(() => { - // Log the error to an error reporting service - console.error('Error boundary caught:', error); - }, [error]); - return ( -
-
-
-
- - - -
-

Something went wrong

-

- We encountered an unexpected error. Please try again. -

- {error.digest && ( -

- Error ID: {error.digest} -

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

- If this problem persists,{' '} - - contact support - -

-
-
+ ); } diff --git a/src/app/golf/(dashboard)/dashboard/calendar/page.tsx b/src/app/golf/(dashboard)/dashboard/calendar/page.tsx index 48a44ed74..08ea68786 100644 --- a/src/app/golf/(dashboard)/dashboard/calendar/page.tsx +++ b/src/app/golf/(dashboard)/dashboard/calendar/page.tsx @@ -8,6 +8,7 @@ import type { CalendarEvent } from '@/hooks/useCalendarEvents'; import { resolveCoachTeamIdWithCookie } from '@/lib/golf/resolve-team-server'; import { fairwayScope } from '@/lib/redesign/flag'; import type { Metadata } from 'next'; +import { logServerException } from '@/lib/server-error-logger'; // Code-split the Fairway calendar surface — it's the ONLY tree the route // renders, so this next/dynamic keeps its chunk loaded only when actually @@ -69,7 +70,8 @@ export default async function GolfCalendarPage({ searchParams }: GolfCalendarPag teamId = coachTeamId || playerTeamResult.data?.team_id || null; coachList = coachListResult.data || []; - } catch { + } catch (error) { + void logServerException(error, { action: 'calendar-load', route: '/golf/dashboard/calendar', source: 'server_component', sport: 'golf' }, 'warning'); // Team resolution failed (network/DB) — rendering an empty calendar here // is indistinguishable from "my season got wiped" (audit finding #20). // Throw so the route error boundary renders a real, retryable error state. diff --git a/src/app/golf/(dashboard)/dashboard/rounds/page.tsx b/src/app/golf/(dashboard)/dashboard/rounds/page.tsx index 949227c98..e1bc21fee 100644 --- a/src/app/golf/(dashboard)/dashboard/rounds/page.tsx +++ b/src/app/golf/(dashboard)/dashboard/rounds/page.tsx @@ -6,6 +6,7 @@ import type { Metadata } from 'next'; import { resolveCoachTeamIdWithCookie } from '@/lib/golf/resolve-team-server'; import { fairwayScope } from '@/lib/redesign/flag'; import { fetchAllRowsResult } from '@/lib/supabase/fetch-all-rows'; +import { logServerException } from '@/lib/server-error-logger'; import { FairwayRoundsLibrary, type RoundLibraryRound as FairwayRoundLibraryRound, @@ -46,7 +47,8 @@ export default async function RoundsPage() { if (coach?.organization_id) { try { teamId = await resolveCoachTeamIdWithCookie(supabase, coach.organization_id, coach.id); - } catch { + } catch (error) { + void logServerException(error, { action: 'rounds-teamid-load', route: '/golf/dashboard/rounds', source: 'server_component', sport: 'golf' }, 'warning'); // Network failure — proceed with null teamId } } @@ -95,7 +97,8 @@ export default async function RoundsPage() { .eq('team_id', teamId) .eq('status', 'active'); teamMembers = result.data; - } catch { + } catch (error) { + void logServerException(error, { action: 'rounds-teammembers-load', route: '/golf/dashboard/rounds', source: 'server_component', sport: 'golf' }, 'warning'); // Network failure } diff --git a/src/app/golf/actions/__tests__/coverage-contract.observability.test.ts b/src/app/golf/actions/__tests__/coverage-contract.observability.test.ts index cdc72cd80..a62dba549 100644 --- a/src/app/golf/actions/__tests__/coverage-contract.observability.test.ts +++ b/src/app/golf/actions/__tests__/coverage-contract.observability.test.ts @@ -26,6 +26,15 @@ function isDefaultExport(node: ts.Node): boolean { return Boolean(ts.getCombinedModifierFlags(node as ts.Declaration) & ts.ModifierFlags.Default); } +/** `export const foo = async () => {...}` / `export const foo = async function () {...}` — + * the arrow/function-expression equivalent of an exported async function + * declaration. Modifiers (incl. `async`) live on the initializer itself for + * these forms, not on the VariableStatement. */ +function isAsyncArrowOrFunctionExpr(node: ts.Node): node is ts.ArrowFunction | ts.FunctionExpression { + if (!ts.isArrowFunction(node) && !ts.isFunctionExpression(node)) return false; + return Boolean(node.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword)); +} + function wrappedActionNames(sourceFile: ts.SourceFile): Set { const names = new Set(); @@ -90,6 +99,32 @@ describe('Golf server-action observability coverage', () => { gaps.push(`${path.relative(process.cwd(), file)}:${line} ${node.name.text}`); } } + + if (ts.isVariableStatement(node) && isExported(node)) { + for (const declaration of node.declarationList.declarations) { + if ( + !ts.isIdentifier(declaration.name) || + !declaration.initializer || + !isAsyncArrowOrFunctionExpr(declaration.initializer) + ) { + continue; + } + + const fn = declaration.initializer; + const body = fn.body?.getText(sourceFile) ?? ''; + const delegatesToWrapped = [...wrappedNames].some((name) => + new RegExp(`\\b${name}\\s*\\(`).test(body), + ); + const wrapsInline = /\bwithAdminObserved\s*\(/.test(body); + const logsInline = /\blogServer(Exception|Error)\s*\(/.test(body); + + if (!delegatesToWrapped && !wrapsInline && !logsInline) { + const line = sourceFile.getLineAndCharacterOfPosition(declaration.getStart(sourceFile)).line + 1; + gaps.push(`${path.relative(process.cwd(), file)}:${line} ${declaration.name.text}`); + } + } + } + ts.forEachChild(node, visit); } diff --git a/src/app/golf/actions/admin-data.ts b/src/app/golf/actions/admin-data.ts index 3df4ad2f4..c21805233 100644 --- a/src/app/golf/actions/admin-data.ts +++ b/src/app/golf/actions/admin-data.ts @@ -1778,7 +1778,11 @@ async function getAdminDashboardDataImpl(): Promise { const rows = res.data ?? []; if (!Array.isArray(rows) || rows.length === 0) return null; return rows[0] ?? null; - } catch { + } catch (e) { + void logServerError( + `[admin-data] get_platform_health_stats threw: ${describeError(e)}`, + { action: 'admin_data.getAdminDashboardData', featureArea: 'admin' }, + ); return null; } })(), diff --git a/src/app/golf/actions/auth.ts b/src/app/golf/actions/auth.ts index 66012f8fd..50950c6ea 100644 --- a/src/app/golf/actions/auth.ts +++ b/src/app/golf/actions/auth.ts @@ -437,10 +437,18 @@ async function requestPasswordResetActionImpl( const supabase = await createClient(); - await supabase.auth.resetPasswordForEmail(normalizedEmail, { + const { error } = await supabase.auth.resetPasswordForEmail(normalizedEmail, { redirectTo: `${getAppBaseUrl()}/golf/reset-password`, }); + if (error) { + await logServerError(`[Golf Auth Error]: ${error.message}`, { + action: 'auth.requestPasswordResetAction', + source: 'auth', + sport: 'golf', + }); + } + // Log password-reset request (fire-and-forget) — closes the golf auth // capture gap: logins/failed-logins/signups were already tracked, but // reset requests were invisible to the admin auth feed. diff --git a/src/app/golf/actions/crm-assignee.ts b/src/app/golf/actions/crm-assignee.ts index 256d8aa2e..d120e3fb7 100644 --- a/src/app/golf/actions/crm-assignee.ts +++ b/src/app/golf/actions/crm-assignee.ts @@ -11,6 +11,8 @@ import { revalidatePath } from 'next/cache'; import { createClient } from '@/lib/supabase/server'; +import { logServerError } from '@/lib/server-error-logger'; +import { describeError } from '@/lib/utils/describe-error'; export async function setCoachAssignee(input: { coach_id: string; @@ -27,6 +29,19 @@ export async function setCoachAssignee(input: { .eq('id', input.coach_id); if (error) { + await logServerError( + `[crm-assignee] setCoachAssignee failed: ${describeError(error)}`, + { + action: 'crm_assignee.setCoachAssignee', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + errorCode: error.code, + errorHint: error.hint, + errorDetails: error.details, + metadata: { coachId: input.coach_id }, + }, + ); return { ok: false }; } revalidatePath('/golf/admin/crm'); diff --git a/src/app/golf/actions/crm-automations.ts b/src/app/golf/actions/crm-automations.ts index 4b22a0162..d86a35dee 100644 --- a/src/app/golf/actions/crm-automations.ts +++ b/src/app/golf/actions/crm-automations.ts @@ -17,6 +17,7 @@ import { revalidatePath } from 'next/cache'; import { createClient } from '@/lib/supabase/server'; +import { logServerException } from '@/lib/server-error-logger'; import type { CrmAutomation, CrmAutomationAction, @@ -81,43 +82,63 @@ export async function listAutomations(opts?: { activeOnly?: boolean; }): Promise { const { supabase } = await getAuthedClient(); - const client = supabase as AnySupabase; + try { + const client = supabase as AnySupabase; - let query = client - .from('crm_automations') - .select('*') - .order('trigger_event', { ascending: true }) - .order('priority', { ascending: true }) - .order('created_at', { ascending: false }); + let query = client + .from('crm_automations') + .select('*') + .order('trigger_event', { ascending: true }) + .order('priority', { ascending: true }) + .order('created_at', { ascending: false }); - if (opts?.trigger) { - query = query.eq('trigger_event', opts.trigger); - } - if (opts?.activeOnly) { - query = query.eq('is_active', true); - } + if (opts?.trigger) { + query = query.eq('trigger_event', opts.trigger); + } + if (opts?.activeOnly) { + query = query.eq('is_active', true); + } - const { data, error } = await query; - if (error) { - throw new Error(`Failed to load automations: ${error.message}`); + const { data, error } = await query; + if (error) { + throw new Error(`Failed to load automations: ${error.message}`); + } + return (data ?? []).map((row: Record) => normalizeRow(row)); + } catch (error) { + void logServerException(error, { + action: 'crm_automations.listAutomations', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + }); + throw error; } - return (data ?? []).map((row: Record) => normalizeRow(row)); } export async function getAutomation(id: string): Promise { const { supabase } = await getAuthedClient(); - const client = supabase as AnySupabase; + try { + const client = supabase as AnySupabase; - const { data, error } = await client - .from('crm_automations') - .select('*') - .eq('id', id) - .single(); + const { data, error } = await client + .from('crm_automations') + .select('*') + .eq('id', id) + .single(); - if (error) { - throw new Error(`Failed to load automation: ${error.message}`); + if (error) { + throw new Error(`Failed to load automation: ${error.message}`); + } + return normalizeRow(data as Record); + } catch (error) { + void logServerException(error, { + action: 'crm_automations.getAutomation', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + }); + throw error; } - return normalizeRow(data as Record); } export async function createAutomation(input: { @@ -130,36 +151,46 @@ export async function createAutomation(input: { priority?: number; }): Promise { const { supabase, user } = await getAuthedClient(); - const client = supabase as AnySupabase; + try { + const client = supabase as AnySupabase; - if (!input.name || !input.name.trim()) { - throw new Error('Automation name is required'); - } - if (!Array.isArray(input.actions) || input.actions.length === 0) { - throw new Error('At least one action is required'); - } + if (!input.name || !input.name.trim()) { + throw new Error('Automation name is required'); + } + if (!Array.isArray(input.actions) || input.actions.length === 0) { + throw new Error('At least one action is required'); + } - const { data, error } = await client - .from('crm_automations') - .insert({ - name: input.name.trim(), - description: input.description ?? null, - trigger_event: input.trigger_event, - conditions: input.conditions ?? [], - actions: input.actions, - is_active: input.is_active ?? true, - priority: input.priority ?? 100, - created_by: user.id, - }) - .select('*') - .single(); - - if (error) { - throw new Error(`Failed to create automation: ${error.message}`); - } + const { data, error } = await client + .from('crm_automations') + .insert({ + name: input.name.trim(), + description: input.description ?? null, + trigger_event: input.trigger_event, + conditions: input.conditions ?? [], + actions: input.actions, + is_active: input.is_active ?? true, + priority: input.priority ?? 100, + created_by: user.id, + }) + .select('*') + .single(); + + if (error) { + throw new Error(`Failed to create automation: ${error.message}`); + } - revalidatePath(CRM_AUTOMATIONS_PATH); - return normalizeRow(data as Record); + revalidatePath(CRM_AUTOMATIONS_PATH); + return normalizeRow(data as Record); + } catch (error) { + void logServerException(error, { + action: 'crm_automations.createAutomation', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + }); + throw error; + } } export async function updateAutomation( @@ -175,46 +206,66 @@ export async function updateAutomation( }>, ): Promise { const { supabase } = await getAuthedClient(); - const client = supabase as AnySupabase; - - // Strip server-managed columns from any patch attempt. - const safePatch: Record = {}; - if (patch.name !== undefined) safePatch.name = patch.name.trim(); - if (patch.description !== undefined) safePatch.description = patch.description; - if (patch.trigger_event !== undefined) safePatch.trigger_event = patch.trigger_event; - if (patch.conditions !== undefined) safePatch.conditions = patch.conditions; - if (patch.actions !== undefined) safePatch.actions = patch.actions; - if (patch.is_active !== undefined) safePatch.is_active = patch.is_active; - if (patch.priority !== undefined) safePatch.priority = patch.priority; - - const { data, error } = await client - .from('crm_automations') - .update(safePatch) - .eq('id', id) - .select('*') - .single(); - - if (error) { - throw new Error(`Failed to update automation: ${error.message}`); - } + try { + const client = supabase as AnySupabase; + + // Strip server-managed columns from any patch attempt. + const safePatch: Record = {}; + if (patch.name !== undefined) safePatch.name = patch.name.trim(); + if (patch.description !== undefined) safePatch.description = patch.description; + if (patch.trigger_event !== undefined) safePatch.trigger_event = patch.trigger_event; + if (patch.conditions !== undefined) safePatch.conditions = patch.conditions; + if (patch.actions !== undefined) safePatch.actions = patch.actions; + if (patch.is_active !== undefined) safePatch.is_active = patch.is_active; + if (patch.priority !== undefined) safePatch.priority = patch.priority; + + const { data, error } = await client + .from('crm_automations') + .update(safePatch) + .eq('id', id) + .select('*') + .single(); + + if (error) { + throw new Error(`Failed to update automation: ${error.message}`); + } - revalidatePath(CRM_AUTOMATIONS_PATH); - return normalizeRow(data as Record); + revalidatePath(CRM_AUTOMATIONS_PATH); + return normalizeRow(data as Record); + } catch (error) { + void logServerException(error, { + action: 'crm_automations.updateAutomation', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + }); + throw error; + } } export async function deleteAutomation(id: string): Promise<{ ok: true }> { const { supabase } = await getAuthedClient(); - const client = supabase as AnySupabase; + try { + const client = supabase as AnySupabase; - const { error } = await client - .from('crm_automations') - .delete() - .eq('id', id); + const { error } = await client + .from('crm_automations') + .delete() + .eq('id', id); - if (error) { - throw new Error(`Failed to delete automation: ${error.message}`); - } + if (error) { + throw new Error(`Failed to delete automation: ${error.message}`); + } - revalidatePath(CRM_AUTOMATIONS_PATH); - return { ok: true }; + revalidatePath(CRM_AUTOMATIONS_PATH); + return { ok: true }; + } catch (error) { + void logServerException(error, { + action: 'crm_automations.deleteAutomation', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + }); + throw error; + } } diff --git a/src/app/golf/actions/crm-dedup.ts b/src/app/golf/actions/crm-dedup.ts index c8491940c..f98a5abe4 100644 Binary files a/src/app/golf/actions/crm-dedup.ts and b/src/app/golf/actions/crm-dedup.ts differ diff --git a/src/app/golf/actions/crm-foundations.ts b/src/app/golf/actions/crm-foundations.ts index e20635856..62f986b6f 100644 --- a/src/app/golf/actions/crm-foundations.ts +++ b/src/app/golf/actions/crm-foundations.ts @@ -19,6 +19,7 @@ import { revalidatePath } from 'next/cache'; import { createClient } from '@/lib/supabase/server'; +import { logServerException } from '@/lib/server-error-logger'; import type { CrmNote, CrmSegment, @@ -54,21 +55,31 @@ type AnySupabase = any; // ============================================================================ export async function getSuppressions(emails?: string[]): Promise { const { supabase } = await getAuthedClient(); - const client = supabase as AnySupabase; - let query = client - .from('crm_email_suppressions') - .select('*') - .order('suppressed_at', { ascending: false }); - - if (emails && emails.length > 0) { - query = query.in('email', emails); + try { + const client = supabase as AnySupabase; + let query = client + .from('crm_email_suppressions') + .select('*') + .order('suppressed_at', { ascending: false }); + + if (emails && emails.length > 0) { + query = query.in('email', emails); + } + + const { data, error } = await query; + if (error) { + throw new Error(`Failed to load suppressions: ${error.message}`); + } + return (data ?? []) as EmailSuppression[]; + } catch (error) { + void logServerException(error, { + action: 'crm_foundations.getSuppressions', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + }); + throw error; } - - const { data, error } = await query; - if (error) { - throw new Error(`Failed to load suppressions: ${error.message}`); - } - return (data ?? []) as EmailSuppression[]; } export async function addSuppression(input: { @@ -77,43 +88,63 @@ export async function addSuppression(input: { metadata?: Record; }): Promise { const { supabase, user } = await getAuthedClient(); - const client = supabase as AnySupabase; - - const { data, error } = await client - .from('crm_email_suppressions') - .insert({ - email: input.email, - reason: input.reason, - source: 'admin', - metadata: input.metadata ?? {}, - suppressed_by: user.id, - }) - .select('*') - .single(); - - if (error) { - throw new Error(`Failed to add suppression: ${error.message}`); + try { + const client = supabase as AnySupabase; + + const { data, error } = await client + .from('crm_email_suppressions') + .insert({ + email: input.email, + reason: input.reason, + source: 'admin', + metadata: input.metadata ?? {}, + suppressed_by: user.id, + }) + .select('*') + .single(); + + if (error) { + throw new Error(`Failed to add suppression: ${error.message}`); + } + + revalidatePath(CRM_REVALIDATE_PATH); + return data as EmailSuppression; + } catch (error) { + void logServerException(error, { + action: 'crm_foundations.addSuppression', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + }); + throw error; } - - revalidatePath(CRM_REVALIDATE_PATH); - return data as EmailSuppression; } export async function removeSuppression(id: string): Promise<{ ok: true }> { const { supabase } = await getAuthedClient(); - const client = supabase as AnySupabase; - - const { error } = await client - .from('crm_email_suppressions') - .delete() - .eq('id', id); - - if (error) { - throw new Error(`Failed to remove suppression: ${error.message}`); + try { + const client = supabase as AnySupabase; + + const { error } = await client + .from('crm_email_suppressions') + .delete() + .eq('id', id); + + if (error) { + throw new Error(`Failed to remove suppression: ${error.message}`); + } + + revalidatePath(CRM_REVALIDATE_PATH); + return { ok: true }; + } catch (error) { + void logServerException(error, { + action: 'crm_foundations.removeSuppression', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + }); + throw error; } - - revalidatePath(CRM_REVALIDATE_PATH); - return { ok: true }; } // ============================================================================ @@ -121,19 +152,29 @@ export async function removeSuppression(id: string): Promise<{ ok: true }> { // ============================================================================ export async function listCoachNotes(coachId: string): Promise { const { supabase } = await getAuthedClient(); - const client = supabase as AnySupabase; - - const { data, error } = await client - .from('crm_notes') - .select('*') - .eq('coach_id', coachId) - .order('is_pinned', { ascending: false }) - .order('created_at', { ascending: false }); - - if (error) { - throw new Error(`Failed to load notes: ${error.message}`); + try { + const client = supabase as AnySupabase; + + const { data, error } = await client + .from('crm_notes') + .select('*') + .eq('coach_id', coachId) + .order('is_pinned', { ascending: false }) + .order('created_at', { ascending: false }); + + if (error) { + throw new Error(`Failed to load notes: ${error.message}`); + } + return (data ?? []) as CrmNote[]; + } catch (error) { + void logServerException(error, { + action: 'crm_foundations.listCoachNotes', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + }); + throw error; } - return (data ?? []) as CrmNote[]; } export async function createCoachNote(input: { @@ -143,26 +184,36 @@ export async function createCoachNote(input: { is_pinned?: boolean; }): Promise { const { supabase, user } = await getAuthedClient(); - const client = supabase as AnySupabase; - - const { data, error } = await client - .from('crm_notes') - .insert({ - coach_id: input.coach_id, - author_id: user.id, - body: input.body, - kind: input.kind ?? 'note', - is_pinned: input.is_pinned ?? false, - }) - .select('*') - .single(); - - if (error) { - throw new Error(`Failed to create note: ${error.message}`); + try { + const client = supabase as AnySupabase; + + const { data, error } = await client + .from('crm_notes') + .insert({ + coach_id: input.coach_id, + author_id: user.id, + body: input.body, + kind: input.kind ?? 'note', + is_pinned: input.is_pinned ?? false, + }) + .select('*') + .single(); + + if (error) { + throw new Error(`Failed to create note: ${error.message}`); + } + + revalidatePath(CRM_REVALIDATE_PATH); + return data as CrmNote; + } catch (error) { + void logServerException(error, { + action: 'crm_foundations.createCoachNote', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + }); + throw error; } - - revalidatePath(CRM_REVALIDATE_PATH); - return data as CrmNote; } export async function updateCoachNote( @@ -170,38 +221,58 @@ export async function updateCoachNote( patch: Partial>, ): Promise { const { supabase } = await getAuthedClient(); - const client = supabase as AnySupabase; - - const { data, error } = await client - .from('crm_notes') - .update(patch) - .eq('id', id) - .select('*') - .single(); - - if (error) { - throw new Error(`Failed to update note: ${error.message}`); + try { + const client = supabase as AnySupabase; + + const { data, error } = await client + .from('crm_notes') + .update(patch) + .eq('id', id) + .select('*') + .single(); + + if (error) { + throw new Error(`Failed to update note: ${error.message}`); + } + + revalidatePath(CRM_REVALIDATE_PATH); + return data as CrmNote; + } catch (error) { + void logServerException(error, { + action: 'crm_foundations.updateCoachNote', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + }); + throw error; } - - revalidatePath(CRM_REVALIDATE_PATH); - return data as CrmNote; } export async function deleteCoachNote(id: string): Promise<{ ok: true }> { const { supabase } = await getAuthedClient(); - const client = supabase as AnySupabase; - - const { error } = await client - .from('crm_notes') - .delete() - .eq('id', id); - - if (error) { - throw new Error(`Failed to delete note: ${error.message}`); + try { + const client = supabase as AnySupabase; + + const { error } = await client + .from('crm_notes') + .delete() + .eq('id', id); + + if (error) { + throw new Error(`Failed to delete note: ${error.message}`); + } + + revalidatePath(CRM_REVALIDATE_PATH); + return { ok: true }; + } catch (error) { + void logServerException(error, { + action: 'crm_foundations.deleteCoachNote', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + }); + throw error; } - - revalidatePath(CRM_REVALIDATE_PATH); - return { ok: true }; } // ============================================================================ @@ -212,55 +283,75 @@ export async function listCoachTasks( opts?: { includeCompleted?: boolean }, ): Promise { const { supabase } = await getAuthedClient(); - const client = supabase as AnySupabase; - - let query = client - .from('crm_tasks') - .select('*') - .eq('coach_id', coachId); - - if (!opts?.includeCompleted) { - query = query.in('status', ['pending', 'in_progress']); - } - - const { data, error } = await query - .order('due_at', { ascending: true, nullsFirst: false }) - .order('created_at', { ascending: false }); - - if (error) { - throw new Error(`Failed to load tasks: ${error.message}`); + try { + const client = supabase as AnySupabase; + + let query = client + .from('crm_tasks') + .select('*') + .eq('coach_id', coachId); + + if (!opts?.includeCompleted) { + query = query.in('status', ['pending', 'in_progress']); + } + + const { data, error } = await query + .order('due_at', { ascending: true, nullsFirst: false }) + .order('created_at', { ascending: false }); + + if (error) { + throw new Error(`Failed to load tasks: ${error.message}`); + } + return (data ?? []) as CrmTask[]; + } catch (error) { + void logServerException(error, { + action: 'crm_foundations.listCoachTasks', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + }); + throw error; } - return (data ?? []) as CrmTask[]; } export async function listMyDueTasks( opts?: { byEod?: boolean; limit?: number }, ): Promise { const { supabase, user } = await getAuthedClient(); - const client = supabase as AnySupabase; - - let query = client - .from('crm_tasks') - .select('*') - .eq('assignee_id', user.id) - .in('status', ['pending', 'in_progress']); - - if (opts?.byEod) { - const eod = new Date(); - eod.setHours(23, 59, 59, 999); - query = query.lte('due_at', eod.toISOString()); + try { + const client = supabase as AnySupabase; + + let query = client + .from('crm_tasks') + .select('*') + .eq('assignee_id', user.id) + .in('status', ['pending', 'in_progress']); + + if (opts?.byEod) { + const eod = new Date(); + eod.setHours(23, 59, 59, 999); + query = query.lte('due_at', eod.toISOString()); + } + + query = query.order('due_at', { ascending: true, nullsFirst: false }); + if (opts?.limit && opts.limit > 0) { + query = query.limit(opts.limit); + } + + const { data, error } = await query; + if (error) { + throw new Error(`Failed to load due tasks: ${error.message}`); + } + return (data ?? []) as CrmTask[]; + } catch (error) { + void logServerException(error, { + action: 'crm_foundations.listMyDueTasks', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + }); + throw error; } - - query = query.order('due_at', { ascending: true, nullsFirst: false }); - if (opts?.limit && opts.limit > 0) { - query = query.limit(opts.limit); - } - - const { data, error } = await query; - if (error) { - throw new Error(`Failed to load due tasks: ${error.message}`); - } - return (data ?? []) as CrmTask[]; } export async function createCrmTask( @@ -270,33 +361,43 @@ export async function createCrmTask( >, ): Promise { const { supabase, user } = await getAuthedClient(); - const client = supabase as AnySupabase; - - const { data, error } = await client - .from('crm_tasks') - .insert({ - coach_id: input.coach_id, - assignee_id: input.assignee_id, - created_by: user.id, - title: input.title, - description: input.description, - due_at: input.due_at, - status: input.status, - priority: input.priority, - kind: input.kind, - source: input.source, - reminder_at: input.reminder_at, - metadata: input.metadata ?? {}, - }) - .select('*') - .single(); - - if (error) { - throw new Error(`Failed to create task: ${error.message}`); + try { + const client = supabase as AnySupabase; + + const { data, error } = await client + .from('crm_tasks') + .insert({ + coach_id: input.coach_id, + assignee_id: input.assignee_id, + created_by: user.id, + title: input.title, + description: input.description, + due_at: input.due_at, + status: input.status, + priority: input.priority, + kind: input.kind, + source: input.source, + reminder_at: input.reminder_at, + metadata: input.metadata ?? {}, + }) + .select('*') + .single(); + + if (error) { + throw new Error(`Failed to create task: ${error.message}`); + } + + revalidatePath(CRM_REVALIDATE_PATH); + return data as CrmTask; + } catch (error) { + void logServerException(error, { + action: 'crm_foundations.createCrmTask', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + }); + throw error; } - - revalidatePath(CRM_REVALIDATE_PATH); - return data as CrmTask; } export async function updateCrmTask( @@ -304,53 +405,73 @@ export async function updateCrmTask( patch: Partial, ): Promise { const { supabase } = await getAuthedClient(); - const client = supabase as AnySupabase; - - // Strip server-managed columns from any patch attempt - const { - id: _id, - created_by: _createdBy, - created_at: _createdAt, - updated_at: _updatedAt, - ...safePatch - } = patch; - void _id; void _createdBy; void _createdAt; void _updatedAt; - - const { data, error } = await client - .from('crm_tasks') - .update(safePatch) - .eq('id', id) - .select('*') - .single(); - - if (error) { - throw new Error(`Failed to update task: ${error.message}`); + try { + const client = supabase as AnySupabase; + + // Strip server-managed columns from any patch attempt + const { + id: _id, + created_by: _createdBy, + created_at: _createdAt, + updated_at: _updatedAt, + ...safePatch + } = patch; + void _id; void _createdBy; void _createdAt; void _updatedAt; + + const { data, error } = await client + .from('crm_tasks') + .update(safePatch) + .eq('id', id) + .select('*') + .single(); + + if (error) { + throw new Error(`Failed to update task: ${error.message}`); + } + + revalidatePath(CRM_REVALIDATE_PATH); + return data as CrmTask; + } catch (error) { + void logServerException(error, { + action: 'crm_foundations.updateCrmTask', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + }); + throw error; } - - revalidatePath(CRM_REVALIDATE_PATH); - return data as CrmTask; } export async function completeCrmTask(id: string): Promise { const { supabase } = await getAuthedClient(); - const client = supabase as AnySupabase; - - const { data, error } = await client - .from('crm_tasks') - .update({ - status: 'completed', - completed_at: new Date().toISOString(), - }) - .eq('id', id) - .select('*') - .single(); - - if (error) { - throw new Error(`Failed to complete task: ${error.message}`); + try { + const client = supabase as AnySupabase; + + const { data, error } = await client + .from('crm_tasks') + .update({ + status: 'completed', + completed_at: new Date().toISOString(), + }) + .eq('id', id) + .select('*') + .single(); + + if (error) { + throw new Error(`Failed to complete task: ${error.message}`); + } + + revalidatePath(CRM_REVALIDATE_PATH); + return data as CrmTask; + } catch (error) { + void logServerException(error, { + action: 'crm_foundations.completeCrmTask', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + }); + throw error; } - - revalidatePath(CRM_REVALIDATE_PATH); - return data as CrmTask; } // ============================================================================ @@ -358,18 +479,28 @@ export async function completeCrmTask(id: string): Promise { // ============================================================================ export async function listSegments(): Promise { const { supabase } = await getAuthedClient(); - const client = supabase as AnySupabase; - - const { data, error } = await client - .from('crm_segments') - .select('*') - .order('pin_order', { ascending: true, nullsFirst: false }) - .order('updated_at', { ascending: false }); - - if (error) { - throw new Error(`Failed to load segments: ${error.message}`); + try { + const client = supabase as AnySupabase; + + const { data, error } = await client + .from('crm_segments') + .select('*') + .order('pin_order', { ascending: true, nullsFirst: false }) + .order('updated_at', { ascending: false }); + + if (error) { + throw new Error(`Failed to load segments: ${error.message}`); + } + return (data ?? []) as CrmSegment[]; + } catch (error) { + void logServerException(error, { + action: 'crm_foundations.listSegments', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + }); + throw error; } - return (data ?? []) as CrmSegment[]; } export async function createSegment(input: { @@ -380,27 +511,37 @@ export async function createSegment(input: { pin_order?: number; }): Promise { const { supabase, user } = await getAuthedClient(); - const client = supabase as AnySupabase; - - const { data, error } = await client - .from('crm_segments') - .insert({ - name: input.name, - description: input.description ?? null, - definition: input.definition, - created_by: user.id, - is_shared: input.is_shared ?? true, - pin_order: input.pin_order ?? null, - }) - .select('*') - .single(); - - if (error) { - throw new Error(`Failed to create segment: ${error.message}`); + try { + const client = supabase as AnySupabase; + + const { data, error } = await client + .from('crm_segments') + .insert({ + name: input.name, + description: input.description ?? null, + definition: input.definition, + created_by: user.id, + is_shared: input.is_shared ?? true, + pin_order: input.pin_order ?? null, + }) + .select('*') + .single(); + + if (error) { + throw new Error(`Failed to create segment: ${error.message}`); + } + + revalidatePath(CRM_REVALIDATE_PATH); + return data as CrmSegment; + } catch (error) { + void logServerException(error, { + action: 'crm_foundations.createSegment', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + }); + throw error; } - - revalidatePath(CRM_REVALIDATE_PATH); - return data as CrmSegment; } export async function updateSegment( @@ -408,36 +549,56 @@ export async function updateSegment( patch: Partial>, ): Promise { const { supabase } = await getAuthedClient(); - const client = supabase as AnySupabase; - - const { data, error } = await client - .from('crm_segments') - .update(patch) - .eq('id', id) - .select('*') - .single(); - - if (error) { - throw new Error(`Failed to update segment: ${error.message}`); + try { + const client = supabase as AnySupabase; + + const { data, error } = await client + .from('crm_segments') + .update(patch) + .eq('id', id) + .select('*') + .single(); + + if (error) { + throw new Error(`Failed to update segment: ${error.message}`); + } + + revalidatePath(CRM_REVALIDATE_PATH); + return data as CrmSegment; + } catch (error) { + void logServerException(error, { + action: 'crm_foundations.updateSegment', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + }); + throw error; } - - revalidatePath(CRM_REVALIDATE_PATH); - return data as CrmSegment; } export async function deleteSegment(id: string): Promise<{ ok: true }> { const { supabase } = await getAuthedClient(); - const client = supabase as AnySupabase; - - const { error } = await client - .from('crm_segments') - .delete() - .eq('id', id); - - if (error) { - throw new Error(`Failed to delete segment: ${error.message}`); + try { + const client = supabase as AnySupabase; + + const { error } = await client + .from('crm_segments') + .delete() + .eq('id', id); + + if (error) { + throw new Error(`Failed to delete segment: ${error.message}`); + } + + revalidatePath(CRM_REVALIDATE_PATH); + return { ok: true }; + } catch (error) { + void logServerException(error, { + action: 'crm_foundations.deleteSegment', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + }); + throw error; } - - revalidatePath(CRM_REVALIDATE_PATH); - return { ok: true }; } diff --git a/src/app/golf/actions/crm-gmail-send.ts b/src/app/golf/actions/crm-gmail-send.ts index 0dabd681b..6e1f15293 100644 --- a/src/app/golf/actions/crm-gmail-send.ts +++ b/src/app/golf/actions/crm-gmail-send.ts @@ -24,6 +24,7 @@ import { verifyEmailDeliverability } from '@/lib/crm/email-verify'; import { checkDomainAuth, type DomainAuthResult } from '@/lib/crm/domain-auth-check'; import { mergeTags, type Recipient } from '@/lib/crm/merge-tags'; import { describeError } from '@/lib/utils/describe-error'; +import { logServerError, logServerException } from '@/lib/server-error-logger'; const CRM_REVALIDATE_PATH = '/golf/admin/crm'; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -233,6 +234,13 @@ export async function sendCoachViaGmail(input: { revalidatePath(CRM_REVALIDATE_PATH); return { ok: true }; } catch (err) { + await logServerException(err, { + action: 'crm_gmail_send.sendCoachViaGmail', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + metadata: { coachId: input.coach_id, subject: input.subject }, + }); return { ok: false, error: describeError(err) }; } } @@ -343,15 +351,44 @@ export async function sendNextBatchViaGmail(input: { sent++; details.push({ name: c.name, school: c.school, status: 'sent' }); } catch (err) { + // Per-recipient failures are rolled up into ONE log after the loop + // (below) rather than logged here — a bad batch would otherwise spam + // one admin_events row per recipient. failed++; details.push({ name: c.name, school: c.school, status: 'failed', reason: describeError(err) }); } if (i < targets.length - 1) await sleep(jitterGapMs()); // jittered pace for deliverability } + if (failed > 0) { + await logServerError( + `[crm-gmail-send] sendNextBatchViaGmail: ${failed} of ${targets.length} sends failed`, + { + action: 'crm_gmail_send.sendNextBatchViaGmail', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + metadata: { + templateId: input.templateId, + sentCount: sent, + skippedCount: skipped, + failedCount: failed, + samples: details.filter((d) => d.status === 'failed').slice(0, 5), + }, + }, + ); + } + revalidatePath(CRM_REVALIDATE_PATH); return { ok: true, sent, skipped, failed, capped: sentToday + sent >= dailyCap, details }; } catch (err) { + await logServerException(err, { + action: 'crm_gmail_send.sendNextBatchViaGmail', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + metadata: { templateId: input.templateId, limit: input.limit }, + }); return { ok: false, ...empty, error: describeError(err) }; } } diff --git a/src/app/golf/actions/crm-manual-send.ts b/src/app/golf/actions/crm-manual-send.ts index 556435ba6..97d6bdf30 100644 --- a/src/app/golf/actions/crm-manual-send.ts +++ b/src/app/golf/actions/crm-manual-send.ts @@ -24,6 +24,8 @@ import { revalidatePath } from 'next/cache'; import { createClient } from '@/lib/supabase/server'; +import { logServerError } from '@/lib/server-error-logger'; +import { describeError } from '@/lib/utils/describe-error'; const CRM_REVALIDATE_PATH = '/golf/admin/crm'; @@ -92,7 +94,16 @@ export async function logManualGmailTouch(input: { revalidatePath(CRM_REVALIDATE_PATH); return { ok: true }; } catch (err) { - console.error('[crm] logManualGmailTouch failed:', err); + await logServerError( + `[crm-manual-send] logManualGmailTouch failed: ${describeError(err)}`, + { + action: 'crm_manual_send.logManualGmailTouch', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + metadata: { coachId: input.coach_id, subject: input.subject }, + }, + ); return { ok: false }; } } diff --git a/src/app/golf/actions/crm-replies.ts b/src/app/golf/actions/crm-replies.ts index 9f6eba20d..aba691818 100644 --- a/src/app/golf/actions/crm-replies.ts +++ b/src/app/golf/actions/crm-replies.ts @@ -15,6 +15,7 @@ import { revalidatePath } from 'next/cache'; import { createClient } from '@/lib/supabase/server'; +import { logServerException } from '@/lib/server-error-logger'; import { listMyDueTasks } from './crm-foundations'; import type { CrmTask } from '../admin/crm/types/foundations'; @@ -86,31 +87,41 @@ export async function listReplies(opts?: { limit?: number; }): Promise { const { supabase } = await getAuthedClient(); - const client = supabase as AnySupabase; - - let query = client - .from('crm_replies') - .select('*') - .order('received_at', { ascending: false }); - - if (opts?.coachId) { - query = query.eq('coach_id', opts.coachId); - } - if (opts?.threadId) { - query = query.eq('thread_id', opts.threadId); - } - if (opts?.unreadOnly) { - query = query.eq('is_read', false); - } - if (opts?.limit && opts.limit > 0) { - query = query.limit(opts.limit); + try { + const client = supabase as AnySupabase; + + let query = client + .from('crm_replies') + .select('*') + .order('received_at', { ascending: false }); + + if (opts?.coachId) { + query = query.eq('coach_id', opts.coachId); + } + if (opts?.threadId) { + query = query.eq('thread_id', opts.threadId); + } + if (opts?.unreadOnly) { + query = query.eq('is_read', false); + } + if (opts?.limit && opts.limit > 0) { + query = query.limit(opts.limit); + } + + const { data, error } = await query; + if (error) { + throw new Error(`Failed to load replies: ${error.message}`); + } + return (data ?? []).map((row: Record) => normalizeReply(row)); + } catch (error) { + void logServerException(error, { + action: 'crm_replies.listReplies', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + }); + throw error; } - - const { data, error } = await query; - if (error) { - throw new Error(`Failed to load replies: ${error.message}`); - } - return (data ?? []).map((row: Record) => normalizeReply(row)); } export async function getCoachReplies(coachId: string): Promise { @@ -119,21 +130,31 @@ export async function getCoachReplies(coachId: string): Promise { export async function markReplyRead(id: string): Promise { const { supabase } = await getAuthedClient(); - const client = supabase as AnySupabase; - - const { data, error } = await client - .from('crm_replies') - .update({ is_read: true }) - .eq('id', id) - .select('*') - .single(); - - if (error) { - throw new Error(`Failed to mark reply read: ${error.message}`); + try { + const client = supabase as AnySupabase; + + const { data, error } = await client + .from('crm_replies') + .update({ is_read: true }) + .eq('id', id) + .select('*') + .single(); + + if (error) { + throw new Error(`Failed to mark reply read: ${error.message}`); + } + + revalidatePath(CRM_INBOX_PATH); + return normalizeReply(data as Record); + } catch (error) { + void logServerException(error, { + action: 'crm_replies.markReplyRead', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + }); + throw error; } - - revalidatePath(CRM_INBOX_PATH); - return normalizeReply(data as Record); } // ---------------------------------------------------------------------------- diff --git a/src/app/golf/actions/crm-sequences.ts b/src/app/golf/actions/crm-sequences.ts index 55d1a1bb4..32e1270ae 100644 --- a/src/app/golf/actions/crm-sequences.ts +++ b/src/app/golf/actions/crm-sequences.ts @@ -21,6 +21,7 @@ import { revalidatePath } from 'next/cache'; import { createClient } from '@/lib/supabase/server'; import { fetchAllRows } from '@/lib/supabase/fetch-all-rows'; +import { logServerException } from '@/lib/server-error-logger'; // ============================================================================ // Types — exported for consumers (UI components, cron route) @@ -104,17 +105,27 @@ type AnySupabase = any; // ============================================================================ export async function listSequences(): Promise { const { supabase } = await getAuthedClient(); - const client = supabase as AnySupabase; + try { + const client = supabase as AnySupabase; - const { data, error } = await client - .from('crm_sequences') - .select('*') - .order('updated_at', { ascending: false }); + const { data, error } = await client + .from('crm_sequences') + .select('*') + .order('updated_at', { ascending: false }); - if (error) { - throw new Error(`Failed to load sequences: ${error.message}`); + if (error) { + throw new Error(`Failed to load sequences: ${error.message}`); + } + return (data ?? []) as CrmSequence[]; + } catch (error) { + void logServerException(error, { + action: 'crm_sequences.listSequences', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + }); + throw error; } - return (data ?? []) as CrmSequence[]; } export async function getSequence(id: string): Promise<{ @@ -122,32 +133,42 @@ export async function getSequence(id: string): Promise<{ steps: CrmSequenceStep[]; }> { const { supabase } = await getAuthedClient(); - const client = supabase as AnySupabase; + try { + const client = supabase as AnySupabase; - const { data: sequence, error: seqError } = await client - .from('crm_sequences') - .select('*') - .eq('id', id) - .single(); + const { data: sequence, error: seqError } = await client + .from('crm_sequences') + .select('*') + .eq('id', id) + .single(); - if (seqError) { - throw new Error(`Failed to load sequence: ${seqError.message}`); - } + if (seqError) { + throw new Error(`Failed to load sequence: ${seqError.message}`); + } - const { data: steps, error: stepsError } = await client - .from('crm_sequence_steps') - .select('*') - .eq('sequence_id', id) - .order('step_order', { ascending: true }); + const { data: steps, error: stepsError } = await client + .from('crm_sequence_steps') + .select('*') + .eq('sequence_id', id) + .order('step_order', { ascending: true }); - if (stepsError) { - throw new Error(`Failed to load sequence steps: ${stepsError.message}`); - } + if (stepsError) { + throw new Error(`Failed to load sequence steps: ${stepsError.message}`); + } - return { - sequence: sequence as CrmSequence, - steps: (steps ?? []) as CrmSequenceStep[], - }; + return { + sequence: sequence as CrmSequence, + steps: (steps ?? []) as CrmSequenceStep[], + }; + } catch (error) { + void logServerException(error, { + action: 'crm_sequences.getSequence', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + }); + throw error; + } } // True enrollment counts by status (exact COUNT, not a row-limited slice) — the @@ -165,21 +186,32 @@ export async function getSequenceEnrollmentCounts( sequence_id: string, ): Promise { const { supabase } = await getAuthedClient(); - const client = supabase as AnySupabase; - const countFor = async (status?: SequenceEnrollmentStatus) => { - let q = client - .from('crm_sequence_enrollments') - .select('id', { count: 'exact', head: true }) - .eq('sequence_id', sequence_id); - if (status) q = q.eq('status', status); - const { count, error } = await q; - if (error) throw new Error(`Failed to count enrollments: ${error.message}`); - return count ?? 0; - }; - const [active, completed, stopped, paused, total] = await Promise.all([ - countFor('active'), countFor('completed'), countFor('stopped'), countFor('paused'), countFor(), - ]); - return { active, completed, stopped, paused, total }; + try { + const client = supabase as AnySupabase; + const countFor = async (status?: SequenceEnrollmentStatus) => { + let q = client + .from('crm_sequence_enrollments') + .select('id', { count: 'exact', head: true }) + .eq('sequence_id', sequence_id); + if (status) q = q.eq('status', status); + const { count, error } = await q; + if (error) throw new Error(`Failed to count enrollments: ${error.message}`); + return count ?? 0; + }; + const [active, completed, stopped, paused, total] = await Promise.all([ + countFor('active'), countFor('completed'), countFor('stopped'), countFor('paused'), countFor(), + ]); + return { active, completed, stopped, paused, total }; + } catch (error) { + void logServerException(error, { + action: 'crm_sequences.getSequenceEnrollmentCounts', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + metadata: { sequenceId: sequence_id }, + }); + throw error; + } } export async function createSequence(input: { @@ -188,26 +220,36 @@ export async function createSequence(input: { trigger_kind?: SequenceTriggerKind; }): Promise { const { supabase, user } = await getAuthedClient(); - const client = supabase as AnySupabase; - - const { data, error } = await client - .from('crm_sequences') - .insert({ - name: input.name, - description: input.description ?? null, - trigger_kind: input.trigger_kind ?? 'manual', - is_active: true, - created_by: user.id, - }) - .select('*') - .single(); - - if (error) { - throw new Error(`Failed to create sequence: ${error.message}`); - } + try { + const client = supabase as AnySupabase; + + const { data, error } = await client + .from('crm_sequences') + .insert({ + name: input.name, + description: input.description ?? null, + trigger_kind: input.trigger_kind ?? 'manual', + is_active: true, + created_by: user.id, + }) + .select('*') + .single(); + + if (error) { + throw new Error(`Failed to create sequence: ${error.message}`); + } - revalidatePath(CRM_SEQUENCES_REVALIDATE_PATH); - return data as CrmSequence; + revalidatePath(CRM_SEQUENCES_REVALIDATE_PATH); + return data as CrmSequence; + } catch (error) { + void logServerException(error, { + action: 'crm_sequences.createSequence', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + }); + throw error; + } } export async function updateSequence( @@ -215,38 +257,58 @@ export async function updateSequence( patch: Partial>, ): Promise { const { supabase } = await getAuthedClient(); - const client = supabase as AnySupabase; + try { + const client = supabase as AnySupabase; + + const { data, error } = await client + .from('crm_sequences') + .update(patch) + .eq('id', id) + .select('*') + .single(); - const { data, error } = await client - .from('crm_sequences') - .update(patch) - .eq('id', id) - .select('*') - .single(); + if (error) { + throw new Error(`Failed to update sequence: ${error.message}`); + } - if (error) { - throw new Error(`Failed to update sequence: ${error.message}`); + revalidatePath(CRM_SEQUENCES_REVALIDATE_PATH); + return data as CrmSequence; + } catch (error) { + void logServerException(error, { + action: 'crm_sequences.updateSequence', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + }); + throw error; } - - revalidatePath(CRM_SEQUENCES_REVALIDATE_PATH); - return data as CrmSequence; } export async function deleteSequence(id: string): Promise<{ ok: true }> { const { supabase } = await getAuthedClient(); - const client = supabase as AnySupabase; + try { + const client = supabase as AnySupabase; - const { error } = await client - .from('crm_sequences') - .delete() - .eq('id', id); + const { error } = await client + .from('crm_sequences') + .delete() + .eq('id', id); - if (error) { - throw new Error(`Failed to delete sequence: ${error.message}`); - } + if (error) { + throw new Error(`Failed to delete sequence: ${error.message}`); + } - revalidatePath(CRM_SEQUENCES_REVALIDATE_PATH); - return { ok: true }; + revalidatePath(CRM_SEQUENCES_REVALIDATE_PATH); + return { ok: true }; + } catch (error) { + void logServerException(error, { + action: 'crm_sequences.deleteSequence', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + }); + throw error; + } } // ============================================================================ @@ -262,51 +324,71 @@ export async function upsertSequenceStep(input: { condition?: Record; }): Promise { const { supabase } = await getAuthedClient(); - const client = supabase as AnySupabase; - - // Upsert on (sequence_id, step_order) — the unique constraint handles - // collisions cleanly so re-saving a step at the same position updates - // rather than erroring. - const { data, error } = await client - .from('crm_sequence_steps') - .upsert( - { - sequence_id: input.sequence_id, - step_order: input.step_order, - delay_hours: input.delay_hours, - template_id: input.template_id ?? null, - subject_override: input.subject_override ?? null, - body_override: input.body_override ?? null, - condition: input.condition ?? {}, - }, - { onConflict: 'sequence_id,step_order' }, - ) - .select('*') - .single(); + try { + const client = supabase as AnySupabase; - if (error) { - throw new Error(`Failed to save sequence step: ${error.message}`); - } + // Upsert on (sequence_id, step_order) — the unique constraint handles + // collisions cleanly so re-saving a step at the same position updates + // rather than erroring. + const { data, error } = await client + .from('crm_sequence_steps') + .upsert( + { + sequence_id: input.sequence_id, + step_order: input.step_order, + delay_hours: input.delay_hours, + template_id: input.template_id ?? null, + subject_override: input.subject_override ?? null, + body_override: input.body_override ?? null, + condition: input.condition ?? {}, + }, + { onConflict: 'sequence_id,step_order' }, + ) + .select('*') + .single(); - revalidatePath(CRM_SEQUENCES_REVALIDATE_PATH); - return data as CrmSequenceStep; + if (error) { + throw new Error(`Failed to save sequence step: ${error.message}`); + } + + revalidatePath(CRM_SEQUENCES_REVALIDATE_PATH); + return data as CrmSequenceStep; + } catch (error) { + void logServerException(error, { + action: 'crm_sequences.upsertSequenceStep', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + }); + throw error; + } } export async function deleteSequenceStep(id: string): Promise<{ ok: true }> { const { supabase } = await getAuthedClient(); - const client = supabase as AnySupabase; + try { + const client = supabase as AnySupabase; - const { error } = await client - .from('crm_sequence_steps') - .delete() - .eq('id', id); + const { error } = await client + .from('crm_sequence_steps') + .delete() + .eq('id', id); - if (error) { - throw new Error(`Failed to delete sequence step: ${error.message}`); - } + if (error) { + throw new Error(`Failed to delete sequence step: ${error.message}`); + } - revalidatePath(CRM_SEQUENCES_REVALIDATE_PATH); - return { ok: true }; + revalidatePath(CRM_SEQUENCES_REVALIDATE_PATH); + return { ok: true }; + } catch (error) { + void logServerException(error, { + action: 'crm_sequences.deleteSequenceStep', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + }); + throw error; + } } // ============================================================================ @@ -339,53 +421,64 @@ export async function enrollCoachesInSequence(input: { coach_ids: string[]; }): Promise<{ enrolled: number; skipped: number }> { const { supabase, user } = await getAuthedClient(); - const client = supabase as AnySupabase; + try { + const client = supabase as AnySupabase; - if (input.coach_ids.length === 0) { - return { enrolled: 0, skipped: 0 }; - } + if (input.coach_ids.length === 0) { + return { enrolled: 0, skipped: 0 }; + } - // De-duplicate against existing (sequence, coach) pairs so we report skipped - // accurately. The DB UNIQUE constraint would also catch this but on-conflict - // ignore returns less helpful counts. - const { data: existing, error: existingErr } = await client - .from('crm_sequence_enrollments') - .select('coach_id') - .eq('sequence_id', input.sequence_id) - .in('coach_id', input.coach_ids); - - if (existingErr) { - throw new Error(`Failed to check existing enrollments: ${existingErr.message}`); - } + // De-duplicate against existing (sequence, coach) pairs so we report skipped + // accurately. The DB UNIQUE constraint would also catch this but on-conflict + // ignore returns less helpful counts. + const { data: existing, error: existingErr } = await client + .from('crm_sequence_enrollments') + .select('coach_id') + .eq('sequence_id', input.sequence_id) + .in('coach_id', input.coach_ids); - const existingSet = new Set( - ((existing ?? []) as Array<{ coach_id: string }>).map((r) => r.coach_id), - ); - const newCoachIds = input.coach_ids.filter((id) => !existingSet.has(id)); + if (existingErr) { + throw new Error(`Failed to check existing enrollments: ${existingErr.message}`); + } - if (newCoachIds.length === 0) { - return { enrolled: 0, skipped: input.coach_ids.length }; - } + const existingSet = new Set( + ((existing ?? []) as Array<{ coach_id: string }>).map((r) => r.coach_id), + ); + const newCoachIds = input.coach_ids.filter((id) => !existingSet.has(id)); - const rows = await buildEnrollmentRows({ - sequence_id: input.sequence_id, - coach_ids: newCoachIds, - enrolled_by: user.id, - }); + if (newCoachIds.length === 0) { + return { enrolled: 0, skipped: input.coach_ids.length }; + } - const { error: insertErr } = await client - .from('crm_sequence_enrollments') - .insert(rows); + const rows = await buildEnrollmentRows({ + sequence_id: input.sequence_id, + coach_ids: newCoachIds, + enrolled_by: user.id, + }); - if (insertErr) { - throw new Error(`Failed to enroll coaches: ${insertErr.message}`); - } + const { error: insertErr } = await client + .from('crm_sequence_enrollments') + .insert(rows); - revalidatePath(CRM_SEQUENCES_REVALIDATE_PATH); - return { - enrolled: newCoachIds.length, - skipped: input.coach_ids.length - newCoachIds.length, - }; + if (insertErr) { + throw new Error(`Failed to enroll coaches: ${insertErr.message}`); + } + + revalidatePath(CRM_SEQUENCES_REVALIDATE_PATH); + return { + enrolled: newCoachIds.length, + skipped: input.coach_ids.length - newCoachIds.length, + }; + } catch (error) { + void logServerException(error, { + action: 'crm_sequences.enrollCoachesInSequence', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + metadata: { sequenceId: input.sequence_id, coachCount: input.coach_ids.length }, + }); + throw error; + } } export async function enrollSegmentInSequence(input: { @@ -393,90 +486,101 @@ export async function enrollSegmentInSequence(input: { segment_id: string; }): Promise<{ enrolled: number; skipped: number }> { const { supabase } = await getAuthedClient(); - const client = supabase as AnySupabase; - - // Pull the segment definition (Filters JSONB) and translate to a - // crm_coaches query. The set of fields on SegmentDefinition mirrors the - // Filters interface in CoachFilters.tsx (frozen contract — see - // src/app/golf/admin/crm/types/foundations.ts). - const { data: segment, error: segErr } = await client - .from('crm_segments') - .select('definition') - .eq('id', input.segment_id) - .single(); - - if (segErr || !segment) { - throw new Error(`Failed to load segment: ${segErr?.message ?? 'not found'}`); - } + try { + const client = supabase as AnySupabase; + + // Pull the segment definition (Filters JSONB) and translate to a + // crm_coaches query. The set of fields on SegmentDefinition mirrors the + // Filters interface in CoachFilters.tsx (frozen contract — see + // src/app/golf/admin/crm/types/foundations.ts). + const { data: segment, error: segErr } = await client + .from('crm_segments') + .select('definition') + .eq('id', input.segment_id) + .single(); + + if (segErr || !segment) { + throw new Error(`Failed to load segment: ${segErr?.message ?? 'not found'}`); + } - const def = (segment as { definition: Record }).definition ?? {}; - let query = client - .from('crm_coaches') - .select('id') - // NULL-safe archived filter: legacy rows with is_archived = NULL must still - // enroll, so match NULL OR false. A bare .eq('is_archived', false) would drop - // NULL rows via Postgres three-valued logic. Mirrors admin/crm/page.tsx. - .or('is_archived.is.null,is_archived.eq.false'); - - if (def.status && def.status !== 'all') { - query = query.eq('status', def.status); - } - if (def.division && def.division !== 'all') { - query = query.eq('division', def.division); - } - if (def.conference && def.conference !== 'all') { - query = query.eq('conference', def.conference); - } - if (def.program && def.program !== 'all') { - query = query.eq('program', def.program); - } - if (def.priority && def.priority !== 'all') { - const priorityNum = Number.parseInt(String(def.priority), 10); - if (!Number.isNaN(priorityNum)) { - query = query.eq('priority', priorityNum); + const def = (segment as { definition: Record }).definition ?? {}; + let query = client + .from('crm_coaches') + .select('id') + // NULL-safe archived filter: legacy rows with is_archived = NULL must still + // enroll, so match NULL OR false. A bare .eq('is_archived', false) would drop + // NULL rows via Postgres three-valued logic. Mirrors admin/crm/page.tsx. + .or('is_archived.is.null,is_archived.eq.false'); + + if (def.status && def.status !== 'all') { + query = query.eq('status', def.status); + } + if (def.division && def.division !== 'all') { + query = query.eq('division', def.division); + } + if (def.conference && def.conference !== 'all') { + query = query.eq('conference', def.conference); + } + if (def.program && def.program !== 'all') { + query = query.eq('program', def.program); + } + if (def.priority && def.priority !== 'all') { + const priorityNum = Number.parseInt(String(def.priority), 10); + if (!Number.isNaN(priorityNum)) { + query = query.eq('priority', priorityNum); + } + } + if (def.starred === true) { + query = query.eq('is_starred', true); + } + if (def.primaryOnly === true) { + query = query.eq('is_primary_contact', true); + } + // Mirror the remaining crm_coaches-column filters from CoachFilters/filteredCoaches + // so the segment we ENROLL matches the segment the operator SEES. Previously these + // were applied only client-side, so enrolling a saved "cold, has-notes" view could + // email the entire cold cohort. (queueStatus + temperature depend on joins to the + // enrollment state / engagement view and are intentionally not mirrored here — they + // are rare in enrollment segments and the dialog's resolved-count surfaces any gap.) + if (typeof def.search === 'string' && def.search.trim()) { + const s = def.search.trim().replace(/[%,()]/g, ''); // sanitize for the or-filter grammar + if (s) query = query.or(`name.ilike.%${s}%,school.ilike.%${s}%,email.ilike.%${s}%,conference.ilike.%${s}%`); + } + if (def.followUpDue === true) { + query = query.not('next_follow_up_at', 'is', null).lte('next_follow_up_at', new Date().toISOString()); + } + if (def.hasNotes === true) { + query = query.not('notes', 'is', null).neq('notes', ''); + } + if (def.noContact30Days === true) { + const cutoff = new Date(Date.now() - 30 * 86400_000).toISOString(); + query = query.or(`last_contacted_at.is.null,last_contacted_at.lt.${cutoff}`); } - } - if (def.starred === true) { - query = query.eq('is_starred', true); - } - if (def.primaryOnly === true) { - query = query.eq('is_primary_contact', true); - } - // Mirror the remaining crm_coaches-column filters from CoachFilters/filteredCoaches - // so the segment we ENROLL matches the segment the operator SEES. Previously these - // were applied only client-side, so enrolling a saved "cold, has-notes" view could - // email the entire cold cohort. (queueStatus + temperature depend on joins to the - // enrollment state / engagement view and are intentionally not mirrored here — they - // are rare in enrollment segments and the dialog's resolved-count surfaces any gap.) - if (typeof def.search === 'string' && def.search.trim()) { - const s = def.search.trim().replace(/[%,()]/g, ''); // sanitize for the or-filter grammar - if (s) query = query.or(`name.ilike.%${s}%,school.ilike.%${s}%,email.ilike.%${s}%,conference.ilike.%${s}%`); - } - if (def.followUpDue === true) { - query = query.not('next_follow_up_at', 'is', null).lte('next_follow_up_at', new Date().toISOString()); - } - if (def.hasNotes === true) { - query = query.not('notes', 'is', null).neq('notes', ''); - } - if (def.noContact30Days === true) { - const cutoff = new Date(Date.now() - 30 * 86400_000).toISOString(); - query = query.or(`last_contacted_at.is.null,last_contacted_at.lt.${cutoff}`); - } - const { data: coaches, error: coachErr } = await query; - if (coachErr) { - throw new Error(`Failed to resolve segment coaches: ${coachErr.message}`); - } + const { data: coaches, error: coachErr } = await query; + if (coachErr) { + throw new Error(`Failed to resolve segment coaches: ${coachErr.message}`); + } - const coachIds = ((coaches ?? []) as Array<{ id: string }>).map((c) => c.id); - if (coachIds.length === 0) { - return { enrolled: 0, skipped: 0 }; - } + const coachIds = ((coaches ?? []) as Array<{ id: string }>).map((c) => c.id); + if (coachIds.length === 0) { + return { enrolled: 0, skipped: 0 }; + } - return enrollCoachesInSequence({ - sequence_id: input.sequence_id, - coach_ids: coachIds, - }); + return enrollCoachesInSequence({ + sequence_id: input.sequence_id, + coach_ids: coachIds, + }); + } catch (error) { + void logServerException(error, { + action: 'crm_sequences.enrollSegmentInSequence', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + metadata: { sequenceId: input.sequence_id, segmentId: input.segment_id }, + }); + throw error; + } } export async function listEnrollments( @@ -484,27 +588,38 @@ export async function listEnrollments( opts?: { status?: SequenceEnrollmentStatus; limit?: number }, ): Promise { const { supabase } = await getAuthedClient(); - const client = supabase as AnySupabase; + try { + const client = supabase as AnySupabase; - let query = client - .from('crm_sequence_enrollments') - .select('*') - .eq('sequence_id', sequence_id); + let query = client + .from('crm_sequence_enrollments') + .select('*') + .eq('sequence_id', sequence_id); - if (opts?.status) { - query = query.eq('status', opts.status); - } + if (opts?.status) { + query = query.eq('status', opts.status); + } - query = query.order('enrolled_at', { ascending: false }); - if (opts?.limit && opts.limit > 0) { - query = query.limit(opts.limit); - } + query = query.order('enrolled_at', { ascending: false }); + if (opts?.limit && opts.limit > 0) { + query = query.limit(opts.limit); + } - const { data, error } = await query; - if (error) { - throw new Error(`Failed to load enrollments: ${error.message}`); + const { data, error } = await query; + if (error) { + throw new Error(`Failed to load enrollments: ${error.message}`); + } + return (data ?? []) as CrmSequenceEnrollment[]; + } catch (error) { + void logServerException(error, { + action: 'crm_sequences.listEnrollments', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + metadata: { sequenceId: sequence_id }, + }); + throw error; } - return (data ?? []) as CrmSequenceEnrollment[]; } // Per-coach enrollment summary for list/badge views (Coaches + Conferences tabs). @@ -522,50 +637,71 @@ export async function getCoachSequenceEnrollmentStatuses( ): Promise> { if (!coachIds.length) return {}; const { supabase } = await getAuthedClient(); - const client = supabase as AnySupabase; - const out: Record = {}; - for (let i = 0; i < coachIds.length; i += 500) { - const chunk = coachIds.slice(i, i + 500); - const { data, error } = await client - .from('crm_sequence_enrollments') - .select('coach_id, status, current_step, next_send_at, enrolled_at') - .in('coach_id', chunk) - .order('enrolled_at', { ascending: false }); - if (error) { - throw new Error(`Failed to load coach enrollment statuses: ${error.message}`); - } - for (const row of (data ?? []) as Array<{ - coach_id: string; status: SequenceEnrollmentStatus; current_step: number; next_send_at: string | null; - }>) { - const summary: CoachEnrollmentSummary = { - status: row.status, current_step: row.current_step, next_send_at: row.next_send_at, - }; - const existing = out[row.coach_id]; - // first row per coach is the most recent (ordered desc); prefer an active one - if (!existing) out[row.coach_id] = summary; - else if (existing.status !== 'active' && summary.status === 'active') out[row.coach_id] = summary; + try { + const client = supabase as AnySupabase; + const out: Record = {}; + for (let i = 0; i < coachIds.length; i += 500) { + const chunk = coachIds.slice(i, i + 500); + const { data, error } = await client + .from('crm_sequence_enrollments') + .select('coach_id, status, current_step, next_send_at, enrolled_at') + .in('coach_id', chunk) + .order('enrolled_at', { ascending: false }); + if (error) { + throw new Error(`Failed to load coach enrollment statuses: ${error.message}`); + } + for (const row of (data ?? []) as Array<{ + coach_id: string; status: SequenceEnrollmentStatus; current_step: number; next_send_at: string | null; + }>) { + const summary: CoachEnrollmentSummary = { + status: row.status, current_step: row.current_step, next_send_at: row.next_send_at, + }; + const existing = out[row.coach_id]; + // first row per coach is the most recent (ordered desc); prefer an active one + if (!existing) out[row.coach_id] = summary; + else if (existing.status !== 'active' && summary.status === 'active') out[row.coach_id] = summary; + } } + return out; + } catch (error) { + void logServerException(error, { + action: 'crm_sequences.getCoachSequenceEnrollmentStatuses', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + metadata: { coachCount: coachIds.length }, + }); + throw error; } - return out; } export async function pauseEnrollment(id: string): Promise { const { supabase } = await getAuthedClient(); - const client = supabase as AnySupabase; + try { + const client = supabase as AnySupabase; - const { data, error } = await client - .from('crm_sequence_enrollments') - .update({ status: 'paused' }) - .eq('id', id) - .select('*') - .single(); + const { data, error } = await client + .from('crm_sequence_enrollments') + .update({ status: 'paused' }) + .eq('id', id) + .select('*') + .single(); - if (error) { - throw new Error(`Failed to pause enrollment: ${error.message}`); - } + if (error) { + throw new Error(`Failed to pause enrollment: ${error.message}`); + } - revalidatePath(CRM_SEQUENCES_REVALIDATE_PATH); - return data as CrmSequenceEnrollment; + revalidatePath(CRM_SEQUENCES_REVALIDATE_PATH); + return data as CrmSequenceEnrollment; + } catch (error) { + void logServerException(error, { + action: 'crm_sequences.pauseEnrollment', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + }); + throw error; + } } export async function stopEnrollment( @@ -573,25 +709,35 @@ export async function stopEnrollment( reason: SequenceEnrollmentStopReason, ): Promise { const { supabase } = await getAuthedClient(); - const client = supabase as AnySupabase; - - const { data, error } = await client - .from('crm_sequence_enrollments') - .update({ - status: 'stopped', - stopped_at: new Date().toISOString(), - stop_reason: reason, - }) - .eq('id', id) - .select('*') - .single(); - - if (error) { - throw new Error(`Failed to stop enrollment: ${error.message}`); - } + try { + const client = supabase as AnySupabase; + + const { data, error } = await client + .from('crm_sequence_enrollments') + .update({ + status: 'stopped', + stopped_at: new Date().toISOString(), + stop_reason: reason, + }) + .eq('id', id) + .select('*') + .single(); - revalidatePath(CRM_SEQUENCES_REVALIDATE_PATH); - return data as CrmSequenceEnrollment; + if (error) { + throw new Error(`Failed to stop enrollment: ${error.message}`); + } + + revalidatePath(CRM_SEQUENCES_REVALIDATE_PATH); + return data as CrmSequenceEnrollment; + } catch (error) { + void logServerException(error, { + action: 'crm_sequences.stopEnrollment', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + }); + throw error; + } } // ============================================================================ @@ -658,184 +804,195 @@ export async function getSequencePerformance( sequence_id: string, ): Promise { const { supabase } = await getAuthedClient(); - const client = supabase as AnySupabase; - - // 1) Sequence name (for notes matching) + its declared steps (so a step with - // zero sends still renders an honest "No sends yet" row). - const { data: seq, error: seqErr } = await client - .from('crm_sequences') - .select('name') - .eq('id', sequence_id) - .single(); - if (seqErr) { - throw new Error(`Failed to load sequence: ${seqErr.message}`); - } - const sequenceName: string = (seq as { name: string }).name; - - const { data: stepRows, error: stepErr } = await client - .from('crm_sequence_steps') - .select('step_order') - .eq('sequence_id', sequence_id) - .order('step_order', { ascending: true }); - if (stepErr) { - throw new Error(`Failed to load sequence steps: ${stepErr.message}`); - } - const declaredOrders = ((stepRows ?? []) as Array<{ step_order: number }>).map( - (s) => s.step_order, - ); - - // 2) Every send logged for this sequence. Match notes by the sequence name - // substring; sanitize the ILIKE pattern so %/_ in a sequence name are - // treated literally. Paginate past the 1000-row cap. - const ilikePattern = `%${sequenceName.replace(/[\\%_]/g, '\\$&')}%`; - const sends = await fetchAllRows<{ id: string; coach_id: string; notes: string | null }>( - (from, to) => - client - .from('crm_contact_log') - .select('id, coach_id, notes') - .eq('contact_type', 'email') - .ilike('notes', ilikePattern) - .order('id', { ascending: true }) - .range(from, to), - ); - - // Bucket sends by parsed step order. logId -> step_order powers per-step - // attribution of delivery/reply events back to the originating step. - const stepByLogId = new Map(); - const coachIdsByStep = new Map>(); - const sentByStep = new Map(); - const allLogIds: string[] = []; - for (const row of sends) { - const order = parseStepOrder(row.notes); - if (order == null) continue; - allLogIds.push(row.id); - stepByLogId.set(row.id, order); - sentByStep.set(order, (sentByStep.get(order) ?? 0) + 1); - if (row.coach_id) { - const set = coachIdsByStep.get(order) ?? new Set(); - set.add(row.coach_id); - coachIdsByStep.set(order, set); + try { + const client = supabase as AnySupabase; + + // 1) Sequence name (for notes matching) + its declared steps (so a step with + // zero sends still renders an honest "No sends yet" row). + const { data: seq, error: seqErr } = await client + .from('crm_sequences') + .select('name') + .eq('id', sequence_id) + .single(); + if (seqErr) { + throw new Error(`Failed to load sequence: ${seqErr.message}`); } - } + const sequenceName: string = (seq as { name: string }).name; + + const { data: stepRows, error: stepErr } = await client + .from('crm_sequence_steps') + .select('step_order') + .eq('sequence_id', sequence_id) + .order('step_order', { ascending: true }); + if (stepErr) { + throw new Error(`Failed to load sequence steps: ${stepErr.message}`); + } + const declaredOrders = ((stepRows ?? []) as Array<{ step_order: number }>).map( + (s) => s.step_order, + ); - // 3) Delivery / bounce events for those sends. Count DISTINCT contact_log_ids - // per outcome (a single message can emit several webhooks). - const deliveredByStep = new Map>(); - const bouncedByStep = new Map>(); - for (let i = 0; i < allLogIds.length; i += PERF_IN_CHUNK) { - const chunk = allLogIds.slice(i, i + PERF_IN_CHUNK); - const events = await fetchAllRows<{ contact_log_id: string | null; event_type: string }>( + // 2) Every send logged for this sequence. Match notes by the sequence name + // substring; sanitize the ILIKE pattern so %/_ in a sequence name are + // treated literally. Paginate past the 1000-row cap. + const ilikePattern = `%${sequenceName.replace(/[\\%_]/g, '\\$&')}%`; + const sends = await fetchAllRows<{ id: string; coach_id: string; notes: string | null }>( (from, to) => client - .from('email_events') - .select('contact_log_id, event_type') - .in('contact_log_id', chunk) + .from('crm_contact_log') + .select('id, coach_id, notes') + .eq('contact_type', 'email') + .ilike('notes', ilikePattern) .order('id', { ascending: true }) .range(from, to), ); - for (const ev of events) { - const logId = ev.contact_log_id; - if (!logId) continue; - const order = stepByLogId.get(logId); + + // Bucket sends by parsed step order. logId -> step_order powers per-step + // attribution of delivery/reply events back to the originating step. + const stepByLogId = new Map(); + const coachIdsByStep = new Map>(); + const sentByStep = new Map(); + const allLogIds: string[] = []; + for (const row of sends) { + const order = parseStepOrder(row.notes); if (order == null) continue; - if (ev.event_type === 'email.delivered') { - const set = deliveredByStep.get(order) ?? new Set(); - set.add(logId); - deliveredByStep.set(order, set); - } else if (ev.event_type === 'email.bounced') { - const set = bouncedByStep.get(order) ?? new Set(); - set.add(logId); - bouncedByStep.set(order, set); + allLogIds.push(row.id); + stepByLogId.set(row.id, order); + sentByStep.set(order, (sentByStep.get(order) ?? 0) + 1); + if (row.coach_id) { + const set = coachIdsByStep.get(order) ?? new Set(); + set.add(row.coach_id); + coachIdsByStep.set(order, set); } } - } - // 4) Human replies. Prefer the precise contact_log_id link; fall back to - // coach_id for replies that arrived before the inbound parser resolved the - // originating send. Track which replies are already counted by log id so - // the coach-id fallback can't double-count the same reply row. - const repliedByStep = new Map(); - const countedReplyIds = new Set(); - for (let i = 0; i < allLogIds.length; i += PERF_IN_CHUNK) { - const chunk = allLogIds.slice(i, i + PERF_IN_CHUNK); - const replies = await fetchAllRows<{ id: string; contact_log_id: string | null }>( - (from, to) => - client - .from('crm_replies') - .select('id, contact_log_id') - .in('contact_log_id', chunk) - .order('id', { ascending: true }) - .range(from, to), - ); - for (const r of replies) { - const logId = r.contact_log_id; - if (!logId) continue; - const order = stepByLogId.get(logId); - if (order == null || countedReplyIds.has(r.id)) continue; - countedReplyIds.add(r.id); - repliedByStep.set(order, (repliedByStep.get(order) ?? 0) + 1); + // 3) Delivery / bounce events for those sends. Count DISTINCT contact_log_ids + // per outcome (a single message can emit several webhooks). + const deliveredByStep = new Map>(); + const bouncedByStep = new Map>(); + for (let i = 0; i < allLogIds.length; i += PERF_IN_CHUNK) { + const chunk = allLogIds.slice(i, i + PERF_IN_CHUNK); + const events = await fetchAllRows<{ contact_log_id: string | null; event_type: string }>( + (from, to) => + client + .from('email_events') + .select('contact_log_id, event_type') + .in('contact_log_id', chunk) + .order('id', { ascending: true }) + .range(from, to), + ); + for (const ev of events) { + const logId = ev.contact_log_id; + if (!logId) continue; + const order = stepByLogId.get(logId); + if (order == null) continue; + if (ev.event_type === 'email.delivered') { + const set = deliveredByStep.get(order) ?? new Set(); + set.add(logId); + deliveredByStep.set(order, set); + } else if (ev.event_type === 'email.bounced') { + const set = bouncedByStep.get(order) ?? new Set(); + set.add(logId); + bouncedByStep.set(order, set); + } + } } - } - // Fallback: replies attributable only by enrolled coach (no resolved log id). - for (const [order, coachSet] of coachIdsByStep) { - const coachIds = [...coachSet]; - for (let i = 0; i < coachIds.length; i += PERF_IN_CHUNK) { - const chunk = coachIds.slice(i, i + PERF_IN_CHUNK); + + // 4) Human replies. Prefer the precise contact_log_id link; fall back to + // coach_id for replies that arrived before the inbound parser resolved the + // originating send. Track which replies are already counted by log id so + // the coach-id fallback can't double-count the same reply row. + const repliedByStep = new Map(); + const countedReplyIds = new Set(); + for (let i = 0; i < allLogIds.length; i += PERF_IN_CHUNK) { + const chunk = allLogIds.slice(i, i + PERF_IN_CHUNK); const replies = await fetchAllRows<{ id: string; contact_log_id: string | null }>( (from, to) => client .from('crm_replies') .select('id, contact_log_id') - .is('contact_log_id', null) - .in('coach_id', chunk) + .in('contact_log_id', chunk) .order('id', { ascending: true }) .range(from, to), ); for (const r of replies) { - if (countedReplyIds.has(r.id)) continue; + const logId = r.contact_log_id; + if (!logId) continue; + const order = stepByLogId.get(logId); + if (order == null || countedReplyIds.has(r.id)) continue; countedReplyIds.add(r.id); repliedByStep.set(order, (repliedByStep.get(order) ?? 0) + 1); } } - } + // Fallback: replies attributable only by enrolled coach (no resolved log id). + for (const [order, coachSet] of coachIdsByStep) { + const coachIds = [...coachSet]; + for (let i = 0; i < coachIds.length; i += PERF_IN_CHUNK) { + const chunk = coachIds.slice(i, i + PERF_IN_CHUNK); + const replies = await fetchAllRows<{ id: string; contact_log_id: string | null }>( + (from, to) => + client + .from('crm_replies') + .select('id, contact_log_id') + .is('contact_log_id', null) + .in('coach_id', chunk) + .order('id', { ascending: true }) + .range(from, to), + ); + for (const r of replies) { + if (countedReplyIds.has(r.id)) continue; + countedReplyIds.add(r.id); + repliedByStep.set(order, (repliedByStep.get(order) ?? 0) + 1); + } + } + } - // 5) Assemble per-step rows. Union of declared steps + any step order that - // actually appears in the sends (defends against renamed/deleted steps). - const orders = new Set(declaredOrders); - for (const order of sentByStep.keys()) orders.add(order); - - const steps: SequencePerformanceStep[] = [...orders] - .sort((a, b) => a - b) - .map((order) => { - const sent = sentByStep.get(order) ?? 0; - const delivered = deliveredByStep.get(order)?.size ?? 0; - const bounced = bouncedByStep.get(order)?.size ?? 0; - const replied = repliedByStep.get(order) ?? 0; - return { - step_order: order, - sent, - delivered, - bounced, - replied, - reply_rate: replyRate(replied, delivered), - }; - }); + // 5) Assemble per-step rows. Union of declared steps + any step order that + // actually appears in the sends (defends against renamed/deleted steps). + const orders = new Set(declaredOrders); + for (const order of sentByStep.keys()) orders.add(order); + + const steps: SequencePerformanceStep[] = [...orders] + .sort((a, b) => a - b) + .map((order) => { + const sent = sentByStep.get(order) ?? 0; + const delivered = deliveredByStep.get(order)?.size ?? 0; + const bounced = bouncedByStep.get(order)?.size ?? 0; + const replied = repliedByStep.get(order) ?? 0; + return { + step_order: order, + sent, + delivered, + bounced, + replied, + reply_rate: replyRate(replied, delivered), + }; + }); + + const totals = steps.reduce( + (acc, s) => ({ + sent: acc.sent + s.sent, + delivered: acc.delivered + s.delivered, + bounced: acc.bounced + s.bounced, + replied: acc.replied + s.replied, + }), + { sent: 0, delivered: 0, bounced: 0, replied: 0 }, + ); - const totals = steps.reduce( - (acc, s) => ({ - sent: acc.sent + s.sent, - delivered: acc.delivered + s.delivered, - bounced: acc.bounced + s.bounced, - replied: acc.replied + s.replied, - }), - { sent: 0, delivered: 0, bounced: 0, replied: 0 }, - ); - - return { - steps, - totals: { - ...totals, - reply_rate: replyRate(totals.replied, totals.delivered), - }, - }; + return { + steps, + totals: { + ...totals, + reply_rate: replyRate(totals.replied, totals.delivered), + }, + }; + } catch (error) { + void logServerException(error, { + action: 'crm_sequences.getSequencePerformance', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + metadata: { sequenceId: sequence_id }, + }); + throw error; + } } diff --git a/src/app/golf/actions/crm-templates.ts b/src/app/golf/actions/crm-templates.ts index 93e81464b..3bcc770d3 100644 --- a/src/app/golf/actions/crm-templates.ts +++ b/src/app/golf/actions/crm-templates.ts @@ -32,6 +32,7 @@ import { revalidatePath } from 'next/cache'; import { cookies } from 'next/headers'; import { createClient } from '@/lib/supabase/server'; +import { logServerException } from '@/lib/server-error-logger'; // ============================================================================ // Types — exported for consumers (TemplateManager, TemplatePicker) @@ -112,31 +113,41 @@ function normalizeFormat(raw: unknown): TemplateFormat { // ============================================================================ export async function listTemplates(): Promise { const { supabase } = await getAuthedClient(); - const client = supabase as AnySupabase; - - const { data, error } = await client - .from('crm_email_templates') - .select('id, name, subject, body, category, format, merge_tags, is_default, usage_count') - // Default templates first within a category, then most-used. - .order('category', { ascending: true }) - .order('is_default', { ascending: false }) - .order('usage_count', { ascending: false }); - - if (error) { - throw new Error(`Failed to load templates: ${error.message}`); - } + try { + const client = supabase as AnySupabase; + + const { data, error } = await client + .from('crm_email_templates') + .select('id, name, subject, body, category, format, merge_tags, is_default, usage_count') + // Default templates first within a category, then most-used. + .order('category', { ascending: true }) + .order('is_default', { ascending: false }) + .order('usage_count', { ascending: false }); + + if (error) { + throw new Error(`Failed to load templates: ${error.message}`); + } - return ((data ?? []) as Array>).map((row) => ({ - id: row.id as string, - name: (row.name as string) ?? '', - subject: (row.subject as string) ?? '', - body: (row.body as string) ?? '', - category: ((row.category as string) ?? 'general') as TemplateCategory, - format: normalizeFormat(row.format), - merge_tags: (row.merge_tags as string[] | null) ?? null, - is_default: (row.is_default as boolean | null) ?? false, - usage_count: (row.usage_count as number | null) ?? 0, - })); + return ((data ?? []) as Array>).map((row) => ({ + id: row.id as string, + name: (row.name as string) ?? '', + subject: (row.subject as string) ?? '', + body: (row.body as string) ?? '', + category: ((row.category as string) ?? 'general') as TemplateCategory, + format: normalizeFormat(row.format), + merge_tags: (row.merge_tags as string[] | null) ?? null, + is_default: (row.is_default as boolean | null) ?? false, + usage_count: (row.usage_count as number | null) ?? 0, + })); + } catch (error) { + void logServerException(error, { + action: 'crm_templates.listTemplates', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + }); + throw error; + } } // ============================================================================ @@ -155,56 +166,66 @@ export async function createTemplate(input: { is_default?: boolean; }): Promise { const { supabase, user } = await getAuthedClient(); - const client = supabase as AnySupabase; + try { + const client = supabase as AnySupabase; + + const name = input.name.trim(); + const subject = input.subject.trim(); + const body = input.body.trim(); + if (!name || !subject || !body) { + throw new Error('Name, subject, and body are required'); + } - const name = input.name.trim(); - const subject = input.subject.trim(); - const body = input.body.trim(); - if (!name || !subject || !body) { - throw new Error('Name, subject, and body are required'); - } + const { data, error } = await client + .from('crm_email_templates') + .insert({ + name, + category: input.category, + subject, + body, + format: input.format ?? 'plain', + merge_tags: input.merge_tags ?? null, + // Insert as non-default first; promote via setDefaultTemplate below so + // the single-default-per-category invariant is enforced in one place. + is_default: false, + usage_count: 0, + created_by: user.id, + }) + .select('id, name, subject, body, category, format, merge_tags, is_default, usage_count') + .single(); - const { data, error } = await client - .from('crm_email_templates') - .insert({ - name, - category: input.category, - subject, - body, - format: input.format ?? 'plain', - merge_tags: input.merge_tags ?? null, - // Insert as non-default first; promote via setDefaultTemplate below so - // the single-default-per-category invariant is enforced in one place. - is_default: false, - usage_count: 0, - created_by: user.id, - }) - .select('id, name, subject, body, category, format, merge_tags, is_default, usage_count') - .single(); - - if (error) { - throw new Error(`Failed to create template: ${error.message}`); - } + if (error) { + throw new Error(`Failed to create template: ${error.message}`); + } - const created = data as Record; - const id = created.id as string; + const created = data as Record; + const id = created.id as string; - if (input.is_default) { - return setDefaultTemplate(id, input.category); - } + if (input.is_default) { + return setDefaultTemplate(id, input.category); + } - revalidatePath(CRM_REVALIDATE_PATH); - return { - id, - name: created.name as string, - subject: created.subject as string, - body: created.body as string, - category: created.category as TemplateCategory, - format: normalizeFormat(created.format), - merge_tags: (created.merge_tags as string[] | null) ?? null, - is_default: (created.is_default as boolean | null) ?? false, - usage_count: (created.usage_count as number | null) ?? 0, - }; + revalidatePath(CRM_REVALIDATE_PATH); + return { + id, + name: created.name as string, + subject: created.subject as string, + body: created.body as string, + category: created.category as TemplateCategory, + format: normalizeFormat(created.format), + merge_tags: (created.merge_tags as string[] | null) ?? null, + is_default: (created.is_default as boolean | null) ?? false, + usage_count: (created.usage_count as number | null) ?? 0, + }; + } catch (error) { + void logServerException(error, { + action: 'crm_templates.createTemplate', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + }); + throw error; + } } // ============================================================================ @@ -227,72 +248,83 @@ export async function updateTemplate( }>, ): Promise { const { supabase } = await getAuthedClient(); - const client = supabase as AnySupabase; - - // Build the patch, trimming string fields when present. - const patch: Record = {}; - if (fields.name !== undefined) patch.name = fields.name.trim(); - if (fields.category !== undefined) patch.category = fields.category; - if (fields.subject !== undefined) patch.subject = fields.subject.trim(); - if (fields.body !== undefined) patch.body = fields.body.trim(); - if (fields.format !== undefined) patch.format = fields.format; - if (fields.merge_tags !== undefined) patch.merge_tags = fields.merge_tags; - - // Promoting to default must go through the invariant helper. We still apply - // the rest of the patch first so name/body/etc. changes land in the same call. - const promoteToDefault = fields.is_default === true; - if (fields.is_default === false) patch.is_default = false; - - if (Object.keys(patch).length > 0) { - const { error } = await client - .from('crm_email_templates') - .update(patch) - .eq('id', id); - if (error) { - throw new Error(`Failed to update template: ${error.message}`); + try { + const client = supabase as AnySupabase; + + // Build the patch, trimming string fields when present. + const patch: Record = {}; + if (fields.name !== undefined) patch.name = fields.name.trim(); + if (fields.category !== undefined) patch.category = fields.category; + if (fields.subject !== undefined) patch.subject = fields.subject.trim(); + if (fields.body !== undefined) patch.body = fields.body.trim(); + if (fields.format !== undefined) patch.format = fields.format; + if (fields.merge_tags !== undefined) patch.merge_tags = fields.merge_tags; + + // Promoting to default must go through the invariant helper. We still apply + // the rest of the patch first so name/body/etc. changes land in the same call. + const promoteToDefault = fields.is_default === true; + if (fields.is_default === false) patch.is_default = false; + + if (Object.keys(patch).length > 0) { + const { error } = await client + .from('crm_email_templates') + .update(patch) + .eq('id', id); + if (error) { + throw new Error(`Failed to update template: ${error.message}`); + } } - } - if (promoteToDefault) { - // Resolve the category to scope the default-unset (use the incoming patch - // value, else read it back from the row). - let category = fields.category; - if (!category) { - const { data: row, error: readErr } = await client - .from('crm_email_templates') - .select('category') - .eq('id', id) - .single(); - if (readErr) { - throw new Error(`Failed to resolve template category: ${readErr.message}`); + if (promoteToDefault) { + // Resolve the category to scope the default-unset (use the incoming patch + // value, else read it back from the row). + let category = fields.category; + if (!category) { + const { data: row, error: readErr } = await client + .from('crm_email_templates') + .select('category') + .eq('id', id) + .single(); + if (readErr) { + throw new Error(`Failed to resolve template category: ${readErr.message}`); + } + category = ((row as { category: string }).category ?? 'general') as TemplateCategory; } - category = ((row as { category: string }).category ?? 'general') as TemplateCategory; + return setDefaultTemplate(id, category); } - return setDefaultTemplate(id, category); - } - const { data, error } = await client - .from('crm_email_templates') - .select('id, name, subject, body, category, format, merge_tags, is_default, usage_count') - .eq('id', id) - .single(); - if (error) { - throw new Error(`Failed to reload template: ${error.message}`); - } + const { data, error } = await client + .from('crm_email_templates') + .select('id, name, subject, body, category, format, merge_tags, is_default, usage_count') + .eq('id', id) + .single(); + if (error) { + throw new Error(`Failed to reload template: ${error.message}`); + } - const row = data as Record; - revalidatePath(CRM_REVALIDATE_PATH); - return { - id: row.id as string, - name: row.name as string, - subject: row.subject as string, - body: row.body as string, - category: row.category as TemplateCategory, - format: normalizeFormat(row.format), - merge_tags: (row.merge_tags as string[] | null) ?? null, - is_default: (row.is_default as boolean | null) ?? false, - usage_count: (row.usage_count as number | null) ?? 0, - }; + const row = data as Record; + revalidatePath(CRM_REVALIDATE_PATH); + return { + id: row.id as string, + name: row.name as string, + subject: row.subject as string, + body: row.body as string, + category: row.category as TemplateCategory, + format: normalizeFormat(row.format), + merge_tags: (row.merge_tags as string[] | null) ?? null, + is_default: (row.is_default as boolean | null) ?? false, + usage_count: (row.usage_count as number | null) ?? 0, + }; + } catch (error) { + void logServerException(error, { + action: 'crm_templates.updateTemplate', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + metadata: { templateId: id }, + }); + throw error; + } } // ============================================================================ @@ -300,15 +332,26 @@ export async function updateTemplate( // ============================================================================ export async function deleteTemplate(id: string): Promise<{ ok: true }> { const { supabase } = await getAuthedClient(); - const client = supabase as AnySupabase; + try { + const client = supabase as AnySupabase; - const { error } = await client.from('crm_email_templates').delete().eq('id', id); - if (error) { - throw new Error(`Failed to delete template: ${error.message}`); - } + const { error } = await client.from('crm_email_templates').delete().eq('id', id); + if (error) { + throw new Error(`Failed to delete template: ${error.message}`); + } - revalidatePath(CRM_REVALIDATE_PATH); - return { ok: true }; + revalidatePath(CRM_REVALIDATE_PATH); + return { ok: true }; + } catch (error) { + void logServerException(error, { + action: 'crm_templates.deleteTemplate', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + metadata: { templateId: id }, + }); + throw error; + } } // ============================================================================ @@ -318,51 +361,62 @@ export async function deleteTemplate(id: string): Promise<{ ok: true }> { // " (copy)" name. The copy is never a default and starts at 0 uses. export async function duplicateTemplate(id: string): Promise { const { supabase, user } = await getAuthedClient(); - const client = supabase as AnySupabase; - - const { data: src, error: readErr } = await client - .from('crm_email_templates') - .select('name, subject, body, category, format, merge_tags') - .eq('id', id) - .single(); - if (readErr || !src) { - throw new Error(`Failed to load template to duplicate: ${readErr?.message ?? 'not found'}`); - } + try { + const client = supabase as AnySupabase; - const source = src as Record; - const { data, error } = await client - .from('crm_email_templates') - .insert({ - name: `${(source.name as string) ?? 'Template'} (copy)`, - subject: (source.subject as string) ?? '', - body: (source.body as string) ?? '', - category: (source.category as string) ?? 'general', - format: normalizeFormat(source.format), - merge_tags: (source.merge_tags as string[] | null) ?? null, - is_default: false, - usage_count: 0, - created_by: user.id, - }) - .select('id, name, subject, body, category, format, merge_tags, is_default, usage_count') - .single(); - - if (error) { - throw new Error(`Failed to duplicate template: ${error.message}`); - } + const { data: src, error: readErr } = await client + .from('crm_email_templates') + .select('name, subject, body, category, format, merge_tags') + .eq('id', id) + .single(); + if (readErr || !src) { + throw new Error(`Failed to load template to duplicate: ${readErr?.message ?? 'not found'}`); + } + + const source = src as Record; + const { data, error } = await client + .from('crm_email_templates') + .insert({ + name: `${(source.name as string) ?? 'Template'} (copy)`, + subject: (source.subject as string) ?? '', + body: (source.body as string) ?? '', + category: (source.category as string) ?? 'general', + format: normalizeFormat(source.format), + merge_tags: (source.merge_tags as string[] | null) ?? null, + is_default: false, + usage_count: 0, + created_by: user.id, + }) + .select('id, name, subject, body, category, format, merge_tags, is_default, usage_count') + .single(); + + if (error) { + throw new Error(`Failed to duplicate template: ${error.message}`); + } - const row = data as Record; - revalidatePath(CRM_REVALIDATE_PATH); - return { - id: row.id as string, - name: row.name as string, - subject: row.subject as string, - body: row.body as string, - category: row.category as TemplateCategory, - format: normalizeFormat(row.format), - merge_tags: (row.merge_tags as string[] | null) ?? null, - is_default: (row.is_default as boolean | null) ?? false, - usage_count: (row.usage_count as number | null) ?? 0, - }; + const row = data as Record; + revalidatePath(CRM_REVALIDATE_PATH); + return { + id: row.id as string, + name: row.name as string, + subject: row.subject as string, + body: row.body as string, + category: row.category as TemplateCategory, + format: normalizeFormat(row.format), + merge_tags: (row.merge_tags as string[] | null) ?? null, + is_default: (row.is_default as boolean | null) ?? false, + usage_count: (row.usage_count as number | null) ?? 0, + }; + } catch (error) { + void logServerException(error, { + action: 'crm_templates.duplicateTemplate', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + metadata: { templateId: id }, + }); + throw error; + } } // ============================================================================ @@ -377,43 +431,54 @@ export async function setDefaultTemplate( category: TemplateCategory, ): Promise { const { supabase } = await getAuthedClient(); - const client = supabase as AnySupabase; - - // 1) Clear existing defaults in this category (skip the target row). - const { error: clearErr } = await client - .from('crm_email_templates') - .update({ is_default: false }) - .eq('category', category) - .eq('is_default', true) - .neq('id', id); - if (clearErr) { - throw new Error(`Failed to clear existing default: ${clearErr.message}`); - } + try { + const client = supabase as AnySupabase; - // 2) Mark the target as default. - const { data, error } = await client - .from('crm_email_templates') - .update({ is_default: true }) - .eq('id', id) - .select('id, name, subject, body, category, format, merge_tags, is_default, usage_count') - .single(); - if (error) { - throw new Error(`Failed to set default template: ${error.message}`); - } + // 1) Clear existing defaults in this category (skip the target row). + const { error: clearErr } = await client + .from('crm_email_templates') + .update({ is_default: false }) + .eq('category', category) + .eq('is_default', true) + .neq('id', id); + if (clearErr) { + throw new Error(`Failed to clear existing default: ${clearErr.message}`); + } - const row = data as Record; - revalidatePath(CRM_REVALIDATE_PATH); - return { - id: row.id as string, - name: row.name as string, - subject: row.subject as string, - body: row.body as string, - category: row.category as TemplateCategory, - format: normalizeFormat(row.format), - merge_tags: (row.merge_tags as string[] | null) ?? null, - is_default: (row.is_default as boolean | null) ?? false, - usage_count: (row.usage_count as number | null) ?? 0, - }; + // 2) Mark the target as default. + const { data, error } = await client + .from('crm_email_templates') + .update({ is_default: true }) + .eq('id', id) + .select('id, name, subject, body, category, format, merge_tags, is_default, usage_count') + .single(); + if (error) { + throw new Error(`Failed to set default template: ${error.message}`); + } + + const row = data as Record; + revalidatePath(CRM_REVALIDATE_PATH); + return { + id: row.id as string, + name: row.name as string, + subject: row.subject as string, + body: row.body as string, + category: row.category as TemplateCategory, + format: normalizeFormat(row.format), + merge_tags: (row.merge_tags as string[] | null) ?? null, + is_default: (row.is_default as boolean | null) ?? false, + usage_count: (row.usage_count as number | null) ?? 0, + }; + } catch (error) { + void logServerException(error, { + action: 'crm_templates.setDefaultTemplate', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + metadata: { templateId: id, category }, + }); + throw error; + } } // ============================================================================ @@ -433,103 +498,114 @@ export async function sendTestTemplate(input: { toEmail?: string; }): Promise<{ ok: true; to: string }> { const { supabase, user } = await getAuthedClient(); - const client = supabase as AnySupabase; - - // 1) Load the template (subject/body/format). - const { data: tpl, error: tplErr } = await client - .from('crm_email_templates') - .select('subject, body, format') - .eq('id', input.id) - .single(); - if (tplErr || !tpl) { - throw new Error(`Failed to load template: ${tplErr?.message ?? 'not found'}`); - } - const template = tpl as { subject: string; body: string; format: string | null }; + try { + const client = supabase as AnySupabase; - // 2) Resolve the destination — explicit override, else the admin's email. - const toEmail = (input.toEmail?.trim() || user.email || '').trim(); - if (!toEmail) { - throw new Error('No destination email — pass toEmail or set an email on your account'); - } + // 1) Load the template (subject/body/format). + const { data: tpl, error: tplErr } = await client + .from('crm_email_templates') + .select('subject, body, format') + .eq('id', input.id) + .single(); + if (tplErr || !tpl) { + throw new Error(`Failed to load template: ${tplErr?.message ?? 'not found'}`); + } + const template = tpl as { subject: string; body: string; format: string | null }; - // 3) Pick a representative sample coach for merge data (a fully-populated row - // so the test renders with real-looking values). Fall back to the admin's - // own details when no coach data is available. - const { data: sample } = await client - .from('crm_coaches') - .select('name, title, school, conference, division, program, team_size, current_software') - .not('school', 'is', null) - .not('conference', 'is', null) - .or('is_archived.is.null,is_archived.eq.false') - .order('priority', { ascending: false }) - .limit(1) - .maybeSingle(); - - const coach = (sample ?? null) as { - name?: string | null; - title?: string | null; - school?: string | null; - conference?: string | null; - division?: string | null; - program?: string | null; - team_size?: number | null; - current_software?: string | null; - } | null; - - // The send route's Recipient contract — `id` is the merge identity. We use a - // synthetic id (this is a one-off test, not a tracked coach send) so it never - // collides with a real crm_coaches row or writes a misattributed contact log. - const recipient = { - id: `test-${user.id}`, - email: toEmail, - name: coach?.name ?? 'Coach Sample', - title: coach?.title ?? 'Head Coach', - school: coach?.school ?? 'State University', - conference: coach?.conference ?? 'Atlantic Coast', - division: coach?.division ?? 'D1', - program: coach?.program ?? 'mens', - team_size: coach?.team_size ?? 10, - current_software: coach?.current_software ?? 'spreadsheets', - }; - - const format = normalizeFormat(template.format); - - // 4) POST to the internal send route with the caller's cookies forwarded so - // the route's auth.getUser() + admin check pass. Base URL mirrors - // task-reminders.ts. - const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://helmsportslabs.com'; - const cookieHeader = (await cookies()).toString(); - - const res = await fetch(new URL('/api/admin/crm/send-email', baseUrl).toString(), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - cookie: cookieHeader, - }, - body: JSON.stringify({ - recipients: [recipient], - subject: `[TEST] ${template.subject}`, - body: template.body, - format, - // No templateId — a test must NOT bump the production usage_count. - }), - }); - - if (!res.ok) { - const detail = (await res.text().catch(() => '')).slice(0, 300); - throw new Error(`Test send failed (${res.status}): ${detail || 'unknown error'}`); - } + // 2) Resolve the destination — explicit override, else the admin's email. + const toEmail = (input.toEmail?.trim() || user.email || '').trim(); + if (!toEmail) { + throw new Error('No destination email — pass toEmail or set an email on your account'); + } - const result = (await res.json().catch(() => ({}))) as { - sent?: number; - failed?: number; - skipped?: number; - }; - if ((result.sent ?? 0) < 1) { - throw new Error( - `Test send did not deliver (sent=${result.sent ?? 0}, failed=${result.failed ?? 0}, skipped=${result.skipped ?? 0})`, - ); - } + // 3) Pick a representative sample coach for merge data (a fully-populated row + // so the test renders with real-looking values). Fall back to the admin's + // own details when no coach data is available. + const { data: sample } = await client + .from('crm_coaches') + .select('name, title, school, conference, division, program, team_size, current_software') + .not('school', 'is', null) + .not('conference', 'is', null) + .or('is_archived.is.null,is_archived.eq.false') + .order('priority', { ascending: false }) + .limit(1) + .maybeSingle(); + + const coach = (sample ?? null) as { + name?: string | null; + title?: string | null; + school?: string | null; + conference?: string | null; + division?: string | null; + program?: string | null; + team_size?: number | null; + current_software?: string | null; + } | null; + + // The send route's Recipient contract — `id` is the merge identity. We use a + // synthetic id (this is a one-off test, not a tracked coach send) so it never + // collides with a real crm_coaches row or writes a misattributed contact log. + const recipient = { + id: `test-${user.id}`, + email: toEmail, + name: coach?.name ?? 'Coach Sample', + title: coach?.title ?? 'Head Coach', + school: coach?.school ?? 'State University', + conference: coach?.conference ?? 'Atlantic Coast', + division: coach?.division ?? 'D1', + program: coach?.program ?? 'mens', + team_size: coach?.team_size ?? 10, + current_software: coach?.current_software ?? 'spreadsheets', + }; + + const format = normalizeFormat(template.format); + + // 4) POST to the internal send route with the caller's cookies forwarded so + // the route's auth.getUser() + admin check pass. Base URL mirrors + // task-reminders.ts. + const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://helmsportslabs.com'; + const cookieHeader = (await cookies()).toString(); + + const res = await fetch(new URL('/api/admin/crm/send-email', baseUrl).toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + cookie: cookieHeader, + }, + body: JSON.stringify({ + recipients: [recipient], + subject: `[TEST] ${template.subject}`, + body: template.body, + format, + // No templateId — a test must NOT bump the production usage_count. + }), + }); + + if (!res.ok) { + const detail = (await res.text().catch(() => '')).slice(0, 300); + throw new Error(`Test send failed (${res.status}): ${detail || 'unknown error'}`); + } - return { ok: true, to: toEmail }; + const result = (await res.json().catch(() => ({}))) as { + sent?: number; + failed?: number; + skipped?: number; + }; + if ((result.sent ?? 0) < 1) { + throw new Error( + `Test send did not deliver (sent=${result.sent ?? 0}, failed=${result.failed ?? 0}, skipped=${result.skipped ?? 0})`, + ); + } + + return { ok: true, to: toEmail }; + } catch (error) { + void logServerException(error, { + action: 'crm_templates.sendTestTemplate', + source: 'server_action', + sport: 'golf', + featureArea: 'crm', + metadata: { templateId: input.id }, + }); + throw error; + } } diff --git a/src/app/golf/actions/resend-activity.ts b/src/app/golf/actions/resend-activity.ts index e4a65b4f5..e4ed44164 100644 --- a/src/app/golf/actions/resend-activity.ts +++ b/src/app/golf/actions/resend-activity.ts @@ -248,25 +248,37 @@ export async function getEmailDetail(resendMessageId: string): Promise<{ }> { const supabase = await requireAdmin(); - const [emailRes, eventsRes] = await Promise.all([ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (supabase as any) - .from('emails') - .select('*') - .eq('resend_message_id', resendMessageId) - .maybeSingle(), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (supabase as any) - .from('email_events') - .select('*') - .eq('resend_message_id', resendMessageId) - .order('occurred_at', { ascending: true }), - ]); + try { + const [emailRes, eventsRes] = await Promise.all([ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (supabase as any) + .from('emails') + .select('*') + .eq('resend_message_id', resendMessageId) + .maybeSingle(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (supabase as any) + .from('email_events') + .select('*') + .eq('resend_message_id', resendMessageId) + .order('occurred_at', { ascending: true }), + ]); + + if (emailRes.error) { + await logServerError(`[resend-activity] email detail query failed: ${emailRes.error instanceof Error ? emailRes.error.message : String(emailRes.error)}`, { action: 'resend_activity.getEmailDetail' }); + } + if (eventsRes.error) { + await logServerError(`[resend-activity] email detail events query failed: ${eventsRes.error instanceof Error ? eventsRes.error.message : String(eventsRes.error)}`, { action: 'resend_activity.getEmailDetail' }); + } - return { - email: (emailRes.data as EmailRow) ?? null, - events: ((eventsRes.data ?? []) as EmailEventRow[]), - }; + return { + email: (emailRes.data as EmailRow) ?? null, + events: ((eventsRes.data ?? []) as EmailEventRow[]), + }; + } catch (err) { + await logServerError(`[resend-activity] email detail threw: ${err instanceof Error ? err.message : String(err)}`, { action: 'resend_activity.getEmailDetail' }); + throw err; + } } // --------------------------------------------------------------------------- diff --git a/src/app/golf/admin/crm/components/QuickActionsPanel.tsx b/src/app/golf/admin/crm/components/QuickActionsPanel.tsx index 311afc544..a1118369e 100644 --- a/src/app/golf/admin/crm/components/QuickActionsPanel.tsx +++ b/src/app/golf/admin/crm/components/QuickActionsPanel.tsx @@ -2,6 +2,8 @@ import { useState, useId } from 'react'; import { createClient } from '@/lib/supabase/client'; +import { toast } from '@/components/ui/sonner'; +import { logError } from '@/lib/error-logging'; import { cn } from '@/lib/utils'; import { IconStar, @@ -131,6 +133,11 @@ export function QuickActionsPanel({ onClose(); } catch (err) { console.error('Failed to schedule:', err); + logError( + err instanceof Error ? err : new Error(String(err)), + { component: 'QuickActionsPanel', action: 'schedule-crm-event', sport: 'golf', coachId: coach.id }, + 'high' + ); } finally { setSubmitting(false); } @@ -160,6 +167,12 @@ export function QuickActionsPanel({ onClose(); } catch (err) { console.error('Failed to log contact:', err); + toast.error('Failed to log contact', err instanceof Error ? err.message : 'Please try again.'); + logError( + err instanceof Error ? err : new Error(String(err)), + { component: 'QuickActionsPanel', action: 'log-contact', sport: 'golf', coachId: coach.id }, + 'high' + ); } finally { setSubmitting(false); } @@ -172,6 +185,11 @@ export function QuickActionsPanel({ onClose(); } catch (err) { console.error('Failed to save note:', err); + logError( + err instanceof Error ? err : new Error(String(err)), + { component: 'QuickActionsPanel', action: 'save-note', sport: 'golf', coachId: coach.id }, + 'high' + ); } finally { setSubmitting(false); } diff --git a/src/app/golf/admin/crm/components/ScheduleEventModal.tsx b/src/app/golf/admin/crm/components/ScheduleEventModal.tsx index 72db74ca2..bdff5050f 100644 --- a/src/app/golf/admin/crm/components/ScheduleEventModal.tsx +++ b/src/app/golf/admin/crm/components/ScheduleEventModal.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useId } from 'react'; import { createClient } from '@/lib/supabase/client'; +import { logError } from '@/lib/error-logging'; import { cn } from '@/lib/utils'; import { format, addDays, addHours, parseISO } from 'date-fns'; import type { Coach } from '../crm-config'; @@ -134,11 +135,18 @@ export function ScheduleEventModal({ } const timer = setTimeout(async () => { - const { data } = await supabase + const { data, error: searchError } = await supabase .from('crm_coaches') .select('*') .or(`name.ilike.%${coachSearchQuery}%,school.ilike.%${coachSearchQuery}%`) .limit(5); + if (searchError) { + logError( + new Error(searchError.message), + { component: 'ScheduleEventModal', action: 'search-coaches', sport: 'golf' }, + 'medium' + ); + } setCoachSearchResults((data || []) as Coach[]); }, 200); @@ -201,6 +209,11 @@ export function ScheduleEventModal({ } catch (err) { console.error('Failed to save event:', err); setError(err instanceof Error ? err.message : 'Failed to save event'); + logError( + err instanceof Error ? err : new Error(String(err)), + { component: 'ScheduleEventModal', action: isEditing ? 'update-crm-event' : 'create-crm-event', sport: 'golf', eventId: event?.id, coachId: form.coachId }, + 'high' + ); } finally { setSubmitting(false); } @@ -223,6 +236,11 @@ export function ScheduleEventModal({ } catch (err) { console.error('Failed to delete event:', err); setError(err instanceof Error ? err.message : 'Failed to delete event'); + logError( + err instanceof Error ? err : new Error(String(err)), + { component: 'ScheduleEventModal', action: 'delete-crm-event', sport: 'golf', eventId: event.id }, + 'high' + ); } finally { setSubmitting(false); } diff --git a/src/app/golf/admin/crm/page.tsx b/src/app/golf/admin/crm/page.tsx index 409243ae8..d00c83a3d 100644 --- a/src/app/golf/admin/crm/page.tsx +++ b/src/app/golf/admin/crm/page.tsx @@ -90,6 +90,7 @@ import { mergeTemplate, buildGmailComposeUrl } from '@/lib/crm/gmail-compose'; import type { CoachEngagement } from './types/foundations'; import { Button, IconButton } from '@/components/ui/button'; import { toast } from '@/components/ui/sonner'; +import { logError } from '@/lib/error-logging'; import { NativeSelect } from '@/components/ui/native-select'; // ============================================================================ @@ -453,6 +454,12 @@ export default function CRMPage() { setConferences(uniqueConferences); } catch (err) { console.error('Failed to fetch all coaches:', err); + toast.error('Failed to load coaches', err instanceof Error ? err.message : 'Please refresh and try again.'); + logError( + err instanceof Error ? err : new Error(String(err)), + { component: 'CRMPage', action: 'fetch-all-coaches', sport: 'golf' }, + 'medium' + ); } finally { setLoading(false); } @@ -549,6 +556,11 @@ export default function CRMPage() { .catch((err) => { // Non-fatal: badges fall back to "—" placeholder when the map is empty. console.warn('[crm] engagement fetch failed:', err); + logError( + err instanceof Error ? err : new Error(String(err)), + { component: 'CRMPage', action: 'fetch-coach-engagement', sport: 'golf' }, + 'medium' + ); }); return () => { cancelled = true; @@ -571,6 +583,11 @@ export default function CRMPage() { }) .catch((err) => { console.warn('[crm] enrollment fetch failed:', err); + logError( + err instanceof Error ? err : new Error(String(err)), + { component: 'CRMPage', action: 'fetch-coach-sequence-enrollment', sport: 'golf' }, + 'medium' + ); }); return () => { cancelled = true; @@ -628,6 +645,11 @@ export default function CRMPage() { if (cancelled) return; if (tplError) { console.warn('[crm] email template fetch failed:', tplError.message); + logError( + new Error(tplError.message), + { component: 'CRMPage', action: 'fetch-email-templates', sport: 'golf' }, + 'medium' + ); return; } const rows = (data ?? []) as Array<{ id: string; name: string | null; subject: string | null; body: string | null }>; @@ -675,10 +697,22 @@ export default function CRMPage() { if (r.configured) { getDomainAuthStatus() .then((d) => { if (!cancelled && d.checked && d.result) setDomainAuth(d.result); }) - .catch(() => {}); + .catch((err) => { + logError( + err instanceof Error ? err : new Error(String(err)), + { component: 'CRMPage', action: 'fetch-domain-auth-status', sport: 'golf' }, + 'medium' + ); + }); } }) - .catch(() => {}); + .catch((err) => { + logError( + err instanceof Error ? err : new Error(String(err)), + { component: 'CRMPage', action: 'fetch-gmail-send-status', sport: 'golf' }, + 'medium' + ); + }); return () => { cancelled = true; }; }, []); @@ -727,6 +761,12 @@ export default function CRMPage() { if (selectedCoach?.id === coachId) setSelectedCoach(prev => prev ? { ...prev, ...finalUpdates } : null); } catch (err) { console.error('Failed to update coach:', err); + toast.error('Failed to update coach', err instanceof Error ? err.message : 'Please try again.'); + logError( + err instanceof Error ? err : new Error(String(err)), + { component: 'CRMPage', action: 'update-coach', sport: 'golf', coachId }, + 'high' + ); fetchAllCoaches(); } }; @@ -759,6 +799,12 @@ export default function CRMPage() { })); } catch (err) { console.error('Failed to bulk update:', err); + toast.error('Failed to update coaches', err instanceof Error ? err.message : 'Please try again.'); + logError( + err instanceof Error ? err : new Error(String(err)), + { component: 'CRMPage', action: 'bulk-update-coaches', sport: 'golf', coachIds: ids }, + 'high' + ); fetchAllCoaches(); } }; @@ -837,10 +883,20 @@ export default function CRMPage() { toast.success(`Sent to ${coach.name} — marked contacted`); } else { toast.error(res.error ?? 'Gmail send failed'); + logError( + new Error(res.error ?? 'Gmail send failed'), + { component: 'CRMPage', action: 'send-coach-via-gmail', sport: 'golf', coachId: coach.id }, + 'high' + ); } } catch (err) { toast.dismiss(tid); toast.error(err instanceof Error ? err.message : 'Gmail send failed'); + logError( + err instanceof Error ? err : new Error(String(err)), + { component: 'CRMPage', action: 'send-coach-via-gmail', sport: 'golf', coachId: coach.id }, + 'high' + ); } }, [activeManualTemplate, markCoachContactedLocally]); @@ -851,7 +907,13 @@ export default function CRMPage() { if (!activeManualTemplate || !coach.email) return; if (gmailDirectEnabled) { void sendViaGmail(coach); return; } const subject = mergeTemplate(activeManualTemplate.subject, coach); - logManualGmailTouch({ coach_id: coach.id, subject }).catch(() => {}); + logManualGmailTouch({ coach_id: coach.id, subject }).catch((err) => { + logError( + err instanceof Error ? err : new Error(String(err)), + { component: 'CRMPage', action: 'log-manual-gmail-touch', sport: 'golf', coachId: coach.id }, + 'medium' + ); + }); markCoachContactedLocally(coach.id); toast.success(`Opened Gmail for ${coach.name} — logged as contacted`); }, [activeManualTemplate, gmailDirectEnabled, sendViaGmail, markCoachContactedLocally]); @@ -884,6 +946,11 @@ export default function CRMPage() { const { ok } = await setCoachAssignee({ coach_id: coachId, assignee }); if (!ok) { toast.error('Failed to update assignee'); + logError( + new Error('Failed to update coach assignee'), + { component: 'CRMPage', action: 'set-coach-assignee', sport: 'golf', coachId }, + 'high' + ); fetchAllCoaches(); } }, @@ -942,7 +1009,15 @@ export default function CRMPage() { fetchAllCoaches(); } setSelectedIds(new Set()); - } catch (err) { console.error('Bulk action failed:', err); } + } catch (err) { + console.error('Bulk action failed:', err); + toast.error('Bulk action failed', err instanceof Error ? err.message : 'Please try again.'); + logError( + err instanceof Error ? err : new Error(String(err)), + { component: 'CRMPage', action: 'bulk-action', sport: 'golf', bulkAction: action, coachIds: ids }, + 'high' + ); + } }; const exportToCSV = () => { @@ -983,6 +1058,11 @@ export default function CRMPage() { toast.dismiss(tid); if (!res.ok) { toast.error(res.error ?? 'Batch send failed'); + logError( + new Error(res.error ?? 'Batch send failed'), + { component: 'CRMPage', action: 'send-batch-via-gmail', sport: 'golf' }, + 'high' + ); } else if (res.capped && res.sent === 0) { toast('Daily Gmail send cap reached — try again tomorrow'); } else { @@ -994,6 +1074,11 @@ export default function CRMPage() { } catch (err) { toast.dismiss(tid); toast.error(err instanceof Error ? err.message : 'Batch send failed'); + logError( + err instanceof Error ? err : new Error(String(err)), + { component: 'CRMPage', action: 'send-batch-via-gmail', sport: 'golf' }, + 'high' + ); } finally { setGmailBatchSending(false); } diff --git a/src/components/auth/baseball-sign-in-form.tsx b/src/components/auth/baseball-sign-in-form.tsx index dfefcd3f3..0ae147f80 100644 --- a/src/components/auth/baseball-sign-in-form.tsx +++ b/src/components/auth/baseball-sign-in-form.tsx @@ -4,6 +4,7 @@ import { useState, useEffect } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import Link from 'next/link'; import { loginAction } from '@/app/baseball/actions/auth'; +import { logError } from '@/lib/error-logging'; import { invalidateAuthCache } from '@/hooks/use-baseball-auth'; import { Input } from '@/components/ui/input'; import { triggerHaptic } from '@/lib/utils/capacitor'; @@ -88,7 +89,12 @@ export function BaseballSignInForm() { if (storedReturnTo) sessionStorage.removeItem('baseball_login_returnTo'); router.replace(result.redirectTo || '/baseball/dashboard'); } - } catch { + } catch (err) { + logError( + err instanceof Error ? err : new Error(String(err)), + { component: 'BaseballSignInForm', action: 'loginAction', sport: 'baseball' }, + 'high' + ); setError('An unexpected error occurred. Please try again.'); setIsLoading(false); } diff --git a/src/components/auth/golf-sign-in-form.tsx b/src/components/auth/golf-sign-in-form.tsx index 0c307969e..36def1036 100644 --- a/src/components/auth/golf-sign-in-form.tsx +++ b/src/components/auth/golf-sign-in-form.tsx @@ -4,6 +4,7 @@ import { useState, useEffect } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import Link from 'next/link'; import { loginAction } from '@/app/golf/actions/auth'; +import { logError } from '@/lib/error-logging'; import { Input } from '@/components/ui/input'; import { AlertCircle, Loader2 } from 'lucide-react'; import { triggerHaptic } from '@/lib/utils/capacitor'; @@ -135,7 +136,12 @@ export function GolfSignInForm() { } else { router.push(`/golf/welcome?next=${encodeURIComponent(destination)}`); } - } catch { + } catch (err) { + logError( + err instanceof Error ? err : new Error(String(err)), + { component: 'GolfSignInForm', action: 'loginAction', sport: 'golf' }, + 'high' + ); setError('An unexpected error occurred. Please try again.'); setIsLoading(false); } diff --git a/src/components/baseball/performance/PlayerLiftToday.tsx b/src/components/baseball/performance/PlayerLiftToday.tsx index 1ddbc0e7c..9caf30f31 100644 --- a/src/components/baseball/performance/PlayerLiftToday.tsx +++ b/src/components/baseball/performance/PlayerLiftToday.tsx @@ -49,6 +49,7 @@ import { } from '@/components/icons'; import { useAuth } from '@/hooks/use-auth'; import { useTeamStore } from '@/stores/team-store'; +import { logError } from '@/lib/error-logging'; import { submitReadinessCheckin } from '@/app/baseball/actions/lifting'; import { getPlayerLiftTodaySummary } from '@/app/baseball/actions/player-today-lift'; import type { @@ -140,6 +141,11 @@ export default function PlayerLiftToday({ if (!result.success) { setError(result.error ?? 'Could not load your lifts. Please try again.'); setSessions([]); + logError( + new Error(result.error ?? 'Could not load lift sessions'), + { component: 'PlayerLiftToday', action: 'load', sport: 'baseball' }, + 'medium' + ); return; } setSessions(result.sessions); @@ -154,9 +160,14 @@ export default function PlayerLiftToday({ setArm((existing.arm_status as BaseballReadinessArmStatus) ?? ''); setReadinessNotes(existing.notes ?? ''); } - } catch { + } catch (err) { setError('Could not load your lifts. Pull to refresh or try again.'); setSessions([]); + logError( + err instanceof Error ? err : new Error('Could not load lift sessions'), + { component: 'PlayerLiftToday', action: 'load', sport: 'baseball' }, + 'medium' + ); } finally { setLoading(false); } @@ -181,6 +192,11 @@ export default function PlayerLiftToday({ }); if (!res.success) { setError(res.error ?? 'Could not save your check-in.'); + logError( + new Error(res.error ?? 'Could not save readiness check-in'), + { component: 'PlayerLiftToday', action: 'handleReadiness', sport: 'baseball' }, + 'high' + ); return; } setReadinessDone(true); @@ -199,8 +215,13 @@ export default function PlayerLiftToday({ updated_at: new Date().toISOString(), })); setTimeout(() => setReadinessDone(false), 3000); - } catch { + } catch (err) { setError('Something went wrong saving your check-in.'); + logError( + err instanceof Error ? err : new Error('Something went wrong saving readiness check-in'), + { component: 'PlayerLiftToday', action: 'handleReadiness', sport: 'baseball' }, + 'high' + ); } finally { setSavingReadiness(false); } diff --git a/src/components/baseball/postgame/PostgameReviewClient.tsx b/src/components/baseball/postgame/PostgameReviewClient.tsx index 856eb77a6..aa711ef92 100644 --- a/src/components/baseball/postgame/PostgameReviewClient.tsx +++ b/src/components/baseball/postgame/PostgameReviewClient.tsx @@ -37,6 +37,7 @@ import Link from 'next/link'; import { cn } from '@/lib/utils'; import { Button } from '@/components/fairway'; import { toast } from '@/components/ui/sonner'; +import { logError } from '@/lib/error-logging'; import { IconClock, IconNote, @@ -139,6 +140,11 @@ export function PostgameReviewClient({ router.refresh(); } else { toast.error('Could not generate review', res.error ?? 'Please try again.'); + logError( + new Error(res.error ?? 'Could not generate postgame review'), + { component: 'PostgameReviewClient', action: 'handleGenerate', sport: 'baseball' }, + 'high' + ); } }); } @@ -164,6 +170,11 @@ export function PostgameReviewClient({ kind === 'practice' ? 'Could not add to practice' : 'Could not log it', res.error ?? 'Please try again.', ); + logError( + new Error(res.error ?? `Could not convert postgame item (${kind})`), + { component: 'PostgameReviewClient', action: 'handleConvert', sport: 'baseball' }, + 'high' + ); } }); } @@ -178,6 +189,11 @@ export function PostgameReviewClient({ router.refresh(); } else { toast.error('Could not update', res.error ?? 'Please try again.'); + logError( + new Error(res.error ?? 'Could not update postgame item disposition'), + { component: 'PostgameReviewClient', action: 'handleDispose', sport: 'baseball' }, + 'high' + ); } }); } diff --git a/src/components/baseball/staff-decision-room/StaffDecisionRoomClient.tsx b/src/components/baseball/staff-decision-room/StaffDecisionRoomClient.tsx index 1046714f7..1b26a6c37 100644 --- a/src/components/baseball/staff-decision-room/StaffDecisionRoomClient.tsx +++ b/src/components/baseball/staff-decision-room/StaffDecisionRoomClient.tsx @@ -37,6 +37,7 @@ import { toast } from '@/components/ui/sonner'; import { createMeetingItem, type DecisionRoomData } from '@/app/baseball/actions/decision-room'; import { recordActionOutcomes } from '@/app/baseball/actions/coachhelm-actions'; +import { logError } from '@/lib/error-logging'; import { StaffDecisionRoomFairway } from './StaffDecisionRoomFairway'; interface StaffDecisionRoomClientProps { @@ -91,9 +92,19 @@ export function StaffDecisionRoomClient({ data }: StaffDecisionRoomClientProps) refresh(); } else { toast.error('Could not add item', res.error ?? 'Please try again.'); + logError( + new Error(res.error ?? 'Could not add agenda item'), + { component: 'StaffDecisionRoomClient', action: 'submitNewItem', sport: 'baseball' }, + 'high' + ); } - } catch { + } catch (error) { toast.error('Something went wrong', 'Please try again.'); + logError( + error instanceof Error ? error : new Error('Something went wrong adding agenda item'), + { component: 'StaffDecisionRoomClient', action: 'submitNewItem', sport: 'baseball' }, + 'high' + ); } }); } @@ -206,9 +217,19 @@ export function StaffDecisionRoomClient({ data }: StaffDecisionRoomClientProps) router.refresh(); } else { toast.error('Could not re-measure', res.error); + logError( + new Error(res.error || 'Could not re-measure action outcomes'), + { component: 'StaffDecisionRoomClient', action: 'reMeasureOutcomes', sport: 'baseball' }, + 'high' + ); } - } catch { + } catch (error) { toast.error('Something went wrong', 'Please try again.'); + logError( + error instanceof Error ? error : new Error('Something went wrong re-measuring action outcomes'), + { component: 'StaffDecisionRoomClient', action: 'reMeasureOutcomes', sport: 'baseball' }, + 'high' + ); } }); diff --git a/src/components/baseball/staff-decision-room/StaffDecisionRoomFairway.tsx b/src/components/baseball/staff-decision-room/StaffDecisionRoomFairway.tsx index 7dc9b7caa..ecdf32e38 100644 --- a/src/components/baseball/staff-decision-room/StaffDecisionRoomFairway.tsx +++ b/src/components/baseball/staff-decision-room/StaffDecisionRoomFairway.tsx @@ -49,6 +49,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { toast } from '@/components/ui/sonner'; +import { logError } from '@/lib/error-logging'; import { IconBolt, IconAlertCircle, @@ -816,6 +817,11 @@ function AgendaDetailPane({ item, onDone }: { item: DecisionRoomAgendaItem; onDo const res = await fn(); if (!res.success) { toast.error('Could not complete that', res.error); + logError( + new Error(res.error || 'Could not complete agenda item action'), + { component: 'StaffDecisionRoomFairway', action: 'agendaDetailAction', sport: 'baseball' }, + 'high' + ); return; } if (sealLabel) { @@ -836,8 +842,13 @@ function AgendaDetailPane({ item, onDone }: { item: DecisionRoomAgendaItem; onDo setText(''); onDone(); } - } catch { + } catch (error) { toast.error('Something went wrong', 'Please try again.'); + logError( + error instanceof Error ? error : new Error('Something went wrong completing agenda item action'), + { component: 'StaffDecisionRoomFairway', action: 'agendaDetailAction', sport: 'baseball' }, + 'high' + ); } }); } diff --git a/src/components/coach/discover/DiscoverView.tsx b/src/components/coach/discover/DiscoverView.tsx index aca981fc4..3a11e044b 100644 --- a/src/components/coach/discover/DiscoverView.tsx +++ b/src/components/coach/discover/DiscoverView.tsx @@ -4,6 +4,8 @@ import { useState, useMemo, useCallback, useTransition } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { motion, AnimatePresence, useReducedMotion } from 'framer-motion'; import { cn } from '@/lib/utils'; +import { toast } from '@/components/ui/sonner'; +import { logError } from '@/lib/error-logging'; import type { Player, Organization } from '@/lib/types'; import { DiscoverToggle, type DiscoverMode } from './DiscoverToggle'; import { PlayerCardData } from './PlayerCard'; @@ -390,15 +392,35 @@ export function DiscoverView({ const result = await removeFromWatchlist(coachId, playerId); if (result.success) { setWatchlistIds((prev) => prev.filter((id) => id !== playerId)); + } else { + toast.error('Failed to update watchlist', result.message || 'Could not remove player from watchlist.'); + logError( + new Error(result.message || 'Failed to remove from watchlist'), + { component: 'DiscoverView', action: 'handleWatchlist', sport: 'baseball' }, + 'high' + ); } } else { const result = await addToWatchlist(coachId, playerId); if (result.success) { setWatchlistIds((prev) => [...prev, playerId]); + } else { + toast.error('Failed to update watchlist', result.message || 'Could not add player to watchlist.'); + logError( + new Error(result.message || 'Failed to add to watchlist'), + { component: 'DiscoverView', action: 'handleWatchlist', sport: 'baseball' }, + 'high' + ); } } } catch (error) { console.error('Error updating watchlist:', error); + toast.error('Failed to update watchlist', error instanceof Error ? error.message : 'Please try again.'); + logError( + error instanceof Error ? error : new Error('Failed to update watchlist'), + { component: 'DiscoverView', action: 'handleWatchlist', sport: 'baseball' }, + 'high' + ); } }; diff --git a/src/components/fairway/pages/messages/FairwayMessages.tsx b/src/components/fairway/pages/messages/FairwayMessages.tsx index 1ae2684cf..f6917a623 100644 --- a/src/components/fairway/pages/messages/FairwayMessages.tsx +++ b/src/components/fairway/pages/messages/FairwayMessages.tsx @@ -51,6 +51,7 @@ import { createClient } from '@/lib/supabase/client'; import { fairwayScope } from '@/lib/redesign/flag'; import { decodeMessageContent } from '@/lib/utils/decode-message-content'; import { useToast } from '@/components/ui/sonner'; +import { logError } from '@/lib/error-logging'; import { useGolfUser } from '@/contexts/golf-user-context'; import { useGolfConversations, useGolfMessages } from '@/hooks/golf/use-golf-messages'; import { useMessageAttachments } from '@/hooks/golf/use-message-attachments'; @@ -132,16 +133,24 @@ export function FairwayMessages() { const fetchGroupParticipants = React.useCallback(async (conversationId: string) => { const supabase = createClient(); - const { data: participants } = await supabase + const { data: participants, error: participantsError } = await supabase .from('golf_conversation_participants') .select('user_id') .eq('conversation_id', conversationId); + if (participantsError) { + logError( + new Error(participantsError.message || 'Failed to fetch group participants'), + { component: 'FairwayMessages', action: 'fetchGroupParticipants', sport: 'shared' }, + 'medium' + ); + } + if (!participants || participants.length === 0) return; const userIds = participants.map(p => p.user_id); - const [{ data: coaches }, { data: players }] = await Promise.all([ + const [{ data: coaches, error: coachesError }, { data: players, error: playersError }] = await Promise.all([ supabase .from('golf_coaches') .select('user_id, full_name, avatar_url') @@ -152,6 +161,14 @@ export function FairwayMessages() { .in('user_id', userIds), ]); + if (coachesError || playersError) { + logError( + new Error(coachesError?.message || playersError?.message || 'Failed to fetch group participant details'), + { component: 'FairwayMessages', action: 'fetchGroupParticipants', sport: 'shared' }, + 'medium' + ); + } + const map = new Map(); (coaches ?? []).forEach(c => { if (c.user_id) { @@ -218,8 +235,12 @@ export function FairwayMessages() { showToast('Conversation started', 'success'); } } catch (err) { - void err; showToast('Failed to start conversation', 'error'); + logError( + err instanceof Error ? err : new Error('Failed to start conversation'), + { component: 'FairwayMessages', action: 'handlePlayerParam', sport: 'shared' }, + 'high' + ); } setHandledPlayerParam(true); router.replace('/golf/dashboard/messages', { scroll: false }); @@ -291,10 +312,19 @@ export function FairwayMessages() { showToast('Conversation started', 'success'); } else if ('error' in result) { showToast(String(result.error) || 'Failed to start conversation', 'error'); + logError( + new Error(String(result.error) || 'Failed to start conversation'), + { component: 'FairwayMessages', action: 'handleNewConversation', sport: 'shared' }, + 'high' + ); } } catch (err) { - void err; showToast('Failed to start conversation', 'error'); + logError( + err instanceof Error ? err : new Error('Failed to start conversation'), + { component: 'FairwayMessages', action: 'handleNewConversation', sport: 'shared' }, + 'high' + ); } }; @@ -313,6 +343,11 @@ export function FairwayMessages() { return true; } catch (error) { showToast(error instanceof Error ? error.message : 'Failed to send message', 'error'); + logError( + error instanceof Error ? error : new Error('Failed to send message'), + { component: 'FairwayMessages', action: 'handleSendMessage', sport: 'shared' }, + 'high' + ); return false; } }; @@ -327,11 +362,21 @@ export function FairwayMessages() { }); if (!result.success) { showToast(result.error || 'Failed to send message', 'error'); + logError( + new Error(result.error || 'Failed to send message with attachments'), + { component: 'FairwayMessages', action: 'handleSendMessageWithAttachments', sport: 'shared' }, + 'high' + ); return false; } return true; } catch (error) { showToast(error instanceof Error ? error.message : 'Failed to send message', 'error'); + logError( + error instanceof Error ? error : new Error('Failed to send message with attachments'), + { component: 'FairwayMessages', action: 'handleSendMessageWithAttachments', sport: 'shared' }, + 'high' + ); return false; } }; @@ -356,6 +401,11 @@ export function FairwayMessages() { setEditContent(''); } catch (error) { showToast(error instanceof Error ? error.message : 'Failed to update message', 'error'); + logError( + error instanceof Error ? error : new Error('Failed to update message'), + { component: 'FairwayMessages', action: 'handleSaveEdit', sport: 'shared' }, + 'high' + ); } finally { setIsEditSaving(false); } @@ -374,6 +424,11 @@ export function FairwayMessages() { setDeleteConfirmId(null); } catch (error) { showToast(error instanceof Error ? error.message : 'Failed to delete message', 'error'); + logError( + error instanceof Error ? error : new Error('Failed to delete message'), + { component: 'FairwayMessages', action: 'handleConfirmDelete', sport: 'shared' }, + 'high' + ); } }; const handleCancelDelete = () => setDeleteConfirmId(null); diff --git a/src/components/fairway/pages/settings/FairwaySettingsGeneral.tsx b/src/components/fairway/pages/settings/FairwaySettingsGeneral.tsx index 741b65b0e..4d34d8360 100644 --- a/src/components/fairway/pages/settings/FairwaySettingsGeneral.tsx +++ b/src/components/fairway/pages/settings/FairwaySettingsGeneral.tsx @@ -41,6 +41,7 @@ import Link from 'next/link'; import { Field } from '@base-ui-components/react/field'; import { createClient } from '@/lib/supabase/client'; +import { logError } from '@/lib/error-logging'; import { clearActiveTeam } from '@/app/golf/actions/team-switcher'; import { fromUntyped } from '@/lib/supabase/untyped'; import { cn } from '@/lib/utils'; @@ -496,8 +497,13 @@ export function FairwaySettingsGeneral() { : undefined, }); } - } catch { + } catch (err) { setLoadError(true); + logError( + err instanceof Error ? err : new Error(String(err)), + { component: 'FairwaySettingsGeneral', action: 'load-profile', sport: 'golf' }, + 'medium' + ); } }, [golfUser]); @@ -521,14 +527,24 @@ export function FairwaySettingsGeneral() { const payload = await response.json().catch(() => ({})); void triggerHaptic('error'); fairwayToast.error(payload.error || 'Failed to delete account'); + logError( + new Error(payload.error || `Failed to delete account (status ${response.status})`), + { component: 'FairwaySettingsGeneral', action: 'delete-account', sport: 'golf', statusCode: response.status }, + 'critical' + ); return; } void triggerHaptic('success'); fairwayToast.success('Account deleted successfully'); window.location.href = isNativeApp() ? '/golf/login' : '/'; - } catch { + } catch (err) { void triggerHaptic('error'); fairwayToast.error('Failed to delete account'); + logError( + err instanceof Error ? err : new Error(String(err)), + { component: 'FairwaySettingsGeneral', action: 'delete-account', sport: 'golf' }, + 'critical' + ); } finally { setDeletingAccount(false); setDeleteConfirmOpen(false); @@ -826,6 +842,11 @@ function PersonalInfoPanel({ router.refresh(); } catch (err) { fairwayToast.error(err instanceof Error ? err.message : 'Failed to update profile'); + logError( + err instanceof Error ? err : new Error(String(err)), + { component: 'PersonalInfoPanel', action: 'update-profile', sport: 'golf', role: profile.role }, + 'high' + ); } finally { setSaving(false); } @@ -912,6 +933,11 @@ function EmailPanel({ currentEmail }: { currentEmail: string }) { setNewEmail(''); } catch (err) { fairwayToast.error(err instanceof Error ? err.message : 'Failed to update email'); + logError( + err instanceof Error ? err : new Error(String(err)), + { component: 'EmailPanel', action: 'update-email', sport: 'golf' }, + 'high' + ); } finally { setSaving(false); } @@ -983,6 +1009,11 @@ function PasswordPanel() { setConfirmPassword(''); } catch (err) { fairwayToast.error(err instanceof Error ? err.message : 'Failed to update password'); + logError( + err instanceof Error ? err : new Error(String(err)), + { component: 'PasswordPanel', action: 'update-password', sport: 'golf' }, + 'high' + ); } finally { setSaving(false); } @@ -1222,6 +1253,11 @@ function NotificationsPanel() { const res = await getNotificationPreferences(); if (res.error || !res.data) { setLoadFailed(true); + logError( + new Error(res.error || 'Failed to load notification preferences'), + { component: 'NotificationsPanel', action: 'load-notification-preferences', sport: 'golf' }, + 'medium' + ); return; } setPrefs(res.data as DeliveryNotificationPreferences); @@ -1245,6 +1281,11 @@ function NotificationsPanel() { setPrefs(previous); void triggerHaptic('error'); fairwayToast.error(res.error || 'Failed to save preference'); + logError( + new Error(res.error || 'Failed to save notification preference'), + { component: 'NotificationsPanel', action: 'toggle-notification-preference', sport: 'golf', key }, + 'high' + ); } else { void triggerHaptic('light'); setSavedAt(Date.now()); // P384: flash the auto-save "Saved" signal. @@ -1405,6 +1446,11 @@ function GolfScoringPanel({ teamId }: { teamId: string }) { if (error) { setLoadFailed(true); setLoaded(true); + logError( + new Error(error.message), + { component: 'GolfScoringPanel', action: 'load-golf-team-settings', sport: 'golf', teamId }, + 'medium' + ); return; } @@ -1467,6 +1513,11 @@ function GolfScoringPanel({ teamId }: { teamId: string }) { fairwayToast.success('Golf settings updated'); } catch (err) { fairwayToast.error(err instanceof Error ? err.message : 'Failed to save'); + logError( + err instanceof Error ? err : new Error(String(err)), + { component: 'GolfScoringPanel', action: 'save-golf-team-settings', sport: 'golf', teamId }, + 'high' + ); } finally { setSaving(false); } @@ -1676,6 +1727,11 @@ function PlayerGolfDetailsPanel({ onUpdate(); } catch (err) { fairwayToast.error(err instanceof Error ? err.message : 'Failed to save'); + logError( + err instanceof Error ? err : new Error(String(err)), + { component: 'PlayerGolfDetailsPanel', action: 'save-golf-details', sport: 'golf', playerId }, + 'high' + ); } finally { setSaving(false); } @@ -1773,6 +1829,11 @@ export function TeamSettingsPanel({ onUpdate }: { onUpdate: () => void }) { if (teamError) { setLoadFailed(true); setLoaded(true); + logError( + new Error(teamError.message), + { component: 'TeamSettingsPanel', action: 'load-team', sport: 'golf', teamId: activeTeamId }, + 'medium' + ); return; } @@ -1797,6 +1858,11 @@ export function TeamSettingsPanel({ onUpdate }: { onUpdate: () => void }) { if (orgError) { setLoadFailed(true); setLoaded(true); + logError( + new Error(orgError.message), + { component: 'TeamSettingsPanel', action: 'load-organization', sport: 'golf', organizationId: team.organization_id }, + 'medium' + ); return; } if (org) { @@ -1882,6 +1948,11 @@ export function TeamSettingsPanel({ onUpdate }: { onUpdate: () => void }) { onUpdate(); } catch (err) { fairwayToast.error(err instanceof Error ? err.message : 'Failed to save'); + logError( + err instanceof Error ? err : new Error(String(err)), + { component: 'TeamSettingsPanel', action: 'save-team-settings', sport: 'golf', teamId }, + 'high' + ); } finally { setSaving(false); } @@ -1995,6 +2066,11 @@ export function InviteSettingsPanel() { if (error) { setLoadFailed(true); setLoaded(true); + logError( + new Error(error.message), + { component: 'InviteSettingsPanel', action: 'load-invite-code', sport: 'golf', teamId: activeTeamId }, + 'medium' + ); return; } @@ -2021,8 +2097,13 @@ export function InviteSettingsPanel() { if (error) throw error; setInviteCode(newCode); fairwayToast.success('New invite code generated'); - } catch { + } catch (err) { fairwayToast.error('Failed to generate code'); + logError( + err instanceof Error ? err : new Error(String(err)), + { component: 'InviteSettingsPanel', action: 'regenerate-invite-code', sport: 'golf', teamId }, + 'high' + ); } finally { setLoading(false); } @@ -2035,8 +2116,13 @@ export function InviteSettingsPanel() { setCopied(true); fairwayToast.success('Invite link copied'); setTimeout(() => setCopied(false), 2000); - } catch { + } catch (err) { fairwayToast.error('Failed to copy'); + logError( + err instanceof Error ? err : new Error(String(err)), + { component: 'InviteSettingsPanel', action: 'copy-invite-link', sport: 'golf', teamId }, + 'medium' + ); } }; diff --git a/src/components/features/video-upload.tsx b/src/components/features/video-upload.tsx index 2c34dbed7..e212239b0 100644 --- a/src/components/features/video-upload.tsx +++ b/src/components/features/video-upload.tsx @@ -2,6 +2,7 @@ import { useState, useRef, useCallback } from 'react'; import { createClient } from '@/lib/supabase/client'; +import { logError } from '@/lib/error-logging'; import { useAuthStore } from '@/stores/auth-store'; import { Button, IconButton } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -150,6 +151,11 @@ export function VideoUpload({ onUploadComplete, onCancel }: VideoUploadProps) { // just-uploaded storage object so it doesn't become an orphan. await supabase.storage.from('baseball_videos').remove([fileName]); setError(saveResult.error ?? 'Could not save the video. Please try again.'); + logError( + new Error(saveResult.error ?? 'Failed to save video'), + { component: 'VideoUpload', action: 'save-video', sport: 'shared' }, + 'high' + ); return; } dbSaved = true; @@ -163,6 +169,11 @@ export function VideoUpload({ onUploadComplete, onCancel }: VideoUploadProps) { const primaryResult = await setMyPrimaryVideo({ videoId: newVideoId }); if (!primaryResult.success) { setError(primaryResult.error ?? 'Video uploaded, but it could not be set as your primary video. You can set it from your video library.'); + logError( + new Error(primaryResult.error ?? 'Failed to set primary video'), + { component: 'VideoUpload', action: 'set-primary-video', sport: 'shared', videoId: newVideoId }, + 'high' + ); } } setProgress(100); @@ -177,6 +188,11 @@ export function VideoUpload({ onUploadComplete, onCancel }: VideoUploadProps) { await supabase.storage.from('baseball_videos').remove([fileName]).catch(() => undefined); } setError(err instanceof Error ? err.message : 'Upload failed. Please try again.'); + logError( + err instanceof Error ? err : new Error(String(err)), + { component: 'VideoUpload', action: 'upload-video', sport: 'shared' }, + 'high' + ); } finally { setUploading(false); } diff --git a/src/components/golf/calendar/AttendancePanel.tsx b/src/components/golf/calendar/AttendancePanel.tsx index cb537071f..15fab8afa 100644 --- a/src/components/golf/calendar/AttendancePanel.tsx +++ b/src/components/golf/calendar/AttendancePanel.tsx @@ -24,6 +24,7 @@ import { Check, ClipboardCheck, Loader2 } from 'lucide-react'; import { cn } from '@/lib/utils'; import { toast } from '@/components/ui/sonner'; import { Button } from '@/components/ui/button'; +import { logError } from '@/lib/error-logging'; import { bulkCheckIn, getAttendanceReport, @@ -123,13 +124,18 @@ export function AttendancePanel({ eventId, teamId, canManage }: AttendancePanelP setViewerPlayerId(res.data.viewerPlayerId); } else { setLoadError(res.error ?? 'Failed to load attendance'); + logError( + new Error(res.error ?? 'Failed to load attendance'), + { component: 'AttendancePanel', action: 'load-attendance-report', sport: 'golf', eventId, teamId }, + 'medium' + ); } setLoading(false); }); return () => { cancelled = true; }; - }, [eventId, reloadKey]); + }, [eventId, reloadKey, teamId]); const presentCount = useMemo( () => rows.filter((r) => r.mark === 'present' || r.mark === 'late').length, @@ -166,9 +172,14 @@ export function AttendancePanel({ eventId, teamId, canManage }: AttendancePanelP // Roll back just this player's mark. setRowMark(playerId, previousMark); toast.error('Could not save attendance', res.error || 'Try again in a moment.'); + logError( + new Error(res.error || 'Failed to save attendance'), + { component: 'AttendancePanel', action: 'mark-attendance', sport: 'golf', eventId, teamId, playerId }, + 'high' + ); } }, - [bulkPending, eventId, pendingIds, rows, setRowMark], + [bulkPending, eventId, teamId, pendingIds, rows, setRowMark], ); const handleMarkAllPresent = useCallback(async () => { @@ -186,8 +197,13 @@ export function AttendancePanel({ eventId, teamId, canManage }: AttendancePanelP prev.map((r) => ({ ...r, mark: previousMarks.get(r.playerId) ?? null })), ); toast.error('Could not check everyone in', res.error || 'Try again in a moment.'); + logError( + new Error(res.error || 'Failed to bulk check in'), + { component: 'AttendancePanel', action: 'bulk-check-in', sport: 'golf', eventId, teamId }, + 'high' + ); } - }, [bulkPending, eventId, rows]); + }, [bulkPending, eventId, teamId, rows]); const handleRetry = useCallback(() => setReloadKey((k) => k + 1), []); diff --git a/src/components/golf/calendar/EventDetailModal.tsx b/src/components/golf/calendar/EventDetailModal.tsx index 596c252d2..3d1cae775 100644 --- a/src/components/golf/calendar/EventDetailModal.tsx +++ b/src/components/golf/calendar/EventDetailModal.tsx @@ -23,6 +23,7 @@ import { PlayerRSVPCard } from './PlayerRSVPCard'; import { ConflictWarning } from './ConflictWarning'; import { useRSVP, usePlayerEventRSVP } from '@/hooks/useRSVP'; import { toast } from '@/components/ui/sonner'; +import { logError } from '@/lib/error-logging'; import { EventDocumentsSection } from './EventDocumentsSection'; import { Button, IconButton } from '@/components/ui/button'; import { Input, Textarea } from '@/components/ui/input'; @@ -452,16 +453,28 @@ export function EventDetailModal({ const { getEventRSVP } = await loadGolfCalendarActions(); const result = await getEventRSVP(event.id); if (cancelled) return; - if (result.success && result.data) { + if (result.success) { const ids = result.data.summary.attendees.map((a) => a.playerId); setExistingAttendeeIds(ids); setFormData((prev) => ({ ...prev, attendeeIds: ids })); setAttendeeHydration('loaded'); } else { setAttendeeHydration('error'); + logError( + new Error(result.error || 'Failed to load event attendees'), + { component: 'EventDetailModal', action: 'hydrate-attendees', sport: 'golf', eventId: event.id }, + 'medium' + ); + } + } catch (err) { + if (!cancelled) { + setAttendeeHydration('error'); + logError( + err instanceof Error ? err : new Error(String(err)), + { component: 'EventDetailModal', action: 'hydrate-attendees', sport: 'golf', eventId: event.id }, + 'medium' + ); } - } catch { - if (!cancelled) setAttendeeHydration('error'); } })(); return () => { @@ -549,8 +562,13 @@ export function EventDetailModal({ if (result.success && result.data) { setConflicts(result.data as ConflictData); } - } catch { + } catch (err) { // Conflict check failed - continue without warning + logError( + err instanceof Error ? err : new Error(String(err)), + { component: 'EventDetailModal', action: 'check-schedule-conflicts', sport: 'golf' }, + 'medium' + ); } finally { setCheckingConflicts(false); } @@ -604,6 +622,11 @@ export function EventDetailModal({ } } catch (err) { setError(err instanceof Error ? err.message : 'Failed to update'); + logError( + err instanceof Error ? err : new Error(String(err)), + { component: 'EventDetailModal', action: 'submit-with-scope', sport: 'golf', pendingScopeAction }, + 'high' + ); } finally { setPendingScopeAction(null); } @@ -627,6 +650,11 @@ export function EventDetailModal({ await onSave(buildSubmitData()); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to save event'); + logError( + err instanceof Error ? err : new Error(String(err)), + { component: 'EventDetailModal', action: 'save-event', sport: 'golf', isCreating }, + 'high' + ); } }; @@ -637,6 +665,11 @@ export function EventDetailModal({ await onRestore(); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to restore event'); + logError( + err instanceof Error ? err : new Error(String(err)), + { component: 'EventDetailModal', action: 'restore-event', sport: 'golf', eventId: event?.id }, + 'high' + ); } }; @@ -651,6 +684,11 @@ export function EventDetailModal({ await onDelete(); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to delete event'); + logError( + err instanceof Error ? err : new Error(String(err)), + { component: 'EventDetailModal', action: 'delete-event', sport: 'golf', eventId: event?.id }, + 'high' + ); } }; @@ -673,12 +711,22 @@ export function EventDetailModal({ toast.success('Reminders sent', `Notified ${sent} player${sent === 1 ? '' : 's'}.`); } else { toast.error('Failed to send reminders', result.error ?? 'Try again in a moment.'); + logError( + new Error(result.error || 'Failed to send event reminders'), + { component: 'EventDetailModal', action: 'send-roster-reminder', sport: 'golf', eventId: event?.id }, + 'medium' + ); } } catch (err) { toast.error( 'Failed to send reminders', err instanceof Error ? err.message : 'Try again in a moment.', ); + logError( + err instanceof Error ? err : new Error(String(err)), + { component: 'EventDetailModal', action: 'send-roster-reminder', sport: 'golf', eventId: event?.id }, + 'medium' + ); } }; @@ -1328,6 +1376,11 @@ export function EventDetailModal({ onRespond={async (response) => { const result = await respondToRSVP(response); if (!result.success) { + logError( + new Error(result.error || 'Failed to update RSVP'), + { component: 'EventDetailModal', action: 'respond-to-rsvp', sport: 'golf', eventId: event.id }, + 'high' + ); throw new Error(result.error || 'Failed to update RSVP'); } }} diff --git a/src/components/golf/calendar/EventDocumentsSection.tsx b/src/components/golf/calendar/EventDocumentsSection.tsx index 86542e187..895f4c071 100644 --- a/src/components/golf/calendar/EventDocumentsSection.tsx +++ b/src/components/golf/calendar/EventDocumentsSection.tsx @@ -26,6 +26,7 @@ import { Loader2, } from 'lucide-react'; import { toast } from '@/components/ui/sonner'; +import { logError } from '@/lib/error-logging'; import { attachDocumentToEvent, detachDocumentFromEvent, @@ -89,6 +90,12 @@ export function EventDocumentsSection({ if (cancelled) return; if (res.success && res.data) { setAttached(res.data); + } else { + logError( + new Error(res.error || 'Failed to load event documents'), + { component: 'EventDocumentsSection', action: 'load-event-documents', sport: 'golf', eventId }, + 'medium' + ); } setLoading(false); }); @@ -108,6 +115,11 @@ export function EventDocumentsSection({ setShowPicker(false); } else { toast.error('Could not attach', res.error || 'Try again in a moment.'); + logError( + new Error(res.error || 'Failed to attach document to event'), + { component: 'EventDocumentsSection', action: 'attach-document', sport: 'golf', eventId, documentId: docId }, + 'high' + ); } setPendingAction(null); }; @@ -120,6 +132,11 @@ export function EventDocumentsSection({ setAttached((prev) => prev.filter((r) => r.document.id !== docId)); } else { toast.error('Could not remove', res.error || 'Try again in a moment.'); + logError( + new Error(res.error || 'Failed to detach document from event'), + { component: 'EventDocumentsSection', action: 'detach-document', sport: 'golf', eventId, documentId: docId }, + 'high' + ); } setPendingAction(null); }; @@ -261,7 +278,15 @@ function DocumentPickerDialog({ setLoading(true); getDocuments(teamId).then((res) => { if (cancelled) return; - if (res.data) setDocs(res.data); + if (res.data) { + setDocs(res.data); + } else { + logError( + new Error(res.error || 'Failed to load team documents'), + { component: 'EventDocumentsSection', action: 'load-document-library', sport: 'golf', teamId }, + 'medium' + ); + } setLoading(false); }); return () => { diff --git a/src/components/golf/calendar/PremiumCalendarClient.tsx b/src/components/golf/calendar/PremiumCalendarClient.tsx index 39f0804a4..05d7c31ca 100644 --- a/src/components/golf/calendar/PremiumCalendarClient.tsx +++ b/src/components/golf/calendar/PremiumCalendarClient.tsx @@ -8,6 +8,7 @@ import { useMediaQuery } from '@/hooks/use-media-query'; import { toast } from '@/components/ui/sonner'; import { useMobileDetection } from '@/hooks/use-mobile-detection'; import { triggerHaptic } from '@/lib/utils/capacitor'; +import { logError } from '@/lib/error-logging'; import { DndContext, DragEndEvent, @@ -347,8 +348,18 @@ export function PremiumCalendarClient({ }); return { success: true }; } + logError( + new Error(result.error || 'Failed to update RSVP'), + { component: 'PremiumCalendarClient', action: 'rsvp-response', sport: 'golf', eventId }, + 'high' + ); return { success: false, error: result.error || 'Failed to update RSVP' }; } catch (err) { + logError( + err instanceof Error ? err : new Error(String(err)), + { component: 'PremiumCalendarClient', action: 'rsvp-response', sport: 'golf', eventId }, + 'high' + ); return { success: false, error: err instanceof Error ? err.message : 'Unknown error' }; } }, [actionHandlers, resolvedCapabilities.rsvpWrite]); @@ -420,8 +431,13 @@ export function PremiumCalendarClient({ })) as Array & { start: string; end: string; color: typeof PLAYER_COLORS[0] }>; return { playerId, periods }; } - } catch { + } catch (err) { // Individual player fetch failed — return empty so others still show + logError( + err instanceof Error ? err : new Error(String(err)), + { component: 'PremiumCalendarClient', action: 'fetch-player-availability', sport: 'golf', playerId }, + 'medium' + ); } return { playerId, periods: [] as Array & { start: string; end: string; color: typeof PLAYER_COLORS[0] }> }; }) @@ -433,9 +449,14 @@ export function PremiumCalendarClient({ newMap.set(playerId, periods); }); setMultiPlayerBusyPeriods(newMap); - } catch { + } catch (err) { // Entire availability fetch failed — silently degrade (calendar still works, just no availability overlay) console.warn('[PremiumCalendarClient] Failed to fetch player availability'); + logError( + err instanceof Error ? err : new Error(String(err)), + { component: 'PremiumCalendarClient', action: 'fetch-all-player-availability', sport: 'golf' }, + 'medium' + ); } }; @@ -496,9 +517,14 @@ export function PremiumCalendarClient({ })) as Array & { start: string; end: string; ownerType?: 'coach' | 'player' }>; setCoachBusyPeriods(periods); } - } catch { + } catch (err) { // Availability fetch failed — calendar still works, just no busy-period overlay console.warn('[PremiumCalendarClient] Failed to fetch current user availability'); + logError( + err instanceof Error ? err : new Error(String(err)), + { component: 'PremiumCalendarClient', action: 'fetch-current-user-availability', sport: 'golf' }, + 'medium' + ); } }; @@ -826,6 +852,11 @@ export function PremiumCalendarClient({ return; } toast.error('Save failed', msg || 'Failed to save event. Please try again.'); + logError( + err instanceof Error ? err : new Error(msg), + { component: 'PremiumCalendarClient', action: 'save-event', sport: 'golf', isCreatingEvent }, + 'high' + ); } finally { setIsSavingEvent(false); } @@ -959,11 +990,23 @@ export function PremiumCalendarClient({ }); if (!result.success) { console.error('Failed to reschedule event:', result.error); + toast.error('Reschedule failed', result.error || 'Failed to reschedule event. Please try again.'); + logError( + new Error(result.error || 'Failed to reschedule event'), + { component: 'PremiumCalendarClient', action: 'drag-reschedule-all-day-event', sport: 'golf', eventId }, + 'high' + ); return; } router.refresh(); } catch (err) { console.error('Failed to reschedule event:', err); + toast.error('Reschedule failed', err instanceof Error ? err.message : 'Failed to reschedule event. Please try again.'); + logError( + err instanceof Error ? err : new Error(String(err)), + { component: 'PremiumCalendarClient', action: 'drag-reschedule-all-day-event', sport: 'golf', eventId }, + 'high' + ); } return; } @@ -1005,12 +1048,24 @@ export function PremiumCalendarClient({ if (!result.success) { console.error('Failed to reschedule event:', result.error); + toast.error('Reschedule failed', result.error || 'Failed to reschedule event. Please try again.'); + logError( + new Error(result.error || 'Failed to reschedule event'), + { component: 'PremiumCalendarClient', action: 'drag-reschedule-event', sport: 'golf', eventId }, + 'high' + ); return; } router.refresh(); } catch (err) { console.error('Failed to reschedule event:', err); + toast.error('Reschedule failed', err instanceof Error ? err.message : 'Failed to reschedule event. Please try again.'); + logError( + err instanceof Error ? err : new Error(String(err)), + { component: 'PremiumCalendarClient', action: 'drag-reschedule-event', sport: 'golf', eventId }, + 'high' + ); } }; @@ -1406,6 +1461,11 @@ export function PremiumCalendarClient({ window.location.reload(); return; } + logError( + err instanceof Error ? err : new Error(msg), + { component: 'PremiumCalendarClient', action: 'save-event-mobile-sheet', sport: 'golf', isCreatingEvent }, + 'high' + ); throw err; } finally { setIsSavingEvent(false); @@ -1428,6 +1488,11 @@ export function PremiumCalendarClient({ window.location.reload(); return; } + logError( + err instanceof Error ? err : new Error(msg), + { component: 'PremiumCalendarClient', action: 'delete-event-mobile-sheet', sport: 'golf', eventId: selectedEvent.id }, + 'high' + ); throw err; } finally { setIsSavingEvent(false); diff --git a/src/components/lifting/groups/StrengthGroupsClient.tsx b/src/components/lifting/groups/StrengthGroupsClient.tsx index 38cc4d303..16aba5aae 100644 --- a/src/components/lifting/groups/StrengthGroupsClient.tsx +++ b/src/components/lifting/groups/StrengthGroupsClient.tsx @@ -12,6 +12,7 @@ import { useMemo, useState, useTransition } from 'react'; import { toast } from 'sonner'; import { motion, AnimatePresence, useReducedMotion } from 'framer-motion'; +import { logError } from '@/lib/error-logging'; import { Card, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -259,6 +260,11 @@ export function StrengthGroupsClient({ groups: initialGroups, athletes, orgId, c setActivePane('detail'); } else { setCreateError(result.error ?? 'Failed to create group.'); + logError( + new Error(result.error ?? 'Failed to create group'), + { component: 'StrengthGroupsClient', action: 'create-group', sport: 'golf', orgId }, + 'high' + ); } }); } @@ -277,6 +283,11 @@ export function StrengthGroupsClient({ groups: initialGroups, athletes, orgId, c ); } else { toast.error(result.error ?? 'Failed to add member.'); + logError( + new Error(result.error ?? 'Failed to add group member'), + { component: 'StrengthGroupsClient', action: 'add-group-member', sport: 'golf', orgId, groupId: selectedGroup.id, athleteId }, + 'high' + ); } }); } @@ -299,6 +310,11 @@ export function StrengthGroupsClient({ groups: initialGroups, athletes, orgId, c ); } else { toast.error(result.error ?? 'Failed to remove member.'); + logError( + new Error(result.error ?? 'Failed to remove group member'), + { component: 'StrengthGroupsClient', action: 'remove-group-member', sport: 'golf', orgId, groupId: selectedGroup.id, athleteId }, + 'high' + ); } }); } @@ -311,6 +327,11 @@ export function StrengthGroupsClient({ groups: initialGroups, athletes, orgId, c if (selectedGroupId === groupId) setSelectedGroupId(groups[0]?.id ?? null); } else { toast.error(result.error ?? 'Failed to archive group.'); + logError( + new Error(result.error ?? 'Failed to archive group'), + { component: 'StrengthGroupsClient', action: 'archive-group', sport: 'golf', orgId, groupId }, + 'high' + ); } }); } diff --git a/src/components/lifting/nutrition/NutritionPlanUploader.tsx b/src/components/lifting/nutrition/NutritionPlanUploader.tsx index c5a033531..0d6941440 100644 --- a/src/components/lifting/nutrition/NutritionPlanUploader.tsx +++ b/src/components/lifting/nutrition/NutritionPlanUploader.tsx @@ -18,6 +18,7 @@ import { useRef, useState, useTransition, type ChangeEvent, type FormEvent } from 'react'; import { motion, AnimatePresence, useReducedMotion } from 'framer-motion'; import { toast } from 'sonner'; +import { logError } from '@/lib/error-logging'; import { Card } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -292,6 +293,11 @@ export function NutritionPlanUploader({ orgId, onSuccess, onCancel }: Props) { if (!result.success) { toast.error(result.error ?? 'Failed to upload nutrition plan.'); + logError( + new Error(result.error ?? 'Failed to upload nutrition plan'), + { component: 'NutritionPlanUploader', action: 'upload-nutrition-plan', sport: 'golf', orgId, planType }, + 'high' + ); return; } @@ -300,6 +306,11 @@ export function NutritionPlanUploader({ orgId, onSuccess, onCancel }: Props) { } catch (err) { const msg = err instanceof Error ? err.message : 'Something went wrong.'; toast.error(msg); + logError( + err instanceof Error ? err : new Error(msg), + { component: 'NutritionPlanUploader', action: 'upload-nutrition-plan', sport: 'golf', orgId, planType }, + 'high' + ); } }); } diff --git a/src/components/lifting/sessions/LiveWeightRoomClient.tsx b/src/components/lifting/sessions/LiveWeightRoomClient.tsx index 50b642b8c..1c37be271 100644 --- a/src/components/lifting/sessions/LiveWeightRoomClient.tsx +++ b/src/components/lifting/sessions/LiveWeightRoomClient.tsx @@ -23,6 +23,7 @@ import { useRouter } from 'next/navigation'; import { motion, useReducedMotion } from 'framer-motion'; import { createClient } from '@/lib/supabase/client'; import { toast } from 'sonner'; +import { logError } from '@/lib/error-logging'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -265,6 +266,11 @@ export function LiveWeightRoomClient({ initialAthletes, orgId, canEdit, loading if (!result.success) { setAthletes(prev); toast.error('Failed to mark session complete.'); + logError( + new Error(result.error || 'Failed to advance session lifecycle'), + { component: 'LiveWeightRoomClient', action: 'advance-session-lifecycle', sport: 'golf', orgId, sessionId: athlete.session_id }, + 'high' + ); } }); } @@ -300,6 +306,11 @@ export function LiveWeightRoomClient({ initialAthletes, orgId, canEdit, loading if (!result.success) { setAthletes(prev); toast.error('Failed to log set.'); + logError( + new Error(result.error || 'Failed to log set result'), + { component: 'LiveWeightRoomClient', action: 'log-set-result', sport: 'golf', orgId, athleteId: athlete.athlete_id }, + 'high' + ); } else { toast.success('Set logged.'); setSetNum((n) => n + 1); diff --git a/src/components/ui/avatar-upload.tsx b/src/components/ui/avatar-upload.tsx index 383382069..e4438920f 100644 --- a/src/components/ui/avatar-upload.tsx +++ b/src/components/ui/avatar-upload.tsx @@ -2,6 +2,7 @@ import { useState, useRef } from 'react'; import { createClient } from '@/lib/supabase/client'; +import { logError } from '@/lib/error-logging'; import { Avatar } from '@/components/ui/avatar'; import { Button } from '@/components/fairway'; import { IconUpload, IconTrash, IconLoader, IconAlertCircle } from '@/components/icons'; @@ -92,6 +93,11 @@ export function AvatarUpload({ console.error('Upload error:', err); setError(err instanceof Error ? err.message : 'Failed to upload image'); setPreviewUrl(currentAvatarUrl || null); // Revert preview on error + logError( + err instanceof Error ? err : new Error(String(err)), + { component: 'AvatarUpload', action: 'upload-avatar', sport: 'shared', bucket }, + 'high' + ); } finally { setUploading(false); if (fileInputRef.current) { diff --git a/src/hooks/golf/use-golf-messages.ts b/src/hooks/golf/use-golf-messages.ts index 145608cda..cfdc49556 100644 --- a/src/hooks/golf/use-golf-messages.ts +++ b/src/hooks/golf/use-golf-messages.ts @@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { createClient } from '@/lib/supabase/client'; import { sendGolfMessage, markGolfMessagesAsRead, updateGolfMessage, deleteGolfMessage, getGolfActiveTeamConversationIds } from '@/app/golf/actions/messages'; import type { GolfMessageRow } from '@/lib/types'; +import { logError } from '@/lib/error-logging'; export interface GolfConversationParticipant { id: string; @@ -70,11 +71,19 @@ export function useGolfMessages(conversationId: string) { const fetchOtherParticipantReadStatus = useCallback(async () => { if (!conversationId || !currentUserId) return; - const { data: participants } = await supabase + const { data: participants, error: participantsError } = await supabase .from('golf_conversation_participants') .select('user_id, last_read_at') .eq('conversation_id', conversationId); + if (participantsError) { + logError( + new Error(participantsError.message), + { component: 'useGolfMessages', action: 'fetch-other-participant-read-status', sport: 'golf', conversationId }, + 'medium' + ); + } + if (participants) { const otherParticipant = participants.find(p => p.user_id !== currentUserId); if (otherParticipant) { @@ -108,6 +117,11 @@ export function useGolfMessages(conversationId: string) { // thread the user was already reading. if (fetchError) { console.error('[useGolfMessages] Failed to load messages:', fetchError); + logError( + new Error(fetchError.message), + { component: 'useGolfMessages', action: 'fetch-messages', sport: 'golf', conversationId }, + 'medium' + ); setError(true); setLoading(false); return; @@ -125,6 +139,11 @@ export function useGolfMessages(conversationId: string) { await markGolfMessagesAsRead(conversationId); } catch (err) { console.error('[useGolfMessages] Failed to mark messages as read:', err); + logError( + err instanceof Error ? err : new Error(String(err)), + { component: 'useGolfMessages', action: 'mark-messages-as-read', sport: 'golf', conversationId }, + 'medium' + ); } // Fetch read receipt status @@ -314,6 +333,11 @@ export function useGolfMessages(conversationId: string) { } catch (error) { // Roll back optimistic message on any error setMessages(prev => prev.filter(m => m.id !== optimisticId)); + logError( + error instanceof Error ? error : new Error(String(error)), + { component: 'useGolfMessages', action: 'send-message', sport: 'golf', conversationId }, + 'high' + ); throw error; } }; @@ -323,10 +347,20 @@ export function useGolfMessages(conversationId: string) { const result = await updateGolfMessage(messageId, newContent); if (result && 'error' in result && result.error) { + logError( + new Error(result.error), + { component: 'useGolfMessages', action: 'edit-message', sport: 'golf', conversationId, messageId }, + 'high' + ); throw new Error(result.error); } if (!result || !result.success) { + logError( + new Error('Failed to edit message'), + { component: 'useGolfMessages', action: 'edit-message', sport: 'golf', conversationId, messageId }, + 'high' + ); throw new Error('Failed to edit message'); } @@ -343,11 +377,21 @@ export function useGolfMessages(conversationId: string) { if (result && 'error' in result && result.error) { // Rollback: re-fetch messages on failure fetchMessages(); + logError( + new Error(result.error), + { component: 'useGolfMessages', action: 'delete-message', sport: 'golf', conversationId, messageId }, + 'high' + ); throw new Error(result.error); } if (!result || !result.success) { fetchMessages(); + logError( + new Error('Failed to delete message'), + { component: 'useGolfMessages', action: 'delete-message', sport: 'golf', conversationId, messageId }, + 'high' + ); throw new Error('Failed to delete message'); } @@ -434,8 +478,13 @@ export function useGolfConversations() { try { const allowedIds = await getGolfActiveTeamConversationIds(); if (allowedIds !== null) teamAllow = new Set(allowedIds); - } catch { + } catch (teamAllowErr) { teamAllow = null; + logError( + teamAllowErr instanceof Error ? teamAllowErr : new Error(String(teamAllowErr)), + { component: 'useGolfMessages', action: 'fetch-active-team-scope', sport: 'golf', userId }, + 'medium' + ); } let conversationsData = rawData as ConversationRow[] | null; @@ -445,7 +494,7 @@ export function useGolfConversations() { } // Also fetch team chat conversations directly (in case DB function doesn't include them) - const { data: groupConvs } = await supabase + const { data: groupConvs, error: groupConvsError } = await supabase .from('golf_conversation_participants') .select(` conversation:golf_conversations!inner( @@ -459,6 +508,14 @@ export function useGolfConversations() { `) .eq('user_id', userId); + if (groupConvsError) { + logError( + new Error(groupConvsError.message), + { component: 'useGolfConversations', action: 'fetch-team-chat-conversations', sport: 'golf', userId }, + 'medium' + ); + } + // Extract team chat conversations and merge them const groupConversations: ConversationRow[] = []; const existingIds = new Set(conversationsData?.map(c => c.id) || []); @@ -607,6 +664,11 @@ export function useGolfConversations() { // P257: a real backend failure (RPC error AND no rows recovered) must NOT // masquerade as an empty inbox. Flag it so the rail shows a recoverable // error with Retry instead of the cheerful "No conversations yet" empty. + logError( + error instanceof Error ? error : new Error(String((error as { message?: string })?.message ?? error)), + { component: 'useGolfConversations', action: 'fetch-conversations', sport: 'golf', userId }, + 'medium' + ); setError(true); setConversations([]); setLoading(false); diff --git a/src/hooks/golf/use-message-attachments.ts b/src/hooks/golf/use-message-attachments.ts index 5f423e7b5..4067cf2dc 100644 --- a/src/hooks/golf/use-message-attachments.ts +++ b/src/hooks/golf/use-message-attachments.ts @@ -11,6 +11,7 @@ import { sendGolfMessageWithAttachments, type AttachmentUploadData, } from '@/app/golf/actions/messages'; +import { logError } from '@/lib/error-logging'; interface SendMessageWithAttachmentsOptions { conversationId: string; @@ -62,6 +63,11 @@ export function useMessageAttachments() { ); if (!result.success) { + logError( + new Error(`Failed to upload ${attachment.file.name}: ${result.error}`), + { component: 'useMessageAttachments', action: 'upload-attachment', sport: 'golf', conversationId, fileName: attachment.file.name }, + 'high' + ); throw new Error(`Failed to upload ${attachment.file.name}: ${result.error}`); } @@ -92,20 +98,39 @@ export function useMessageAttachments() { ); if (!messageResult.success) { - // Message failed - clean up uploaded files - const supabase = createClient(); + logError( + new Error(messageResult.error || 'Failed to send message with attachments'), + { component: 'useMessageAttachments', action: 'send-message-with-attachments', sport: 'golf', conversationId }, + 'high' + ); + // Message failed - clean up uploaded files. Own try/catch so a failed + // cleanup can't mask the original send failure returned below. const pathsToDelete = uploadResults .filter((r) => r.storagePath) .map((r) => r.storagePath!); if (pathsToDelete.length > 0) { - await supabase.storage.from(STORAGE_BUCKET).remove(pathsToDelete); + try { + const supabase = createClient(); + await supabase.storage.from(STORAGE_BUCKET).remove(pathsToDelete); + } catch (cleanupErr) { + logError( + cleanupErr instanceof Error ? cleanupErr : new Error(String(cleanupErr)), + { component: 'useMessageAttachments', action: 'cleanup-orphaned-attachments', sport: 'golf', conversationId }, + 'medium' + ); + } } } return messageResult; } catch (err) { console.error('[useMessageAttachments] Error:', err); + logError( + err instanceof Error ? err : new Error(String(err)), + { component: 'useMessageAttachments', action: 'send-message-with-attachments', sport: 'golf', conversationId }, + 'high' + ); return { success: false, error: err instanceof Error ? err.message : 'Failed to send message with attachments', diff --git a/src/hooks/use-messages.ts b/src/hooks/use-messages.ts index 73fbafa64..75ed7c091 100644 --- a/src/hooks/use-messages.ts +++ b/src/hooks/use-messages.ts @@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { createClient } from '@/lib/supabase/client'; import { useAuthStore } from '@/stores/auth-store'; import { sendMessage as sendMessageAction, markMessagesAsRead } from '@/app/baseball/actions/messages'; +import { logError } from '@/lib/error-logging'; import type { Message } from '@/lib/types'; import type { ConversationWithMeta } from '@/lib/types/messages'; @@ -34,7 +35,7 @@ export function useMessages(conversationId: string) { // display order. The prior unbounded ascending fetch silently dropped the // newest messages once a thread passed 1000 rows (PostgREST's row cap) -- // mirrors src/hooks/golf/use-golf-messages.ts. - const { data } = await supabase + const { data, error: fetchError } = await supabase .from('baseball_messages') .select('id, conversation_id, sender_id, content, read, created_at') .eq('conversation_id', requestConversationId) @@ -46,6 +47,15 @@ export function useMessages(conversationId: string) { // thread's messages could paint over the one the user is now viewing. if (conversationIdRef.current !== requestConversationId) return; + if (fetchError) { + console.error('[useMessages] Failed to load messages:', fetchError); + logError( + new Error(fetchError.message || 'Failed to load messages'), + { component: 'useMessages', action: 'fetchMessages', sport: 'baseball' }, + 'medium' + ); + } + setMessages(((data || []) as Message[]).reverse()); setLoading(false); @@ -111,12 +121,23 @@ export function useMessages(conversationId: string) { // Without this check a rejected send (auth, validation, not-a-participant) // was reported as sent: the input cleared and no error surfaced (#450). if (!result.success) { - console.error('Error sending message:', 'error' in result ? result.error : 'Unknown error'); + const failureMessage = 'error' in result ? result.error : 'Unknown error'; + console.error('Error sending message:', failureMessage); + logError( + new Error(typeof failureMessage === 'string' ? failureMessage : 'Failed to send message'), + { component: 'useMessages', action: 'sendMessage', sport: 'baseball' }, + 'high' + ); return false; } return true; } catch (error) { console.error('Error sending message:', error); + logError( + error instanceof Error ? error : new Error('Failed to send message'), + { component: 'useMessages', action: 'sendMessage', sport: 'baseball' }, + 'high' + ); return false; } }; @@ -168,6 +189,11 @@ export function useConversations() { if (error) { console.error('Error fetching conversations:', error); + logError( + new Error((error as { message?: string })?.message || 'Failed to fetch conversations'), + { component: 'useMessages', action: 'fetchConversations', sport: 'baseball' }, + 'medium' + ); setConversations([]); setLoading(false); return; diff --git a/src/hooks/use-watchlist.ts b/src/hooks/use-watchlist.ts index 71b266679..ff152e360 100644 --- a/src/hooks/use-watchlist.ts +++ b/src/hooks/use-watchlist.ts @@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { createClient } from '@/lib/supabase/client'; import { useAuthStore } from '@/stores/auth-store'; import { toast } from '@/components/ui/sonner'; +import { logError } from '@/lib/error-logging'; import { addToWatchlist as addToWatchlistAction, removeFromWatchlist as removeFromWatchlistAction, @@ -27,7 +28,7 @@ export function useWatchlist() { setLoading(true); try { - const { data } = await supabase + const { data, error: fetchError } = await supabase .from('baseball_watchlists') .select(` id, @@ -45,9 +46,25 @@ export function useWatchlist() { .eq('coach_id', coach.id) .order('priority', { ascending: false }); + if (fetchError) { + console.error('[useWatchlist] fetchWatchlist failed:', fetchError); + logError( + new Error(fetchError.message || 'Failed to load watchlist'), + { component: 'useWatchlist', action: 'fetchWatchlist', sport: 'baseball' }, + 'medium' + ); + setWatchlist([]); + return; + } + setWatchlist((data || []) as WatchlistWithPlayer[]); } catch (err) { console.error('[useWatchlist] fetchWatchlist failed:', err); + logError( + err instanceof Error ? err : new Error('Failed to load watchlist'), + { component: 'useWatchlist', action: 'fetchWatchlist', sport: 'baseball' }, + 'medium' + ); setWatchlist([]); } finally { setLoading(false); @@ -69,6 +86,11 @@ export function useWatchlist() { if (!result.success) { toast.error('Failed to add player', result.message || 'Could not add player to watchlist. Please try again.'); + logError( + new Error(result.message || 'Failed to add player to watchlist'), + { component: 'useWatchlist', action: 'addToWatchlist', sport: 'baseball' }, + 'high' + ); return false; } @@ -87,6 +109,11 @@ export function useWatchlist() { if (!result.success) { toast.error('Failed to remove player', result.message || 'Could not remove player from watchlist. Please try again.'); + logError( + new Error(result.message || 'Failed to remove player from watchlist'), + { component: 'useWatchlist', action: 'removeFromWatchlist', sport: 'baseball' }, + 'high' + ); return false; } @@ -128,6 +155,11 @@ export function useWatchlist() { if (!result.success) { toast.error('Failed to update stage', result.error || 'Could not update pipeline stage. Please try again.'); + logError( + new Error(result.error || 'Failed to update pipeline stage'), + { component: 'useWatchlist', action: 'updateStage', sport: 'baseball' }, + 'high' + ); return false; } @@ -152,6 +184,11 @@ export function useWatchlist() { if (!result.success) { toast.error('Failed to update notes', result.error || 'Could not save notes. Please try again.'); + logError( + new Error(result.error || 'Failed to save watchlist notes'), + { component: 'useWatchlist', action: 'updateNotes', sport: 'baseball' }, + 'high' + ); return false; } diff --git a/src/instrumentation-client.ts b/src/instrumentation-client.ts index 821db46f3..f97db1f8f 100644 --- a/src/instrumentation-client.ts +++ b/src/instrumentation-client.ts @@ -82,12 +82,17 @@ Sentry.init({ delete locationCtx.referrer; } // Auto-tag sport from current pathname — makes "errors in /golf vs - // /baseball" a one-click filter in the Sentry UI. + // /baseball" a one-click filter in the Sentry UI. /admin and /lifting + // get their own buckets (rather than falling into 'marketing') since + // both carry meaningfully different error populations than the public + // marketing/landing surfaces. if (typeof window !== 'undefined') { const path = window.location.pathname; - const sport = path.startsWith('/golf') ? 'golf' + const sport = path.startsWith('/admin') ? 'admin' + : path.startsWith('/lifting') ? 'lifting' : path.startsWith('/baseball') ? 'baseball' - : 'platform'; + : path.startsWith('/golf') ? 'golf' + : 'marketing'; event.tags = { ...event.tags, sport }; } return event; diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 35601665f..a00dbb79c 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -1,4 +1,6 @@ import * as Sentry from '@sentry/nextjs'; +import { getAppBaseUrl } from '@/lib/app-base-url'; +import { isAlreadyBridgeLogged } from '@/lib/bridge-logged-marker'; const release = process.env.NEXT_PUBLIC_SENTRY_RELEASE || process.env.VERCEL_GIT_COMMIT_SHA; const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN?.trim() || process.env.SENTRY_DSN?.trim(); @@ -35,6 +37,16 @@ const sharedIgnoreErrors = [ 'PlayerAccessError', ]; +/** Best-effort per-app classification for RSC/route errors that never went through a sport-aware wrapper (those already scope.setTag('sport', ...) themselves). */ +function deriveSportFromUrl(url: string | undefined): 'baseball' | 'golf' | 'lifting' | 'admin' | 'marketing' { + const path = url?.split('?')[0] ?? ''; + if (/\/baseball(\/|$)/.test(path)) return 'baseball'; + if (/\/golf(\/|$)/.test(path)) return 'golf'; + if (/\/lifting(\/|$)/.test(path)) return 'lifting'; + if (/\/admin(\/|$)/.test(path)) return 'admin'; + return 'marketing'; +} + const scrubPii: Sentry.NodeOptions['beforeSend'] = (event) => { if (event.request) { delete event.request.cookies; @@ -48,6 +60,13 @@ const scrubPii: Sentry.NodeOptions['beforeSend'] = (event) => { event.request.url = event.request.url.split('?')[0]; } } + // Helm Bridge: tag every event with a sport so RSC/render errors (which + // never pass through logServerError's scope.setTag('sport', ...)) stay + // filterable per app in Sentry. Additive only — never overrides a tag a + // sport-aware wrapper already set. + if (!event.tags?.sport) { + event.tags = { ...event.tags, sport: deriveSportFromUrl(event.request?.url) }; + } return event; }; @@ -109,6 +128,8 @@ export async function register() { import('@/lib/admin/deploy-marker') .then((m) => m.recordDeployMarker()) .catch(() => {}); + + registerProcessErrorHandlers(); } if (process.env.NEXT_RUNTIME === 'edge') { @@ -134,5 +155,192 @@ export async function register() { } } -// Capture errors from nested React Server Components -export const onRequestError = Sentry.captureRequestError; +// Error *names* (not full messages) for the Baseball control-flow classes +// sharedIgnoreErrors already suppresses at the Sentry level. withBaseballAction +// classifies these as handled/expected and RE-RAISES so callers can branch — +// the re-raise escapes the server-action boundary and lands in onRequestError +// a second time. Sentry already ignores them (sharedIgnoreErrors above); this +// derives the same name list to skip the Bridge write too, so a re-raise +// never becomes a duplicate admin_events row. +const bridgeSkipErrorNames = new Set( + sharedIgnoreErrors.filter( + (entry): entry is string => + typeof entry === 'string' && (entry.startsWith('Baseball') || entry === 'PlayerAccessError'), + ), +); + +function isNextControlFlowDigest(error: unknown): boolean { + const digest = (error as { digest?: unknown } | null)?.digest; + return typeof digest === 'string' && (digest === 'DYNAMIC_SERVER_USAGE' || digest.startsWith('NEXT_')); +} + +function shouldSkipBridgeWrite(error: unknown, alreadyLogged: boolean): boolean { + if (isNextControlFlowDigest(error)) return true; + // Already went through logServerException/logServerError at the throw + // site (e.g. a golf CRM server action's `catch { logServerException(...); + // throw error; }`) and is now escaping to onRequestError a second time — + // skip the Bridge write so the same failure doesn't produce a duplicate + // error_logs/admin_events row (Sentry already has it too, via + // Sentry.captureRequestError above). + if (alreadyLogged) return true; + const name = error instanceof Error ? error.name : undefined; + return Boolean(name && bridgeSkipErrorNames.has(name)); +} + +// Next's actual routeType union is 'render' | 'route' | 'action' | 'proxy' +// (see node_modules/next/dist/server/instrumentation/types.d.ts); typed as +// plain string here so an unrecognized future value falls through to the +// 'server_component' default in mapRouteTypeToSource instead of a type error. +type OnRequestErrorRoutePath = string; + +function mapRouteTypeToSource( + routeType: OnRequestErrorRoutePath, +): 'server_component' | 'route_handler' | 'server_action' | 'request_hook' { + switch (routeType) { + case 'route': + return 'route_handler'; + case 'action': + return 'server_action'; + // Next 16 renamed middleware.ts → proxy.ts and routeType to match + // ('proxy'); 'middleware' kept for forward/back compatibility. + case 'proxy': + case 'middleware': + return 'request_hook'; + case 'render': + default: + return 'server_component'; + } +} + +type OnRequestErrorRequest = Readonly<{ + path: string; + method: string; + headers: Record; +}>; + +type OnRequestErrorContext = Readonly<{ + routerKind: 'Pages Router' | 'App Router'; + routePath: string; + routeType: OnRequestErrorRoutePath; + renderSource?: 'react-server-components' | 'react-server-components-payload' | 'server-rendering'; + revalidateReason?: 'on-demand' | 'stale'; +}>; + +// Capture errors from nested React Server Components. Sentry still gets +// every error first (unchanged behavior); the Helm Bridge write is strictly +// additive and can never affect Next's own error handling. +export async function onRequestError( + error: unknown, + request: OnRequestErrorRequest, + errorContext: OnRequestErrorContext, +): Promise { + Sentry.captureRequestError(error, request, errorContext); + + try { + // Dynamic import keeps server-error-logger (and its @/lib/supabase/admin + // dependency) out of the edge bundle — only resolved on the nodejs path. + // isAlreadyBridgeLogged has no node-only deps (see bridge-logged-marker.ts) + // so it's imported statically above and is safe to call on both paths. + const { logServerException } = + process.env.NEXT_RUNTIME === 'nodejs' + ? await import('@/lib/server-error-logger') + : { logServerException: undefined }; + + if (shouldSkipBridgeWrite(error, isAlreadyBridgeLogged(error))) return; + + const route = errorContext.routePath || request.path; + const source = mapRouteTypeToSource(errorContext.routeType); + + if (process.env.NEXT_RUNTIME === 'nodejs' && logServerException) { + await logServerException( + error, + { + action: route, + route, + source, + handled: false, + statusCode: 500, + runtime: 'nodejs', + // Sentry.captureRequestError already captured this exception above + // — logServerException's own internal Sentry.captureException call + // would otherwise produce a second, differently-fingerprinted + // Sentry issue for the same error. The Bridge DB write (error_logs + // + admin_events) still happens; only its Sentry capture is skipped. + skipSentry: true, + metadata: { + routerKind: errorContext.routerKind, + routeType: errorContext.routeType, + renderSource: errorContext.renderSource, + method: request.method, + }, + }, + 'error', + ); + } else if (process.env.NEXT_RUNTIME === 'edge') { + const key = process.env.INTERNAL_LOG_KEY; + if (!key) return; // silently skip — matches src/proxy.ts's guard + const normalized = error instanceof Error ? error : new Error(String(error)); + fetch(new URL('/api/internal/log-server-error', getAppBaseUrl()), { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-internal-log-key': key }, + body: JSON.stringify({ + message: normalized.message.slice(0, 2000), + stack: normalized.stack?.slice(0, 8000) ?? null, + name: normalized.name, + route, + routeType: errorContext.routeType, + routerKind: errorContext.routerKind, + method: request.method, + }), + }).catch(() => {}); + } + } catch { + // Bridge write must never break Next's own error handling. + } +} + +// --- process-level fallback: catches errors that never flow through Next's +// request lifecycle at all (detached timers, fire-and-forget promises, +// background job code). nodejs runtime only — process doesn't exist on edge. +let processHandlersRegistered = false; + +// Shared 20-writes/minute ceiling across both handlers so a rejection storm +// (e.g. a bad dependency spinning on a promise) can't flood admin_events. +const BRIDGE_PROCESS_WRITE_LIMIT = 20; +let bridgeProcessWindowStart = Date.now(); +let bridgeProcessWriteCount = 0; + +function allowBridgeProcessWrite(): boolean { + const now = Date.now(); + if (now - bridgeProcessWindowStart > 60_000) { + bridgeProcessWindowStart = now; + bridgeProcessWriteCount = 0; + } + bridgeProcessWriteCount += 1; + return bridgeProcessWriteCount <= BRIDGE_PROCESS_WRITE_LIMIT; +} + +function logProcessErrorToBridge(action: string, error: Error): void { + if (!allowBridgeProcessWrite()) return; + void import('@/lib/server-error-logger') + .then((m) => m.logServerException(error, { action, source: 'background_job', handled: false }, 'error')) + .catch(() => {}); +} + +function registerProcessErrorHandlers(): void { + if (processHandlersRegistered) return; + processHandlersRegistered = true; + + process.on('unhandledRejection', (reason) => { + const error = reason instanceof Error ? reason : new Error(String(reason)); + Sentry.captureException(error); + logProcessErrorToBridge('process.unhandledRejection', error); + }); + + process.on('uncaughtException', (error) => { + Sentry.captureException(error); + logProcessErrorToBridge('process.uncaughtException', error); + // Do NOT process.exit() or rethrow here — Sentry's own uncaughtException + // integration (wired via Sentry.init above) owns fatality/exit behavior. + }); +} diff --git a/src/lib/admin-logger-client.ts b/src/lib/admin-logger-client.ts index f4bb28d2b..690b1c8fd 100644 --- a/src/lib/admin-logger-client.ts +++ b/src/lib/admin-logger-client.ts @@ -120,142 +120,23 @@ async function logClientEvent(input: ClientEventInput): Promise { }); } -// ============================================ -// ERROR HELPERS -// ============================================ - -/** - * Log a client-side error - */ -async function logClientError( - error: Error | unknown, - context: { - title?: string; - severity?: AdminEventSeverity; - metadata?: Record; - } = {} -): Promise { - const err = error instanceof Error ? error : new Error(String(error)); - - return logClientEvent({ - eventType: 'error', - title: context.title ?? err.message.slice(0, 200), - severity: context.severity ?? 'error', - message: err.message, - stackTrace: err.stack, - metadata: { - ...context.metadata, - errorName: err.name, - errorMessage: err.message, - }, - }); -} - // ============================================ // GLOBAL ERROR HANDLER // ============================================ -let isGlobalHandlerSetup = false; -let pendingErrors: Array<{ error: Error; errorInfo?: { componentStack?: string } }> = []; -let flushTimeout: ReturnType | null = null; - /** - * Flush pending errors to the API - * Batches errors to reduce API calls - */ -async function flushPendingErrors() { - if (pendingErrors.length === 0) return; - - const errors = [...pendingErrors]; - pendingErrors = []; - - // Log each error (could be batched in future) - for (const { error, errorInfo } of errors) { - await logClientError(error, { - title: `Unhandled error: ${error.message.slice(0, 100)}`, - severity: 'error', - metadata: { - componentStack: errorInfo?.componentStack, - isUnhandled: true, - }, - }); - } -} - -/** - * Schedule a flush of pending errors - */ -function scheduleFlush() { - if (flushTimeout) return; - - flushTimeout = setTimeout(() => { - flushTimeout = null; - flushPendingErrors(); - }, 1000); // Batch errors for 1 second -} - -/** - * Add an error to the pending queue - */ -function queueError(error: Error, errorInfo?: { componentStack?: string }) { - pendingErrors.push({ error, errorInfo }); - scheduleFlush(); -} - -/** - * Setup global error handlers - * Call this once in your app's root layout + * No longer registers window 'error'/'unhandledrejection' listeners — those + * duplicated the canonical handlers in `@/lib/error-logging` + * (`setupGlobalErrorHandlers`, mounted via `GlobalErrorHandlerSetup` in the + * root layout for both golf and baseball), which classify richer context + * (chunk-load, hydration, stale-server-action) and already write to Sentry + + * error_logs. Two listener pairs meant every uncaught error and rejection + * was logged twice, to two different tables. Kept as an exported no-op + * (rather than deleted) because `AdminErrorHandler` still calls it alongside + * `trackPagePerformance`, which remains this module's live responsibility. */ export function setupGlobalErrorHandler() { - if (typeof window === 'undefined') return; - if (isGlobalHandlerSetup) return; - - isGlobalHandlerSetup = true; - - // Handle uncaught errors - window.addEventListener('error', (event) => { - // Ignore cross-origin script errors (no useful info) - if (!event.error) return; - - queueError(event.error); - }); - - // Handle unhandled promise rejections - window.addEventListener('unhandledrejection', (event) => { - const error = event.reason instanceof Error - ? event.reason - : new Error(String(event.reason)); - - queueError(error); - }); - - // Flush on page unload - window.addEventListener('beforeunload', () => { - if (pendingErrors.length > 0) { - // Use sendBeacon for reliability on page unload - const browserInfo = getBrowserInfo(); - for (const { error, errorInfo } of pendingErrors) { - const payload = JSON.stringify({ - eventType: 'error', - title: `Unhandled error: ${error.message.slice(0, 100)}`, - severity: 'error', - message: error.message, - stackTrace: error.stack, - url: window.location.href, - browserInfo, - metadata: { - componentStack: errorInfo?.componentStack, - isUnhandled: true, - isBeforeUnload: true, - }, - }); - navigator.sendBeacon('/api/admin/log-event', payload); - } - pendingErrors = []; - } - }); - - console.log('[AdminLoggerClient] Global error handler installed'); + // Intentionally empty — see comment above. } // ============================================ diff --git a/src/lib/admin/job-log.ts b/src/lib/admin/job-log.ts index 1db2b89ec..dde2825e9 100644 --- a/src/lib/admin/job-log.ts +++ b/src/lib/admin/job-log.ts @@ -21,6 +21,24 @@ export async function recordJobRun(jobType: string, fn: () => Promise): Pr const startedAt = new Date(); try { const result = await fn(); + // A resolved (not thrown) Response/NextResponse with a 4xx/5xx status is + // still a failed run — most cron routes catch internally and return an + // error JSON rather than throwing. clone() before reading so the caller + // (Vercel Cron / the HTTP layer) can still consume the original body. + if (result instanceof Response && result.status >= 400) { + const message = await extractResponseErrorLine(result); + await writeRow(jobType, 'failed', startedAt, message); + try { + void logServerEvent( + `Cron failed: ${jobType}`, + { action: `cron.${jobType}`, source: 'cron', errorDetails: message }, + 'error', + ).catch(() => {}); + } catch { + /* never mask the real failure */ + } + return result; + } await writeRow(jobType, 'completed', startedAt, null); return result; } catch (err) { @@ -39,6 +57,29 @@ export async function recordJobRun(jobType: string, fn: () => Promise): Pr } } +/** Best-effort short error line from a failed Response's body — never throws. */ +async function extractResponseErrorLine(response: Response): Promise { + try { + const clone = response.clone(); + const contentType = clone.headers.get('content-type') ?? ''; + if (contentType.includes('application/json')) { + const body: unknown = await clone.json(); + if (body && typeof body === 'object') { + const record = body as Record; + const candidate = record.error ?? record.message; + if (typeof candidate === 'string' && candidate.trim()) { + return candidate.slice(0, 2000); + } + } + return `HTTP ${response.status}`; + } + const text = await clone.text(); + return text.trim() ? text.slice(0, 2000) : `HTTP ${response.status}`; + } catch { + return `HTTP ${response.status}`; + } +} + async function writeRow( jobType: string, status: 'completed' | 'failed', diff --git a/src/lib/admin/rls-denial.ts b/src/lib/admin/rls-denial.ts index 76a157176..633d5bca2 100644 --- a/src/lib/admin/rls-denial.ts +++ b/src/lib/admin/rls-denial.ts @@ -17,6 +17,37 @@ export function isRlsDenial( return /row-level security/i.test(error.message ?? ''); } +const STORM_WINDOW_MS = 10 * 60 * 1000; +const STORM_THRESHOLD = 5; + +/** + * Per-instance, in-memory only — serverless functions don't share state + * across invocations or instances, so a storm spread thin across many + * cold-started instances under-counts here. Acceptable: this is a cheap + * single-instance tripwire for a hot loop hammering one denial, not an + * exact global counter (Sentry/admin_events volume itself is the + * cross-instance signal). + */ +const denialWindows = new Map(); + +/** Returns true when this call crosses STORM_THRESHOLD within the window, and resets the window. */ +function trackDenialStorm(key: string): boolean { + const now = Date.now(); + const existing = denialWindows.get(key); + if (!existing || now - existing.windowStart > STORM_WINDOW_MS) { + denialWindows.set(key, { count: 1, windowStart: now }); + return false; + } + existing.count += 1; + if (existing.count > STORM_THRESHOLD) { + // Reset so the storm tag fires once per crossing, not on every denial + // for the remainder of the original window. + denialWindows.set(key, { count: 0, windowStart: now }); + return true; + } + return false; +} + export function maybeCaptureRlsDenial( error: { code?: string | null; message?: string | null } | null | undefined, ctx: { @@ -32,6 +63,7 @@ export function maybeCaptureRlsDenial( if (!isRlsDenial(error)) return; try { const feature = ctx.feature ?? featureForTable(ctx.table) ?? undefined; + const isStorm = trackDenialStorm(`${ctx.table}:${ctx.verb}`); void logServerEvent( `RLS denial: ${ctx.verb} on ${ctx.table}`, { @@ -42,9 +74,12 @@ export function maybeCaptureRlsDenial( sport: ctx.sport, feature: feature ?? null, metadata: { table: ctx.table, verb: ctx.verb, message: error?.message ?? null }, - skipSentry: true, // operational telemetry — admin feed, not a Sentry issue + tags: isStorm ? { rls_denial_storm: 'true' } : undefined, + // Routine denials stay admin-feed-only; a storm crossing the + // threshold escalates to a real Sentry issue at 'error' severity. + skipSentry: !isStorm, }, - 'warning', + isStorm ? 'error' : 'warning', ).catch(() => {}); } catch { // Never break the caller. diff --git a/src/lib/api/with-route-handler.ts b/src/lib/api/with-route-handler.ts new file mode 100644 index 000000000..38adb5066 --- /dev/null +++ b/src/lib/api/with-route-handler.ts @@ -0,0 +1,85 @@ +// ============================================================================= +// src/lib/api/with-route-handler.ts +// +// withRouteHandler — the shared error-tracking wrapper for Next.js route +// handlers. It standardizes the one thing a silent API route always got +// wrong: an uncaught throw disappearing with nothing but a generic 500, +// invisible to both Sentry and the Helm Bridge admin feed. +// +// Contract: NEVER changes a route's success-path behavior. It only wraps +// the call — on a clean return (including early `return NextResponse.json( +// {...}, { status: 4xx })` branches inside the handler) the response passes +// straight through untouched. Only an actual thrown exception is caught, +// logged, and converted to a generic 500 JSON body. Next.js control-flow +// "errors" (redirect(), notFound(), etc. — digest starting with 'NEXT_') +// are re-thrown as-is so the framework can still handle them. +// +// This is NOT a 'use server' module — it's a plain helper imported by route +// handler files, which are already server-only. +// +// USAGE +// export const GET = withRouteHandler( +// 'golfPuttTendenciesApi.get', +// { sport: 'golf', featureArea: 'golf_putt_tendencies' }, +// async (request, { params }: { params: Promise<{ playerId: string }> }) => { +// ... +// return NextResponse.json(payload); +// }, +// ); +// ============================================================================= + +import { NextResponse } from 'next/server'; +import { logServerException } from '@/lib/server-error-logger'; + +export interface WithRouteHandlerOptions { + /** Defaults to 'route_handler'. Use 'cron' for scheduled/Inngest-triggered routes. */ + source?: 'route_handler' | 'cron'; + sport?: 'golf' | 'baseball' | 'shared'; + feature?: string; + featureArea?: string; + /** Defaults to 'error'. */ + severity?: 'warning' | 'error' | 'critical'; +} + +/** Next.js control-flow signals (redirect(), notFound()) are framework behavior, not incidents. */ +function isNextControlFlowError(error: unknown): boolean { + if (!error || typeof error !== 'object') return false; + const digest = (error as { digest?: unknown }).digest; + return typeof digest === 'string' && digest.startsWith('NEXT_'); +} + +/** + * Wrap a route handler so any uncaught throw is logged to Sentry + error_logs + * + admin_events before falling back to a generic 500 JSON response. `Req` is + * inferred from the handler you pass in (Request or NextRequest), and `Args` + * captures whatever additional context arg Next.js supplies (e.g. the + * `{ params }` object on dynamic routes). + */ +export function withRouteHandler( + name: string, + opts: WithRouteHandlerOptions, + handler: (request: Req, ...args: Args) => Promise, +): (request: Req, ...args: Args) => Promise { + return async (request: Req, ...args: Args): Promise => { + try { + return await handler(request, ...args); + } catch (error) { + if (isNextControlFlowError(error)) { + throw error; + } + + await logServerException(error, { + action: name, + route: new URL(request.url).pathname, + source: opts.source ?? 'route_handler', + handled: false, + statusCode: 500, + sport: opts.sport, + feature: opts.feature, + featureArea: opts.featureArea, + }, opts.severity ?? 'error'); + + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } + }; +} diff --git a/src/lib/baseball/coachhelm/outcome-sweep.ts b/src/lib/baseball/coachhelm/outcome-sweep.ts index 676295c59..6a15b84aa 100644 --- a/src/lib/baseball/coachhelm/outcome-sweep.ts +++ b/src/lib/baseball/coachhelm/outcome-sweep.ts @@ -49,6 +49,7 @@ import { type EngineEventRows, } from '@/lib/baseball/coachhelm/engine-event-derived'; import type { BaseballActionOutcomeVerdict } from '@/lib/types/baseball-coachhelm-v10'; +import { logServerError } from '@/lib/server-error-logger'; // A minimally-typed client so the sweep runs against either the RLS server // client or the service-role admin client (both expose `.from`). RLS still @@ -181,6 +182,11 @@ export async function sweepActionOutcomes( const nowIso = new Date().toISOString(); let measured = 0; + // Rows whose outcome UPDATE failed this pass. Never thrown per-row (one bad + // action must not abort measuring the rest of the batch) — accumulated and + // reported as ONE roll-up trace after the loop instead of silently dropped. + let failed = 0; + const failureSamples: string[] = []; // Signal ids whose linked action's target metric IMPROVED this pass — used to // transition the SOURCE insight's lifecycle_state -> 'resolved' (orthogonal to // the coach's own status). This closes source → signal → action → outcome: @@ -250,9 +256,31 @@ export async function sweepActionOutcomes( if (!error) { measured += 1; if (verdict === 'improved' && a.signal_id) improvedSignalIds.add(a.signal_id); + } else { + failed += 1; + if (failureSamples.length < 5) { + failureSamples.push( + `${a.id}: ${(error as { message?: string }).message ?? 'unknown error'}`, + ); + } } } + if (failed > 0) { + await logServerError( + `outcome-sweep: ${failed} of ${todo.length} action outcome write(s) failed for team ${teamId}`, + { + action: 'sweepActionOutcomes', + sport: 'baseball', + source: 'background_job', + skipSentry: true, + teamId, + metadata: { failed, sample: failureSamples }, + }, + 'warning', + ); + } + // RESOLVE SOURCE INSIGHTS for actions whose target metric improved. The link // chain is action.signal_id → baseball_signals.id, and a signal shares its // dedupe_key with the originating baseball_coach_insights row. We resolve the diff --git a/src/lib/baseball/daily-contract/missed-sweep.ts b/src/lib/baseball/daily-contract/missed-sweep.ts index 3033140db..e4f9e137a 100644 --- a/src/lib/baseball/daily-contract/missed-sweep.ts +++ b/src/lib/baseball/daily-contract/missed-sweep.ts @@ -52,6 +52,7 @@ import { fetchAllRowsResult } from '@/lib/supabase/fetch-all-rows'; import type { DailyContractItem } from '@/lib/types/baseball-passport'; +import { logServerError } from '@/lib/server-error-logger'; // isoMinusDays now lives in the shared contract-day module (killing the former // triple-duplication across the read-models + this sweep). Re-exported here so @@ -199,6 +200,11 @@ export async function sweepMissedContracts( const nowIso = new Date().toISOString(); let missed = 0; let timelineWritten = 0; + // Best-effort timeline echo failures — never rethrown per-row (the status + // transition already stands), accumulated and reported as ONE roll-up trace + // after the loop instead of silently dropped. + let timelineFailed = 0; + const failureSamples: string[] = []; for (const row of candidates) { // Non-destructive single-row UPDATE: status + missed_at only. We also re-assert @@ -238,10 +244,29 @@ export async function sweepMissedContracts( occurredAt: `${row.contract_date}T23:59:59.000Z`, }); if (res.ok) timelineWritten += 1; - } catch { + } catch (err) { // Swallowed by design (best-effort). The status transition stands. + timelineFailed += 1; + if (failureSamples.length < 5) { + failureSamples.push(`${row.id}: ${err instanceof Error ? err.message : String(err)}`); + } } } + if (timelineFailed > 0) { + await logServerError( + `missed-sweep: ${timelineFailed} timeline echo write(s) failed for team ${teamId}`, + { + action: 'sweepMissedContracts', + sport: 'baseball', + source: 'background_job', + skipSentry: true, + teamId, + metadata: { failed: timelineFailed, sample: failureSamples }, + }, + 'warning', + ); + } + return { evaluated: candidates.length, missed, timelineWritten }; } diff --git a/src/lib/baseball/tasks/reminder-sweep.ts b/src/lib/baseball/tasks/reminder-sweep.ts index 439657a28..f4147e4d4 100644 --- a/src/lib/baseball/tasks/reminder-sweep.ts +++ b/src/lib/baseball/tasks/reminder-sweep.ts @@ -47,6 +47,8 @@ // it is unit-testable with an in-memory fake and runs identically under the cron. // ============================================================================= +import { logServerError } from '@/lib/server-error-logger'; + // A minimally-typed client so the sweep runs against the service-role admin // client (the trusted Inngest cron) the same way missed-sweep.ts does. Every // query is scoped by team_id; the admin client is the only RLS-bypass path, @@ -182,6 +184,11 @@ export async function sweepTaskReminders( let reminded = 0; let delivered = 0; let skippedClosed = 0; + // Best-effort per-assignee delivery failures — never rethrown (the flag flip + // proceeds independently), accumulated and reported as ONE roll-up trace after + // the loop instead of silently dropped. + let deliveryFailed = 0; + const failureSamples: string[] = []; for (const row of rows) { // Closed tasks: flag (so they drop out of future sweeps) but never nag. @@ -241,8 +248,12 @@ export async function sweepTaskReminders( delivered += 1; deliveredThisTask += 1; } - } catch { + } catch (err) { // Swallowed by design (best-effort). The flag flip below still proceeds. + deliveryFailed += 1; + if (failureSamples.length < 5) { + failureSamples.push(`${row.id}/${playerId}: ${err instanceof Error ? err.message : String(err)}`); + } } } @@ -275,5 +286,20 @@ export async function sweepTaskReminders( reminded += 1; } + if (deliveryFailed > 0) { + await logServerError( + `reminder-sweep: ${deliveryFailed} per-assignee timeline delivery failure(s) for team ${teamId}`, + { + action: 'sweepTaskReminders', + sport: 'baseball', + source: 'background_job', + skipSentry: true, + teamId, + metadata: { failed: deliveryFailed, sample: failureSamples }, + }, + 'warning', + ); + } + return { reminded, delivered, skippedClosed }; } diff --git a/src/lib/bridge-logged-marker.ts b/src/lib/bridge-logged-marker.ts new file mode 100644 index 000000000..66414b73b --- /dev/null +++ b/src/lib/bridge-logged-marker.ts @@ -0,0 +1,34 @@ +/** + * Marker property set on an Error instance once it has gone through + * logServerException, so instrumentation.ts's onRequestError (which sees the + * same rethrown Error object escape the server-action/route boundary) can + * skip writing a duplicate error_logs/admin_events/Sentry entry for it. + * Non-enumerable so it never leaks into JSON.stringify'd error payloads. + * + * Lives in its own module (NOT server-error-logger) because that file is + * 'use server' — every export there must be an async server action, and + * exporting these sync helpers from it broke the production build + * ("Server Actions must be async functions"; same class of bug previously + * fixed for shouldPersistAdminTables — see telemetry-gate.ts). + * + * Also has zero node-only dependencies, so instrumentation.ts can import it + * statically on both the edge and nodejs paths instead of needing a runtime + * guard just for this check. + */ +const BRIDGE_LOGGED_MARKER = '__helmBridgeLogged'; + +export function isAlreadyBridgeLogged(error: unknown): boolean { + return Boolean(error && typeof error === 'object' && (error as Record)[BRIDGE_LOGGED_MARKER] === true); +} + +export function markBridgeLogged(error: Error): void { + try { + Object.defineProperty(error, BRIDGE_LOGGED_MARKER, { + value: true, + enumerable: false, + configurable: true, + }); + } catch { + // Frozen/sealed error objects — best effort only, never throw here. + } +} diff --git a/src/lib/error-logging.ts b/src/lib/error-logging.ts index 70592b056..f7bd3f928 100644 --- a/src/lib/error-logging.ts +++ b/src/lib/error-logging.ts @@ -282,27 +282,77 @@ export function logError( sendToMonitoringService(logEntry); } +/** 429 (rate-limited) and 5xx (backend fault) are worth retrying; other 4xx are not — the + * request itself is malformed/rejected and a retry or beacon fallback would just repeat it. */ +function isRetryableStatus(status: number): boolean { + return status === 429 || status >= 500; +} + /** * Send error to external monitoring service - * Replace this with your actual error monitoring service integration + * + * Lossless-effort delivery: a plain `fetch().catch(() => {})` drops the report + * the moment the network hiccups or the tab is backgrounded mid-request, which + * is exactly when we most need the report. `keepalive` lets the request outlive + * a navigating/closing page; one retry absorbs a transient 429/5xx or network + * blip; `sendBeacon` is the last-resort transport that the browser guarantees + * to attempt even during unload, when `fetch` itself can be aborted outright. + * + * This path must never recurse into `logError` (that would risk an infinite + * loop reporting its own delivery failures) and must never throw — the worst + * outcome here is a `console.error`, not a crashed caller. */ function sendToMonitoringService(logEntry: ErrorLogEntry): void { if (typeof window === 'undefined') return; - fetch('/api/log-error', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - message: logEntry.error.message, - stack: logEntry.error.stack, - context: logEntry.context, - severity: logEntry.severity, - timestamp: logEntry.timestamp, - url: window.location.href, - }), - }).catch(() => { - // Silently fail - don't let logging errors break the app + const body = JSON.stringify({ + message: logEntry.error.message, + stack: logEntry.error.stack, + context: logEntry.context, + severity: logEntry.severity, + timestamp: logEntry.timestamp, + url: window.location.href, }); + + const fallbackToBeacon = (): void => { + try { + if (typeof navigator.sendBeacon === 'function') { + const delivered = navigator.sendBeacon('/api/log-error', new Blob([body], { type: 'application/json' })); + if (delivered) return; + } + } catch { + // fall through to the console.error below + } + console.error('[error-logging] failed to deliver error report to /api/log-error'); + }; + + const post = (isRetry: boolean): void => { + fetch('/api/log-error', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + keepalive: true, + }) + .then((response) => { + if (response.ok || !isRetryableStatus(response.status)) return; + if (isRetry) { + fallbackToBeacon(); + } else { + setTimeout(() => post(true), 2000); + } + }) + .catch(() => { + // Network-level failure (offline, DNS, aborted) — treat the same as a + // retryable status: one retry, then the beacon fallback. + if (isRetry) { + fallbackToBeacon(); + } else { + setTimeout(() => post(true), 2000); + } + }); + }; + + post(false); } /** @@ -384,6 +434,43 @@ export function softReloadForStaleServerAction(): void { }); } +/** + * Mirrors the chunk-load detection in `RouteErrorBoundary` (not exported there, + * so duplicated here rather than imported) — a stale deployment reference is + * a distinct, non-actionable failure mode worth tagging separately from a + * generic runtime error. + */ +function isChunkLoadErrorMessage(message: string): boolean { + const msg = message.toLowerCase(); + return ( + msg.includes('loading chunk') || + msg.includes('loading css chunk') || + msg.includes('chunkloaderror') || + (msg.includes('cannot read properties of undefined') && msg.includes("'call'")) + ); +} + +/** React hydration mismatches, including the minified production error codes + * (#418/#419/#421/#425) that replace the descriptive dev-mode message. */ +function isHydrationErrorMessage(message: string): boolean { + const msg = message.toLowerCase(); + return ( + msg.includes('hydration failed') || + msg.includes('while hydrating') || + msg.includes('text content does not match server-rendered html') || + msg.includes('#418') || + msg.includes('#419') || + msg.includes('#421') || + msg.includes('#425') + ); +} + +function classifyGlobalErrorKind(message: string): 'chunk_load' | 'hydration' | undefined { + if (isChunkLoadErrorMessage(message)) return 'chunk_load'; + if (isHydrationErrorMessage(message)) return 'hydration'; + return undefined; +} + /** * Capture and log unhandled promise rejections */ @@ -402,12 +489,14 @@ export function setupGlobalErrorHandlers(): void { return; } + const errorKind = classifyGlobalErrorKind(reasonMessage); logError( new Error(reasonMessage || 'Unhandled Promise Rejection'), { component: 'GlobalErrorHandler', action: 'unhandledrejection', reason, + ...(errorKind ? { errorKind } : {}), }, 'high' ); @@ -420,6 +509,7 @@ export function setupGlobalErrorHandlers(): void { softReloadForStaleServerAction(); return; } + const errorKind = classifyGlobalErrorKind(err.message || event.message || ''); logError( err, { @@ -428,6 +518,7 @@ export function setupGlobalErrorHandlers(): void { filename: event.filename, lineno: event.lineno, colno: event.colno, + ...(errorKind ? { errorKind } : {}), }, 'high' ); diff --git a/src/lib/inngest/functions.ts b/src/lib/inngest/functions.ts index 25ba7d20b..5a6ab74ab 100644 --- a/src/lib/inngest/functions.ts +++ b/src/lib/inngest/functions.ts @@ -1,5 +1,6 @@ import type { InngestFunction } from 'inngest'; import { inngest } from './client'; +import { logServerException } from '@/lib/server-error-logger'; /** * Inngest function registry. @@ -17,8 +18,36 @@ import { inngest } from './client'; * Pattern: every step is its own retry boundary. Wrap any I/O * (Supabase query, third-party API, file write) in `step.run()` so a * transient failure only retries the step, not the whole function. + * + * Bridge convention: wrap the handler body in `withBridgeLogging(id, ...)` + * so every failed attempt lands in error_logs/admin_events (via + * logServerException, at 'warning' severity + skipSentry — expected retry + * noise until exhaustion) before rethrowing — the throw is preserved so + * Inngest's own step/function retry logic still sees it. Additionally + * set `onFailure` in the function config so the FINAL failure (after + * retries are exhausted) gets its own 'error' severity, Sentry-visible + * Bridge entry, distinguishable from the per-attempt ones above. This + * keeps a default multi-attempt retry policy from turning one persistent + * root cause into N Sentry issues (one per attempt) plus a final one. */ +/** + * Logs an in-flight function failure to the Bridge (error_logs + admin_events + * only — Sentry is deliberately skipped here, see module doc comment above) + * and rethrows unchanged so Inngest's retry policy is unaffected. Awaited + * (not fire-and-forget): logServerException never throws on its own logging + * failures, so awaiting it here cannot change whether/what this function + * throws — it just orders the log write before the retry-triggering throw. + */ +async function withBridgeLogging(fnId: string, run: () => Promise): Promise { + try { + return await run(); + } catch (err) { + await logServerException(err, { action: fnId, source: 'background_job', skipSentry: true }, 'warning'); + throw err; + } +} + /** * Example: scheduled health ping. Replace with a real workflow when * migrating one of the v3 backfills (W12/W20/W27/W33/W35). @@ -27,17 +56,21 @@ export const weeklyHealthPing: InngestFunction.Any = inngest.createFunction( { id: 'weekly-health-ping', triggers: [{ cron: '0 14 * * 1' }], // Mondays 14:00 UTC = 10:00 ET + onFailure: async ({ error }: { error: Error }) => { + await logServerException(error, { action: 'weekly-health-ping', source: 'background_job' }, 'error'); + }, }, - async ({ step, logger }) => { - const ok = await step.run('ping-self', async () => { - // Replace with a real health check (Supabase ping, Vercel - // deployment status, CoachHelm budget read, etc.). - logger.info('inngest weekly ping'); - return true; - }); + async ({ step, logger }) => + withBridgeLogging('weekly-health-ping', async () => { + const ok = await step.run('ping-self', async () => { + // Replace with a real health check (Supabase ping, Vercel + // deployment status, CoachHelm budget read, etc.). + logger.info('inngest weekly ping'); + return true; + }); - return { ok }; - }, + return { ok }; + }), ); /** diff --git a/src/lib/server-error-logger.ts b/src/lib/server-error-logger.ts index 1231dfe71..7345b4041 100644 --- a/src/lib/server-error-logger.ts +++ b/src/lib/server-error-logger.ts @@ -6,6 +6,7 @@ import { createAdminClient } from '@/lib/supabase/admin'; import type { Json } from '@/lib/types/database'; import { buildIncidentSignature, type IncidentSeverity } from '@/lib/admin/incident-grouping'; import { classifyTraceSurface } from '@/lib/error-trace-classification'; +import { markBridgeLogged } from '@/lib/bridge-logged-marker'; export type ServerTraceSeverity = 'info' | 'warning' | 'error' | 'critical'; export type ServerTraceSource = @@ -172,6 +173,25 @@ function enrichTraceContext(message: string, context: RoundErrorContext): RoundE }; } +// Ceiling on 'bridge_write_failed' Sentry alerts (mirrors instrumentation.ts's +// BRIDGE_PROCESS_WRITE_LIMIT for the process-level fallback path). Without +// this, a Supabase outage — the exact scenario the Bridge exists to surface — +// turns every failed error_logs/admin_events insert across the whole app +// into its own unbounded Sentry issue. +const BRIDGE_WRITE_FAILURE_ALERT_LIMIT = 5; +let bridgeWriteFailureWindowStart = Date.now(); +let bridgeWriteFailureCount = 0; + +function allowBridgeWriteFailureAlert(): boolean { + const now = Date.now(); + if (now - bridgeWriteFailureWindowStart > 60_000) { + bridgeWriteFailureWindowStart = now; + bridgeWriteFailureCount = 0; + } + bridgeWriteFailureCount += 1; + return bridgeWriteFailureCount <= BRIDGE_WRITE_FAILURE_ALERT_LIMIT; +} + async function writeAdminTables( message: string, error: Error | null, @@ -223,7 +243,34 @@ async function writeAdminTables( feature: enriched.feature ?? enriched.featureArea ?? null, }); - await Promise.allSettled([errorLogInsert, adminEventInsert]); + const [errorLogResult, adminEventResult] = await Promise.allSettled([errorLogInsert, adminEventInsert]); + + // The Bridge's own persistence must never depend on itself to notice it's + // failing — this only ever falls through to console + Sentry, never back + // into logServerError/logServerException (that would recurse). + for (const [table, result] of [ + ['error_logs', errorLogResult], + ['admin_events', adminEventResult], + ] as const) { + const writeError = + result.status === 'rejected' + ? result.reason + : (result.value as { error?: { code?: string; message?: string } } | undefined)?.error; + if (!writeError) continue; + + const code = (writeError as { code?: string })?.code ?? null; + const failureMessage = (writeError as { message?: string })?.message ?? String(writeError); + console.error('[ServerErrorLogger] Bridge write failed', { table, code, message: failureMessage }); + if (allowBridgeWriteFailureAlert()) { + Sentry.captureMessage('bridge_write_failed', { + level: 'error', + tags: { table }, + // Stable fingerprint (not per-message) so repeated failures during + // an outage collapse into one Sentry issue instead of fanning out. + fingerprint: ['bridge_write_failed', table], + }); + } + } } /** Static Sentry title — user/error copy lives in scope context, not the format string. */ @@ -376,6 +423,12 @@ export async function logServerException( // Caller explicitly handed us an Error — preserve the exception path so // the stack trace is captured even at warning severity. await captureServerTrace(normalizedError.message, context, severity, normalizedError, true); + // Mark the ORIGINAL error object (not normalizedError, which may be a + // freshly-constructed wrapper) so a subsequent `throw error;` by the + // caller is recognized by onRequestError as already-logged. + if (error instanceof Error) { + markBridgeLogged(error); + } } /** diff --git a/src/lib/supabase/middleware.ts b/src/lib/supabase/middleware.ts index 00e6197c7..162225e5c 100644 --- a/src/lib/supabase/middleware.ts +++ b/src/lib/supabase/middleware.ts @@ -283,6 +283,7 @@ async function checkRouteAuthorization( user: { id: string }, pathname: string, activeTeamCookie: string | null, + requestUrl: string, ): Promise<{ authorized: boolean; redirectTo?: string }> { const isRecruitingRoute = pathStartsWithAny(pathname, RECRUITING_ROUTES); const isOrgRoute = pathStartsWithAny(pathname, ORG_ROUTES); @@ -306,6 +307,24 @@ async function checkRouteAuthorization( .eq('user_id', user.id) .maybeSingle(); + if (error) { + // maybeSingle() resolves "no row" as { data: null, error: null }, so a + // truthy error here is always a genuine query failure (not a missing + // profile) that was otherwise silently folded into the same PLAYER_HOME + // redirect below. Best-effort visibility only — routing is unchanged. + const key = process.env.INTERNAL_LOG_KEY; + if (key) { + fetch(new URL('/api/internal/log-auth-failure', requestUrl), { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-internal-log-key': key }, + body: JSON.stringify({ + message: `checkRouteAuthorization baseball_coaches query failed: ${error.message}`.slice(0, 2000), + pathname, + }), + }).catch(() => {}); + } + } + if (error || !coach) { return { authorized: false, redirectTo: PLAYER_HOME }; } @@ -531,8 +550,21 @@ export async function updateSession(request: NextRequest) { // Local scope: clear cookies without a GoTrue round-trip — middleware // must stay fast and must not depend on auth-server reachability. await supabase.auth.signOut({ scope: 'local' }); - } catch { - /* fall through — the explicit cookie clears below still sign them out */ + } catch (signOutError) { + // fall through — the explicit cookie clears below still sign them out + const message = signOutError instanceof Error ? signOutError.message : String(signOutError); + console.warn('[Middleware] idle-timeout signOut failed:', message); + const key = process.env.INTERNAL_LOG_KEY; + if (key) { + fetch(new URL('/api/internal/log-auth-failure', request.url), { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-internal-log-key': key }, + body: JSON.stringify({ + message: `idle-timeout signOut failed: ${message}`.slice(0, 2000), + pathname, + }), + }).catch(() => {}); + } } const loginUrl = request.nextUrl.clone(); @@ -603,6 +635,7 @@ export async function updateSession(request: NextRequest) { user, pathname, activeTeamCookie, + request.url, ); if (!authResult.authorized && authResult.redirectTo) { return NextResponse.redirect( diff --git a/src/lib/telemetry-gate.ts b/src/lib/telemetry-gate.ts index bf94617e0..d6dcc2f98 100644 --- a/src/lib/telemetry-gate.ts +++ b/src/lib/telemetry-gate.ts @@ -19,6 +19,17 @@ * GITHUB_ACTIONS are asserted explicitly below so a stray VERCEL_ENV value * can never smuggle a CI run past the gate. * + * "Every error recorded" now extends to preview deployments on demand: + * ADMIN_EVENTS_CAPTURE_PREVIEW=1 opts a Vercel preview deployment into + * persistence, for rehearsing the pipeline against a real preview before a + * prod rollout. This is strictly additive and narrower than + * ADMIN_EVENTS_FORCE_CAPTURE — it only ever flips preview on, and the CI/ + * GITHUB_ACTIONS and NEXT_PHASE guards above still win absolutely (checked + * first, unconditionally). Persisted preview rows are distinguishable from + * production rows via the `runtimeEnv: 'preview'` metadata tag + * (getRuntimeEnv() below), so they never get mistaken for prod incidents + * in the Bridge. + * * Lives in its own module (NOT server-error-logger) because that file is * 'use server' — every export there must be an async server action, and * exporting this sync helper from it broke the production build @@ -30,8 +41,20 @@ export function shouldPersistAdminTables(): boolean { // GitHub Actions sets both CI=true and GITHUB_ACTIONS=true on every job // runner — never persist from there, independent of whatever VERCEL_ENV // happens to read (it's normally unset in CI, but this must not depend - // on that staying true). + // on that staying true). This check, and NEXT_PHASE above, must run + // before the preview opt-in below so neither guard can be bypassed by it. if (process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true') return false; + // Deliberate opt-in to rehearse the pipeline against a real preview + // deployment. Requires both the flag and an actual Vercel preview env — + // a local machine can't set VERCEL_ENV=preview without deliberately + // faking it, so this can't be smuggled in the way a bare VERCEL_ENV + // check could. + if ( + process.env.ADMIN_EVENTS_CAPTURE_PREVIEW === '1' && + process.env.VERCEL_ENV === 'preview' + ) { + return true; + } // A VERCEL_ENV that exists but isn't 'production' (preview, or a local // override) is excluded explicitly rather than falling through. if (process.env.VERCEL_ENV && process.env.VERCEL_ENV !== 'production') return false; diff --git a/src/stores/auth-store.ts b/src/stores/auth-store.ts index eb02e43be..d4efe8cbb 100644 --- a/src/stores/auth-store.ts +++ b/src/stores/auth-store.ts @@ -2,10 +2,28 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; +import * as Sentry from '@sentry/nextjs'; import type { User, CoachWithOrganization, Player } from '@/lib/types'; type CoachMode = 'recruiting' | 'team'; +// Sentry identity is wired here (rather than in each consuming hook) so ANY +// caller of this shared store gets equivalent user/role/sport tagging — +// baseball's use-auth.ts today, golf if/when it adopts the store. `user` is +// the generic `users` row (shared across both apps), so sport can't be +// inferred from which sport-specific table a coach/player came from — it's +// resolved from the current pathname instead, same heuristic +// instrumentation-client.ts uses for event tagging. Calling Sentry.setUser +// again with the same id (e.g. use-auth.ts's own setUser call right after +// this store's) is idempotent — last call wins, no conflicting state. +function currentSportTag(): 'golf' | 'baseball' | 'shared' { + if (typeof window === 'undefined') return 'shared'; + const path = window.location.pathname; + if (path.startsWith('/golf')) return 'golf'; + if (path.startsWith('/baseball')) return 'baseball'; + return 'shared'; +} + interface AuthState { user: User | null; coach: CoachWithOrganization | null; @@ -28,14 +46,37 @@ export const useAuthStore = create()( player: null, loading: false, coachMode: 'recruiting', - setUser: (user) => set({ user }), - setCoach: (coach) => set({ coach }), - setPlayer: (player) => set({ player }), + setUser: (user) => { + set({ user }); + if (user) { + // Email intentionally omitted — id is enough for triage without + // shipping personal contact info to Sentry. + Sentry.setUser({ id: user.id }); + Sentry.setTag('sport', currentSportTag()); + } + }, + setCoach: (coach) => { + set({ coach }); + if (coach) { + Sentry.setTag('user_role', 'coach'); + if (coach.organization?.id) { + Sentry.setTag('org_id', coach.organization.id); + Sentry.setTag('org_name', coach.organization.name); + } + } + }, + setPlayer: (player) => { + set({ player }); + if (player) Sentry.setTag('user_role', 'player'); + }, setLoading: (loading) => set({ loading }), setCoachMode: (mode) => { set({ coachMode: mode }); }, - clear: () => set({ user: null, coach: null, player: null }), + clear: () => { + set({ user: null, coach: null, player: null }); + Sentry.setUser(null); + }, }), { name: 'auth-storage',