Add draft email notifications feature (#459)

* 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>
This commit is contained in:
Chris Parsons 2026-05-20 15:35:43 -07:00 committed by GitHub
parent 7809864674
commit b8b58ec9f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 6664 additions and 51 deletions

View file

@ -1,6 +1,8 @@
import { useState } from "react";
import { Switch } from "~/components/ui/switch"; import { Switch } from "~/components/ui/switch";
import { Label } from "~/components/ui/label"; import { Label } from "~/components/ui/label";
import { RadioGroup, RadioGroupItem } from "~/components/ui/radio-group"; import { RadioGroup, RadioGroupItem } from "~/components/ui/radio-group";
import { Popover, PopoverAnchor, PopoverContent } from "~/components/ui/popover";
import type { NotificationMode } from "~/hooks/useDraftNotifications"; import type { NotificationMode } from "~/hooks/useDraftNotifications";
interface NotificationSettingsProps { interface NotificationSettingsProps {
@ -20,42 +22,49 @@ export function NotificationSettings({
permissionState, permissionState,
switchOnly = false, switchOnly = false,
}: NotificationSettingsProps) { }: NotificationSettingsProps) {
const [popoverOpen, setPopoverOpen] = useState(false);
if (permissionState === "unsupported") { if (permissionState === "unsupported") {
return null; return null;
} }
if (switchOnly) { if (switchOnly) {
const handleEnabledChange = (val: boolean) => {
onEnabledChange(val);
setPopoverOpen(val);
};
return ( return (
<> <Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<Switch <PopoverAnchor asChild>
aria-label="Enable notifications" <Switch
checked={enabled} aria-label="Enable push notifications"
onCheckedChange={onEnabledChange} checked={enabled}
disabled={permissionState === "denied"} onCheckedChange={handleEnabledChange}
/> disabled={permissionState === "denied"}
{enabled && ( />
<div className="absolute top-full right-0 mt-2 bg-card border border-border rounded-lg shadow-lg p-3 z-50 min-w-[180px]"> </PopoverAnchor>
<RadioGroup <PopoverContent align="end" className="w-48 p-3">
value={mode} <RadioGroup
onValueChange={(value) => onModeChange(value as NotificationMode)} value={mode}
className="space-y-2" onValueChange={(value) => onModeChange(value as NotificationMode)}
> className="space-y-2"
<div className="flex items-center space-x-2"> >
<RadioGroupItem value="my_turn" id="notif_header_my_turn" /> <div className="flex items-center space-x-2">
<Label htmlFor="notif_header_my_turn" className="text-sm cursor-pointer"> <RadioGroupItem value="my_turn" id="notif_header_my_turn" />
My Turn Only <Label htmlFor="notif_header_my_turn" className="text-sm cursor-pointer">
</Label> My Turn Only
</div> </Label>
<div className="flex items-center space-x-2"> </div>
<RadioGroupItem value="all_picks" id="notif_header_all_picks" /> <div className="flex items-center space-x-2">
<Label htmlFor="notif_header_all_picks" className="text-sm cursor-pointer"> <RadioGroupItem value="all_picks" id="notif_header_all_picks" />
All Picks <Label htmlFor="notif_header_all_picks" className="text-sm cursor-pointer">
</Label> All Picks
</div> </Label>
</RadioGroup> </div>
</div> </RadioGroup>
)} </PopoverContent>
</> </Popover>
); );
} }

View file

@ -7,6 +7,8 @@ const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef< const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>, React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
@ -26,4 +28,4 @@ const PopoverContent = React.forwardRef<
)) ))
PopoverContent.displayName = PopoverPrimitive.Content.displayName PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent } export { Popover, PopoverTrigger, PopoverAnchor, PopoverContent }

View file

@ -2,28 +2,52 @@ import { useFetcher } from "react-router";
import { Switch } from "~/components/ui/switch"; import { Switch } from "~/components/ui/switch";
import { Label } from "~/components/ui/label"; import { Label } from "~/components/ui/label";
type ActionResult = { success?: boolean; error?: string };
function feedbackFromFetcher(fetcher: ReturnType<typeof useFetcher>) {
if (fetcher.state !== "idle" || !fetcher.data) return { success: false, error: undefined };
const data = fetcher.data as ActionResult;
return { success: !!data.success, error: data.error };
}
type Props = { type Props = {
discordPingEnabled: boolean; discordPingEnabled: boolean;
hasDiscordLinked: boolean; hasDiscordLinked: boolean;
success?: boolean; draftEmailNotificationsEnabled: boolean;
error?: string;
onNavigateToAccount: () => void; onNavigateToAccount: () => void;
}; };
export function NotificationsSection({ discordPingEnabled, hasDiscordLinked, success, error, onNavigateToAccount }: Props) { export function NotificationsSection({ discordPingEnabled, hasDiscordLinked, draftEmailNotificationsEnabled, onNavigateToAccount }: Props) {
const fetcher = useFetcher(); const discordFetcher = useFetcher();
const optimisticEnabled = const emailFetcher = useFetcher();
fetcher.state !== "idle"
? fetcher.formData?.get("discordPingEnabled") === "true" const optimisticDiscordEnabled =
discordFetcher.state !== "idle"
? discordFetcher.formData?.get("discordPingEnabled") === "true"
: discordPingEnabled; : discordPingEnabled;
const handleToggle = (checked: boolean) => { const optimisticEmailEnabled =
fetcher.submit( emailFetcher.state !== "idle"
? emailFetcher.formData?.get("draftEmailNotificationsEnabled") === "true"
: draftEmailNotificationsEnabled;
const { success: discordSuccess, error: discordError } = feedbackFromFetcher(discordFetcher);
const { success: emailSuccess } = feedbackFromFetcher(emailFetcher);
const handleDiscordToggle = (checked: boolean) => {
discordFetcher.submit(
{ intent: "update-discord-ping", discordPingEnabled: String(checked) }, { intent: "update-discord-ping", discordPingEnabled: String(checked) },
{ method: "post" } { method: "post" }
); );
}; };
const handleEmailToggle = (checked: boolean) => {
emailFetcher.submit(
{ intent: "update-email-draft-notifications", draftEmailNotificationsEnabled: String(checked) },
{ method: "post" }
);
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
@ -33,6 +57,30 @@ export function NotificationsSection({ discordPingEnabled, hasDiscordLinked, suc
</p> </p>
</div> </div>
<div className="rounded-lg border p-4 space-y-4">
<h3 className="text-sm font-medium">Draft Pick Emails</h3>
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<Label htmlFor="email-draft-switch" className="text-sm font-normal">
Email me when I'm on the clock
</Label>
<p className="text-xs text-muted-foreground">
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.
</p>
</div>
<Switch
id="email-draft-switch"
checked={optimisticEmailEnabled}
onCheckedChange={handleEmailToggle}
disabled={emailFetcher.state !== "idle"}
/>
</div>
{emailSuccess && (
<p className="text-sm text-green-600">Email notification preference saved.</p>
)}
</div>
<div className="rounded-lg border p-4 space-y-4"> <div className="rounded-lg border p-4 space-y-4">
<h3 className="text-sm font-medium">Discord Pings</h3> <h3 className="text-sm font-medium">Discord Pings</h3>
@ -62,16 +110,16 @@ export function NotificationsSection({ discordPingEnabled, hasDiscordLinked, suc
</div> </div>
<Switch <Switch
id="discord-ping-switch" id="discord-ping-switch"
checked={optimisticEnabled} checked={optimisticDiscordEnabled}
onCheckedChange={handleToggle} onCheckedChange={handleDiscordToggle}
disabled={fetcher.state !== "idle"} disabled={discordFetcher.state !== "idle"}
/> />
</div> </div>
{success && ( {discordSuccess && (
<p className="text-sm text-green-600">Notification preference saved.</p> <p className="text-sm text-green-600">Notification preference saved.</p>
)} )}
{error && ( {discordError && (
<p className="text-sm text-destructive">{error}</p> <p className="text-sm text-destructive">{discordError}</p>
)} )}
</> </>
)} )}

View file

@ -8,6 +8,7 @@ import { isParticipantDrafted, getDraftPicksWithSports, getTeamDraftPicksWithSpo
import { getParticipantsForSeasonWithSports } from "./season-participant"; import { getParticipantsForSeasonWithSports } from "./season-participant";
import { getSeasonSportsSimple } from "./season-sport"; import { getSeasonSportsSimple } from "./season-sport";
import { calculateDraftEligibility } from "~/lib/draft-eligibility"; import { calculateDraftEligibility } from "~/lib/draft-eligibility";
import { sendOnTheClockEmail } from "~/services/draft-email.server";
import { getSocketIO, scheduleDraftRoomClosure } from "../../server/socket"; import { getSocketIO, scheduleDraftRoomClosure } from "../../server/socket";
import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server"; import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server";
@ -833,6 +834,11 @@ export async function executeAutoPick(params: {
draftSlots, draftSlots,
db, 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 { return {

View file

@ -11,6 +11,7 @@ import { calculatePickInfo, checkAndTriggerNextAutodraft, pruneIneligibleQueueIt
import { getSocketIO, scheduleDraftRoomClosure } from "../../../server/socket"; import { getSocketIO, scheduleDraftRoomClosure } from "../../../server/socket";
import { logger } from "~/lib/logger"; import { logger } from "~/lib/logger";
import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server"; import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server";
import { sendOnTheClockEmail } from "~/services/draft-email.server";
import type { ActionFunctionArgs } from "react-router"; import type { ActionFunctionArgs } from "react-router";
export async function action(args: ActionFunctionArgs) { export async function action(args: ActionFunctionArgs) {
@ -322,6 +323,11 @@ export async function action(args: ActionFunctionArgs) {
db, 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({ return Response.json({

View file

@ -1328,7 +1328,7 @@ export default function DraftRoom() {
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="hidden md:flex items-center gap-2 relative"> <div className="hidden md:flex items-center gap-2 relative">
<span className="text-xs text-muted-foreground">Pick Notifications</span> <span className="text-xs text-muted-foreground">Push Notifications</span>
<NotificationSettings <NotificationSettings
enabled={notificationsEnabled} enabled={notificationsEnabled}
onEnabledChange={setNotificationsEnabled} onEnabledChange={setNotificationsEnabled}

View file

@ -196,6 +196,7 @@ describe('Team Management - Admin User List', () => {
isAdmin: false, isAdmin: false,
timezone: null, timezone: null,
discordPingEnabled: false, discordPingEnabled: false,
draftEmailNotificationsEnabled: false,
lastDataRequestAt: null, lastDataRequestAt: null,
deletedAt: null, deletedAt: null,
createdAt: new Date(), createdAt: new Date(),
@ -215,6 +216,7 @@ describe('Team Management - Admin User List', () => {
isAdmin: false, isAdmin: false,
timezone: null, timezone: null,
discordPingEnabled: false, discordPingEnabled: false,
draftEmailNotificationsEnabled: false,
lastDataRequestAt: null, lastDataRequestAt: null,
deletedAt: null, deletedAt: null,
createdAt: new Date(), createdAt: new Date(),
@ -234,7 +236,7 @@ describe('Team Management - Admin User List', () => {
it('should only fetch users when user is admin', async () => { it('should only fetch users when user is admin', async () => {
const userId = 'admin-user-id'; const userId = 'admin-user-id';
vi.mocked(isUserAdmin).mockResolvedValue(true); vi.mocked(isUserAdmin).mockResolvedValue(true);
const isAdmin = await isUserAdmin(userId); const isAdmin = await isUserAdmin(userId);
@ -255,6 +257,7 @@ describe('Team Management - Admin User List', () => {
isAdmin: false, isAdmin: false,
timezone: null, timezone: null,
discordPingEnabled: false, discordPingEnabled: false,
draftEmailNotificationsEnabled: false,
lastDataRequestAt: null, lastDataRequestAt: null,
deletedAt: null, deletedAt: null,
createdAt: new Date(), createdAt: new Date(),

View file

@ -29,7 +29,7 @@ type SectionId = "profile" | "account" | "notifications" | "api" | "privacy";
const SECTIONS: readonly SettingsGridSection[] = [ const SECTIONS: readonly SettingsGridSection[] = [
{ id: "profile", label: "Profile", icon: User, subtitle: "Name, avatar, timezone" }, { id: "profile", label: "Profile", icon: User, subtitle: "Name, avatar, timezone" },
{ id: "account", label: "Account", icon: Shield, subtitle: "Email, sign-in methods" }, { id: "account", label: "Account", icon: Shield, subtitle: "Email, sign-in methods" },
{ id: "notifications", label: "Notifications", icon: Bell, subtitle: "Discord pings" }, { id: "notifications", label: "Notifications", icon: Bell, subtitle: "Email & Discord" },
{ id: "api", label: "API Access", icon: Key, subtitle: "Coming soon" }, { id: "api", label: "API Access", icon: Key, subtitle: "Coming soon" },
{ id: "privacy", label: "Data & Privacy", icon: Lock, subtitle: "Export, delete account", isDanger: true }, { id: "privacy", label: "Data & Privacy", icon: Lock, subtitle: "Export, delete account", isDanger: true },
] as const; ] as const;
@ -47,6 +47,8 @@ type ActionData =
| { intent: "update-profile"; error: string } | { intent: "update-profile"; error: string }
| { intent: "update-discord-ping"; success: true } | { intent: "update-discord-ping"; success: true }
| { intent: "update-discord-ping"; error: string } | { intent: "update-discord-ping"; error: string }
| { intent: "update-email-draft-notifications"; success: true }
| { intent: "update-email-draft-notifications"; error: string }
| { intent: "request-data"; dataRequestSuccess: true } | { intent: "request-data"; dataRequestSuccess: true }
| { intent: "request-data"; dataRequestError: string } | { intent: "request-data"; dataRequestError: string }
| { intent: string; error: string }; | { intent: string; error: string };
@ -157,6 +159,12 @@ export async function action(args: Route.ActionArgs): Promise<ActionData | Respo
return { intent: "update-discord-ping", success: true }; return { intent: "update-discord-ping", success: true };
} }
if (intent === "update-email-draft-notifications") {
const enabled = formData.get("draftEmailNotificationsEnabled") === "true";
await updateUser(session.user.id, { draftEmailNotificationsEnabled: enabled });
return { intent: "update-email-draft-notifications", success: true };
}
if (intent === "request-data") { if (intent === "request-data") {
if (user.lastDataRequestAt) { if (user.lastDataRequestAt) {
const cooldownExpires = new Date(user.lastDataRequestAt.getTime() + DATA_REQUEST_COOLDOWN_MS); const cooldownExpires = new Date(user.lastDataRequestAt.getTime() + DATA_REQUEST_COOLDOWN_MS);
@ -270,8 +278,7 @@ export default function SettingsPage({ loaderData, actionData }: Route.Component
<NotificationsSection <NotificationsSection
discordPingEnabled={user.discordPingEnabled} discordPingEnabled={user.discordPingEnabled}
hasDiscordLinked={linkedAccounts.some((a) => a.providerId === "discord")} hasDiscordLinked={linkedAccounts.some((a) => a.providerId === "discord")}
success={ad?.intent === "update-discord-ping" && "success" in ad} draftEmailNotificationsEnabled={user.draftEmailNotificationsEnabled}
error={ad?.intent === "update-discord-ping" && "error" in ad ? ad.error : undefined}
onNavigateToAccount={() => handleSectionChange("account")} onNavigateToAccount={() => handleSectionChange("account")}
/> />
)} )}

View file

@ -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) => `<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);
});
});

View file

@ -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<typeof database>;
}): Promise<void> {
const { seasonId, totalTeams, draftSlots, db } = params;
// Season is fetched first: we need currentPickNumber + draftRounds + leagueId
// all in one round-trip, and the pick number must be read after the autodraft
// chain has committed its updates to the DB.
const season = await db.query.seasons.findFirst({
where: eq(schema.seasons.id, seasonId),
});
if (!season) return;
const currentPickNumber = season.currentPickNumber ?? 1;
const totalPicks = totalTeams * season.draftRounds;
if (currentPickNumber > totalPicks) return;
const { pickInRound, round } = calculatePickInfo(currentPickNumber, totalTeams);
const currentSlot = draftSlots.find((s) => s.draftOrder === pickInRound);
if (!currentSlot) return;
// Parallel: team, autodraft settings, and league are all independent reads
const [team, autodraft, league] = await Promise.all([
db.query.teams.findFirst({ where: eq(schema.teams.id, currentSlot.teamId) }),
db.query.autodraftSettings.findFirst({
where: and(
eq(schema.autodraftSettings.seasonId, seasonId),
eq(schema.autodraftSettings.teamId, currentSlot.teamId)
),
}),
db.query.leagues.findFirst({ where: eq(schema.leagues.id, season.leagueId) }),
]);
// Safety net: skip if autodraft is enabled (pick will be made automatically)
if (autodraft?.isEnabled) return;
if (!team?.ownerId || !league) return;
const ownerId = team.ownerId;
const owner = await db.query.users.findFirst({
where: eq(schema.users.id, ownerId),
});
if (!owner?.email || !owner.draftEmailNotificationsEnabled) return;
// Detect consecutive picks: same team picks the very next slot too
let isConsecutive = false;
if (currentPickNumber + 1 <= totalPicks) {
const { pickInRound: nextPickInRound } = calculatePickInfo(currentPickNumber + 1, totalTeams);
const nextSlot = draftSlots.find((s) => s.draftOrder === nextPickInRound);
if (nextSlot?.teamId === currentSlot.teamId) {
isConsecutive = true;
}
}
const appUrl = process.env.APP_URL ?? "https://brackt.com";
const draftUrl = `${appUrl}/leagues/${season.leagueId}/draft/${seasonId}`;
const leagueName = escapeHtml(league.name);
const teamName = escapeHtml(team.name);
let subject: string;
let preheader: string;
let bodyHtml: string;
if (isConsecutive) {
const { round: round2 } = calculatePickInfo(currentPickNumber + 1, totalTeams);
subject = `You have back-to-back picks in ${league.name}`;
preheader = `${team.name} picks twice in a row — head to the draft room!`;
bodyHtml = `
${emailParagraph(`<strong>You're up twice in a row!</strong>`)}
${emailParagraph(`<strong>${teamName}</strong> has back-to-back picks (Round ${round} and Round ${round2}) in <strong>${leagueName}</strong>. Head to the draft room to make both picks before time runs out.`)}
${emailButton(draftUrl, "Go to Draft Room")}
${emailParagraph(`<span style="font-size:13px;color:rgba(255,255,255,0.6);">You're receiving this because you enabled draft email notifications in your account settings.</span>`)}
`;
} else {
subject = `You're on the clock in ${league.name}`;
preheader = `${team.name} is on the clock — make your pick!`;
bodyHtml = `
${emailParagraph(`<strong>You're on the clock!</strong>`)}
${emailParagraph(`<strong>${teamName}</strong> is now on the clock in Round ${round} of <strong>${leagueName}</strong>. Head to the draft room to make your pick before time runs out.`)}
${emailButton(draftUrl, "Go to Draft Room")}
${emailParagraph(`<span style="font-size:13px;color:rgba(255,255,255,0.6);">You're receiving this because you enabled draft email notifications in your account settings.</span>`)}
`;
}
const { error } = await sendEmail({
to: owner.email,
subject,
html: wrapInEmailTemplate(bodyHtml, preheader),
});
if (error) {
logger.error(`[DraftEmail] Failed to send on-the-clock email to ${owner.email}:`, error);
}
}

View file

@ -15,6 +15,7 @@ export const users = pgTable("users", {
isAdmin: boolean("is_admin").notNull().default(false), isAdmin: boolean("is_admin").notNull().default(false),
timezone: varchar("timezone", { length: 50 }), // IANA timezone string, e.g. "America/Chicago" timezone: varchar("timezone", { length: 50 }), // IANA timezone string, e.g. "America/Chicago"
discordPingEnabled: boolean("discord_ping_enabled").notNull().default(false), discordPingEnabled: boolean("discord_ping_enabled").notNull().default(false),
draftEmailNotificationsEnabled: boolean("draft_email_notifications_enabled").notNull().default(false),
lastDataRequestAt: timestamp("last_data_request_at"), lastDataRequestAt: timestamp("last_data_request_at"),
deletedAt: timestamp("deleted_at"), deletedAt: timestamp("deleted_at"),
createdAt: timestamp("created_at").defaultNow().notNull(), createdAt: timestamp("created_at").defaultNow().notNull(),

View file

@ -0,0 +1 @@
ALTER TABLE "users" ADD COLUMN "draft_email_notifications_enabled" boolean DEFAULT false NOT NULL;

File diff suppressed because it is too large Load diff

View file

@ -778,6 +778,13 @@
"when": 1779256349467, "when": 1779256349467,
"tag": "0110_thankful_nova", "tag": "0110_thankful_nova",
"breakpoints": true "breakpoints": true
},
{
"idx": 111,
"version": "7",
"when": 1779312132517,
"tag": "0111_gorgeous_captain_universe",
"breakpoints": true
} }
] ]
} }