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 { Label } from "~/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "~/components/ui/radio-group";
|
||||
import { Popover, PopoverAnchor, PopoverContent } from "~/components/ui/popover";
|
||||
import type { NotificationMode } from "~/hooks/useDraftNotifications";
|
||||
|
||||
interface NotificationSettingsProps {
|
||||
|
|
@ -20,21 +22,30 @@ export function NotificationSettings({
|
|||
permissionState,
|
||||
switchOnly = false,
|
||||
}: NotificationSettingsProps) {
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
|
||||
if (permissionState === "unsupported") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (switchOnly) {
|
||||
const handleEnabledChange = (val: boolean) => {
|
||||
onEnabledChange(val);
|
||||
setPopoverOpen(val);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Switch
|
||||
aria-label="Enable notifications"
|
||||
checked={enabled}
|
||||
onCheckedChange={onEnabledChange}
|
||||
disabled={permissionState === "denied"}
|
||||
/>
|
||||
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
|
||||
<PopoverAnchor asChild>
|
||||
<Switch
|
||||
aria-label="Enable push notifications"
|
||||
checked={enabled}
|
||||
onCheckedChange={handleEnabledChange}
|
||||
disabled={permissionState === "denied"}
|
||||
/>
|
||||
</PopoverAnchor>
|
||||
{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
|
||||
value={mode}
|
||||
onValueChange={(value) => onModeChange(value as NotificationMode)}
|
||||
|
|
@ -53,9 +64,9 @@ export function NotificationSettings({
|
|||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
)}
|
||||
</>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ const Popover = PopoverPrimitive.Root
|
|||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverAnchor = PopoverPrimitive.Anchor
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
|
|
@ -26,4 +28,4 @@ const PopoverContent = React.forwardRef<
|
|||
))
|
||||
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 = {
|
||||
discordPingEnabled: boolean;
|
||||
hasDiscordLinked: boolean;
|
||||
draftEmailNotificationsEnabled: boolean;
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
onNavigateToAccount: () => void;
|
||||
};
|
||||
|
||||
export function NotificationsSection({ discordPingEnabled, hasDiscordLinked, success, error, onNavigateToAccount }: Props) {
|
||||
const fetcher = useFetcher();
|
||||
const optimisticEnabled =
|
||||
fetcher.state !== "idle"
|
||||
? fetcher.formData?.get("discordPingEnabled") === "true"
|
||||
export function NotificationsSection({ discordPingEnabled, hasDiscordLinked, draftEmailNotificationsEnabled, success, error, onNavigateToAccount }: Props) {
|
||||
const discordFetcher = useFetcher();
|
||||
const emailFetcher = useFetcher();
|
||||
|
||||
const optimisticDiscordEnabled =
|
||||
discordFetcher.state !== "idle"
|
||||
? discordFetcher.formData?.get("discordPingEnabled") === "true"
|
||||
: discordPingEnabled;
|
||||
|
||||
const handleToggle = (checked: boolean) => {
|
||||
fetcher.submit(
|
||||
const optimisticEmailEnabled =
|
||||
emailFetcher.state !== "idle"
|
||||
? emailFetcher.formData?.get("draftEmailNotificationsEnabled") === "true"
|
||||
: draftEmailNotificationsEnabled;
|
||||
|
||||
const handleDiscordToggle = (checked: boolean) => {
|
||||
discordFetcher.submit(
|
||||
{ intent: "update-discord-ping", discordPingEnabled: String(checked) },
|
||||
{ method: "post" }
|
||||
);
|
||||
};
|
||||
|
||||
const handleEmailToggle = (checked: boolean) => {
|
||||
emailFetcher.submit(
|
||||
{ intent: "update-email-draft-notifications", draftEmailNotificationsEnabled: String(checked) },
|
||||
{ method: "post" }
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
|
|
@ -33,6 +48,30 @@ export function NotificationsSection({ discordPingEnabled, hasDiscordLinked, suc
|
|||
</p>
|
||||
</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">
|
||||
<h3 className="text-sm font-medium">Discord Pings</h3>
|
||||
|
||||
|
|
@ -62,9 +101,9 @@ export function NotificationsSection({ discordPingEnabled, hasDiscordLinked, suc
|
|||
</div>
|
||||
<Switch
|
||||
id="discord-ping-switch"
|
||||
checked={optimisticEnabled}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={fetcher.state !== "idle"}
|
||||
checked={optimisticDiscordEnabled}
|
||||
onCheckedChange={handleDiscordToggle}
|
||||
disabled={discordFetcher.state !== "idle"}
|
||||
/>
|
||||
</div>
|
||||
{success && (
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { isParticipantDrafted, getDraftPicksWithSports, getTeamDraftPicksWithSpo
|
|||
import { getParticipantsForSeasonWithSports } from "./season-participant";
|
||||
import { getSeasonSportsSimple } from "./season-sport";
|
||||
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
|
||||
import { sendOnTheClockEmail } from "~/services/draft-email.server";
|
||||
import { getSocketIO, scheduleDraftRoomClosure } from "../../server/socket";
|
||||
import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server";
|
||||
|
||||
|
|
@ -833,6 +834,12 @@ export async function executeAutoPick(params: {
|
|||
draftSlots,
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { calculatePickInfo, checkAndTriggerNextAutodraft, pruneIneligibleQueueIt
|
|||
import { getSocketIO, scheduleDraftRoomClosure } from "../../../server/socket";
|
||||
import { logger } from "~/lib/logger";
|
||||
import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server";
|
||||
import { sendOnTheClockEmail } from "~/services/draft-email.server";
|
||||
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
export async function action(args: ActionFunctionArgs) {
|
||||
|
|
@ -322,6 +323,13 @@ export async function action(args: ActionFunctionArgs) {
|
|||
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({
|
||||
|
|
|
|||
|
|
@ -1328,7 +1328,7 @@ export default function DraftRoom() {
|
|||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<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
|
||||
enabled={notificationsEnabled}
|
||||
onEnabledChange={setNotificationsEnabled}
|
||||
|
|
|
|||
|
|
@ -196,6 +196,7 @@ describe('Team Management - Admin User List', () => {
|
|||
isAdmin: false,
|
||||
timezone: null,
|
||||
discordPingEnabled: false,
|
||||
draftEmailNotificationsEnabled: false,
|
||||
lastDataRequestAt: null,
|
||||
deletedAt: null,
|
||||
createdAt: new Date(),
|
||||
|
|
@ -215,6 +216,7 @@ describe('Team Management - Admin User List', () => {
|
|||
isAdmin: false,
|
||||
timezone: null,
|
||||
discordPingEnabled: false,
|
||||
draftEmailNotificationsEnabled: false,
|
||||
lastDataRequestAt: null,
|
||||
deletedAt: null,
|
||||
createdAt: new Date(),
|
||||
|
|
@ -234,7 +236,7 @@ describe('Team Management - Admin User List', () => {
|
|||
|
||||
it('should only fetch users when user is admin', async () => {
|
||||
const userId = 'admin-user-id';
|
||||
|
||||
|
||||
vi.mocked(isUserAdmin).mockResolvedValue(true);
|
||||
|
||||
const isAdmin = await isUserAdmin(userId);
|
||||
|
|
@ -255,6 +257,7 @@ describe('Team Management - Admin User List', () => {
|
|||
isAdmin: false,
|
||||
timezone: null,
|
||||
discordPingEnabled: false,
|
||||
draftEmailNotificationsEnabled: false,
|
||||
lastDataRequestAt: null,
|
||||
deletedAt: null,
|
||||
createdAt: new Date(),
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ type SectionId = "profile" | "account" | "notifications" | "api" | "privacy";
|
|||
const SECTIONS: readonly SettingsGridSection[] = [
|
||||
{ id: "profile", label: "Profile", icon: User, subtitle: "Name, avatar, timezone" },
|
||||
{ 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: "privacy", label: "Data & Privacy", icon: Lock, subtitle: "Export, delete account", isDanger: true },
|
||||
] as const;
|
||||
|
|
@ -47,6 +47,8 @@ type ActionData =
|
|||
| { intent: "update-profile"; error: string }
|
||||
| { intent: "update-discord-ping"; success: true }
|
||||
| { 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"; dataRequestError: 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 };
|
||||
}
|
||||
|
||||
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 (user.lastDataRequestAt) {
|
||||
const cooldownExpires = new Date(user.lastDataRequestAt.getTime() + DATA_REQUEST_COOLDOWN_MS);
|
||||
|
|
@ -270,6 +278,7 @@ export default function SettingsPage({ loaderData, actionData }: Route.Component
|
|||
<NotificationsSection
|
||||
discordPingEnabled={user.discordPingEnabled}
|
||||
hasDiscordLinked={linkedAccounts.some((a) => a.providerId === "discord")}
|
||||
draftEmailNotificationsEnabled={user.draftEmailNotificationsEnabled}
|
||||
success={ad?.intent === "update-discord-ping" && "success" in ad}
|
||||
error={ad?.intent === "update-discord-ping" && "error" in ad ? ad.error : undefined}
|
||||
onNavigateToAccount={() => handleSectionChange("account")}
|
||||
|
|
|
|||
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),
|
||||
timezone: varchar("timezone", { length: 50 }), // IANA timezone string, e.g. "America/Chicago"
|
||||
discordPingEnabled: boolean("discord_ping_enabled").notNull().default(false),
|
||||
draftEmailNotificationsEnabled: boolean("draft_email_notifications_enabled").notNull().default(false),
|
||||
lastDataRequestAt: timestamp("last_data_request_at"),
|
||||
deletedAt: timestamp("deleted_at"),
|
||||
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,
|
||||
"tag": "0110_thankful_nova",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 111,
|
||||
"version": "7",
|
||||
"when": 1779312132517,
|
||||
"tag": "0111_gorgeous_captain_universe",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue