brackt/app/routes/settings.tsx
Chris Parsons b8b58ec9f8
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>
2026-05-20 15:35:43 -07:00

298 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { redirect, useSearchParams } from "react-router";
import { useState } from "react";
import { Bell, Key, Lock, Shield, User } from "lucide-react";
import { auth } from "~/lib/auth.server";
import { findUserById, findUserByUsername, isUserInActiveDraft, updateUser, anonymizeUserAccount, USERNAME_RE } from "~/models/user";
import { findLinkedAccountsByUserId } from "~/models/account";
import { findTeamsByOwnerId, removeTeamOwner } from "~/models/team";
import { removeAllCommissionersByUserId } from "~/models/commissioner";
import { parseFlagConfig } from "~/lib/flag-types";
import { deleteCloudinaryImageByUrl } from "~/lib/cloudinary.server";
import { sendEmail, wrapInEmailTemplate, emailParagraph, escapeHtml } from "~/lib/email.server";
import { logger } from "~/lib/logger";
import { TIMEZONE_OPTIONS } from "~/components/league/TimezoneSelect";
import {
SettingsDesktopNav,
SettingsMobileGridNav,
SettingsMobileSectionPill,
type SettingsGridSection,
} from "~/components/league/settings/SettingsNavigation";
import { ProfileSection } from "~/components/user/settings/ProfileSection";
import { AccountSection } from "~/components/user/settings/AccountSection";
import { ApiSection } from "~/components/user/settings/ApiSection";
import { PrivacySection } from "~/components/user/settings/PrivacySection";
import { NotificationsSection } from "~/components/user/settings/NotificationsSection";
import type { Route } from "./+types/settings";
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: "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;
const VALID_TIMEZONES = new Set(TIMEZONE_OPTIONS.flatMap((g) => g.zones.map((z) => z.value)));
const VALID_SECTION_IDS = new Set(SECTIONS.map((s) => s.id as SectionId));
const DATA_REQUEST_COOLDOWN_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
type ActionData =
| { intent: "update-avatar-flag"; success: true }
| { intent: "update-avatar-flag"; error: string }
| { intent: "remove-avatar-photo"; success: true }
| { intent: "update-profile"; success: true }
| { 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 };
export function meta(): Route.MetaDescriptors {
return [{ title: "Settings - Brackt" }];
}
export async function loader(args: Route.LoaderArgs) {
const session = await auth.api.getSession({ headers: args.request.headers });
if (!session) {
return redirect("/login?redirectTo=/settings");
}
const user = await findUserById(session.user.id);
if (!user) {
return redirect("/");
}
const [isInActiveDraft, linkedAccounts] = await Promise.all([
isUserInActiveDraft(session.user.id),
findLinkedAccountsByUserId(session.user.id),
]);
const dataRequestCooldownUntil = user.lastDataRequestAt
? new Date(user.lastDataRequestAt.getTime() + DATA_REQUEST_COOLDOWN_MS)
: null;
return { user, isInActiveDraft, linkedAccounts, dataRequestCooldownUntil };
}
export async function action(args: Route.ActionArgs): Promise<ActionData | Response> {
const session = await auth.api.getSession({ headers: args.request.headers });
if (!session) {
return redirect("/login?redirectTo=/settings");
}
const user = await findUserById(session.user.id);
if (!user) {
return redirect("/");
}
const formData = await args.request.formData();
const intent = formData.get("intent");
if (intent === "update-avatar-flag") {
const rawConfig = formData.get("flagConfig");
if (typeof rawConfig !== "string") {
return { intent: "update-avatar-flag", error: "Flag config is required." };
}
let parsedConfig: unknown;
try {
parsedConfig = JSON.parse(rawConfig);
} catch {
return { intent: "update-avatar-flag", error: "Invalid flag config." };
}
const flagConfig = parseFlagConfig(parsedConfig);
if (!flagConfig) {
return { intent: "update-avatar-flag", error: "Invalid flag config." };
}
await updateUser(session.user.id, { flagConfig, avatarType: "flag" });
return { intent: "update-avatar-flag", success: true };
}
if (intent === "remove-avatar-photo") {
await updateUser(session.user.id, { customAvatarUrl: null, avatarType: "flag" });
if (user.customAvatarUrl) {
deleteCloudinaryImageByUrl(user.customAvatarUrl).catch((err) => {
logger.error("Failed to delete user avatar from Cloudinary:", err);
});
}
return { intent: "remove-avatar-photo", success: true };
}
if (intent === "update-profile") {
const username = (formData.get("username") as string | null)?.trim() ?? "";
const timezone = formData.get("timezone") as string | null;
const timezoneValue = timezone && VALID_TIMEZONES.has(timezone) ? timezone : undefined;
if (username !== "") {
if (!USERNAME_RE.test(username)) {
return { intent: "update-profile", error: "Username must be 330 characters and contain only letters, numbers, underscores, or hyphens." };
}
const existing = await findUserByUsername(username);
if (existing && existing.id !== session.user.id) {
return { intent: "update-profile", error: "That username is already taken." };
}
}
try {
await updateUser(session.user.id, {
...(username !== "" ? { username } : {}),
timezone: timezoneValue,
});
} catch {
return { intent: "update-profile", error: "That username is already taken." };
}
return { intent: "update-profile", success: true };
}
if (intent === "update-discord-ping") {
const enabled = formData.get("discordPingEnabled") === "true";
if (enabled) {
const accounts = await findLinkedAccountsByUserId(session.user.id);
if (!accounts.some((a) => a.providerId === "discord")) {
return { intent: "update-discord-ping", error: "Link your Discord account first." };
}
}
await updateUser(session.user.id, { discordPingEnabled: enabled });
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);
if (cooldownExpires > new Date()) {
return {
intent: "request-data",
dataRequestError: `You already submitted a data request. You may submit another after ${cooldownExpires.toLocaleDateString()}.`,
};
}
}
const notes = (formData.get("dataRequestNotes") as string | null) ?? "";
const html = wrapInEmailTemplate(`
${emailParagraph(`A data export request has been submitted by <strong>${escapeHtml(user.email)}</strong> (user ID: ${escapeHtml(user.id)}).`)}
${notes ? emailParagraph(`Notes: ${escapeHtml(notes)}`) : ""}
${emailParagraph("Please fulfill this request within 30 days per GDPR/CCPA requirements.")}
`);
const { error } = await sendEmail({
to: "privacy@brackt.com",
subject: `Data Request from ${user.email}`,
html,
replyTo: user.email,
});
if (error) {
logger.error("Failed to send data request email:", error);
return { intent: "request-data", dataRequestError: "Failed to submit request. Please email privacy@brackt.com directly." };
}
await updateUser(session.user.id, { lastDataRequestAt: new Date() });
return { intent: "request-data", dataRequestSuccess: true };
}
if (intent === "delete-account") {
const customAvatarUrl = user.customAvatarUrl;
const ownedTeams = await findTeamsByOwnerId(session.user.id);
await Promise.all(ownedTeams.map((team) => removeTeamOwner(team.id)));
await removeAllCommissionersByUserId(session.user.id);
await anonymizeUserAccount(session.user.id);
if (customAvatarUrl) {
deleteCloudinaryImageByUrl(customAvatarUrl).catch((err) => {
logger.error("Failed to delete user avatar from Cloudinary after account deletion:", err);
});
}
return redirect("/");
}
return { intent: intent as string, error: "Unknown action." };
}
export default function SettingsPage({ loaderData, actionData }: Route.ComponentProps) {
const { user, isInActiveDraft, linkedAccounts, dataRequestCooldownUntil } = loaderData;
const [searchParams] = useSearchParams();
const raw = searchParams.get("section");
const initialSection: SectionId = raw && (VALID_SECTION_IDS as Set<string>).has(raw) ? (raw as SectionId) : "profile";
const [activeSection, setActiveSection] = useState<SectionId>(initialSection);
const [mobileView, setMobileView] = useState<"grid" | "section">("grid");
const handleSectionChange = (id: string, mobile = false) => {
setActiveSection(id as SectionId);
if (mobile) setMobileView("section");
};
const ad = actionData as ActionData | undefined;
const profileSuccess = ad?.intent === "update-profile" && "success" in ad;
const profileError = ad?.intent === "update-profile" && "error" in ad ? ad.error : undefined;
const avatarSuccess =
(ad?.intent === "update-avatar-flag" || ad?.intent === "remove-avatar-photo") &&
"success" in ad;
const dataRequestSuccess = ad?.intent === "request-data" && "dataRequestSuccess" in ad;
const dataRequestError =
ad?.intent === "request-data" && "dataRequestError" in ad ? ad.dataRequestError : undefined;
return (
<div className="container mx-auto max-w-5xl px-4 py-6 sm:py-8">
<div className="mb-6">
<h1 className="text-2xl font-bold">Settings</h1>
</div>
{mobileView === "grid" && (
<div className="lg:hidden">
<SettingsMobileGridNav
sections={SECTIONS}
onSectionChange={(id) => handleSectionChange(id, true)}
/>
</div>
)}
{mobileView === "section" && (
<SettingsMobileSectionPill onShowGrid={() => setMobileView("grid")} />
)}
<div className={`grid gap-6 lg:grid-cols-[220px_minmax(0,1fr)] ${mobileView === "grid" ? "hidden lg:grid" : "grid"}`}>
<SettingsDesktopNav
sections={SECTIONS}
activeSection={activeSection}
onSectionChange={(id) => handleSectionChange(id)}
/>
<main className="min-w-0">
{activeSection === "profile" && (
<ProfileSection
user={user}
isInActiveDraft={isInActiveDraft}
success={profileSuccess || avatarSuccess}
error={profileError}
/>
)}
{activeSection === "account" && (
<AccountSection email={user.email} linkedAccounts={linkedAccounts} />
)}
{activeSection === "notifications" && (
<NotificationsSection
discordPingEnabled={user.discordPingEnabled}
hasDiscordLinked={linkedAccounts.some((a) => a.providerId === "discord")}
draftEmailNotificationsEnabled={user.draftEmailNotificationsEnabled}
onNavigateToAccount={() => handleSectionChange("account")}
/>
)}
{activeSection === "api" && <ApiSection />}
{activeSection === "privacy" && (
<PrivacySection
userEmail={user.email}
dataRequestCooldownUntil={dataRequestCooldownUntil}
dataRequestSuccess={dataRequestSuccess}
dataRequestError={dataRequestError}
/>
)}
</main>
</div>
</div>
);
}