brackt/app/services/draft-email.server.ts

113 lines
4.7 KiB
TypeScript
Raw Permalink Normal View History

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
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);
}
}