diff --git a/app/(app)/account/page.tsx b/app/(app)/account/page.tsx new file mode 100644 index 00000000..7e537a8d --- /dev/null +++ b/app/(app)/account/page.tsx @@ -0,0 +1,344 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { and, desc, eq, inArray } from "drizzle-orm"; +import { auth } from "@/app/auth"; +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"; + +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 CorrectionArchiveAction = "withdrawn" | "converted" | null; + +type UploadStatus = { + label: string; + description: string; + tone: UploadStatusTone; +}; + +type PaperStatusInput = { + aiReview: AiModerationReview | null; + correctionAction: CorrectionArchiveAction; + 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, + correctionAction, + isClear, + moderationArchivedAt, +}: PaperStatusInput): UploadStatus { + if (isClear) { + return { + label: "Published", + description: "Approved and visible in the past-paper library.", + tone: "success", + }; + } + + 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", + 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); + + 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 ( +
+
+
+

+ 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, + 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 ?? "Course unassigned", + 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} +
+
+
+ ); + })} +
+ )} +
+
+ ); +} 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