* Add on-the-clock email notifications and fix push notification modal - Add `draft_email_notifications_enabled` column to users table - New `sendOnTheClockEmail` service sends an email when a user's turn arrives in a draft; detects back-to-back picks and sends one combined email instead of two; skips notification if the team has autodraft enabled - Email is triggered after every manual pick (make-pick route) and every timer-expiry pick (executeAutoPick) once the full autodraft chain settles, so the notified user is always the first human on the clock - Add email notification toggle to the user settings Notifications section - Fix push notification dropdown in the draft room: the bare absolute div is replaced with a Radix Popover so clicking outside dismisses it without toggling notifications off - Rename draft room label from "Pick Notifications" to "Push Notifications" to distinguish from the new email notifications https://claude.ai/code/session_01LMGxgYvtE3CF8Jf3u2pXgA * Address code review feedback on email notification feature - sendOnTheClockEmail now fetches season (and currentPickNumber) internally, removing the blocking postChainSeason pre-fetch from both make-pick.ts and executeAutoPick; callers are now true fire-and-forget with no IIFE needed - Parallel Promise.all for team, autodraftSettings, and league queries reduces sequential DB round-trips from 5 to 3 - Remove team.ownerId type cast; assign to local after the null guard - Combine the two calculatePickInfo calls for the same pick into one - Fix popoverOpen/enabled divergence: remove the {enabled && ...} guard on PopoverContent so popoverOpen is the sole visibility control - Unify NotificationsSection feedback pattern: both Discord and email sections now read success/error directly from their respective fetcher data via a shared feedbackFromFetcher helper; removes the success/error props and the verbose cast that was used for email - Add 13 unit tests for sendOnTheClockEmail covering: early-exit paths (no season, past totalPicks, no owner, notifications disabled, autodraft enabled, no league), single vs back-to-back subject selection, draft room link presence, error logging without throwing, and parallel query execution https://claude.ai/code/session_01LMGxgYvtE3CF8Jf3u2pXgA --------- Co-authored-by: Claude <noreply@anthropic.com>
247 lines
8 KiB
TypeScript
247 lines
8 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||
|
||
vi.mock("~/database/context", () => ({
|
||
database: vi.fn(),
|
||
}));
|
||
|
||
vi.mock("~/lib/email.server", () => ({
|
||
sendEmail: vi.fn().mockResolvedValue({ error: null }),
|
||
wrapInEmailTemplate: vi.fn((content: string) => `<html>${content}</html>`),
|
||
emailParagraph: vi.fn((html: string) => `<p>${html}</p>`),
|
||
emailButton: vi.fn((href: string, text: string) => `<a href="${href}">${text}</a>`),
|
||
escapeHtml: vi.fn((s: string) => s),
|
||
}));
|
||
|
||
import { sendOnTheClockEmail } from "../draft-email.server";
|
||
import { sendEmail } from "~/lib/email.server";
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const SEASON_ID = "season-1";
|
||
const LEAGUE_ID = "league-1";
|
||
const TEAM_ID = "team-1";
|
||
const TEAM_ID_2 = "team-2";
|
||
const OWNER_ID = "owner-1";
|
||
|
||
/** 2-team, 4-round draft: picks 1,2,3,4,5,6,7,8 */
|
||
const DRAFT_SLOTS_2_TEAM = [
|
||
{ teamId: TEAM_ID, draftOrder: 1 },
|
||
{ teamId: TEAM_ID_2, draftOrder: 2 },
|
||
];
|
||
|
||
function makeSeason(currentPickNumber: number, draftRounds = 4) {
|
||
return {
|
||
id: SEASON_ID,
|
||
leagueId: LEAGUE_ID,
|
||
currentPickNumber,
|
||
draftRounds,
|
||
};
|
||
}
|
||
|
||
function makeOwner(emailNotificationsEnabled = true) {
|
||
return {
|
||
id: OWNER_ID,
|
||
email: "owner@example.com",
|
||
draftEmailNotificationsEnabled: emailNotificationsEnabled,
|
||
};
|
||
}
|
||
|
||
function makeMockDb(overrides: {
|
||
season?: object | null;
|
||
team?: object | null;
|
||
autodraft?: object | null;
|
||
league?: object | null;
|
||
owner?: object | null;
|
||
} = {}) {
|
||
const season = "season" in overrides ? overrides.season : makeSeason(1);
|
||
const team = "team" in overrides ? overrides.team : { id: TEAM_ID, name: "Team One", ownerId: OWNER_ID };
|
||
const autodraft = "autodraft" in overrides ? overrides.autodraft : null;
|
||
const league = "league" in overrides ? overrides.league : { id: LEAGUE_ID, name: "Test League" };
|
||
const owner = "owner" in overrides ? overrides.owner : makeOwner();
|
||
|
||
return {
|
||
query: {
|
||
seasons: { findFirst: vi.fn().mockResolvedValue(season) },
|
||
teams: { findFirst: vi.fn().mockResolvedValue(team) },
|
||
autodraftSettings: { findFirst: vi.fn().mockResolvedValue(autodraft) },
|
||
leagues: { findFirst: vi.fn().mockResolvedValue(league) },
|
||
users: { findFirst: vi.fn().mockResolvedValue(owner) },
|
||
},
|
||
};
|
||
}
|
||
|
||
const BASE_PARAMS = {
|
||
seasonId: SEASON_ID,
|
||
totalTeams: 2,
|
||
draftSlots: DRAFT_SLOTS_2_TEAM,
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests
|
||
// ---------------------------------------------------------------------------
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
process.env.APP_URL = "https://test.brackt.com";
|
||
});
|
||
|
||
afterEach(() => {
|
||
delete process.env.APP_URL;
|
||
});
|
||
|
||
describe("sendOnTheClockEmail", () => {
|
||
it("sends an email with the correct subject and recipient", async () => {
|
||
const db = makeMockDb({ season: makeSeason(1) });
|
||
|
||
await sendOnTheClockEmail({ ...BASE_PARAMS, db: db as never });
|
||
|
||
expect(sendEmail).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
to: "owner@example.com",
|
||
subject: "You're on the clock in Test League",
|
||
})
|
||
);
|
||
});
|
||
|
||
it("returns early when season is not found", async () => {
|
||
const db = makeMockDb({ season: null });
|
||
|
||
await sendOnTheClockEmail({ ...BASE_PARAMS, db: db as never });
|
||
|
||
expect(sendEmail).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("returns early when currentPickNumber exceeds totalPicks", async () => {
|
||
// 2 teams × 4 rounds = 8 total picks; pick 9 is past the end
|
||
const db = makeMockDb({ season: makeSeason(9, 4) });
|
||
|
||
await sendOnTheClockEmail({ ...BASE_PARAMS, db: db as never });
|
||
|
||
expect(sendEmail).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("returns early when the team has no owner", async () => {
|
||
const db = makeMockDb({ team: { id: TEAM_ID, name: "Team One", ownerId: null } });
|
||
|
||
await sendOnTheClockEmail({ ...BASE_PARAMS, db: db as never });
|
||
|
||
expect(sendEmail).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("returns early when owner has email notifications disabled", async () => {
|
||
const db = makeMockDb({ owner: makeOwner(false) });
|
||
|
||
await sendOnTheClockEmail({ ...BASE_PARAMS, db: db as never });
|
||
|
||
expect(sendEmail).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("returns early when autodraft is enabled for the team", async () => {
|
||
const db = makeMockDb({ autodraft: { isEnabled: true } });
|
||
|
||
await sendOnTheClockEmail({ ...BASE_PARAMS, db: db as never });
|
||
|
||
expect(sendEmail).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("returns early when league is not found", async () => {
|
||
const db = makeMockDb({ league: null });
|
||
|
||
await sendOnTheClockEmail({ ...BASE_PARAMS, db: db as never });
|
||
|
||
expect(sendEmail).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("sends back-to-back subject when the same team picks consecutively (snake turnaround)", async () => {
|
||
// In a 2-team snake draft, pick 2 (team 2 forward) is followed by pick 3
|
||
// (team 2 again on the reversal). Team 2 picks back-to-back.
|
||
const db = makeMockDb({
|
||
season: makeSeason(2),
|
||
team: { id: TEAM_ID_2, name: "Team Two", ownerId: OWNER_ID },
|
||
});
|
||
|
||
await sendOnTheClockEmail({ ...BASE_PARAMS, db: db as never });
|
||
|
||
expect(sendEmail).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
subject: "You have back-to-back picks in Test League",
|
||
})
|
||
);
|
||
});
|
||
|
||
it("sends a regular subject when the next pick goes to a different team", async () => {
|
||
// Pick 1 is team 1; pick 2 is team 2 — not consecutive for team 1
|
||
const db = makeMockDb({ season: makeSeason(1) });
|
||
|
||
await sendOnTheClockEmail({ ...BASE_PARAMS, db: db as never });
|
||
|
||
expect(sendEmail).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
subject: "You're on the clock in Test League",
|
||
})
|
||
);
|
||
});
|
||
|
||
it("does not treat the final pick as consecutive even when it is the last slot", async () => {
|
||
// Pick 8 is the last pick (2 teams × 4 rounds). No pick 9, so not consecutive.
|
||
const db = makeMockDb({ season: makeSeason(8, 4) });
|
||
|
||
await sendOnTheClockEmail({ ...BASE_PARAMS, db: db as never });
|
||
|
||
expect(sendEmail).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
subject: expect.stringContaining("on the clock"),
|
||
})
|
||
);
|
||
});
|
||
|
||
it("includes a link to the draft room in the email", async () => {
|
||
const db = makeMockDb({ season: makeSeason(1) });
|
||
|
||
await sendOnTheClockEmail({ ...BASE_PARAMS, db: db as never });
|
||
|
||
const call = vi.mocked(sendEmail).mock.calls[0][0];
|
||
expect(call.html).toContain(
|
||
`https://test.brackt.com/leagues/${LEAGUE_ID}/draft/${SEASON_ID}`
|
||
);
|
||
});
|
||
|
||
it("logs an error but does not throw when sendEmail fails", async () => {
|
||
const db = makeMockDb({ season: makeSeason(1) });
|
||
vi.mocked(sendEmail).mockResolvedValueOnce({ error: new Error("network failure") });
|
||
|
||
await expect(
|
||
sendOnTheClockEmail({ ...BASE_PARAMS, db: db as never })
|
||
).resolves.toBeUndefined();
|
||
});
|
||
|
||
it("fetches team, autodraft settings, and league in parallel", async () => {
|
||
const order: string[] = [];
|
||
const db = makeMockDb({ season: makeSeason(1) });
|
||
|
||
db.query.teams.findFirst = vi.fn().mockImplementation(async () => {
|
||
order.push("team");
|
||
return { id: TEAM_ID, name: "Team One", ownerId: OWNER_ID };
|
||
});
|
||
db.query.autodraftSettings.findFirst = vi.fn().mockImplementation(async () => {
|
||
order.push("autodraft");
|
||
return null;
|
||
});
|
||
db.query.leagues.findFirst = vi.fn().mockImplementation(async () => {
|
||
order.push("league");
|
||
return { id: LEAGUE_ID, name: "Test League" };
|
||
});
|
||
|
||
await sendOnTheClockEmail({ ...BASE_PARAMS, db: db as never });
|
||
|
||
// All three should be called; order may vary since they run in parallel
|
||
expect(order).toContain("team");
|
||
expect(order).toContain("autodraft");
|
||
expect(order).toContain("league");
|
||
expect(db.query.teams.findFirst).toHaveBeenCalledTimes(1);
|
||
expect(db.query.autodraftSettings.findFirst).toHaveBeenCalledTimes(1);
|
||
expect(db.query.leagues.findFirst).toHaveBeenCalledTimes(1);
|
||
});
|
||
});
|