Add spec compliance improvements: seed switch alert, calendar emojis, period indicator, IP logging
Some checks failed
CI / quality (push) Failing after 28s
Deploy / deploy (push) Successful in 2m38s

- NutritionPanel: Display seed switch alert on day 15 per dashboard spec
- MonthView: Add phase emojis to legend (🩸🌱🌸🌙🌑) per calendar spec
- DayCell: Show period indicator (🩸) for days 1-3 per calendar spec
- Auth middleware: Log client IP from x-forwarded-for/x-real-ip per observability spec
- Updated NutritionGuidance type to include seedSwitchAlert field
- /api/today now returns seedSwitchAlert in nutrition response

Test coverage: 1005 tests (15 new tests added)
- nutrition-panel.test.tsx: +4 tests
- month-view.test.tsx: +1 test
- day-cell.test.tsx: +5 tests
- auth-middleware.test.ts: +3 tests
- today/route.test.ts: +2 tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-12 23:33:14 +00:00
parent d613417e47
commit eeeece17bf
12 changed files with 293 additions and 48 deletions

View File

@@ -4,7 +4,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
## Current State Summary ## Current State Summary
### Overall Status: 990 unit tests passing across 50 test files + 64 E2E tests across 6 files ### Overall Status: 1005 unit tests passing across 50 test files + 64 E2E tests across 6 files
### Library Implementation ### Library Implementation
| File | Status | Gap Analysis | | File | Status | Gap Analysis |
@@ -17,7 +17,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
| `decision-engine.ts` | **COMPLETE** | 8 priority rules + override handling with `getDecisionWithOverrides()`, 24 tests | | `decision-engine.ts` | **COMPLETE** | 8 priority rules + override handling with `getDecisionWithOverrides()`, 24 tests |
| `garmin.ts` | **COMPLETE** | 33 tests covering fetchGarminData, fetchHrvStatus, fetchBodyBattery, fetchIntensityMinutes, isTokenExpired, daysUntilExpiry, error handling, token validation | | `garmin.ts` | **COMPLETE** | 33 tests covering fetchGarminData, fetchHrvStatus, fetchBodyBattery, fetchIntensityMinutes, isTokenExpired, daysUntilExpiry, error handling, token validation |
| `pocketbase.ts` | **COMPLETE** | 9 tests covering `createPocketBaseClient()`, `isAuthenticated()`, `getCurrentUser()`, `loadAuthFromCookies()` | | `pocketbase.ts` | **COMPLETE** | 9 tests covering `createPocketBaseClient()`, `isAuthenticated()`, `getCurrentUser()`, `loadAuthFromCookies()` |
| `auth-middleware.ts` | **COMPLETE** | 9 tests covering `withAuth()` wrapper for API route protection, structured logging for auth failures | | `auth-middleware.ts` | **COMPLETE** | 12 tests covering `withAuth()` wrapper for API route protection, structured logging for auth failures, IP address logging |
| `middleware.ts` (Next.js) | **COMPLETE** | 12 tests covering page protection, redirects to login | | `middleware.ts` (Next.js) | **COMPLETE** | 12 tests covering page protection, redirects to login |
| `logger.ts` | **COMPLETE** | 16 tests covering JSON output, log levels, error stack traces, child loggers | | `logger.ts` | **COMPLETE** | 16 tests covering JSON output, log levels, error stack traces, child loggers |
| `metrics.ts` | **COMPLETE** | 33 tests covering metrics collection, counters, gauges, histograms, Prometheus format | | `metrics.ts` | **COMPLETE** | 33 tests covering metrics collection, counters, gauges, histograms, Prometheus format |
@@ -39,7 +39,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
| PATCH /api/user | **COMPLETE** | Updates cycleLength, notificationTime, timezone (17 tests) | | PATCH /api/user | **COMPLETE** | Updates cycleLength, notificationTime, timezone (17 tests) |
| POST /api/cycle/period | **COMPLETE** | Logs period start, updates user, creates PeriodLog with prediction tracking (13 tests) | | POST /api/cycle/period | **COMPLETE** | Logs period start, updates user, creates PeriodLog with prediction tracking (13 tests) |
| GET /api/cycle/current | **COMPLETE** | Returns cycle day, phase, config, daysUntilNextPhase (10 tests) | | GET /api/cycle/current | **COMPLETE** | Returns cycle day, phase, config, daysUntilNextPhase (10 tests) |
| GET /api/today | **COMPLETE** | Returns decision, cycle, biometrics, nutrition (22 tests) | | GET /api/today | **COMPLETE** | Returns decision, cycle, biometrics, nutrition (24 tests) |
| POST /api/overrides | **COMPLETE** | Adds override to user.activeOverrides (14 tests) | | POST /api/overrides | **COMPLETE** | Adds override to user.activeOverrides (14 tests) |
| DELETE /api/overrides | **COMPLETE** | Removes override from user.activeOverrides (14 tests) | | DELETE /api/overrides | **COMPLETE** | Removes override from user.activeOverrides (14 tests) |
| POST /api/garmin/tokens | **COMPLETE** | Stores encrypted Garmin OAuth tokens (15 tests) | | POST /api/garmin/tokens | **COMPLETE** | Stores encrypted Garmin OAuth tokens (15 tests) |
@@ -88,14 +88,14 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
| `src/lib/cycle.test.ts` | **EXISTS** - 22 tests | | `src/lib/cycle.test.ts` | **EXISTS** - 22 tests |
| `src/lib/decision-engine.test.ts` | **EXISTS** - 24 tests (8 algorithmic rules + 16 override scenarios) | | `src/lib/decision-engine.test.ts` | **EXISTS** - 24 tests (8 algorithmic rules + 16 override scenarios) |
| `src/lib/pocketbase.test.ts` | **EXISTS** - 9 tests (auth helpers, cookie loading) | | `src/lib/pocketbase.test.ts` | **EXISTS** - 9 tests (auth helpers, cookie loading) |
| `src/lib/auth-middleware.test.ts` | **EXISTS** - 9 tests (withAuth wrapper, error handling, structured logging) | | `src/lib/auth-middleware.test.ts` | **EXISTS** - 12 tests (withAuth wrapper, error handling, structured logging, IP address logging) |
| `src/lib/logger.test.ts` | **EXISTS** - 16 tests (JSON format, log levels, error serialization, child loggers) | | `src/lib/logger.test.ts` | **EXISTS** - 16 tests (JSON format, log levels, error serialization, child loggers) |
| `src/lib/metrics.test.ts` | **EXISTS** - 18 tests (metrics collection, counters, gauges, histograms, Prometheus format) | | `src/lib/metrics.test.ts` | **EXISTS** - 18 tests (metrics collection, counters, gauges, histograms, Prometheus format) |
| `src/middleware.test.ts` | **EXISTS** - 12 tests (page protection, public routes, static assets) | | `src/middleware.test.ts` | **EXISTS** - 12 tests (page protection, public routes, static assets) |
| `src/app/api/user/route.test.ts` | **EXISTS** - 21 tests (GET/PATCH profile, auth, validation, security) | | `src/app/api/user/route.test.ts` | **EXISTS** - 21 tests (GET/PATCH profile, auth, validation, security) |
| `src/app/api/cycle/period/route.test.ts` | **EXISTS** - 13 tests (POST period, auth, validation, date checks, prediction tracking) | | `src/app/api/cycle/period/route.test.ts` | **EXISTS** - 13 tests (POST period, auth, validation, date checks, prediction tracking) |
| `src/app/api/cycle/current/route.test.ts` | **EXISTS** - 10 tests (GET current cycle, auth, all phases, rollover, custom lengths) | | `src/app/api/cycle/current/route.test.ts` | **EXISTS** - 10 tests (GET current cycle, auth, all phases, rollover, custom lengths) |
| `src/app/api/today/route.test.ts` | **EXISTS** - 22 tests (daily snapshot, auth, decision, overrides, phases, nutrition, biometrics) | | `src/app/api/today/route.test.ts` | **EXISTS** - 24 tests (daily snapshot, auth, decision, overrides, phases, nutrition, biometrics, seed switch alert) |
| `src/app/api/overrides/route.test.ts` | **EXISTS** - 14 tests (POST/DELETE overrides, auth, validation, type checks) | | `src/app/api/overrides/route.test.ts` | **EXISTS** - 14 tests (POST/DELETE overrides, auth, validation, type checks) |
| `src/app/login/page.test.tsx` | **EXISTS** - 32 tests (form rendering, auth flow, error handling, validation, accessibility, rate limiting) | | `src/app/login/page.test.tsx` | **EXISTS** - 32 tests (form rendering, auth flow, error handling, validation, accessibility, rate limiting) |
| `src/app/page.test.tsx` | **EXISTS** - 28 tests (data fetching, component rendering, override toggles, error handling) | | `src/app/page.test.tsx` | **EXISTS** - 28 tests (data fetching, component rendering, override toggles, error handling) |
@@ -117,18 +117,18 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
| `src/app/history/page.test.tsx` | **EXISTS** - 26 tests (rendering, data loading, pagination, date filtering, styling) | | `src/app/history/page.test.tsx` | **EXISTS** - 26 tests (rendering, data loading, pagination, date filtering, styling) |
| `src/app/period-history/page.test.tsx` | **EXISTS** - 27 tests (rendering, edit/delete modals, pagination, prediction accuracy) | | `src/app/period-history/page.test.tsx` | **EXISTS** - 27 tests (rendering, edit/delete modals, pagination, prediction accuracy) |
| `src/app/api/metrics/route.test.ts` | **EXISTS** - 15 tests (Prometheus format validation, metric types, route handling) | | `src/app/api/metrics/route.test.ts` | **EXISTS** - 15 tests (Prometheus format validation, metric types, route handling) |
| `src/components/calendar/month-view.test.tsx` | **EXISTS** - 30 tests (calendar grid, phase colors, navigation, legend, keyboard navigation) | | `src/components/calendar/month-view.test.tsx` | **EXISTS** - 31 tests (calendar grid, phase colors, navigation, legend, keyboard navigation, emojis) |
| `src/app/calendar/page.test.tsx` | **EXISTS** - 23 tests (rendering, navigation, ICS subscription, token regeneration) | | `src/app/calendar/page.test.tsx` | **EXISTS** - 23 tests (rendering, navigation, ICS subscription, token regeneration) |
| `src/app/settings/page.test.tsx` | **EXISTS** - 34 tests (form rendering, validation, submission, accessibility, logout functionality) | | `src/app/settings/page.test.tsx` | **EXISTS** - 34 tests (form rendering, validation, submission, accessibility, logout functionality) |
| `src/app/api/auth/logout/route.test.ts` | **EXISTS** - 5 tests (cookie clearing, success response, error handling) | | `src/app/api/auth/logout/route.test.ts` | **EXISTS** - 5 tests (cookie clearing, success response, error handling) |
| `src/app/settings/garmin/page.test.tsx` | **EXISTS** - 27 tests (connection status, token management) | | `src/app/settings/garmin/page.test.tsx` | **EXISTS** - 27 tests (connection status, token management) |
| `src/components/dashboard/decision-card.test.tsx` | **EXISTS** - 19 tests (rendering, status icons, styling, color-coded backgrounds) | | `src/components/dashboard/decision-card.test.tsx` | **EXISTS** - 19 tests (rendering, status icons, styling, color-coded backgrounds) |
| `src/components/dashboard/data-panel.test.tsx` | **EXISTS** - 29 tests (biometrics display, null handling, styling, HRV status color-coding, intensity progress bar) | | `src/components/dashboard/data-panel.test.tsx` | **EXISTS** - 29 tests (biometrics display, null handling, styling, HRV status color-coding, intensity progress bar) |
| `src/components/dashboard/nutrition-panel.test.tsx` | **EXISTS** - 12 tests (seeds, carbs, keto guidance) | | `src/components/dashboard/nutrition-panel.test.tsx` | **EXISTS** - 16 tests (seeds, carbs, keto guidance, seed switch alert) |
| `src/components/dashboard/override-toggles.test.tsx` | **EXISTS** - 18 tests (toggle states, callbacks, styling) | | `src/components/dashboard/override-toggles.test.tsx` | **EXISTS** - 18 tests (toggle states, callbacks, styling) |
| `src/components/dashboard/mini-calendar.test.tsx` | **EXISTS** - 23 tests (calendar grid, phase colors, navigation, legend) | | `src/components/dashboard/mini-calendar.test.tsx` | **EXISTS** - 23 tests (calendar grid, phase colors, navigation, legend) |
| `src/components/dashboard/onboarding-banner.test.tsx` | **EXISTS** - 16 tests (setup prompts, icons, action buttons, interactions, dismissal) | | `src/components/dashboard/onboarding-banner.test.tsx` | **EXISTS** - 16 tests (setup prompts, icons, action buttons, interactions, dismissal) |
| `src/components/calendar/day-cell.test.tsx` | **EXISTS** - 27 tests (phase coloring, today highlighting, click handling, accessibility) | | `src/components/calendar/day-cell.test.tsx` | **EXISTS** - 32 tests (phase coloring, today highlighting, click handling, accessibility, period indicator) |
| `src/app/plan/page.test.tsx` | **EXISTS** - 16 tests (loading states, error handling, phase display, exercise reference, rebounding techniques) | | `src/app/plan/page.test.tsx` | **EXISTS** - 16 tests (loading states, error handling, phase display, exercise reference, rebounding techniques) |
| `src/app/layout.test.tsx` | **EXISTS** - 4 tests (skip navigation link rendering, accessibility, Toaster rendering) | | `src/app/layout.test.tsx` | **EXISTS** - 4 tests (skip navigation link rendering, accessibility, Toaster rendering) |
| `src/components/ui/toaster.test.tsx` | **EXISTS** - 23 tests (toast rendering, types, auto-dismiss, error persistence, accessibility) | | `src/components/ui/toaster.test.tsx` | **EXISTS** - 23 tests (toast rendering, types, auto-dismiss, error persistence, accessibility) |
@@ -158,10 +158,10 @@ These must be completed first - nothing else works without them.
### P0.2: Auth Middleware for API Routes ✅ COMPLETE ### P0.2: Auth Middleware for API Routes ✅ COMPLETE
- [x] Create reusable auth middleware for protected API endpoints - [x] Create reusable auth middleware for protected API endpoints
- **Files:** - **Files:**
- `src/lib/auth-middleware.ts` - Added `withAuth()` wrapper for route handlers - `src/lib/auth-middleware.ts` - Added `withAuth()` wrapper for route handlers with IP address logging
- `src/middleware.ts` - Added Next.js middleware for page protection - `src/middleware.ts` - Added Next.js middleware for page protection
- **Tests:** - **Tests:**
- `src/lib/auth-middleware.test.ts` - 6 tests covering unauthorized rejection, user context passing, error handling - `src/lib/auth-middleware.test.ts` - 12 tests covering unauthorized rejection, user context passing, error handling, IP address logging
- `src/middleware.test.ts` - 12 tests covering protected routes, public routes, API routes, static assets - `src/middleware.test.ts` - 12 tests covering protected routes, public routes, API routes, static assets
- **Why:** All API routes except `/api/calendar/[userId]/[token].ics` and `/api/cron/*` require auth - **Why:** All API routes except `/api/calendar/[userId]/[token].ics` and `/api/cron/*` require auth
- **Depends On:** P0.1 - **Depends On:** P0.1
@@ -239,11 +239,11 @@ Minimum viable product - app can be used for daily decisions.
- **Files:** - **Files:**
- `src/app/api/today/route.ts` - Implemented GET with `withAuth()` wrapper, aggregates cycle, biometrics, and nutrition - `src/app/api/today/route.ts` - Implemented GET with `withAuth()` wrapper, aggregates cycle, biometrics, and nutrition
- **Tests:** - **Tests:**
- `src/app/api/today/route.test.ts` - 22 tests covering auth, validation, decision calculation, overrides, phases, nutrition - `src/app/api/today/route.test.ts` - 24 tests covering auth, validation, decision calculation, overrides, phases, nutrition, seed switch alert
- **Response Shape:** - **Response Shape:**
- `decision` (status, reason, icon), `cycleDay`, `phase`, `phaseConfig`, `daysUntilNextPhase`, `cycleLength` - `decision` (status, reason, icon), `cycleDay`, `phase`, `phaseConfig`, `daysUntilNextPhase`, `cycleLength`
- `biometrics` (hrvStatus, bodyBatteryCurrent, bodyBatteryYesterdayLow, weekIntensityMinutes, phaseLimit) - `biometrics` (hrvStatus, bodyBatteryCurrent, bodyBatteryYesterdayLow, weekIntensityMinutes, phaseLimit)
- `nutrition` (seeds, carbRange, ketoGuidance) - `nutrition` (seeds, carbRange, ketoGuidance, seedSwitchAlert)
- **Fallback Behavior:** When no DailyLog exists (Garmin not synced), returns defaults: hrvStatus="Unknown", BB=100, weekIntensity=0 - **Fallback Behavior:** When no DailyLog exists (Garmin not synced), returns defaults: hrvStatus="Unknown", BB=100, weekIntensity=0
- **Why:** This is THE core API for the dashboard - **Why:** This is THE core API for the dashboard
- **Depends On:** P0.1, P0.2, P0.3, P1.3 - **Depends On:** P0.1, P0.2, P0.3, P1.3
@@ -447,9 +447,9 @@ Full feature set for production use.
- [x] In-app calendar with phase visualization - [x] In-app calendar with phase visualization
- **Files:** - **Files:**
- `src/app/calendar/page.tsx` - Month view with navigation, ICS subscription section with URL display, copy button, token regeneration - `src/app/calendar/page.tsx` - Month view with navigation, ICS subscription section with URL display, copy button, token regeneration
- `src/components/calendar/month-view.tsx` - Complete calendar grid with DayCell integration, navigation controls, phase legend - `src/components/calendar/month-view.tsx` - Complete calendar grid with DayCell integration, navigation controls, phase legend with emojis
- **Tests:** - **Tests:**
- `src/components/calendar/month-view.test.tsx` - 21 tests covering calendar grid, phase colors, navigation, legend - `src/components/calendar/month-view.test.tsx` - 31 tests covering calendar grid, phase colors, navigation, legend, emojis
- `src/app/calendar/page.test.tsx` - 23 tests covering rendering, navigation, ICS subscription, token regeneration - `src/app/calendar/page.test.tsx` - 23 tests covering rendering, navigation, ICS subscription, token regeneration
- **Why:** Planning ahead is a key user need - **Why:** Planning ahead is a key user need
- **Depends On:** P2.6 - **Depends On:** P2.6
@@ -646,7 +646,7 @@ Testing, error handling, and refinements.
- [x] Replace console.error with structured pino logger - [x] Replace console.error with structured pino logger
- [x] Add logging for key events per observability spec - [x] Add logging for key events per observability spec
- **Files:** - **Files:**
- `src/lib/auth-middleware.ts` - Replaced console.error with structured logger, added auth failure logging - `src/lib/auth-middleware.ts` - Replaced console.error with structured logger, added auth failure logging with IP address
- `src/app/api/cycle/period/route.ts` - Added "Period logged" event logging, structured error logging - `src/app/api/cycle/period/route.ts` - Added "Period logged" event logging, structured error logging
- `src/app/api/calendar/[userId]/[token].ics/route.ts` - Replaced console.error with structured logger - `src/app/api/calendar/[userId]/[token].ics/route.ts` - Replaced console.error with structured logger
- `src/app/api/overrides/route.ts` - Added "Override toggled" event logging - `src/app/api/overrides/route.ts` - Added "Override toggled" event logging
@@ -654,9 +654,9 @@ Testing, error handling, and refinements.
- `src/app/api/cron/garmin-sync/route.ts` - Added "Garmin sync start", "Garmin sync complete", "Garmin sync failure" logging - `src/app/api/cron/garmin-sync/route.ts` - Added "Garmin sync start", "Garmin sync complete", "Garmin sync failure" logging
- `src/app/api/auth/logout/route.ts` - Added "User logged out" logging - `src/app/api/auth/logout/route.ts` - Added "User logged out" logging
- **Tests:** - **Tests:**
- `src/lib/auth-middleware.test.ts` - Added 3 tests for structured logging (9 total) - `src/lib/auth-middleware.test.ts` - Added 6 tests for structured logging and IP address logging (12 total)
- **Events Logged (per observability spec):** - **Events Logged (per observability spec):**
- Auth failure (warn): reason - Auth failure (warn): reason, ip (from x-forwarded-for or x-real-ip headers)
- Period logged (info): userId, date - Period logged (info): userId, date
- Override toggled (info): userId, override, enabled - Override toggled (info): userId, override, enabled
- Decision calculated (info): userId, decision, reason - Decision calculated (info): userId, decision, reason
@@ -713,16 +713,16 @@ Testing, error handling, and refinements.
- **Components Tested (5 total):** - **Components Tested (5 total):**
- `src/components/dashboard/decision-card.tsx` - 19 tests for rendering decision status, icon, reason, styling, color-coded backgrounds - `src/components/dashboard/decision-card.tsx` - 19 tests for rendering decision status, icon, reason, styling, color-coded backgrounds
- `src/components/dashboard/data-panel.tsx` - 18 tests for biometrics display (BB, HRV, intensity), null handling, styling - `src/components/dashboard/data-panel.tsx` - 18 tests for biometrics display (BB, HRV, intensity), null handling, styling
- `src/components/dashboard/nutrition-panel.tsx` - 12 tests for seeds, carbs, keto guidance display - `src/components/dashboard/nutrition-panel.tsx` - 16 tests for seeds, carbs, keto guidance display, seed switch alert
- `src/components/dashboard/override-toggles.tsx` - 18 tests for toggle states, callbacks, styling - `src/components/dashboard/override-toggles.tsx` - 18 tests for toggle states, callbacks, styling
- `src/components/calendar/day-cell.tsx` - 23 tests for phase coloring, today highlighting, click handling - `src/components/calendar/day-cell.tsx` - 32 tests for phase coloring, today highlighting, click handling, period indicator
- **Test Files Created:** - **Test Files Created:**
- `src/components/dashboard/decision-card.test.tsx` - 19 tests - `src/components/dashboard/decision-card.test.tsx` - 19 tests
- `src/components/dashboard/data-panel.test.tsx` - 18 tests - `src/components/dashboard/data-panel.test.tsx` - 18 tests
- `src/components/dashboard/nutrition-panel.test.tsx` - 12 tests - `src/components/dashboard/nutrition-panel.test.tsx` - 16 tests
- `src/components/dashboard/override-toggles.test.tsx` - 18 tests - `src/components/dashboard/override-toggles.test.tsx` - 18 tests
- `src/components/calendar/day-cell.test.tsx` - 23 tests - `src/components/calendar/day-cell.test.tsx` - 32 tests
- **Total Tests Added:** 90 tests across 5 files - **Total Tests Added:** 103 tests across 5 files
- **Why:** Component isolation ensures UI correctness and prevents regressions - **Why:** Component isolation ensures UI correctness and prevents regressions
--- ---
@@ -909,7 +909,7 @@ P4.* UX Polish ────────> After core functionality complete
- [x] **ics.ts** - Complete with 33 tests (`generateIcsFeed`, ICS format validation, 90-day event generation, period prediction feedback, CATEGORIES for calendar colors) (P3.4, P4.5) - [x] **ics.ts** - Complete with 33 tests (`generateIcsFeed`, ICS format validation, 90-day event generation, period prediction feedback, CATEGORIES for calendar colors) (P3.4, P4.5)
- [x] **encryption.ts** - Complete with 14 tests (AES-256-GCM encrypt/decrypt, round-trip validation, error handling) (P3.5) - [x] **encryption.ts** - Complete with 14 tests (AES-256-GCM encrypt/decrypt, round-trip validation, error handling) (P3.5)
- [x] **garmin.ts** - Complete with 33 tests (`fetchGarminData`, `fetchHrvStatus`, `fetchBodyBattery`, `fetchIntensityMinutes`, `isTokenExpired`, `daysUntilExpiry`, error handling) (P2.1, P3.6) - [x] **garmin.ts** - Complete with 33 tests (`fetchGarminData`, `fetchHrvStatus`, `fetchBodyBattery`, `fetchIntensityMinutes`, `isTokenExpired`, `daysUntilExpiry`, error handling) (P2.1, P3.6)
- [x] **auth-middleware.ts** - Complete with 6 tests (`withAuth()` wrapper) - [x] **auth-middleware.ts** - Complete with 12 tests (`withAuth()` wrapper, structured logging, IP address logging)
- [x] **middleware.ts** - Complete with 12 tests (Next.js page protection) - [x] **middleware.ts** - Complete with 12 tests (Next.js page protection)
- [x] **logger.ts** - Complete with 16 tests (JSON output, log levels, error serialization, child loggers) (P2.17) - [x] **logger.ts** - Complete with 16 tests (JSON output, log levels, error serialization, child loggers) (P2.17)
- [x] **metrics.ts** - Complete with 18 tests (metrics collection, counters, gauges, histograms, Prometheus format) (P2.16) - [x] **metrics.ts** - Complete with 18 tests (metrics collection, counters, gauges, histograms, Prometheus format) (P2.16)
@@ -920,7 +920,7 @@ P4.* UX Polish ────────> After core functionality complete
- [x] **NutritionPanel** - Shows seeds, carbs, keto guidance - [x] **NutritionPanel** - Shows seeds, carbs, keto guidance
- [x] **OverrideToggles** - Toggle buttons for flare/stress/sleep/pms - [x] **OverrideToggles** - Toggle buttons for flare/stress/sleep/pms
- [x] **DayCell** - Phase-colored calendar day cell with click handler - [x] **DayCell** - Phase-colored calendar day cell with click handler
- [x] **MonthView** - Calendar grid with DayCell integration, navigation controls (prev/next month, Today button), phase legend, 21 tests - [x] **MonthView** - Calendar grid with DayCell integration, navigation controls (prev/next month, Today button), phase legend with emojis, 31 tests
- [x] **MiniCalendar** - Compact calendar widget with phase colors, navigation, legend, 23 tests (P2.14) - [x] **MiniCalendar** - Compact calendar widget with phase colors, navigation, legend, 23 tests (P2.14)
### API Routes (21 complete) ### API Routes (21 complete)
@@ -929,7 +929,7 @@ P4.* UX Polish ────────> After core functionality complete
- [x] **PATCH /api/user** - Updates user profile (cycleLength, notificationTime, timezone), 17 tests (P1.1) - [x] **PATCH /api/user** - Updates user profile (cycleLength, notificationTime, timezone), 17 tests (P1.1)
- [x] **POST /api/cycle/period** - Logs period start date, updates user, creates PeriodLog with prediction tracking, 13 tests (P1.2, P4.5) - [x] **POST /api/cycle/period** - Logs period start date, updates user, creates PeriodLog with prediction tracking, 13 tests (P1.2, P4.5)
- [x] **GET /api/cycle/current** - Returns cycle day, phase, phaseConfig, daysUntilNextPhase, cycleLength, 10 tests (P1.3) - [x] **GET /api/cycle/current** - Returns cycle day, phase, phaseConfig, daysUntilNextPhase, cycleLength, 10 tests (P1.3)
- [x] **GET /api/today** - Returns complete daily snapshot with decision, biometrics, nutrition, 22 tests (P1.4) - [x] **GET /api/today** - Returns complete daily snapshot with decision, biometrics, nutrition, 24 tests (P1.4)
- [x] **POST /api/overrides** - Adds override to user.activeOverrides array, 14 tests (P1.5) - [x] **POST /api/overrides** - Adds override to user.activeOverrides array, 14 tests (P1.5)
- [x] **DELETE /api/overrides** - Removes override from user.activeOverrides array, 14 tests (P1.5) - [x] **DELETE /api/overrides** - Removes override from user.activeOverrides array, 14 tests (P1.5)
- [x] **POST /api/garmin/tokens** - Stores encrypted Garmin OAuth tokens, 15 tests (P2.2) - [x] **POST /api/garmin/tokens** - Stores encrypted Garmin OAuth tokens, 15 tests (P2.2)
@@ -966,10 +966,10 @@ P4.* UX Polish ────────> After core functionality complete
- [x] **P3.4: ICS Tests** - Complete with 28 tests covering ICS format validation, 90-day event generation, timezone handling, period prediction feedback - [x] **P3.4: ICS Tests** - Complete with 28 tests covering ICS format validation, 90-day event generation, timezone handling, period prediction feedback
- [x] **P3.5: Encryption Tests** - Complete with 14 tests covering AES-256-GCM round-trip, error handling, key validation - [x] **P3.5: Encryption Tests** - Complete with 14 tests covering AES-256-GCM round-trip, error handling, key validation
- [x] **P3.6: Garmin Tests** - Complete with 33 tests covering API interactions, token expiry, error handling - [x] **P3.6: Garmin Tests** - Complete with 33 tests covering API interactions, token expiry, error handling
- [x] **P3.7: Error Handling Improvements** - Replaced console.error with structured pino logger across API routes, added key event logging (Period logged, Override toggled, Decision calculated, Auth failure), 3 new tests in auth-middleware.test.ts - [x] **P3.7: Error Handling Improvements** - Replaced console.error with structured pino logger across API routes, added key event logging (Period logged, Override toggled, Decision calculated, Auth failure with IP address), 6 new tests in auth-middleware.test.ts
- [x] **P3.8: Loading States** - Complete with skeleton components (DecisionCardSkeleton, DataPanelSkeleton, NutritionPanelSkeleton, MiniCalendarSkeleton, OverrideTogglesSkeleton, CycleInfoSkeleton, DashboardSkeleton), 29 tests in skeletons.test.tsx; loading.tsx files for all routes (dashboard, calendar, history, plan, settings); shimmer animations matching spec requirements - [x] **P3.8: Loading States** - Complete with skeleton components (DecisionCardSkeleton, DataPanelSkeleton, NutritionPanelSkeleton, MiniCalendarSkeleton, OverrideTogglesSkeleton, CycleInfoSkeleton, DashboardSkeleton), 29 tests in skeletons.test.tsx; loading.tsx files for all routes (dashboard, calendar, history, plan, settings); shimmer animations matching spec requirements
- [x] **P3.9: Token Expiration Warnings** - Complete with 10 new tests in email.test.ts, 10 new tests in garmin-sync/route.test.ts; sends warnings at 14 and 7 days before expiry - [x] **P3.9: Token Expiration Warnings** - Complete with 10 new tests in email.test.ts, 10 new tests in garmin-sync/route.test.ts; sends warnings at 14 and 7 days before expiry
- [x] **P3.11: Missing Component Tests** - Complete with 90 tests across 5 component test files (DecisionCard: 19, DataPanel: 18, NutritionPanel: 12, OverrideToggles: 18, DayCell: 23) - [x] **P3.11: Missing Component Tests** - Complete with 98 tests across 5 component test files (DecisionCard: 19, DataPanel: 18, NutritionPanel: 16, OverrideToggles: 18, DayCell: 27)
### P4: UX Polish and Accessibility ### P4: UX Polish and Accessibility
- [x] **P4.1: Dashboard Onboarding Banners** - Complete with OnboardingBanner component (16 tests), dashboard integration (5 new tests) - [x] **P4.1: Dashboard Onboarding Banners** - Complete with OnboardingBanner component (16 tests), dashboard integration (5 new tests)
@@ -1007,6 +1007,10 @@ Additional spec compliance improvements implemented:
| Seed switch alert in email | notifications.md | **FIXED** | Daily email now includes seed switch alert on day 15 | | Seed switch alert in email | notifications.md | **FIXED** | Daily email now includes seed switch alert on day 15 |
| HRV status color-coding | dashboard.md | **FIXED** | Data panel now shows green/red/gray based on HRV status | | HRV status color-coding | dashboard.md | **FIXED** | Data panel now shows green/red/gray based on HRV status |
| Intensity progress bar | dashboard.md | **FIXED** | Data panel now shows visual progress bar with color-coding | | Intensity progress bar | dashboard.md | **FIXED** | Data panel now shows visual progress bar with color-coding |
| Seed switch alert on day 15 | nutrition.md | **FIXED** | NutritionPanel now displays seedSwitchAlert from API |
| Phase emojis in calendar legend | calendar.md | **FIXED** | MonthView now shows emojis per spec (🩸 Menstrual \| 🌱 Follicular \| 🌸 Ovulation \| 🌙 Early Luteal \| 🌑 Late Luteal) |
| Period indicator in day cells | calendar.md | **FIXED** | DayCell now shows 🩸 for days 1-3 |
| IP address in auth failure logs | observability.md | **FIXED** | Auth middleware now logs client IP from x-forwarded-for or x-real-ip headers |
--- ---

View File

@@ -456,6 +456,40 @@ describe("GET /api/today", () => {
); );
expect(body.nutrition.carbRange).toBe("75-125g"); expect(body.nutrition.carbRange).toBe("75-125g");
}); });
it("returns seed switch alert on day 15", async () => {
// Set to cycle day 15 - the seed switch day
currentMockUser = createMockUser({
lastPeriodDate: new Date("2024-12-27"), // 14 days ago = day 15
});
currentMockDailyLog = createMockDailyLog({
cycleDay: 15,
phase: "OVULATION",
});
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.nutrition.seedSwitchAlert).toBe(
"🌱 SWITCH TODAY! Start Sesame + Sunflower",
);
});
it("returns null seed switch alert on other days", async () => {
currentMockUser = createMockUser({
lastPeriodDate: new Date("2025-01-01"), // cycle day 10
});
currentMockDailyLog = createMockDailyLog();
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.nutrition.seedSwitchAlert).toBeNull();
});
}); });
describe("biometrics data", () => { describe("biometrics data", () => {

View File

@@ -11,7 +11,7 @@ import {
} from "@/lib/cycle"; } from "@/lib/cycle";
import { getDecisionWithOverrides } from "@/lib/decision-engine"; import { getDecisionWithOverrides } from "@/lib/decision-engine";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { getNutritionGuidance } from "@/lib/nutrition"; import { getNutritionGuidance, getSeedSwitchAlert } from "@/lib/nutrition";
import type { DailyData, DailyLog, HrvStatus } from "@/types"; import type { DailyData, DailyLog, HrvStatus } from "@/types";
// Default biometrics when no Garmin data is available // Default biometrics when no Garmin data is available
@@ -107,8 +107,12 @@ export const GET = withAuth(async (_request, user, pb) => {
"Decision calculated", "Decision calculated",
); );
// Get nutrition guidance // Get nutrition guidance with seed switch alert
const nutrition = getNutritionGuidance(cycleDay); const baseNutrition = getNutritionGuidance(cycleDay);
const nutrition = {
...baseNutrition,
seedSwitchAlert: getSeedSwitchAlert(cycleDay),
};
return NextResponse.json({ return NextResponse.json({
decision, decision,

View File

@@ -238,4 +238,36 @@ describe("DayCell", () => {
expect(button.getAttribute("aria-label")).toContain("Late Luteal phase"); expect(button.getAttribute("aria-label")).toContain("Late Luteal phase");
}); });
}); });
describe("period indicator", () => {
it("shows period indicator dot on cycle day 1", () => {
render(<DayCell {...baseProps} cycleDay={1} phase="MENSTRUAL" />);
expect(screen.getByText("🩸")).toBeInTheDocument();
});
it("shows period indicator dot on cycle day 2", () => {
render(<DayCell {...baseProps} cycleDay={2} phase="MENSTRUAL" />);
expect(screen.getByText("🩸")).toBeInTheDocument();
});
it("shows period indicator dot on cycle day 3", () => {
render(<DayCell {...baseProps} cycleDay={3} phase="MENSTRUAL" />);
expect(screen.getByText("🩸")).toBeInTheDocument();
});
it("does not show period indicator on cycle day 4", () => {
render(<DayCell {...baseProps} cycleDay={4} phase="FOLLICULAR" />);
expect(screen.queryByText("🩸")).not.toBeInTheDocument();
});
it("does not show period indicator on cycle day 10", () => {
render(<DayCell {...baseProps} cycleDay={10} phase="FOLLICULAR" />);
expect(screen.queryByText("🩸")).not.toBeInTheDocument();
});
});
}); });

View File

@@ -53,6 +53,8 @@ export function DayCell({
}: DayCellProps) { }: DayCellProps) {
const ariaLabel = formatAriaLabel(date, cycleDay, phase, isToday); const ariaLabel = formatAriaLabel(date, cycleDay, phase, isToday);
const isPeriodDay = cycleDay >= 1 && cycleDay <= 3;
return ( return (
<button <button
type="button" type="button"
@@ -61,7 +63,10 @@ export function DayCell({
data-day={dataDay} data-day={dataDay}
className={`p-2 rounded ${PHASE_COLORS[phase]} ${isToday ? "ring-2 ring-black" : ""}`} className={`p-2 rounded ${PHASE_COLORS[phase]} ${isToday ? "ring-2 ring-black" : ""}`}
> >
<span className="text-sm font-medium">{date.getDate()}</span> <span className="text-sm font-medium">
{date.getDate()}
{isPeriodDay && <span className="ml-0.5">🩸</span>}
</span>
<span className="text-xs text-gray-500 block">Day {cycleDay}</span> <span className="text-xs text-gray-500 block">Day {cycleDay}</span>
</button> </button>
); );

View File

@@ -218,6 +218,18 @@ describe("MonthView", () => {
expect(screen.getByText(/early luteal/i)).toBeInTheDocument(); expect(screen.getByText(/early luteal/i)).toBeInTheDocument();
expect(screen.getByText(/late luteal/i)).toBeInTheDocument(); expect(screen.getByText(/late luteal/i)).toBeInTheDocument();
}); });
it("displays phase emojis per spec", () => {
render(<MonthView {...baseProps} />);
// Spec requires: 🩸 Menstrual | 🌱 Follicular | 🌸 Ovulation | 🌙 Early Luteal | 🌑 Late Luteal
// Look for complete legend items to avoid matching period indicator emojis
expect(screen.getByText(/🩸 Menstrual/)).toBeInTheDocument();
expect(screen.getByText(/🌱 Follicular/)).toBeInTheDocument();
expect(screen.getByText(/🌸 Ovulation/)).toBeInTheDocument();
expect(screen.getByText(/🌙 Early Luteal/)).toBeInTheDocument();
expect(screen.getByText(/🌑 Late Luteal/)).toBeInTheDocument();
});
}); });
describe("cycle rollover", () => { describe("cycle rollover", () => {

View File

@@ -18,11 +18,11 @@ interface MonthViewProps {
const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const PHASE_LEGEND = [ const PHASE_LEGEND = [
{ name: "Menstrual", color: "bg-blue-100" }, { name: "Menstrual", color: "bg-blue-100", emoji: "🩸" },
{ name: "Follicular", color: "bg-green-100" }, { name: "Follicular", color: "bg-green-100", emoji: "🌱" },
{ name: "Ovulation", color: "bg-purple-100" }, { name: "Ovulation", color: "bg-purple-100", emoji: "🌸" },
{ name: "Early Luteal", color: "bg-yellow-100" }, { name: "Early Luteal", color: "bg-yellow-100", emoji: "🌙" },
{ name: "Late Luteal", color: "bg-red-100" }, { name: "Late Luteal", color: "bg-red-100", emoji: "🌑" },
]; ];
function getDaysInMonth(year: number, month: number): number { function getDaysInMonth(year: number, month: number): number {
@@ -228,7 +228,9 @@ export function MonthView({
{PHASE_LEGEND.map((phase) => ( {PHASE_LEGEND.map((phase) => (
<div key={phase.name} className="flex items-center gap-1"> <div key={phase.name} className="flex items-center gap-1">
<div className={`w-4 h-4 rounded ${phase.color}`} /> <div className={`w-4 h-4 rounded ${phase.color}`} />
<span className="text-xs text-gray-600">{phase.name}</span> <span className="text-xs text-gray-600">
{phase.emoji} {phase.name}
</span>
</div> </div>
))} ))}
</div> </div>

View File

@@ -116,6 +116,52 @@ describe("NutritionPanel", () => {
}); });
}); });
describe("seed switch alert", () => {
it("displays seed switch alert when provided", () => {
const nutrition: NutritionGuidance = {
...baseNutrition,
seedSwitchAlert: "🌱 SWITCH TODAY! Start Sesame + Sunflower",
};
render(<NutritionPanel nutrition={nutrition} />);
expect(
screen.getByText("🌱 SWITCH TODAY! Start Sesame + Sunflower"),
).toBeInTheDocument();
});
it("does not display alert section when seedSwitchAlert is null", () => {
const nutrition: NutritionGuidance = {
...baseNutrition,
seedSwitchAlert: null,
};
render(<NutritionPanel nutrition={nutrition} />);
expect(screen.queryByText(/SWITCH TODAY/)).not.toBeInTheDocument();
});
it("does not display alert section when seedSwitchAlert is undefined", () => {
render(<NutritionPanel nutrition={baseNutrition} />);
expect(screen.queryByText(/SWITCH TODAY/)).not.toBeInTheDocument();
});
it("renders alert with prominent styling", () => {
const nutrition: NutritionGuidance = {
...baseNutrition,
seedSwitchAlert: "🌱 SWITCH TODAY! Start Sesame + Sunflower",
};
render(<NutritionPanel nutrition={nutrition} />);
const alert = screen.getByText(
"🌱 SWITCH TODAY! Start Sesame + Sunflower",
);
expect(alert).toHaveClass("bg-amber-100", "dark:bg-amber-900");
});
});
describe("styling", () => { describe("styling", () => {
it("renders within a bordered container", () => { it("renders within a bordered container", () => {
const { container } = render( const { container } = render(

View File

@@ -10,6 +10,11 @@ export function NutritionPanel({ nutrition }: NutritionPanelProps) {
return ( return (
<div className="rounded-lg border p-4"> <div className="rounded-lg border p-4">
<h3 className="font-semibold mb-4">NUTRITION TODAY</h3> <h3 className="font-semibold mb-4">NUTRITION TODAY</h3>
{nutrition.seedSwitchAlert && (
<div className="mb-3 p-2 rounded bg-amber-100 dark:bg-amber-900 text-sm font-medium">
{nutrition.seedSwitchAlert}
</div>
)}
<ul className="space-y-2 text-sm"> <ul className="space-y-2 text-sm">
<li>🌱 {nutrition.seeds}</li> <li>🌱 {nutrition.seeds}</li>
<li>🍽 Carbs: {nutrition.carbRange}</li> <li>🍽 Carbs: {nutrition.carbRange}</li>

View File

@@ -79,6 +79,16 @@ describe("withAuth", () => {
get: vi.fn(), get: vi.fn(),
}; };
// Helper to create mock request with headers
const createMockRequest = (
headers: Record<string, string | null> = {},
): NextRequest =>
({
headers: {
get: vi.fn((name: string) => headers[name] ?? null),
},
}) as unknown as NextRequest;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockCookies.mockResolvedValue(mockCookieStore); mockCookies.mockResolvedValue(mockCookieStore);
@@ -91,7 +101,7 @@ describe("withAuth", () => {
const handler = vi.fn(); const handler = vi.fn();
const wrappedHandler = withAuth(handler); const wrappedHandler = withAuth(handler);
const mockRequest = {} as NextRequest; const mockRequest = createMockRequest();
const response = await wrappedHandler(mockRequest); const response = await wrappedHandler(mockRequest);
expect(response.status).toBe(401); expect(response.status).toBe(401);
@@ -109,7 +119,7 @@ describe("withAuth", () => {
.mockResolvedValue(NextResponse.json({ data: "success" })); .mockResolvedValue(NextResponse.json({ data: "success" }));
const wrappedHandler = withAuth(handler); const wrappedHandler = withAuth(handler);
const mockRequest = {} as NextRequest; const mockRequest = createMockRequest();
const response = await wrappedHandler(mockRequest); const response = await wrappedHandler(mockRequest);
expect(response.status).toBe(200); expect(response.status).toBe(200);
@@ -128,7 +138,7 @@ describe("withAuth", () => {
const handler = vi.fn().mockResolvedValue(NextResponse.json({})); const handler = vi.fn().mockResolvedValue(NextResponse.json({}));
const wrappedHandler = withAuth(handler); const wrappedHandler = withAuth(handler);
await wrappedHandler({} as NextRequest); await wrappedHandler(createMockRequest());
expect(mockCreatePocketBaseClient).toHaveBeenCalled(); expect(mockCreatePocketBaseClient).toHaveBeenCalled();
expect(mockCookies).toHaveBeenCalled(); expect(mockCookies).toHaveBeenCalled();
@@ -146,7 +156,7 @@ describe("withAuth", () => {
const handler = vi.fn(); const handler = vi.fn();
const wrappedHandler = withAuth(handler); const wrappedHandler = withAuth(handler);
const response = await wrappedHandler({} as NextRequest); const response = await wrappedHandler(createMockRequest());
expect(response.status).toBe(401); expect(response.status).toBe(401);
expect(handler).not.toHaveBeenCalled(); expect(handler).not.toHaveBeenCalled();
@@ -159,7 +169,7 @@ describe("withAuth", () => {
const handler = vi.fn().mockResolvedValue(NextResponse.json({})); const handler = vi.fn().mockResolvedValue(NextResponse.json({}));
const wrappedHandler = withAuth(handler); const wrappedHandler = withAuth(handler);
const mockRequest = {} as NextRequest; const mockRequest = createMockRequest();
const mockParams = { id: "123" }; const mockParams = { id: "123" };
await wrappedHandler(mockRequest, { params: mockParams }); await wrappedHandler(mockRequest, { params: mockParams });
@@ -176,7 +186,7 @@ describe("withAuth", () => {
const handler = vi.fn().mockRejectedValue(new Error("Handler error")); const handler = vi.fn().mockRejectedValue(new Error("Handler error"));
const wrappedHandler = withAuth(handler); const wrappedHandler = withAuth(handler);
const response = await wrappedHandler({} as NextRequest); const response = await wrappedHandler(createMockRequest());
expect(response.status).toBe(500); expect(response.status).toBe(500);
const body = await response.json(); const body = await response.json();
@@ -196,7 +206,7 @@ describe("withAuth", () => {
const handler = vi.fn(); const handler = vi.fn();
const wrappedHandler = withAuth(handler); const wrappedHandler = withAuth(handler);
await wrappedHandler({} as NextRequest); await wrappedHandler(createMockRequest());
expect(mockLogger.warn).toHaveBeenCalledWith( expect(mockLogger.warn).toHaveBeenCalledWith(
expect.objectContaining({ reason: "not_authenticated" }), expect.objectContaining({ reason: "not_authenticated" }),
@@ -204,6 +214,76 @@ describe("withAuth", () => {
); );
}); });
it("logs auth failure with IP address from x-forwarded-for header", async () => {
mockIsAuthenticated.mockReturnValue(false);
const handler = vi.fn();
const wrappedHandler = withAuth(handler);
const mockRequest = {
headers: {
get: vi.fn((name: string) =>
name === "x-forwarded-for" ? "192.168.1.100" : null,
),
},
} as unknown as NextRequest;
await wrappedHandler(mockRequest);
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.objectContaining({
reason: "not_authenticated",
ip: "192.168.1.100",
}),
expect.stringContaining("Auth failure"),
);
});
it("logs auth failure with IP address from x-real-ip header when x-forwarded-for not present", async () => {
mockIsAuthenticated.mockReturnValue(false);
const handler = vi.fn();
const wrappedHandler = withAuth(handler);
const mockRequest = {
headers: {
get: vi.fn((name: string) =>
name === "x-real-ip" ? "10.0.0.1" : null,
),
},
} as unknown as NextRequest;
await wrappedHandler(mockRequest);
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.objectContaining({
reason: "not_authenticated",
ip: "10.0.0.1",
}),
expect.stringContaining("Auth failure"),
);
});
it("logs auth failure with unknown IP when no IP headers present", async () => {
mockIsAuthenticated.mockReturnValue(false);
const handler = vi.fn();
const wrappedHandler = withAuth(handler);
const mockRequest = {
headers: {
get: vi.fn(() => null),
},
} as unknown as NextRequest;
await wrappedHandler(mockRequest);
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.objectContaining({ reason: "not_authenticated", ip: "unknown" }),
expect.stringContaining("Auth failure"),
);
});
it("logs auth failure when getCurrentUser returns null", async () => { it("logs auth failure when getCurrentUser returns null", async () => {
mockIsAuthenticated.mockReturnValue(true); mockIsAuthenticated.mockReturnValue(true);
mockGetCurrentUser.mockReturnValue(null); mockGetCurrentUser.mockReturnValue(null);
@@ -211,7 +291,7 @@ describe("withAuth", () => {
const handler = vi.fn(); const handler = vi.fn();
const wrappedHandler = withAuth(handler); const wrappedHandler = withAuth(handler);
await wrappedHandler({} as NextRequest); await wrappedHandler(createMockRequest());
expect(mockLogger.warn).toHaveBeenCalledWith( expect(mockLogger.warn).toHaveBeenCalledWith(
expect.objectContaining({ reason: "user_not_found" }), expect.objectContaining({ reason: "user_not_found" }),
@@ -227,7 +307,7 @@ describe("withAuth", () => {
const handler = vi.fn().mockRejectedValue(testError); const handler = vi.fn().mockRejectedValue(testError);
const wrappedHandler = withAuth(handler); const wrappedHandler = withAuth(handler);
await wrappedHandler({} as NextRequest); await wrappedHandler(createMockRequest());
expect(mockLogger.error).toHaveBeenCalledWith( expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({

View File

@@ -42,6 +42,23 @@ export type AuthenticatedHandler<T = unknown> = (
* }); * });
* ``` * ```
*/ */
/**
* Extracts client IP address from request headers.
* Checks x-forwarded-for and x-real-ip headers, returns "unknown" if neither present.
*/
function getClientIp(request: NextRequest): string {
const forwardedFor = request.headers.get("x-forwarded-for");
if (forwardedFor) {
// x-forwarded-for can contain multiple IPs; first one is the client
return forwardedFor.split(",")[0].trim();
}
const realIp = request.headers.get("x-real-ip");
if (realIp) {
return realIp;
}
return "unknown";
}
export function withAuth<T = unknown>( export function withAuth<T = unknown>(
handler: AuthenticatedHandler<T>, handler: AuthenticatedHandler<T>,
): (request: NextRequest, context?: { params?: T }) => Promise<NextResponse> { ): (request: NextRequest, context?: { params?: T }) => Promise<NextResponse> {
@@ -57,16 +74,19 @@ export function withAuth<T = unknown>(
const cookieStore = await cookies(); const cookieStore = await cookies();
loadAuthFromCookies(pb, cookieStore); loadAuthFromCookies(pb, cookieStore);
// Get client IP for logging
const ip = getClientIp(request);
// Check if the user is authenticated // Check if the user is authenticated
if (!isAuthenticated(pb)) { if (!isAuthenticated(pb)) {
logger.warn({ reason: "not_authenticated" }, "Auth failure"); logger.warn({ reason: "not_authenticated", ip }, "Auth failure");
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
} }
// Get the current user // Get the current user
const user = getCurrentUser(pb); const user = getCurrentUser(pb);
if (!user) { if (!user) {
logger.warn({ reason: "user_not_found" }, "Auth failure"); logger.warn({ reason: "user_not_found", ip }, "Auth failure");
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
} }

View File

@@ -101,4 +101,5 @@ export interface NutritionGuidance {
seeds: string; seeds: string;
carbRange: string; carbRange: string;
ketoGuidance: string; ketoGuidance: string;
seedSwitchAlert?: string | null;
} }