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
This commit is contained in:
parent
7809864674
commit
2ef75a1d44
13 changed files with 6383 additions and 24 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,21 +22,30 @@ 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"}
|
||||||
|
/>
|
||||||
|
</PopoverAnchor>
|
||||||
{enabled && (
|
{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]">
|
<PopoverContent align="end" className="w-48 p-3">
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
value={mode}
|
value={mode}
|
||||||
onValueChange={(value) => onModeChange(value as NotificationMode)}
|
onValueChange={(value) => onModeChange(value as NotificationMode)}
|
||||||
|
|
@ -53,9 +64,9 @@ export function NotificationSettings({
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</div>
|
</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 }
|
||||||
|
|
|
||||||
|
|
@ -5,25 +5,40 @@ import { Label } from "~/components/ui/label";
|
||||||
type Props = {
|
type Props = {
|
||||||
discordPingEnabled: boolean;
|
discordPingEnabled: boolean;
|
||||||
hasDiscordLinked: boolean;
|
hasDiscordLinked: boolean;
|
||||||
|
draftEmailNotificationsEnabled: boolean;
|
||||||
success?: boolean;
|
success?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
onNavigateToAccount: () => void;
|
onNavigateToAccount: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function NotificationsSection({ discordPingEnabled, hasDiscordLinked, success, error, onNavigateToAccount }: Props) {
|
export function NotificationsSection({ discordPingEnabled, hasDiscordLinked, draftEmailNotificationsEnabled, success, error, 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 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 +48,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>
|
||||||
|
{emailFetcher.state === "idle" && emailFetcher.data && (emailFetcher.data as { intent?: string; success?: boolean }).intent === "update-email-draft-notifications" && (emailFetcher.data as { success?: boolean }).success && (
|
||||||
|
<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,9 +101,9 @@ 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 && (
|
{success && (
|
||||||
|
|
|
||||||
|
|
@ -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,12 @@ export async function executeAutoPick(params: {
|
||||||
draftSlots,
|
draftSlots,
|
||||||
db,
|
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 })
|
||||||
|
.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,13 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
db,
|
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;
|
||||||
|
const totalPicks = totalTeams * season.draftRounds;
|
||||||
|
sendOnTheClockEmail({ seasonId, currentPickNumber: finalPickNumber, totalTeams, totalPicks, 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,6 +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")}
|
||||||
|
draftEmailNotificationsEnabled={user.draftEmailNotificationsEnabled}
|
||||||
success={ad?.intent === "update-discord-ping" && "success" in ad}
|
success={ad?.intent === "update-discord-ping" && "success" in ad}
|
||||||
error={ad?.intent === "update-discord-ping" && "error" in ad ? ad.error : undefined}
|
error={ad?.intent === "update-discord-ping" && "error" in ad ? ad.error : undefined}
|
||||||
onNavigateToAccount={() => handleSectionChange("account")}
|
onNavigateToAccount={() => handleSectionChange("account")}
|
||||||
|
|
|
||||||
107
app/services/draft-email.server.ts
Normal file
107
app/services/draft-email.server.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
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 };
|
||||||
|
|
||||||
|
export async function sendOnTheClockEmail(params: {
|
||||||
|
seasonId: string;
|
||||||
|
currentPickNumber: number;
|
||||||
|
totalTeams: number;
|
||||||
|
totalPicks: number;
|
||||||
|
draftSlots: DraftSlot[];
|
||||||
|
db: ReturnType<typeof database>;
|
||||||
|
}): Promise<void> {
|
||||||
|
const { seasonId, currentPickNumber, totalTeams, totalPicks, draftSlots, db } = params;
|
||||||
|
|
||||||
|
if (currentPickNumber > totalPicks) return;
|
||||||
|
|
||||||
|
const { pickInRound } = calculatePickInfo(currentPickNumber, totalTeams);
|
||||||
|
const currentSlot = draftSlots.find((s) => s.draftOrder === pickInRound);
|
||||||
|
if (!currentSlot) return;
|
||||||
|
|
||||||
|
// Fetch team + owner user
|
||||||
|
const team = await db.query.teams.findFirst({
|
||||||
|
where: eq(schema.teams.id, currentSlot.teamId),
|
||||||
|
});
|
||||||
|
if (!team?.ownerId) return;
|
||||||
|
|
||||||
|
const owner = await db.query.users.findFirst({
|
||||||
|
where: eq(schema.users.id, team.ownerId as string),
|
||||||
|
});
|
||||||
|
if (!owner?.email || !owner.draftEmailNotificationsEnabled) return;
|
||||||
|
|
||||||
|
// Safety net: skip if this team has autodraft enabled (their pick will be made automatically)
|
||||||
|
const autodraft = await db.query.autodraftSettings.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(schema.autodraftSettings.seasonId, seasonId),
|
||||||
|
eq(schema.autodraftSettings.teamId, currentSlot.teamId)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (autodraft?.isEnabled) 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch season + league for context
|
||||||
|
const season = await db.query.seasons.findFirst({
|
||||||
|
where: eq(schema.seasons.id, seasonId),
|
||||||
|
});
|
||||||
|
if (!season) return;
|
||||||
|
|
||||||
|
const league = await db.query.leagues.findFirst({
|
||||||
|
where: eq(schema.leagues.id, season.leagueId),
|
||||||
|
});
|
||||||
|
if (!league) return;
|
||||||
|
|
||||||
|
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);
|
||||||
|
const { round } = calculatePickInfo(currentPickNumber, totalTeams);
|
||||||
|
|
||||||
|
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