Email notification preference saved.
)} @@ -106,11 +115,11 @@ export function NotificationsSection({ discordPingEnabled, hasDiscordLinked, dra disabled={discordFetcher.state !== "idle"} /> - {success && ( + {discordSuccess && (Notification preference saved.
)} - {error && ( -{error}
+ {discordError && ( +{discordError}
)} > )} diff --git a/app/models/draft-utils.ts b/app/models/draft-utils.ts index 0a11b49..1f3c076 100644 --- a/app/models/draft-utils.ts +++ b/app/models/draft-utils.ts @@ -835,10 +835,9 @@ export async function executeAutoPick(params: { db, }); - // After autodraft chain settles, notify whoever is actually on the clock - const postChainSeason = await db.query.seasons.findFirst({ where: eq(schema.seasons.id, seasonId) }); - const finalPickNumber = postChainSeason?.currentPickNumber ?? nextPickNumber; - sendOnTheClockEmail({ seasonId, currentPickNumber: finalPickNumber, totalTeams, totalPicks, draftSlots, db }) + // Fire-and-forget: sendOnTheClockEmail fetches the current pick number from + // the DB itself, so it always sees the post-chain state without blocking here. + sendOnTheClockEmail({ seasonId, totalTeams, draftSlots, db }) .catch((err) => logger.error("[AutoPick] On-the-clock email failed:", err)); } diff --git a/app/routes/api/draft.make-pick.ts b/app/routes/api/draft.make-pick.ts index c8c15c1..e8d8087 100644 --- a/app/routes/api/draft.make-pick.ts +++ b/app/routes/api/draft.make-pick.ts @@ -324,11 +324,9 @@ export async function action(args: ActionFunctionArgs) { }); } - // After autodraft chain settles, notify whoever is actually on the clock - const postChainSeason = await db.query.seasons.findFirst({ where: eq(schema.seasons.id, seasonId) }); - const finalPickNumber = postChainSeason?.currentPickNumber ?? nextPickNumber; - const totalPicks = totalTeams * season.draftRounds; - sendOnTheClockEmail({ seasonId, currentPickNumber: finalPickNumber, totalTeams, totalPicks, draftSlots, db }) + // Fire-and-forget: sendOnTheClockEmail fetches the current pick number from + // the DB itself, so it always sees the post-chain state without blocking here. + sendOnTheClockEmail({ seasonId, totalTeams, draftSlots, db }) .catch((err) => logger.error("On-the-clock email failed:", err)); } diff --git a/app/routes/settings.tsx b/app/routes/settings.tsx index a8a1fd3..a7d4455 100644 --- a/app/routes/settings.tsx +++ b/app/routes/settings.tsx @@ -279,8 +279,6 @@ export default function SettingsPage({ loaderData, actionData }: Route.Component discordPingEnabled={user.discordPingEnabled} hasDiscordLinked={linkedAccounts.some((a) => a.providerId === "discord")} draftEmailNotificationsEnabled={user.draftEmailNotificationsEnabled} - success={ad?.intent === "update-discord-ping" && "success" in ad} - error={ad?.intent === "update-discord-ping" && "error" in ad ? ad.error : undefined} onNavigateToAccount={() => handleSectionChange("account")} /> )} diff --git a/app/services/__tests__/draft-email.server.test.ts b/app/services/__tests__/draft-email.server.test.ts new file mode 100644 index 0000000..c4ba0ef --- /dev/null +++ b/app/services/__tests__/draft-email.server.test.ts @@ -0,0 +1,247 @@ +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) => `${content}`), + emailParagraph: vi.fn((html: string) => `${html}
`), + emailButton: vi.fn((href: string, text: string) => `${text}`), + 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); + }); +}); diff --git a/app/services/draft-email.server.ts b/app/services/draft-email.server.ts index a126e43..59b8e9b 100644 --- a/app/services/draft-email.server.ts +++ b/app/services/draft-email.server.ts @@ -7,43 +7,60 @@ import type { database } from "~/database/context"; type DraftSlot = { teamId: string; draftOrder: number }; +/** + * Sends an "on the clock" email to the team owner whose turn it is. + * Reads the current pick number directly from the DB so callers never need + * to await a pre-fetch — just fire and forget this after the autodraft chain + * has settled. + */ export async function sendOnTheClockEmail(params: { seasonId: string; - currentPickNumber: number; totalTeams: number; - totalPicks: number; draftSlots: DraftSlot[]; db: ReturnType