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>
212 lines
6.1 KiB
TypeScript
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);
|
|
}
|