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