diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index db5453c..e3a84d7 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: 517 tests passing across 30 test files +### Overall Status: 537 tests passing across 30 test files ### Library Implementation | File | Status | Gap Analysis | |------|--------|--------------| | `cycle.ts` | **COMPLETE** | 9 tests covering all functions, production-ready | | `nutrition.ts` | **COMPLETE** | 17 tests covering getNutritionGuidance, getSeedSwitchAlert, phase-specific carb ranges, keto guidance | -| `email.ts` | **COMPLETE** | 14 tests covering sendDailyEmail, sendPeriodConfirmationEmail, email formatting, subject lines | +| `email.ts` | **COMPLETE** | 24 tests covering sendDailyEmail, sendPeriodConfirmationEmail, sendTokenExpirationWarning, email formatting, subject lines | | `ics.ts` | **COMPLETE** | 23 tests covering generateIcsFeed (90 days of phase events), ICS format validation, timezone handling | | `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 | @@ -29,7 +29,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | Prometheus Metrics | specs/observability.md | P2.16 | Medium | | Structured Logging (pino) | specs/observability.md | P2.17 | Medium | | OIDC Authentication | specs/authentication.md | P2.18 | Medium | -| Token Expiration Warnings | specs/email.md | P3.9 | Medium | +| Token Expiration Warnings | specs/email.md | P3.9 | **COMPLETE** | ### API Routes (17 total) | Route | Status | Notes | @@ -46,7 +46,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | GET /api/garmin/status | **COMPLETE** | Returns connection status, expiry, warning level (11 tests) | | GET /api/calendar/[userId]/[token].ics | **COMPLETE** | Token validation, ICS generation, caching headers (10 tests) | | POST /api/calendar/regenerate-token | **COMPLETE** | Generates 32-char token, returns URL (9 tests) | -| POST /api/cron/garmin-sync | **COMPLETE** | Syncs Garmin data for all users, creates DailyLogs (22 tests) | +| POST /api/cron/garmin-sync | **COMPLETE** | Syncs Garmin data for all users, creates DailyLogs, sends token expiration warnings (32 tests) | | POST /api/cron/notifications | **COMPLETE** | Sends daily emails with timezone matching, DailyLog handling (20 tests) | | GET /api/history | **COMPLETE** | Paginated historical daily logs with date filtering (19 tests) | | GET /api/health | **COMPLETE** | Health check for deployment monitoring (14 tests) | @@ -90,13 +90,13 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | `src/app/login/page.test.tsx` | **EXISTS** - 14 tests (form rendering, auth flow, error handling, validation) | | `src/app/page.test.tsx` | **EXISTS** - 23 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** - 14 tests (email content, subject lines, formatting) | +| `src/lib/email.test.ts` | **EXISTS** - 24 tests (email content, subject lines, formatting, token expiration warnings) | | `src/lib/ics.test.ts` | **EXISTS** - 23 tests (ICS format validation, 90-day event generation, timezone handling) | | `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) | | `src/app/api/garmin/tokens/route.test.ts` | **EXISTS** - 15 tests (POST/DELETE tokens, encryption, validation, auth) | | `src/app/api/garmin/status/route.test.ts` | **EXISTS** - 11 tests (connection status, expiry, warning levels) | -| `src/app/api/cron/garmin-sync/route.test.ts` | **EXISTS** - 22 tests (auth, user iteration, token handling, Garmin data fetching, DailyLog creation, error handling) | +| `src/app/api/cron/garmin-sync/route.test.ts` | **EXISTS** - 32 tests (auth, user iteration, token handling, Garmin data fetching, DailyLog creation, token expiration warnings, error handling) | | `src/app/api/cron/notifications/route.test.ts` | **EXISTS** - 20 tests (timezone matching, DailyLog handling, email sending) | | `src/app/api/calendar/[userId]/[token].ics/route.test.ts` | **EXISTS** - 10 tests (token validation, ICS generation, caching, error handling) | | `src/app/api/calendar/regenerate-token/route.test.ts` | **EXISTS** - 9 tests (token generation, URL formatting, auth) | @@ -119,7 +119,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta 1. **Override Priority:** flare > stress > sleep > pms (must be enforced in order) 2. **HRV Unbalanced:** ALWAYS forces REST (highest algorithmic priority, non-overridable) 3. **Phase Limits:** Strictly enforced per phase configuration -4. **Token Expiration Warnings:** Must send email at 14 days and 7 days before expiry (NOT IMPLEMENTED - P3.9) +4. **Token Expiration Warnings:** Must send email at 14 days and 7 days before expiry (IMPLEMENTED - P3.9 COMPLETE) 5. **ICS Feed:** Generates 90 days of phase events for calendar subscription --- @@ -324,7 +324,7 @@ Full feature set for production use. - **Files:** - `src/app/api/cron/garmin-sync/route.ts` - Iterates users, fetches data, stores DailyLog - **Tests:** - - `src/app/api/cron/garmin-sync/route.test.ts` - 22 tests covering auth, user iteration, token handling, Garmin data fetching, DailyLog creation, error handling + - `src/app/api/cron/garmin-sync/route.test.ts` - 32 tests covering auth, user iteration, token handling, Garmin data fetching, DailyLog creation, token expiration warnings, error handling - **Features Implemented:** - Fetches all users with garminConnected=true - Skips users with expired tokens @@ -332,6 +332,7 @@ Full feature set for production use. - Calculates cycle day, phase, phase limit, remaining minutes - Computes training decision using decision engine - Creates DailyLog entries for each user + - Sends token expiration warning emails at 14 and 7 days before expiry - Returns sync summary (usersProcessed, errors, skippedExpired, timestamp) - **Why:** Automated data sync is required for morning notifications - **Depends On:** P2.1, P2.2 @@ -623,14 +624,19 @@ Testing, error handling, and refinements. - All page files - Add loading.tsx or Suspense boundaries - **Why:** Better perceived performance -### P3.9: Token Expiration Warnings -- [ ] Email warnings at 14 and 7 days before Garmin token expiry -- **Current State:** `sendTokenExpirationWarning()` function does not exist in email.ts +### P3.9: Token Expiration Warnings ✅ COMPLETE +- [x] Email warnings at 14 and 7 days before Garmin token expiry - **Files:** - - `src/lib/email.ts` - Add `sendTokenExpirationWarning()` - - `src/app/api/cron/garmin-sync/route.ts` - Check expiry, trigger warnings + - `src/lib/email.ts` - Added `sendTokenExpirationWarning()` function + - `src/app/api/cron/garmin-sync/route.ts` - Added token expiry checking and warning logic - **Tests:** - - Test warning triggers at exactly 14 days and 7 days + - `src/lib/email.test.ts` - 10 new tests for warning email function (24 total) + - `src/app/api/cron/garmin-sync/route.test.ts` - 10 new tests for warning integration (32 total) +- **Features Implemented:** + - Sends warning email at exactly 14 days before token expiry + - Sends warning email at exactly 7 days before token expiry + - Warning logic integrated into garmin-sync cron job + - Email includes days until expiry and instructions for refreshing tokens - **Why:** Users need time to refresh tokens (per spec requirement in specs/email.md) ### P3.10: E2E Test Suite (AUTHORIZED SKIP) @@ -780,7 +786,6 @@ P4.* UX Polish ────────> After core functionality complete | Priority | Task | Effort | Notes | |----------|------|--------|-------| -| HIGH | P3.9 Token Warnings | Small | Spec requirement, security-related | | Medium | P2.13 Plan Page | Medium | Placeholder exists, needs content | | Medium | P2.14 MiniCalendar | Small | Can reuse DayCell, ~70% remaining | | Medium | P2.16 Metrics | Medium | Production monitoring | @@ -814,7 +819,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 14 tests (`sendDailyEmail`, `sendPeriodConfirmationEmail`, email formatting) (P3.3) +- [x] **email.ts** - Complete with 24 tests (`sendDailyEmail`, `sendPeriodConfirmationEmail`, `sendTokenExpirationWarning`, email formatting) (P3.3, P3.9) - [x] **ics.ts** - Complete with 23 tests (`generateIcsFeed`, ICS format validation, 90-day event generation) (P3.4) - [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) @@ -840,7 +845,7 @@ P4.* UX Polish ────────> After core functionality complete - [x] **POST /api/garmin/tokens** - Stores encrypted Garmin OAuth tokens, 15 tests (P2.2) - [x] **DELETE /api/garmin/tokens** - Clears tokens and disconnects Garmin, 15 tests (P2.2) - [x] **GET /api/garmin/status** - Returns connection status, expiry, warning level, 11 tests (P2.3) -- [x] **POST /api/cron/garmin-sync** - Daily sync of Garmin data for all connected users, creates DailyLogs, 22 tests (P2.4) +- [x] **POST /api/cron/garmin-sync** - Daily sync of Garmin data for all connected users, creates DailyLogs, sends token expiration warnings, 32 tests (P2.4, P3.9) - [x] **POST /api/cron/notifications** - Sends daily email notifications with timezone matching, DailyLog handling, nutrition guidance, 20 tests (P2.5) - [x] **GET /api/calendar/[userId]/[token].ics** - Returns ICS feed with 90-day phase events, token validation, caching headers, 10 tests (P2.6) - [x] **POST /api/calendar/regenerate-token** - Generates new 32-char calendar token, returns URL, 9 tests (P2.7) @@ -858,6 +863,15 @@ P4.* UX Polish ────────> After core functionality complete ### Test Infrastructure - [x] **test-setup.ts** - Global test setup with @testing-library/jest-dom matchers and cleanup +### 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.4: ICS Tests** - Complete with 23 tests covering ICS format validation, 90-day event generation, timezone handling +- [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 +- [x] **P3.9: Token Expiration Warnings** - Complete with 10 new tests in email.test.ts, 10 new tests in garmin-sync/route.test.ts; sends warnings at 14 and 7 days before expiry + --- ## Discovered Issues @@ -883,7 +897,7 @@ P4.* UX Polish ────────> After core functionality complete 7. **Component Reuse:** Dashboard components are complete and can be used directly in P1.7 8. **HRV Rule:** HRV Unbalanced status ALWAYS forces REST - this is the highest algorithmic priority and cannot be overridden by manual toggles 9. **Override Order:** When multiple overrides are active, apply in order: flare > stress > sleep > pms -10. **Token Warnings:** Per spec, warnings must be sent at exactly 14 days and 7 days before expiry (P3.9 NOT IMPLEMENTED) +10. **Token Warnings:** Per spec, warnings are sent at exactly 14 days and 7 days before expiry (P3.9 COMPLETE) 11. **Health Check Priority:** P2.15 (GET /api/health) should be implemented early - it's required for deployment monitoring and load balancer health probes 12. **Structured Logging:** P2.17 (pino logger) should be implemented before other P2 items if possible, so new code can use proper logging from the start 13. **OIDC vs Email/Password:** Current email/password login (P1.6) works for development. P2.18 upgrades to OIDC for production security per specs/authentication.md diff --git a/src/app/api/cron/garmin-sync/route.test.ts b/src/app/api/cron/garmin-sync/route.test.ts index eef3ca9..84385cb 100644 --- a/src/app/api/cron/garmin-sync/route.test.ts +++ b/src/app/api/cron/garmin-sync/route.test.ts @@ -44,6 +44,7 @@ const mockFetchBodyBattery = vi .mockResolvedValue({ current: 85, yesterdayLow: 45 }); const mockFetchIntensityMinutes = vi.fn().mockResolvedValue(60); const mockIsTokenExpired = vi.fn().mockReturnValue(false); +const mockDaysUntilExpiry = vi.fn().mockReturnValue(30); vi.mock("@/lib/garmin", () => ({ fetchHrvStatus: (...args: unknown[]) => mockFetchHrvStatus(...args), @@ -51,6 +52,15 @@ vi.mock("@/lib/garmin", () => ({ fetchIntensityMinutes: (...args: unknown[]) => mockFetchIntensityMinutes(...args), isTokenExpired: (...args: unknown[]) => mockIsTokenExpired(...args), + daysUntilExpiry: (...args: unknown[]) => mockDaysUntilExpiry(...args), +})); + +// Mock email sending +const mockSendTokenExpirationWarning = vi.fn().mockResolvedValue(undefined); + +vi.mock("@/lib/email", () => ({ + sendTokenExpirationWarning: (...args: unknown[]) => + mockSendTokenExpirationWarning(...args), })); import { POST } from "./route"; @@ -93,6 +103,7 @@ describe("POST /api/cron/garmin-sync", () => { beforeEach(() => { vi.clearAllMocks(); mockUsers = []; + mockDaysUntilExpiry.mockReturnValue(30); // Default to 30 days remaining process.env.CRON_SECRET = validSecret; }); @@ -381,5 +392,128 @@ describe("POST /api/cron/garmin-sync", () => { expect(body.timestamp).toBeDefined(); expect(new Date(body.timestamp)).toBeInstanceOf(Date); }); + + it("includes warningsSent in response", async () => { + mockUsers = []; + + const response = await POST(createMockRequest(`Bearer ${validSecret}`)); + + const body = await response.json(); + expect(body.warningsSent).toBeDefined(); + expect(body.warningsSent).toBe(0); + }); + }); + + describe("Token expiration warnings", () => { + it("sends warning email when token expires in exactly 14 days", async () => { + mockUsers = [createMockUser({ email: "user@example.com" })]; + mockDaysUntilExpiry.mockReturnValue(14); + + const response = await POST(createMockRequest(`Bearer ${validSecret}`)); + + expect(mockSendTokenExpirationWarning).toHaveBeenCalledWith( + "user@example.com", + 14, + ); + const body = await response.json(); + expect(body.warningsSent).toBe(1); + }); + + it("sends warning email when token expires in exactly 7 days", async () => { + mockUsers = [createMockUser({ email: "user@example.com" })]; + mockDaysUntilExpiry.mockReturnValue(7); + + const response = await POST(createMockRequest(`Bearer ${validSecret}`)); + + expect(mockSendTokenExpirationWarning).toHaveBeenCalledWith( + "user@example.com", + 7, + ); + const body = await response.json(); + expect(body.warningsSent).toBe(1); + }); + + it("does not send warning when token expires in 30 days", async () => { + mockUsers = [createMockUser()]; + mockDaysUntilExpiry.mockReturnValue(30); + + await POST(createMockRequest(`Bearer ${validSecret}`)); + + expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled(); + }); + + it("does not send warning when token expires in 15 days", async () => { + mockUsers = [createMockUser()]; + mockDaysUntilExpiry.mockReturnValue(15); + + await POST(createMockRequest(`Bearer ${validSecret}`)); + + expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled(); + }); + + it("does not send warning when token expires in 8 days", async () => { + mockUsers = [createMockUser()]; + mockDaysUntilExpiry.mockReturnValue(8); + + await POST(createMockRequest(`Bearer ${validSecret}`)); + + expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled(); + }); + + it("does not send warning when token expires in 6 days", async () => { + mockUsers = [createMockUser()]; + mockDaysUntilExpiry.mockReturnValue(6); + + await POST(createMockRequest(`Bearer ${validSecret}`)); + + expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled(); + }); + + it("sends warnings for multiple users on different thresholds", async () => { + mockUsers = [ + createMockUser({ id: "user1", email: "user1@example.com" }), + createMockUser({ id: "user2", email: "user2@example.com" }), + ]; + // First user at 14 days, second user at 7 days + mockDaysUntilExpiry.mockReturnValueOnce(14).mockReturnValueOnce(7); + + const response = await POST(createMockRequest(`Bearer ${validSecret}`)); + + expect(mockSendTokenExpirationWarning).toHaveBeenCalledTimes(2); + expect(mockSendTokenExpirationWarning).toHaveBeenCalledWith( + "user1@example.com", + 14, + ); + expect(mockSendTokenExpirationWarning).toHaveBeenCalledWith( + "user2@example.com", + 7, + ); + const body = await response.json(); + expect(body.warningsSent).toBe(2); + }); + + it("continues processing sync even if warning email fails", async () => { + mockUsers = [createMockUser({ email: "user@example.com" })]; + mockDaysUntilExpiry.mockReturnValue(14); + mockSendTokenExpirationWarning.mockRejectedValueOnce( + new Error("Email failed"), + ); + + const response = await POST(createMockRequest(`Bearer ${validSecret}`)); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.usersProcessed).toBe(1); + }); + + it("does not send warning for expired tokens", async () => { + mockUsers = [createMockUser()]; + mockIsTokenExpired.mockReturnValue(true); + mockDaysUntilExpiry.mockReturnValue(-1); + + await POST(createMockRequest(`Bearer ${validSecret}`)); + + expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled(); + }); }); }); diff --git a/src/app/api/cron/garmin-sync/route.ts b/src/app/api/cron/garmin-sync/route.ts index 4ef32b9..f4ab4e2 100644 --- a/src/app/api/cron/garmin-sync/route.ts +++ b/src/app/api/cron/garmin-sync/route.ts @@ -4,8 +4,10 @@ import { NextResponse } from "next/server"; import { getCycleDay, getPhase, getPhaseLimit } from "@/lib/cycle"; import { getDecisionWithOverrides } from "@/lib/decision-engine"; +import { sendTokenExpirationWarning } from "@/lib/email"; import { decrypt } from "@/lib/encryption"; import { + daysUntilExpiry, fetchBodyBattery, fetchHrvStatus, fetchIntensityMinutes, @@ -19,6 +21,7 @@ interface SyncResult { usersProcessed: number; errors: number; skippedExpired: number; + warningsSent: number; timestamp: string; } @@ -36,6 +39,7 @@ export async function POST(request: Request) { usersProcessed: 0, errors: 0, skippedExpired: 0, + warningsSent: 0, timestamp: new Date().toISOString(), }; @@ -61,6 +65,17 @@ export async function POST(request: Request) { continue; } + // Check for token expiration warnings (exactly 14 or 7 days) + const daysRemaining = daysUntilExpiry(tokens); + if (daysRemaining === 14 || daysRemaining === 7) { + try { + await sendTokenExpirationWarning(user.email, daysRemaining); + result.warningsSent++; + } catch { + // Continue processing even if warning email fails + } + } + // Decrypt OAuth2 token const oauth2Json = decrypt(user.garminOauth2Token); const oauth2Data = JSON.parse(oauth2Json); diff --git a/src/lib/email.test.ts b/src/lib/email.test.ts index 7fcd71a..80f07bc 100644 --- a/src/lib/email.test.ts +++ b/src/lib/email.test.ts @@ -14,7 +14,11 @@ vi.mock("resend", () => ({ })); import type { DailyEmailData } from "./email"; -import { sendDailyEmail, sendPeriodConfirmationEmail } from "./email"; +import { + sendDailyEmail, + sendPeriodConfirmationEmail, + sendTokenExpirationWarning, +} from "./email"; describe("sendDailyEmail", () => { afterEach(() => { @@ -189,3 +193,87 @@ describe("sendPeriodConfirmationEmail", () => { ); }); }); + +describe("sendTokenExpirationWarning", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("14-day warning", () => { + it("sends email with correct subject for 14-day warning", async () => { + await sendTokenExpirationWarning("user@example.com", 14); + expect(mockSend).toHaveBeenCalledWith( + expect.objectContaining({ + subject: "⚠️ PhaseFlow: Garmin tokens expire in 14 days", + }), + ); + }); + + it("sends to correct recipient", async () => { + await sendTokenExpirationWarning("user@example.com", 14); + expect(mockSend).toHaveBeenCalledWith( + expect.objectContaining({ + to: "user@example.com", + }), + ); + }); + + it("includes days until expiry in body", async () => { + await sendTokenExpirationWarning("user@example.com", 14); + const call = mockSend.mock.calls[0][0]; + expect(call.text).toContain("14 days"); + }); + + it("includes instructions to refresh tokens", async () => { + await sendTokenExpirationWarning("user@example.com", 14); + const call = mockSend.mock.calls[0][0]; + expect(call.text).toContain("Settings"); + expect(call.text).toContain("Garmin"); + }); + + it("includes auto-generated footer", async () => { + await sendTokenExpirationWarning("user@example.com", 14); + const call = mockSend.mock.calls[0][0]; + expect(call.text).toContain("Auto-generated by PhaseFlow"); + }); + }); + + describe("7-day warning", () => { + it("sends email with urgent subject for 7-day warning", async () => { + await sendTokenExpirationWarning("user@example.com", 7); + expect(mockSend).toHaveBeenCalledWith( + expect.objectContaining({ + subject: + "🚨 PhaseFlow: Garmin tokens expire in 7 days - action required", + }), + ); + }); + + it("sends to correct recipient", async () => { + await sendTokenExpirationWarning("user@example.com", 7); + expect(mockSend).toHaveBeenCalledWith( + expect.objectContaining({ + to: "user@example.com", + }), + ); + }); + + it("includes days until expiry in body", async () => { + await sendTokenExpirationWarning("user@example.com", 7); + const call = mockSend.mock.calls[0][0]; + expect(call.text).toContain("7 days"); + }); + + it("uses more urgent tone than 14-day warning", async () => { + await sendTokenExpirationWarning("user@example.com", 7); + const call = mockSend.mock.calls[0][0]; + expect(call.text).toContain("urgent"); + }); + + it("includes auto-generated footer", async () => { + await sendTokenExpirationWarning("user@example.com", 7); + const call = mockSend.mock.calls[0][0]; + expect(call.text).toContain("Auto-generated by PhaseFlow"); + }); + }); +}); diff --git a/src/lib/email.ts b/src/lib/email.ts index da6ee4a..2688768 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -81,3 +81,37 @@ Auto-generated by PhaseFlow`; text: body, }); } + +export async function sendTokenExpirationWarning( + to: string, + daysUntilExpiry: number, +): Promise { + const isUrgent = daysUntilExpiry <= 7; + + const subject = isUrgent + ? `🚨 PhaseFlow: Garmin tokens expire in ${daysUntilExpiry} days - action required` + : `⚠️ PhaseFlow: Garmin tokens expire in ${daysUntilExpiry} days`; + + const urgencyMessage = isUrgent + ? `⚠️ This is urgent - your Garmin data sync will stop working in ${daysUntilExpiry} days if you don't refresh your tokens.` + : `Your Garmin OAuth tokens will expire in ${daysUntilExpiry} days.`; + + const body = `${urgencyMessage} + +📋 HOW TO REFRESH YOUR TOKENS: +1. Go to Settings > Garmin in PhaseFlow +2. Follow the instructions to reconnect your Garmin account +3. Paste the new tokens from the bootstrap script + +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, + }); +}