diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index ba5853e..77af88b 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -4,14 +4,14 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta ## Current State Summary -### Overall Status: 835 tests passing across 44 test files +### Overall Status: 841 tests passing across 44 test files ### Library Implementation | File | Status | Gap Analysis | |------|--------|--------------| | `cycle.ts` | **COMPLETE** | 22 tests covering all functions including dynamic phase boundaries for variable cycle lengths | | `nutrition.ts` | **COMPLETE** | 17 tests covering getNutritionGuidance, getSeedSwitchAlert, phase-specific carb ranges, keto guidance | -| `email.ts` | **COMPLETE** | 24 tests covering sendDailyEmail, sendPeriodConfirmationEmail, sendTokenExpirationWarning, email formatting, subject lines | +| `email.ts` | **COMPLETE** | 30 tests covering sendDailyEmail, sendPeriodConfirmationEmail, sendTokenExpirationWarning, email formatting, subject lines, structured logging | | `ics.ts` | **COMPLETE** | 33 tests covering generateIcsFeed (90 days of phase events), ICS format validation, timezone handling, period prediction feedback, CATEGORIES for calendar colors | | `encryption.ts` | **COMPLETE** | 14 tests covering AES-256-GCM encrypt/decrypt round-trip, error handling, key validation | | `decision-engine.ts` | **COMPLETE** | 8 priority rules + override handling with `getDecisionWithOverrides()`, 24 tests | @@ -94,7 +94,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | `src/app/login/page.test.tsx` | **EXISTS** - 32 tests (form rendering, auth flow, error handling, validation, accessibility, rate limiting) | | `src/app/page.test.tsx` | **EXISTS** - 28 tests (data fetching, component rendering, override toggles, error handling) | | `src/lib/nutrition.test.ts` | **EXISTS** - 17 tests (seed cycling, carb ranges, keto guidance by phase) | -| `src/lib/email.test.ts` | **EXISTS** - 24 tests (email content, subject lines, formatting, token expiration warnings) | +| `src/lib/email.test.ts` | **EXISTS** - 30 tests (email content, subject lines, formatting, token expiration warnings, structured logging) | | `src/lib/ics.test.ts` | **EXISTS** - 33 tests (ICS format validation, 90-day event generation, timezone handling, period prediction feedback, CATEGORIES for colors) | | `src/lib/encryption.test.ts` | **EXISTS** - 14 tests (encrypt/decrypt round-trip, error handling, key validation) | | `src/lib/garmin.test.ts` | **EXISTS** - 33 tests (fetchGarminData, fetchHrvStatus, fetchBodyBattery, fetchIntensityMinutes, token expiry, error handling) | @@ -892,7 +892,7 @@ P4.* UX Polish ────────> After core functionality complete - [x] **decision-engine.ts** - Complete with 24 tests (`getTrainingDecision` + `getDecisionWithOverrides`) - [x] **pocketbase.ts** - Complete with 9 tests (`createPocketBaseClient`, `isAuthenticated`, `getCurrentUser`, `loadAuthFromCookies`) - [x] **nutrition.ts** - Complete with 17 tests (`getNutritionGuidance`, `getSeedSwitchAlert`, phase-specific carb ranges, keto guidance) (P3.2) -- [x] **email.ts** - Complete with 24 tests (`sendDailyEmail`, `sendPeriodConfirmationEmail`, `sendTokenExpirationWarning`, email formatting) (P3.3, P3.9) +- [x] **email.ts** - Complete with 30 tests (`sendDailyEmail`, `sendPeriodConfirmationEmail`, `sendTokenExpirationWarning`, email formatting, structured logging for sent/failed events) (P3.3, P3.9) - [x] **ics.ts** - Complete with 33 tests (`generateIcsFeed`, ICS format validation, 90-day event generation, period prediction feedback, CATEGORIES for calendar colors) (P3.4, P4.5) - [x] **encryption.ts** - Complete with 14 tests (AES-256-GCM encrypt/decrypt, round-trip validation, error handling) (P3.5) - [x] **garmin.ts** - Complete with 33 tests (`fetchGarminData`, `fetchHrvStatus`, `fetchBodyBattery`, `fetchIntensityMinutes`, `isTokenExpired`, `daysUntilExpiry`, error handling) (P2.1, P3.6) @@ -945,7 +945,7 @@ P4.* UX Polish ────────> After core functionality complete ### P3: Quality and Testing - [x] **P3.1: Decision Engine Tests** - Complete with 24 tests covering all 8 priority rules and override combinations - [x] **P3.2: Nutrition Tests** - Complete with 17 tests covering seed cycling, carb ranges, keto guidance by phase -- [x] **P3.3: Email Tests** - Complete with 24 tests covering daily emails, period confirmation, token expiration warnings +- [x] **P3.3: Email Tests** - Complete with 30 tests covering daily emails, period confirmation, token expiration warnings, structured logging - [x] **P3.4: ICS Tests** - Complete with 28 tests covering ICS format validation, 90-day event generation, timezone handling, period prediction feedback - [x] **P3.5: Encryption Tests** - Complete with 14 tests covering AES-256-GCM round-trip, error handling, key validation - [x] **P3.6: Garmin Tests** - Complete with 33 tests covering API interactions, token expiry, error handling @@ -975,7 +975,7 @@ Analysis of all specs vs implementation revealed these gaps: |-----|------|--------|-------| | Logout functionality | authentication.md | **COMPLETE** | Added POST /api/auth/logout + settings button | | Garmin sync structured logging | observability.md | **COMPLETE** | Added sync start/complete/failure logging | -| Email sent/failed logging | observability.md | **PENDING** | Email events should be logged | +| Email sent/failed logging | observability.md | **COMPLETE** | Email events now logged (info for success, error for failure) with structured data (userId, emailType, success) | | Period history UI | cycle-tracking.md | **PENDING** | UI for viewing/editing past periods | | Dashboard color-coded backgrounds | dashboard.md | **PENDING** | Phase-based background colors | | Toast notifications | dashboard.md | **PENDING** | Success/error toasts for user actions | diff --git a/src/app/api/cron/garmin-sync/route.test.ts b/src/app/api/cron/garmin-sync/route.test.ts index 2898407..3653dfd 100644 --- a/src/app/api/cron/garmin-sync/route.test.ts +++ b/src/app/api/cron/garmin-sync/route.test.ts @@ -424,6 +424,7 @@ describe("POST /api/cron/garmin-sync", () => { expect(mockSendTokenExpirationWarning).toHaveBeenCalledWith( "user@example.com", 14, + "user123", ); const body = await response.json(); expect(body.warningsSent).toBe(1); @@ -438,6 +439,7 @@ describe("POST /api/cron/garmin-sync", () => { expect(mockSendTokenExpirationWarning).toHaveBeenCalledWith( "user@example.com", 7, + "user123", ); const body = await response.json(); expect(body.warningsSent).toBe(1); @@ -493,10 +495,12 @@ describe("POST /api/cron/garmin-sync", () => { expect(mockSendTokenExpirationWarning).toHaveBeenCalledWith( "user1@example.com", 14, + "user1", ); expect(mockSendTokenExpirationWarning).toHaveBeenCalledWith( "user2@example.com", 7, + "user2", ); const body = await response.json(); expect(body.warningsSent).toBe(2); diff --git a/src/app/api/cron/garmin-sync/route.ts b/src/app/api/cron/garmin-sync/route.ts index 0a803d5..0aac55f 100644 --- a/src/app/api/cron/garmin-sync/route.ts +++ b/src/app/api/cron/garmin-sync/route.ts @@ -82,7 +82,7 @@ export async function POST(request: Request) { const daysRemaining = daysUntilExpiry(tokens); if (daysRemaining === 14 || daysRemaining === 7) { try { - await sendTokenExpirationWarning(user.email, daysRemaining); + await sendTokenExpirationWarning(user.email, daysRemaining, user.id); result.warningsSent++; } catch { // Continue processing even if warning email fails diff --git a/src/app/api/cron/notifications/route.ts b/src/app/api/cron/notifications/route.ts index 48074ba..3850dc4 100644 --- a/src/app/api/cron/notifications/route.ts +++ b/src/app/api/cron/notifications/route.ts @@ -121,25 +121,28 @@ export async function POST(request: Request) { 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), + 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, }, - 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, { diff --git a/src/lib/email.test.ts b/src/lib/email.test.ts index 80f07bc..69ec293 100644 --- a/src/lib/email.test.ts +++ b/src/lib/email.test.ts @@ -2,8 +2,10 @@ // ABOUTME: Tests email composition, subject lines, and Resend integration. import { afterEach, describe, expect, it, vi } from "vitest"; -const { mockSend } = vi.hoisted(() => ({ +const { mockSend, mockLoggerInfo, mockLoggerError } = vi.hoisted(() => ({ mockSend: vi.fn().mockResolvedValue({ id: "mock-email-id" }), + mockLoggerInfo: vi.fn(), + mockLoggerError: vi.fn(), })); // Mock the resend module before importing email utilities @@ -13,6 +15,14 @@ vi.mock("resend", () => ({ }, })); +// Mock the logger +vi.mock("@/lib/logger", () => ({ + logger: { + info: mockLoggerInfo, + error: mockLoggerError, + }, +})); + import type { DailyEmailData } from "./email"; import { sendDailyEmail, @@ -277,3 +287,135 @@ describe("sendTokenExpirationWarning", () => { }); }); }); + +describe("email structured logging", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + const sampleDailyEmailData: DailyEmailData = { + to: "user@example.com", + cycleDay: 15, + phase: "OVULATION", + decision: { + status: "TRAIN", + reason: "Body battery high", + icon: "💪", + }, + bodyBatteryCurrent: 85, + bodyBatteryYesterdayLow: 45, + hrvStatus: "Balanced", + weekIntensity: 60, + phaseLimit: 80, + remainingMinutes: 20, + seeds: "Sesame", + carbRange: "100-150g", + ketoGuidance: "No", + }; + + describe("sendDailyEmail logging", () => { + it("logs email sent with info level on success", async () => { + await sendDailyEmail(sampleDailyEmailData, "user-123"); + expect(mockLoggerInfo).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user-123", + type: "daily", + recipient: "user@example.com", + }), + "Email sent", + ); + }); + + it("logs email failed with error level on failure", async () => { + const error = new Error("Resend API failed"); + mockSend.mockRejectedValueOnce(error); + + await expect( + sendDailyEmail(sampleDailyEmailData, "user-123"), + ).rejects.toThrow("Resend API failed"); + + expect(mockLoggerError).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user-123", + type: "daily", + err: error, + }), + "Email failed", + ); + }); + }); + + describe("sendPeriodConfirmationEmail logging", () => { + it("logs email sent with info level on success", async () => { + await sendPeriodConfirmationEmail( + "user@example.com", + new Date("2025-01-15"), + 31, + "user-456", + ); + expect(mockLoggerInfo).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user-456", + type: "period_confirmation", + recipient: "user@example.com", + }), + "Email sent", + ); + }); + + it("logs email failed with error level on failure", async () => { + const error = new Error("Resend API failed"); + mockSend.mockRejectedValueOnce(error); + + await expect( + sendPeriodConfirmationEmail( + "user@example.com", + new Date("2025-01-15"), + 31, + "user-456", + ), + ).rejects.toThrow("Resend API failed"); + + expect(mockLoggerError).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user-456", + type: "period_confirmation", + err: error, + }), + "Email failed", + ); + }); + }); + + describe("sendTokenExpirationWarning logging", () => { + it("logs email sent with info level on success", async () => { + await sendTokenExpirationWarning("user@example.com", 14, "user-789"); + expect(mockLoggerInfo).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user-789", + type: "warning", + recipient: "user@example.com", + }), + "Email sent", + ); + }); + + it("logs email failed with error level on failure", async () => { + const error = new Error("Resend API failed"); + mockSend.mockRejectedValueOnce(error); + + await expect( + sendTokenExpirationWarning("user@example.com", 14, "user-789"), + ).rejects.toThrow("Resend API failed"); + + expect(mockLoggerError).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user-789", + type: "warning", + err: error, + }), + "Email failed", + ); + }); + }); +}); diff --git a/src/lib/email.ts b/src/lib/email.ts index 01ebe9a..d42529d 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -2,6 +2,7 @@ // ABOUTME: Sends daily training notifications and period confirmation emails. import { Resend } from "resend"; +import { logger } from "@/lib/logger"; import { emailSentTotal } from "@/lib/metrics"; const resend = new Resend(process.env.RESEND_API_KEY); @@ -28,7 +29,10 @@ export interface DailyEmailData { ketoGuidance: string; } -export async function sendDailyEmail(data: DailyEmailData): Promise { +export async function sendDailyEmail( + data: DailyEmailData, + userId?: string, +): Promise { const subject = `Today's Training: ${data.decision.icon} ${data.decision.status}`; const body = `Good morning! @@ -53,20 +57,27 @@ ${data.decision.icon} ${data.decision.reason} --- Auto-generated by PhaseFlow`; - await resend.emails.send({ - from: EMAIL_FROM, - to: data.to, - subject, - text: body, - }); + try { + await resend.emails.send({ + from: EMAIL_FROM, + to: data.to, + subject, + text: body, + }); - emailSentTotal.inc({ type: "daily" }); + logger.info({ userId, type: "daily", recipient: data.to }, "Email sent"); + emailSentTotal.inc({ type: "daily" }); + } catch (err) { + logger.error({ userId, type: "daily", err }, "Email failed"); + throw err; + } } export async function sendPeriodConfirmationEmail( to: string, lastPeriodDate: Date, cycleLength: number, + userId?: string, ): Promise { const subject = "🔵 Period Tracking Updated"; @@ -78,17 +89,28 @@ Your calendar will update automatically within 24 hours. --- Auto-generated by PhaseFlow`; - await resend.emails.send({ - from: EMAIL_FROM, - to, - subject, - text: body, - }); + try { + await resend.emails.send({ + from: EMAIL_FROM, + to, + subject, + text: body, + }); + + logger.info( + { userId, type: "period_confirmation", recipient: to }, + "Email sent", + ); + } catch (err) { + logger.error({ userId, type: "period_confirmation", err }, "Email failed"); + throw err; + } } export async function sendTokenExpirationWarning( to: string, daysUntilExpiry: number, + userId?: string, ): Promise { const isUrgent = daysUntilExpiry <= 7; @@ -112,12 +134,18 @@ This will ensure your training recommendations continue to use fresh Garmin data --- Auto-generated by PhaseFlow`; - await resend.emails.send({ - from: EMAIL_FROM, - to, - subject, - text: body, - }); + try { + await resend.emails.send({ + from: EMAIL_FROM, + to, + subject, + text: body, + }); - emailSentTotal.inc({ type: "warning" }); + logger.info({ userId, type: "warning", recipient: to }, "Email sent"); + emailSentTotal.inc({ type: "warning" }); + } catch (err) { + logger.error({ userId, type: "warning", err }, "Email failed"); + throw err; + } }