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:
2026-01-11 08:24:19 +00:00
parent 6c3dd34412
commit 2ffee63a59
5 changed files with 304 additions and 19 deletions

View File

@@ -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();
});
});
});

View File

@@ -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);

View File

@@ -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");
});
});
});

View File

@@ -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,
});
}