Skip to content

Commit 0c288c4

Browse files
committed
feat: Report tag
added a complete tag-reporting system->a backend API with validation + rate limiting, a new DB model (tagreports) to store reports, and a frontend modal. Removed page up and down buttons , replaced by report Flag.
1 parent 90b9492 commit 0c288c4

6 files changed

Lines changed: 660 additions & 57 deletions

File tree

src/app/api/report-tag/route.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { NextResponse } from "next/server";
2+
import { connectToDatabase } from "@/lib/database/mongoose";
3+
import TagReport from "@/db/tagReport";
4+
5+
const ipCounters = new Map<string, { count: number; resetAt: number }>();
6+
const IP_LIMIT = 3; // tesing purpose ->> 3 reports/per IP/ per paper/ per hour
7+
const IP_WINDOW_MS = 1000 * 60 * 60;
8+
9+
const ALLOWED_EXAMS = [
10+
"CAT-1",
11+
"CAT-2",
12+
"FAT",
13+
];
14+
const ALLOWED_FIELDS = [
15+
"subject",
16+
"courseCode",
17+
"exam",
18+
"slot",
19+
"year",
20+
];
21+
22+
function getClientIp(req: Request) {
23+
const forwarded = req.headers.get("x-forwarded-for");
24+
if (forwarded) {
25+
const first = forwarded.split(",")[0] ?? forwarded;
26+
return first.trim();
27+
}
28+
const real = req.headers.get("x-real-ip");
29+
if (real) return real;
30+
try {
31+
const url = new URL(req.url);
32+
return url.hostname || "127.0.0.1";
33+
} catch {
34+
return "127.0.0.1";
35+
}
36+
}
37+
38+
export async function POST(req: Request) {
39+
try {
40+
await connectToDatabase();
41+
const body = (await req.json()) as {
42+
paperId?: string;
43+
reportedFields?: { field: string; value?: string }[];
44+
comment?: string;
45+
reporterEmail?: string;
46+
reporterId?: string;
47+
};
48+
49+
const { paperId } = body;
50+
if (!paperId)
51+
return NextResponse.json(
52+
{ error: "paperId is required" },
53+
{ status: 400 },
54+
);
55+
56+
const ip = getClientIp(req);
57+
const key = `${ip}::${paperId}`;
58+
const now = Date.now();
59+
const entry = ipCounters.get(key);
60+
if (entry && entry.resetAt > now) {
61+
if (entry.count >= IP_LIMIT)
62+
return NextResponse.json(
63+
{ error: "Rate limit exceeded for reporting." },
64+
{ status: 429 },
65+
);
66+
entry.count += 1;
67+
} else {
68+
ipCounters.set(key, { count: 1, resetAt: now + IP_WINDOW_MS });
69+
}
70+
71+
const existingCount = await TagReport.countDocuments({ paperId });
72+
const MAX_REPORTS_PER_PAPER = 2; // for testing purpose kept it to be 2 !!
73+
if (existingCount >= MAX_REPORTS_PER_PAPER)
74+
return NextResponse.json(
75+
{ error: "Received many reports; we are currently working on it." },
76+
{ status: 429 },
77+
);
78+
79+
const reportedFields = (body.reportedFields ?? [])
80+
.map((r) => ({ field: String(r.field).trim(), value: r.value?.trim() }))
81+
.filter((r) => r.field);
82+
83+
for (const rf of reportedFields) {
84+
if (!ALLOWED_FIELDS.includes(rf.field)) {
85+
return NextResponse.json(
86+
{ error: `Invalid field: ${rf.field}` },
87+
{ status: 400 },
88+
);
89+
}
90+
if (rf.field === "exam" && rf.value) {
91+
if (!ALLOWED_EXAMS.includes(rf.value))
92+
return NextResponse.json(
93+
{ error: `Invalid exam value: ${rf.value}` },
94+
{ status: 400 },
95+
);
96+
}
97+
if (rf.field === "year" && rf.value) {
98+
const val = rf.value.trim();
99+
100+
const rangeMatch = val.match(/^(\d{4})[-/](\d{4})$/);
101+
102+
if (rangeMatch) {
103+
const start = Number(rangeMatch[1]);
104+
const end = Number(rangeMatch[2]);
105+
if (
106+
start < 1900 || start > 2100 ||
107+
end < 1900 || end > 2100 ||
108+
end < start
109+
) {
110+
return NextResponse.json(
111+
{ error: `Invalid year range: ${rf.value}` },
112+
{ status: 400 }
113+
);
114+
}
115+
continue;
116+
}
117+
}
118+
119+
}
120+
121+
const newReport = await TagReport.create({
122+
paperId,
123+
reportedFields,
124+
comment: body.comment,
125+
reporterEmail: body.reporterEmail,
126+
reporterId: body.reporterId,
127+
});
128+
129+
return NextResponse.json(
130+
{ message: "Report submitted.", report: newReport },
131+
{ status: 201 },
132+
);
133+
} catch (err) {
134+
console.error(err);
135+
return NextResponse.json(
136+
{ error: "Failed to submit tag report." },
137+
{ status: 500 },
138+
);
139+
}
140+
}

src/app/paper/[id]/page.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,11 @@ const PaperPage = async ({ params }: { params: { id: string } }) => {
167167
<PdfViewer
168168
url={paper.file_url}
169169
name={`${extractBracketContent(paper.subject)}-${paper.exam}-${paper.slot}-${paper.year}`}
170+
paperId={params.id}
171+
subject={paper.subject}
172+
exam={paper.exam}
173+
slot={paper.slot}
174+
year={paper.year}
170175
></PdfViewer>
171176
</center>
172177
<RelatedPapers />

src/components/ReportButton.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
import { FaFlag } from "react-icons/fa6";
5+
import { Button } from "./ui/button";
6+
import ReportTagModal from "./ReportTagModal";
7+
8+
export default function ReportButton({
9+
paperId, subject, exam, slot, year
10+
}: {
11+
paperId: string;
12+
subject?: string;
13+
exam?: string;
14+
slot?: string;
15+
year?: string;
16+
}) {
17+
const [open, setOpen] = useState(false);
18+
19+
return (
20+
<>
21+
<Button
22+
onClick={() => setOpen(true)}
23+
className="h-10 w-10 rounded p-0 text-white transition hover:bg-red-600 bg-red-500"
24+
>
25+
<FaFlag className="text-sm" />
26+
</Button>
27+
28+
<ReportTagModal
29+
paperId={paperId}
30+
subject={subject}
31+
exam={exam}
32+
slot={slot}
33+
year={year}
34+
open={open}
35+
setOpen={setOpen}
36+
/>
37+
</>
38+
);
39+
}

0 commit comments

Comments
 (0)