Implement notifications cron endpoint (P2.5)

Add daily email notification system that sends training decisions
at each user's preferred time in their timezone.

Features:
- Timezone-aware notification matching using Intl.DateTimeFormat
- DailyLog-based notifications with duplicate prevention
- Nutrition guidance integration via getNutritionGuidance
- Graceful error handling (continues processing on per-user failures)
- Summary response with detailed stats

Includes 20 tests covering:
- CRON_SECRET authentication
- Timezone matching (UTC and America/New_York)
- DailyLog existence and already-sent checks
- Email content assembly
- Error handling and response format

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-10 19:56:16 +00:00
parent fc970a2c61
commit 901543cb4d
3 changed files with 615 additions and 9 deletions

View File

@@ -1,7 +1,53 @@
// ABOUTME: Cron endpoint for sending daily email notifications.
// ABOUTME: Sends morning training decision emails to all users.
// ABOUTME: Sends morning training decision emails to users at their preferred time.
import { NextResponse } from "next/server";
import { sendDailyEmail } from "@/lib/email";
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 the current hour in a specific timezone
function getCurrentHourInTimezone(timezone: string): number {
const formatter = new Intl.DateTimeFormat("en-US", {
timeZone: timezone,
hour: "numeric",
hour12: false,
});
return parseInt(formatter.format(new Date()), 10);
}
// Extract hour from "HH:MM" format
function getNotificationHour(notificationTime: string): number {
return parseInt(notificationTime.split(":")[0], 10);
}
// 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");
@@ -11,6 +57,100 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// TODO: Implement notification sending
return NextResponse.json({ message: "Not implemented" }, { status: 501 });
const result: NotificationResult = {
success: true,
notificationsSent: 0,
errors: 0,
skippedNoLog: 0,
skippedAlreadySent: 0,
skippedWrongTime: 0,
timestamp: new Date().toISOString(),
};
const pb = createPocketBaseClient();
// Fetch all users
const users = await pb.collection("users").getFullList<User>();
// 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 hour in user's timezone matches their notification time
const currentHour = getCurrentHourInTimezone(user.timezone);
const notificationHour = getNotificationHour(user.notificationTime);
if (currentHour !== notificationHour) {
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,
});
// Update notificationSentAt timestamp
await pb.collection("dailyLogs").update(dailyLog.id, {
notificationSentAt: new Date().toISOString(),
});
result.notificationsSent++;
} catch {
result.errors++;
}
}
return NextResponse.json(result);
}