diff --git a/.env.example b/.env.example index 371577e..6dcfb1e 100644 --- a/.env.example +++ b/.env.example @@ -8,7 +8,9 @@ DATABASE_URL="" PGBOUNCER= DATABASE_DIRECT_URL= BETTER_AUTH_SECRET="" -BETTER_AUTH_URL="" +# Canonical public app URL used by auth links, OAuth callbacks, and Cloudinary webhook callbacks. +# Examples: http://localhost:5173, https://brackt.com +APP_URL="" GOOGLE_CLIENT_ID="" GOOGLE_CLIENT_SECRET="" DISCORD_CLIENT_ID="" @@ -18,4 +20,9 @@ SENTRY_AUTH_TOKEN="" SQUIGGLE_CONTACT_EMAIL="" RESEND_API_KEY="" TURNSTILE_SITE_KEY=1x00000000000000000000AA -TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA \ No newline at end of file +TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA + +# Cloudinary avatar uploads. API secret must stay server-only. +CLOUDINARY_CLOUD_NAME="" +CLOUDINARY_API_KEY="" +CLOUDINARY_API_SECRET="" diff --git a/app/components/DraftGrid.tsx b/app/components/DraftGrid.tsx index 8eca4c9..f9d338a 100644 --- a/app/components/DraftGrid.tsx +++ b/app/components/DraftGrid.tsx @@ -7,6 +7,7 @@ import { import { DraftPickCell, type CoronaState } from "~/components/draft/DraftPickCell"; import { TeamAvatar } from "~/components/TeamAvatar"; import type { SeasonStatus } from "~/models/season"; +import type { RawFlagConfig } from "~/lib/flag-types"; interface DraftCell { pickNumber: number; @@ -23,6 +24,8 @@ interface DraftGridProps { id: string; name: string; logoUrl?: string | null; + flagConfig?: RawFlagConfig | null; + avatarType?: string | null; }; }>; draftGrid: Array>; @@ -79,6 +82,8 @@ export function DraftGrid({ teamId={slot.team.id} teamName={ownerMap[slot.team.id] || slot.team.name} logoUrl={slot.team.logoUrl} + flagConfig={slot.team.flagConfig} + avatarType={slot.team.avatarType} size="lg" /> diff --git a/app/components/TeamAvatar.stories.tsx b/app/components/TeamAvatar.stories.tsx index eb6cda1..118b432 100644 --- a/app/components/TeamAvatar.stories.tsx +++ b/app/components/TeamAvatar.stories.tsx @@ -43,6 +43,15 @@ export const WithLogo: Story = { }, }; +export const WithFlag: Story = { + args: { + teamId: "team-4", + teamName: "Flag Club", + avatarType: "flag", + flagConfig: { pattern: "nordic-cross", colors: ["#adf661", "#111827", "#ffffff"] }, + }, +}; + export const SingleWordName: Story = { args: { teamId: "team-3", @@ -50,6 +59,29 @@ export const SingleWordName: Story = { }, }; +export const InheritingOwnerImage: Story = { + name: "Inheriting Owner Image (avatarType: owner)", + args: { + teamId: "team-5", + teamName: "Inherited Squad", + avatarType: "owner", + ownerAvatarData: { type: "image", url: "https://placehold.co/48x48/1a1a2e/adf661?text=OW" }, + }, +}; + +export const InheritingOwnerFlag: Story = { + name: "Inheriting Owner Flag (avatarType: owner)", + args: { + teamId: "team-6", + teamName: "Flag Inheritors", + avatarType: "owner", + ownerAvatarData: { + type: "flag", + config: { pattern: "diagonal", colors: ["#adf661", "#111827", "#ffffff"] }, + }, + }, +}; + export const AllSizes: Story = { name: "All Sizes — Side by Side", render: () => ( @@ -66,6 +98,10 @@ export const AllSizes: Story = { lg +
+ + xl +
), }; diff --git a/app/components/TeamAvatar.tsx b/app/components/TeamAvatar.tsx index c7f9615..326d212 100644 --- a/app/components/TeamAvatar.tsx +++ b/app/components/TeamAvatar.tsx @@ -1,25 +1,67 @@ import { avatarColor } from "~/lib/avatar-colors"; +import { resolveTeamAvatarData } from "~/lib/avatar-data"; +import type { AvatarData, RawFlagConfig } from "~/lib/flag-types"; +import { FlagSvg } from "~/components/ui/FlagSvg"; +import { cn } from "~/lib/utils"; +import { cloudinaryAvatarUrl } from "~/lib/cloudinary-url"; -interface TeamAvatarProps { +export interface TeamAvatarProps { teamId: string; teamName: string; logoUrl?: string | null; - size?: "sm" | "md" | "lg"; + flagConfig?: RawFlagConfig | null; + avatarType?: string | null; + ownerAvatarData?: AvatarData | null; + size?: "sm" | "md" | "lg" | "xl"; + className?: string; } const sizeClasses = { sm: "h-6 w-6 text-[10px]", md: "h-9 w-9 text-sm", lg: "h-12 w-12 text-base", + xl: "h-16 w-16 text-xl", }; -export function TeamAvatar({ teamId, teamName, logoUrl, size = "md" }: TeamAvatarProps) { - if (logoUrl) { +const pixelSizes = { + sm: 24, + md: 36, + lg: 48, + xl: 64, +}; + +export function TeamAvatar({ + teamId, + teamName, + logoUrl, + flagConfig, + avatarType, + ownerAvatarData, + size = "md", + className, +}: TeamAvatarProps) { + const avatar = resolveTeamAvatarData( + { id: teamId, logoUrl, flagConfig, avatarType }, + ownerAvatarData + ); + + if (avatar.type === "image") { return ( {teamName} + ); + } + + if (avatar.type === "flag") { + return ( + ); } @@ -37,7 +79,7 @@ export function TeamAvatar({ teamId, teamName, logoUrl, size = "md" }: TeamAvata
{initials} diff --git a/app/components/UserMenu.tsx b/app/components/UserMenu.tsx index f5130ce..d8f3d12 100644 --- a/app/components/UserMenu.tsx +++ b/app/components/UserMenu.tsx @@ -2,20 +2,28 @@ import { Link, useNavigate } from "react-router"; import { authClient } from "~/lib/auth-client"; import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover"; import { Button } from "~/components/ui/button"; +import { UserAvatar } from "~/components/ui/UserAvatar"; +import type { RawFlagConfig } from "~/lib/flag-types"; interface UserMenuProps { + userId: string; name: string | null; email: string; - imageUrl: string | null; + customAvatarUrl?: string | null; + flagConfig?: RawFlagConfig | null; + avatarType?: string | null; } -export function UserMenu({ name, email, imageUrl }: UserMenuProps) { +export function UserMenu({ + userId, + name, + email, + customAvatarUrl, + flagConfig, + avatarType, +}: UserMenuProps) { const navigate = useNavigate(); - const initials = name - ? name.split(" ").map((n) => n[0]).join("").toUpperCase().slice(0, 2) - : email.slice(0, 2).toUpperCase(); - async function handleSignOut() { await authClient.signOut(); navigate("/"); @@ -24,14 +32,15 @@ export function UserMenu({ name, email, imageUrl }: UserMenuProps) { return ( - diff --git a/app/components/__tests__/DraftGrid.test.tsx b/app/components/__tests__/DraftGrid.test.tsx index c5c46d3..cfb4788 100644 --- a/app/components/__tests__/DraftGrid.test.tsx +++ b/app/components/__tests__/DraftGrid.test.tsx @@ -32,8 +32,8 @@ describe('DraftGrid Component', () => { /> ); - expect(screen.getByText('Team 1')).toBeInTheDocument(); - expect(screen.getByText('Team 2')).toBeInTheDocument(); + expect(screen.getByText('Team 1', { selector: 'span' })).toBeInTheDocument(); + expect(screen.getByText('Team 2', { selector: 'span' })).toBeInTheDocument(); }); it('should render draft grid with correct structure', () => { diff --git a/app/components/draft/DraftGridSection.tsx b/app/components/draft/DraftGridSection.tsx index 9fa5a5e..039717c 100644 --- a/app/components/draft/DraftGridSection.tsx +++ b/app/components/draft/DraftGridSection.tsx @@ -1,5 +1,6 @@ import { memo, useMemo, useState } from "react"; import { TeamAvatar } from "~/components/TeamAvatar"; +import type { RawFlagConfig } from "~/lib/flag-types"; import { ContextMenu, ContextMenuContent, @@ -31,6 +32,8 @@ interface DraftGridSectionProps { id: string; name: string; logoUrl?: string | null; + flagConfig?: RawFlagConfig | null; + avatarType?: string | null; }; }>; draftGrid: Array< @@ -116,6 +119,8 @@ export const DraftGridSection = memo(function DraftGridSection({ teamId={slot.team.id} teamName={ownerMap[slot.team.id] || slot.team.name} logoUrl={slot.team.logoUrl} + flagConfig={slot.team.flagConfig} + avatarType={slot.team.avatarType} size="md" />
diff --git a/app/components/draft/DraftSummaryView.tsx b/app/components/draft/DraftSummaryView.tsx index 45eaf50..d5fd7e4 100644 --- a/app/components/draft/DraftSummaryView.tsx +++ b/app/components/draft/DraftSummaryView.tsx @@ -1,9 +1,19 @@ import { memo, useMemo, Fragment } from "react"; import { TeamAvatar } from "~/components/TeamAvatar"; import { type DraftPick, groupPicksByTeamAndSport } from "./picks-utils"; +import type { RawFlagConfig } from "~/lib/flag-types"; interface DraftSummaryViewProps { - draftSlots: Array<{ id: string; team: { id: string; name: string; logoUrl?: string | null } }>; + draftSlots: Array<{ + id: string; + team: { + id: string; + name: string; + logoUrl?: string | null; + flagConfig?: RawFlagConfig | null; + avatarType?: string | null; + }; + }>; ownerMap?: Record; picks: DraftPick[]; sports: Array<{ id: string; name: string }>; @@ -87,6 +97,8 @@ export const DraftSummaryView = memo(function DraftSummaryView({ teamId={slot.team.id} teamName={ownerName || slot.team.name} logoUrl={slot.team.logoUrl} + flagConfig={slot.team.flagConfig} + avatarType={slot.team.avatarType} size="md" />
diff --git a/app/components/navbar.tsx b/app/components/navbar.tsx index 9c940e6..5a37bdb 100644 --- a/app/components/navbar.tsx +++ b/app/components/navbar.tsx @@ -10,6 +10,7 @@ import { import { Button } from "~/components/ui/button"; import { authClient } from "~/lib/auth-client"; import { UserMenu } from "~/components/UserMenu"; +import type { RawFlagConfig } from "~/lib/flag-types"; import { HelpCircle, MenuIcon, Settings } from "lucide-react"; const NAV_LINK_CLASS = @@ -27,20 +28,42 @@ function mobileNavLinkClass({ isActive }: { isActive: boolean }) { interface NavbarProps { isAdmin: boolean; + currentUser: { + id: string; + email: string; + username: string | null; + displayName: string | null; + customAvatarUrl: string | null; + flagConfig: RawFlagConfig | null; + avatarType: string | null; + } | null; } -export function Navbar({ isAdmin }: NavbarProps) { +export function Navbar({ isAdmin, currentUser }: NavbarProps) { const [open, setOpen] = useState(false); const location = useLocation(); const { data: session } = authClient.useSession(); const signInHref = `/login?redirectTo=${encodeURIComponent(location.pathname)}`; - const authSection = session ? ( + const menuUser = currentUser ?? (session ? { + id: session.user.id, + email: session.user.email, + username: null, + displayName: session.user.name ?? null, + customAvatarUrl: null, + flagConfig: null, + avatarType: null, + } : null); + + const authSection = menuUser ? ( ) : ( diff --git a/app/components/ui/AvatarEditor.tsx b/app/components/ui/AvatarEditor.tsx new file mode 100644 index 0000000..70e9271 --- /dev/null +++ b/app/components/ui/AvatarEditor.tsx @@ -0,0 +1,136 @@ +import { useMemo, useState } from "react"; +import { Save, User } from "lucide-react"; +import { getDisplayFlagConfig } from "~/lib/avatar-data"; +import type { AvatarData, FlagConfig, RawFlagConfig } from "~/lib/flag-types"; +import { Button } from "~/components/ui/button"; +import { FlagBuilder } from "~/components/ui/FlagBuilder"; +import { FlagSvg } from "~/components/ui/FlagSvg"; +import { AvatarUploader } from "~/components/ui/AvatarUploader"; +import { cloudinaryAvatarUrl } from "~/lib/cloudinary-url"; +import { cn } from "~/lib/utils"; + +interface AvatarEditorProps { + id: string; + flagConfig?: RawFlagConfig | null; + uploadedPhotoUrl?: string | null; + uploadUrl: string; + isTeam?: boolean; + currentAvatarType?: string | null; + ownerAvatarData?: AvatarData | null; + flagIntent?: string; + removePhotoIntent?: string; + useOwnerAvatarIntent?: string; +} + +type Mode = "flag" | "upload" | "owner"; + +export function AvatarEditor({ + id, + flagConfig, + uploadedPhotoUrl, + uploadUrl, + isTeam = false, + currentAvatarType, + ownerAvatarData, + flagIntent = "update-avatar-flag", + removePhotoIntent = "remove-avatar-photo", + useOwnerAvatarIntent = "use-owner-avatar", +}: AvatarEditorProps) { + const initialFlag = useMemo(() => getDisplayFlagConfig(id, flagConfig), [flagConfig, id]); + const [config, setConfig] = useState(initialFlag); + + const showOwnerTab = isTeam && ownerAvatarData !== null && ownerAvatarData !== undefined; + const initialMode: Mode = + currentAvatarType === "owner" && showOwnerTab ? "owner" : + currentAvatarType === "uploaded" ? "upload" : + "flag"; + const [mode, setMode] = useState(initialMode); + + const tabs = [ + { key: "flag" as const, label: "Flag" }, + { key: "upload" as const, label: "Upload Photo" }, + ...(showOwnerTab ? [{ key: "owner" as const, label: "My Avatar" }] : []), + ]; + + return ( +
+
+ {tabs.map((tab) => ( + + ))} +
+ + {mode === "flag" && ( +
+ + + + + + )} + + {mode === "upload" && ( + + )} + + {mode === "owner" && ownerAvatarData && ( +
+ +
+ +

Your team will use your current profile avatar.

+
+ +
+ )} +
+ ); +} + +function OwnerAvatarPreview({ avatarData }: { avatarData: AvatarData }) { + if (avatarData.type === "image") { + return ( + Your avatar + ); + } + + if (avatarData.type === "flag") { + return ( + + ); + } + + return null; +} diff --git a/app/components/ui/AvatarUploader.tsx b/app/components/ui/AvatarUploader.tsx new file mode 100644 index 0000000..948fc54 --- /dev/null +++ b/app/components/ui/AvatarUploader.tsx @@ -0,0 +1,262 @@ +import { useRef, useState } from "react"; +import { + ReactCrop, + centerCrop, + convertToPixelCrop, + makeAspectCrop, + type Crop, + type PixelCrop, +} from "react-image-crop"; +import "react-image-crop/dist/ReactCrop.css"; +import { ImageIcon, Trash2, Upload } from "lucide-react"; +import { Button } from "~/components/ui/button"; +import { cn } from "~/lib/utils"; + +interface AvatarUploaderProps { + uploadUrl: string; + teamId?: string; + savedPhotoUrl?: string | null; + removePhotoIntent?: string; +} + +const ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"]; + +function centerSquareCrop(width: number, height: number): Crop { + return centerCrop( + makeAspectCrop({ unit: "%", width: 85 }, 1, width, height), + width, + height + ); +} + +async function cropImageToBlob(image: HTMLImageElement, crop: PixelCrop): Promise { + const canvas = document.createElement("canvas"); + const scaleX = image.naturalWidth / image.width; + const scaleY = image.naturalHeight / image.height; + canvas.width = 256; + canvas.height = 256; + const ctx = canvas.getContext("2d"); + + if (!ctx) { + throw new Error("Canvas is not available"); + } + + ctx.drawImage( + image, + crop.x * scaleX, + crop.y * scaleY, + crop.width * scaleX, + crop.height * scaleY, + 0, + 0, + 256, + 256 + ); + + return await new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (blob) resolve(blob); + else reject(new Error("Could not prepare image")); + }, "image/jpeg", 0.9); + }); +} + +export function AvatarUploader({ + uploadUrl, + teamId, + savedPhotoUrl, + removePhotoIntent = "remove-avatar-photo", +}: AvatarUploaderProps) { + const imageRef = useRef(null); + const fileInputRef = useRef(null); + const [imageSrc, setImageSrc] = useState(null); + const [shouldRemoveSavedPhoto, setShouldRemoveSavedPhoto] = useState(false); + const [fileName, setFileName] = useState("avatar.jpg"); + const [crop, setCrop] = useState(); + const [completedCrop, setCompletedCrop] = useState(); + const [status, setStatus] = useState<"idle" | "submitting" | "pending" | "error">("idle"); + const [error, setError] = useState(null); + const [isDragging, setIsDragging] = useState(false); + + function prepareFile(file: File | undefined) { + setError(null); + setStatus("idle"); + setShouldRemoveSavedPhoto(false); + + if (!file) return; + if (!ACCEPTED_TYPES.includes(file.type)) { + setError("Choose a JPG, PNG, WebP, or GIF image."); + return; + } + if (file.size > 5 * 1024 * 1024) { + setError("Choose an image under 5 MB."); + return; + } + + setFileName(file.name); + const reader = new FileReader(); + reader.addEventListener("load", () => setImageSrc(String(reader.result))); + reader.readAsDataURL(file); + } + + function handleFileChange(event: React.ChangeEvent) { + prepareFile(event.target.files?.[0]); + event.target.value = ""; + } + + function clearImage() { + setImageSrc(null); + setFileName("avatar.jpg"); + setCrop(undefined); + setCompletedCrop(undefined); + setError(null); + setStatus("idle"); + } + + function handleRemoveSavedPhoto() { + setShouldRemoveSavedPhoto(true); + clearImage(); + } + + function handleDrop(event: React.DragEvent) { + event.preventDefault(); + setIsDragging(false); + prepareFile(event.dataTransfer.files[0]); + } + + async function handleSubmit() { + const image = imageRef.current; + const cropToUse = + completedCrop ?? + (image && crop ? convertToPixelCrop(crop, image.width, image.height) : undefined); + + if (!image || !cropToUse) { + setError("Choose and crop an image first."); + return; + } + + setStatus("submitting"); + setError(null); + + try { + const blob = await cropImageToBlob(image, cropToUse); + const formData = new FormData(); + formData.append("file", blob, fileName.replace(/\.[^.]+$/, ".jpg")); + if (teamId) formData.append("teamId", teamId); + + const response = await fetch(uploadUrl, { method: "POST", body: formData }); + const result = await response.json().catch(() => ({})); + + if (!response.ok) { + throw new Error(typeof result.error === "string" ? result.error : "Upload failed."); + } + + setStatus("pending"); + } catch (err) { + setStatus("error"); + setError(err instanceof Error ? err.message : "Upload failed."); + } + } + + return ( +
+ + + {savedPhotoUrl && !shouldRemoveSavedPhoto && !imageSrc && ( +
+
+ Current avatar +
+ +
+ )} + + {(!savedPhotoUrl || shouldRemoveSavedPhoto) && !imageSrc && ( + + )} + + {imageSrc && ( + <> +
+ setCrop(percentCrop)} + onComplete={(pixelCrop) => setCompletedCrop(pixelCrop)} + aspect={1} + > + { + const { width, height } = event.currentTarget; + setCrop(centerSquareCrop(width, height)); + }} + className="max-h-[360px]" + /> + +
+ + + + )} + + {status === "pending" && ( +

Photo submitted for review. It will appear after approval.

+ )} + {error &&

{error}

} + + {shouldRemoveSavedPhoto && !imageSrc ? ( +
+ + +
+ ) : ( + + )} +
+ ); +} diff --git a/app/components/ui/FlagBuilder.stories.tsx b/app/components/ui/FlagBuilder.stories.tsx new file mode 100644 index 0000000..5070ac3 --- /dev/null +++ b/app/components/ui/FlagBuilder.stories.tsx @@ -0,0 +1,23 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { generateFlagConfig } from "~/lib/flag-generator"; +import type { FlagConfig } from "~/lib/flag-types"; +import { FlagBuilder } from "./FlagBuilder"; + +const meta: Meta = { + title: "UI/FlagBuilder", + component: FlagBuilder, + parameters: { layout: "padded" }, +}; + +export default meta; +type Story = StoryObj; + +function FlagBuilderStory() { + const [config, setConfig] = useState(generateFlagConfig("story-user")); + return ; +} + +export const Default: Story = { + render: () => , +}; diff --git a/app/components/ui/FlagBuilder.tsx b/app/components/ui/FlagBuilder.tsx new file mode 100644 index 0000000..f1f3935 --- /dev/null +++ b/app/components/ui/FlagBuilder.tsx @@ -0,0 +1,183 @@ +import { useMemo, useState } from "react"; +import { Check } from "lucide-react"; +import { AVATAR_COLORS } from "~/lib/avatar-colors"; +import { generateFlagConfig } from "~/lib/flag-generator"; +import { FLAG_PATTERNS, type FlagConfig, type FlagPattern } from "~/lib/flag-types"; +import { cn } from "~/lib/utils"; +import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; +import { FlagSvg } from "~/components/ui/FlagSvg"; + +interface FlagBuilderProps { + value?: FlagConfig | null; + seed: string; + onChange: (config: FlagConfig) => void; +} + +const SWATCHES = [ + ...AVATAR_COLORS, + "#22c55e", + "#14b8a6", + "#06b6d4", + "#0ea5e9", + "#6366f1", + "#a855f7", + "#d946ef", + "#ec4899", + "#f43f5e", + "#dc2626", + "#f97316", + "#eab308", + "#84cc16", + "#ffffff", + "#d1d5db", + "#6b7280", + "#111827", + "#000000", +]; +const COLOR_SLOT_KEYS = ["primary", "secondary", "accent"]; +const FALLBACK_COLORS = ["#adf661", "#111827", "#ffffff"]; +const HEX_COLOR_RE = /^#[0-9a-fA-F]{6}$/; + +function normalizeColors(colors: string[]): string[] { + return [ + colors[0] ?? FALLBACK_COLORS[0], + colors[1] ?? FALLBACK_COLORS[1], + colors[2] ?? FALLBACK_COLORS[2], + ]; +} + +export function FlagBuilder({ value: configValue, seed, onChange }: FlagBuilderProps) { + const initial = useMemo(() => configValue ?? generateFlagConfig(seed), [seed, configValue]); + const [customInputs, setCustomInputs] = useState(() => normalizeColors(initial.colors)); + const [selectedColorIndex, setSelectedColorIndex] = useState(0); + const colors = normalizeColors(initial.colors); + const config = { ...initial, colors }; + const colorSlots = colors.map((color, index) => ({ + key: COLOR_SLOT_KEYS[index], + label: `Color ${index + 1}`, + color, + index, + })); + const activeColorIndex = Math.min(selectedColorIndex, colors.length - 1); + const activeColor = colors[activeColorIndex]; + const customColor = customInputs[activeColorIndex] ?? activeColor; + const isValidCustomColor = HEX_COLOR_RE.test(customColor); + + function updatePattern(pattern: FlagPattern) { + onChange({ ...config, pattern }); + } + + function updateColor(index: number, color: string) { + const nextColors = [...colors]; + nextColors[index] = color; + onChange({ ...config, colors: nextColors }); + } + + return ( +
+
+ {FLAG_PATTERNS.map((pattern) => { + const selected = config.pattern === pattern; + return ( + + ); + })} +
+ +
+
+ {colorSlots.map((slot) => ( + + ))} +
+ +
+ +
+ {SWATCHES.map((swatch) => ( +
+
+
+
+ ); +} diff --git a/app/components/ui/FlagSvg.stories.tsx b/app/components/ui/FlagSvg.stories.tsx new file mode 100644 index 0000000..2d3be6a --- /dev/null +++ b/app/components/ui/FlagSvg.stories.tsx @@ -0,0 +1,29 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FLAG_PATTERNS } from "~/lib/flag-types"; +import { FlagSvg } from "./FlagSvg"; + +const meta: Meta = { + title: "UI/FlagSvg", + component: FlagSvg, + parameters: { layout: "padded" }, +}; + +export default meta; +type Story = StoryObj; + +export const AllPatterns: Story = { + render: () => ( +
+ {FLAG_PATTERNS.map((pattern) => ( +
+ +

{pattern}

+
+ ))} +
+ ), +}; diff --git a/app/components/ui/FlagSvg.tsx b/app/components/ui/FlagSvg.tsx new file mode 100644 index 0000000..d47b92b --- /dev/null +++ b/app/components/ui/FlagSvg.tsx @@ -0,0 +1,127 @@ +import { cn } from "~/lib/utils"; +import { getDisplayFlagConfig } from "~/lib/avatar-data"; +import type { FlagConfig } from "~/lib/flag-types"; + +interface FlagSvgProps { + config: FlagConfig; + size?: number; + className?: string; + title?: string; +} + +function colorAt(colors: string[], index: number): string { + return colors[index % colors.length] ?? "#adf661"; +} + +export function FlagSvg({ config, size = 48, className, title }: FlagSvgProps) { + const safeConfig = getDisplayFlagConfig(`${config.pattern}:${config.colors.join("-")}`, config); + const colors = safeConfig.colors; + const c0 = colorAt(colors, 0); + const c1 = colorAt(colors, 1); + const c2 = colorAt(colors, 2); + + return ( + + {title ? {title} : null} + {safeConfig.pattern === "triband-h" && ( + <> + + + + + )} + {safeConfig.pattern === "triband-v" && ( + <> + + + + + )} + {safeConfig.pattern === "diagonal" && ( + <> + + + + + )} + {safeConfig.pattern === "nordic-cross" && ( + <> + + + + + + + )} + {safeConfig.pattern === "cross" && ( + <> + + + + + + + )} + {safeConfig.pattern === "canton" && ( + <> + + + + + )} + {safeConfig.pattern === "triangle" && ( + <> + + + + + )} + {safeConfig.pattern === "chevron" && ( + <> + + + + + )} + {safeConfig.pattern === "quartered" && ( + <> + + + + + + )} + {safeConfig.pattern === "bordered" && ( + <> + + + + + )} + {safeConfig.pattern === "circle" && ( + <> + + + + + )} + {safeConfig.pattern === "star" && ( + <> + + + + + )} + + ); +} diff --git a/app/components/ui/UserAvatar.stories.tsx b/app/components/ui/UserAvatar.stories.tsx new file mode 100644 index 0000000..33a489c --- /dev/null +++ b/app/components/ui/UserAvatar.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { UserAvatar } from "./UserAvatar"; + +const meta: Meta = { + title: "UI/UserAvatar", + component: UserAvatar, + parameters: { layout: "padded" }, +}; + +export default meta; +type Story = StoryObj; + +export const Flag: Story = { + args: { + userId: "user-1", + name: "Chris Example", + email: "chris@example.com", + avatarType: "flag", + }, +}; + +export const Uploaded: Story = { + args: { + userId: "user-2", + name: "Sam Example", + email: "sam@example.com", + avatarType: "uploaded", + customAvatarUrl: "https://placehold.co/96x96/111827/adf661?text=SE", + }, +}; + +export const AllSizes: Story = { + render: () => ( +
+ {(["xs", "sm", "md", "lg", "xl"] as const).map((size) => ( +
+ + {size} +
+ ))} +
+ ), +}; diff --git a/app/components/ui/UserAvatar.tsx b/app/components/ui/UserAvatar.tsx new file mode 100644 index 0000000..0aec776 --- /dev/null +++ b/app/components/ui/UserAvatar.tsx @@ -0,0 +1,65 @@ +import { resolveUserAvatarData } from "~/lib/avatar-data"; +import { cn } from "~/lib/utils"; +import { FlagSvg } from "~/components/ui/FlagSvg"; +import type { RawFlagConfig } from "~/lib/flag-types"; +import { cloudinaryAvatarUrl } from "~/lib/cloudinary-url"; + +interface UserAvatarProps { + userId: string; + name?: string | null; + email?: string | null; + customAvatarUrl?: string | null; + flagConfig?: RawFlagConfig | null; + avatarType?: string | null; + size?: "xs" | "sm" | "md" | "lg" | "xl"; + className?: string; +} + +const sizeClasses = { + xs: "h-5 w-5 text-[9px]", + sm: "h-6 w-6 text-[10px]", + md: "h-9 w-9 text-sm", + lg: "h-12 w-12 text-base", + xl: "h-16 w-16 text-xl", +}; + +const pixelSizes = { + xs: 20, + sm: 24, + md: 36, + lg: 48, + xl: 64, +}; + +export function UserAvatar({ + userId, + name, + email, + customAvatarUrl, + flagConfig, + avatarType, + size = "md", + className, +}: UserAvatarProps) { + const avatar = resolveUserAvatarData({ id: userId, customAvatarUrl, flagConfig, avatarType }); + const label = name ?? email ?? "User avatar"; + + if (avatar.type === "image") { + return ( + {label} + ); + } + + return ( + + ); +} diff --git a/app/lib/__tests__/cloudinary-url.test.ts b/app/lib/__tests__/cloudinary-url.test.ts new file mode 100644 index 0000000..0582d79 --- /dev/null +++ b/app/lib/__tests__/cloudinary-url.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { cloudinaryAvatarUrl } from "~/lib/cloudinary-url"; + +const BASE = "https://res.cloudinary.com/demo/image/upload/v1/avatars/user-1.jpg"; + +describe("cloudinaryAvatarUrl", () => { + it("inserts retina transformation into a Cloudinary URL", () => { + expect(cloudinaryAvatarUrl(BASE, 48)).toBe( + "https://res.cloudinary.com/demo/image/upload/w_96,h_96,c_fill,g_face,f_auto,q_auto/v1/avatars/user-1.jpg" + ); + }); + + it("doubles the display size for retina", () => { + const result = cloudinaryAvatarUrl(BASE, 24); + expect(result).toContain("w_48,h_48"); + }); + + it("passes non-Cloudinary URLs through unchanged", () => { + const url = "https://lh3.googleusercontent.com/photo.jpg"; + expect(cloudinaryAvatarUrl(url, 48)).toBe(url); + }); + + it("passes null-ish strings through unchanged", () => { + expect(cloudinaryAvatarUrl("", 48)).toBe(""); + }); + + it("is idempotent — calling twice returns the same URL", () => { + const once = cloudinaryAvatarUrl(BASE, 48); + expect(cloudinaryAvatarUrl(once, 48)).toBe(once); + }); +}); diff --git a/app/lib/__tests__/cloudinary.server.test.ts b/app/lib/__tests__/cloudinary.server.test.ts new file mode 100644 index 0000000..3b05871 --- /dev/null +++ b/app/lib/__tests__/cloudinary.server.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { cloudinaryPublicIdFromUrl } from "~/lib/cloudinary.server"; + +describe("cloudinaryPublicIdFromUrl", () => { + it("extracts public IDs from regular delivery URLs", () => { + expect( + cloudinaryPublicIdFromUrl("https://res.cloudinary.com/demo/image/upload/v1234567890/avatars/user-1.jpg") + ).toBe("avatars/user-1"); + }); + + it("extracts public IDs from transformed delivery URLs", () => { + expect( + cloudinaryPublicIdFromUrl("https://res.cloudinary.com/demo/image/upload/w_45,h_45,c_fill/v1234567890/avatars/team-2.png") + ).toBe("avatars/team-2"); + }); + + it("returns null for non-Cloudinary-like URLs", () => { + expect(cloudinaryPublicIdFromUrl("https://example.com/avatar.jpg")).toBeNull(); + }); +}); diff --git a/app/lib/__tests__/flag-generator.test.ts b/app/lib/__tests__/flag-generator.test.ts new file mode 100644 index 0000000..b43f0c2 --- /dev/null +++ b/app/lib/__tests__/flag-generator.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { resolveTeamAvatarData, resolveUserAvatarData } from "~/lib/avatar-data"; +import { generateFlagConfig } from "~/lib/flag-generator"; +import { FLAG_PATTERNS, isFlagConfig } from "~/lib/flag-types"; + +describe("generateFlagConfig", () => { + it("generates the same flag for the same seed", () => { + expect(generateFlagConfig("user-1")).toEqual(generateFlagConfig("user-1")); + }); + + it("generates valid configs", () => { + const config = generateFlagConfig("team-9"); + expect(FLAG_PATTERNS).toContain(config.pattern); + expect(config.colors).toHaveLength(3); + expect(isFlagConfig(config)).toBe(true); + }); +}); + +describe("avatar data resolution", () => { + it("returns uploaded avatar when avatarType is uploaded", () => { + expect(resolveUserAvatarData({ + id: "user-1", + avatarType: "uploaded", + customAvatarUrl: "https://example.com/avatar.jpg", + flagConfig: generateFlagConfig("user-1"), + })).toEqual({ type: "image", url: "https://example.com/avatar.jpg" }); + }); + + it("returns flag when avatarType is uploaded but no customAvatarUrl", () => { + const avatar = resolveUserAvatarData({ id: "user-1", avatarType: "uploaded" }); + expect(avatar.type).toBe("flag"); + }); + + it("returns flag regardless of oauth imageUrl being present", () => { + const avatar = resolveUserAvatarData({ id: "user-2", avatarType: "flag" }); + expect(avatar.type).toBe("flag"); + if (avatar.type === "flag") { + expect(avatar.config).toEqual(generateFlagConfig("user-2")); + } + }); + + it("returns generated flag for legacy users with null avatarType", () => { + const avatar = resolveUserAvatarData({ id: "user-3", avatarType: null }); + expect(avatar.type).toBe("flag"); + }); + + it("falls back from teams to owner avatars", () => { + const ownerAvatar = { type: "image" as const, url: "https://example.com/owner.jpg" }; + expect(resolveTeamAvatarData({ id: "team-1", avatarType: "owner" }, ownerAvatar)).toEqual(ownerAvatar); + }); + + it("prefers uploaded team logos over owner avatars", () => { + expect(resolveTeamAvatarData( + { id: "team-1", avatarType: "uploaded", logoUrl: "https://example.com/team.jpg" }, + { type: "image", url: "https://example.com/owner.jpg" } + )).toEqual({ type: "image", url: "https://example.com/team.jpg" }); + }); +}); diff --git a/app/lib/auth.server.ts b/app/lib/auth.server.ts index 544db57..577bdf3 100644 --- a/app/lib/auth.server.ts +++ b/app/lib/auth.server.ts @@ -1,8 +1,10 @@ import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { eq } from "drizzle-orm"; import bcrypt from "bcrypt"; import { Resend } from "resend"; import { db } from "~/server/db"; +import * as schema from "~/database/schema"; if (!process.env.BETTER_AUTH_SECRET) { throw new Error("BETTER_AUTH_SECRET is required"); @@ -18,11 +20,38 @@ const discordProvider = process.env.DISCORD_CLIENT_ID && process.env.DISCORD_CLI ? { discord: { clientId: process.env.DISCORD_CLIENT_ID, clientSecret: process.env.DISCORD_CLIENT_SECRET } } : {}; +const appBaseUrl = process.env.APP_URL ?? process.env.BETTER_AUTH_URL; + export const auth = betterAuth({ + baseURL: appBaseUrl, database: drizzleAdapter(db, { provider: "pg", usePlural: true, }), + databaseHooks: { + user: { + update: { + before: async (data) => { + const incomingImage = + "imageUrl" in data ? data.imageUrl : "image" in data ? data.image : undefined; + const userId = typeof data.id === "string" ? data.id : null; + + if (!incomingImage || !userId) return; + + const existing = await db.query.users.findFirst({ + where: eq(schema.users.id, userId), + }); + + if (existing?.avatarType === "uploaded" || existing?.avatarType === "flag") { + const next = { ...data }; + delete next.imageUrl; + delete next.image; + return { data: next }; + } + }, + }, + }, + }, advanced: { database: { generateId: false, diff --git a/app/lib/avatar-data.ts b/app/lib/avatar-data.ts new file mode 100644 index 0000000..7e176ae --- /dev/null +++ b/app/lib/avatar-data.ts @@ -0,0 +1,48 @@ +import { generateFlagConfig } from "~/lib/flag-generator"; +import { + parseFlagConfig, + type AvatarData, + type FlagConfig, + type RawFlagConfig, +} from "~/lib/flag-types"; + +interface UserAvatarSource { + id: string; + customAvatarUrl?: string | null; + avatarType?: string | null; + flagConfig?: RawFlagConfig | null; +} + +interface TeamAvatarSource { + id: string; + logoUrl?: string | null; + avatarType?: string | null; + flagConfig?: RawFlagConfig | null; +} + +export function getDisplayFlagConfig(id: string, flagConfig: RawFlagConfig | null | undefined): FlagConfig { + return parseFlagConfig(flagConfig) ?? generateFlagConfig(id); +} + +export function resolveUserAvatarData(user: UserAvatarSource): AvatarData { + if (user.avatarType === "uploaded" && user.customAvatarUrl) { + return { type: "image", url: user.customAvatarUrl }; + } + + return { type: "flag", config: getDisplayFlagConfig(user.id, user.flagConfig) }; +} + +export function resolveTeamAvatarData( + team: TeamAvatarSource, + ownerAvatarData?: AvatarData | null +): AvatarData { + if (team.avatarType === "uploaded" && team.logoUrl) { + return { type: "image", url: team.logoUrl }; + } + + if (team.avatarType === "flag") { + return { type: "flag", config: getDisplayFlagConfig(team.id, team.flagConfig) }; + } + + return ownerAvatarData ?? { type: "flag", config: getDisplayFlagConfig(team.id, team.flagConfig) }; +} diff --git a/app/lib/cloudinary-url.ts b/app/lib/cloudinary-url.ts new file mode 100644 index 0000000..7ea36fc --- /dev/null +++ b/app/lib/cloudinary-url.ts @@ -0,0 +1,19 @@ +const CLOUDINARY_UPLOAD_SEGMENT = "/image/upload/"; + +function isCloudinaryUrl(url: string): boolean { + return url.includes("res.cloudinary.com") && url.includes(CLOUDINARY_UPLOAD_SEGMENT); +} + +/** + * Returns a resized, format- and quality-optimized variant of a Cloudinary avatar URL. + * Doubles the requested pixel size for retina displays. + * Non-Cloudinary URLs are returned unchanged. + */ +export function cloudinaryAvatarUrl(url: string, displaySizePx: number): string { + if (!isCloudinaryUrl(url) || url.includes(",c_fill,")) return url; + + const retinaSizePx = displaySizePx * 2; + const transform = `w_${retinaSizePx},h_${retinaSizePx},c_fill,g_face,f_auto,q_auto`; + + return url.replace(CLOUDINARY_UPLOAD_SEGMENT, `${CLOUDINARY_UPLOAD_SEGMENT}${transform}/`); +} diff --git a/app/lib/cloudinary.server.ts b/app/lib/cloudinary.server.ts new file mode 100644 index 0000000..b650ecc --- /dev/null +++ b/app/lib/cloudinary.server.ts @@ -0,0 +1,93 @@ +import { v2 as cloudinary } from "cloudinary"; + +type AvatarTarget = "user" | "team"; + +interface UploadAvatarImageArgs { + buffer: Buffer; + contentType: string; + target: AvatarTarget; + entityId: string; + notificationUrl: string; +} + +function requireCloudinaryConfig() { + const cloudName = process.env.CLOUDINARY_CLOUD_NAME; + const apiKey = process.env.CLOUDINARY_API_KEY; + const apiSecret = process.env.CLOUDINARY_API_SECRET; + + if (!cloudName || !apiKey || !apiSecret) { + throw new Error("Cloudinary environment variables are not configured"); + } + + cloudinary.config({ + cloud_name: cloudName, + api_key: apiKey, + api_secret: apiSecret, + secure: true, + }); +} + +function toDataUri(buffer: Buffer, contentType: string): string { + return `data:${contentType};base64,${buffer.toString("base64")}`; +} + +export async function uploadAvatarImage({ + buffer, + contentType, + target, + entityId, + notificationUrl, +}: UploadAvatarImageArgs) { + requireCloudinaryConfig(); + + return await cloudinary.uploader.upload(toDataUri(buffer, contentType), { + resource_type: "image", + folder: "avatars", + public_id: `${target}-${entityId}-${Date.now()}`, + overwrite: true, + moderation: "aws_rek", + notification_url: notificationUrl, + context: { + avatar_target: target, + entity_id: entityId, + }, + transformation: [{ width: 256, height: 256, crop: "fill", gravity: "face" }], + }); +} + +export function cloudinaryPublicIdFromUrl(url: string | null | undefined): string | null { + if (!url) return null; + const match = url.match(/\/avatars\/([^/.]+)/); + return match ? `avatars/${match[1]}` : null; +} + +export function buildWebhookUrl(request: Request): string { + const baseUrl = process.env.APP_URL ?? process.env.BETTER_AUTH_URL ?? new URL(request.url).origin; + return new URL("/api/webhooks/cloudinary", baseUrl).toString(); +} + +export async function deleteCloudinaryImageByUrl(url: string | null | undefined): Promise { + const publicId = cloudinaryPublicIdFromUrl(url); + if (!publicId) return; + + requireCloudinaryConfig(); + await cloudinary.uploader.destroy(publicId, { resource_type: "image", invalidate: true }); +} + +export function verifyCloudinaryWebhookSignature({ + body, + signature, + timestamp, +}: { + body: string; + signature: string | null; + timestamp: string | null; +}): boolean { + requireCloudinaryConfig(); + + if (!signature || !timestamp) return false; + const parsedTimestamp = Number(timestamp); + if (!Number.isFinite(parsedTimestamp)) return false; + + return cloudinary.utils.verifyNotificationSignature(body, parsedTimestamp, signature); +} diff --git a/app/lib/flag-generator.ts b/app/lib/flag-generator.ts new file mode 100644 index 0000000..09c58df --- /dev/null +++ b/app/lib/flag-generator.ts @@ -0,0 +1,28 @@ +import { AVATAR_COLORS, hashString } from "~/lib/avatar-colors"; +import { FLAG_PATTERNS, type FlagConfig } from "~/lib/flag-types"; + +const EXTRA_COLORS = ["#ffffff", "#111827"]; +const FLAG_COLORS = [...AVATAR_COLORS, ...EXTRA_COLORS]; + +function pickColor(seed: number, offset: number, used: Set): string { + for (let i = 0; i < FLAG_COLORS.length; i++) { + const color = FLAG_COLORS[(seed + offset + i * 3) % FLAG_COLORS.length]; + if (!used.has(color)) return color; + } + return FLAG_COLORS[(seed + offset) % FLAG_COLORS.length]; +} + +export function generateFlagConfig(seed: string): FlagConfig { + const hash = hashString(seed || "brackt"); + const pattern = FLAG_PATTERNS[hash % FLAG_PATTERNS.length]; + const used = new Set(); + const colors: string[] = []; + + for (let i = 0; i < 3; i++) { + const color = pickColor(hash, i * 7 + Math.floor(hash / (i + 1)), used); + used.add(color); + colors.push(color); + } + + return { pattern, colors }; +} diff --git a/app/lib/flag-types.ts b/app/lib/flag-types.ts new file mode 100644 index 0000000..b1c8ff2 --- /dev/null +++ b/app/lib/flag-types.ts @@ -0,0 +1,52 @@ +export type RawFlagConfig = { pattern: string; colors: string[] }; + +export const FLAG_PATTERNS = [ + "triband-h", + "triband-v", + "diagonal", + "nordic-cross", + "cross", + "canton", + "triangle", + "chevron", + "quartered", + "bordered", + "circle", + "star", +] as const; + +export type FlagPattern = (typeof FLAG_PATTERNS)[number]; + +export interface FlagConfig { + pattern: FlagPattern; + colors: string[]; +} + +export type UserAvatarType = "flag" | "uploaded"; +export type TeamAvatarType = "owner" | "flag" | "uploaded"; + +export type AvatarData = + | { type: "image"; url: string } + | { type: "flag"; config: FlagConfig }; + +const FLAG_PATTERN_SET = new Set(FLAG_PATTERNS); +const HEX_COLOR_RE = /^#[0-9a-fA-F]{6}$/; + +export function isFlagPattern(value: unknown): value is FlagPattern { + return typeof value === "string" && FLAG_PATTERN_SET.has(value); +} + +export function isFlagConfig(value: unknown): value is FlagConfig { + if (!value || typeof value !== "object") return false; + const config = value as { pattern?: unknown; colors?: unknown }; + return ( + isFlagPattern(config.pattern) && + Array.isArray(config.colors) && + config.colors.length === 3 && + config.colors.every((color) => typeof color === "string" && HEX_COLOR_RE.test(color)) + ); +} + +export function parseFlagConfig(value: unknown): FlagConfig | null { + return isFlagConfig(value) ? value : null; +} diff --git a/app/root.tsx b/app/root.tsx index cda09e3..459bbcf 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -16,14 +16,15 @@ import { NavigationProgress } from "~/components/NavigationProgress"; import { Toaster } from "~/components/ui/sonner"; import { BracktGradients } from "~/components/ui/BracktGradients"; import { Footer } from "~/components/marketing/Footer"; -import { isUserAdmin } from "~/models/user"; +import { findUserById } from "~/models/user"; import { AlertCircle, FileQuestion, Lock, ShieldOff, ServerCrash } from "lucide-react"; import logoUrl from "../public/logo.svg?url"; export async function loader({ request }: Route.LoaderArgs) { const session = await auth.api.getSession({ headers: request.headers }); - const isAdmin = session ? await isUserAdmin(session.user.id) : false; - return { isAdmin }; + const currentUser = session ? await findUserById(session.user.id) : null; + const isAdmin = currentUser?.isAdmin ?? false; + return { isAdmin, currentUser }; } export const links: Route.LinksFunction = () => [ @@ -70,7 +71,12 @@ export default function App({ loaderData }: Route.ComponentProps) { return ( <> - {!isDraftRoute && } + {!isDraftRoute && ( + + )} {isDraftRoute ? ( ) : ( @@ -178,4 +184,4 @@ function statusIcon(status: number) { if (status === 404) return ; if (status >= 500) return ; return ; -} \ No newline at end of file +} diff --git a/app/routes.ts b/app/routes.ts index 9ba6e9b..d55a538 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -50,6 +50,9 @@ export default [ route("api/autodraft/update", "routes/api/autodraft.update.ts"), route("api/watchlist/toggle", "routes/api/watchlist.toggle.ts"), route("api/user/timezone", "routes/api/user.timezone.ts"), + route("api/upload-avatar", "routes/api.upload-avatar.ts"), + route("api/upload-team-logo", "routes/api.upload-team-logo.ts"), + route("api/webhooks/cloudinary", "routes/api.webhooks.cloudinary.ts"), route("api/seasons/:seasonId/draft", "routes/api/seasons.$seasonId.draft.ts"), route("login", "routes/login.tsx"), route("register", "routes/register.tsx"), diff --git a/app/routes/__tests__/api.webhooks.cloudinary.test.ts b/app/routes/__tests__/api.webhooks.cloudinary.test.ts new file mode 100644 index 0000000..e938a2f --- /dev/null +++ b/app/routes/__tests__/api.webhooks.cloudinary.test.ts @@ -0,0 +1,195 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("~/lib/cloudinary.server", () => ({ + verifyCloudinaryWebhookSignature: vi.fn(), + deleteCloudinaryImageByUrl: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("~/models/user", () => ({ + findUserById: vi.fn(), + updateUser: vi.fn(), +})); +vi.mock("~/models/team", () => ({ + findTeamById: vi.fn(), + updateTeam: vi.fn(), +})); +vi.mock("~/lib/logger", () => ({ + logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn() }, +})); + +import { action } from "../api.webhooks.cloudinary"; +import { + verifyCloudinaryWebhookSignature, + deleteCloudinaryImageByUrl, +} from "~/lib/cloudinary.server"; +import { findUserById, updateUser } from "~/models/user"; +import { findTeamById, updateTeam } from "~/models/team"; + +function makeRequest(body: object) { + return new Request("http://test/api/webhooks/cloudinary", { + method: "POST", + body: JSON.stringify(body), + headers: { + "Content-Type": "application/json", + "X-Cld-Signature": "sig", + "X-Cld-Timestamp": "1234567890", + }, + }); +} + +function approvedUserPayload(overrides: object = {}) { + return { + secure_url: "https://res.cloudinary.com/demo/image/upload/v1/avatars/user-1.jpg", + moderation_status: "approved", + context: { avatar_target: "user", entity_id: "user-abc" }, + ...overrides, + }; +} + +function approvedTeamPayload(overrides: object = {}) { + return { + secure_url: "https://res.cloudinary.com/demo/image/upload/v1/avatars/team-1.jpg", + moderation_status: "approved", + context: { avatar_target: "team", entity_id: "team-xyz" }, + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(verifyCloudinaryWebhookSignature).mockReturnValue(true); + vi.mocked(updateUser).mockResolvedValue({} as never); + vi.mocked(updateTeam).mockResolvedValue({} as never); + vi.mocked(findUserById).mockResolvedValue(null as never); + vi.mocked(findTeamById).mockResolvedValue(null as never); +}); + +describe("Cloudinary webhook — signature", () => { + it("returns 401 when signature is invalid", async () => { + vi.mocked(verifyCloudinaryWebhookSignature).mockReturnValue(false); + const res = await action({ request: makeRequest(approvedUserPayload()), params: {}, context: {} } as never); + expect(res.status).toBe(401); + }); +}); + +describe("Cloudinary webhook — moderation", () => { + it("returns ignored when moderation_status is rejected", async () => { + const res = await action({ + request: makeRequest({ ...approvedUserPayload(), moderation_status: "rejected" }), + params: {}, + context: {}, + } as never); + const body = await res.json(); + expect(body).toEqual({ status: "ignored" }); + }); + + it("returns ignored when moderation_status is pending", async () => { + const res = await action({ + request: makeRequest({ ...approvedUserPayload(), moderation_status: "pending" }), + params: {}, + context: {}, + } as never); + const body = await res.json(); + expect(body).toEqual({ status: "ignored" }); + }); + + it("reads approval from nested moderation array", async () => { + vi.mocked(findUserById).mockResolvedValue({ customAvatarUrl: null } as never); + const payload = { + secure_url: "https://res.cloudinary.com/demo/image/upload/v1/avatars/user-1.jpg", + moderation: [{ status: "approved" }], + context: { avatar_target: "user", entity_id: "user-abc" }, + }; + const res = await action({ request: makeRequest(payload), params: {}, context: {} } as never); + const body = await res.json(); + expect(body).toEqual({ status: "updated" }); + }); +}); + +describe("Cloudinary webhook — missing fields", () => { + it("returns 400 when secure_url is absent", async () => { + const res = await action({ + request: makeRequest({ moderation_status: "approved", context: { avatar_target: "user", entity_id: "u1" } }), + params: {}, + context: {}, + } as never); + expect(res.status).toBe(400); + }); + + it("returns 400 when context is absent", async () => { + const res = await action({ + request: makeRequest({ moderation_status: "approved", secure_url: "https://res.cloudinary.com/demo/image/upload/v1/avatars/x.jpg" }), + params: {}, + context: {}, + } as never); + expect(res.status).toBe(400); + }); + + it("returns 400 for unknown avatar_target", async () => { + const res = await action({ + request: makeRequest({ ...approvedUserPayload(), context: { avatar_target: "league", entity_id: "l1" } }), + params: {}, + context: {}, + } as never); + expect(res.status).toBe(400); + }); +}); + +describe("Cloudinary webhook — user update", () => { + it("sets customAvatarUrl and avatarType on the user", async () => { + vi.mocked(findUserById).mockResolvedValue({ customAvatarUrl: null } as never); + await action({ request: makeRequest(approvedUserPayload()), params: {}, context: {} } as never); + expect(updateUser).toHaveBeenCalledWith("user-abc", { + customAvatarUrl: "https://res.cloudinary.com/demo/image/upload/v1/avatars/user-1.jpg", + avatarType: "uploaded", + }); + }); + + it("deletes the previous avatar when one exists", async () => { + const oldUrl = "https://res.cloudinary.com/demo/image/upload/v1/avatars/user-old.jpg"; + vi.mocked(findUserById).mockResolvedValue({ customAvatarUrl: oldUrl } as never); + await action({ request: makeRequest(approvedUserPayload()), params: {}, context: {} } as never); + expect(deleteCloudinaryImageByUrl).toHaveBeenCalledWith(oldUrl); + }); + + it("does not call delete when there is no previous avatar", async () => { + vi.mocked(findUserById).mockResolvedValue({ customAvatarUrl: null } as never); + await action({ request: makeRequest(approvedUserPayload()), params: {}, context: {} } as never); + expect(deleteCloudinaryImageByUrl).not.toHaveBeenCalled(); + }); + + it("does not delete when previous URL matches new URL", async () => { + const url = "https://res.cloudinary.com/demo/image/upload/v1/avatars/user-1.jpg"; + vi.mocked(findUserById).mockResolvedValue({ customAvatarUrl: url } as never); + await action({ request: makeRequest(approvedUserPayload({ secure_url: url })), params: {}, context: {} } as never); + expect(deleteCloudinaryImageByUrl).not.toHaveBeenCalled(); + }); +}); + +describe("Cloudinary webhook — team update", () => { + it("sets logoUrl and avatarType on the team", async () => { + vi.mocked(findTeamById).mockResolvedValue({ logoUrl: null } as never); + await action({ request: makeRequest(approvedTeamPayload()), params: {}, context: {} } as never); + expect(updateTeam).toHaveBeenCalledWith("team-xyz", { + logoUrl: "https://res.cloudinary.com/demo/image/upload/v1/avatars/team-1.jpg", + avatarType: "uploaded", + }); + }); + + it("deletes the previous team logo when one exists", async () => { + const oldUrl = "https://res.cloudinary.com/demo/image/upload/v1/avatars/team-old.jpg"; + vi.mocked(findTeamById).mockResolvedValue({ logoUrl: oldUrl } as never); + await action({ request: makeRequest(approvedTeamPayload()), params: {}, context: {} } as never); + expect(deleteCloudinaryImageByUrl).toHaveBeenCalledWith(oldUrl); + }); + + it("also handles context in pipe-separated string format", async () => { + vi.mocked(findUserById).mockResolvedValue({ customAvatarUrl: null } as never); + const payload = { + secure_url: "https://res.cloudinary.com/demo/image/upload/v1/avatars/user-1.jpg", + moderation_status: "approved", + context: "avatar_target=user|entity_id=user-abc", + }; + await action({ request: makeRequest(payload), params: {}, context: {} } as never); + expect(updateUser).toHaveBeenCalledWith("user-abc", expect.objectContaining({ avatarType: "uploaded" })); + }); +}); diff --git a/app/routes/api.upload-avatar.ts b/app/routes/api.upload-avatar.ts new file mode 100644 index 0000000..70d2206 --- /dev/null +++ b/app/routes/api.upload-avatar.ts @@ -0,0 +1,45 @@ +import { auth } from "~/lib/auth.server"; +import { buildWebhookUrl, uploadAvatarImage } from "~/lib/cloudinary.server"; +import { logger } from "~/lib/logger"; +import type { Route } from "./+types/api.upload-avatar"; + +const MAX_UPLOAD_BYTES = 5 * 1024 * 1024; +const ACCEPTED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]); + +export async function action({ request }: Route.ActionArgs) { + const session = await auth.api.getSession({ headers: request.headers }); + if (!session) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const formData = await request.formData(); + const file = formData.get("file"); + + if (!(file instanceof File)) { + return Response.json({ error: "Image file is required" }, { status: 400 }); + } + + if (!ACCEPTED_IMAGE_TYPES.has(file.type)) { + return Response.json({ error: "Unsupported image type" }, { status: 400 }); + } + + if (file.size > MAX_UPLOAD_BYTES) { + return Response.json({ error: "Image must be under 5 MB" }, { status: 400 }); + } + + try { + const buffer = Buffer.from(await file.arrayBuffer()); + await uploadAvatarImage({ + buffer, + contentType: file.type, + target: "user", + entityId: session.user.id, + notificationUrl: buildWebhookUrl(request), + }); + + return Response.json({ status: "pending" }); + } catch (error) { + logger.error("Avatar upload failed:", error); + return Response.json({ error: "Upload failed" }, { status: 500 }); + } +} diff --git a/app/routes/api.upload-team-logo.ts b/app/routes/api.upload-team-logo.ts new file mode 100644 index 0000000..5e847e1 --- /dev/null +++ b/app/routes/api.upload-team-logo.ts @@ -0,0 +1,60 @@ +import { auth } from "~/lib/auth.server"; +import { buildWebhookUrl, uploadAvatarImage } from "~/lib/cloudinary.server"; +import { logger } from "~/lib/logger"; +import { findTeamById } from "~/models/team"; +import type { Route } from "./+types/api.upload-team-logo"; + +const MAX_UPLOAD_BYTES = 5 * 1024 * 1024; +const ACCEPTED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]); + +export async function action({ request }: Route.ActionArgs) { + const session = await auth.api.getSession({ headers: request.headers }); + if (!session) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const formData = await request.formData(); + const teamId = formData.get("teamId"); + const file = formData.get("file"); + + if (typeof teamId !== "string" || !teamId) { + return Response.json({ error: "Team ID is required" }, { status: 400 }); + } + + const team = await findTeamById(teamId); + if (!team) { + return Response.json({ error: "Team not found" }, { status: 404 }); + } + + if (team.ownerId !== session.user.id) { + return Response.json({ error: "Forbidden" }, { status: 403 }); + } + + if (!(file instanceof File)) { + return Response.json({ error: "Image file is required" }, { status: 400 }); + } + + if (!ACCEPTED_IMAGE_TYPES.has(file.type)) { + return Response.json({ error: "Unsupported image type" }, { status: 400 }); + } + + if (file.size > MAX_UPLOAD_BYTES) { + return Response.json({ error: "Image must be under 5 MB" }, { status: 400 }); + } + + try { + const buffer = Buffer.from(await file.arrayBuffer()); + await uploadAvatarImage({ + buffer, + contentType: file.type, + target: "team", + entityId: team.id, + notificationUrl: buildWebhookUrl(request), + }); + + return Response.json({ status: "pending" }); + } catch (error) { + logger.error("Team avatar upload failed:", error); + return Response.json({ error: "Upload failed" }, { status: 500 }); + } +} diff --git a/app/routes/api.webhooks.cloudinary.ts b/app/routes/api.webhooks.cloudinary.ts new file mode 100644 index 0000000..5f2beba --- /dev/null +++ b/app/routes/api.webhooks.cloudinary.ts @@ -0,0 +1,109 @@ +import { + deleteCloudinaryImageByUrl, + verifyCloudinaryWebhookSignature, +} from "~/lib/cloudinary.server"; +import { logger } from "~/lib/logger"; +import { findTeamById, updateTeam } from "~/models/team"; +import { findUserById, updateUser } from "~/models/user"; +import type { Route } from "./+types/api.webhooks.cloudinary"; + +type CloudinaryContext = Record | string | undefined; + +interface CloudinaryWebhookPayload { + secure_url?: string; + moderation?: Array<{ status?: string }>; + moderation_status?: string; + info?: { + moderation?: { status?: string } | Array<{ status?: string }>; + }; + context?: CloudinaryContext | { custom?: Record }; +} + +function getContextValue(context: CloudinaryWebhookPayload["context"], key: string): string | null { + if (!context) return null; + + if (typeof context === "string") { + const pairs = context.split("|").map((part) => part.split("=")); + const found = pairs.find(([pairKey]) => pairKey === key); + return found?.[1] ?? null; + } + + const custom = "custom" in context && context.custom && typeof context.custom === "object" + ? context.custom + : context; + const value = (custom as Record)[key]; + return typeof value === "string" ? value : null; +} + +function moderationStatus(payload: CloudinaryWebhookPayload): string | null { + const direct = payload.moderation?.[0]?.status ?? payload.moderation_status; + if (direct) return direct; + + const infoModeration = payload.info?.moderation; + if (Array.isArray(infoModeration)) return infoModeration[0]?.status ?? null; + return infoModeration?.status ?? null; +} + +export async function action({ request }: Route.ActionArgs) { + const body = await request.text(); + const signature = request.headers.get("X-Cld-Signature"); + const timestamp = request.headers.get("X-Cld-Timestamp"); + + if (!verifyCloudinaryWebhookSignature({ body, signature, timestamp })) { + return Response.json({ error: "Invalid signature" }, { status: 401 }); + } + + let payload: CloudinaryWebhookPayload; + try { + payload = JSON.parse(body) as CloudinaryWebhookPayload; + } catch { + return Response.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const status = moderationStatus(payload); + if (status !== "approved") { + logger.info("Cloudinary avatar not approved:", { status }); + return Response.json({ status: "ignored" }); + } + + if (!payload.secure_url) { + return Response.json({ error: "Missing secure_url" }, { status: 400 }); + } + + const target = getContextValue(payload.context, "avatar_target"); + const entityId = getContextValue(payload.context, "entity_id"); + + if (!target || !entityId) { + return Response.json({ error: "Missing avatar context" }, { status: 400 }); + } + + if (target === "user") { + const existingUser = await findUserById(entityId); + await updateUser(entityId, { + customAvatarUrl: payload.secure_url, + avatarType: "uploaded", + }); + if (existingUser?.customAvatarUrl && existingUser.customAvatarUrl !== payload.secure_url) { + deleteCloudinaryImageByUrl(existingUser.customAvatarUrl).catch((error) => { + logger.error("Failed to delete replaced user avatar from Cloudinary:", error); + }); + } + return Response.json({ status: "updated" }); + } + + if (target === "team") { + const existingTeam = await findTeamById(entityId); + await updateTeam(entityId, { + logoUrl: payload.secure_url, + avatarType: "uploaded", + }); + if (existingTeam?.logoUrl && existingTeam.logoUrl !== payload.secure_url) { + deleteCloudinaryImageByUrl(existingTeam.logoUrl).catch((error) => { + logger.error("Failed to delete replaced team avatar from Cloudinary:", error); + }); + } + return Response.json({ status: "updated" }); + } + + return Response.json({ error: "Unknown avatar target" }, { status: 400 }); +} diff --git a/app/routes/leagues/__tests__/team-management.test.ts b/app/routes/leagues/__tests__/team-management.test.ts index 2ff1a82..2b29958 100644 --- a/app/routes/leagues/__tests__/team-management.test.ts +++ b/app/routes/leagues/__tests__/team-management.test.ts @@ -15,6 +15,8 @@ const createMockTeam = (overrides: { seasonId: overrides.seasonId || 'season-1', ownerId: overrides.ownerId, logoUrl: null, + flagConfig: null, + avatarType: 'owner', draftPosition: overrides.draftPosition || 1, createdAt: new Date(), updatedAt: new Date(), @@ -190,6 +192,9 @@ describe('Team Management - Admin User List', () => { firstName: 'User', lastName: 'One', imageUrl: null, + flagConfig: null, + customAvatarUrl: null, + avatarType: 'flag', isAdmin: false, timezone: null, createdAt: new Date(), @@ -205,6 +210,9 @@ describe('Team Management - Admin User List', () => { firstName: 'User', lastName: 'Two', imageUrl: null, + flagConfig: null, + customAvatarUrl: null, + avatarType: 'flag', isAdmin: false, timezone: null, createdAt: new Date(), @@ -241,6 +249,9 @@ describe('Team Management - Admin User List', () => { firstName: 'User', lastName: 'One', imageUrl: null, + flagConfig: null, + customAvatarUrl: null, + avatarType: 'flag', isAdmin: false, timezone: null, createdAt: new Date(), diff --git a/app/routes/teams/$teamId.settings.tsx b/app/routes/teams/$teamId.settings.tsx index f923d4e..8f37fef 100644 --- a/app/routes/teams/$teamId.settings.tsx +++ b/app/routes/teams/$teamId.settings.tsx @@ -8,6 +8,8 @@ import { removeTeamOwner, } from "~/models/team"; import { findSeasonById } from "~/models/season"; +import { findUserById } from "~/models/user"; +import { resolveUserAvatarData } from "~/lib/avatar-data"; import { Button } from "~/components/ui/button"; import { Card, @@ -18,6 +20,10 @@ import { } from "~/components/ui/card"; import { Input } from "~/components/ui/input"; import { Label } from "~/components/ui/label"; +import { AvatarEditor } from "~/components/ui/AvatarEditor"; +import { parseFlagConfig } from "~/lib/flag-types"; +import { deleteCloudinaryImageByUrl } from "~/lib/cloudinary.server"; +import { logger } from "~/lib/logger"; import { AlertDialog, AlertDialogAction, @@ -62,7 +68,10 @@ export async function loader(args: Route.LoaderArgs) { throw new Response("Season not found", { status: 404 }); } - return { team, season }; + const owner = await findUserById(userId); + const ownerAvatarData = owner ? resolveUserAvatarData(owner) : null; + + return { team, season, ownerAvatarData }; } export async function action(args: Route.ActionArgs) { @@ -89,9 +98,58 @@ export async function action(args: Route.ActionArgs) { const formData = await request.formData(); const intent = formData.get("intent"); + if (intent === "update-avatar-flag") { + const rawConfig = formData.get("flagConfig"); + if (typeof rawConfig !== "string") { + return { error: "Flag config is required" }; + } + + let parsedConfig: unknown; + try { + parsedConfig = JSON.parse(rawConfig); + } catch { + return { error: "Invalid flag config" }; + } + + const flagConfig = parseFlagConfig(parsedConfig); + if (!flagConfig) { + return { error: "Invalid flag config" }; + } + + await updateTeam(teamId, { + flagConfig, + avatarType: "flag", + }); + + return { success: true }; + } + + if (intent === "remove-avatar-photo") { + await updateTeam(teamId, { + logoUrl: null, + avatarType: "owner", + }); + if (team.logoUrl) { + deleteCloudinaryImageByUrl(team.logoUrl).catch((error) => { + logger.error("Failed to delete team avatar from Cloudinary:", error); + }); + } + + return { success: true }; + } + + if (intent === "use-owner-avatar") { + await updateTeam(teamId, { avatarType: "owner", flagConfig: null }); + if (team.avatarType === "uploaded" && team.logoUrl) { + deleteCloudinaryImageByUrl(team.logoUrl).catch((error) => { + logger.error("Failed to delete replaced team avatar from Cloudinary:", error); + }); + } + return { success: true }; + } + if (intent === "update") { const name = formData.get("name"); - const logoUrl = formData.get("logoUrl"); if (!name || typeof name !== "string") { return { error: "Team name is required" }; @@ -99,7 +157,6 @@ export async function action(args: Route.ActionArgs) { await updateTeam(teamId, { name: name.trim(), - logoUrl: logoUrl && typeof logoUrl === "string" ? logoUrl.trim() : undefined, }); await syncPrivateBracktParticipants(team.seasonId); @@ -115,7 +172,7 @@ export async function action(args: Route.ActionArgs) { return { error: "Invalid action" }; } -export default function TeamSettings({ loaderData }: Route.ComponentProps) { +export default function TeamSettings({ loaderData, actionData }: Route.ComponentProps) { const { team, season } = loaderData; const navigate = useNavigate(); @@ -138,11 +195,38 @@ export default function TeamSettings({ loaderData }: Route.ComponentProps) {
+ {actionData && "success" in actionData && actionData.success && ( +

Team settings updated.

+ )} + {actionData && "error" in actionData && actionData.error && ( +

{actionData.error}

+ )} + + + + Team Avatar + + Update the flag or submit a photo for review + + + + + + + Team Information - Update your team name and logo + Update your team name @@ -161,21 +245,6 @@ export default function TeamSettings({ loaderData }: Route.ComponentProps) { />
-
- - -

- Enter a URL to an image for your team logo -

-
- diff --git a/app/routes/user-profile.tsx b/app/routes/user-profile.tsx index dc1945a..a2a9dd6 100644 --- a/app/routes/user-profile.tsx +++ b/app/routes/user-profile.tsx @@ -8,6 +8,10 @@ import { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; import { Label } from "~/components/ui/label"; import { TimezoneSelect, TIMEZONE_OPTIONS } from "~/components/league/TimezoneSelect"; +import { AvatarEditor } from "~/components/ui/AvatarEditor"; +import { parseFlagConfig } from "~/lib/flag-types"; +import { deleteCloudinaryImageByUrl } from "~/lib/cloudinary.server"; +import { logger } from "~/lib/logger"; const VALID_TIMEZONES = new Set( TIMEZONE_OPTIONS.flatMap((g) => g.zones.map((z) => z.value)) @@ -36,7 +40,54 @@ export async function action(args: Route.ActionArgs) { return redirect("/login?redirectTo=/user-profile"); } + 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 { error: "Flag config is required." }; + } + + let parsedConfig: unknown; + try { + parsedConfig = JSON.parse(rawConfig); + } catch { + return { error: "Invalid flag config." }; + } + + const flagConfig = parseFlagConfig(parsedConfig); + if (!flagConfig) { + return { error: "Invalid flag config." }; + } + + await updateUser(session.user.id, { + flagConfig, + avatarType: "flag", + }); + + return { success: true }; + } + + if (intent === "remove-avatar-photo") { + await updateUser(session.user.id, { + customAvatarUrl: null, + avatarType: "flag", + }); + if (user.customAvatarUrl) { + deleteCloudinaryImageByUrl(user.customAvatarUrl).catch((error) => { + logger.error("Failed to delete user avatar from Cloudinary:", error); + }); + } + + return { success: true }; + } + const displayName = formData.get("displayName") as string; const username = formData.get("username") as string | null; const firstName = formData.get("firstName") as string | null; @@ -85,6 +136,17 @@ export default function UserProfilePage({ loaderData, actionData }: Route.Compon {actionData?.success && (

Profile updated successfully.

)} + {actionData && "error" in actionData && actionData.error && ( +

{actionData.error}

+ )} +
+ +
diff --git a/database/schema.ts b/database/schema.ts index fe4e862..adf0b35 100644 --- a/database/schema.ts +++ b/database/schema.ts @@ -11,6 +11,9 @@ export const users = pgTable("users", { firstName: varchar("first_name", { length: 255 }), lastName: varchar("last_name", { length: 255 }), imageUrl: varchar("image_url", { length: 512 }), // mapped as BetterAuth's "image" field + flagConfig: jsonb("flag_config").$type<{ pattern: string; colors: string[] }>(), + customAvatarUrl: varchar("custom_avatar_url", { length: 512 }), + avatarType: varchar("avatar_type", { length: 20 }).notNull().default("flag"), isAdmin: boolean("is_admin").notNull().default(false), timezone: varchar("timezone", { length: 50 }), // IANA timezone string, e.g. "America/Chicago" createdAt: timestamp("created_at").defaultNow().notNull(), @@ -228,6 +231,8 @@ export const teams = pgTable("teams", { .references(() => seasons.id, { onDelete: "cascade" }), name: varchar("name", { length: 255 }).notNull(), logoUrl: varchar("logo_url", { length: 512 }), + flagConfig: jsonb("flag_config").$type<{ pattern: string; colors: string[] }>(), + avatarType: varchar("avatar_type", { length: 20 }).notNull().default("owner"), ownerId: varchar("owner_id", { length: 255 }), // UUID → users.id, nullable createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), diff --git a/docs/agents/auth.md b/docs/agents/auth.md index 5615734..e2fe519 100644 --- a/docs/agents/auth.md +++ b/docs/agents/auth.md @@ -72,7 +72,7 @@ await requireLeagueAccess(args, { leagueId, seasonId, db }); | `/forgot-password` | Request a password reset email | | `/reset-password` | Enter new password via reset token (link sent by email via Resend) | -Password reset emails are sent via Resend from `noreply@brackt.com`. The reset link points to `/reset-password?token=…` using `BETTER_AUTH_URL` as the base. +Password reset emails are sent via Resend from `noreply@brackt.com`. The reset link points to `/reset-password?token=…` using `APP_URL` as the base. ## OAuth Providers @@ -89,6 +89,6 @@ Account linking is enabled — users who sign in with OAuth get their accounts l ``` BETTER_AUTH_SECRET # required — random secret for signing sessions -BETTER_AUTH_URL # required — app base URL, e.g. https://brackt.com +APP_URL # required — app base URL, e.g. https://brackt.com RESEND_API_KEY # required for password reset emails ``` diff --git a/drizzle/0095_lucky_madrox.sql b/drizzle/0095_lucky_madrox.sql new file mode 100644 index 0000000..890f6ff --- /dev/null +++ b/drizzle/0095_lucky_madrox.sql @@ -0,0 +1,5 @@ +ALTER TABLE "teams" ADD COLUMN "flag_config" jsonb;--> statement-breakpoint +ALTER TABLE "teams" ADD COLUMN "avatar_type" varchar(20) DEFAULT 'owner' NOT NULL;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "flag_config" jsonb;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "custom_avatar_url" varchar(512);--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "avatar_type" varchar(20) DEFAULT 'flag' NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0095_snapshot.json b/drizzle/meta/0095_snapshot.json new file mode 100644 index 0000000..946f120 --- /dev/null +++ b/drizzle/meta/0095_snapshot.json @@ -0,0 +1,5755 @@ +{ + "id": "bccadfbd-9150-4041-9d26-9d846eb2947e", + "prevId": "dd058b7e-a8e6-4f2e-b1b9-102046a6a494", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autodraft_settings": { + "name": "autodraft_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "season_id": { + "name": "season_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "mode": { + "name": "mode", + "type": "autodraft_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'next_pick'" + }, + "queue_only": { + "name": "queue_only", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "autodraft_settings_season_id_seasons_id_fk": { + "name": "autodraft_settings_season_id_seasons_id_fk", + "tableFrom": "autodraft_settings", + "tableTo": "seasons", + "columnsFrom": [ + "season_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autodraft_settings_team_id_teams_id_fk": { + "name": "autodraft_settings_team_id_teams_id_fk", + "tableFrom": "autodraft_settings", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.commissioner_audit_log": { + "name": "commissioner_audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "season_id": { + "name": "season_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "league_id": { + "name": "league_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "actor_display_name": { + "name": "actor_display_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "audit_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "affected_team_ids": { + "name": "affected_team_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_season_id_idx": { + "name": "audit_log_season_id_idx", + "columns": [ + { + "expression": "season_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "commissioner_audit_log_season_id_seasons_id_fk": { + "name": "commissioner_audit_log_season_id_seasons_id_fk", + "tableFrom": "commissioner_audit_log", + "tableTo": "seasons", + "columnsFrom": [ + "season_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "commissioner_audit_log_league_id_leagues_id_fk": { + "name": "commissioner_audit_log_league_id_leagues_id_fk", + "tableFrom": "commissioner_audit_log", + "tableTo": "leagues", + "columnsFrom": [ + "league_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.commissioners": { + "name": "commissioners", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "league_id": { + "name": "league_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "commissioners_league_id_leagues_id_fk": { + "name": "commissioners_league_id_leagues_id_fk", + "tableFrom": "commissioners", + "tableTo": "leagues", + "columnsFrom": [ + "league_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cs2_major_stage_results": { + "name": "cs2_major_stage_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "scoring_event_id": { + "name": "scoring_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "participant_id": { + "name": "participant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stage_entry": { + "name": "stage_entry", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "stage_eliminated": { + "name": "stage_eliminated", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "stage_eliminated_wins": { + "name": "stage_eliminated_wins", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "final_placement": { + "name": "final_placement", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cs2_major_stage_results_unique": { + "name": "cs2_major_stage_results_unique", + "columns": [ + { + "expression": "scoring_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "participant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cs2_major_stage_results_scoring_event_id_scoring_events_id_fk": { + "name": "cs2_major_stage_results_scoring_event_id_scoring_events_id_fk", + "tableFrom": "cs2_major_stage_results", + "tableTo": "scoring_events", + "columnsFrom": [ + "scoring_event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cs2_major_stage_results_participant_id_season_participants_id_fk": { + "name": "cs2_major_stage_results_participant_id_season_participants_id_fk", + "tableFrom": "cs2_major_stage_results", + "tableTo": "season_participants", + "columnsFrom": [ + "participant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.draft_picks": { + "name": "draft_picks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "season_id": { + "name": "season_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "participant_id": { + "name": "participant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pick_number": { + "name": "pick_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "round": { + "name": "round", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pick_in_round": { + "name": "pick_in_round", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "picked_by_user_id": { + "name": "picked_by_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "picked_by_type": { + "name": "picked_by_type", + "type": "picked_by_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "time_used": { + "name": "time_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "draft_picks_season_pick_unique": { + "name": "draft_picks_season_pick_unique", + "columns": [ + { + "expression": "season_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pick_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "draft_picks_season_id_seasons_id_fk": { + "name": "draft_picks_season_id_seasons_id_fk", + "tableFrom": "draft_picks", + "tableTo": "seasons", + "columnsFrom": [ + "season_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "draft_picks_team_id_teams_id_fk": { + "name": "draft_picks_team_id_teams_id_fk", + "tableFrom": "draft_picks", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "draft_picks_participant_id_season_participants_id_fk": { + "name": "draft_picks_participant_id_season_participants_id_fk", + "tableFrom": "draft_picks", + "tableTo": "season_participants", + "columnsFrom": [ + "participant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.draft_queue": { + "name": "draft_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "season_id": { + "name": "season_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "participant_id": { + "name": "participant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "queue_position": { + "name": "queue_position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "draft_queue_season_id_seasons_id_fk": { + "name": "draft_queue_season_id_seasons_id_fk", + "tableFrom": "draft_queue", + "tableTo": "seasons", + "columnsFrom": [ + "season_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "draft_queue_team_id_teams_id_fk": { + "name": "draft_queue_team_id_teams_id_fk", + "tableFrom": "draft_queue", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "draft_queue_participant_id_season_participants_id_fk": { + "name": "draft_queue_participant_id_season_participants_id_fk", + "tableFrom": "draft_queue", + "tableTo": "season_participants", + "columnsFrom": [ + "participant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.draft_slots": { + "name": "draft_slots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "season_id": { + "name": "season_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "draft_order": { + "name": "draft_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "draft_slots_season_id_seasons_id_fk": { + "name": "draft_slots_season_id_seasons_id_fk", + "tableFrom": "draft_slots", + "tableTo": "seasons", + "columnsFrom": [ + "season_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "draft_slots_team_id_teams_id_fk": { + "name": "draft_slots_team_id_teams_id_fk", + "tableFrom": "draft_slots", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.draft_timers": { + "name": "draft_timers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "season_id": { + "name": "season_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "time_remaining": { + "name": "time_remaining", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "draft_timers_season_id_seasons_id_fk": { + "name": "draft_timers_season_id_seasons_id_fk", + "tableFrom": "draft_timers", + "tableTo": "seasons", + "columnsFrom": [ + "season_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "draft_timers_team_id_teams_id_fk": { + "name": "draft_timers_team_id_teams_id_fk", + "tableFrom": "draft_timers", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.event_results": { + "name": "event_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "scoring_event_id": { + "name": "scoring_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "season_participant_id": { + "name": "season_participant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "placement": { + "name": "placement", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "qualifying_points_awarded": { + "name": "qualifying_points_awarded", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "eliminated": { + "name": "eliminated", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "raw_score": { + "name": "raw_score", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "event_results_event_participant_unique": { + "name": "event_results_event_participant_unique", + "columns": [ + { + "expression": "scoring_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "season_participant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "event_results_scoring_event_id_scoring_events_id_fk": { + "name": "event_results_scoring_event_id_scoring_events_id_fk", + "tableFrom": "event_results", + "tableTo": "scoring_events", + "columnsFrom": [ + "scoring_event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "event_results_season_participant_id_season_participants_id_fk": { + "name": "event_results_season_participant_id_season_participants_id_fk", + "tableFrom": "event_results", + "tableTo": "season_participants", + "columnsFrom": [ + "season_participant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group_stage_matches": { + "name": "group_stage_matches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tournament_group_id": { + "name": "tournament_group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "participant1_id": { + "name": "participant1_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "participant2_id": { + "name": "participant2_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "participant1_score": { + "name": "participant1_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "participant2_score": { + "name": "participant2_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_complete": { + "name": "is_complete", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "matchday": { + "name": "matchday", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "group_stage_matches_group_pair_unique": { + "name": "group_stage_matches_group_pair_unique", + "columns": [ + { + "expression": "tournament_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "participant1_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "participant2_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "group_stage_matches_tournament_group_id_tournament_groups_id_fk": { + "name": "group_stage_matches_tournament_group_id_tournament_groups_id_fk", + "tableFrom": "group_stage_matches", + "tableTo": "tournament_groups", + "columnsFrom": [ + "tournament_group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "group_stage_matches_participant1_id_season_participants_id_fk": { + "name": "group_stage_matches_participant1_id_season_participants_id_fk", + "tableFrom": "group_stage_matches", + "tableTo": "season_participants", + "columnsFrom": [ + "participant1_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "group_stage_matches_participant2_id_season_participants_id_fk": { + "name": "group_stage_matches_participant2_id_season_participants_id_fk", + "tableFrom": "group_stage_matches", + "tableTo": "season_participants", + "columnsFrom": [ + "participant2_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.leagues": { + "name": "leagues", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "current_season_id": { + "name": "current_season_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_public_draft_board": { + "name": "is_public_draft_board", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "discord_webhook_url": { + "name": "discord_webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.participant_ev_snapshots": { + "name": "participant_ev_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "participant_id": { + "name": "participant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sports_season_id": { + "name": "sports_season_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "snapshot_date": { + "name": "snapshot_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "prob_first": { + "name": "prob_first", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "prob_second": { + "name": "prob_second", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "prob_third": { + "name": "prob_third", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "prob_fourth": { + "name": "prob_fourth", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "prob_fifth": { + "name": "prob_fifth", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "prob_sixth": { + "name": "prob_sixth", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "prob_seventh": { + "name": "prob_seventh", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "prob_eighth": { + "name": "prob_eighth", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "calculated_ev": { + "name": "calculated_ev", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "source": { + "name": "source", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "participant_ev_snapshots_unique": { + "name": "participant_ev_snapshots_unique", + "columns": [ + { + "expression": "participant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sports_season_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "snapshot_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "participant_ev_snapshots_participant_id_season_participants_id_fk": { + "name": "participant_ev_snapshots_participant_id_season_participants_id_fk", + "tableFrom": "participant_ev_snapshots", + "tableTo": "season_participants", + "columnsFrom": [ + "participant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "participant_ev_snapshots_sports_season_id_sports_seasons_id_fk": { + "name": "participant_ev_snapshots_sports_season_id_sports_seasons_id_fk", + "tableFrom": "participant_ev_snapshots", + "tableTo": "sports_seasons", + "columnsFrom": [ + "sports_season_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.participant_golf_skills": { + "name": "participant_golf_skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "participant_id": { + "name": "participant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sg_total": { + "name": "sg_total", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false + }, + "datagolf_rank": { + "name": "datagolf_rank", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "masters_odds": { + "name": "masters_odds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "us_open_odds": { + "name": "us_open_odds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "open_championship_odds": { + "name": "open_championship_odds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pga_championship_odds": { + "name": "pga_championship_odds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "participant_golf_skills_participant_unique": { + "name": "participant_golf_skills_participant_unique", + "columns": [ + { + "expression": "participant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "participant_golf_skills_participant_id_participants_id_fk": { + "name": "participant_golf_skills_participant_id_participants_id_fk", + "tableFrom": "participant_golf_skills", + "tableTo": "participants", + "columnsFrom": [ + "participant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.participant_season_results": { + "name": "participant_season_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "participant_id": { + "name": "participant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sports_season_id": { + "name": "sports_season_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "current_points": { + "name": "current_points", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_position": { + "name": "current_position", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "participant_season_results_participant_id_season_participants_id_fk": { + "name": "participant_season_results_participant_id_season_participants_id_fk", + "tableFrom": "participant_season_results", + "tableTo": "season_participants", + "columnsFrom": [ + "participant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "participant_season_results_sports_season_id_sports_seasons_id_fk": { + "name": "participant_season_results_sports_season_id_sports_seasons_id_fk", + "tableFrom": "participant_season_results", + "tableTo": "sports_seasons", + "columnsFrom": [ + "sports_season_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.participant_surface_elos": { + "name": "participant_surface_elos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "participant_id": { + "name": "participant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "world_ranking": { + "name": "world_ranking", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "elo_hard": { + "name": "elo_hard", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "elo_clay": { + "name": "elo_clay", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "elo_grass": { + "name": "elo_grass", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "participant_surface_elos_participant_unique": { + "name": "participant_surface_elos_participant_unique", + "columns": [ + { + "expression": "participant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "participant_surface_elos_participant_id_participants_id_fk": { + "name": "participant_surface_elos_participant_id_participants_id_fk", + "tableFrom": "participant_surface_elos", + "tableTo": "participants", + "columnsFrom": [ + "participant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.participants": { + "name": "participants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "sport_id": { + "name": "sport_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "external_key": { + "name": "external_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "participants_sport_name_unique": { + "name": "participants_sport_name_unique", + "columns": [ + { + "expression": "sport_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "participants_sport_id_sports_id_fk": { + "name": "participants_sport_id_sports_id_fk", + "tableFrom": "participants", + "tableTo": "sports", + "columnsFrom": [ + "sport_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_standings_mappings": { + "name": "pending_standings_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "sports_season_id": { + "name": "sports_season_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_team_id": { + "name": "external_team_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "team_name": { + "name": "team_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "standing_data": { + "name": "standing_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "psm_season_external_id_idx": { + "name": "psm_season_external_id_idx", + "columns": [ + { + "expression": "sports_season_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_standings_mappings_sports_season_id_sports_seasons_id_fk": { + "name": "pending_standings_mappings_sports_season_id_sports_seasons_id_fk", + "tableFrom": "pending_standings_mappings", + "tableTo": "sports_seasons", + "columnsFrom": [ + "sports_season_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.playoff_match_games": { + "name": "playoff_match_games", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "playoff_match_id": { + "name": "playoff_match_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "game_number": { + "name": "game_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "playoff_match_game_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "participant1_score": { + "name": "participant1_score", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "participant2_score": { + "name": "participant2_score", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "winner_id": { + "name": "winner_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "playoff_match_games_playoff_match_id_playoff_matches_id_fk": { + "name": "playoff_match_games_playoff_match_id_playoff_matches_id_fk", + "tableFrom": "playoff_match_games", + "tableTo": "playoff_matches", + "columnsFrom": [ + "playoff_match_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "playoff_match_games_winner_id_season_participants_id_fk": { + "name": "playoff_match_games_winner_id_season_participants_id_fk", + "tableFrom": "playoff_match_games", + "tableTo": "season_participants", + "columnsFrom": [ + "winner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.playoff_match_odds": { + "name": "playoff_match_odds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "playoff_match_id": { + "name": "playoff_match_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "participant_id": { + "name": "participant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "moneyline_odds": { + "name": "moneyline_odds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "implied_probability": { + "name": "implied_probability", + "type": "numeric(6, 4)", + "primaryKey": false, + "notNull": false + }, + "odds_source": { + "name": "odds_source", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "recorded_at": { + "name": "recorded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "playoff_match_odds_playoff_match_id_playoff_matches_id_fk": { + "name": "playoff_match_odds_playoff_match_id_playoff_matches_id_fk", + "tableFrom": "playoff_match_odds", + "tableTo": "playoff_matches", + "columnsFrom": [ + "playoff_match_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "playoff_match_odds_participant_id_season_participants_id_fk": { + "name": "playoff_match_odds_participant_id_season_participants_id_fk", + "tableFrom": "playoff_match_odds", + "tableTo": "season_participants", + "columnsFrom": [ + "participant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.playoff_matches": { + "name": "playoff_matches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "scoring_event_id": { + "name": "scoring_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "round": { + "name": "round", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "match_number": { + "name": "match_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "participant1_id": { + "name": "participant1_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "participant2_id": { + "name": "participant2_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "winner_id": { + "name": "winner_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "loser_id": { + "name": "loser_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_complete": { + "name": "is_complete", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "participant1_score": { + "name": "participant1_score", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "participant2_score": { + "name": "participant2_score", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "is_scoring": { + "name": "is_scoring", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "template_round": { + "name": "template_round", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "seed_info": { + "name": "seed_info", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "playoff_matches_scoring_event_id_scoring_events_id_fk": { + "name": "playoff_matches_scoring_event_id_scoring_events_id_fk", + "tableFrom": "playoff_matches", + "tableTo": "scoring_events", + "columnsFrom": [ + "scoring_event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "playoff_matches_participant1_id_season_participants_id_fk": { + "name": "playoff_matches_participant1_id_season_participants_id_fk", + "tableFrom": "playoff_matches", + "tableTo": "season_participants", + "columnsFrom": [ + "participant1_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "playoff_matches_participant2_id_season_participants_id_fk": { + "name": "playoff_matches_participant2_id_season_participants_id_fk", + "tableFrom": "playoff_matches", + "tableTo": "season_participants", + "columnsFrom": [ + "participant2_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "playoff_matches_winner_id_season_participants_id_fk": { + "name": "playoff_matches_winner_id_season_participants_id_fk", + "tableFrom": "playoff_matches", + "tableTo": "season_participants", + "columnsFrom": [ + "winner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "playoff_matches_loser_id_season_participants_id_fk": { + "name": "playoff_matches_loser_id_season_participants_id_fk", + "tableFrom": "playoff_matches", + "tableTo": "season_participants", + "columnsFrom": [ + "loser_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qualifying_point_config": { + "name": "qualifying_point_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "sports_season_id": { + "name": "sports_season_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "placement": { + "name": "placement", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "points": { + "name": "points", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "qualifying_point_config_sports_season_id_sports_seasons_id_fk": { + "name": "qualifying_point_config_sports_season_id_sports_seasons_id_fk", + "tableFrom": "qualifying_point_config", + "tableTo": "sports_seasons", + "columnsFrom": [ + "sports_season_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.regular_season_standings": { + "name": "regular_season_standings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "participant_id": { + "name": "participant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sports_season_id": { + "name": "sports_season_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "wins": { + "name": "wins", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "losses": { + "name": "losses", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "ot_losses": { + "name": "ot_losses", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ties": { + "name": "ties", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "win_pct": { + "name": "win_pct", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false + }, + "games_played": { + "name": "games_played", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "games_back": { + "name": "games_back", + "type": "numeric(5, 1)", + "primaryKey": false, + "notNull": false + }, + "conference": { + "name": "conference", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "division": { + "name": "division", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "conference_rank": { + "name": "conference_rank", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "division_rank": { + "name": "division_rank", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "league_rank": { + "name": "league_rank", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "streak": { + "name": "streak", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "last_ten": { + "name": "last_ten", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false + }, + "home_record": { + "name": "home_record", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false + }, + "away_record": { + "name": "away_record", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false + }, + "external_team_id": { + "name": "external_team_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "srs": { + "name": "srs", + "type": "numeric(6, 2)", + "primaryKey": false, + "notNull": false + }, + "synced_at": { + "name": "synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rss_participant_season_idx": { + "name": "rss_participant_season_idx", + "columns": [ + { + "expression": "participant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sports_season_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "regular_season_standings_participant_id_season_participants_id_fk": { + "name": "regular_season_standings_participant_id_season_participants_id_fk", + "tableFrom": "regular_season_standings", + "tableTo": "season_participants", + "columnsFrom": [ + "participant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "regular_season_standings_sports_season_id_sports_seasons_id_fk": { + "name": "regular_season_standings_sports_season_id_sports_seasons_id_fk", + "tableFrom": "regular_season_standings", + "tableTo": "sports_seasons", + "columnsFrom": [ + "sports_season_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scoring_events": { + "name": "scoring_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "sports_season_id": { + "name": "sports_season_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tournament_id": { + "name": "tournament_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "event_date": { + "name": "event_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "event_starts_at": { + "name": "event_starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "event_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "playoff_round": { + "name": "playoff_round", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "is_qualifying_event": { + "name": "is_qualifying_event", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bracket_template_id": { + "name": "bracket_template_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "scoring_starts_at_round": { + "name": "scoring_starts_at_round", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "bracket_region_config": { + "name": "bracket_region_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_complete": { + "name": "is_complete", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "scoring_events_sports_season_id_sports_seasons_id_fk": { + "name": "scoring_events_sports_season_id_sports_seasons_id_fk", + "tableFrom": "scoring_events", + "tableTo": "sports_seasons", + "columnsFrom": [ + "sports_season_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scoring_events_tournament_id_tournaments_id_fk": { + "name": "scoring_events_tournament_id_tournaments_id_fk", + "tableFrom": "scoring_events", + "tableTo": "tournaments", + "columnsFrom": [ + "tournament_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "scoring_events_qualifying_require_tournament": { + "name": "scoring_events_qualifying_require_tournament", + "value": "NOT \"scoring_events\".\"is_qualifying_event\" OR \"scoring_events\".\"tournament_id\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.season_participant_expected_values": { + "name": "season_participant_expected_values", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "participant_id": { + "name": "participant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sports_season_id": { + "name": "sports_season_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "prob_first": { + "name": "prob_first", + "type": "numeric(6, 4)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "prob_second": { + "name": "prob_second", + "type": "numeric(6, 4)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "prob_third": { + "name": "prob_third", + "type": "numeric(6, 4)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "prob_fourth": { + "name": "prob_fourth", + "type": "numeric(6, 4)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "prob_fifth": { + "name": "prob_fifth", + "type": "numeric(6, 4)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "prob_sixth": { + "name": "prob_sixth", + "type": "numeric(6, 4)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "prob_seventh": { + "name": "prob_seventh", + "type": "numeric(6, 4)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "prob_eighth": { + "name": "prob_eighth", + "type": "numeric(6, 4)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "expected_value": { + "name": "expected_value", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "source": { + "name": "source", + "type": "probability_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'manual'" + }, + "source_odds": { + "name": "source_odds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "source_elo": { + "name": "source_elo", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "world_ranking": { + "name": "world_ranking", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "calculated_at": { + "name": "calculated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "participant_ev_participant_season_unique": { + "name": "participant_ev_participant_season_unique", + "columns": [ + { + "expression": "participant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sports_season_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "season_participant_expected_values_participant_id_season_participants_id_fk": { + "name": "season_participant_expected_values_participant_id_season_participants_id_fk", + "tableFrom": "season_participant_expected_values", + "tableTo": "season_participants", + "columnsFrom": [ + "participant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "season_participant_expected_values_sports_season_id_sports_seasons_id_fk": { + "name": "season_participant_expected_values_sports_season_id_sports_seasons_id_fk", + "tableFrom": "season_participant_expected_values", + "tableTo": "sports_seasons", + "columnsFrom": [ + "sports_season_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.season_participant_golf_skills": { + "name": "season_participant_golf_skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "participant_id": { + "name": "participant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sports_season_id": { + "name": "sports_season_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sg_total": { + "name": "sg_total", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false + }, + "datagolf_rank": { + "name": "datagolf_rank", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "masters_odds": { + "name": "masters_odds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "us_open_odds": { + "name": "us_open_odds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "open_championship_odds": { + "name": "open_championship_odds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pga_championship_odds": { + "name": "pga_championship_odds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "season_participant_golf_skills_unique": { + "name": "season_participant_golf_skills_unique", + "columns": [ + { + "expression": "participant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sports_season_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "season_participant_golf_skills_participant_id_season_participants_id_fk": { + "name": "season_participant_golf_skills_participant_id_season_participants_id_fk", + "tableFrom": "season_participant_golf_skills", + "tableTo": "season_participants", + "columnsFrom": [ + "participant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "season_participant_golf_skills_sports_season_id_sports_seasons_id_fk": { + "name": "season_participant_golf_skills_sports_season_id_sports_seasons_id_fk", + "tableFrom": "season_participant_golf_skills", + "tableTo": "sports_seasons", + "columnsFrom": [ + "sports_season_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.season_participant_qualifying_totals": { + "name": "season_participant_qualifying_totals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "participant_id": { + "name": "participant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sports_season_id": { + "name": "sports_season_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "total_qualifying_points": { + "name": "total_qualifying_points", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "events_scored": { + "name": "events_scored", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "final_ranking": { + "name": "final_ranking", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "season_participant_qualifying_totals_participant_id_season_participants_id_fk": { + "name": "season_participant_qualifying_totals_participant_id_season_participants_id_fk", + "tableFrom": "season_participant_qualifying_totals", + "tableTo": "season_participants", + "columnsFrom": [ + "participant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "season_participant_qualifying_totals_sports_season_id_sports_seasons_id_fk": { + "name": "season_participant_qualifying_totals_sports_season_id_sports_seasons_id_fk", + "tableFrom": "season_participant_qualifying_totals", + "tableTo": "sports_seasons", + "columnsFrom": [ + "sports_season_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.season_participant_results": { + "name": "season_participant_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "participant_id": { + "name": "participant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sports_season_id": { + "name": "sports_season_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "final_position": { + "name": "final_position", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_partial_score": { + "name": "is_partial_score", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "qualifying_points": { + "name": "qualifying_points", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "season_participant_results_participant_id_season_participants_id_fk": { + "name": "season_participant_results_participant_id_season_participants_id_fk", + "tableFrom": "season_participant_results", + "tableTo": "season_participants", + "columnsFrom": [ + "participant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "season_participant_results_sports_season_id_sports_seasons_id_fk": { + "name": "season_participant_results_sports_season_id_sports_seasons_id_fk", + "tableFrom": "season_participant_results", + "tableTo": "sports_seasons", + "columnsFrom": [ + "sports_season_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.season_participants": { + "name": "season_participants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "sports_season_id": { + "name": "sports_season_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "participant_id": { + "name": "participant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "short_name": { + "name": "short_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "expected_value": { + "name": "expected_value", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "vorp_value": { + "name": "vorp_value", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "participants_sports_season_name_unique": { + "name": "participants_sports_season_name_unique", + "columns": [ + { + "expression": "sports_season_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "season_participants_sports_season_id_sports_seasons_id_fk": { + "name": "season_participants_sports_season_id_sports_seasons_id_fk", + "tableFrom": "season_participants", + "tableTo": "sports_seasons", + "columnsFrom": [ + "sports_season_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "season_participants_participant_id_participants_id_fk": { + "name": "season_participants_participant_id_participants_id_fk", + "tableFrom": "season_participants", + "tableTo": "participants", + "columnsFrom": [ + "participant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.season_sports": { + "name": "season_sports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "season_id": { + "name": "season_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sports_season_id": { + "name": "sports_season_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "season_sports_season_id_seasons_id_fk": { + "name": "season_sports_season_id_seasons_id_fk", + "tableFrom": "season_sports", + "tableTo": "seasons", + "columnsFrom": [ + "season_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "season_sports_sports_season_id_sports_seasons_id_fk": { + "name": "season_sports_sports_season_id_sports_seasons_id_fk", + "tableFrom": "season_sports", + "tableTo": "sports_seasons", + "columnsFrom": [ + "sports_season_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.season_template_sports": { + "name": "season_template_sports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "template_id": { + "name": "template_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sports_season_id": { + "name": "sports_season_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "season_template_sports_template_id_season_templates_id_fk": { + "name": "season_template_sports_template_id_season_templates_id_fk", + "tableFrom": "season_template_sports", + "tableTo": "season_templates", + "columnsFrom": [ + "template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "season_template_sports_sports_season_id_sports_seasons_id_fk": { + "name": "season_template_sports_sports_season_id_sports_seasons_id_fk", + "tableFrom": "season_template_sports", + "tableTo": "sports_seasons", + "columnsFrom": [ + "sports_season_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.season_templates": { + "name": "season_templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "year": { + "name": "year", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.seasons": { + "name": "seasons", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "league_id": { + "name": "league_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "year": { + "name": "year", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "season_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pre_draft'" + }, + "template_id": { + "name": "template_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "draft_rounds": { + "name": "draft_rounds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 20 + }, + "flex_spots": { + "name": "flex_spots", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "draft_date_time": { + "name": "draft_date_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "draft_initial_time": { + "name": "draft_initial_time", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 120 + }, + "draft_increment_time": { + "name": "draft_increment_time", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 30 + }, + "draft_timer_mode": { + "name": "draft_timer_mode", + "type": "draft_timer_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'chess_clock'" + }, + "current_pick_number": { + "name": "current_pick_number", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "draft_started_at": { + "name": "draft_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "draft_completed_at": { + "name": "draft_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "draft_paused": { + "name": "draft_paused", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "overnight_pause_mode": { + "name": "overnight_pause_mode", + "type": "overnight_pause_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "overnight_pause_start": { + "name": "overnight_pause_start", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "overnight_pause_end": { + "name": "overnight_pause_end", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "overnight_pause_timezone": { + "name": "overnight_pause_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "invite_code": { + "name": "invite_code", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "points_for_1st": { + "name": "points_for_1st", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "points_for_2nd": { + "name": "points_for_2nd", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 70 + }, + "points_for_3rd": { + "name": "points_for_3rd", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 50 + }, + "points_for_4th": { + "name": "points_for_4th", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 40 + }, + "points_for_5th": { + "name": "points_for_5th", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 25 + }, + "points_for_6th": { + "name": "points_for_6th", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 25 + }, + "points_for_7th": { + "name": "points_for_7th", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15 + }, + "points_for_8th": { + "name": "points_for_8th", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "seasons_league_id_leagues_id_fk": { + "name": "seasons_league_id_leagues_id_fk", + "tableFrom": "seasons", + "tableTo": "leagues", + "columnsFrom": [ + "league_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "seasons_template_id_season_templates_id_fk": { + "name": "seasons_template_id_season_templates_id_fk", + "tableFrom": "seasons", + "tableTo": "season_templates", + "columnsFrom": [ + "template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "seasons_invite_code_unique": { + "name": "seasons_invite_code_unique", + "nullsNotDistinct": false, + "columns": [ + "invite_code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sports": { + "name": "sports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "sport_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon_url": { + "name": "icon_url", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "simulator_type": { + "name": "simulator_type", + "type": "simulator_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sports_slug_unique": { + "name": "sports_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sports_seasons": { + "name": "sports_seasons", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "sport_id": { + "name": "sport_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "fantasy_season_id": { + "name": "fantasy_season_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "year": { + "name": "year", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "end_date": { + "name": "end_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "sports_season_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'upcoming'" + }, + "scoring_type": { + "name": "scoring_type", + "type": "scoring_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "scoring_pattern": { + "name": "scoring_pattern", + "type": "scoring_pattern", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "total_majors": { + "name": "total_majors", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "majors_completed": { + "name": "majors_completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "qualifying_points_finalized": { + "name": "qualifying_points_finalized", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "elo_calibration_exponent": { + "name": "elo_calibration_exponent", + "type": "numeric(3, 2)", + "primaryKey": false, + "notNull": false + }, + "elo_min_rating": { + "name": "elo_min_rating", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1250 + }, + "elo_max_rating": { + "name": "elo_max_rating", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1750 + }, + "simulation_status": { + "name": "simulation_status", + "type": "simulation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "draft_on": { + "name": "draft_on", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "draft_off": { + "name": "draft_off", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sports_seasons_sport_id_sports_id_fk": { + "name": "sports_seasons_sport_id_sports_id_fk", + "tableFrom": "sports_seasons", + "tableTo": "sports", + "columnsFrom": [ + "sport_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sports_seasons_fantasy_season_id_seasons_id_fk": { + "name": "sports_seasons_fantasy_season_id_seasons_id_fk", + "tableFrom": "sports_seasons", + "tableTo": "seasons", + "columnsFrom": [ + "fantasy_season_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_ev_snapshots": { + "name": "team_ev_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "season_id": { + "name": "season_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "snapshot_date": { + "name": "snapshot_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "projected_points": { + "name": "projected_points", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "actual_points": { + "name": "actual_points", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "team_ev_snapshots_unique": { + "name": "team_ev_snapshots_unique", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "season_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "snapshot_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_ev_snapshots_team_id_teams_id_fk": { + "name": "team_ev_snapshots_team_id_teams_id_fk", + "tableFrom": "team_ev_snapshots", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_ev_snapshots_season_id_seasons_id_fk": { + "name": "team_ev_snapshots_season_id_seasons_id_fk", + "tableFrom": "team_ev_snapshots", + "tableTo": "seasons", + "columnsFrom": [ + "season_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_score_events": { + "name": "team_score_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "season_id": { + "name": "season_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scoring_event_id": { + "name": "scoring_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scoring_event_name": { + "name": "scoring_event_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "sport_name": { + "name": "sport_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "match_id": { + "name": "match_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "participant_ids": { + "name": "participant_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "points_delta": { + "name": "points_delta", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "team_score_events_match_unique": { + "name": "team_score_events_match_unique", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "season_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "match_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "match_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_score_events_event_unique": { + "name": "team_score_events_event_unique", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "season_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scoring_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "match_id IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_score_events_team_id_teams_id_fk": { + "name": "team_score_events_team_id_teams_id_fk", + "tableFrom": "team_score_events", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_score_events_season_id_seasons_id_fk": { + "name": "team_score_events_season_id_seasons_id_fk", + "tableFrom": "team_score_events", + "tableTo": "seasons", + "columnsFrom": [ + "season_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_score_events_scoring_event_id_scoring_events_id_fk": { + "name": "team_score_events_scoring_event_id_scoring_events_id_fk", + "tableFrom": "team_score_events", + "tableTo": "scoring_events", + "columnsFrom": [ + "scoring_event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "team_score_events_match_id_playoff_matches_id_fk": { + "name": "team_score_events_match_id_playoff_matches_id_fk", + "tableFrom": "team_score_events", + "tableTo": "playoff_matches", + "columnsFrom": [ + "match_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_sport_scores": { + "name": "team_sport_scores", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sports_season_id": { + "name": "sports_season_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "total_points": { + "name": "total_points", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "participants_completed": { + "name": "participants_completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "participants_total": { + "name": "participants_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "calculated_at": { + "name": "calculated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_sport_scores_team_id_teams_id_fk": { + "name": "team_sport_scores_team_id_teams_id_fk", + "tableFrom": "team_sport_scores", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_sport_scores_sports_season_id_sports_seasons_id_fk": { + "name": "team_sport_scores_sports_season_id_sports_seasons_id_fk", + "tableFrom": "team_sport_scores", + "tableTo": "sports_seasons", + "columnsFrom": [ + "sports_season_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_standings": { + "name": "team_standings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "season_id": { + "name": "season_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "total_points": { + "name": "total_points", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_rank": { + "name": "current_rank", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_rank": { + "name": "previous_rank", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "first_place_count": { + "name": "first_place_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "second_place_count": { + "name": "second_place_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "third_place_count": { + "name": "third_place_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "fourth_place_count": { + "name": "fourth_place_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "fifth_place_count": { + "name": "fifth_place_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sixth_place_count": { + "name": "sixth_place_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "seventh_place_count": { + "name": "seventh_place_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "eighth_place_count": { + "name": "eighth_place_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "participants_remaining": { + "name": "participants_remaining", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "actual_points": { + "name": "actual_points", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "projected_points": { + "name": "projected_points", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "participants_finished": { + "name": "participants_finished", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "calculated_at": { + "name": "calculated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_standings_team_id_teams_id_fk": { + "name": "team_standings_team_id_teams_id_fk", + "tableFrom": "team_standings", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_standings_season_id_seasons_id_fk": { + "name": "team_standings_season_id_seasons_id_fk", + "tableFrom": "team_standings", + "tableTo": "seasons", + "columnsFrom": [ + "season_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_standings_snapshots": { + "name": "team_standings_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "season_id": { + "name": "season_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "snapshot_date": { + "name": "snapshot_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "total_points": { + "name": "total_points", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "rank": { + "name": "rank", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "first_place_count": { + "name": "first_place_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "second_place_count": { + "name": "second_place_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "third_place_count": { + "name": "third_place_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "fourth_place_count": { + "name": "fourth_place_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "fifth_place_count": { + "name": "fifth_place_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sixth_place_count": { + "name": "sixth_place_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "seventh_place_count": { + "name": "seventh_place_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "eighth_place_count": { + "name": "eighth_place_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "participants_remaining": { + "name": "participants_remaining", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "actual_points": { + "name": "actual_points", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "projected_points": { + "name": "projected_points", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "participants_finished": { + "name": "participants_finished", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "team_standings_snapshots_unique": { + "name": "team_standings_snapshots_unique", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "season_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "snapshot_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_standings_snapshots_team_id_teams_id_fk": { + "name": "team_standings_snapshots_team_id_teams_id_fk", + "tableFrom": "team_standings_snapshots", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_standings_snapshots_season_id_seasons_id_fk": { + "name": "team_standings_snapshots_season_id_seasons_id_fk", + "tableFrom": "team_standings_snapshots", + "tableTo": "seasons", + "columnsFrom": [ + "season_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.teams": { + "name": "teams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "season_id": { + "name": "season_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "logo_url": { + "name": "logo_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "flag_config": { + "name": "flag_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "avatar_type": { + "name": "avatar_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'owner'" + }, + "owner_id": { + "name": "owner_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "teams_season_id_seasons_id_fk": { + "name": "teams_season_id_seasons_id_fk", + "tableFrom": "teams", + "tableTo": "seasons", + "columnsFrom": [ + "season_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tournament_group_members": { + "name": "tournament_group_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tournament_group_id": { + "name": "tournament_group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "participant_id": { + "name": "participant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "eliminated": { + "name": "eliminated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "tournament_group_members_tournament_group_id_tournament_groups_id_fk": { + "name": "tournament_group_members_tournament_group_id_tournament_groups_id_fk", + "tableFrom": "tournament_group_members", + "tableTo": "tournament_groups", + "columnsFrom": [ + "tournament_group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tournament_group_members_participant_id_season_participants_id_fk": { + "name": "tournament_group_members_participant_id_season_participants_id_fk", + "tableFrom": "tournament_group_members", + "tableTo": "season_participants", + "columnsFrom": [ + "participant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tournament_groups": { + "name": "tournament_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "scoring_event_id": { + "name": "scoring_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "group_name": { + "name": "group_name", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "tournament_groups_scoring_event_id_scoring_events_id_fk": { + "name": "tournament_groups_scoring_event_id_scoring_events_id_fk", + "tableFrom": "tournament_groups", + "tableTo": "scoring_events", + "columnsFrom": [ + "scoring_event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tournament_results": { + "name": "tournament_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tournament_id": { + "name": "tournament_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "participant_id": { + "name": "participant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "placement": { + "name": "placement", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "raw_score": { + "name": "raw_score", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tournament_results_tournament_participant_unique": { + "name": "tournament_results_tournament_participant_unique", + "columns": [ + { + "expression": "tournament_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "participant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tournament_results_tournament_id_tournaments_id_fk": { + "name": "tournament_results_tournament_id_tournaments_id_fk", + "tableFrom": "tournament_results", + "tableTo": "tournaments", + "columnsFrom": [ + "tournament_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tournament_results_participant_id_participants_id_fk": { + "name": "tournament_results_participant_id_participants_id_fk", + "tableFrom": "tournament_results", + "tableTo": "participants", + "columnsFrom": [ + "participant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tournaments": { + "name": "tournaments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "sport_id": { + "name": "sport_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "year": { + "name": "year", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "surface": { + "name": "surface", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "tournament_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "external_key": { + "name": "external_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tournaments_sport_name_year_unique": { + "name": "tournaments_sport_name_year_unique", + "columns": [ + { + "expression": "sport_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "year", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tournaments_sport_id_sports_id_fk": { + "name": "tournaments_sport_id_sports_id_fk", + "tableFrom": "tournaments", + "tableTo": "sports", + "columnsFrom": [ + "sport_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "clerk_id": { + "name": "clerk_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "flag_config": { + "name": "flag_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "custom_avatar_url": { + "name": "custom_avatar_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "avatar_type": { + "name": "avatar_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'flag'" + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "timezone": { + "name": "timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_clerk_id_unique": { + "name": "users_clerk_id_unique", + "nullsNotDistinct": false, + "columns": [ + "clerk_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.watchlist": { + "name": "watchlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "season_id": { + "name": "season_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "participant_id": { + "name": "participant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "watchlist_season_team_participant_unique": { + "name": "watchlist_season_team_participant_unique", + "columns": [ + { + "expression": "season_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "participant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "watchlist_season_id_seasons_id_fk": { + "name": "watchlist_season_id_seasons_id_fk", + "tableFrom": "watchlist", + "tableTo": "seasons", + "columnsFrom": [ + "season_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "watchlist_team_id_teams_id_fk": { + "name": "watchlist_team_id_teams_id_fk", + "tableFrom": "watchlist", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "watchlist_participant_id_season_participants_id_fk": { + "name": "watchlist_participant_id_season_participants_id_fk", + "tableFrom": "watchlist", + "tableTo": "season_participants", + "columnsFrom": [ + "participant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.audit_action": { + "name": "audit_action", + "schema": "public", + "values": [ + "league_settings_changed", + "draft_settings_changed", + "scoring_rules_changed", + "sports_changed", + "draft_order_set", + "draft_order_randomized", + "draft_started", + "draft_paused", + "draft_resumed", + "draft_reset", + "draft_rollback", + "force_autopick", + "force_manual_pick", + "draft_pick_changed", + "time_bank_edited", + "brackt_resolved" + ] + }, + "public.autodraft_mode": { + "name": "autodraft_mode", + "schema": "public", + "values": [ + "next_pick", + "while_on" + ] + }, + "public.draft_timer_mode": { + "name": "draft_timer_mode", + "schema": "public", + "values": [ + "chess_clock", + "standard" + ] + }, + "public.event_type": { + "name": "event_type", + "schema": "public", + "values": [ + "playoff_game", + "major_tournament", + "final_standings", + "schedule_event" + ] + }, + "public.overnight_pause_mode": { + "name": "overnight_pause_mode", + "schema": "public", + "values": [ + "none", + "league", + "per_user" + ] + }, + "public.picked_by_type": { + "name": "picked_by_type", + "schema": "public", + "values": [ + "owner", + "commissioner", + "admin", + "auto" + ] + }, + "public.playoff_match_game_status": { + "name": "playoff_match_game_status", + "schema": "public", + "values": [ + "scheduled", + "complete", + "postponed" + ] + }, + "public.probability_source": { + "name": "probability_source", + "schema": "public", + "values": [ + "manual", + "futures_odds", + "elo_simulation", + "performance_model" + ] + }, + "public.scoring_pattern": { + "name": "scoring_pattern", + "schema": "public", + "values": [ + "playoff_bracket", + "season_standings", + "qualifying_points" + ] + }, + "public.scoring_type": { + "name": "scoring_type", + "schema": "public", + "values": [ + "playoffs", + "regular_season", + "majors" + ] + }, + "public.season_status": { + "name": "season_status", + "schema": "public", + "values": [ + "pre_draft", + "draft", + "active", + "completed" + ] + }, + "public.simulation_status": { + "name": "simulation_status", + "schema": "public", + "values": [ + "idle", + "running", + "failed" + ] + }, + "public.simulator_type": { + "name": "simulator_type", + "schema": "public", + "values": [ + "f1_standings", + "indycar_standings", + "golf_qualifying_points", + "playoff_bracket", + "ucl_bracket", + "ncaam_bracket", + "ncaaw_bracket", + "nba_bracket", + "nhl_bracket", + "nfl_bracket", + "afl_bracket", + "snooker_bracket", + "tennis_qualifying_points", + "mlb_bracket", + "wnba_bracket", + "world_cup", + "darts_bracket", + "cs2_major_qualifying_points", + "ncaa_football_bracket", + "llws_bracket", + "brackt" + ] + }, + "public.sport_type": { + "name": "sport_type", + "schema": "public", + "values": [ + "team", + "individual" + ] + }, + "public.sports_season_status": { + "name": "sports_season_status", + "schema": "public", + "values": [ + "upcoming", + "active", + "completed" + ] + }, + "public.tournament_status": { + "name": "tournament_status", + "schema": "public", + "values": [ + "scheduled", + "in_progress", + "completed" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index ee8577f..c2d7c39 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -666,6 +666,13 @@ "when": 1777920441672, "tag": "0094_curved_nick_fury", "breakpoints": true + }, + { + "idx": 95, + "version": "7", + "when": 1778043585280, + "tag": "0095_lucky_madrox", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index df84091..dfec4cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "bcrypt": "^6.0.0", "better-auth": "^1.6.9", "class-variance-authority": "^0.7.1", + "cloudinary": "^2.10.0", "clsx": "^2.1.1", "compression": "^1.8.0", "date-fns": "^4.1.0", @@ -46,6 +47,7 @@ "react": "^19.1.0", "react-day-picker": "^9.11.1", "react-dom": "^19.1.0", + "react-image-crop": "^11.0.10", "react-is": "^19.2.5", "react-router": "^7.7.1", "recharts": "^3.4.1", @@ -8690,6 +8692,18 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/cloudinary": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/cloudinary/-/cloudinary-2.10.0.tgz", + "integrity": "sha512-sY09kYg7wprkndAOjZBAYqFZqwL+SxnEGcAvksOvFA+5upnFn949UjkEkHKNSwkBtW/xRDd0p6NgbSXZcxkI3w==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.23" + }, + "engines": { + "node": ">=9" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -12614,10 +12628,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true, + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash.once": { @@ -14506,6 +14519,15 @@ "react": "^19.2.4" } }, + "node_modules/react-image-crop": { + "version": "11.0.10", + "resolved": "https://registry.npmjs.org/react-image-crop/-/react-image-crop-11.0.10.tgz", + "integrity": "sha512-+5FfDXUgYLLqBh1Y/uQhIycpHCbXkI50a+nbfkB1C0xXXUTwkisHDo2QCB1SQJyHCqIuia4FeyReqXuMDKWQTQ==", + "license": "ISC", + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/react-inspector": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/react-inspector/-/react-inspector-9.0.0.tgz", diff --git a/package.json b/package.json index 821019d..00184e8 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "bcrypt": "^6.0.0", "better-auth": "^1.6.9", "class-variance-authority": "^0.7.1", + "cloudinary": "^2.10.0", "clsx": "^2.1.1", "compression": "^1.8.0", "date-fns": "^4.1.0", @@ -68,6 +69,7 @@ "react": "^19.1.0", "react-day-picker": "^9.11.1", "react-dom": "^19.1.0", + "react-image-crop": "^11.0.10", "react-is": "^19.2.5", "react-router": "^7.7.1", "recharts": "^3.4.1", diff --git a/plans/avatar-system.md b/plans/avatar-system.md new file mode 100644 index 0000000..c6e18ba --- /dev/null +++ b/plans/avatar-system.md @@ -0,0 +1,270 @@ +# Avatar System: Flag Builder + Photo Upload + +## Context + +Users and teams currently show initials in a colored box when no avatar is set. OAuth users get their Google/Discord photo in `users.imageUrl`, but there's no custom upload path and no procedurally generated identity. This plan adds: + +1. **Flag avatars** — SVG flags (12 patterns, 2–3 colors) as the default identity for every user and team, editable via a builder UI. +2. **Photo upload** — Cloudinary-hosted images with Rekognition NSFW moderation before display. +3. **Hierarchy** — teams inherit their owner's avatar by default; users and teams can independently override. + +--- + +## Architecture Decisions + +- **Image hosting**: Cloudinary. Store at 256×256, serve at 45×45 or 18×18 via URL-based transformations (e.g. `w_45,h_45,c_fill`). Transformations are generated once per unique image+size then CDN-cached; cost is ~2 credits/1k new uploads. At ~2 KB average delivered size, the free tier (25 GB bandwidth) covers ~80,000 active users. NSFW moderation via the AWS Rekognition add-on. Images are never shown until the webhook approves them. +- **Flag library**: None exists. Build a custom `FlagSvg` component (~300 lines). Pure SVG, no dependencies, SSR-safe. +- **DB**: Two new JSONB/varchar columns each on `users` and `teams`; keep existing `imageUrl` / `logoUrl` untouched. +- **OAuth protection**: BetterAuth's `updateUser` hook will be patched to skip writing `imageUrl` once a user has set a custom avatar. + +--- + +## DB Schema Changes + +**`database/schema.ts`** — add to `users`: +```ts +flagConfig: jsonb("flag_config"), // {pattern: string, colors: string[]} +customAvatarUrl: varchar("custom_avatar_url", { length: 512 }), // Cloudinary URL (post-approval) +avatarType: varchar("avatar_type", { length: 20 }).notNull().default("flag"), +// avatarType: 'flag' | 'uploaded' +``` + +Add to `teams`: +```ts +flagConfig: jsonb("flag_config"), +avatarType: varchar("avatar_type", { length: 20 }).notNull().default("owner"), +// avatarType: 'owner' | 'flag' | 'uploaded' +// existing logoUrl reused for uploaded team images +``` + +Run `npm run db:generate` then `npm run db:migrate`. + +--- + +## Avatar Display Hierarchy + +``` +getUserAvatarData(user): + if avatarType === 'uploaded' && customAvatarUrl → {type: 'image', url: customAvatarUrl} + if flagConfig → {type: 'flag', config: flagConfig} + if imageUrl → {type: 'image', url: imageUrl} // OAuth fallback + → {type: 'initials'} + +getTeamAvatarData(team, owner): + if avatarType === 'uploaded' && logoUrl → {type: 'image', url: logoUrl} + if avatarType === 'flag' && flagConfig → {type: 'flag', config: flagConfig} + → getUserAvatarData(owner) // 'owner' default + final fallback +``` + +--- + +## Phases + +### Phase 1 — Flag SVG Engine + +**New files:** +- `app/lib/flag-types.ts` — `FlagPattern` union type, `FlagConfig` interface +- `app/components/ui/FlagSvg.tsx` — renders `` from `{pattern, colors, size?}` +- `app/lib/flag-generator.ts` — `generateFlagConfig(seed: string): FlagConfig` (deterministic from hash) + +**12 patterns** (pure SVG geometry, all square viewport): + +| Pattern | SVG approach | +|---|---| +| `triband-h` | 3 stacked `` | +| `triband-v` | 3 side-by-side `` | +| `diagonal` | `` bg + `` triangle | +| `nordic-cross` | bg + two crossing `` strips, cross offset left | +| `cross` | bg + two centered `` strips | +| `canton` | bg + `` overlay top-left quarter | +| `triangle` | bg + `` left-pointing triangle | +| `chevron` | bg + `` V pointing right | +| `quartered` | 4 `` in 2×2 | +| `bordered` | bg `` + inset `` with gap | +| `circle` | bg + `` center | +| `star` | bg + `` 5-point star | + +`generateFlagConfig` picks pattern + 2-3 colors deterministically using `hashString` from `app/lib/avatar-colors.ts` (reuse existing). Colors drawn from `AVATAR_COLORS` palette + white/black. + +**Tests:** `app/lib/__tests__/flag-generator.test.ts` + +--- + +### Phase 2 — Schema Migration + Backfill + +1. Add columns to schema (`database/schema.ts`) +2. `npm run db:generate` → `npm run db:migrate` +3. Update model functions: + - `app/models/user.ts` — add `flagConfig`, `customAvatarUrl`, `avatarType` to select/update types + - `app/models/team.ts` — add `flagConfig`, `avatarType` +4. Backfill script: `scripts/backfill-flag-configs.ts` + - Queries all users/teams where `flagConfig IS NULL` + - Calls `generateFlagConfig(id)` for each + - Batch-updates. Run once manually: `npx tsx scripts/backfill-flag-configs.ts` + +--- + +### Phase 3 — Avatar Display Components + +**Modified files:** +- `app/components/TeamAvatar.tsx` — accept `flagConfig?` prop; render `` when present instead of initials +- `app/components/UserMenu.tsx` — use `customAvatarUrl ?? flagConfig ?? imageUrl` priority +- **New:** `app/components/ui/UserAvatar.tsx` — mirrors `TeamAvatar` for user contexts + +`TeamAvatar` new props: +```ts +interface TeamAvatarProps { + teamId: string; + teamName: string; + logoUrl?: string | null; + flagConfig?: FlagConfig | null; + avatarType?: 'owner' | 'flag' | 'uploaded'; + ownerAvatarData?: AvatarData; // resolved by parent from owner user + size?: 'sm' | 'md' | 'lg'; +} +``` + +Add `xl` size (`h-16 w-16`) to both components for the editor preview. + +--- + +### Phase 4 — Flag Builder UI + +**New files:** +- `app/components/ui/FlagBuilder.tsx` — interactive editor + - Grid of 12 pattern thumbnails (4-col, using `` at ~60×60px) + - Selected pattern highlighted with brackt green border + - Color slot pickers: each slot shows the 6 brackt palette swatches + a custom hex `