diff --git a/frontend/app/api/leaderboard/route.ts b/frontend/app/api/leaderboard/route.ts index d9b31e0..e79706d 100644 --- a/frontend/app/api/leaderboard/route.ts +++ b/frontend/app/api/leaderboard/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { headers } from "next/headers"; +import { createClient as createSupabaseClient } from "@supabase/supabase-js"; import { createClient } from "@/utils/supabaseServerClient"; type LeaderboardRow = { @@ -9,8 +10,26 @@ type LeaderboardRow = { uploaded_note_count: number | null; total_credits_earned: number | null; credit_score: number | null; + created_at: string; }; +type WeeklyResourceRow = { + profile_id: string; +}; + +type WeeklyCreditRow = { + profile_id: string; + amount: number; + source: string; + resource_id: string | null; +}; + +type ResourceStatusRow = { + id: string; +}; + +type Period = "all_time" | "this_week"; + function buildInitials(displayName: string | null, handle: string | null): string { const source = (displayName?.trim() || handle?.trim() || "U").replace(/^@/, ""); const parts = source.split(/\s+/).filter(Boolean); @@ -22,7 +41,7 @@ function buildInitials(displayName: string | null, handle: string | null): strin return source.slice(0, 2).toUpperCase(); } -export async function GET() { +export async function GET(req: Request) { const headerStore = await headers(); const authHeader = headerStore.get("authorization"); const bearerToken = authHeader?.toLowerCase().startsWith("bearer ") @@ -48,28 +67,155 @@ export async function GET() { return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); } - const { data, error } = await supabase + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + const supabaseServiceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + if (!supabaseUrl || !supabaseServiceRoleKey) { + return NextResponse.json( + { error: "Supabase environment variables are not configured." }, + { status: 500 }, + ); + } + const adminClient = createSupabaseClient(supabaseUrl, supabaseServiceRoleKey); + + const periodParam = new URL(req.url).searchParams.get("period"); + const period: Period = periodParam === "this_week" ? "this_week" : "all_time"; + + const { data, error } = await adminClient .from("profiles") - .select("id, handle, display_name, uploaded_note_count, total_credits_earned, credit_score") - .order("uploaded_note_count", { ascending: false }) - .order("total_credits_earned", { ascending: false }) - .order("credit_score", { ascending: false }) - .order("created_at", { ascending: true }) - .limit(100) + .select("id, handle, display_name, uploaded_note_count, total_credits_earned, credit_score, created_at") .returns(); if (error) { return NextResponse.json({ error: error.message }, { status: 500 }); } - const entries = (data ?? []).map((row, index) => ({ - rank: index + 1, - userId: row.id, - name: row.display_name?.trim() || row.handle || "Anonymous", - uploads: Number(row.uploaded_note_count ?? 0), - credits: Number(row.credit_score ?? 0), - avatar: buildInitials(row.display_name, row.handle), - })); + const profiles = data ?? []; + const sortedAllTime = [...profiles].sort((a, b) => { + const uploadDiff = Number(b.uploaded_note_count ?? 0) - Number(a.uploaded_note_count ?? 0); + if (uploadDiff !== 0) return uploadDiff; + + const creditsEarnedDiff = Number(b.total_credits_earned ?? 0) - Number(a.total_credits_earned ?? 0); + if (creditsEarnedDiff !== 0) return creditsEarnedDiff; + + const creditScoreDiff = Number(b.credit_score ?? 0) - Number(a.credit_score ?? 0); + if (creditScoreDiff !== 0) return creditScoreDiff; + + return a.created_at.localeCompare(b.created_at); + }); + + if (period === "all_time") { + const entries = sortedAllTime + .filter((row) => Number(row.uploaded_note_count ?? 0) > 0) + .map((row, index) => ({ + rank: index + 1, + userId: row.id, + name: row.display_name?.trim() || row.handle || "Anonymous", + uploads: Number(row.uploaded_note_count ?? 0), + credits: Number(row.credit_score ?? 0), + avatar: buildInitials(row.display_name, row.handle), + })); + + return NextResponse.json({ leaderboard: entries }, { status: 200 }); + } + + const weekStart = new Date(); + weekStart.setUTCHours(0, 0, 0, 0); + weekStart.setUTCDate(weekStart.getUTCDate() - weekStart.getUTCDay()); + + const { data: weeklyRows, error: weeklyError } = await adminClient + .from("resources") + .select("profile_id") + .eq("status", "active") + .gte("created_at", weekStart.toISOString()) + .returns(); + + if (weeklyError) { + return NextResponse.json({ error: weeklyError.message }, { status: 500 }); + } + + const weeklyUploadCounts = new Map(); + for (const row of weeklyRows ?? []) { + weeklyUploadCounts.set(row.profile_id, (weeklyUploadCounts.get(row.profile_id) ?? 0) + 1); + } + + const { data: weeklyCreditRows, error: weeklyCreditsError } = await adminClient + .from("credits_ledger") + .select("profile_id, amount, source, resource_id") + .in("source", ["upload_reward", "upvote_bonus"]) + .gte("created_at", weekStart.toISOString()) + .returns(); + + if (weeklyCreditsError) { + return NextResponse.json({ error: weeklyCreditsError.message }, { status: 500 }); + } + + const uploadRewardResourceIds = Array.from( + new Set( + (weeklyCreditRows ?? []) + .filter((row) => row.source === "upload_reward" && row.resource_id) + .map((row) => row.resource_id as string), + ), + ); + const activeUploadRewardResourceIds = new Set(); + if (uploadRewardResourceIds.length > 0) { + const { data: activeResources, error: activeResourcesError } = await adminClient + .from("resources") + .select("id") + .eq("status", "active") + .in("id", uploadRewardResourceIds) + .returns(); + + if (activeResourcesError) { + return NextResponse.json({ error: activeResourcesError.message }, { status: 500 }); + } + + for (const resource of activeResources ?? []) { + activeUploadRewardResourceIds.add(resource.id); + } + } + + const weeklyCreditsEarned = new Map(); + for (const row of weeklyCreditRows ?? []) { + if (Number(row.amount ?? 0) <= 0) continue; + if ( + row.source === "upload_reward" && + (!row.resource_id || !activeUploadRewardResourceIds.has(row.resource_id)) + ) { + continue; + } + weeklyCreditsEarned.set( + row.profile_id, + (weeklyCreditsEarned.get(row.profile_id) ?? 0) + Number(row.amount ?? 0), + ); + } + + const weeklyEntries = sortedAllTime + .map((row) => ({ + ...row, + weekUploadCount: weeklyUploadCounts.get(row.id) ?? 0, + weekCreditsEarned: weeklyCreditsEarned.get(row.id) ?? 0, + })) + .filter((row) => row.weekUploadCount > 0 || row.weekCreditsEarned > 0) + .sort((a, b) => { + const uploadDiff = b.weekUploadCount - a.weekUploadCount; + if (uploadDiff !== 0) return uploadDiff; + + const weeklyCreditsDiff = b.weekCreditsEarned - a.weekCreditsEarned; + if (weeklyCreditsDiff !== 0) return weeklyCreditsDiff; + + const creditScoreDiff = Number(b.credit_score ?? 0) - Number(a.credit_score ?? 0); + if (creditScoreDiff !== 0) return creditScoreDiff; + + return a.created_at.localeCompare(b.created_at); + }) + .map((row, index) => ({ + rank: index + 1, + userId: row.id, + name: row.display_name?.trim() || row.handle || "Anonymous", + uploads: row.weekUploadCount, + credits: row.weekCreditsEarned, + avatar: buildInitials(row.display_name, row.handle), + })); - return NextResponse.json({ leaderboard: entries }, { status: 200 }); + return NextResponse.json({ leaderboard: weeklyEntries }, { status: 200 }); } diff --git a/frontend/app/api/upload/route.ts b/frontend/app/api/upload/route.ts index 33b6ee9..f88c0df 100644 --- a/frontend/app/api/upload/route.ts +++ b/frontend/app/api/upload/route.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { NextRequest, NextResponse } from "next/server"; -import { createClient } from "@supabase/supabase-js"; +import { createClient, type SupabaseClient } from "@supabase/supabase-js"; const MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024; const PDF_MIME_TYPES = new Set(["application/pdf"]); @@ -33,7 +33,7 @@ const buildFilePath = (userId: string, originalName: string) => { return `${userId}/${suffix}-${normalizedBase}.pdf`; }; -const ensureResourcesBucket = async (adminClient: ReturnType) => { +const ensureResourcesBucket = async (adminClient: SupabaseClient) => { const { data: buckets, error: listError } = await adminClient.storage.listBuckets(); if (listError) { throw listError; @@ -57,7 +57,8 @@ export async function POST(req: NextRequest) { const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; const supabaseServiceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; - const bypassEnabled = process.env.NEXT_PUBLIC_UPLOAD_BYPASS === "true"; + const bypassEnabled = + process.env.NODE_ENV !== "production" && process.env.NEXT_PUBLIC_UPLOAD_BYPASS === "true"; const bypassProfileId = process.env.UPLOAD_BYPASS_PROFILE_ID; if (!supabaseUrl || !supabaseAnonKey || !supabaseServiceRoleKey) { @@ -193,37 +194,5 @@ export async function POST(req: NextRequest) { ); } - // update credit_score in profiles database - - // read current credit_score - const { data: profile, error: readError } = await adminClient - .from("profiles") - .select("credit_score") - .eq("id", userId) - .single(); - - if (readError || !profile) { - return NextResponse.json( - { error: "failed to read credit score", details: readError?.message }, - { status: 500 }, - ); - } - - // create new credit_score - const newCreditScore = (profile.credit_score ?? 0) + 5; - - // update credit_score in database - const { error: updateError } = await adminClient - .from("profiles") - .update({ credit_score: newCreditScore }) - .eq("id", userId); - - if (updateError) { - return NextResponse.json( - { error: "failed to update credit score", details: updateError.message }, - { status: 500 }, - ); - } - return NextResponse.json({ data: resource }, { status: 200 }); } diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx index 46feadc..428c77b 100644 --- a/frontend/app/dashboard/page.tsx +++ b/frontend/app/dashboard/page.tsx @@ -85,7 +85,7 @@ export default function DashboardPage() { const [searchResults, setSearchResults] = useState(null); const [searchLoading, setSearchLoading] = useState(false); const searchCacheRef = useRef>(new Map()); - const searchDebounceRef = useRef | null>(null); + const searchDebounceRef = useRef(null); const [isCourseRequestOpen, setIsCourseRequestOpen] = useState(false); const [courseRequest, setCourseRequest] = @@ -232,26 +232,32 @@ export default function DashboardPage() { const SEARCH_DEBOUNCE_MS = 280; useEffect(() => { - if (selectedDepartment != null) { - setSearchResults(null); - setSearchLoading(false); + const clearSearchDebounce = () => { if (searchDebounceRef.current) { clearTimeout(searchDebounceRef.current); searchDebounceRef.current = null; } + }; + + const scheduleSearchReset = () => { + clearSearchDebounce(); + searchDebounceRef.current = window.setTimeout(() => { + searchDebounceRef.current = null; + setSearchResults(null); + setSearchLoading(false); + }, 0); + }; + + if (selectedDepartment != null) { + scheduleSearchReset(); return; } const q = browseSearch.trim(); if (!q) { - setSearchResults(null); - setSearchLoading(false); - if (searchDebounceRef.current) { - clearTimeout(searchDebounceRef.current); - searchDebounceRef.current = null; - } + scheduleSearchReset(); return; } - if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); + clearSearchDebounce(); searchDebounceRef.current = window.setTimeout(() => { searchDebounceRef.current = null; const normalized = q.toUpperCase().replace(/\s+/g, " "); @@ -299,10 +305,7 @@ export default function DashboardPage() { }); }, SEARCH_DEBOUNCE_MS); return () => { - if (searchDebounceRef.current) { - clearTimeout(searchDebounceRef.current); - searchDebounceRef.current = null; - } + clearSearchDebounce(); }; }, [browseSearch, selectedDepartment, accessToken, refreshToken, fetchCoursesBySearch]); diff --git a/frontend/app/leaderboard/page.tsx b/frontend/app/leaderboard/page.tsx index ad3d4c1..05fb07a 100644 --- a/frontend/app/leaderboard/page.tsx +++ b/frontend/app/leaderboard/page.tsx @@ -13,12 +13,15 @@ type LeaderboardEntry = { avatar: string; }; +type LeaderboardPeriod = "all_time" | "this_week"; + export default function LeaderboardPage() { const [isVisible, setIsVisible] = useState(false); const [entries, setEntries] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [accessToken, setAccessToken] = useState(null); + const [period, setPeriod] = useState("all_time"); useEffect(() => { setIsVisible(true); @@ -45,7 +48,8 @@ export default function LeaderboardPage() { setError(null); try { - const res = await fetch("/api/leaderboard", { + const params = new URLSearchParams({ period }); + const res = await fetch(`/api/leaderboard?${params.toString()}`, { headers: { Authorization: `Bearer ${accessToken}` }, }); @@ -64,7 +68,7 @@ export default function LeaderboardPage() { } finally { setLoading(false); } - }, [accessToken]); + }, [accessToken, period]); useEffect(() => { if (!accessToken) return; @@ -83,6 +87,7 @@ export default function LeaderboardPage() { const third = entries[2] ?? null; const hasPodium = entries.length >= 3; const listStartIndex = hasPodium ? 3 : 0; + const creditLabel = period === "all_time" ? "credits" : "earned"; return (
@@ -95,7 +100,35 @@ export default function LeaderboardPage() { }`} >

Leaderboard

-

Ranked by total uploaded notes

+

+ {period === "all_time" ? "Ranked by total uploaded notes" : "Ranked by notes uploaded this week"} +

+
+
+ + +
+
{loading &&

Loading leaderboard...

} @@ -166,7 +199,7 @@ export default function LeaderboardPage() {
{user.credits}
-
credits
+
{creditLabel}
diff --git a/frontend/app/upload/page.tsx b/frontend/app/upload/page.tsx index f6acf18..9f23f5e 100644 --- a/frontend/app/upload/page.tsx +++ b/frontend/app/upload/page.tsx @@ -4,7 +4,6 @@ import { useState, useEffect, useMemo, useCallback } from "react"; import { createPortal } from "react-dom"; import { useRouter } from "next/navigation"; -import Link from "next/link"; import { getSessionWithRecovery, supabase } from "@/lib/supabaseClient"; import { DesignNav } from "@/app/components/DesignNav"; import ProfileIcons from "@/app/dashboard/profile-icon"; @@ -126,7 +125,9 @@ export default function UploadPage() { if (cancelled) return; if (res.ok) { const data = (await res.json()) as { credits?: number }; - setCredits(Number.isFinite(data?.credits) ? data.credits : 0); + const nextCredits = + typeof data?.credits === "number" && Number.isFinite(data.credits) ? data.credits : 0; + setCredits(nextCredits); } else { setCredits(null); } diff --git a/supabase/migrations/202602231230_add_rpc_grant_upload_reward.sql b/supabase/migrations/202602231230_add_rpc_grant_upload_reward.sql new file mode 100644 index 0000000..d947f7c --- /dev/null +++ b/supabase/migrations/202602231230_add_rpc_grant_upload_reward.sql @@ -0,0 +1,60 @@ +create or replace function public.rpc_grant_upload_reward( + p_profile_id uuid, + p_resource_id uuid, + p_amount integer default 5 +) +returns table ( + granted boolean +) +language plpgsql +security definer +set search_path = public +as $$ +declare + v_owner uuid; + v_exists boolean; +begin + if p_amount <= 0 then + raise exception 'p_amount must be positive'; + end if; + + select profile_id into v_owner + from public.resources + where id = p_resource_id; + + if v_owner is null then + raise exception 'Resource % not found', p_resource_id; + end if; + + if v_owner <> p_profile_id then + raise exception 'Resource owner mismatch for %', p_resource_id; + end if; + + select exists ( + select 1 + from public.credits_ledger + where resource_id = p_resource_id + and source = 'upload_reward' + ) into v_exists; + + if v_exists then + return query select false; + return; + end if; + + insert into public.credits_ledger (profile_id, resource_id, source, amount, metadata) + values ( + p_profile_id, + p_resource_id, + 'upload_reward', + p_amount, + jsonb_build_object('reason', 'upload_reward') + ); + + update public.profiles + set credit_score = coalesce(credit_score, 0) + p_amount + where id = p_profile_id; + + return query select true; +end; +$$; diff --git a/supabase/migrations/202602231300_harden_upload_reward_flow.sql b/supabase/migrations/202602231300_harden_upload_reward_flow.sql new file mode 100644 index 0000000..3e4ac47 --- /dev/null +++ b/supabase/migrations/202602231300_harden_upload_reward_flow.sql @@ -0,0 +1,128 @@ +-- Prevent duplicate upload rewards from historical race windows and repair balances. +with ranked_upload_rewards as ( + select + id, + profile_id, + amount, + row_number() over (partition by resource_id order by id asc) as rn + from public.credits_ledger + where source = 'upload_reward' + and resource_id is not null +), removed_rewards as ( + delete from public.credits_ledger cl + using ranked_upload_rewards r + where cl.id = r.id + and r.rn > 1 + returning cl.profile_id, cl.amount +), removed_totals as ( + select profile_id, coalesce(sum(amount), 0) as removed_amount + from removed_rewards + group by profile_id +) +update public.profiles p +set credit_score = greatest(coalesce(p.credit_score, 0) - removed_totals.removed_amount, 0) +from removed_totals +where p.id = removed_totals.profile_id; + +-- Enforce one upload_reward per resource at the database level. +create unique index if not exists idx_credits_ledger_upload_reward_one_per_resource + on public.credits_ledger (resource_id) + where source = 'upload_reward' and resource_id is not null; + +create or replace function public.rpc_grant_upload_reward( + p_profile_id uuid, + p_resource_id uuid, + p_amount integer default 5 +) +returns table ( + granted boolean +) +language plpgsql +security definer +set search_path = public +as $$ +declare + v_owner uuid; + v_status public.resource_status; + v_inserted boolean := false; +begin + if p_amount <= 0 then + raise exception 'p_amount must be positive'; + end if; + + select profile_id, status into v_owner, v_status + from public.resources + where id = p_resource_id; + + if v_owner is null then + raise exception 'Resource % not found', p_resource_id; + end if; + + if v_owner <> p_profile_id then + raise exception 'Resource owner mismatch for %', p_resource_id; + end if; + + if v_status <> 'active' then + raise exception 'Resource % is not active', p_resource_id; + end if; + + begin + insert into public.credits_ledger (profile_id, resource_id, source, amount, metadata) + values ( + p_profile_id, + p_resource_id, + 'upload_reward', + p_amount, + jsonb_build_object('reason', 'upload_reward') + ); + v_inserted := true; + exception + when unique_violation then + v_inserted := false; + end; + + if v_inserted then + update public.profiles + set credit_score = coalesce(credit_score, 0) + p_amount + where id = p_profile_id; + end if; + + return query select v_inserted; +end; +$$; + +create or replace function public.fn_award_upload_reward_on_activation() +returns trigger +language plpgsql +security definer +set search_path = public +as $$ +begin + if tg_op = 'INSERT' and new.status = 'active' then + perform public.rpc_grant_upload_reward(new.profile_id, new.id, 5); + elsif tg_op = 'UPDATE' + and old.status is distinct from new.status + and new.status = 'active' then + perform public.rpc_grant_upload_reward(new.profile_id, new.id, 5); + end if; + + return new; +end; +$$; + +drop trigger if exists trg_resources_award_upload_reward on public.resources; + +create trigger trg_resources_award_upload_reward +after insert or update of status on public.resources +for each row execute function public.fn_award_upload_reward_on_activation(); + +-- Harden RPC execution. These should only be callable by server-side role. +revoke all on function public.rpc_grant_upload_reward(uuid, uuid, integer) from public; +revoke all on function public.rpc_grant_upload_reward(uuid, uuid, integer) from anon; +revoke all on function public.rpc_grant_upload_reward(uuid, uuid, integer) from authenticated; +grant execute on function public.rpc_grant_upload_reward(uuid, uuid, integer) to service_role; + +revoke all on function public.rpc_award_upload_credits(uuid) from public; +revoke all on function public.rpc_award_upload_credits(uuid) from anon; +revoke all on function public.rpc_award_upload_credits(uuid) from authenticated; +grant execute on function public.rpc_award_upload_credits(uuid) to service_role;