Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 163 additions & 17 deletions frontend/app/api/leaderboard/route.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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);
Expand All @@ -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 ")
Expand All @@ -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<LeaderboardRow[]>();

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<WeeklyResourceRow[]>();

if (weeklyError) {
return NextResponse.json({ error: weeklyError.message }, { status: 500 });
}

const weeklyUploadCounts = new Map<string, number>();
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<WeeklyCreditRow[]>();

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<string>();
if (uploadRewardResourceIds.length > 0) {
const { data: activeResources, error: activeResourcesError } = await adminClient
.from("resources")
.select("id")
.eq("status", "active")
.in("id", uploadRewardResourceIds)
.returns<ResourceStatusRow[]>();

if (activeResourcesError) {
return NextResponse.json({ error: activeResourcesError.message }, { status: 500 });
}

for (const resource of activeResources ?? []) {
activeUploadRewardResourceIds.add(resource.id);
}
}

const weeklyCreditsEarned = new Map<string, number>();
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 });
}
39 changes: 4 additions & 35 deletions frontend/app/api/upload/route.ts
Original file line number Diff line number Diff line change
@@ -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"]);
Expand Down Expand Up @@ -33,7 +33,7 @@ const buildFilePath = (userId: string, originalName: string) => {
return `${userId}/${suffix}-${normalizedBase}.pdf`;
};

const ensureResourcesBucket = async (adminClient: ReturnType<typeof createClient>) => {
const ensureResourcesBucket = async (adminClient: SupabaseClient) => {
const { data: buckets, error: listError } = await adminClient.storage.listBuckets();
if (listError) {
throw listError;
Expand All @@ -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) {
Expand Down Expand Up @@ -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 });
}
33 changes: 18 additions & 15 deletions frontend/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export default function DashboardPage() {
const [searchResults, setSearchResults] = useState<CourseOption[] | null>(null);
const [searchLoading, setSearchLoading] = useState(false);
const searchCacheRef = useRef<Map<string, CourseOption[]>>(new Map());
const searchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const searchDebounceRef = useRef<number | null>(null);

const [isCourseRequestOpen, setIsCourseRequestOpen] = useState(false);
const [courseRequest, setCourseRequest] =
Expand Down Expand Up @@ -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, " ");
Expand Down Expand Up @@ -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]);

Expand Down
Loading