From d4b04a17bece045c25a96d8ca9aeefb42f67118f Mon Sep 17 00:00:00 2001 From: Petru Paler Date: Thu, 22 Jan 2026 08:50:30 +0000 Subject: [PATCH] Fix email delivery and body battery null handling - Add PocketBase admin auth to notifications endpoint (was returning 0 users) - Store null instead of 100 for body battery when Garmin returns no data - Update decision engine to skip body battery rules when values are null - Dashboard and email already display "N/A" for null values Co-Authored-By: Claude Opus 4.5 --- src/app/api/cron/garmin-sync/route.test.ts | 11 +++-- src/app/api/cron/garmin-sync/route.ts | 14 +++--- src/app/api/cron/notifications/route.test.ts | 47 +++++++++++++++++++- src/app/api/cron/notifications/route.ts | 28 ++++++++++++ src/app/api/today/route.test.ts | 16 +++---- src/app/api/today/route.ts | 22 ++++----- src/lib/decision-engine.test.ts | 46 +++++++++++++++++++ src/lib/decision-engine.ts | 6 +-- src/types/index.ts | 4 +- 9 files changed, 153 insertions(+), 41 deletions(-) diff --git a/src/app/api/cron/garmin-sync/route.test.ts b/src/app/api/cron/garmin-sync/route.test.ts index a82b03d..8221341 100644 --- a/src/app/api/cron/garmin-sync/route.test.ts +++ b/src/app/api/cron/garmin-sync/route.test.ts @@ -560,11 +560,10 @@ describe("POST /api/cron/garmin-sync", () => { expect(body.errors).toBe(1); }); - it("stores default value 100 when body battery is null from Garmin", async () => { + it("stores null when body battery is null from Garmin", async () => { // When Garmin API returns null for body battery values (no data available), - // we store the default value 100 instead of null. - // This prevents PocketBase's number field null-to-0 coercion from causing - // the dashboard to display 0 instead of a meaningful value. + // we store null and the UI displays "N/A". The decision engine skips + // body battery rules when values are null. mockUsers = [createMockUser()]; mockFetchBodyBattery.mockResolvedValue({ current: null, @@ -575,8 +574,8 @@ describe("POST /api/cron/garmin-sync", () => { expect(mockPbCreate).toHaveBeenCalledWith( expect.objectContaining({ - bodyBatteryCurrent: 100, - bodyBatteryYesterdayLow: 100, + bodyBatteryCurrent: null, + bodyBatteryYesterdayLow: null, }), ); }); diff --git a/src/app/api/cron/garmin-sync/route.ts b/src/app/api/cron/garmin-sync/route.ts index b2b577d..d042198 100644 --- a/src/app/api/cron/garmin-sync/route.ts +++ b/src/app/api/cron/garmin-sync/route.ts @@ -194,30 +194,30 @@ export async function POST(request: Request) { const remainingMinutes = Math.max(0, phaseLimit - weekIntensityMinutes); // Calculate training decision + // Pass null body battery values through - decision engine handles null gracefully const decision = getDecisionWithOverrides( { hrvStatus, - bbYesterdayLow: bodyBattery.yesterdayLow ?? 100, + bbYesterdayLow: bodyBattery.yesterdayLow, phase, weekIntensity: weekIntensityMinutes, phaseLimit, - bbCurrent: bodyBattery.current ?? 100, + bbCurrent: bodyBattery.current, }, user.activeOverrides, ); // Upsert DailyLog entry - update existing record for today or create new one - // Store default value 100 for body battery when Garmin returns null. - // This prevents PocketBase's number field null-to-0 coercion from - // causing the dashboard to display 0 instead of a meaningful value. + // Store null for body battery when Garmin returns null - the UI displays "N/A" + // and the decision engine skips body battery rules when values are null. // Use YYYY-MM-DD format for PocketBase date field compatibility const dailyLogData = { user: user.id, date: today, cycleDay, phase, - bodyBatteryCurrent: bodyBattery.current ?? 100, - bodyBatteryYesterdayLow: bodyBattery.yesterdayLow ?? 100, + bodyBatteryCurrent: bodyBattery.current, + bodyBatteryYesterdayLow: bodyBattery.yesterdayLow, hrvStatus, weekIntensityMinutes, phaseLimit, diff --git a/src/app/api/cron/notifications/route.test.ts b/src/app/api/cron/notifications/route.test.ts index da4d93a..cddc016 100644 --- a/src/app/api/cron/notifications/route.test.ts +++ b/src/app/api/cron/notifications/route.test.ts @@ -9,7 +9,8 @@ let mockUsers: User[] = []; let mockDailyLogs: DailyLog[] = []; const mockPbUpdate = vi.fn().mockResolvedValue({ id: "log123" }); -// Mock PocketBase +// Mock PocketBase with admin auth +const mockAuthWithPassword = vi.fn().mockResolvedValue({ id: "admin" }); vi.mock("@/lib/pocketbase", () => ({ createPocketBaseClient: vi.fn(() => ({ collection: vi.fn((name: string) => ({ @@ -23,10 +24,21 @@ vi.mock("@/lib/pocketbase", () => ({ return []; }), update: mockPbUpdate, + authWithPassword: (email: string, password: string) => + mockAuthWithPassword(email, password), })), })), })); +// Mock logger +vi.mock("@/lib/logger", () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + // Mock email sending const mockSendDailyEmail = vi.fn().mockResolvedValue(undefined); const mockSendTokenExpirationWarning = vi.fn().mockResolvedValue(undefined); @@ -110,6 +122,9 @@ describe("POST /api/cron/notifications", () => { mockUsers = []; mockDailyLogs = []; process.env.CRON_SECRET = validSecret; + process.env.POCKETBASE_ADMIN_EMAIL = "admin@example.com"; + process.env.POCKETBASE_ADMIN_PASSWORD = "admin-password"; + mockAuthWithPassword.mockResolvedValue({ id: "admin" }); // Mock current time to 07:00 UTC vi.useFakeTimers(); vi.setSystemTime(new Date("2025-01-15T07:00:00Z")); @@ -143,6 +158,36 @@ describe("POST /api/cron/notifications", () => { expect(response.status).toBe(401); }); + + it("returns 500 when POCKETBASE_ADMIN_EMAIL is not set", async () => { + process.env.POCKETBASE_ADMIN_EMAIL = ""; + + const response = await POST(createMockRequest(`Bearer ${validSecret}`)); + + expect(response.status).toBe(500); + const body = await response.json(); + expect(body.error).toBe("Server misconfiguration"); + }); + + it("returns 500 when POCKETBASE_ADMIN_PASSWORD is not set", async () => { + process.env.POCKETBASE_ADMIN_PASSWORD = ""; + + const response = await POST(createMockRequest(`Bearer ${validSecret}`)); + + expect(response.status).toBe(500); + const body = await response.json(); + expect(body.error).toBe("Server misconfiguration"); + }); + + it("returns 500 when PocketBase admin auth fails", async () => { + mockAuthWithPassword.mockRejectedValueOnce(new Error("Auth failed")); + + const response = await POST(createMockRequest(`Bearer ${validSecret}`)); + + expect(response.status).toBe(500); + const body = await response.json(); + expect(body.error).toBe("Database authentication failed"); + }); }); describe("User time matching", () => { diff --git a/src/app/api/cron/notifications/route.ts b/src/app/api/cron/notifications/route.ts index a9904d3..65e6387 100644 --- a/src/app/api/cron/notifications/route.ts +++ b/src/app/api/cron/notifications/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from "next/server"; import { sendDailyEmail } from "@/lib/email"; +import { logger } from "@/lib/logger"; import { getNutritionGuidance } from "@/lib/nutrition"; import { createPocketBaseClient } from "@/lib/pocketbase"; import type { DailyLog, DecisionStatus, User } from "@/types"; @@ -90,8 +91,35 @@ export async function POST(request: Request) { const pb = createPocketBaseClient(); + // Authenticate as admin to bypass API rules and list all users + const adminEmail = process.env.POCKETBASE_ADMIN_EMAIL; + const adminPassword = process.env.POCKETBASE_ADMIN_PASSWORD; + if (!adminEmail || !adminPassword) { + logger.error("Missing POCKETBASE_ADMIN_EMAIL or POCKETBASE_ADMIN_PASSWORD"); + return NextResponse.json( + { error: "Server misconfiguration" }, + { status: 500 }, + ); + } + + try { + await pb + .collection("_superusers") + .authWithPassword(adminEmail, adminPassword); + } catch (authError) { + logger.error( + { err: authError }, + "Failed to authenticate as PocketBase admin", + ); + return NextResponse.json( + { error: "Database authentication failed" }, + { status: 500 }, + ); + } + // Fetch all users const users = await pb.collection("users").getFullList(); + logger.info({ userCount: users.length }, "Fetched users for notifications"); // Get today's date for querying daily logs const today = new Date().toISOString().split("T")[0]; diff --git a/src/app/api/today/route.test.ts b/src/app/api/today/route.test.ts index 8cc2aa4..1df514a 100644 --- a/src/app/api/today/route.test.ts +++ b/src/app/api/today/route.test.ts @@ -595,10 +595,10 @@ describe("GET /api/today", () => { expect(response.status).toBe(200); const body = await response.json(); - // Defaults when no Garmin data + // Defaults when no Garmin data - null values indicate no data available expect(body.biometrics.hrvStatus).toBe("Unknown"); - expect(body.biometrics.bodyBatteryCurrent).toBe(100); - expect(body.biometrics.bodyBatteryYesterdayLow).toBe(100); + expect(body.biometrics.bodyBatteryCurrent).toBeNull(); + expect(body.biometrics.bodyBatteryYesterdayLow).toBeNull(); expect(body.biometrics.weekIntensityMinutes).toBe(0); }); @@ -611,8 +611,8 @@ describe("GET /api/today", () => { expect(response.status).toBe(200); const body = await response.json(); - // With defaults (BB=100, HRV=Unknown), should allow training - // unless in restrictive phase + // With null body battery, decision engine skips BB rules + // and allows training unless in restrictive phase expect(body.decision.status).toBe("TRAIN"); }); }); @@ -681,10 +681,10 @@ describe("GET /api/today", () => { const body = await response.json(); expect(body.lastSyncedAt).toBeNull(); - // Should use DEFAULT_BIOMETRICS + // Should use DEFAULT_BIOMETRICS with null for body battery expect(body.biometrics.hrvStatus).toBe("Unknown"); - expect(body.biometrics.bodyBatteryCurrent).toBe(100); - expect(body.biometrics.bodyBatteryYesterdayLow).toBe(100); + expect(body.biometrics.bodyBatteryCurrent).toBeNull(); + expect(body.biometrics.bodyBatteryYesterdayLow).toBeNull(); }); it("handles fallback log with string date format", async () => { diff --git a/src/app/api/today/route.ts b/src/app/api/today/route.ts index 3259158..0148897 100644 --- a/src/app/api/today/route.ts +++ b/src/app/api/today/route.ts @@ -18,13 +18,13 @@ import type { DailyData, DailyLog, HrvStatus } from "@/types"; // Default biometrics when no Garmin data is available const DEFAULT_BIOMETRICS: { hrvStatus: HrvStatus; - bodyBatteryCurrent: number; - bodyBatteryYesterdayLow: number; + bodyBatteryCurrent: number | null; + bodyBatteryYesterdayLow: number | null; weekIntensityMinutes: number; } = { hrvStatus: "Unknown", - bodyBatteryCurrent: 100, - bodyBatteryYesterdayLow: 100, + bodyBatteryCurrent: null, + bodyBatteryYesterdayLow: null, weekIntensityMinutes: 0, }; @@ -104,11 +104,8 @@ export const GET = withAuth(async (_request, user, pb) => { biometrics = { hrvStatus: dailyLog.hrvStatus, - bodyBatteryCurrent: - dailyLog.bodyBatteryCurrent ?? DEFAULT_BIOMETRICS.bodyBatteryCurrent, - bodyBatteryYesterdayLow: - dailyLog.bodyBatteryYesterdayLow ?? - DEFAULT_BIOMETRICS.bodyBatteryYesterdayLow, + bodyBatteryCurrent: dailyLog.bodyBatteryCurrent, + bodyBatteryYesterdayLow: dailyLog.bodyBatteryYesterdayLow, weekIntensityMinutes: dailyLog.weekIntensityMinutes, phaseLimit: dailyLog.phaseLimit, }; @@ -142,11 +139,8 @@ export const GET = withAuth(async (_request, user, pb) => { biometrics = { hrvStatus: dailyLog.hrvStatus, - bodyBatteryCurrent: - dailyLog.bodyBatteryCurrent ?? DEFAULT_BIOMETRICS.bodyBatteryCurrent, - bodyBatteryYesterdayLow: - dailyLog.bodyBatteryYesterdayLow ?? - DEFAULT_BIOMETRICS.bodyBatteryYesterdayLow, + bodyBatteryCurrent: dailyLog.bodyBatteryCurrent, + bodyBatteryYesterdayLow: dailyLog.bodyBatteryYesterdayLow, weekIntensityMinutes: dailyLog.weekIntensityMinutes, phaseLimit: dailyLog.phaseLimit, }; diff --git a/src/lib/decision-engine.test.ts b/src/lib/decision-engine.test.ts index 9f0ea2d..61c48b7 100644 --- a/src/lib/decision-engine.test.ts +++ b/src/lib/decision-engine.test.ts @@ -127,6 +127,52 @@ describe("getTrainingDecision (algorithmic rules)", () => { }); }); +describe("null body battery handling", () => { + it("skips bbYesterdayLow check when null and allows TRAIN", () => { + const data = createHealthyData(); + data.bbYesterdayLow = null; + const result = getTrainingDecision(data); + expect(result.status).toBe("TRAIN"); + }); + + it("skips bbCurrent check when null and allows TRAIN", () => { + const data = createHealthyData(); + data.bbCurrent = null; + const result = getTrainingDecision(data); + expect(result.status).toBe("TRAIN"); + }); + + it("applies other rules when body battery is null", () => { + const data = createHealthyData(); + data.bbYesterdayLow = null; + data.bbCurrent = null; + data.hrvStatus = "Unbalanced"; + const result = getTrainingDecision(data); + expect(result.status).toBe("REST"); + expect(result.reason).toContain("HRV"); + }); + + it("applies phase rules when body battery is null", () => { + const data = createHealthyData(); + data.bbYesterdayLow = null; + data.bbCurrent = null; + data.phase = "LATE_LUTEAL"; + const result = getTrainingDecision(data); + expect(result.status).toBe("GENTLE"); + }); + + it("applies weekly limit when body battery is null", () => { + const data = createHealthyData(); + data.bbYesterdayLow = null; + data.bbCurrent = null; + data.weekIntensity = 120; + data.phaseLimit = 120; + const result = getTrainingDecision(data); + expect(result.status).toBe("REST"); + expect(result.reason).toContain("LIMIT"); + }); +}); + describe("getDecisionWithOverrides", () => { describe("override types force appropriate decisions", () => { it("flare override forces REST", () => { diff --git a/src/lib/decision-engine.ts b/src/lib/decision-engine.ts index cf04545..6285d7d 100644 --- a/src/lib/decision-engine.ts +++ b/src/lib/decision-engine.ts @@ -47,7 +47,7 @@ export function getTrainingDecision(data: DailyData): Decision { return { status: "REST", reason: "HRV Unbalanced", icon: "🛑" }; } - if (bbYesterdayLow < 30) { + if (bbYesterdayLow !== null && bbYesterdayLow < 30) { return { status: "REST", reason: "BB too depleted", icon: "🛑" }; } @@ -75,7 +75,7 @@ export function getTrainingDecision(data: DailyData): Decision { }; } - if (bbCurrent < 75) { + if (bbCurrent !== null && bbCurrent < 75) { return { status: "LIGHT", reason: "Light activity only - BB not recovered", @@ -83,7 +83,7 @@ export function getTrainingDecision(data: DailyData): Decision { }; } - if (bbCurrent < 85) { + if (bbCurrent !== null && bbCurrent < 85) { return { status: "REDUCED", reason: "Reduce intensity 25%", icon: "🟡" }; } diff --git a/src/types/index.ts b/src/types/index.ts index fe9140f..716b5b6 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -84,11 +84,11 @@ export interface Decision { export interface DailyData { hrvStatus: HrvStatus; - bbYesterdayLow: number; + bbYesterdayLow: number | null; phase: CyclePhase; weekIntensity: number; phaseLimit: number; - bbCurrent: number; + bbCurrent: number | null; } export interface GarminTokens {