Fix email delivery and body battery null handling
All checks were successful
Deploy / deploy (push) Successful in 2m34s
All checks were successful
Deploy / deploy (push) Successful in 2m34s
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<User>();
|
||||
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];
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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: "🟡" };
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user