// 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(); 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(); 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(); 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); }