diff --git a/scripts/seed-course-library-scorecards.ts b/scripts/seed-course-library-scorecards.ts new file mode 100644 index 000000000..301750403 --- /dev/null +++ b/scripts/seed-course-library-scorecards.ts @@ -0,0 +1,414 @@ +/** + * seed-course-library-scorecards.ts — Fixes #913 (part 1/3, DATA). + * + * PROBLEM: every one of the 20 cloud-library courses that has no human + * creator (`golf_courses.created_by_user_id IS NULL` — a bulk-seeded shared + * row, not a coach's own contribution) currently points at the SAME cloned + * 3-tee scorecard: Championship 7,165 yds / 73.6 rating / 140 slope, Members + * 6,455 / 71.2 / 132, Forward 5,450 / 69.0 / 120 — identical hole-by-hole + * pars, yardages, AND stroke indexes across every course, regardless of the + * real facility. (Confirmed live via `mcp__supabase__execute_sql` against + * the current DB on 2026-07-17 — see the PR body for the full course list + * and per-course source/confidence notes.) + * + * This script replaces that clone with realistic, per-course par / yardage / + * rating / slope / hole-by-hole data. Top-line facts (par, championship + * yardage, course rating, slope — COURSE_FACTS below) are hand-set per + * course from well-known public scorecard facts where the course is + * famous/well-documented; where no single confirmed public source exists, + * they're a realistic estimate consistent with the course's known character + * (each entry's `sourceNote` states its confidence level; echoed in the PR + * body). The hole-by-hole routing (which holes are par 3/4/5, individual + * yardages, stroke index) is generated deterministically per course by + * src/lib/golf/course-scorecard-generator.ts from a seeded PRNG keyed on the + * course name — every course gets its own routing/handicap pattern (never a + * shared template), yardages always SUM EXACTLY to the chosen total, and + * re-running this script produces byte-identical output (idempotent). + * + * SAFETY + * - UPDATE-only. Never inserts a golf_courses or golf_course_tees row — + * only the 20 courses' EXISTING "Championship" / "Members" / "Forward" + * tee rows are touched, by id, so `golf_rounds.tee_id` references and + * already-recorded rounds are untouched. (Rounds snapshot par/yardage + * into `golf_holes` at submit time regardless of later course edits — + * see docs/audits/COURSE_LIBRARY_AUDIT_2026-06-13.md §4 "Snapshot + * Safety".) + * - Matches courses by `golf_normalize_name(name)` (the same dedup key the + * app itself uses — see src/lib/golf/course-library.ts `normalizeName`), + * never a hardcoded id, so it's environment-agnostic and safe to re-run. + * - OWNERSHIP GUARD: a name match is only ever touched when + * `created_by_user_id IS NULL` (a library row nobody owns). If a course + * with that name has since been claimed by a real coach/team (rare, but + * e.g. a coach re-created a same-named course), the script SKIPS it with + * a warning instead of overwriting a real user's data — see #913 part 2 + * (ownership gating) for why that distinction matters. + * - Hole rows: upsert-first, prune-surplus-second (stage-and-swap) — the + * same non-destructive pattern `updateTeeImpl` uses in + * src/app/golf/actions/course-library.ts. Never a wipe-then-reinsert + * that could transiently empty a hole set. + * - Defaults to --dry-run: prints the full per-course/per-tee diff and + * writes NOTHING. Only `--apply` performs writes. + * + * THIS SCRIPT IS COMMITTED FOR REVIEW. It has not been executed against any + * environment as part of this PR. A human must review the numbers below, + * then decide whether/when to run --apply against a real database. + * + * Usage: + * npx tsx scripts/seed-course-library-scorecards.ts # dry-run (default) + * npx tsx scripts/seed-course-library-scorecards.ts --apply # writes + * npx tsx scripts/seed-course-library-scorecards.ts --course="Pinehurst No. 2" --apply + * + * Requires (only when --apply is actually invoked by a human): + * - NEXT_PUBLIC_SUPABASE_URL + * - SUPABASE_SERVICE_ROLE_KEY + */ +import 'dotenv/config'; +import { createClient } from '@supabase/supabase-js'; +import { normalizeName } from '../src/lib/golf/course-library'; +import { buildCourseSeed, type CourseFact } from '../src/lib/golf/course-scorecard-generator'; + +// ───────────────────────────────────────────────────────────────────────────── +// Course facts — the "well-known public scorecard" inputs. See PR body for +// the same table with source/confidence annotations spelled out per course. +// ───────────────────────────────────────────────────────────────────────────── + +const COURSE_FACTS: CourseFact[] = [ + { + matchName: 'Bethpage Black', + location: 'Farmingdale, NY', + par: 71, + championshipYards: 7468, + championshipRating: 76.6, + championshipSlope: 148, + sourceNote: 'Well documented — 2002/2009 US Open + 2019 PGA Championship host. Par 71; back-tee yardage/rating/slope are the commonly published member-back-tee figures (tournament setups play a few hundred yards longer).', + }, + { + matchName: 'Harbour Town Golf Links', + location: 'Hilton Head, SC', + par: 71, + championshipYards: 7099, + championshipRating: 74.9, + championshipSlope: 137, + sourceNote: 'Well documented — annual RBC Heritage host, Pete Dye design. Notably short for a Tour venue (~7,100 yds); par 71 and approximate yardage are high confidence, rating/slope are typical published-range estimates.', + }, + { + matchName: 'Isleworth Golf & Country Club', + location: 'Windermere, FL', + par: 72, + championshipYards: 7207, + championshipRating: 75.6, + championshipSlope: 143, + sourceNote: 'Private club (Tiger Woods’ home course); par 72 is high confidence, yardage/rating/slope are realistic estimates (no public tee sheet).', + }, + { + matchName: 'Kiawah Island - Ocean Course', + location: 'Kiawah Island, SC', + par: 72, + championshipYards: 7356, + championshipRating: 76.9, + championshipSlope: 155, + sourceNote: 'Well documented — 2012 & 2021 PGA Championship host, Pete Dye design, one of the highest slope ratings in the US (published figures often cite ~155 from the regular back tees). High confidence on par/character, yardage/rating are the commonly cited back-tee figures.', + }, + { + matchName: 'Marsh Landing Country Club', + location: 'Ponte Vedra Beach, FL', + par: 71, + championshipYards: 6900, + championshipRating: 73.5, + championshipSlope: 132, + sourceNote: 'Private club near TPC Sawgrass; par 71 and moderate yardage/rating/slope are realistic estimates (no confirmed public tee sheet).', + }, + { + matchName: 'Pebble Beach Golf Links', + location: 'Pebble Beach, CA', + par: 72, + championshipYards: 6828, + championshipRating: 74.9, + championshipSlope: 143, + sourceNote: 'Iconic, extensively documented — multiple US Opens. 6,828 yds / par 72 / 74.9 rating / 143 slope is the widely published standard back-tee card (major-championship setups play longer, ~7,075+). High confidence.', + }, + { + matchName: 'Pinehurst No. 2', + location: 'Pinehurst, NC', + par: 72, + championshipYards: 7209, + championshipRating: 75.4, + championshipSlope: 137, + sourceNote: 'Well documented — Donald Ross design, multiple US Opens (2024 major setup plays ~7,588). 7,209 yds / par 72 is the commonly cited resort back-tee card. High confidence.', + }, + { + matchName: 'Reynolds Lake Oconee - Great Waters', + location: 'Greensboro, GA', + par: 72, + championshipYards: 7048, + championshipRating: 74.5, + championshipSlope: 141, + sourceNote: 'Real, well-regarded resort course (Jack Nicklaus design) at Reynolds Lake Oconee. Par 72 high confidence; yardage/rating/slope are realistic published-range estimates.', + }, + { + matchName: 'Sawgrass Country Club', + location: 'Ponte Vedra Beach, FL', + par: 72, + championshipYards: 7100, + championshipRating: 74.3, + championshipSlope: 133, + sourceNote: 'Real private club, historic pre-TPC PGA Tour stop. Par 72 moderate-high confidence; yardage/rating/slope are realistic estimates.', + }, + { + matchName: 'Sea Island - Seaside Course', + location: 'St. Simons Island, GA', + par: 70, + championshipYards: 7005, + championshipRating: 73.4, + championshipSlope: 137, + sourceNote: 'Well documented — annual RSM Classic host. Par 70 (Rees Jones/Tom Fazio redesign) is high confidence; yardage/rating/slope are realistic published-range estimates.', + }, + { + matchName: 'TPC Sawgrass', + location: 'Ponte Vedra Beach, FL', + par: 70, + championshipYards: 6864, + championshipRating: 72.6, + championshipSlope: 128, + sourceNote: 'Treated as the Dye’s Valley Course at TPC Sawgrass (the property’s second, non-Stadium 18 — the Stadium Course is the separate "TPC Sawgrass - Stadium" library entry). Par 70 / ~6,864 yds is the commonly cited Valley Course card; moderate confidence.', + }, + { + matchName: 'TPC Sawgrass - Stadium', + location: 'Ponte Vedra Beach, FL', + par: 72, + championshipYards: 7275, + championshipRating: 74.1, + championshipSlope: 144, + sourceNote: 'Well documented — THE PLAYERS Championship host (Pete Dye, island 17th). 7,275 yds from the Players tees / par 72 is high confidence (widely published); member-card rating/slope are realistic estimates (tournament setup plays much harder).', + }, + { + matchName: 'Whistling Straits', + location: 'Kohler, WI', + par: 72, + championshipYards: 7142, + championshipRating: 76.7, + championshipSlope: 152, + sourceNote: 'Well documented — 2004/2010/2015 PGA Championship + 2021 Ryder Cup (Straits Course). Par 72 high confidence; yardage is the commonly cited standard back-tee card (Ryder Cup tips play ~7,790); rating/slope reflect its reputation as one of the hardest public courses in the US.', + }, + { + matchName: 'World Golf Village - King & Bear', + location: 'St. Augustine, FL', + par: 72, + championshipYards: 7279, + championshipRating: 75.4, + championshipSlope: 140, + sourceNote: 'Real course — Arnold Palmer ("The King") / Jack Nicklaus ("The Golden Bear") collaborative design. Par 72 / yardage moderate-high confidence; rating/slope realistic estimates.', + }, + { + matchName: 'Golden Horseshoe gold course', + location: 'Williamsburg, VA', + par: 71, + championshipYards: 6817, + championshipRating: 73.2, + championshipSlope: 144, + sourceNote: 'Real — Colonial Williamsburg’s Golden Horseshoe Gold Course, Robert Trent Jones Sr. design, tight tree-lined layout. Par 71 high confidence; yardage/rating/slope realistic estimates consistent with its reputation as tough for its length.', + }, + { + matchName: 'Club at Savannah Habor', // sic — matches the existing (typo'd) DB row verbatim; this script never rewrites `name` + location: 'Savannah, GA', + par: 72, + championshipYards: 7288, + championshipRating: 75.2, + championshipSlope: 130, + sourceNote: 'Real — The Club at Savannah Harbor (Westin Savannah Harbor Golf Resort), Robert Cupp design, has hosted LPGA events. Par 72 / yardage moderate-high confidence; rating/slope realistic estimates. NOTE: the stored course name has a pre-existing typo ("Habor") — matched verbatim on purpose; renaming is out of scope for this script (see #913 PR body).', + }, + { + matchName: 'Forest Creek', + location: 'Pinehurst, NC', + par: 72, + championshipYards: 7154, + championshipRating: 74.9, + championshipSlope: 143, + sourceNote: 'Real — Forest Creek Golf Club (North Course), Tom Fazio design, highly rated Pinehurst-area private club. Par 72 high confidence; yardage/rating/slope realistic estimates.', + }, + { + matchName: 'Pine Lakes Jekyll Island', + location: 'Jekyll Island, GA', + par: 72, + championshipYards: 6657, + championshipRating: 71.8, + championshipSlope: 124, + sourceNote: 'Pine Lakes is one of the Jekyll Island Golf Club’s four 18-hole courses (with Oleander, Indian Mound, and the historic 9-hole Great Dunes). Moderate confidence — resort-style, shorter/easier than the championship-caliber entries above.', + }, + { + matchName: 'Poplar Grove (real)', + location: 'Amherst, VA', + par: 71, + championshipYards: 6450, + championshipRating: 71.0, + championshipSlope: 125, + sourceNote: 'LOW CONFIDENCE — no single confirmed public scorecard found for a facility matching this exact name + Amherst, VA. Treated as a realistic small-market Virginia public/semi-private course. Flagged for a human to verify or replace if a real source turns up.', + }, + { + matchName: 'The Cardinal', + location: 'Greensboro, NC', + par: 72, + championshipYards: 7213, + championshipRating: 76.0, + championshipSlope: 147, + sourceNote: 'Real — The Cardinal by Pete Dye, Greensboro NC. Par 72 moderate-high confidence; yardage/rating/slope realistic estimates consistent with a modern Pete Dye design (typically high slope).', + }, +]; + +// Courses intentionally NOT in COURSE_FACTS / touched by this script: the 4 +// library-page rows that already have a real human creator (`created_by_user_id` +// is set) — "Danville Golf Club" (source=round), "Forsyth Country Club" +// (source=manual), "Pebble Beach Golf Club" (source=round, distinct from the +// library's "Pebble Beach Golf Links"), and "Statesville Country Club" +// (source=manual). Those already carry distinct, real, user-entered scorecards +// (confirmed via mcp__supabase__execute_sql) — this script's ownership guard +// would skip them even if they were listed, so they're omitted rather than +// implying they need fixing. + +// ───────────────────────────────────────────────────────────────────────────── +// DB apply +// ───────────────────────────────────────────────────────────────────────────── + +interface CourseDbRow { + id: string; + name: string; + created_by_user_id: string | null; +} +interface TeeDbRow { + id: string; + tee_name: string; +} + +async function main() { + const args = process.argv.slice(2); + const apply = args.includes('--apply'); + const courseFilterArg = args.find((a) => a.startsWith('--course=')); + const courseFilter = courseFilterArg ? normalizeName(courseFilterArg.slice('--course='.length)) : null; + + const facts = courseFilter + ? COURSE_FACTS.filter((f) => normalizeName(f.matchName) === courseFilter) + : COURSE_FACTS; + + console.log(`seed-course-library-scorecards: ${apply ? 'APPLY' : 'DRY-RUN'} — ${facts.length} course(s) targeted\n`); + + let supabase: ReturnType | null = null; + if (apply) { + const supabaseUrl = (process.env.NEXT_PUBLIC_SUPABASE_URL ?? '').trim(); + const serviceKey = (process.env.SUPABASE_SERVICE_ROLE_KEY ?? '').trim(); + if (!supabaseUrl || !serviceKey) throw new Error('Missing SUPABASE env vars for --apply'); + supabase = createClient(supabaseUrl, serviceKey, { auth: { persistSession: false } }); + } + + let touched = 0; + let skippedNotFound = 0; + let skippedUserOwned = 0; + + for (const fact of facts) { + const seed = buildCourseSeed(fact); + const normalized = normalizeName(seed.matchName); + + console.log(`── ${seed.matchName} (${seed.location}) — par ${seed.par}`); + console.log(` source: ${seed.sourceNote}`); + for (const tee of seed.tees) { + console.log( + ` ${tee.teeName.padEnd(12)} par ${tee.totalPar} ${tee.totalYards} yds ` + + `${tee.courseRating.toFixed(1)}/${tee.slopeRating}`, + ); + } + + if (!supabase) continue; // dry-run: report only, no DB round trip + + const { data: courseRow, error: courseErr } = await supabase + .from('golf_courses') + .select('id, name, created_by_user_id') + .eq('normalized_name', normalized) + .is('deleted_at', null) + .maybeSingle(); + if (courseErr) throw courseErr; + const course = courseRow as CourseDbRow | null; + if (!course) { + console.warn(` SKIP — no active golf_courses row matches "${seed.matchName}"`); + skippedNotFound++; + continue; + } + if (course.created_by_user_id) { + console.warn(` SKIP — "${course.name}" has a real creator (${course.created_by_user_id}); not a library row, leaving untouched`); + skippedUserOwned++; + continue; + } + + const { error: courseUpdateErr } = await supabase + .from('golf_courses') + .update({ + par: seed.par, + course_rating: seed.championshipRating, + slope_rating: seed.championshipSlope, + last_edited_at: new Date().toISOString(), + }) + .eq('id', course.id); + if (courseUpdateErr) throw courseUpdateErr; + + const { data: teeRows, error: teeErr } = await supabase + .from('golf_course_tees') + .select('id, tee_name') + .eq('course_id', course.id) + .is('deleted_at', null); + if (teeErr) throw teeErr; + const teesByName = new Map(((teeRows ?? []) as TeeDbRow[]).map((t) => [t.tee_name, t.id])); + + for (const tee of seed.tees) { + const teeId = teesByName.get(tee.teeName); + if (!teeId) { + console.warn(` SKIP tee — "${course.name}" has no active "${tee.teeName}" tee to update`); + continue; + } + + const { error: teeUpdateErr } = await supabase + .from('golf_course_tees') + .update({ + total_yards: tee.totalYards, + total_par: tee.totalPar, + course_rating: tee.courseRating, + slope_rating: tee.slopeRating, + is_draft: false, + last_edited_at: new Date().toISOString(), + }) + .eq('id', teeId); + if (teeUpdateErr) throw teeUpdateErr; + + // Stage-and-swap: upsert the new hole rows FIRST (never a moment with + // fewer than 18 rows present), then prune any surplus. Mirrors + // updateTeeImpl in src/app/golf/actions/course-library.ts. + const holeRows = tee.holes.map((h) => ({ + tee_id: teeId, hole_number: h.hole, par: h.par, yardage: h.yards, handicap_index: h.hcp, + })); + const { error: upsertErr } = await supabase + .from('golf_course_tee_holes') + .upsert(holeRows, { onConflict: 'tee_id,hole_number' }); + if (upsertErr) throw upsertErr; + + const keep = holeRows.map((r) => r.hole_number); + const { error: pruneErr } = await supabase + .from('golf_course_tee_holes') + .delete() + .eq('tee_id', teeId) + .not('hole_number', 'in', `(${keep.join(',')})`); + if (pruneErr) throw pruneErr; + } + + touched++; + console.log(' ✓ applied'); + } + + console.log( + `\nDone. ${apply ? `applied=${touched} ` : ''}skipped_not_found=${skippedNotFound} skipped_user_owned=${skippedUserOwned}`, + ); + if (!apply) { + console.log('Dry-run only — nothing was written. Re-run with --apply to write.'); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/app/golf/(dashboard)/dashboard/courses/page.tsx b/src/app/golf/(dashboard)/dashboard/courses/page.tsx index 58d4f899c..e915bb8dd 100644 --- a/src/app/golf/(dashboard)/dashboard/courses/page.tsx +++ b/src/app/golf/(dashboard)/dashboard/courses/page.tsx @@ -2,6 +2,7 @@ import { redirect } from 'next/navigation'; import type { Metadata } from 'next'; import { getGolfSessionProfile } from '@/lib/auth/session'; import { fairwayScope } from '@/lib/redesign/flag'; +import { isSuperAdminUserId } from '@/lib/admin/super-admin-shared'; import { listCoursesStrict, getCourseTeeCountsStrict, @@ -32,6 +33,10 @@ export default async function CoursesPage() { getTeamSavedCoursesStrict(), ]); + // #913 part 2 — shared library courses (no human creator) may only be + // edited/removed by a super admin; everything else keeps open contribution. + const isSuperAdmin = isSuperAdminUserId(session.userId, process.env.SUPER_ADMIN_USER_IDS); + return (
); diff --git a/src/app/golf/actions/__tests__/course-library.test.ts b/src/app/golf/actions/__tests__/course-library.test.ts index 357aa0f4c..ac7c3c129 100644 --- a/src/app/golf/actions/__tests__/course-library.test.ts +++ b/src/app/golf/actions/__tests__/course-library.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; // ── Mocks (declared before importing the action module) ────────────────────── const revalidatePath = vi.fn(); @@ -13,7 +13,7 @@ vi.mock('@/lib/golf/resolve-team-server', () => ({ let currentClient: unknown = null; vi.mock('@/lib/supabase/server', () => ({ createClient: async () => currentClient })); -import { createCourse, listCourses, saveTeamCourse, updateTee, getTeeRoundDefaults, getTeamSavedCourses, updateCourse, restoreCourse, contributeCourseFromRound, listCoursesStrict, getCourseTeeCountsStrict, getTeamSavedCoursesStrict } from '../course-library'; +import { createCourse, listCourses, saveTeamCourse, updateTee, getTeeRoundDefaults, getTeamSavedCourses, updateCourse, restoreCourse, softDeleteCourse, getCourseTeeHoles, contributeCourseFromRound, listCoursesStrict, getCourseTeeCountsStrict, getTeamSavedCoursesStrict } from '../course-library'; // ── A scriptable, chainable Supabase query-builder mock ────────────────────── type Scripted = { maybeSingle?: unknown; single?: unknown; resolve?: unknown }; @@ -212,7 +212,10 @@ describe('getTeamSavedCourses — soft-delete must not leak into a team library' describe('Phase 5 — unique normalized_name index: 23505 collision handling', () => { it('updateCourse surfaces a rename collision as a clear message (not a generic failure)', async () => { - const before = { id: 'c1', name: 'Old Name', normalized_name: 'old name', deleted_at: null }; + // created_by_user_id set: a user-owned course, not a library row — keeps + // this test isolated to the 23505 collision path (see the ownership-gate + // describe block below for the library-row-blocks-edit behavior itself). + const before = { id: 'c1', name: 'Old Name', normalized_name: 'old name', deleted_at: null, created_by_user_id: 'u1' }; const courses = tableBuilder({ maybeSingle: { data: before, error: null }, // the "before" fetch single: { data: null, error: { code: '23505', message: 'duplicate key' } }, // the update collides @@ -227,7 +230,12 @@ describe('Phase 5 — unique normalized_name index: 23505 collision handling', ( }); it('restoreCourse refuses to un-delete into an active name collision (23505)', async () => { - const courses = tableBuilder({ resolve: { data: null, error: { code: '23505', message: 'duplicate key' } } }); + // maybeSingle: the ownership-gate lookup in setCourseDeleted — created_by_user_id + // set so this stays a user-owned-course scenario, isolated to the 23505 path. + const courses = tableBuilder({ + maybeSingle: { data: { id: 'c1', created_by_user_id: 'u1' }, error: null }, + resolve: { data: null, error: { code: '23505', message: 'duplicate key' } }, + }); currentClient = makeClient({ id: 'u1' }, { golf_coaches: tableBuilder({ maybeSingle: { data: null, error: null } }), golf_courses: courses, @@ -332,3 +340,152 @@ describe('updateTee — destructive-write guard', () => { expect(client.from).not.toHaveBeenCalledWith('golf_course_tee_holes'); }); }); + +describe('#913 part 2 — library-owned courses require a super admin to edit/remove', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('updateCourse blocks a non-admin coach from editing a library row (created_by_user_id null)', async () => { + const before = { + id: 'c1', name: 'Pinehurst No. 2', normalized_name: 'pinehurst 2', + deleted_at: null, created_by_user_id: null, + }; + const courses = tableBuilder({ maybeSingle: { data: before, error: null } }); + currentClient = makeClient({ id: 'u1' }, { + golf_coaches: tableBuilder({ maybeSingle: { data: null, error: null } }), + golf_courses: courses, + }); + + const res = await updateCourse('c1', { name: 'Renamed' }); + + expect(res).toEqual({ success: false, error: 'This is a shared library course — only an admin can edit it.' }); + expect(courses._calls.update).toBeUndefined(); // never reaches the write + }); + + it('updateCourse allows a super admin (SUPER_ADMIN_USER_IDS allowlist) to edit a library row', async () => { + vi.stubEnv('SUPER_ADMIN_USER_IDS', 'u1,someone-else'); + const before = { + id: 'c1', name: 'Pinehurst No. 2', normalized_name: 'pinehurst 2', + deleted_at: null, created_by_user_id: null, + }; + const after = { ...before, name: 'Renamed', normalized_name: 'renamed' }; + const courses = tableBuilder({ + maybeSingle: { data: before, error: null }, + single: { data: after, error: null }, + }); + currentClient = makeClient({ id: 'u1' }, { + golf_coaches: tableBuilder({ maybeSingle: { data: null, error: null } }), + golf_courses: courses, + golf_course_edit_history: tableBuilder({ resolve: { data: null, error: null } }), + }); + + const res = await updateCourse('c1', { name: 'Renamed' }); + expect(res.success).toBe(true); + }); + + it('updateCourse leaves a team/user-contributed course editable by any authenticated coach (open-contribution model unchanged)', async () => { + const before = { + id: 'c1', name: 'My Local Club', normalized_name: 'my local club', + deleted_at: null, created_by_user_id: 'some-other-real-user', + }; + const after = { ...before, name: 'Renamed', normalized_name: 'renamed' }; + const courses = tableBuilder({ + maybeSingle: { data: before, error: null }, + single: { data: after, error: null }, + }); + currentClient = makeClient({ id: 'u1' }, { + golf_coaches: tableBuilder({ maybeSingle: { data: null, error: null } }), + golf_courses: courses, + golf_course_edit_history: tableBuilder({ resolve: { data: null, error: null } }), + }); + + const res = await updateCourse('c1', { name: 'Renamed' }); + expect(res.success).toBe(true); + }); + + it('softDeleteCourse blocks a non-admin coach from removing a library row', async () => { + const courses = tableBuilder({ + maybeSingle: { data: { id: 'c1', created_by_user_id: null }, error: null }, + }); + currentClient = makeClient({ id: 'u1' }, { + golf_coaches: tableBuilder({ maybeSingle: { data: null, error: null } }), + golf_courses: courses, + }); + + const res = await softDeleteCourse('c1'); + + expect(res).toEqual({ success: false, error: 'This is a shared library course — only an admin can remove it.' }); + expect(courses._calls.update).toBeUndefined(); + }); + + it('softDeleteCourse allows a super admin to remove a library row', async () => { + vi.stubEnv('SUPER_ADMIN_USER_IDS', 'u1'); + const courses = tableBuilder({ + maybeSingle: { data: { id: 'c1', created_by_user_id: null }, error: null }, + resolve: { data: null, error: null }, + }); + currentClient = makeClient({ id: 'u1' }, { + golf_coaches: tableBuilder({ maybeSingle: { data: null, error: null } }), + golf_courses: courses, + golf_course_edit_history: tableBuilder({ resolve: { data: null, error: null } }), + }); + + const res = await softDeleteCourse('c1'); + expect(res.success).toBe(true); + }); + + it('softDeleteCourse leaves a team/user-contributed course removable by any authenticated coach (open-contribution model unchanged)', async () => { + const courses = tableBuilder({ + maybeSingle: { data: { id: 'c1', created_by_user_id: 'some-other-real-user' }, error: null }, + resolve: { data: null, error: null }, + }); + currentClient = makeClient({ id: 'u1' }, { + golf_coaches: tableBuilder({ maybeSingle: { data: null, error: null } }), + golf_courses: courses, + golf_course_edit_history: tableBuilder({ resolve: { data: null, error: null } }), + }); + + const res = await softDeleteCourse('c1'); + expect(res.success).toBe(true); + }); +}); + +describe('getCourseTeeHoles — #913 part 3 (course detail "Holes" summary)', () => { + it('returns {} for an unauthenticated caller', async () => { + currentClient = makeClient(null); + expect(await getCourseTeeHoles('c1')).toEqual({}); + }); + + it('returns {} when the course has no active tees (skips the holes query)', async () => { + const tees = tableBuilder({ resolve: { data: [], error: null } }); + const client = makeClient({ id: 'u1' }, { golf_course_tees: tees }); + currentClient = client; + + expect(await getCourseTeeHoles('c1')).toEqual({}); + expect(client.from).not.toHaveBeenCalledWith('golf_course_tee_holes'); + }); + + it('groups hole rows by tee_id, scoped to only this course’s active tee ids', async () => { + const tees = tableBuilder({ resolve: { data: [{ id: 't1' }, { id: 't2' }], error: null } }); + const holes = tableBuilder({ + resolve: { + data: [ + { id: 'h1', tee_id: 't1', hole_number: 1, par: 4, yardage: 400, handicap_index: 7 }, + { id: 'h2', tee_id: 't2', hole_number: 1, par: 5, yardage: 520, handicap_index: 3 }, + { id: 'h3', tee_id: 't1', hole_number: 2, par: 3, yardage: 175, handicap_index: 15 }, + ], + error: null, + }, + }); + currentClient = makeClient({ id: 'u1' }, { golf_course_tees: tees, golf_course_tee_holes: holes }); + + const res = await getCourseTeeHoles('c1'); + + expect(res['t1']).toHaveLength(2); + expect(res['t2']).toHaveLength(1); + expect(res['t1']![0]!.hole_number).toBe(1); + expect(res['t1']![1]!.hole_number).toBe(2); + expect(holes._calls.in).toContainEqual(['tee_id', ['t1', 't2']]); + }); +}); diff --git a/src/app/golf/actions/course-library.ts b/src/app/golf/actions/course-library.ts index 51d3144e3..d993bede9 100644 --- a/src/app/golf/actions/course-library.ts +++ b/src/app/golf/actions/course-library.ts @@ -27,6 +27,7 @@ import { resolveCoachTeamIdWithCookie } from '@/lib/golf/resolve-team-server'; import { logServerError } from '@/lib/server-error-logger'; import { fetchAllRowsResult } from '@/lib/supabase/fetch-all-rows'; import { withAdminObserved } from '@/lib/admin/observed-action'; +import { isSuperAdminUserId } from '@/lib/admin/super-admin-shared'; import { normalizeName, isTeeComplete, @@ -43,6 +44,7 @@ import { import type { GolfCourse, GolfCourseTee, + GolfCourseTeeHole, GolfCourseTeeWithHoles, GolfTeamSavedCourse, GolfTeamSavedCourseWithCourse, @@ -106,6 +108,30 @@ async function getCoachTeam( return { userId: user.id, teamId }; } +/** + * True when a golf_courses row has NO human creator — i.e. it's a + * staff-curated shared LIBRARY course (bulk-seeded via a reviewed script, + * e.g. scripts/seed-course-library-scorecards.ts), not a coach/team's own + * contribution. `created_by_user_id` is set on every row created through the + * app (createCourse / contributeCourseFromRound both stamp the actor), so a + * null value can only mean "nobody added this — it shipped with the + * library." (#913 part 2 — ownership gating.) + * + * Library rows stay visible + usable by every team (tees, saves, rounds all + * still work normally), but must not be renamed or removed by an arbitrary + * coach — only a super admin may edit or remove them. Team/user-contributed + * courses keep the pre-existing open-contribution model unchanged. + */ +function isLibraryOwnedCourseRow(row: { created_by_user_id?: unknown }): boolean { + return row.created_by_user_id == null; +} + +/** Cheap, DB-round-trip-free super-admin check (env-var allowlist), the same + * pattern src/app/golf/actions/auth.ts already uses outside /admin. */ +function isActorSuperAdmin(actor: Actor): boolean { + return isSuperAdminUserId(actor.userId, process.env.SUPER_ADMIN_USER_IDS); +} + // ───────────────────────────────────────────────────────────────────────────── // READS // ───────────────────────────────────────────────────────────────────────────── @@ -410,6 +436,54 @@ export async function getCourseDetail( return observedGetCourseDetail(courseId); } +/** + * Read-only hole rows for EVERY active tee of a course, keyed by tee id + * (each sorted by hole_number). #913 part 3 — powers the course detail + * sheet's compact "Holes" summary (par row + yardage row per tee) so a coach + * can see the scorecard without opening tee-set edit. One extra round trip + * per course-detail open; deliberately a separate action from + * `getCourseDetail` (used by the new-round + tee-picker flows too) so those + * hot paths never pay for hole data they don't render. + */ +async function getCourseTeeHolesImpl(courseId: string): Promise> { + const supabase = await createClient(); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return {}; + + const { data: teeRows } = await supabase + .from('golf_course_tees') + .select('id') + .eq('course_id', courseId) + .is('deleted_at', null); + const teeIds = (teeRows ?? []).map((t) => t.id as string); + if (teeIds.length === 0) return {}; + + const { data: holeRows } = await supabase + .from('golf_course_tee_holes') + .select('*') + .in('tee_id', teeIds) + .order('hole_number'); + + const byTee: Record = {}; + for (const row of holeRows ?? []) { + const hole = mapTeeHoleRow(row); + (byTee[hole.tee_id] ??= []).push(hole); + } + return byTee; +} + +const observedGetCourseTeeHoles = withAdminObserved( + 'getCourseTeeHoles', + { sport: 'golf', feature: 'course_library' }, + getCourseTeeHolesImpl, +); + +export async function getCourseTeeHoles( + courseId: string, +): Promise> { + return observedGetCourseTeeHoles(courseId); +} + /** A single tee with its hole rows (ordered by hole_number). */ async function getTeeWithHolesImpl(teeId: string): Promise { const supabase = await createClient(); @@ -834,6 +908,12 @@ async function updateCourseImpl( .maybeSingle(); if (!before) return { success: false, error: 'Course not found' }; + // #913 part 2 — library-owned rows (no human creator) can only be edited + // by a super admin; team/user-contributed courses keep open contribution. + if (isLibraryOwnedCourseRow(before) && !isActorSuperAdmin(actor)) { + return { success: false, error: 'This is a shared library course — only an admin can edit it.' }; + } + const update: CourseUpdate = { last_edited_by_user_id: actor.userId, last_edited_by_team_id: actor.teamId, @@ -934,6 +1014,25 @@ async function setCourseDeleted(courseId: string, deleted: boolean): Promise void; canManageTeam: boolean; + /** #913 part 2 — only a super admin may edit/remove a shared LIBRARY course + * (one with no human creator, i.e. `course.created_by_user_id == null`). + * Team/user-contributed courses keep the pre-existing open-contribution + * model regardless of this flag. */ + isSuperAdmin: boolean; savedCourseIds: Set; /** Saved-course rows keyed by course id — supplies pin + default-tee state. */ savedById?: Map; @@ -42,11 +47,12 @@ const CATEGORY_LABEL: Record = { }; export function CourseDetailDrawer({ - courseId, open, onOpenChange, canManageTeam, savedCourseIds, savedById, onChanged, + courseId, open, onOpenChange, canManageTeam, isSuperAdmin, savedCourseIds, savedById, onChanged, }: CourseDetailDrawerProps) { const { showToast } = useToast(); const fileRef = useRef(null); const [detail, setDetail] = useState<{ course: GolfCourse; tees: GolfCourseTee[] } | null>(null); + const [holesByTeeId, setHolesByTeeId] = useState>({}); const [loading, setLoading] = useState(false); const [loadError, setLoadError] = useState(false); const [editCourseOpen, setEditCourseOpen] = useState(false); @@ -76,17 +82,32 @@ export function CourseDetailDrawer({ setDefaultTeeId(savedRow?.default_tee_id ?? null); }, [savedRow]); + // Hole data (for the read-only "Holes" summary, #913 part 3) is fetched + // alongside the course detail but is deliberately non-fatal: a failure here + // must never block the rest of the drawer (tee sets, save/pin, edit) — + // it just leaves the Holes section empty. const reload = async (id: string) => { - const d = await getCourseDetail(id); + const [d, holes] = await Promise.all([ + getCourseDetail(id), + getCourseTeeHoles(id).catch(() => ({})), + ]); setDetail(d); + setHolesByTeeId(holes); }; const loadDetail = (id: string) => { setLoading(true); setLoadError(false); setDetail(null); - getCourseDetail(id) - .then((d) => setDetail(d)) + setHolesByTeeId({}); + Promise.all([ + getCourseDetail(id), + getCourseTeeHoles(id).catch(() => ({})), + ]) + .then(([d, holes]) => { + setDetail(d); + setHolesByTeeId(holes); + }) .catch(() => setLoadError(true)) .finally(() => setLoading(false)); }; @@ -214,6 +235,12 @@ export function CourseDetailDrawer({ const tees = detail?.tees ?? []; const location = course ? [course.city, course.state].filter(Boolean).join(', ') : ''; + // #913 part 2 — a course with no human creator shipped with the shared + // library; only a super admin may edit or remove it. Team/user-contributed + // courses (created_by_user_id set) keep the existing open-contribution model. + const isLibraryOwned = course ? course.created_by_user_id == null : false; + const canEditCourse = !isLibraryOwned || isSuperAdmin; + return ( <> @@ -272,9 +299,19 @@ export function CourseDetailDrawer({ /> {/* title overlay */}
-

- {course?.name ?? (loadError ? 'Couldn’t load course' : 'Loading…')} -

+
+

+ {course?.name ?? (loadError ? 'Couldn’t load course' : 'Loading…')} +

+ {isLibraryOwned && ( + + Library course + + )} +
{location && (

{location} @@ -333,12 +370,15 @@ export function CourseDetailDrawer({ {pinned ? 'Pinned' : 'Pin to top'} )} - {course && ( + {/* #913 part 2 — a library-owned course (no human creator) can + only be edited/removed by a super admin; team/user-contributed + courses keep the pre-existing open-contribution model. */} + {course && canEditCourse && ( )} - {canManageTeam && course && ( + {canManageTeam && course && canEditCourse && (

); } + +/** + * Read-only compact scorecard for one tee — a "Hole" header row, a "Par" + * row, and a "Yds" row, all horizontally scrollable together (#913 part 3). + * Purely presentational: no edit affordance here on purpose — hole editing + * stays exclusively in TeeFormDrawer (Edit tee) so there's one place that + * can write hole data, and one place (here) to just see it. + */ +function TeeHolesSummary({ tee, holes }: { tee: GolfCourseTee; holes: GolfCourseTeeHole[] }) { + if (holes.length === 0) return null; + + const byHoleNumber = new Map(holes.map((h) => [h.hole_number, h])); + const holeCount = Math.max(tee.holes_count, ...holes.map((h) => h.hole_number)); + const holeNumbers = Array.from({ length: holeCount }, (_, i) => i + 1); + + return ( +
+

+ {tee.tee_name} + {tee.is_draft && · partial} +

+
+
+ + byHoleNumber.get(n)?.par ?? null)} + /> + byHoleNumber.get(n)?.yardage ?? null)} + /> +
+
+
+ ); +} + +function HoleValuesRow({ + label, values, labelClassName, valueClassName, +}: { + label: string; + values: ReadonlyArray; + labelClassName?: string; + valueClassName?: string; +}) { + return ( +
+ + {label} + + {values.map((v, i) => ( + // Fixed-length hole-number columns (1..N) — position IS the identity. + + {v == null ? '—' : v} + + ))} +
+ ); +} diff --git a/src/components/golf/courses/CourseLibraryClient.tsx b/src/components/golf/courses/CourseLibraryClient.tsx index d36d6bddb..e7340a86d 100644 --- a/src/components/golf/courses/CourseLibraryClient.tsx +++ b/src/components/golf/courses/CourseLibraryClient.tsx @@ -17,6 +17,9 @@ export interface CourseLibraryClientProps { savedCourses: GolfTeamSavedCourseWithCourse[]; /** Coaches may save courses to the team library + manage tees. */ canManageTeam: boolean; + /** #913 part 2 — only a super admin may edit/remove a shared LIBRARY course + * (one with no human creator). Everything else keeps open contribution. */ + isSuperAdmin: boolean; } function playedMeta(saved: GolfTeamSavedCourseWithCourse): string | undefined { @@ -30,6 +33,7 @@ export function CourseLibraryClient({ teeCounts, savedCourses, canManageTeam, + isSuperAdmin, }: CourseLibraryClientProps) { const router = useRouter(); const [query, setQuery] = useState(''); @@ -274,6 +278,7 @@ export function CourseLibraryClient({ open={selectedCourseId !== null} onOpenChange={(o) => !o && setSelectedCourseId(null)} canManageTeam={canManageTeam} + isSuperAdmin={isSuperAdmin} savedCourseIds={new Set(savedCourses.map((s) => s.course_id))} savedById={savedById} onChanged={refresh} diff --git a/src/lib/admin/__tests__/coverage-contract.foundation.test.ts b/src/lib/admin/__tests__/coverage-contract.foundation.test.ts index e270f897c..40a2ae331 100644 --- a/src/lib/admin/__tests__/coverage-contract.foundation.test.ts +++ b/src/lib/admin/__tests__/coverage-contract.foundation.test.ts @@ -274,6 +274,14 @@ describe('global tripwire', () => { // flow) and `reconcileQualifierStatus` (view-time lifecycle self-heal on // the qualifier detail page) — both withAdminObserved-wrapped and listed // in FEATURE_REGISTRY's qualifiers manifest. - expect(total).toBe(421); + // + // 422 as of the 2026-07-17 course-library-owner-gate fix (#913, +1): + // course-library.ts (already 'ALL'-mapped to `course_library`) gains + // `getCourseTeeHoles` — a new withAdminObserved-wrapped read that powers + // the course detail sheet's read-only "Holes" summary. No manifest edit + // was needed (the file is a whole-file 'ALL' owner), so this live scan + // and feature-registry.test.ts's manifest-length count both move by + // exactly +1. + expect(total).toBe(422); }); }); diff --git a/src/lib/admin/__tests__/feature-registry.test.ts b/src/lib/admin/__tests__/feature-registry.test.ts index bfbd1d0b5..eacf4d05d 100644 --- a/src/lib/admin/__tests__/feature-registry.test.ts +++ b/src/lib/admin/__tests__/feature-registry.test.ts @@ -318,7 +318,13 @@ describe('FEATURE_REGISTRY completeness', () => { // `updateGolfQualifierDetails` (the previously-missing edit-qualifier flow) // and `reconcileQualifierStatus` (view-time lifecycle self-heal) — both new // withAdminObserved-wrapped exports in golf.ts. - it('total manifest size is exactly 421 (excludes the CRM row)', () => { + // 2026-07-17 course-library-owner-gate fix (#913): +1 to 422. New read + // action `getCourseTeeHoles` (course-library.ts) powers the course detail + // sheet's read-only "Holes" summary. course-library.ts is already + // 'ALL'-mapped to `course_library`, so this new withAdminObserved-wrapped + // export is picked up by the live `scanExports` count with no manifest + // edit required. + it('total manifest size is exactly 422 (excludes the CRM row)', () => { let total = 0; for (const def of FEATURE_REGISTRY) { if (def.excluded || def.app === 'baseballhelm') continue; @@ -330,7 +336,7 @@ describe('FEATURE_REGISTRY completeness', () => { } } } - expect(total).toBe(421); + expect(total).toBe(422); }); it('the CRM row lists no files (never a wrap target)', () => { diff --git a/src/lib/golf/__tests__/course-scorecard-generator.test.ts b/src/lib/golf/__tests__/course-scorecard-generator.test.ts new file mode 100644 index 000000000..d204f09f9 --- /dev/null +++ b/src/lib/golf/__tests__/course-scorecard-generator.test.ts @@ -0,0 +1,169 @@ +import { describe, it, expect } from 'vitest'; +import { + seededRng, + buildParSequence, + buildYardages, + buildHandicaps, + buildCourseSeed, + PAR_COMPOSITIONS, + type CourseFact, +} from '@/lib/golf/course-scorecard-generator'; + +describe('seededRng', () => { + it('is deterministic for the same seed', () => { + const a = seededRng('Pinehurst No. 2'); + const b = seededRng('Pinehurst No. 2'); + const seqA = Array.from({ length: 10 }, () => a()); + const seqB = Array.from({ length: 10 }, () => b()); + expect(seqA).toEqual(seqB); + }); + + it('produces a different sequence for a different seed', () => { + const a = seededRng('Pinehurst No. 2'); + const b = seededRng('Bethpage Black'); + const seqA = Array.from({ length: 10 }, () => a()); + const seqB = Array.from({ length: 10 }, () => b()); + expect(seqA).not.toEqual(seqB); + }); +}); + +describe('buildParSequence', () => { + it('sums to the course par and holds exactly 18 holes for every supported par', () => { + for (const par of Object.keys(PAR_COMPOSITIONS).map(Number)) { + const rng = seededRng(`par-${par}`); + const pars = buildParSequence(par, rng); + expect(pars).toHaveLength(18); + expect(pars.reduce((a, b) => a + b, 0)).toBe(par); + for (const p of pars) expect([3, 4, 5]).toContain(p); + } + }); + + it('matches the PAR_COMPOSITIONS counts (right number of 3s/4s/5s, just shuffled)', () => { + const rng = seededRng('composition-check'); + const pars = buildParSequence(72, rng); + const comp = PAR_COMPOSITIONS[72]!; + expect(pars.filter((p) => p === 3)).toHaveLength(comp.p3); + expect(pars.filter((p) => p === 4)).toHaveLength(comp.p4); + expect(pars.filter((p) => p === 5)).toHaveLength(comp.p5); + }); + + it('rejects an unsupported par', () => { + expect(() => buildParSequence(69, seededRng('x'))).toThrow(/Unsupported par/); + }); +}); + +describe('buildYardages', () => { + it('sums to EXACTLY the requested total, whatever the base range rounding leaves over', () => { + const rng = seededRng('yardage-sum-check'); + const pars = buildParSequence(72, rng); + for (const target of [6200, 6455, 7005, 7288, 7468]) { + const yards = buildYardages(pars, target, seededRng(`t-${target}`)); + expect(yards).toHaveLength(18); + expect(yards.reduce((a, b) => a + b, 0)).toBe(target); + } + }); + + it('keeps every hole within a plausible yardage range for its par', () => { + const rng = seededRng('rounding-check'); + const pars = buildParSequence(71, rng); + const yards = buildYardages(pars, 6900, seededRng('rounding-check-2')); + yards.forEach((y, i) => { + const par = pars[i]; + if (par === 3) expect(y).toBeGreaterThanOrEqual(120); + if (par === 5) expect(y).toBeLessThanOrEqual(650); + expect(Number.isInteger(y)).toBe(true); + }); + }); +}); + +describe('buildHandicaps', () => { + it('is a valid 1..18 stroke-index permutation', () => { + const rng = seededRng('hcp-permutation'); + const pars = buildParSequence(72, rng); + const yards = buildYardages(pars, 7150, seededRng('hcp-permutation-yds')); + const hcp = buildHandicaps(pars, yards, seededRng('hcp-permutation-hcp')); + expect(hcp).toHaveLength(18); + expect(new Set(hcp)).toEqual(new Set(Array.from({ length: 18 }, (_, i) => i + 1))); + }); + + it('never hands stroke index 1 or 2 to a par 3', () => { + // Run across many seeds — the realism nudge must hold every time, not + // just by luck on one seed. + for (let i = 0; i < 25; i++) { + const seed = `hcp-par3-guard-${i}`; + const rng = seededRng(seed); + const pars = buildParSequence(72, rng); + const yards = buildYardages(pars, 7100, seededRng(`${seed}-yds`)); + const hcp = buildHandicaps(pars, yards, seededRng(`${seed}-hcp`)); + const rank1Hole = hcp.indexOf(1); + const rank2Hole = hcp.indexOf(2); + expect(pars[rank1Hole]).not.toBe(3); + expect(pars[rank2Hole]).not.toBe(3); + } + }); +}); + +const BASE_FACT: CourseFact = { + matchName: 'Pinehurst No. 2', + location: 'Pinehurst, NC', + par: 72, + championshipYards: 7209, + championshipRating: 75.4, + championshipSlope: 137, + sourceNote: 'test fixture', +}; + +describe('buildCourseSeed', () => { + it('is idempotent — same fact in, byte-identical seed out, every time', () => { + const a = buildCourseSeed(BASE_FACT); + const b = buildCourseSeed(BASE_FACT); + expect(a).toEqual(b); + }); + + it('produces 3 tees (Championship/Members/Forward), each with 18 holes summing to the course par', () => { + const seed = buildCourseSeed(BASE_FACT); + expect(seed.tees.map((t) => t.teeName)).toEqual(['Championship', 'Members', 'Forward']); + for (const tee of seed.tees) { + expect(tee.holes).toHaveLength(18); + expect(tee.holes.reduce((a, h) => a + h.par, 0)).toBe(BASE_FACT.par); + expect(tee.totalPar).toBe(BASE_FACT.par); + // totalYards is always the actual sum of the tee's own hole yardages — + // never allowed to drift from what's really stored per hole. + expect(tee.holes.reduce((a, h) => a + h.yards, 0)).toBe(tee.totalYards); + } + }); + + it('the Championship tee totals exactly the requested championship yardage', () => { + const seed = buildCourseSeed(BASE_FACT); + const champ = seed.tees.find((t) => t.teeName === 'Championship')!; + expect(champ.totalYards).toBe(BASE_FACT.championshipYards); + }); + + it('Members and Forward tees are shorter than Championship (realistic tee progression)', () => { + const seed = buildCourseSeed(BASE_FACT); + const [champ, members, forward] = seed.tees; + expect(members!.totalYards).toBeLessThan(champ!.totalYards); + expect(forward!.totalYards).toBeLessThan(members!.totalYards); + expect(members!.slopeRating).toBeLessThanOrEqual(champ!.slopeRating); + expect(forward!.slopeRating).toBeLessThanOrEqual(members!.slopeRating); + }); + + it('never reuses one template — two different courses at the same par/yardage still get distinct routings', () => { + const courseA: CourseFact = { ...BASE_FACT, matchName: 'Course Alpha' }; + const courseB: CourseFact = { ...BASE_FACT, matchName: 'Course Bravo' }; + const seedA = buildCourseSeed(courseA); + const seedB = buildCourseSeed(courseB); + const parsA = seedA.tees[0]!.holes.map((h) => h.par); + const parsB = seedB.tees[0]!.holes.map((h) => h.par); + const yardsA = seedA.tees[0]!.holes.map((h) => h.yards); + const yardsB = seedB.tees[0]!.holes.map((h) => h.yards); + const hcpA = seedA.tees[0]!.holes.map((h) => h.hcp); + const hcpB = seedB.tees[0]!.holes.map((h) => h.hcp); + // At least one of the three dimensions must differ hole-by-hole — proves + // this isn't the same cloned scorecard under a different name. + const identical = JSON.stringify(parsA) === JSON.stringify(parsB) + && JSON.stringify(yardsA) === JSON.stringify(yardsB) + && JSON.stringify(hcpA) === JSON.stringify(hcpB); + expect(identical).toBe(false); + }); +}); diff --git a/src/lib/golf/course-scorecard-generator.ts b/src/lib/golf/course-scorecard-generator.ts new file mode 100644 index 000000000..16005ef6b --- /dev/null +++ b/src/lib/golf/course-scorecard-generator.ts @@ -0,0 +1,222 @@ +/** + * Deterministic per-course scorecard generator — pure, no I/O (fully + * unit-testable, same pattern as course-library.ts). Used by + * scripts/seed-course-library-scorecards.ts (#913 part 1/3, DATA) to turn a + * course's well-known top-line facts (par, championship yardage, rating, + * slope) into a full 18-hole routing + 3 tee sets (Championship / Members / + * Forward), WITHOUT ever reusing the same hole-by-hole layout across two + * courses. + * + * Seeded on the course name (mulberry32 PRNG over a string hash) so the same + * course always produces byte-identical output — the generator itself is + * idempotent, independent of when/how often the caller re-runs it. + */ + +export type TeeName = 'Championship' | 'Members' | 'Forward'; + +export interface CourseFact { + /** Must match `golf_courses.name` for an existing row after normalization + * (golf_normalize_name). Never used to rename anything. */ + matchName: string; + location: string; // display-only, for reporting + par: number; // 70 | 71 | 72 (see PAR_COMPOSITIONS) + championshipYards: number; + championshipRating: number; + championshipSlope: number; + sourceNote: string; +} + +export interface HoleRow { + hole: number; + par: number; + yards: number; + hcp: number; +} + +export interface TeeSeed { + teeName: TeeName; + totalYards: number; + totalPar: number; + courseRating: number; + slopeRating: number; + holes: HoleRow[]; +} + +export interface CourseSeed extends CourseFact { + tees: TeeSeed[]; +} + +/** Deterministic string-seeded PRNG (mulberry32, string-hashed seed). */ +export function seededRng(seed: string): () => number { + let h = 1779033703 ^ seed.length; + for (let i = 0; i < seed.length; i++) { + h = Math.imul(h ^ seed.charCodeAt(i), 3432918353); + h = (h << 13) | (h >>> 19); + } + let a = h >>> 0; + return () => { + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +export function shuffle(arr: readonly T[], rng: () => number): T[] { + const out = arr.slice(); + for (let i = out.length - 1; i > 0; i--) { + const j = Math.floor(rng() * (i + 1)); + [out[i], out[j]] = [out[j] as T, out[i] as T]; + } + return out; +} + +/** Real-world-typical par-3/par-4/par-5 counts for each supported course par. */ +export const PAR_COMPOSITIONS: Record = { + 70: { p3: 4, p4: 12, p5: 2 }, // 12 + 48 + 10 = 70 + 71: { p3: 4, p4: 11, p5: 3 }, // 12 + 44 + 15 = 71 + 72: { p3: 4, p4: 10, p5: 4 }, // 12 + 40 + 20 = 72 +}; + +/** Which of the 18 holes are par 3 / 4 / 5 — varied per course via `rng`. + * Returns an array of 18 par values, index 0 = hole 1 .. index 17 = hole 18. */ +export function buildParSequence(par: number, rng: () => number): number[] { + const comp = PAR_COMPOSITIONS[par]; + if (!comp) throw new Error(`Unsupported par ${par} (only 70/71/72 are wired)`); + + const splitCount = (n: number): [number, number] => { + const half = Math.floor(n / 2); + const extra = n - half * 2; // 0 or 1 + return rng() < 0.5 ? [half + extra, half] : [half, half + extra]; + }; + const [p3Front, p3Back] = splitCount(comp.p3); + const [p5Front, p5Back] = splitCount(comp.p5); + + const parByHole = new Array(19).fill(4); // 1-indexed; index 0 unused + const assign = (holes: number[], p3n: number, p5n: number) => { + // First p3n shuffled holes become par 3, next p5n become par 5, the rest + // (already-initialized) stay par 4. p3n + p5n is always well under 9 + // (the nine's hole count) for every composition in PAR_COMPOSITIONS. + const pool = shuffle(holes, rng); + for (let i = 0; i < p3n; i++) parByHole[pool[i] as number] = 3; + for (let i = 0; i < p5n; i++) parByHole[pool[p3n + i] as number] = 5; + }; + assign([1, 2, 3, 4, 5, 6, 7, 8, 9], p3Front, p5Front); + assign([10, 11, 12, 13, 14, 15, 16, 17, 18], p3Back, p5Back); + return parByHole.slice(1); // 0-indexed holes 1..18 +} + +const YARD_RANGE: Record = { + 3: [150, 235], + 4: [350, 480], + 5: [495, 590], +}; + +/** Per-hole yardages that SUM EXACTLY to `targetTotal` (any integer — real + * published totals are rarely multiples of 5), with the rounding remainder + * distributed 1 yard at a time across the longest holes. */ +export function buildYardages(pars: readonly number[], targetTotal: number, rng: () => number): number[] { + const base = pars.map((p) => { + const [lo, hi] = YARD_RANGE[p] as [number, number]; + return lo + rng() * (hi - lo); + }); + const baseSum = base.reduce((a, b) => a + b, 0); + const scale = targetTotal / baseSum; + const scaled = base.map((v) => Math.round(v * scale)); + + let diff = targetTotal - scaled.reduce((a, b) => a + b, 0); + const order = scaled + .map((_, idx) => idx) + .sort((a, b) => (scaled[b] as number) - (scaled[a] as number)); + let i = 0; + while (diff !== 0 && i < order.length * 8) { + const idx = order[i % order.length] as number; + const step = diff > 0 ? 1 : -1; + scaled[idx] = (scaled[idx] as number) + step; + diff -= step; + i++; + } + return scaled; +} + +/** Stroke index (1 = hardest .. 18 = easiest), varied per course via `rng`. */ +export function buildHandicaps(pars: readonly number[], yards: readonly number[], rng: () => number): number[] { + const scores = pars.map((par, i) => { + let s = yards[i] as number; + if (par === 4) s += 300; + else if (par === 5) s -= 150; + else s -= 250; // par 3s generally play relatively easier for their length + s += (rng() - 0.5) * 80; // jitter so equal-par holes don't tie mechanically + return s; + }); + const order = scores.map((_, i) => i).sort((a, b) => (scores[b] as number) - (scores[a] as number)); + const hcp = new Array(18).fill(0); + order.forEach((holeIdx, rank) => { hcp[holeIdx] = rank + 1; }); + + // Realism nudge: a par 3 essentially never carries stroke index 1 or 2 on a + // real card — swap it with the easiest-ranked non-par-3 hole if it does. + for (const targetRank of [1, 2]) { + const holeIdx = hcp.indexOf(targetRank); + if (holeIdx === -1 || pars[holeIdx] !== 3) continue; + let swapIdx = -1; + let swapRank = Infinity; + for (let i = 0; i < hcp.length; i++) { + if (i === holeIdx || pars[i] === 3) continue; + if ((hcp[i] as number) < swapRank) { swapRank = hcp[i] as number; swapIdx = i; } + } + if (swapIdx !== -1) { + hcp[holeIdx] = swapRank; + hcp[swapIdx] = targetRank; + } + } + return hcp; +} + +const round1 = (n: number): number => Math.round(n * 10) / 10; +const round5 = (n: number): number => Math.round(n / 5) * 5; + +/** Turn a course's top-line facts into a full 18-hole routing + 3 tee sets. */ +export function buildCourseSeed(fact: CourseFact): CourseSeed { + const rng = seededRng(fact.matchName); + const pars = buildParSequence(fact.par, rng); + const champYards = buildYardages(pars, fact.championshipYards, rng); + const hcp = buildHandicaps(pars, champYards, rng); + + const membersFactor = 0.9 + (rng() - 0.5) * 0.04; // 0.88–0.92 + const forwardFactor = 0.76 + (rng() - 0.5) * 0.06; // 0.73–0.79 + const membersYards = buildYardages(pars, round5(fact.championshipYards * membersFactor), rng); + const forwardYards = buildYardages(pars, round5(fact.championshipYards * forwardFactor), rng); + + const membersRating = round1(fact.championshipRating - (1.5 + rng() * 1.0)); + const membersSlope = Math.max(95, Math.round(fact.championshipSlope - (6 + rng() * 6))); + const forwardRating = round1(fact.championshipRating - (5 + rng() * 2)); + const forwardSlope = Math.max(90, Math.round(fact.championshipSlope - (16 + rng() * 8))); + + const teeFrom = ( + teeName: TeeName, + yards: number[], + courseRating: number, + slopeRating: number, + ): TeeSeed => { + const holes: HoleRow[] = pars.map((par, i) => ({ + hole: i + 1, par, yards: yards[i] as number, hcp: hcp[i] as number, + })); + return { + teeName, + totalYards: holes.reduce((a, h) => a + h.yards, 0), + totalPar: fact.par, + courseRating, + slopeRating, + holes, + }; + }; + + return { + ...fact, + tees: [ + teeFrom('Championship', champYards, fact.championshipRating, fact.championshipSlope), + teeFrom('Members', membersYards, membersRating, membersSlope), + teeFrom('Forward', forwardYards, forwardRating, forwardSlope), + ], + }; +} diff --git a/supabase/migrations/20260717120000_course_library_owner_gate.sql b/supabase/migrations/20260717120000_course_library_owner_gate.sql new file mode 100644 index 000000000..acd6eccb9 --- /dev/null +++ b/supabase/migrations/20260717120000_course_library_owner_gate.sql @@ -0,0 +1,54 @@ +-- ============================================================================ +-- Course Library — gate shared LIBRARY-owned courses behind super admin +-- ---------------------------------------------------------------------------- +-- Fixes #913 part 2 (RLS half). The app-layer guard (updateCourse / +-- softDeleteCourse in src/app/golf/actions/course-library.ts) already blocks +-- a non-admin coach from editing or removing a course with no human creator +-- (created_by_user_id IS NULL — the signal for "shipped with the shared +-- library, nobody on any team owns this row"). But the RLS policy underneath +-- it — golf_courses_update_authenticated, from +-- 20260614010000_course_library_update_policy.sql — still allows ANY +-- authenticated user to UPDATE ANY golf_courses row (including +-- soft-deleting one via `deleted_at`), so a caller that talks to +-- PostgREST/Supabase directly (bypassing the Next.js server action) could +-- still rename or remove a shared library course. This migration closes +-- that gap at the DB layer so the two enforcement points can't drift. +-- +-- New rule: an authenticated user may UPDATE a golf_courses row when EITHER +-- (a) it already has a human creator (created_by_user_id IS NOT NULL — +-- a team/coach's own contribution, unaffected: open contribution as +-- designed in 20260613160000_course_library_phase1.sql), OR +-- (b) the caller is a super admin (public.is_super_admin(), from +-- 20260701110000_admin_allowlist_is_super_admin.sql). +-- +-- created_by_user_id is never itself mutated by updateCourse, so USING +-- (pre-update row) and WITH CHECK (post-update row) evaluate the same +-- condition here — both are included per Postgres RLS convention for UPDATE. +-- +-- THIS MIGRATION IS COMMITTED FOR REVIEW as part of the #913 PR. It has NOT +-- been applied to any live environment by the agent that wrote it — CI's +-- `supabase start` + `supabase db lint` + pgTAP suite (see +-- supabase/tests/rls/golf_course_library.sql) will run it against an +-- ephemeral local stack, but a human must review and apply it (via the +-- normal deploy process) before it takes effect on staging/prod. +-- +-- Idempotent: safe to re-run. +-- ============================================================================ + +DROP POLICY IF EXISTS golf_courses_update_authenticated ON public.golf_courses; + +CREATE POLICY golf_courses_update_authenticated + ON public.golf_courses + FOR UPDATE + TO authenticated + USING ( + auth.uid() IS NOT NULL + AND (created_by_user_id IS NOT NULL OR public.is_super_admin()) + ) + WITH CHECK ( + auth.uid() IS NOT NULL + AND (created_by_user_id IS NOT NULL OR public.is_super_admin()) + ); + +COMMENT ON POLICY golf_courses_update_authenticated ON public.golf_courses IS + 'Open contribution for team/user-owned courses (created_by_user_id set); shared LIBRARY rows (created_by_user_id NULL — no human creator) require public.is_super_admin(). Mirrors the app-layer guard in updateCourse/softDeleteCourse (src/app/golf/actions/course-library.ts) — #913 part 2.'; diff --git a/supabase/tests/rls/golf_course_library.sql b/supabase/tests/rls/golf_course_library.sql index 4da39d256..b231c532b 100644 --- a/supabase/tests/rls/golf_course_library.sql +++ b/supabase/tests/rls/golf_course_library.sql @@ -11,7 +11,7 @@ BEGIN; \ir _helpers.sql -SELECT plan(37); +SELECT plan(39); -- ============================================================================ -- A. RLS enabled on every new table (5) @@ -136,5 +136,20 @@ SELECT ok( WHERE schemaname='public' AND indexname='golf_team_saved_courses_team_course_uidx'), 'unique index on (team_id, course_id) prevents duplicate team saves'); +-- ============================================================================ +-- J. #913 part 2 — golf_courses UPDATE is owner-or-admin gated, not wide open (2) +-- A library row (created_by_user_id NULL) may only be edited/removed by +-- public.is_super_admin(); a team/user-owned row keeps open contribution. +-- See 20260717120000_course_library_owner_gate.sql. +-- ============================================================================ +SELECT ok( + tests.policy_with_check_contains('public','golf_courses','golf_courses_update_authenticated','created_by_user_id'), + 'golf_courses UPDATE WITH CHECK references created_by_user_id (owner gate, not just auth.uid())' +); +SELECT ok( + tests.policy_with_check_contains('public','golf_courses','golf_courses_update_authenticated','is_super_admin'), + 'golf_courses UPDATE WITH CHECK carries an is_super_admin() escape hatch for library rows' +); + SELECT * FROM finish(); ROLLBACK;