Implement token expiration warnings (P3.9)
Add email warnings for Garmin token expiration at 14-day and 7-day thresholds. When the garmin-sync cron job runs, it now checks each user's token expiry and sends a warning email at exactly 14 days and 7 days before expiration. Changes: - Add sendTokenExpirationWarning() to email.ts with differentiated subject lines and urgency levels for 14-day vs 7-day warnings - Integrate warning logic into garmin-sync cron route using daysUntilExpiry() - Track warnings sent in sync response with new warningsSent counter - Add 20 new tests (10 for email function, 10 for sync integration) Test count: 517 → 537 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,3 +81,37 @@ Auto-generated by PhaseFlow`;
|
||||
text: body,
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendTokenExpirationWarning(
|
||||
to: string,
|
||||
daysUntilExpiry: number,
|
||||
): Promise<void> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user