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:
@@ -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