KLUB-241: Endpoint Migration Pattern

This is the migration and parity-test contract for KLUB-244, KLUB-246, KLUB-247, and KLUB-254. The goal is to keep migrated API routes as thin Astro adapters while preserving the current user-visible behavior unless this document explicitly defines the schema-v2 replacement behavior.

Decisions locked by this contract:

  1. Migrated API routes authenticate with an API-safe requireAuth(locals.user) guard that returns 401 JSON. The legacy redirecting requireAuth(request, context) implementation is not used by API routes.
  2. Migrated routes write schema-v2 state. Parity tests assert equivalent business state, not identical legacy table names.
  3. GET /api/fantasy-leagues/format-availability is public and returns the wire format defined below.
  4. Public join retains its existing 200/202/409 behavior. The domain service must support no-create joins; it must not create a league when the HTTP adapter requests the pool flow.
  5. KLUB-243 establishes the first API-safe adapter, error mapper, and schema-v2 parity example. KLUB-244, KLUB-246, KLUB-247, and KLUB-254 copy it after it merges.

Current sprint endpoint audit:

Endpoint Current pattern Migration target
POST /api/roster/update Inline locals.user auth, inline window/team/lineup validation, writes active_rosters and active_rosters_history. KLUB-243 reference adapter after migration.
POST /api/draft/:leagueId/pick Inline participant, turn, pick-rule, transaction, Supabase broadcast logic. Adapter validates auth/body, service performs pick and returns broadcast payload data.
POST /api/draft/:leagueId/auto-pick Uses verifyDraftAuth(context, "participant"), then inline auto-pick transaction and broadcast. Adapter validates participant auth, service performs auto-pick and returns broadcast payload data.
POST /api/draft/:leagueId/complete Uses verifyDraftAuth(context, "owner"), updates league draft completion directly. Adapter validates owner auth, service completes draft lifecycle.
POST /api/fantasy-leagues/create Inline auth, body validation, season/window creation, game limits, draft settings, Trigger scheduling. Adapter keeps wire shape, service creates league and owned rows, adapter handles non-transactional scheduling.
POST /api/fantasy-leagues/public/join Zod body validation, inline auth, calls legacy public-league service, optionally triggers pool refill. Adapter calls the domain public-league service with allowCreate: false and keeps 200/202/409 response shapes.
GET /api/fantasy-leagues/format-availability No source route exists today. Domain support exists in LeagueService.checkFormatAvailability. Public read adapter maps domain format names to the defined MID/FULL wire response.

Required adapter-to-service calls:

Endpoint Service call
POST /api/roster/update RosterService.setLineup(fantasyTeamId, windowId, teamIds) after the adapter resolves the caller's team and validates the window state.
POST /api/draft/:leagueId/pick DraftService.makePick({ leagueId, userId, teamId }).
POST /api/draft/:leagueId/auto-pick DraftService.autoPick(leagueId).
POST /api/draft/:leagueId/complete DraftService.complete({ leagueId, userId }); this operation updates the league's active/completed state atomically.
POST /api/fantasy-leagues/create LeagueService.createWithSetup(input); this operation atomically creates the league, owner team, owner participant, game limits, windows, and draft settings.
POST /api/fantasy-leagues/public/join PublicLeagueService.join({ userId, format, season, allowCreate: false }).
GET /api/fantasy-leagues/format-availability LeagueService.checkFormatAvailability(currentSeason).

Endpoint Migration Pattern

Every migrated endpoint follows this structure:

  1. Read locals.user from the Astro API context. Do not call Supabase directly from the route.
  2. Call the API-safe requireAuth(locals.user). It returns the authenticated user or Response.json({ error: "Unauthorized" }, { status: 401 }); API routes never use redirecting page auth.
  3. Validate route params first, then body or query params. Invalid JSON or invalid schema returns 400 with the existing error shape for that endpoint.
  4. Validate league membership or ownership before calling a service when the route is league-scoped.
  5. Convert wire input into domain input. Keep legacy request names such as leagueFormat: "MID" | "FULL" at the API boundary.
  6. Call one domain service method. The route must not contain business-rule SQL or multi-step domain decisions.
  7. Map typed service errors to HTTP responses in the route adapter.
  8. Return Response.json(payload, { status }) consistently.

Target adapter shape:

export const POST: APIRoute = async (context) => {
  const { request, locals, params } = context;
  const auth = requireAuth(locals.user);
  if (auth instanceof Response) return auth;
  const user = auth;

  const leagueId = params.leagueId;
  if (!leagueId) {
    return Response.json({ error: "League ID is required" }, { status: 400 });
  }

  try {
    const payload = parseRequest(await request.json());
    const result = await service.run({ ...payload, leagueId, userId: user.id });
    return Response.json(toResponseBody(result), { status: 200 });
  } catch (error) {
    return mapServiceError(error);
  }
};

Auth / Membership Rules

All migrated endpoints use one of these access levels:

Endpoint Required auth rule
/api/roster/update Authenticated user must own a fantasy_teams row for the target league/window. Missing team remains 404 to match current behavior.
/api/draft/:leagueId/pick Authenticated user must be a league participant and it must be their turn. Non-participant remains 403. Wrong turn remains 403.
/api/draft/:leagueId/auto-pick Authenticated user must be a league participant. The adapter replaces verifyDraftAuth(context, "participant") with the standard API auth guard plus participant check, preserving its 403 response.
/api/draft/:leagueId/complete Authenticated user must be the league owner. The adapter replaces verifyDraftAuth(context, "owner") with the standard API auth guard plus owner check, preserving 403 for non-owners and 404 for a missing league.
/api/fantasy-leagues/create Authenticated user only. Owner membership is created by the service.
/api/fantasy-leagues/public/join Authenticated user only. Public membership rules are enforced by the public-league service.
/api/fantasy-leagues/format-availability Public GET; no auth, no query parameters. The server resolves the current season and returns only format availability.

KLUB-243 refactors the unused requireAuth export in src/middleware/auth.ts to requireAuth(user: User | null): User | Response. It returns the user when present and Response.json({ error: "Unauthorized" }, { status: 401 }) otherwise. Redirect behavior is removed from this helper; page redirects remain middleware behavior.

Every protected migrated endpoint has this authorization parity: unauthenticated 401 { error: "Unauthorized" }; authenticated but ineligible callers retain the route-specific 403 or 404 behavior listed below.

Service Adapter Pattern

The domain service owns business rules and DB side effects. The Astro route owns HTTP concerns only.

Service responsibilities:

  1. Accept plain typed input such as userId, leagueId, teamId, teamIds, format, and season.
  2. Use repositories or db.transaction() for all business writes.
  3. Throw typed errors such as DraftServiceError, RosterServiceError, LeagueServiceError, or PublicLeagueServiceError.
  4. Return domain results that contain enough data for the adapter to build the legacy response and any Supabase broadcast payload.
  5. Use schema-v2 repositories. The adapter must not mix legacy @/db/schema writes with schema-v2 service writes.

Adapter responsibilities:

  1. Keep request and response wire compatibility.
  2. Map legacy format values at the edge: MID -> CAMPAIGN_8, FULL -> REST_OF_SEASON when calling domain services.
  3. Map service errors to the status and JSON shape captured by parity tests.
  4. Execute external non-transactional side effects after service success when they are not part of the domain invariant, such as Trigger.dev draft scheduling or Supabase broadcasts.
  5. Log unexpected failures and return the route's legacy 500 response shape.

Schema-v2 side-effect equivalence:

Legacy observable write Schema-v2 parity assertion
active_rosters active rows Exactly four window_lineups rows for the fantasy team/window, with the selected clubs and positions.
active_rosters_history version rows No replacement history rows. Schema-v2 intentionally eliminates pre-lock lineup history; parity asserts the final mutable window_lineups state only.
fantasy_team_players created by a draft pick One fantasy_roster_entries row for the draft pick's fantasy team and club.
league_game_limits Equivalent fantasy_league_game_limits rows with the same computed match cap.
Legacy draft tables Schema-v2 draft_settings, draft_order, and draft_picks rows represent the same pick, turn, and completion state.

The parity baseline remains the legacy route's observable behavior. A physical-table difference listed above is an approved schema-v2 replacement, not a regression.

Public join service contract:

  1. The migrated adapter calls PublicLeagueService.join({ userId, format, season, allowCreate: false }).
  2. A joinable league returns a join result and the adapter returns the existing 200 body.
  3. No joinable league returns null. On the first request (triggered omitted or false), the adapter triggers public-league-pool and returns the existing 202 pending body.
  4. No joinable league with triggered: true returns the existing 409 body.
  5. The service must preserve idempotent re-joins and never create a public league when allowCreate is false.

Format-availability HTTP contract:

{
  "formats": {
    "MID": { "available": true },
    "FULL": { "available": false, "message": "This format isn't available for the current season: fewer than 8 matchdays remain across all four major leagues." }
  }
}

GET /api/fantasy-leagues/format-availability always resolves the current server season, calls LeagueService.checkFormatAvailability, maps CAMPAIGN_8 to MID and REST_OF_SEASON to FULL, and returns 200. Invalid methods return 405; unexpected failures return 500 { error: "Failed to check format availability" }.

Typed error mapping rule:

Error family Expected mapping
Auth/membership failures 401, 403, or 404 exactly as captured by parity for the route.
Request validation failures 400 with the route's existing JSON shape.
Domain rule failures Usually 400; use 403, 404, or 409 only when the current route already does or the ticket explicitly changes it.
Unexpected errors Existing 500 shape for that route.

Parity Test Requirements

Parity tests must prove user-visible compatibility across the migration seam, not just service unit correctness.

Each migrated endpoint needs parity coverage for:

  1. Success response status and JSON shape.
  2. Expected error statuses and JSON shapes.
  3. Authorization behavior: unauthenticated, non-member, wrong owner, or wrong turn where applicable.
  4. DB side effects: inserted, updated, deleted, or unchanged schema-v2 business state using the equivalence table above.
  5. Edge cases for invalid input and invalid state.
  6. External side-effect boundaries: mock Trigger.dev, Supabase broadcast, email, or clock behavior when the endpoint depends on them.

Use existing specs as the baseline pattern:

Spec What it proves
src/__tests__/parity/roster-update-happy.parity.spec.ts In-process, date-faked happy path with response and roster/history writes.
src/__tests__/parity/roster-update.parity.spec.ts HTTP locked-window guard with status and error body.
src/__tests__/parity/draft.parity.spec.ts HTTP manual pick plus auto-pick with draft and roster DB side effects.
src/__tests__/parity/league-create-http.parity.spec.ts HTTP create-league happy path with response and DB side effects.
src/__tests__/parity/full-flow.parity.spec.ts In-process create -> draft -> roster -> scoring smoke flow with side effects stubbed.

Test execution rules:

  1. Prefer service unit tests for isolated business rules.
  2. Use in-process handler parity tests when the clock or external side effects need deterministic stubs.
  3. Use HTTP parity tests when auth cookies, middleware, or browser-equivalent API behavior is the thing being proven.
  4. Seed deterministic rows and tear down every row written by the spec.
  5. Do not require bun run dev; the dev server is already managed outside this task. HTTP parity specs should stay skippable when PARITY_TEST_EMAIL, PARITY_TEST_PASSWORD, or ALLOW_EMULATED_CLOCK are missing.

Minimum parity matrix per endpoint:

Endpoint Required parity cases
/api/roster/update 200 active-window success; 400 locked/completed/processing window; 400 invalid team count; capture the legacy duplicate/malformed-selection result before changing it; 404 missing fantasy team; no mutation on rejected windows.
/api/draft/:leagueId/pick 200 pick success; 400 missing teamId; 403 non-participant; 403 wrong turn; 404 missing draft/team; duplicate or race behavior captured before migration.
/api/draft/:leagueId/auto-pick 200 auto-pick success; 400 auto-pick disabled/no eligible team; 403 non-participant; 404 missing draft/order/current user; completion response when final pick is made.
/api/draft/:leagueId/complete 200 owner success with success and redirectUrl; 403 non-owner; 404 missing league; DB fields status, draftCompleted, and updatedAt.
/api/fantasy-leagues/create 201 success shape; 400 missing fields; 400 invalid format/team count/date/season window; 401 unauthenticated; DB rows for league, owner team, participant, matchweek windows, game limits, and draft settings.
/api/fantasy-leagues/public/join 200 joined shape; 202 pending pool creation shape; 409 no league after triggered retry; 400 invalid format; 401 unauthenticated; member rows and no duplicate membership on retry.
/api/fantasy-leagues/format-availability Public 200 response matches the defined formats.MID/formats.FULL shape; unavailable message when fewer than eight matchdays remain; unavailable state when major competitions are missing; 405 for non-GET.

Migration Checklist

Paste this checklist into every endpoint ticket:

Reference Implementation

Use KLUB-243 /api/roster/update as the copyable reference once it lands. KLUB-244, KLUB-246, KLUB-247, and KLUB-254 must not choose their own route-auth or service-error conventions before that reference is merged.

The reference implementation must demonstrate:

  1. It provides the API-safe requireAuth(locals.user) helper used by every migrated protected endpoint and proves unauthenticated 401 { error: "Unauthorized" } behavior.
  2. API route only extracts locals.user, validates payload, checks the user's fantasy team/window access, calls the roster service, maps typed errors, and returns JSON.
  3. Roster business rules live in the service, including the four-active-club rule and one-club-per-major-league rule.
  4. The service owns schema-v2 window_lineups writes inside the transaction.
  5. The route preserves the legacy response shapes currently captured by roster-update-happy.parity.spec.ts and roster-update.parity.spec.ts.
  6. Rejected locked/completed/processing windows must not mutate window_lineups.

Do not copy the current pre-KLUB-243 inline /api/roster/update implementation. Copy the post-KLUB-243 adapter/service split.