diff --git a/.github/workflows/crawl-adk-docs.yml b/.github/workflows/crawl-adk-docs.yml index ff6a493f..6db308e3 100644 --- a/.github/workflows/crawl-adk-docs.yml +++ b/.github/workflows/crawl-adk-docs.yml @@ -2,8 +2,9 @@ name: Crawl ADK Documentation on: workflow_dispatch: # Manual trigger - schedule: - - cron: '0 0 * * 0' # Weekly on Sundays at midnight UTC + # schedule disabled — burns Actions minutes + # schedule: + # - cron: '0 0 * * 0' # Weekly on Sundays at midnight UTC permissions: contents: read diff --git a/CLAUDE.md b/CLAUDE.md index fd4d2bd8..8c8a90fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,15 +40,19 @@ npm run ios # iOS simulator npm run android # Android emulator npx expo prebuild # Generate native projects ``` +Expo SDK 54 + Expo Router (file-based routing) + NativeWind (Tailwind for RN) + React Query + MMKV/SecureStore for storage. Shares `firebase`, `zod`, `react-hook-form`, and `zustand` with web app. ### Testing ```bash -npm run test:unit # Vitest unit tests (src/**/*.test.ts) +npm run test:unit # Vitest unit tests npm run test:watch # Vitest watch mode npm run test:coverage # Coverage report (V8) +npm run test:integration # Integration tests (Firebase emulators required) +npm run test:integration:emulator # Spins up emulators + runs integration tests npm run test:e2e # Playwright E2E (03-Tests/e2e/) on port 4000 npm run test:e2e:ui # Playwright UI mode (interactive) npm run test:e2e:debug # Debug mode (PWDEBUG=1, headed, Chromium) +npm run test:security # npm audit (moderate+ severity) npm run qa:e2e:smoke # Quick smoke tests (login + journey) npm run qa:e2e:update-snapshots # Update visual regression baselines @@ -57,7 +61,11 @@ npx vitest run src/lib/billing/plan-limits.test.ts npx playwright test 03-Tests/e2e/01-authentication.spec.ts --project=chromium ``` -**E2E details**: Tests run on port 4000 (not 3000). CI builds production app (standalone) then tests against it. Locally, dev server is used. Global setup (`03-Tests/e2e/global-setup.ts`) creates authenticated storage state reused across tests. Use `test:e2e:debug` to step through with Playwright Inspector. +**Unit tests**: Both co-located (`src/lib/**/*.test.ts`, `src/middleware.test.ts`) and in `src/__tests__/` (`src/__tests__/lib/`, `src/__tests__/api/`). + +**Integration tests** (`*.integration.test.ts`): Separate vitest config (`vitest.integration.config.mts`), run against Firebase emulators in `node` environment with `forks` pool. Found in `src/lib/firebase/admin-services/` and `src/lib/`. + +**E2E details**: Tests run on port 4000 (not 3000), overridable via `PLAYWRIGHT_BASE_URL`. CI builds production app (standalone) then tests against it. Locally, dev server is used. Global setup (`03-Tests/e2e/global-setup.ts`) creates authenticated storage state (`03-Tests/e2e/.auth/user.json`) reused across tests. Use `test:e2e:debug` to step through with Playwright Inspector. ### Firebase & Cloud Functions ```bash @@ -178,7 +186,7 @@ Edge middleware protects `/dashboard/*` and `/api/*` routes: - Protected API routes → return 401 JSON if no session - Public API routes (no auth required): `/api/health`, `/api/auth/*`, `/api/waitlist`, `/api/webhooks`, `/api/verify` -Debug middleware with `MIDDLEWARE_DEBUG=verbose npm run dev` +Debug middleware with `npm run dev:debug` (sets `MIDDLEWARE_DEBUG=verbose`) ### Auth Domain & Email Links - `authDomain` in client config (`NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN`) stays as `hustleapp-production.firebaseapp.com` — internal SDK setting, not user-facing. Changing to `hustlestats.io` would break OAuth. @@ -216,9 +224,9 @@ Enforcement: server-side in `src/lib/stripe/plan-enforcement.ts` and `src/lib/wo ## Testing Strategy -- **Unit tests**: Vitest + Testing Library, co-located as `src/**/*.test.ts` (jsdom environment, `@vitejs/plugin-react`) -- **E2E tests**: Playwright in `03-Tests/e2e/`, numbered sequentially (01-authentication, 02-dashboard, etc.). Snapshots in `03-Tests/snapshots/`. -- **Firestore tests**: Use Firebase emulators locally +- **Unit tests**: Vitest + Testing Library (jsdom environment, `@vitejs/plugin-react`). Co-located in `src/lib/` and `src/__tests__/`. +- **Integration tests**: Vitest in `node` environment against Firebase emulators. Files: `*.integration.test.ts` in `src/lib/firebase/admin-services/` and `src/lib/`. Separate config: `vitest.integration.config.mts`. +- **E2E tests**: Playwright in `03-Tests/e2e/`, numbered sequentially (01-authentication, 02-dashboard, etc.). Snapshots in `03-Tests/snapshots/`. Sequential execution (single worker) for Firebase stability. - **Visual regression**: Playwright screenshot comparison with 0.2% pixel tolerance ## Environment Variables diff --git a/eslint.config.mjs b/eslint.config.mjs index 50687392..53c770e6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -37,8 +37,15 @@ const eslintConfig = [ "functions/**", // Mobile app (separate Expo project) "mobile/**", + // Local Python env / agent tooling (not production JS/TS) + "vertex-agents/**", // Test utility scripts "03-Tests/scripts/**/*.js", + // Generated Playwright artifacts / reports + "03-Tests/playwright-report/**", + "03-Tests/results/**", + "03-Tests/test-results.json", + "test-results/**", ], }, ]; diff --git a/src/app/api/account/pin/route.ts b/src/app/api/account/pin/route.ts index c6ae926c..57f21d90 100644 --- a/src/app/api/account/pin/route.ts +++ b/src/app/api/account/pin/route.ts @@ -11,7 +11,7 @@ const logger = createLogger('api/account/pin'); */ export async function PATCH(request: NextRequest) { try { - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json( diff --git a/src/app/api/billing/create-checkout-session/route.ts b/src/app/api/billing/create-checkout-session/route.ts index 55e1fc2b..b3499c9c 100644 --- a/src/app/api/billing/create-checkout-session/route.ts +++ b/src/app/api/billing/create-checkout-session/route.ts @@ -36,7 +36,7 @@ export async function POST(request: NextRequest) { } // 1. Authenticate user - const session = await auth(); + const session = await auth(request); if (!session) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } diff --git a/src/app/api/debug/biometrics/[playerId]/route.ts b/src/app/api/debug/biometrics/[playerId]/route.ts index 22796b87..5b4b6b8f 100644 --- a/src/app/api/debug/biometrics/[playerId]/route.ts +++ b/src/app/api/debug/biometrics/[playerId]/route.ts @@ -17,7 +17,7 @@ export async function GET( { params }: { params: Promise<{ playerId: string }> } ) { try { - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); diff --git a/src/app/api/debug/workout-logs/[playerId]/route.ts b/src/app/api/debug/workout-logs/[playerId]/route.ts index 3052a86b..fd46885b 100644 --- a/src/app/api/debug/workout-logs/[playerId]/route.ts +++ b/src/app/api/debug/workout-logs/[playerId]/route.ts @@ -17,7 +17,7 @@ export async function GET( { params }: { params: Promise<{ playerId: string }> } ) { try { - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); diff --git a/src/app/api/games/route.ts b/src/app/api/games/route.ts index ecb9ff06..0d5fedb3 100644 --- a/src/app/api/games/route.ts +++ b/src/app/api/games/route.ts @@ -22,7 +22,7 @@ const RATE_LIMIT_MAX = 10; // 10 requests per minute // GET /api/games?playerId=xxx - Get all games for a player export async function GET(request: NextRequest) { try { - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json( @@ -76,7 +76,7 @@ export async function GET(request: NextRequest) { // POST /api/games - Create a new game log export async function POST(request: NextRequest) { try { - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json( diff --git a/src/app/api/players/[id]/assessments/[assessmentId]/route.ts b/src/app/api/players/[id]/assessments/[assessmentId]/route.ts index dd5979eb..bb2bb01d 100644 --- a/src/app/api/players/[id]/assessments/[assessmentId]/route.ts +++ b/src/app/api/players/[id]/assessments/[assessmentId]/route.ts @@ -21,7 +21,7 @@ export async function GET( { params }: { params: Promise<{ id: string; assessmentId: string }> } ) { try { - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json( @@ -90,7 +90,7 @@ export async function PUT( { params }: { params: Promise<{ id: string; assessmentId: string }> } ) { try { - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json( @@ -175,7 +175,7 @@ export async function DELETE( { params }: { params: Promise<{ id: string; assessmentId: string }> } ) { try { - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json( diff --git a/src/app/api/players/[id]/assessments/route.ts b/src/app/api/players/[id]/assessments/route.ts index 15f3231f..95d47c53 100644 --- a/src/app/api/players/[id]/assessments/route.ts +++ b/src/app/api/players/[id]/assessments/route.ts @@ -27,7 +27,7 @@ export async function GET( { params }: { params: Promise<{ id: string }> } ) { try { - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json( @@ -122,7 +122,7 @@ export async function POST( { params }: { params: Promise<{ id: string }> } ) { try { - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json( diff --git a/src/app/api/players/[id]/biometrics/[logId]/route.ts b/src/app/api/players/[id]/biometrics/[logId]/route.ts index a13be562..e164e27c 100644 --- a/src/app/api/players/[id]/biometrics/[logId]/route.ts +++ b/src/app/api/players/[id]/biometrics/[logId]/route.ts @@ -19,7 +19,7 @@ export async function GET( { params }: { params: Promise<{ id: string; logId: string }> } ) { try { - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json( @@ -73,7 +73,7 @@ export async function PUT( { params }: { params: Promise<{ id: string; logId: string }> } ) { try { - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json( @@ -144,7 +144,7 @@ export async function DELETE( { params }: { params: Promise<{ id: string; logId: string }> } ) { try { - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json( diff --git a/src/app/api/players/[id]/biometrics/route.ts b/src/app/api/players/[id]/biometrics/route.ts index 391fb42c..5b68e8ee 100644 --- a/src/app/api/players/[id]/biometrics/route.ts +++ b/src/app/api/players/[id]/biometrics/route.ts @@ -21,7 +21,7 @@ export async function GET( { params }: { params: Promise<{ id: string }> } ) { try { - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json( @@ -128,7 +128,7 @@ export async function POST( { params }: { params: Promise<{ id: string }> } ) { try { - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json( diff --git a/src/app/api/players/[id]/cardio-logs/[logId]/route.ts b/src/app/api/players/[id]/cardio-logs/[logId]/route.ts index 1c77b113..1762a038 100644 --- a/src/app/api/players/[id]/cardio-logs/[logId]/route.ts +++ b/src/app/api/players/[id]/cardio-logs/[logId]/route.ts @@ -19,7 +19,7 @@ export async function GET( { params }: { params: Promise<{ id: string; logId: string }> } ) { try { - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json( @@ -68,7 +68,7 @@ export async function PATCH( { params }: { params: Promise<{ id: string; logId: string }> } ) { try { - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json( @@ -135,7 +135,7 @@ export async function DELETE( { params }: { params: Promise<{ id: string; logId: string }> } ) { try { - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json( diff --git a/src/app/api/players/[id]/cardio-logs/route.ts b/src/app/api/players/[id]/cardio-logs/route.ts index 13ecda01..af7ae21a 100644 --- a/src/app/api/players/[id]/cardio-logs/route.ts +++ b/src/app/api/players/[id]/cardio-logs/route.ts @@ -20,7 +20,7 @@ export async function GET( { params }: { params: Promise<{ id: string }> } ) { try { - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json( @@ -96,7 +96,7 @@ export async function POST( { params }: { params: Promise<{ id: string }> } ) { try { - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json( diff --git a/src/app/api/players/[id]/dream-gym/ai-strategy/route.ts b/src/app/api/players/[id]/dream-gym/ai-strategy/route.ts index 4a81b0b6..09e6a31b 100644 --- a/src/app/api/players/[id]/dream-gym/ai-strategy/route.ts +++ b/src/app/api/players/[id]/dream-gym/ai-strategy/route.ts @@ -134,7 +134,7 @@ export async function POST( { params }: { params: Promise<{ id: string }> } ) { try { - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json( @@ -213,7 +213,7 @@ export async function GET( { params }: { params: Promise<{ id: string }> } ) { try { - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json( diff --git a/src/app/api/players/[id]/dream-gym/check-in/route.ts b/src/app/api/players/[id]/dream-gym/check-in/route.ts index a52979be..5b61eedb 100644 --- a/src/app/api/players/[id]/dream-gym/check-in/route.ts +++ b/src/app/api/players/[id]/dream-gym/check-in/route.ts @@ -14,7 +14,7 @@ export async function POST( { params }: { params: Promise<{ id: string }> } ) { try { - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json( diff --git a/src/app/api/players/[id]/dream-gym/events/[eventId]/route.ts b/src/app/api/players/[id]/dream-gym/events/[eventId]/route.ts index 734273b8..9e72ce2f 100644 --- a/src/app/api/players/[id]/dream-gym/events/[eventId]/route.ts +++ b/src/app/api/players/[id]/dream-gym/events/[eventId]/route.ts @@ -14,7 +14,7 @@ export async function DELETE( { params }: { params: Promise<{ id: string; eventId: string }> } ) { try { - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json( diff --git a/src/app/api/players/[id]/dream-gym/events/route.ts b/src/app/api/players/[id]/dream-gym/events/route.ts index 1034d1a3..4653c741 100644 --- a/src/app/api/players/[id]/dream-gym/events/route.ts +++ b/src/app/api/players/[id]/dream-gym/events/route.ts @@ -14,7 +14,7 @@ export async function POST( { params }: { params: Promise<{ id: string }> } ) { try { - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json( diff --git a/src/app/api/players/[id]/dream-gym/route.ts b/src/app/api/players/[id]/dream-gym/route.ts index 070f8f09..cfa2436a 100644 --- a/src/app/api/players/[id]/dream-gym/route.ts +++ b/src/app/api/players/[id]/dream-gym/route.ts @@ -16,7 +16,7 @@ export async function GET( { params }: { params: Promise<{ id: string }> } ) { try { - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json( @@ -61,7 +61,7 @@ export async function POST( { params }: { params: Promise<{ id: string }> } ) { try { - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json( diff --git a/src/app/api/players/[id]/dream-gym/workout-logs/[logId]/route.ts b/src/app/api/players/[id]/dream-gym/workout-logs/[logId]/route.ts index 41fdc12d..d2e43a08 100644 --- a/src/app/api/players/[id]/dream-gym/workout-logs/[logId]/route.ts +++ b/src/app/api/players/[id]/dream-gym/workout-logs/[logId]/route.ts @@ -19,7 +19,7 @@ export async function GET( { params }: { params: Promise<{ id: string; logId: string }> } ) { try { - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json( @@ -69,7 +69,7 @@ export async function PUT( { params }: { params: Promise<{ id: string; logId: string }> } ) { try { - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json( @@ -137,7 +137,7 @@ export async function DELETE( { params }: { params: Promise<{ id: string; logId: string }> } ) { try { - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json( diff --git a/src/app/api/players/[id]/dream-gym/workout-logs/route.ts b/src/app/api/players/[id]/dream-gym/workout-logs/route.ts index 61961a84..710907d2 100644 --- a/src/app/api/players/[id]/dream-gym/workout-logs/route.ts +++ b/src/app/api/players/[id]/dream-gym/workout-logs/route.ts @@ -20,7 +20,7 @@ export async function GET( { params }: { params: Promise<{ id: string }> } ) { try { - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json( @@ -108,7 +108,7 @@ export async function POST( { params }: { params: Promise<{ id: string }> } ) { try { - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json( diff --git a/src/app/api/players/[id]/journal/[entryId]/route.ts b/src/app/api/players/[id]/journal/[entryId]/route.ts index ac6ddc10..7e18ad28 100644 --- a/src/app/api/players/[id]/journal/[entryId]/route.ts +++ b/src/app/api/players/[id]/journal/[entryId]/route.ts @@ -19,7 +19,7 @@ export async function GET( { params }: { params: Promise<{ id: string; entryId: string }> } ) { try { - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json( @@ -69,7 +69,7 @@ export async function PUT( { params }: { params: Promise<{ id: string; entryId: string }> } ) { try { - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json( @@ -137,7 +137,7 @@ export async function DELETE( { params }: { params: Promise<{ id: string; entryId: string }> } ) { try { - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json( diff --git a/src/app/api/players/[id]/journal/route.ts b/src/app/api/players/[id]/journal/route.ts index 804bb9ca..7f1c7486 100644 --- a/src/app/api/players/[id]/journal/route.ts +++ b/src/app/api/players/[id]/journal/route.ts @@ -20,7 +20,7 @@ export async function GET( { params }: { params: Promise<{ id: string }> } ) { try { - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json( @@ -98,7 +98,7 @@ export async function POST( { params }: { params: Promise<{ id: string }> } ) { try { - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json( diff --git a/src/app/api/players/[id]/practice-logs/[logId]/route.ts b/src/app/api/players/[id]/practice-logs/[logId]/route.ts index ab156eda..2965f401 100644 --- a/src/app/api/players/[id]/practice-logs/[logId]/route.ts +++ b/src/app/api/players/[id]/practice-logs/[logId]/route.ts @@ -19,7 +19,7 @@ export async function GET( { params }: { params: Promise<{ id: string; logId: string }> } ) { try { - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json( @@ -68,7 +68,7 @@ export async function PATCH( { params }: { params: Promise<{ id: string; logId: string }> } ) { try { - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json( @@ -135,7 +135,7 @@ export async function DELETE( { params }: { params: Promise<{ id: string; logId: string }> } ) { try { - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json( diff --git a/src/app/api/players/[id]/practice-logs/route.ts b/src/app/api/players/[id]/practice-logs/route.ts index dadd11c8..3ac6fa80 100644 --- a/src/app/api/players/[id]/practice-logs/route.ts +++ b/src/app/api/players/[id]/practice-logs/route.ts @@ -20,7 +20,7 @@ export async function GET( { params }: { params: Promise<{ id: string }> } ) { try { - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json( @@ -98,7 +98,7 @@ export async function POST( { params }: { params: Promise<{ id: string }> } ) { try { - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json( diff --git a/src/app/api/players/[id]/route.test.ts b/src/app/api/players/[id]/route.test.ts index 39638a0a..34208b3e 100644 --- a/src/app/api/players/[id]/route.test.ts +++ b/src/app/api/players/[id]/route.test.ts @@ -73,7 +73,11 @@ const PARAMS = Promise.resolve({ id: 'player-123' }); const validPutBody = { name: 'Alex Smith Updated', birthday: '2012-03-10', - position: 'ST', + gender: 'male', + primaryPosition: 'ST', + secondaryPositions: ['LW'], + positionNote: 'Can also play wing', + leagueCode: 'local_travel', teamClub: 'New FC', }; @@ -83,7 +87,7 @@ const validPutBody = { describe('GET /api/players/[id]', () => { beforeEach(() => { - vi.clearAllMocks(); + vi.resetAllMocks(); }); it('returns 401 when not authenticated', async () => { @@ -142,7 +146,7 @@ describe('GET /api/players/[id]', () => { describe('PUT /api/players/[id]', () => { beforeEach(() => { - vi.clearAllMocks(); + vi.resetAllMocks(); }); it('returns 401 when not authenticated', async () => { @@ -161,13 +165,13 @@ describe('PUT /api/players/[id]', () => { const request = createMockRequest({ method: 'PUT', - body: { name: 'Alex' }, // missing birthday, position, teamClub + body: { name: 'Alex' }, // missing required profile fields }); const response = await PUT(request, { params: PARAMS }); const body = await response.json(); expect(response.status).toBe(400); - expect(body.error).toBe('Missing required fields'); + expect(body.error).toBe('VALIDATION_FAILED'); }); it('returns 500 when user has no workspace', async () => { @@ -245,11 +249,22 @@ describe('PUT /api/players/[id]', () => { expect(response.status).toBe(200); expect(body.success).toBe(true); expect(body.player.name).toBe('Alex Smith Updated'); - expect(mocks.updatePlayerAdmin).toHaveBeenCalledWith('user-123', 'player-123', { - name: 'Alex Smith Updated', - primaryPosition: 'ST', - teamClub: 'New FC', - }); + expect(mocks.updatePlayerAdmin).toHaveBeenCalledWith( + 'user-123', + 'player-123', + expect.objectContaining({ + name: 'Alex Smith Updated', + birthday: expect.any(Date), + gender: 'male', + primaryPosition: 'ST', + position: 'ST', + secondaryPositions: ['LW'], + positionNote: 'Can also play wing', + leagueCode: 'local_travel', + leagueOtherName: null, + teamClub: 'New FC', + }) + ); }); it('returns 500 when updatePlayerAdmin throws', async () => { @@ -275,7 +290,7 @@ describe('PUT /api/players/[id]', () => { describe('DELETE /api/players/[id]', () => { beforeEach(() => { - vi.clearAllMocks(); + vi.resetAllMocks(); }); it('returns 401 when not authenticated', async () => { diff --git a/src/app/api/players/[id]/route.ts b/src/app/api/players/[id]/route.ts index 0e99f469..602b218d 100644 --- a/src/app/api/players/[id]/route.ts +++ b/src/app/api/players/[id]/route.ts @@ -6,6 +6,7 @@ import { getUserProfileAdmin } from '@/lib/firebase/admin-services/users'; import { getWorkspaceByIdAdmin } from '@/lib/firebase/admin-services/workspaces'; import { assertWorkspaceActive } from '@/lib/workspaces/enforce'; import { WorkspaceAccessError } from '@/lib/firebase/access-control'; +import { playerSchema } from '@/lib/validations/player'; const logger = createLogger('api/players/[id]'); @@ -18,7 +19,7 @@ export async function GET( { params }: { params: Promise<{ id: string }> } ) { try { - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json( @@ -61,7 +62,7 @@ export async function PUT( { params }: { params: Promise<{ id: string }> } ) { try { - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json( @@ -71,17 +72,31 @@ export async function PUT( } const { id } = await params; - const body = await request.json(); - const { name, birthday, position, teamClub } = body; - // Validation - if (!name || !birthday || !position || !teamClub) { + let body: unknown; + try { + body = await request.json(); + } catch { return NextResponse.json( - { error: 'Missing required fields' }, + { error: 'INVALID_REQUEST_BODY', message: 'Invalid request body. Please try again.' }, { status: 400 } ); } + const validationResult = playerSchema.safeParse(body); + if (!validationResult.success) { + return NextResponse.json( + { + error: 'VALIDATION_FAILED', + message: 'Please check the form fields and try again.', + details: validationResult.error.flatten().fieldErrors, + }, + { status: 400 } + ); + } + + const validatedData = validationResult.data; + // Phase 6 Task 5: Enforce workspace status const user = await getUserProfileAdmin(session.user.id); if (!user?.defaultWorkspaceId) { @@ -123,9 +138,18 @@ export async function PUT( // Update player (Firestore) await updatePlayerAdmin(session.user.id, id, { - name, - primaryPosition: position, - teamClub, + name: validatedData.name, + birthday: new Date(validatedData.birthday), + gender: validatedData.gender, + primaryPosition: validatedData.primaryPosition, + position: validatedData.primaryPosition, // Legacy field for backward compatibility + secondaryPositions: validatedData.secondaryPositions ?? [], + positionNote: validatedData.positionNote?.trim() ? validatedData.positionNote.trim() : null, + leagueCode: validatedData.leagueCode, + leagueOtherName: validatedData.leagueCode === 'other' && validatedData.leagueOtherName?.trim() + ? validatedData.leagueOtherName.trim() + : null, + teamClub: validatedData.teamClub, }); // Get updated player for response @@ -154,7 +178,7 @@ export async function DELETE( { params }: { params: Promise<{ id: string }> } ) { try { - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json( diff --git a/src/app/api/players/create/route.ts b/src/app/api/players/create/route.ts index 92d2b668..22f3a9ae 100644 --- a/src/app/api/players/create/route.ts +++ b/src/app/api/players/create/route.ts @@ -40,7 +40,7 @@ export async function POST(request: NextRequest) { } // Get authenticated user from Firebase session (calls cookies() internally) - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { logger.warn('Unauthorized player creation attempt', { @@ -83,9 +83,7 @@ export async function POST(request: NextRequest) { logger.warn(`User has no workspace, attempting fallback provisioning: ${session.user.id}`); try { // Get the decoded token claims from the current session - const { cookies } = await import('next/headers'); - const cookieStore = await cookies(); - const sessionCookie = cookieStore.get('__session')?.value || ''; + const sessionCookie = request.cookies.get('__session')?.value || ''; const decodedToken = await adminAuth.verifySessionCookie(sessionCookie); const provisionResult = await ensureUserProvisioned(decodedToken); logger.info(`Fallback provisioning succeeded: userId=${provisionResult.userId}, workspaceId=${provisionResult.workspaceId}`); diff --git a/src/app/api/players/route.ts b/src/app/api/players/route.ts index 9bfe70a2..d764f358 100644 --- a/src/app/api/players/route.ts +++ b/src/app/api/players/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from 'next/server' +import { NextRequest, NextResponse } from 'next/server' import { auth } from '@/lib/auth' import { createLogger } from '@/lib/logger' import { getPlayersAdmin } from '@/lib/firebase/admin-services/players' @@ -8,9 +8,9 @@ import { getUserProfileAdmin } from '@/lib/firebase/admin-services/users' const logger = createLogger('api/players') // GET /api/players - Get all players for authenticated user -export async function GET() { +export async function GET(request: NextRequest) { try { - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json( @@ -33,9 +33,15 @@ export async function GET() { id: player.id, name: player.name, birthday: player.birthday, - // Use primaryPosition (SoccerPositionCode like "GK", "CB") for position detection + gender: player.gender, + primaryPosition: player.primaryPosition, + secondaryPositions: player.secondaryPositions ?? [], + positionNote: player.positionNote ?? null, + // Legacy field (backward compatibility) position: player.primaryPosition ?? player.position, teamClub: player.teamClub, + leagueCode: player.leagueCode, + leagueOtherName: player.leagueOtherName ?? null, photoUrl: player.photoUrl, pendingGames: unverifiedGames.length, parentEmail: parentUser?.email ?? null diff --git a/src/app/api/storage/delete-player-photo/route.ts b/src/app/api/storage/delete-player-photo/route.ts index 5eefa4a6..756fbb86 100644 --- a/src/app/api/storage/delete-player-photo/route.ts +++ b/src/app/api/storage/delete-player-photo/route.ts @@ -10,9 +10,10 @@ import { NextRequest, NextResponse } from 'next/server'; import { auth } from '@/lib/auth'; -import { deletePlayerPhoto, extractStoragePath } from '@/lib/firebase/storage'; -import { getPlayer, updatePlayer } from '@/lib/firebase/services/players'; -import { updateWorkspaceStorageUsage } from '@/lib/firebase/services/workspaces'; +import { extractStoragePath } from '@/lib/firebase/storage-utils'; +import { deletePlayerPhotoAdmin } from '@/lib/firebase/admin-storage'; +import { getPlayerAdmin, updatePlayerAdmin } from '@/lib/firebase/admin-services/players'; +import { updateWorkspaceStorageUsageAdmin } from '@/lib/firebase/admin-services/workspaces'; import { createLogger } from '@/lib/logger'; const logger = createLogger('api/storage/delete-player-photo'); @@ -36,7 +37,7 @@ const logger = createLogger('api/storage/delete-player-photo'); export async function DELETE(request: NextRequest) { try { // 1. Check authentication - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } @@ -52,7 +53,7 @@ export async function DELETE(request: NextRequest) { } // 3. Get player and verify ownership - const player = await getPlayer(userId, playerId); + const player = await getPlayerAdmin(userId, playerId); if (!player) { return NextResponse.json({ error: 'Player not found' }, { status: 404 }); } @@ -69,16 +70,16 @@ export async function DELETE(request: NextRequest) { } // 6. Delete photo from Firebase Storage - const sizeFreed = await deletePlayerPhoto(storagePath); + const sizeFreed = await deletePlayerPhotoAdmin(storagePath); // 7. Update player photoUrl in Firestore - await updatePlayer(userId, playerId, { + await updatePlayerAdmin(userId, playerId, { photoUrl: null, }); // 8. Update workspace storage usage (negative delta) if (player.workspaceId) { - await updateWorkspaceStorageUsage(player.workspaceId, -sizeFreed); + await updateWorkspaceStorageUsageAdmin(player.workspaceId, -sizeFreed); } logger.info('Player photo deleted successfully', { diff --git a/src/app/api/storage/upload-player-photo/route.ts b/src/app/api/storage/upload-player-photo/route.ts index 45ce1ae2..1c8fd0a6 100644 --- a/src/app/api/storage/upload-player-photo/route.ts +++ b/src/app/api/storage/upload-player-photo/route.ts @@ -10,14 +10,10 @@ import { NextRequest, NextResponse } from 'next/server'; import { auth } from '@/lib/auth'; -import { - uploadPlayerPhoto, - validateFile, - deletePlayerPhoto, - extractStoragePath, -} from '@/lib/firebase/storage'; -import { getPlayer, updatePlayer } from '@/lib/firebase/services/players'; -import { getWorkspace, updateWorkspaceStorageUsage } from '@/lib/firebase/services/workspaces'; +import { validateFile, extractStoragePath } from '@/lib/firebase/storage-utils'; +import { uploadPlayerPhotoAdmin, deletePlayerPhotoAdmin } from '@/lib/firebase/admin-storage'; +import { getPlayerAdmin, updatePlayerAdmin } from '@/lib/firebase/admin-services/players'; +import { getWorkspaceByIdAdmin, updateWorkspaceStorageUsageAdmin } from '@/lib/firebase/admin-services/workspaces'; import { createLogger } from '@/lib/logger'; const logger = createLogger('api/storage/upload-player-photo'); @@ -57,7 +53,7 @@ const logger = createLogger('api/storage/upload-player-photo'); export async function POST(request: NextRequest) { try { // 1. Check authentication - const session = await auth(); + const session = await auth(request); if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } @@ -77,7 +73,7 @@ export async function POST(request: NextRequest) { } // 3. Get player and verify ownership - const player = await getPlayer(userId, playerId); + const player = await getPlayerAdmin(userId, playerId); if (!player) { return NextResponse.json({ error: 'Player not found' }, { status: 404 }); } @@ -86,7 +82,7 @@ export async function POST(request: NextRequest) { if (!player.workspaceId) { return NextResponse.json({ error: 'Player has no workspace assigned' }, { status: 400 }); } - const workspace = await getWorkspace(player.workspaceId); + const workspace = await getWorkspaceByIdAdmin(player.workspaceId); if (!workspace) { return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }); } @@ -118,7 +114,7 @@ export async function POST(request: NextRequest) { const oldPath = extractStoragePath(player.photoUrl); if (oldPath) { try { - oldPhotoSizeMB = await deletePlayerPhoto(oldPath); + oldPhotoSizeMB = await deletePlayerPhotoAdmin(oldPath); logger.info('Replaced old player photo', { userId, playerId, @@ -138,16 +134,16 @@ export async function POST(request: NextRequest) { } // 8. Upload new photo - const uploadResult = await uploadPlayerPhoto(file, userId, playerId); + const uploadResult = await uploadPlayerPhotoAdmin({ file, userId, playerId }); // 9. Update player photoUrl in Firestore - await updatePlayer(userId, playerId, { + await updatePlayerAdmin(userId, playerId, { photoUrl: uploadResult.url, }); // 10. Update workspace storage usage const netStorageChange = uploadResult.size - oldPhotoSizeMB; - await updateWorkspaceStorageUsage(workspace.id, netStorageChange); + await updateWorkspaceStorageUsageAdmin(workspace.id, netStorageChange); logger.info('Player photo uploaded successfully', { userId, diff --git a/src/app/api/verify/route.ts b/src/app/api/verify/route.ts index 967fb216..89316c58 100644 --- a/src/app/api/verify/route.ts +++ b/src/app/api/verify/route.ts @@ -13,7 +13,7 @@ export async function POST(request: NextRequest) { console.log('[Verify API] Request received') try { - const session = await auth(); + const session = await auth(request); console.log('[Verify API] Session:', session?.user?.id ? 'authenticated' : 'not authenticated') if (!session?.user?.id) { diff --git a/src/app/dashboard/add-athlete/page.tsx b/src/app/dashboard/add-athlete/page.tsx index db029426..babdfe50 100644 --- a/src/app/dashboard/add-athlete/page.tsx +++ b/src/app/dashboard/add-athlete/page.tsx @@ -90,10 +90,10 @@ export default function AddAthlete() { // Upload photo if selected if (photo && player.id) { const photoFormData = new FormData(); - photoFormData.append('photo', photo); + photoFormData.append('file', photo); photoFormData.append('playerId', player.id); - const photoResponse = await fetch('/api/players/upload-photo', { + const photoResponse = await fetch('/api/storage/upload-player-photo', { method: 'POST', body: photoFormData, }); @@ -251,7 +251,14 @@ export default function AddAthlete() { setFormData({ ...formData, primaryPosition: e.target.value as SoccerPositionCode })} + onChange={(e) => { + const nextPrimary = e.target.value as SoccerPositionCode; + setFormData((prev) => ({ + ...prev, + primaryPosition: nextPrimary, + secondaryPositions: prev.secondaryPositions.filter((p) => p !== nextPrimary), + })); + }} className="w-full px-4 py-2 border border-zinc-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-zinc-900 focus:border-transparent" > diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 24706230..3247915e 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -9,9 +9,12 @@ */ import { cookies } from 'next/headers'; +import type { NextRequest } from 'next/server'; import { adminAuth, adminDb } from './firebase/admin'; import type { User } from '@/types/firestore'; import { isE2ETestMode } from '@/lib/e2e'; +import { ensureUserProvisioned } from '@/lib/firebase/server-provisioning'; +import { withTimeout } from '@/lib/utils/timeout'; // --------------------------------------------------------------------------- // Types @@ -37,9 +40,23 @@ export interface DashboardUser { // Core helpers // --------------------------------------------------------------------------- -async function getSessionCookie(): Promise { +const SESSION_COOKIE_NAME = '__session'; +const VERIFY_SESSION_COOKIE_TIMEOUT_MS = 10_000; +const USER_PROFILE_READ_TIMEOUT_MS = 10_000; +const PROVISIONING_TIMEOUT_MS = 15_000; + +function getSessionCookieFromRequest(request: NextRequest): string | null { + return request.cookies.get(SESSION_COOKIE_NAME)?.value || null; +} + +async function getSessionCookieFromHeaders(): Promise { const cookieStore = await cookies(); - return cookieStore.get('__session')?.value || null; + return cookieStore.get(SESSION_COOKIE_NAME)?.value || null; +} + +async function getSessionCookie(request?: NextRequest): Promise { + if (request) return getSessionCookieFromRequest(request); + return getSessionCookieFromHeaders(); } // --------------------------------------------------------------------------- @@ -49,12 +66,16 @@ async function getSessionCookie(): Promise { /** * Lightweight session check for API routes (no Firestore hit). */ -export async function auth(): Promise { +export async function auth(request?: NextRequest): Promise { try { - const sessionCookie = await getSessionCookie(); + const sessionCookie = await getSessionCookie(request); if (!sessionCookie) return null; - const decodedToken = await adminAuth.verifySessionCookie(sessionCookie); + const decodedToken = await withTimeout( + adminAuth.verifySessionCookie(sessionCookie), + VERIFY_SESSION_COOKIE_TIMEOUT_MS, + 'verifySessionCookie' + ); return { user: { @@ -73,17 +94,44 @@ export async function auth(): Promise { * Session check + Firestore user profile for dashboard pages. * Returns null if not authenticated. */ -export async function authWithProfile(): Promise { +export async function authWithProfile(request?: NextRequest): Promise { try { - const sessionCookie = await getSessionCookie(); + const sessionCookie = await getSessionCookie(request); if (!sessionCookie) return null; - const decodedToken = await adminAuth.verifySessionCookie(sessionCookie, true); + const decodedToken = await withTimeout( + adminAuth.verifySessionCookie(sessionCookie, true), + VERIFY_SESSION_COOKIE_TIMEOUT_MS, + 'verifySessionCookie' + ); + + let userDoc = await withTimeout( + adminDb.collection('users').doc(decodedToken.uid).get(), + USER_PROFILE_READ_TIMEOUT_MS, + 'getUserProfile' + ); - const userDoc = await adminDb.collection('users').doc(decodedToken.uid).get(); if (!userDoc.exists) { - console.error(`User document not found for UID: ${decodedToken.uid}`); - return null; + try { + await withTimeout( + ensureUserProvisioned(decodedToken), + PROVISIONING_TIMEOUT_MS, + 'ensureUserProvisioned' + ); + + userDoc = await withTimeout( + adminDb.collection('users').doc(decodedToken.uid).get(), + USER_PROFILE_READ_TIMEOUT_MS, + 'getUserProfile' + ); + } catch (provisionError: any) { + console.error('User provisioning failed:', provisionError?.message || provisionError); + } + + if (!userDoc.exists) { + console.error(`User document not found for UID: ${decodedToken.uid}`); + return null; + } } const userData = userDoc.data() as User; diff --git a/src/lib/firebase/admin-services/players.ts b/src/lib/firebase/admin-services/players.ts index 0ccf3371..d856d684 100644 --- a/src/lib/firebase/admin-services/players.ts +++ b/src/lib/firebase/admin-services/players.ts @@ -174,8 +174,10 @@ export async function updatePlayerAdmin( playerId: string, data: Partial<{ name: string; + birthday: Date; gender: PlayerGender; primaryPosition: SoccerPositionCode; + position: SoccerPositionCode; secondaryPositions: SoccerPositionCode[]; positionNote: string | null; leagueCode: LeagueCode; diff --git a/src/lib/firebase/storage-utils.ts b/src/lib/firebase/storage-utils.ts new file mode 100644 index 00000000..467ffd23 --- /dev/null +++ b/src/lib/firebase/storage-utils.ts @@ -0,0 +1,128 @@ +/** + * Firebase Storage Utilities (Isomorphic) + * + * Shared helpers that do not depend on the Firebase client SDK. + * Safe to import from both server (API routes) and client components. + */ + +import type { WorkspacePlan } from '@/types/firestore'; + +/** + * Storage Limits by Plan + * + * Controls maximum file upload size and total workspace storage. + */ +export const STORAGE_LIMITS: Record< + WorkspacePlan, + { + maxFileSize: number; // Max single file size in MB + totalStorage: number; // Total storage quota in MB + } +> = { + free: { + maxFileSize: 2, + totalStorage: 50, + }, + starter: { + maxFileSize: 5, + totalStorage: 500, + }, + plus: { + maxFileSize: 10, + totalStorage: 2000, + }, + pro: { + maxFileSize: 20, + totalStorage: 10000, + }, +}; + +/** + * Allowed File Types for Player Photos + */ +export const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']; + +export interface ValidationResult { + valid: boolean; + error?: string; +} + +/** + * Validate file before upload + * + * @param file - File to validate + * @param plan - Workspace plan tier + * @param currentStorageUsedMB - Current storage usage in MB + * @returns Validation result + */ +export function validateFile( + file: File, + plan: WorkspacePlan, + currentStorageUsedMB: number +): ValidationResult { + const limits = STORAGE_LIMITS[plan]; + + // Check file type + if (!ALLOWED_IMAGE_TYPES.includes(file.type)) { + return { + valid: false, + error: `Invalid file type. Allowed types: ${ALLOWED_IMAGE_TYPES.join(', ')}`, + }; + } + + // Check file size + const fileSizeMB = file.size / (1024 * 1024); + if (fileSizeMB > limits.maxFileSize) { + return { + valid: false, + error: `File too large. Maximum size for ${plan} plan: ${limits.maxFileSize} MB`, + }; + } + + // Check total storage quota + const newTotalStorage = currentStorageUsedMB + fileSizeMB; + if (newTotalStorage > limits.totalStorage) { + const remainingMB = limits.totalStorage - currentStorageUsedMB; + return { + valid: false, + error: `Storage quota exceeded. ${remainingMB.toFixed(2)} MB remaining of ${limits.totalStorage} MB total`, + }; + } + + return { valid: true }; +} + +/** + * Extract storage path from Firebase Storage URL + * + * @param url - Firebase Storage download URL + * @returns Storage path (e.g., "users/123/players/456/photos/file.jpg") + */ +export function extractStoragePath(url: string): string | null { + try { + // Firebase Storage URL format: + // https://firebasestorage.googleapis.com/v0/b/{bucket}/o/{encodedPath}?... + const urlObj = new URL(url); + const pathMatch = urlObj.pathname.match(/\/o\/(.+)/); + if (!pathMatch) return null; + + const encodedPath = pathMatch[1]; + return decodeURIComponent(encodedPath); + } catch { + return null; + } +} + +export function getRemainingStorage(plan: WorkspacePlan, currentStorageUsedMB: number): number { + const limits = STORAGE_LIMITS[plan]; + return Math.max(0, limits.totalStorage - currentStorageUsedMB); +} + +export function getStorageUsagePercentage( + plan: WorkspacePlan, + currentStorageUsedMB: number +): number { + const limits = STORAGE_LIMITS[plan]; + return Math.min(100, (currentStorageUsedMB / limits.totalStorage) * 100); +} + diff --git a/src/lib/firebase/storage.ts b/src/lib/firebase/storage.ts index 6b99da98..77622172 100644 --- a/src/lib/firebase/storage.ts +++ b/src/lib/firebase/storage.ts @@ -17,54 +17,20 @@ import { } from 'firebase/storage'; import { app } from './config'; import { createLogger } from '@/lib/logger'; -import type { WorkspacePlan } from '@/types/firestore'; + +export { + STORAGE_LIMITS, + ALLOWED_IMAGE_TYPES, + validateFile, + extractStoragePath, + getRemainingStorage, + getStorageUsagePercentage, +} from './storage-utils'; +export type { ValidationResult } from './storage-utils'; const logger = createLogger('firebase/storage'); const storage = getStorage(app); -/** - * Storage Limits by Plan - * - * Controls maximum file upload size and total workspace storage. - */ -export const STORAGE_LIMITS: Record< - WorkspacePlan, - { - maxFileSize: number; // Max single file size in MB - totalStorage: number; // Total storage quota in MB - } -> = { - free: { - maxFileSize: 2, // 2 MB per file - totalStorage: 50, // 50 MB total - }, - starter: { - maxFileSize: 5, // 5 MB per file - totalStorage: 500, // 500 MB total - }, - plus: { - maxFileSize: 10, // 10 MB per file - totalStorage: 2000, // 2 GB total - }, - pro: { - maxFileSize: 20, // 20 MB per file - totalStorage: 10000, // 10 GB total - }, -}; - -/** - * Allowed File Types for Player Photos - */ -export const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']; - -/** - * Validation Result - */ -export interface ValidationResult { - valid: boolean; - error?: string; -} - /** * Upload Result */ @@ -74,51 +40,6 @@ export interface UploadResult { size: number; } -/** - * Validate file before upload - * - * @param file - File to validate - * @param plan - Workspace plan tier - * @param currentStorageUsedMB - Current storage usage in MB - * @returns Validation result - */ -export function validateFile( - file: File, - plan: WorkspacePlan, - currentStorageUsedMB: number -): ValidationResult { - const limits = STORAGE_LIMITS[plan]; - - // Check file type - if (!ALLOWED_IMAGE_TYPES.includes(file.type)) { - return { - valid: false, - error: `Invalid file type. Allowed types: ${ALLOWED_IMAGE_TYPES.join(', ')}`, - }; - } - - // Check file size - const fileSizeMB = file.size / (1024 * 1024); - if (fileSizeMB > limits.maxFileSize) { - return { - valid: false, - error: `File too large. Maximum size for ${plan} plan: ${limits.maxFileSize} MB`, - }; - } - - // Check total storage quota - const newTotalStorage = currentStorageUsedMB + fileSizeMB; - if (newTotalStorage > limits.totalStorage) { - const remainingMB = limits.totalStorage - currentStorageUsedMB; - return { - valid: false, - error: `Storage quota exceeded. ${remainingMB.toFixed(2)} MB remaining of ${limits.totalStorage} MB total`, - }; - } - - return { valid: true }; -} - /** * Upload player profile photo * @@ -210,54 +131,3 @@ export async function deletePlayerPhoto(storagePath: string): Promise { throw new Error(`Failed to delete photo: ${error.message}`); } } - -/** - * Extract storage path from Firebase Storage URL - * - * @param url - Firebase Storage download URL - * @returns Storage path (e.g., "users/123/players/456/photos/file.jpg") - */ -export function extractStoragePath(url: string): string | null { - try { - // Firebase Storage URL format: - // https://firebasestorage.googleapis.com/v0/b/{bucket}/o/{encodedPath}?... - const urlObj = new URL(url); - const pathMatch = urlObj.pathname.match(/\/o\/(.+)/); - - if (!pathMatch) return null; - - // Decode the path (e.g., "users%2F123%2Fplayers%2F456%2Fphotos%2Ffile.jpg") - const encodedPath = pathMatch[1]; - return decodeURIComponent(encodedPath); - } catch (error) { - logger.warn('Failed to extract storage path from URL', { url }); - return null; - } -} - -/** - * Get remaining storage quota for workspace - * - * @param plan - Workspace plan tier - * @param currentStorageUsedMB - Current storage usage in MB - * @returns Remaining storage in MB - */ -export function getRemainingStorage(plan: WorkspacePlan, currentStorageUsedMB: number): number { - const limits = STORAGE_LIMITS[plan]; - return Math.max(0, limits.totalStorage - currentStorageUsedMB); -} - -/** - * Get storage usage percentage - * - * @param plan - Workspace plan tier - * @param currentStorageUsedMB - Current storage usage in MB - * @returns Usage percentage (0-100) - */ -export function getStorageUsagePercentage( - plan: WorkspacePlan, - currentStorageUsedMB: number -): number { - const limits = STORAGE_LIMITS[plan]; - return Math.min(100, (currentStorageUsedMB / limits.totalStorage) * 100); -}