Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .coderabbit/semgrep/helmv3.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)
# -----------------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion src/app/admin/errors/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'] },
];
Expand Down
14 changes: 13 additions & 1 deletion src/app/api/baseball/staff/context/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -141,8 +142,19 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
};

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 });
}
}
15 changes: 13 additions & 2 deletions src/app/api/calendar/coach/[token]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -28,7 +29,7 @@ interface CoachStaffRow {
}

export async function GET(
_request: NextRequest,
request: NextRequest,
{ params }: { params: Promise<{ token: string }> }
) {
try {
Expand Down Expand Up @@ -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 });
}
}
25 changes: 23 additions & 2 deletions src/app/api/calendar/feeds/[token]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 });
}

Expand All @@ -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 });
}
}
31 changes: 30 additions & 1 deletion src/app/api/crm/google-calendar/sync/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ function buildEventDescription(event: Record<string, unknown>): 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();
Expand All @@ -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 });
}

Expand Down Expand Up @@ -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')
Expand Down
10 changes: 10 additions & 0 deletions src/app/api/cron/admin-digest/route.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 });
});
}
27 changes: 25 additions & 2 deletions src/app/api/cron/process-sequences/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -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;
Expand All @@ -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 };
}

Expand Down Expand Up @@ -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' };
Expand Down Expand Up @@ -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,
Expand Down
37 changes: 31 additions & 6 deletions src/app/api/cron/v3/genome-backfill/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<NextResponse> {
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;
Expand All @@ -61,16 +72,30 @@ async function handle(): Promise<NextResponse> {
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);
}
Loading
Loading