diff --git a/app/components/NotificationSettings.tsx b/app/components/NotificationSettings.tsx
index 3bf1d8f..a7e746c 100644
--- a/app/components/NotificationSettings.tsx
+++ b/app/components/NotificationSettings.tsx
@@ -1,6 +1,8 @@
+import { useState } from "react";
import { Switch } from "~/components/ui/switch";
import { Label } from "~/components/ui/label";
import { RadioGroup, RadioGroupItem } from "~/components/ui/radio-group";
+import { Popover, PopoverAnchor, PopoverContent } from "~/components/ui/popover";
import type { NotificationMode } from "~/hooks/useDraftNotifications";
interface NotificationSettingsProps {
@@ -20,42 +22,49 @@ export function NotificationSettings({
permissionState,
switchOnly = false,
}: NotificationSettingsProps) {
+ const [popoverOpen, setPopoverOpen] = useState(false);
+
if (permissionState === "unsupported") {
return null;
}
if (switchOnly) {
+ const handleEnabledChange = (val: boolean) => {
+ onEnabledChange(val);
+ setPopoverOpen(val);
+ };
+
return (
- <>
-
+ Receive an email when it's your turn to pick in a draft. If you have back-to-back + picks, you'll get a single email letting you know. +
+Email notification preference saved.
+ )} +Notification preference saved.
)} - {error && ( -{error}
+ {discordError && ( +{discordError}
)} > )} diff --git a/app/models/draft-utils.ts b/app/models/draft-utils.ts index 517f8fe..1f3c076 100644 --- a/app/models/draft-utils.ts +++ b/app/models/draft-utils.ts @@ -8,6 +8,7 @@ import { isParticipantDrafted, getDraftPicksWithSports, getTeamDraftPicksWithSpo import { getParticipantsForSeasonWithSports } from "./season-participant"; import { getSeasonSportsSimple } from "./season-sport"; import { calculateDraftEligibility } from "~/lib/draft-eligibility"; +import { sendOnTheClockEmail } from "~/services/draft-email.server"; import { getSocketIO, scheduleDraftRoomClosure } from "../../server/socket"; import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server"; @@ -833,6 +834,11 @@ export async function executeAutoPick(params: { 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)); } return { diff --git a/app/routes/api/draft.make-pick.ts b/app/routes/api/draft.make-pick.ts index d547cf1..e8d8087 100644 --- a/app/routes/api/draft.make-pick.ts +++ b/app/routes/api/draft.make-pick.ts @@ -11,6 +11,7 @@ import { calculatePickInfo, checkAndTriggerNextAutodraft, pruneIneligibleQueueIt import { getSocketIO, scheduleDraftRoomClosure } from "../../../server/socket"; import { logger } from "~/lib/logger"; import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server"; +import { sendOnTheClockEmail } from "~/services/draft-email.server"; import type { ActionFunctionArgs } from "react-router"; export async function action(args: ActionFunctionArgs) { @@ -322,6 +323,11 @@ export async function action(args: ActionFunctionArgs) { 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)); } return Response.json({ diff --git a/app/routes/leagues/$leagueId.draft.$seasonId.tsx b/app/routes/leagues/$leagueId.draft.$seasonId.tsx index 5e69ace..1ee9492 100644 --- a/app/routes/leagues/$leagueId.draft.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.draft.$seasonId.tsx @@ -1328,7 +1328,7 @@ export default function DraftRoom() {${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 new file mode 100644 index 0000000..59b8e9b --- /dev/null +++ b/app/services/draft-email.server.ts @@ -0,0 +1,112 @@ +import { eq, and } from "drizzle-orm"; +import * as schema from "~/database/schema"; +import { sendEmail, wrapInEmailTemplate, emailParagraph, emailButton, escapeHtml } from "~/lib/email.server"; +import { calculatePickInfo } from "~/models/draft-utils"; +import { logger } from "~/lib/logger"; +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; + totalTeams: number; + draftSlots: DraftSlot[]; + db: ReturnType