From 9b6617309db7e914786c05a48db07ef0fda314e3 Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Sat, 28 Feb 2026 23:16:06 -0800 Subject: [PATCH] feat: add hide completed sports filter to AvailableParticipantsSection (#47) --- .../draft/AvailableParticipantsSection.tsx | 31 +- .../leagues/$leagueId.draft.$seasonId.tsx | 19 +- plans/public-api-v1.md | 608 ++++++++++++++++++ 3 files changed, 655 insertions(+), 3 deletions(-) create mode 100644 plans/public-api-v1.md diff --git a/app/components/draft/AvailableParticipantsSection.tsx b/app/components/draft/AvailableParticipantsSection.tsx index cd60608..e105cce 100644 --- a/app/components/draft/AvailableParticipantsSection.tsx +++ b/app/components/draft/AvailableParticipantsSection.tsx @@ -34,6 +34,7 @@ interface AvailableParticipantsSectionProps { sportFilter: string; hideDrafted: boolean; hideIneligible: boolean; + hideCompletedSports: boolean; uniqueSports: string[]; draftedParticipantIds: Set; queue: Array<{ id: string; participantId: string }>; @@ -47,6 +48,7 @@ interface AvailableParticipantsSectionProps { onSportFilterChange: (sport: string) => void; onHideDraftedChange: (hide: boolean) => void; onHideIneligibleChange: (hide: boolean) => void; + onHideCompletedSportsChange: (hide: boolean) => void; onMakePick: (participantId: string) => void; onAddToQueue: (participantId: string) => void; onRemoveFromQueue: (queueId: string) => void; @@ -58,6 +60,7 @@ export function AvailableParticipantsSection({ sportFilter, hideDrafted, hideIneligible, + hideCompletedSports, uniqueSports, draftedParticipantIds, queue, @@ -68,6 +71,7 @@ export function AvailableParticipantsSection({ onSportFilterChange, onHideDraftedChange, onHideIneligibleChange, + onHideCompletedSportsChange, onMakePick, onAddToQueue, onRemoveFromQueue, @@ -77,6 +81,16 @@ export function AvailableParticipantsSection({ [queue] ); + const emptyMessage = useMemo(() => { + if (participants.length > 0) return null; + const active: string[] = []; + if (hideDrafted) active.push("drafted players"); + if (hideIneligible && eligibility) active.push("ineligible players"); + if (hideCompletedSports) active.push("drafted sports"); + if (active.length === 0) return "No participants found."; + return `No participants found. Try showing ${active.join(", ")}.`; + }, [participants.length, hideDrafted, hideIneligible, hideCompletedSports, eligibility]); + return (
@@ -128,6 +142,19 @@ export function AvailableParticipantsSection({ Show Ineligible )} + + {/* Show drafted sports - only show if user has a team */} + {hasTeam && ( + + )}
@@ -136,7 +163,7 @@ export function AvailableParticipantsSection({
{participants.length === 0 ? (
- No participants found + {emptyMessage}
) : ( participants.map((participant) => { @@ -256,7 +283,7 @@ export function AvailableParticipantsSection({ colSpan={hasTeam ? 3 : 2} className="text-center py-8 text-muted-foreground" > - No participants found + {emptyMessage} ) : ( diff --git a/app/routes/leagues/$leagueId.draft.$seasonId.tsx b/app/routes/leagues/$leagueId.draft.$seasonId.tsx index ac58bd3..5e54844 100644 --- a/app/routes/leagues/$leagueId.draft.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.draft.$seasonId.tsx @@ -335,6 +335,7 @@ export default function DraftRoom() { const [searchQuery, setSearchQuery] = useState(""); const [hideDrafted, setHideDrafted] = useState(true); const [hideIneligible, setHideIneligible] = useState(true); + const [hideCompletedSports, setHideCompletedSports] = useState(false); const [sportFilter, setSportFilter] = useState("all"); const [queue, setQueue] = useState(userQueue); const [isPaused, setIsPaused] = useState(season.draftPaused || false); @@ -1083,6 +1084,16 @@ export default function DraftRoom() { [picks] ); + // Sport IDs where the current user's team has already made at least one pick + const userDraftedSportIds = useMemo(() => { + if (!userTeam) return new Set(); + return new Set( + picks + .filter((p: any) => p.team.id === userTeam.id) + .map((p: any) => p.sport.id) + ); + }, [picks, userTeam]); + // Get unique sports for filter dropdown const uniqueSports = useMemo( () => @@ -1107,6 +1118,10 @@ export default function DraftRoom() { return false; } } + // Hide completed sports filter - hide participants from sports already drafted by user's team + if (hideCompletedSports && userDraftedSportIds.has(participant.sport.id)) { + return false; + } // Search filter if ( searchQuery && @@ -1120,7 +1135,7 @@ export default function DraftRoom() { } return true; }), - [availableParticipants, hideDrafted, hideIneligible, eligibility, draftedParticipantIds, searchQuery, sportFilter] + [availableParticipants, hideDrafted, hideIneligible, hideCompletedSports, eligibility, draftedParticipantIds, userDraftedSportIds, searchQuery, sportFilter] ); // Shared component props — defined once to avoid duplication between desktop and mobile layouts @@ -1140,6 +1155,8 @@ export default function DraftRoom() { onSportFilterChange: setSportFilter, onHideDraftedChange: setHideDrafted, onHideIneligibleChange: setHideIneligible, + hideCompletedSports, + onHideCompletedSportsChange: setHideCompletedSports, onMakePick: handleMakePick, onAddToQueue: handleAddToQueue, onRemoveFromQueue: handleRemoveFromQueue, diff --git a/plans/public-api-v1.md b/plans/public-api-v1.md new file mode 100644 index 0000000..a71634d --- /dev/null +++ b/plans/public-api-v1.md @@ -0,0 +1,608 @@ +# Public API v1 — Implementation Plan + +## Overview + +Add a public REST API to Brackt.com, starting with a single `GET /api/v1/leagues/:leagueId/draft-board` endpoint. The API is read-only, requires API keys (via Clerk's built-in API keys feature), rate-limited at 60 req/min per user, versioned, and documented with OpenAPI + Scalar. + +--- + +## Decisions Made + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Auth | Clerk API keys (beta) | Free during beta, zero custom UI needed — tab auto-appears in UserProfile | +| Access model | Public leagues only + key owner must be member | Balances openness with privacy | +| "Member" definition | Team owner OR commissioner OR system admin | Admins can always access for debugging | +| Read/write | Read-only for now | Dramatically simpler to secure | +| Rate limiting | 60 req/min per userId | Conservative starting point, easy to raise | +| Versioning | `/api/v1/` URL prefix | Best DX for curl/spreadsheet users | +| Docs | OpenAPI spec + Scalar renderer | Auto-generates interactive playground, spec is reusable | +| Timer state | Excluded from response | Stale immediately; document as "snapshot data" | +| Recommended polling interval | Every 15 seconds | Document in API docs, not enforced in code | +| Rate limit store | In-memory to start | Swap to `rate-limit-redis` when going multi-instance | + +--- + +## Scope + +### In Scope (Phase 1) +- Clerk API keys enabled in dashboard (manual setup step) +- API key verification middleware with in-memory caching (60s TTL) +- Per-userId rate limiting middleware +- `GET /api/v1/leagues/:leagueId/draft-board` endpoint +- Standard JSON error format for all v1 routes +- OpenAPI spec (`docs/openapi.yaml`) +- Scalar docs page at `/api/docs` + +### Out of Scope (Future) +- Additional endpoints (standings, participants, teams, etc.) +- Redis rate limit store (add when going multi-instance) +- Usage analytics / per-key call counting +- SDK generation from OpenAPI spec +- Webhook notifications for draft events +- Tiered rate limits + +--- + +## Files + +### New Files +``` +server/middleware/apiAuth.ts API key verification + scope check + in-memory cache +server/middleware/apiRateLimit.ts Per-userId rate limiting (60 req/min) +server/routes/apiV1.ts Express router for /api/v1/* +docs/openapi.yaml OpenAPI 3.1 spec +``` + +### Modified Files +``` +server/app.ts Mount apiV1 router before React Router handler +``` + +### No Changes Needed +- `app/routes.ts` — these are Express routes, not React Router routes +- Database schema — no new tables (Clerk manages key storage) +- Existing models — reuse as-is for data fetching + +--- + +## Step-by-Step Implementation + +### Step 0: Clerk Dashboard Setup (Manual — Do First) + +1. Go to Clerk Dashboard → Configure → **API Keys** +2. Click **Enable API Keys** +3. Select **Enable User API Keys** +4. Click **Enable** + +That's it. An "API Keys" tab will now automatically appear in the existing `` component — users can create, view, and revoke their own keys without any additional frontend code. + +Scope enforcement (`read:draft-board`) is handled in your own middleware, not configured in Clerk. + +--- + +### Step 1: API Key Auth Middleware + +**File:** `server/middleware/apiAuth.ts` + +**Responsibilities:** +- Extract Bearer token from `Authorization` header +- Check in-memory cache (avoid Clerk network call on every request) +- On cache miss: call `clerkClient.apiKeys.verify(token)` +- Validate that the key has the `read:draft-board` scope +- Attach `req.apiUser = { userId: string, scopes: string[] }` for downstream handlers +- Return standardized JSON errors (never HTML) + +**In-memory cache design:** +```typescript +type CachedKey = { + userId: string // Clerk user ID (subject from Clerk) + scopes: string[] + cachedAt: number // Date.now() +} + +const cache = new Map() +const CACHE_TTL_MS = 60_000 // 60 seconds +``` + +On each request: +1. Extract token +2. Hash it (sha256) for the cache key — don't store raw tokens in memory +3. Check cache: if hit and `Date.now() - cachedAt < TTL`, use cached value +4. On miss: call `clerkClient.apiKeys.verify(token)`, cache result + +**Error responses:** +- Missing/malformed header → `401 { "error": "Missing API key", "status": 401 }` +- Invalid/revoked key → `401 { "error": "Invalid API key", "status": 401 }` +- Missing required scope → `403 { "error": "Insufficient scope", "status": 403 }` + +**Extend Express Request type:** +```typescript +// In server/types/express.d.ts (create if needed) +declare namespace Express { + interface Request { + apiUser?: { + userId: string + scopes: string[] + } + } +} +``` + +--- + +### Step 2: Rate Limit Middleware + +**File:** `server/middleware/apiRateLimit.ts` + +**Package:** `express-rate-limit` (already a common dep, or install it) + +**Config:** +```typescript +rateLimit({ + windowMs: 60 * 1000, // 1 minute + max: 60, // 60 requests per window + keyGenerator: (req) => req.apiUser!.userId, // keyed on userId, not IP + standardHeaders: true, // Returns X-RateLimit-* headers + legacyHeaders: false, + handler: (req, res) => { + res.status(429).json({ + error: 'Rate limit exceeded. Maximum 60 requests per minute.', + status: 429, + retryAfter: res.getHeader('Retry-After'), + }) + }, +}) +``` + +**Important:** This middleware must run **after** `apiAuth` (needs `req.apiUser.userId`). + +**Future:** Swap `store` to `new RedisStore(...)` from `rate-limit-redis` when adding Redis sidecar for multi-instance support. + +--- + +### Step 3: Draft Board Endpoint + +**File:** `server/routes/apiV1.ts` + +**Route:** `GET /api/v1/leagues/:leagueId/draft-board` + +**Middleware chain:** `apiAuth` → `apiRateLimit` → handler + +**Handler logic:** + +1. Load league by `leagueId` (use existing `findLeagueById` from `app/models/league.ts`) +2. Check `league.isPublicDraftBoard === true` → else `403 { "error": "This league's draft board is not public" }` +3. Load league's seasons to get the current/active season +4. Authorization: is `req.apiUser.userId` any of: + - System admin: `user.isAdmin === true` + - Commissioner: exists in `commissioners` table for this league + - Team owner: owns a team in any of the league's seasons + → else `403 { "error": "You are not a member of this league" }` +5. Load draft data using existing model functions: + - `draftPicks` for the current season + - `draftSlots` for draft order + - Available participants (not yet picked) + - Team list +6. Return shaped response (see below) + +**Response shape:** +```json +{ + "league": { + "id": "uuid", + "name": "My Fantasy League" + }, + "season": { + "id": "uuid", + "year": 2025, + "status": "draft", + "draftRounds": 10, + "currentPickNumber": 15, + "totalPicks": 100, + "draftPaused": false, + "draftStartedAt": "2025-01-15T18:00:00Z" + }, + "teams": [ + { + "id": "uuid", + "name": "Team Alpha", + "draftOrder": 1 + } + ], + "picks": [ + { + "pickNumber": 1, + "round": 1, + "pickInRound": 1, + "team": { "id": "uuid", "name": "Team Alpha" }, + "participant": { + "id": "uuid", + "name": "Patrick Mahomes", + "sport": { "name": "NFL", "slug": "nfl" } + } + } + ], + "availableParticipants": [ + { + "id": "uuid", + "name": "Josh Allen", + "sport": { "name": "NFL", "slug": "nfl" } + } + ], + "retrievedAt": "2025-01-15T18:05:30Z" +} +``` + +**Notes on response design:** +- `retrievedAt` timestamp helps clients know how stale the data is +- Timer/time bank state intentionally omitted — stale immediately +- No autodraft settings or queue data — those are private per-team +- `totalPicks` = `draftRounds * teams.length`, useful for clients to calculate completion % + +--- + +### Step 4: Standard Error Handler for v1 Router + +All errors from `/api/v1/*` must return JSON, never HTML (Express's default). + +In `apiV1.ts`, add an error handler at the bottom of the router: + +```typescript +// Catch-all error handler for v1 routes +router.use((err, req, res, next) => { + console.error('[API v1 error]', err) + res.status(err.status ?? 500).json({ + error: err.message ?? 'Internal server error', + status: err.status ?? 500, + }) +}) +``` + +Also handle 404s within the v1 namespace: +```typescript +router.use((req, res) => { + res.status(404).json({ error: 'Endpoint not found', status: 404 }) +}) +``` + +--- + +### Step 5: Mount in Express + +**File:** `server/app.ts` + +Add **before** the React Router handler: + +```typescript +import { apiV1Router } from './routes/apiV1' + +// Public API — must come before React Router catch-all +app.use('/api/v1', apiV1Router) +``` + +This ensures `/api/v1/*` requests never reach React Router. + +--- + +### Step 6: OpenAPI Spec + +**File:** `docs/openapi.yaml` + +```yaml +openapi: 3.1.0 +info: + title: Brackt API + version: 1.0.0 + description: | + The Brackt public API provides read-only access to fantasy draft data + for public leagues you are a member of. + + ## Authentication + All requests require an API key sent as a Bearer token: + ``` + Authorization: Bearer your_api_key + ``` + Generate API keys from your account settings page. + + ## Rate Limiting + 60 requests per minute per API key. Responses include X-RateLimit-* + headers. Exceeding the limit returns HTTP 429. + + ## Polling + This is a snapshot API. For live drafts, we recommend polling no more + frequently than every 10–15 seconds. Timer state is not included in + responses as it changes every second. + +servers: + - url: https://brackt.com/api/v1 + description: Production + +security: + - bearerAuth: [] + +paths: + /leagues/{leagueId}/draft-board: + get: + summary: Get draft board state + description: | + Returns the current state of a league's draft board, including all + picks made, available participants, and team draft order. + + The league must have its draft board set to public, and your API key + must belong to a member of the league (team owner, commissioner, or + admin). + operationId: getDraftBoard + tags: [Draft] + parameters: + - name: leagueId + in: path + required: true + schema: + type: string + format: uuid + description: The league's UUID + responses: + '200': + description: Draft board state + content: + application/json: + schema: + $ref: '#/components/schemas/DraftBoard' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/RateLimited' + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + description: API key generated from your account settings + + responses: + Unauthorized: + description: Missing or invalid API key + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + Forbidden: + description: Valid key but insufficient permissions + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + NotFound: + description: Resource not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + RateLimited: + description: Rate limit exceeded + headers: + Retry-After: + schema: + type: integer + description: Seconds until the rate limit window resets + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + schemas: + Error: + type: object + required: [error, status] + properties: + error: + type: string + status: + type: integer + + DraftBoard: + type: object + required: [league, season, teams, picks, availableParticipants, retrievedAt] + properties: + league: + type: object + properties: + id: { type: string, format: uuid } + name: { type: string } + season: + type: object + properties: + id: { type: string, format: uuid } + year: { type: integer } + status: + type: string + enum: [pre_draft, draft, active, completed] + draftRounds: { type: integer } + currentPickNumber: { type: integer } + totalPicks: { type: integer } + draftPaused: { type: boolean } + draftStartedAt: { type: string, format: date-time, nullable: true } + teams: + type: array + items: + type: object + properties: + id: { type: string, format: uuid } + name: { type: string } + draftOrder: { type: integer } + picks: + type: array + items: + type: object + properties: + pickNumber: { type: integer } + round: { type: integer } + pickInRound: { type: integer } + team: + type: object + properties: + id: { type: string, format: uuid } + name: { type: string } + participant: + type: object + properties: + id: { type: string, format: uuid } + name: { type: string } + sport: + type: object + properties: + name: { type: string } + slug: { type: string } + availableParticipants: + type: array + items: + type: object + properties: + id: { type: string, format: uuid } + name: { type: string } + sport: + type: object + properties: + name: { type: string } + slug: { type: string } + retrievedAt: + type: string + format: date-time +``` + +--- + +### Step 7: Scalar Docs Page + +Scalar can be served as a single HTML file from Express. No npm package needed for the basic setup (it loads from CDN): + +**In `server/routes/apiV1.ts` or a separate route in `server/app.ts`:** + +```typescript +app.get('/api/docs', (req, res) => { + res.send(` + + + + Brackt API Reference + + + + + + + + `) +}) + +// Serve the OpenAPI spec itself +app.get('/api/docs/openapi.yaml', (req, res) => { + res.sendFile(path.join(process.cwd(), 'docs/openapi.yaml')) +}) +``` + +This gives a full interactive API playground at `https://brackt.com/api/docs`. + +--- + +## Authorization Logic (Detailed) + +For the draft board endpoint, the userId extracted from the API key needs to map to a Clerk user in the database. The Clerk `subject` field returns a Clerk user ID (e.g., `user_abc123`). This matches the `clerkId` field on the `users` table. + +**Lookup chain:** +```typescript +// 1. Find user by clerkId +const user = await findUserByClerkId(req.apiUser.userId) +if (!user) return 403 + +// 2. Check admin +if (user.isAdmin) → authorized + +// 3. Check commissioner +const isCommissioner = await isLeagueCommissioner(leagueId, user.id) +if (isCommissioner) → authorized + +// 4. Check team owner — user owns a team in any season of this league +const isTeamOwner = await hasTeamInLeague(leagueId, user.clerkId) +if (isTeamOwner) → authorized + +// else → 403 +``` + +The `ownerId` on teams stores the Clerk user ID directly, so the team owner check queries: +```sql +SELECT 1 FROM teams t +JOIN seasons s ON t.season_id = s.id +WHERE s.league_id = :leagueId AND t.owner_id = :clerkId +LIMIT 1 +``` + +--- + +## Future Endpoints (Not In Scope Now — For Reference) + +When ready to expand, natural next endpoints: + +``` +GET /api/v1/leagues/:leagueId/seasons List seasons for a league +GET /api/v1/leagues/:leagueId/standings/:seasonId Final standings +GET /api/v1/leagues/:leagueId/teams/:teamId Team roster/picks +GET /api/v1/participants/:sportSlug All participants for a sport +``` + +Each would need the same auth + rate limit middleware chain, with scopes like `read:standings`, `read:teams`, etc. + +--- + +## Security Checklist + +- [ ] API keys never logged (only hash stored in cache) +- [ ] Rate limiting keyed on userId, not IP (harder to bypass with rotating IPs) +- [ ] `isPublicDraftBoard` checked before any data is returned +- [ ] Membership verified before any data is returned +- [ ] All errors return JSON (no stack traces in production) +- [ ] `retrievedAt` timestamp but no internal IDs beyond what's needed +- [ ] No autodraft, queue, or timer data in response (private/volatile) +- [ ] OpenAPI spec served from static file (no runtime generation risk) + +--- + +## Testing Plan + +### Unit Tests +- `apiAuth` middleware: valid key, invalid key, expired cache, scope missing, no header +- `apiRateLimit` middleware: under limit passes, over limit returns 429 with headers +- Draft board handler: public league + member → 200, private league → 403, non-member → 403 + +### Manual Tests +1. Enable Clerk API keys in dashboard +2. Generate a key from UserProfile in the app +3. `curl -H "Authorization: Bearer " https://localhost:3000/api/v1/leagues//draft-board` +4. Verify 403 on private league +5. Verify 403 on public league where user is not a member +6. Verify 429 after 60 requests in a minute +7. Verify `/api/docs` loads and shows the playground + +--- + +## Dependencies to Install + +```bash +npm install express-rate-limit +``` + +`@clerk/backend` is already installed (used for webhook verification). The `clerkClient.apiKeys.verify()` method is available in the existing Clerk backend client. + +Scalar docs load from CDN — no npm install needed for basic setup. If you want to self-host: +```bash +npm install @scalar/api-reference # optional +```