Files
phaseflow/src/app/api/cron/notifications/route.ts
Petru Paler d4b04a17be
All checks were successful
Deploy / deploy (push) Successful in 2m34s
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 <noreply@anthropic.com>
2026-01-22 08:50:30 +00:00

212 lines
6.1 KiB
TypeScript

// ABOUTME: Cron endpoint for sending daily email notifications.
// ABOUTME: Sends morning training decision emails to users at their preferred time.
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";
interface NotificationResult {
success: boolean;
notificationsSent: number;
errors: number;
skippedNoLog: number;
skippedAlreadySent: number;
skippedWrongTime: number;
timestamp: string;
}
// Get current quarter-hour slot (0, 15, 30, 45) in user's timezone
function getCurrentQuarterHourSlot(timezone: string): {
hour: number;
minute: number;
} {
const formatter = new Intl.DateTimeFormat("en-US", {
timeZone: timezone,
hour: "numeric",
minute: "numeric",
hour12: false,
});
const parts = formatter.formatToParts(new Date());
const hour = Number.parseInt(
parts.find((p) => p.type === "hour")?.value ?? "0",
10,
);
const minute = Number.parseInt(
parts.find((p) => p.type === "minute")?.value ?? "0",
10,
);
// Round down to nearest 15-min slot
const slot = Math.floor(minute / 15) * 15;
return { hour, minute: slot };
}
// Extract quarter-hour slot from "HH:MM" format
function getNotificationSlot(notificationTime: string): {
hour: number;
minute: number;
} {
const [h, m] = notificationTime.split(":").map(Number);
// Round down to nearest 15-min slot
const slot = Math.floor(m / 15) * 15;
return { hour: h, minute: slot };
}
// Map decision status to icon
function getDecisionIcon(status: DecisionStatus): string {
switch (status) {
case "REST":
return "🛑";
case "GENTLE":
case "LIGHT":
case "REDUCED":
return "🟡";
case "TRAIN":
return "✅";
default:
return "❓";
}
}
export async function POST(request: Request) {
// Verify cron secret
const authHeader = request.headers.get("authorization");
const expectedSecret = process.env.CRON_SECRET;
if (!expectedSecret || authHeader !== `Bearer ${expectedSecret}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const result: NotificationResult = {
success: true,
notificationsSent: 0,
errors: 0,
skippedNoLog: 0,
skippedAlreadySent: 0,
skippedWrongTime: 0,
timestamp: new Date().toISOString(),
};
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];
// Fetch all daily logs for today
const dailyLogs = await pb.collection("dailyLogs").getFullList<DailyLog>();
const todayLogs = dailyLogs.filter((log) => {
// Date may come as string from PocketBase or as Date object
const dateValue = log.date as unknown as string | Date;
const logDate =
typeof dateValue === "string"
? dateValue.split("T")[0]
: dateValue.toISOString().split("T")[0];
return logDate === today;
});
// Create a map for quick lookup
const logsByUser = new Map<string, DailyLog>();
for (const log of todayLogs) {
logsByUser.set(log.user, log);
}
for (const user of users) {
try {
// Check if current quarter-hour slot in user's timezone matches their notification time
const currentSlot = getCurrentQuarterHourSlot(user.timezone);
const notificationSlot = getNotificationSlot(user.notificationTime);
if (
currentSlot.hour !== notificationSlot.hour ||
currentSlot.minute !== notificationSlot.minute
) {
result.skippedWrongTime++;
continue;
}
// Check if DailyLog exists for today
const dailyLog = logsByUser.get(user.id);
if (!dailyLog) {
result.skippedNoLog++;
continue;
}
// Check if notification already sent
if (dailyLog.notificationSentAt !== null) {
result.skippedAlreadySent++;
continue;
}
// Get nutrition guidance based on cycle day
const nutrition = getNutritionGuidance(dailyLog.cycleDay);
// Send email
await sendDailyEmail(
{
to: user.email,
cycleDay: dailyLog.cycleDay,
phase: dailyLog.phase,
decision: {
status: dailyLog.trainingDecision,
reason: dailyLog.decisionReason,
icon: getDecisionIcon(dailyLog.trainingDecision as DecisionStatus),
},
bodyBatteryCurrent: dailyLog.bodyBatteryCurrent,
bodyBatteryYesterdayLow: dailyLog.bodyBatteryYesterdayLow,
hrvStatus: dailyLog.hrvStatus,
weekIntensity: dailyLog.weekIntensityMinutes,
phaseLimit: dailyLog.phaseLimit,
remainingMinutes: dailyLog.remainingMinutes,
seeds: nutrition.seeds,
carbRange: nutrition.carbRange,
ketoGuidance: nutrition.ketoGuidance,
},
user.id,
);
// Update notificationSentAt timestamp
await pb.collection("dailyLogs").update(dailyLog.id, {
notificationSentAt: new Date().toISOString(),
});
result.notificationsSent++;
} catch {
result.errors++;
}
}
return NextResponse.json(result);
}