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:
parent
7809864674
commit
b8b58ec9f8
14 changed files with 6664 additions and 51 deletions
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
247
app/services/__tests__/draft-email.server.test.ts
Normal file
247
app/services/__tests__/draft-email.server.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
112
app/services/draft-email.server.ts
Normal file
112
app/services/draft-email.server.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
1
drizzle/0111_gorgeous_captain_universe.sql
Normal file
1
drizzle/0111_gorgeous_captain_universe.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE "users" ADD COLUMN "draft_email_notifications_enabled" boolean DEFAULT false NOT NULL;
|
||||||
6164
drizzle/meta/0111_snapshot.json
Normal file
6164
drizzle/meta/0111_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Loading…
Add table
Reference in a new issue