feat: add hide completed sports filter to AvailableParticipantsSection (#47)
This commit is contained in:
parent
0ac9ef4ff8
commit
9b6617309d
3 changed files with 655 additions and 3 deletions
|
|
@ -34,6 +34,7 @@ interface AvailableParticipantsSectionProps {
|
|||
sportFilter: string;
|
||||
hideDrafted: boolean;
|
||||
hideIneligible: boolean;
|
||||
hideCompletedSports: boolean;
|
||||
uniqueSports: string[];
|
||||
draftedParticipantIds: Set<string>;
|
||||
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 (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="px-4 pt-4 pb-2 flex-shrink-0">
|
||||
|
|
@ -128,6 +142,19 @@ export function AvailableParticipantsSection({
|
|||
<span>Show Ineligible</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Show drafted sports - only show if user has a team */}
|
||||
{hasTeam && (
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer whitespace-nowrap px-3 py-2 border rounded-md">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!hideCompletedSports}
|
||||
onChange={(e) => onHideCompletedSportsChange(!e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span>Show drafted sports</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -136,7 +163,7 @@ export function AvailableParticipantsSection({
|
|||
<div className="flex md:hidden flex-1 overflow-y-auto flex-col gap-2 px-4 py-2">
|
||||
{participants.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||
No participants found
|
||||
{emptyMessage}
|
||||
</div>
|
||||
) : (
|
||||
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}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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<string>("all");
|
||||
const [queue, setQueue] = useState<QueueItem[]>(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<string>();
|
||||
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,
|
||||
|
|
|
|||
608
plans/public-api-v1.md
Normal file
608
plans/public-api-v1.md
Normal file
|
|
@ -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 `<UserProfile />` 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<string, CachedKey>()
|
||||
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(`
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Brackt API Reference</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</head>
|
||||
<body>
|
||||
<script
|
||||
id="api-reference"
|
||||
data-url="/api/docs/openapi.yaml"
|
||||
src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
|
||||
</body>
|
||||
</html>
|
||||
`)
|
||||
})
|
||||
|
||||
// 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 <key>" https://localhost:3000/api/v1/leagues/<id>/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
|
||||
```
|
||||
Loading…
Add table
Reference in a new issue