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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user