From c274d2e7721e3956f6425a9189ccd3cbbdfdc2e8 Mon Sep 17 00:00:00 2001 From: Divyam Agrawal Date: Mon, 17 Aug 2026 09:46:01 +0530 Subject: [PATCH 1/6] feat: add account upload history --- app/(app)/account/page.tsx | 271 +++++++++++++++++++++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100644 app/(app)/account/page.tsx diff --git a/app/(app)/account/page.tsx b/app/(app)/account/page.tsx new file mode 100644 index 00000000..193856c7 --- /dev/null +++ b/app/(app)/account/page.tsx @@ -0,0 +1,271 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { desc, eq } from "drizzle-orm"; +import { auth } from "@/app/auth"; +import { course, db, pastPaper } from "@/db"; +import type { AiModerationReview } from "@/lib/ai/moderation-review-types"; +import { normalizeGcsUrl } from "@/lib/normalize-gcs-url"; + +export const dynamic = "force-dynamic"; + +export const metadata: Metadata = { + title: "Account", + description: "View the past papers you have uploaded and their review status.", + alternates: { canonical: "/account" }, + robots: { index: false, follow: false }, +}; + +type UploadStatusTone = "neutral" | "info" | "warning" | "danger" | "success"; + +type UploadStatus = { + label: string; + description: string; + tone: UploadStatusTone; +}; + +type PaperStatusInput = { + aiReview: AiModerationReview | null; + isClear: boolean; + moderationArchivedAt: Date | null; +}; + +const statusClassNames: Record = { + neutral: "border-black/15 bg-black/5 text-black/70 dark:border-white/15 dark:bg-white/5 dark:text-white/70", + info: "border-blue-500/30 bg-blue-500/10 text-blue-800 dark:text-blue-200", + warning: "border-amber-500/35 bg-amber-500/10 text-amber-900 dark:text-amber-200", + danger: "border-red-500/35 bg-red-500/10 text-red-800 dark:text-red-200", + success: "border-emerald-500/35 bg-emerald-500/10 text-emerald-800 dark:text-emerald-200", +}; + +function getUploadStatus({ + aiReview, + isClear, + moderationArchivedAt, +}: PaperStatusInput): UploadStatus { + if (isClear) { + return { + label: "Published", + description: "Approved and visible in the past-paper library.", + tone: "success", + }; + } + + if (moderationArchivedAt) { + if (aiReview?.status === "duplicate") { + return { + label: "Duplicate", + description: "Removed from review because the paper already exists.", + tone: "warning", + }; + } + + return { + label: "Rejected", + description: "Removed from the moderation queue without being published.", + tone: "danger", + }; + } + + if (!aiReview) { + return { + label: "Uploaded", + description: "Stored successfully and waiting for the automated review.", + tone: "neutral", + }; + } + + if (aiReview.status === "needs_changes") { + return { + label: "Needs changes", + description: "The automated review found metadata or document issues for a moderator to check.", + tone: "warning", + }; + } + + if (aiReview.status === "duplicate") { + return { + label: "Duplicate detected", + description: "The automated review found a likely match in the existing library.", + tone: "warning", + }; + } + + if (aiReview.status === "failed") { + return { + label: "In review", + description: "The automated check could not finish; the paper remains available to moderators.", + tone: "info", + }; + } + + return { + label: "In review", + description: "The automated check is complete and the paper is awaiting final publication.", + tone: "info", + }; +} + +function formatExamType(value: string | null) { + return value?.replaceAll("_", "-") ?? "Exam type unknown"; +} + +function formatSemester(value: string) { + return value === "UNKNOWN" + ? "Semester unknown" + : `${value.charAt(0)}${value.slice(1).toLowerCase()} semester`; +} + +function formatUploadedAt(value: Date) { + return new Intl.DateTimeFormat("en-IN", { + dateStyle: "medium", + timeStyle: "short", + timeZone: "Asia/Kolkata", + }).format(value); +} + +export default async function AccountPage() { + const session = await auth(); + if (!session?.user?.id) { + redirect("/signin?callbackUrl=/account"); + } + + const uploads = await db + .select({ + id: pastPaper.id, + title: pastPaper.title, + fileUrl: pastPaper.fileUrl, + createdAt: pastPaper.createdAt, + isClear: pastPaper.isClear, + moderationArchivedAt: pastPaper.moderationArchivedAt, + aiReview: pastPaper.aiReview, + examType: pastPaper.examType, + semester: pastPaper.semester, + slot: pastPaper.slot, + year: pastPaper.year, + courseCode: course.code, + courseTitle: course.title, + }) + .from(pastPaper) + .leftJoin(course, eq(pastPaper.courseId, course.id)) + .where(eq(pastPaper.authorId, session.user.id)) + .orderBy(desc(pastPaper.createdAt)) + .limit(100); + + return ( +
+
+
+

+ Account +

+
+
+

+ Your uploads +

+

+ {session.user.email} +

+
+
+ + Upload a paper + + + Refresh + +
+
+
+ + {uploads.length === 0 ? ( +
+

No past papers uploaded yet

+

+ Papers you submit will appear here immediately, followed by their automated and moderator review status. +

+
+ ) : ( +
+ {uploads.map((paper) => { + const status = getUploadStatus(paper); + const fileUrl = normalizeGcsUrl(paper.fileUrl) ?? paper.fileUrl; + const metadata = [ + paper.courseCode + ? `${paper.courseCode}${paper.courseTitle ? ` · ${paper.courseTitle}` : ""}` + : paper.courseTitle, + formatExamType(paper.examType), + paper.slot ? `Slot ${paper.slot}` : null, + paper.year?.toString() ?? null, + formatSemester(paper.semester), + ].filter(Boolean); + + return ( +
+
+
+
+ + {status.label} + + + Uploaded {formatUploadedAt(paper.createdAt)} + +
+

+ {paper.title} +

+

+ {status.description} +

+

+ {metadata.join(" · ")} +

+ {paper.aiReview?.summary ? ( +

+ {paper.aiReview.summary} +

+ ) : null} +
+ +
+ + Open file + + {paper.isClear ? ( + + View published paper + + ) : null} +
+
+
+ ); + })} +
+ )} +
+
+ ); +} From b03294c6f1efffaea0a630809ecece890e8078ba Mon Sep 17 00:00:00 2001 From: Divyam Agrawal Date: Mon, 17 Aug 2026 09:47:40 +0530 Subject: [PATCH 2/6] feat: link account menu to upload history --- app/components/nav-bar.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/components/nav-bar.tsx b/app/components/nav-bar.tsx index e555c038..eaf6cf2c 100644 --- a/app/components/nav-bar.tsx +++ b/app/components/nav-bar.tsx @@ -390,6 +390,14 @@ const NavBar: React.FC = ({

{session?.user?.email}

+ dispatch({ type: "close-profile" })} + > + My uploads + = ({ ); }; -export default NavBar; +export default NavBar; \ No newline at end of file From 44850e3d6eab07b79503be5c0f79a0f2d77fd691 Mon Sep 17 00:00:00 2001 From: Divyam Agrawal Date: Mon, 17 Aug 2026 09:48:31 +0530 Subject: [PATCH 3/6] fix: use canonical account paper links --- app/(app)/account/page.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/app/(app)/account/page.tsx b/app/(app)/account/page.tsx index 193856c7..f52cadd7 100644 --- a/app/(app)/account/page.tsx +++ b/app/(app)/account/page.tsx @@ -175,13 +175,12 @@ export default async function AccountPage() { > Upload a paper - Refresh - + @@ -250,9 +249,9 @@ export default async function AccountPage() { > Open file - {paper.isClear ? ( + {paper.isClear && paper.courseCode ? ( View published paper From 121c2405d6dfb844ae5568c4ab6311585ec3c32f Mon Sep 17 00:00:00 2001 From: Divyam Agrawal Date: Mon, 17 Aug 2026 09:48:53 +0530 Subject: [PATCH 4/6] docs: describe upload status mapping --- app/(app)/account/README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 app/(app)/account/README.md diff --git a/app/(app)/account/README.md b/app/(app)/account/README.md new file mode 100644 index 00000000..dd6a4746 --- /dev/null +++ b/app/(app)/account/README.md @@ -0,0 +1,13 @@ +# Account upload status + +The account page derives its labels from the existing `PastPaper` moderation fields: + +- `Published`: `isClear` is true. +- `Uploaded`: the paper is stored but no AI review has been written yet. +- `In review`: the automated review completed or failed, and the paper remains in the moderator queue. +- `Needs changes`: the AI review returned `needs_changes`. +- `Duplicate detected`: the AI review returned `duplicate` while the item is still queued. +- `Duplicate`: an archived item has a duplicate review result. +- `Rejected`: an archived item was not published and was not marked as a duplicate. + +This feature intentionally does not introduce another status column or duplicate the moderation state machine. From af4a0068c7d01c011f5c917f7eeb8447756fa163 Mon Sep 17 00:00:00 2001 From: Divyam Agrawal Date: Mon, 17 Aug 2026 09:49:08 +0530 Subject: [PATCH 5/6] chore: keep account feature focused --- app/(app)/account/README.md | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 app/(app)/account/README.md diff --git a/app/(app)/account/README.md b/app/(app)/account/README.md deleted file mode 100644 index dd6a4746..00000000 --- a/app/(app)/account/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# Account upload status - -The account page derives its labels from the existing `PastPaper` moderation fields: - -- `Published`: `isClear` is true. -- `Uploaded`: the paper is stored but no AI review has been written yet. -- `In review`: the automated review completed or failed, and the paper remains in the moderator queue. -- `Needs changes`: the AI review returned `needs_changes`. -- `Duplicate detected`: the AI review returned `duplicate` while the item is still queued. -- `Duplicate`: an archived item has a duplicate review result. -- `Rejected`: an archived item was not published and was not marked as a duplicate. - -This feature intentionally does not introduce another status column or duplicate the moderation state machine. From a8c45a4c2b392db5a99897c64a2a5c5e62b9fbc5 Mon Sep 17 00:00:00 2001 From: Divyam Agrawal Date: Mon, 17 Aug 2026 10:15:55 +0530 Subject: [PATCH 6/6] fix: preserve withdrawn and unassigned paper states --- app/(app)/account/page.tsx | 86 +++++++++++++++++++++++++++++++++++--- 1 file changed, 80 insertions(+), 6 deletions(-) diff --git a/app/(app)/account/page.tsx b/app/(app)/account/page.tsx index f52cadd7..7e537a8d 100644 --- a/app/(app)/account/page.tsx +++ b/app/(app)/account/page.tsx @@ -1,11 +1,17 @@ import type { Metadata } from "next"; import Link from "next/link"; import { redirect } from "next/navigation"; -import { desc, eq } from "drizzle-orm"; +import { and, desc, eq, inArray } from "drizzle-orm"; import { auth } from "@/app/auth"; -import { course, db, pastPaper } from "@/db"; +import { + contentCorrectionReport, + course, + db, + pastPaper, +} from "@/db"; import type { AiModerationReview } from "@/lib/ai/moderation-review-types"; import { normalizeGcsUrl } from "@/lib/normalize-gcs-url"; +import { getPastPaperDetailPath } from "@/lib/seo"; export const dynamic = "force-dynamic"; @@ -17,6 +23,7 @@ export const metadata: Metadata = { }; type UploadStatusTone = "neutral" | "info" | "warning" | "danger" | "success"; +type CorrectionArchiveAction = "withdrawn" | "converted" | null; type UploadStatus = { label: string; @@ -26,6 +33,7 @@ type UploadStatus = { type PaperStatusInput = { aiReview: AiModerationReview | null; + correctionAction: CorrectionArchiveAction; isClear: boolean; moderationArchivedAt: Date | null; }; @@ -40,6 +48,7 @@ const statusClassNames: Record = { function getUploadStatus({ aiReview, + correctionAction, isClear, moderationArchivedAt, }: PaperStatusInput): UploadStatus { @@ -52,6 +61,24 @@ function getUploadStatus({ } if (moderationArchivedAt) { + if (correctionAction === "converted") { + return { + label: "Moved to notes", + description: + "This upload was reclassified and moved to the notes collection after a correction review.", + tone: "info", + }; + } + + if (correctionAction === "withdrawn") { + return { + label: "Withdrawn", + description: + "Previously published, then removed from the library after a correction review.", + tone: "warning", + }; + } + if (aiReview?.status === "duplicate") { return { label: "Duplicate", @@ -152,6 +179,46 @@ export default async function AccountPage() { .orderBy(desc(pastPaper.createdAt)) .limit(100); + const archivedUploadIds = uploads + .filter((upload) => upload.moderationArchivedAt !== null) + .map((upload) => upload.id); + const correctionReports = + archivedUploadIds.length === 0 + ? [] + : await db + .select({ + pastPaperId: contentCorrectionReport.pastPaperId, + aiDecision: contentCorrectionReport.aiDecision, + }) + .from(contentCorrectionReport) + .where( + and( + inArray( + contentCorrectionReport.pastPaperId, + archivedUploadIds, + ), + inArray(contentCorrectionReport.status, [ + "approved", + "auto_approved", + ]), + ), + ) + .orderBy(desc(contentCorrectionReport.resolvedAt)); + + const correctionActions = new Map>(); + for (const report of correctionReports) { + if (!report.pastPaperId || correctionActions.has(report.pastPaperId)) { + continue; + } + + const appliedFields = report.aiDecision?.appliedFields ?? []; + if (appliedFields.includes("resourceType")) { + correctionActions.set(report.pastPaperId, "converted"); + } else if (appliedFields.includes("visibility")) { + correctionActions.set(report.pastPaperId, "withdrawn"); + } + } + return (
@@ -195,12 +262,16 @@ export default async function AccountPage() { ) : (
{uploads.map((paper) => { - const status = getUploadStatus(paper); + const status = getUploadStatus({ + ...paper, + correctionAction: + correctionActions.get(paper.id) ?? null, + }); const fileUrl = normalizeGcsUrl(paper.fileUrl) ?? paper.fileUrl; const metadata = [ paper.courseCode ? `${paper.courseCode}${paper.courseTitle ? ` · ${paper.courseTitle}` : ""}` - : paper.courseTitle, + : paper.courseTitle ?? "Course unassigned", formatExamType(paper.examType), paper.slot ? `Slot ${paper.slot}` : null, paper.year?.toString() ?? null, @@ -249,9 +320,12 @@ export default async function AccountPage() { > Open file - {paper.isClear && paper.courseCode ? ( + {paper.isClear ? ( View published paper