diff --git a/src/app/api/paper-counts/route.ts b/src/app/api/paper-counts/route.ts new file mode 100644 index 00000000..16729926 --- /dev/null +++ b/src/app/api/paper-counts/route.ts @@ -0,0 +1,34 @@ +import { Types } from "mongoose"; +import { z } from "zod"; +import { countPaperActivity } from "@/lib/services/paper-counts"; +import { getClientIp } from "@/lib/utils/rate-limiter"; +import { success, failure } from "@/lib/utils/response"; + +export const dynamic = "force-dynamic"; + +const paperActivity = z.object({ + kind: z.enum(["view", "download"]), + paperIds: z + .array(z.string().refine((id) => Types.ObjectId.isValid(id))) + .min(1) + .max(100), +}); + +export async function POST(req: Request) { + try { + const body: unknown = await req.json().catch(() => null); + const activity = paperActivity.safeParse(body); + + if (!activity.success) { + return failure("send a kind of view or download and up to 100 paper ids", 400); + } + + const { kind, paperIds } = activity.data; + const counts = await countPaperActivity(kind, [...new Set(paperIds)], getClientIp(req)); + + return success(counts); + } catch (error) { + console.error(error); + return failure("could not update the paper counts", 500); + } +} diff --git a/src/app/paper/[id]/page.tsx b/src/app/paper/[id]/page.tsx index 7e8fba2b..b56ac7a9 100644 --- a/src/app/paper/[id]/page.tsx +++ b/src/app/paper/[id]/page.tsx @@ -8,6 +8,7 @@ import { type Metadata } from "next"; import { redirect } from "next/navigation"; import { PaperProvider } from "@/context/PaperContext"; import PDFViewer from "@/components/newPdfViewer"; +import PaperCountsLine from "@/components/PaperCountsLine"; export async function generateMetadata({ params, @@ -159,11 +160,19 @@ const PaperPage = async ({ params }: { params: { id: string } }) => { ) : ( <> -

+

{paper.subject} {paper.exam} {paper.slot} {paper.year}

+
+ +
{ }, [previewOpen]); const handleDownload = async (paper: IPaper) => { - await downloadFile(getSecureUrl(paper.file_url), generateFileName(paper)); + const downloaded = await downloadFile(getSecureUrl(paper.file_url), generateFileName(paper)); + if (downloaded) void sendPaperActivity("download", [paper._id]); }; const handleCheckboxChange = () => { @@ -102,6 +105,12 @@ const Card = ({ paper, onSelect, isSelected, isShow=true }: CardProps) => { {paper.semester} {paper.school && {paper.school}} + @@ -111,6 +120,7 @@ const Card = ({ paper, onSelect, isSelected, isShow=true }: CardProps) => { onClick={(e) => { e.stopPropagation(); setPreviewOpen(true); + void sendPaperActivity("view", [paper._id]); }} /> diff --git a/src/components/PaperCountsLine.tsx b/src/components/PaperCountsLine.tsx new file mode 100644 index 00000000..d8f6906a --- /dev/null +++ b/src/components/PaperCountsLine.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { useEffect } from "react"; +import { cn } from "@/lib/utils"; +import { describeCount, sendPaperActivity, usePaperCounts } from "@/lib/utils/paper-counts"; + +const viewedOnThisPage = new Set(); + +interface PaperCountsLineProps { + paperId: string; + views?: number; + downloads?: number; + countView?: boolean; + className?: string; +} + +export default function PaperCountsLine({ + paperId, + views = 0, + downloads = 0, + countView = false, + className, +}: PaperCountsLineProps) { + const counts = usePaperCounts(paperId, { views, downloads }); + + useEffect(() => { + if (!countView || viewedOnThisPage.has(paperId)) return; + viewedOnThisPage.add(paperId); + void sendPaperActivity("view", [paperId]); + }, [countView, paperId]); + + return ( + + {describeCount(counts.views, "view")} ยท {describeCount(counts.downloads, "download")} + + ); +} diff --git a/src/components/newPdfViewer.tsx b/src/components/newPdfViewer.tsx index 68ead11f..2c1aa643 100644 --- a/src/components/newPdfViewer.tsx +++ b/src/components/newPdfViewer.tsx @@ -17,6 +17,8 @@ import { Button } from "./ui/button"; import ShareButton from "./ShareButton"; import ReportButton from "./ReportButton"; import { useTheme } from "next-themes"; +import { sendPaperActivity } from "@/lib/utils/paper-counts"; +import { usePaper } from "@/context/PaperContext"; interface ControlProps { documentId: string; @@ -461,6 +463,7 @@ export default function PDFViewer({ onReadingModeChange, }: PdfViewerProps) { const { engine, isLoading } = usePdfiumEngine(); + const { paperId } = usePaper(); const { isMobile, isSmall } = useBreakpoint(); const { resolvedTheme } = useTheme(); const [isFullscreen, setIsFullscreen] = useState(false); @@ -495,8 +498,9 @@ export default function PDFViewer({ posthog.capture("paper_downloaded", { paper_name: name, }); - await downloadFile(url, `${name}.pdf`); - }, [url, name]); + const downloaded = await downloadFile(url, `${name}.pdf`); + if (downloaded) void sendPaperActivity("download", [paperId]); + }, [url, name, paperId]); const toggleFullscreen = useCallback(() => { if (!document.fullscreenElement) { diff --git a/src/context/filterContext.tsx b/src/context/filterContext.tsx index 77548ae8..8de7a752 100644 --- a/src/context/filterContext.tsx +++ b/src/context/filterContext.tsx @@ -18,6 +18,7 @@ import posthog from "posthog-js"; import JSZip from "jszip"; import { toast } from "react-hot-toast"; import { getSecureUrl, generateFileName } from "@/lib/utils/download"; +import { sendPaperActivity } from "@/lib/utils/paper-counts"; interface FilterState { selectedExams: string[]; @@ -162,6 +163,7 @@ export const FilterProvider: React.FC = ({ ).map((id) => selectedPapers.find((paper) => paper._id === id)) as IPaper[]; let failedCount = 0; + const zippedPaperIds: string[] = []; // Different papers can share a generated name (e.g. same subject/exam/slot/year // from different campuses). JSZip overwrites entries with the same path, so // append " (1)", " (2)", ... to keep every paper in the zip. @@ -186,6 +188,7 @@ export const FilterProvider: React.FC = ({ const blob = await response.blob(); const filename = getUniqueName(generateFileName(paper)); zip.file(filename, blob); + zippedPaperIds.push(paper._id); } catch (err) { failedCount += 1; console.error(`Failed to fetch ${paper.file_url}`, err); @@ -221,6 +224,7 @@ export const FilterProvider: React.FC = ({ a.click(); a.remove(); URL.revokeObjectURL(url); + void sendPaperActivity("download", zippedPaperIds); posthog.capture("papers_bulk_downloaded", { download_count: uniquePapers.length - failedCount, diff --git a/src/db/papers.ts b/src/db/papers.ts index 3d1fb039..6b2016c7 100644 --- a/src/db/papers.ts +++ b/src/db/papers.ts @@ -78,6 +78,8 @@ const paperSchema = new Schema({ enum: ["SCOPE","SITE","SENSE","SMEC","SCE","SELECT","SAS","SSL","VITBS","SBST","SCORE","SCHEME","SHINE","V-SIGN","V-SMART","V-SPARC","VAIAL","HOT"], }, answer_key_included: { type: Boolean, default: false }, + views: { type: Number, default: 0 }, + downloads: { type: Number, default: 0 }, }); export const PaperAdmin: Model = diff --git a/src/interface.ts b/src/interface.ts index 03b3c938..895a3937 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -18,6 +18,8 @@ export interface PaperResponse { slot: string; exam: string; school?: string; + views?: number; + downloads?: number; } export interface IAdminPaper { @@ -122,6 +124,13 @@ export interface IPaper { subject: string; year: string; answer_key_included?: boolean; + views?: number; + downloads?: number; +} + +export interface PaperCounts { + views: number; + downloads: number; } export interface Filters { diff --git a/src/lib/services/paper-counts.ts b/src/lib/services/paper-counts.ts new file mode 100644 index 00000000..4ffb68b7 --- /dev/null +++ b/src/lib/services/paper-counts.ts @@ -0,0 +1,54 @@ +import { Ratelimit } from "@upstash/ratelimit"; +import Paper from "@/db/papers"; +import { type PaperCounts } from "@/interface"; +import { connectToDatabase } from "@/lib/database/mongoose"; +import { redis } from "@/lib/utils/redis"; + +export type PaperActivity = "view" | "download"; + +const countedField = { view: "views", download: "downloads" } as const; + +const upstashConfigured = Boolean(process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN); + +const oncePerHour = + redis && upstashConfigured + ? new Ratelimit({ redis, limiter: Ratelimit.fixedWindow(1, "1 h"), prefix: "paper-counts" }) + : null; + +async function firstThisHour(key: string) { + if (!oncePerHour) return true; + const { success } = await oncePerHour.limit(key); + return success; +} + +export async function countPaperActivity( + kind: PaperActivity, + paperIds: string[], + visitor: string, +): Promise> { + await connectToDatabase(); + + const firstTimes = await Promise.all( + paperIds.map((paperId) => firstThisHour(`${visitor}::${kind}::${paperId}`)), + ); + const newlyCounted = paperIds.filter((_, index) => firstTimes[index]); + + if (newlyCounted.length > 0) { + await Paper.updateMany( + { _id: { $in: newlyCounted } }, + { $inc: { [countedField[kind]]: 1 } }, + ); + } + + const papers = await Paper.find( + { _id: { $in: paperIds } }, + { views: 1, downloads: 1 }, + ).lean(); + + return Object.fromEntries( + papers.map((paper) => [ + String(paper._id), + { views: paper.views ?? 0, downloads: paper.downloads ?? 0 }, + ]), + ); +} diff --git a/src/lib/utils/download.ts b/src/lib/utils/download.ts index 5d02d102..538a8499 100644 --- a/src/lib/utils/download.ts +++ b/src/lib/utils/download.ts @@ -14,7 +14,7 @@ export const generateFileName = (paper: IPaper): string => { export const downloadFile = async ( url: string, filename: string, -): Promise => { +): Promise => { try { const response = await axios.get(url, { responseType: "blob" }); const blob = new Blob([response.data]); @@ -23,6 +23,7 @@ export const downloadFile = async ( link.download = filename; link.click(); window.URL.revokeObjectURL(link.href); + return true; } catch (error) { console.error("Download failed (File missing or network error):", error); if (typeof navigator !== "undefined" && !navigator.onLine) { @@ -30,5 +31,6 @@ export const downloadFile = async ( } else { toast.error("This paper is no longer available on the server."); } + return false; } }; \ No newline at end of file diff --git a/src/lib/utils/paper-counts.ts b/src/lib/utils/paper-counts.ts new file mode 100644 index 00000000..cde48a57 --- /dev/null +++ b/src/lib/utils/paper-counts.ts @@ -0,0 +1,44 @@ +import axios from "axios"; +import { useEffect, useState } from "react"; +import { type ApiResponse, type PaperCounts } from "@/interface"; +import { chunkArray } from "@/lib/utils/array"; + +const countsChanged = "paperCountsChanged"; + +export async function sendPaperActivity(kind: "view" | "download", paperIds: string[]) { + for (const batch of chunkArray(paperIds, 100)) { + try { + const response = await axios.post>>( + "/api/paper-counts", + { kind, paperIds: batch }, + ); + const counts = response.data.data; + if (counts) { + window.dispatchEvent(new CustomEvent(countsChanged, { detail: counts })); + } + } catch { + return; + } + } +} + +export function usePaperCounts(paperId: string, initial: PaperCounts) { + const [counts, setCounts] = useState(initial); + + useEffect(() => { + const takeFresh = (event: Event) => { + const fresh = (event as CustomEvent>).detail[paperId]; + if (fresh) setCounts(fresh); + }; + window.addEventListener(countsChanged, takeFresh); + return () => window.removeEventListener(countsChanged, takeFresh); + }, [paperId]); + + return counts; +} + +const compact = new Intl.NumberFormat("en", { notation: "compact" }); + +export function describeCount(count: number, word: string) { + return `${compact.format(count)} ${word}${count === 1 ? "" : "s"}`; +} diff --git a/src/lib/utils/rate-limiter.ts b/src/lib/utils/rate-limiter.ts index bef77232..6f349a51 100644 --- a/src/lib/utils/rate-limiter.ts +++ b/src/lib/utils/rate-limiter.ts @@ -2,7 +2,7 @@ import { redis } from "@/lib/utils/redis"; import { Ratelimit } from "@upstash/ratelimit"; import { CustomError } from "./error"; -function getClientIp(req: Request & { ip?: string}): string { +export function getClientIp(req: Request & { ip?: string}): string { const xff = req.headers.get("x-forwarded-for"); if (typeof xff === "string" && xff.length > 0) { return xff.split(",")[0]?.trim()??"";