Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
cba1885
Merge pull request #387 from ACM-VIT/dev
theg1239 May 4, 2026
2501cd0
Merge pull request #388 from ACM-VIT/dev
theg1239 May 5, 2026
f588e61
Merge pull request #389 from ACM-VIT/dev
theg1239 May 5, 2026
d16c86d
Merge pull request #390 from ACM-VIT/dev
theg1239 May 6, 2026
b14dcee
Merge pull request #391 from ACM-VIT/dev
theg1239 May 6, 2026
89ac409
Merge pull request #392 from ACM-VIT/dev
theg1239 May 6, 2026
458188b
Merge pull request #393 from ACM-VIT/dev
theg1239 May 6, 2026
193c48a
Update README.md
theg1239 May 6, 2026
7c74d99
Merge pull request #394 from ACM-VIT/dev
theg1239 May 6, 2026
609ffb4
Merge pull request #395 from ACM-VIT/dev
theg1239 May 6, 2026
2e6c079
Merge pull request #396 from ACM-VIT/dev
theg1239 May 6, 2026
0c313be
Merge pull request #397 from ACM-VIT/dev
theg1239 May 6, 2026
1cddcb0
Merge pull request #398 from ACM-VIT/dev
theg1239 May 6, 2026
9d6183b
Merge pull request #399 from ACM-VIT/dev
theg1239 May 6, 2026
392c614
Merge pull request #400 from ACM-VIT/dev
theg1239 May 6, 2026
2cdce31
Merge pull request #401 from ACM-VIT/dev
theg1239 May 7, 2026
5581775
Dev (#402)
theg1239 May 7, 2026
b2a3b08
Dev (#403)
theg1239 May 7, 2026
671920c
Merge pull request #404 from ACM-VIT/dev
theg1239 May 8, 2026
007c241
Merge pull request #405 from ACM-VIT/dev
theg1239 May 8, 2026
378a0d4
Merge pull request #406 from ACM-VIT/dev
theg1239 May 11, 2026
f1ac199
Merge pull request #408 from ACM-VIT/dev
theg1239 May 11, 2026
04b22f4
Merge pull request #409 from ACM-VIT/dev
theg1239 May 12, 2026
4d9e47b
Merge pull request #410 from ACM-VIT/dev
theg1239 May 12, 2026
4ada6dd
Merge pull request #411 from ACM-VIT/dev
theg1239 May 12, 2026
8973ca4
Merge pull request #412 from ACM-VIT/dev
theg1239 May 12, 2026
b74802f
Merge pull request #413 from ACM-VIT/dev
theg1239 May 12, 2026
168b0c9
Merge pull request #414 from ACM-VIT/dev
theg1239 May 12, 2026
aac8860
Merge pull request #416 from ACM-VIT/dev
theg1239 May 13, 2026
4177709
fix: sanitize MCP widget links
cursoragent May 16, 2026
6a523e6
fix: protect unpublished paper access
cursoragent May 19, 2026
a03ad99
fix: avoid caching truncated pdf markdown
cursoragent May 19, 2026
38b3f0b
Merge pull request #419 from ACM-VIT/cursor/critical-correctness-bugs…
theg1239 May 23, 2026
80188ca
Merge pull request #417 from ACM-VIT/cursor/critical-correctness-bugs…
theg1239 May 23, 2026
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# ExamCooker

Welcome to **ExamCooker** – a one-stop solution to all your exam problems, powered by ACM-VIT and VIT Vellore. The biggest problem to deal with in VIT is exam resources! This website solves all your issues from notes to past papers to YouTube links. It is an efficient and user-friendly website that helps you manage and save the most needed resource right before exams, **Time!**

(PS: We know that you probably left everything for the last minute :D)
Expand Down
225 changes: 155 additions & 70 deletions app/actions/update-paper-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,34 @@ const schema = z.object({

export type UpdatePaperMetadataInput = z.input<typeof schema>;

function isQuestionPaperLinkUniqueConstraintError(error: unknown) {
if (!error || typeof error !== "object") {
return false;
}

const code =
typeof (error as { code?: unknown }).code === "string"
? (error as { code: string }).code
: "";
if (code !== "23505") {
return false;
}

const constraint =
typeof (error as { constraint?: unknown }).constraint === "string"
? (error as { constraint: string }).constraint
: "";
const message =
typeof (error as { message?: unknown }).message === "string"
? (error as { message: string }).message.toLowerCase()
: "";

return (
constraint === "PastPaper_questionPaperId_key" ||
message.includes("pastpaper_questionpaperid_key")
);
}

export async function updatePaperMetadata(input: UpdatePaperMetadataInput) {
const session = await auth();
if (session?.user?.role !== "MODERATOR") {
Expand All @@ -42,83 +70,140 @@ export async function updatePaperMetadata(input: UpdatePaperMetadataInput) {
const parsed = schema.parse(input);

let questionPaperId: string | null = null;

if (parsed.hasAnswerKey) {
if (!parsed.questionPaperId) {
throw new Error("Answer keys must be linked to a question paper.");
let previousQuestionPaperId: string | null = null;

try {
const result = await db.transaction(async (tx) => {
const [existingPaper] = await tx
.select({
id: pastPaper.id,
questionPaperId: pastPaper.questionPaperId,
})
.from(pastPaper)
.where(eq(pastPaper.id, parsed.id))
.limit(1);

if (!existingPaper) {
throw new Error("Past paper not found.");
}

let nextQuestionPaperId: string | null = null;

if (parsed.hasAnswerKey) {
if (!parsed.questionPaperId) {
throw new Error("Answer keys must be linked to a question paper.");
}

if (parsed.questionPaperId === parsed.id) {
throw new Error("A paper cannot be linked to itself.");
}

const [questionPaper] = await tx
.select({
id: pastPaper.id,
title: pastPaper.title,
courseId: pastPaper.courseId,
hasAnswerKey: pastPaper.hasAnswerKey,
})
.from(pastPaper)
.where(eq(pastPaper.id, parsed.questionPaperId))
.limit(1);

if (!questionPaper) {
throw new Error("Question paper not found.");
}

if (questionPaper.hasAnswerKey) {
throw new Error("Question paper cannot itself be marked as an answer key.");
}

if (
parsed.courseId !== null &&
questionPaper.courseId !== null &&
parsed.courseId !== questionPaper.courseId
) {
throw new Error("Answer key and question paper must belong to the same course.");
}

const [conflictingLink] = await tx
.select({
id: pastPaper.id,
title: pastPaper.title,
})
.from(pastPaper)
.where(
and(
eq(pastPaper.questionPaperId, parsed.questionPaperId),
ne(pastPaper.id, parsed.id),
),
)
.limit(1);

if (conflictingLink) {
throw new Error(
`Question paper already has an answer key linked: ${conflictingLink.title}`,
);
}

const [linkedAnswerKey] = await tx
.select({
id: pastPaper.id,
title: pastPaper.title,
})
.from(pastPaper)
.where(
and(
eq(pastPaper.questionPaperId, parsed.id),
ne(pastPaper.id, parsed.id),
),
)
.limit(1);
Comment on lines +148 to +160
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The ne(pastPaper.id, parsed.id) predicate in the linkedAnswerKey query is never effective. The query already filters on eq(pastPaper.questionPaperId, parsed.id), so the only row whose id could equal parsed.id would be a paper whose questionPaperId equals its own id — a self-reference that is caught earlier in the same transaction. Removing the redundant clause makes the intent clearer.

Suggested change
const [linkedAnswerKey] = await tx
.select({
id: pastPaper.id,
title: pastPaper.title,
})
.from(pastPaper)
.where(
and(
eq(pastPaper.questionPaperId, parsed.id),
ne(pastPaper.id, parsed.id),
),
)
.limit(1);
const [linkedAnswerKey] = await tx
.select({
id: pastPaper.id,
title: pastPaper.title,
})
.from(pastPaper)
.where(
eq(pastPaper.questionPaperId, parsed.id),
)
.limit(1);


if (linkedAnswerKey) {
throw new Error(
`This paper already has an answer key linked: ${linkedAnswerKey.title}`,
);
}

nextQuestionPaperId = questionPaper.id;
}

await tx
.update(pastPaper)
.set({
courseId: parsed.courseId,
examType: parsed.examType,
slot: parsed.slot,
year: parsed.year,
semester: parsed.semester,
campus: parsed.campus,
hasAnswerKey: parsed.hasAnswerKey,
questionPaperId: nextQuestionPaperId,
})
.where(eq(pastPaper.id, parsed.id));

return {
questionPaperId: nextQuestionPaperId,
previousQuestionPaperId: existingPaper.questionPaperId,
};
});

questionPaperId = result.questionPaperId;
previousQuestionPaperId = result.previousQuestionPaperId;
} catch (error) {
if (isQuestionPaperLinkUniqueConstraintError(error)) {
throw new Error("Question paper already has an answer key linked.");
}

if (parsed.questionPaperId === parsed.id) {
throw new Error("A paper cannot be linked to itself.");
}

const [questionPaper] = await db
.select({
id: pastPaper.id,
title: pastPaper.title,
courseId: pastPaper.courseId,
hasAnswerKey: pastPaper.hasAnswerKey,
})
.from(pastPaper)
.where(eq(pastPaper.id, parsed.questionPaperId))
.limit(1);

if (!questionPaper) {
throw new Error("Question paper not found.");
}

if (questionPaper.hasAnswerKey) {
throw new Error("Question paper cannot itself be marked as an answer key.");
}

if (
parsed.courseId !== null &&
questionPaper.courseId !== null &&
parsed.courseId !== questionPaper.courseId
) {
throw new Error("Answer key and question paper must belong to the same course.");
}

const [conflictingLink] = await db
.select({
id: pastPaper.id,
title: pastPaper.title,
})
.from(pastPaper)
.where(
and(
eq(pastPaper.questionPaperId, parsed.questionPaperId),
ne(pastPaper.id, parsed.id),
),
)
.limit(1);

if (conflictingLink) {
throw new Error(
`Question paper already has an answer key linked: ${conflictingLink.title}`,
);
}

questionPaperId = questionPaper.id;
throw error;
}

await db
.update(pastPaper)
.set({
courseId: parsed.courseId,
examType: parsed.examType,
slot: parsed.slot,
year: parsed.year,
semester: parsed.semester,
campus: parsed.campus,
hasAnswerKey: parsed.hasAnswerKey,
questionPaperId,
})
.where(eq(pastPaper.id, parsed.id));

revalidatePath("/mod/papers/review");
revalidateTag("past_papers", "minutes");
revalidateTag(`past_paper:${parsed.id}`, "minutes");
if (previousQuestionPaperId) {
revalidateTag(`past_paper:${previousQuestionPaperId}`, "minutes");
}
if (questionPaperId) {
revalidateTag(`past_paper:${questionPaperId}`, "minutes");
}
Expand Down
19 changes: 18 additions & 1 deletion app/api/pdf/markdown/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,14 @@ function getStreamErrorMessage(error: unknown, streamError: unknown) {
return fallbackMessage || "Failed to convert this PDF to Markdown.";
}

function getIncompletePdfMarkdownMessage(finishReason: string | undefined) {
if (finishReason !== "length") {
return null;
}

return "The Markdown conversion was cut off before it finished. Try a smaller PDF or fewer edited pages.";
}

function getAiProviderFromModel(modelId: string) {
const [provider] = modelId.split("/");
return provider && provider !== modelId ? provider : "openai";
Expand Down Expand Up @@ -676,6 +684,7 @@ export async function POST(request: NextRequest) {
const enqueue = (payload: unknown) => {
controller.enqueue(encoder.encode(`${JSON.stringify(payload)}\n`));
};
let finishReason: string | undefined;

try {
const streamedQuestions: PdfPaperQuestion[] = [];
Expand All @@ -696,6 +705,12 @@ export async function POST(request: NextRequest) {
}

const questions = await result.output;
finishReason = (await result.finishReason) ?? undefined;
const incompleteMessage = getIncompletePdfMarkdownMessage(finishReason);
if (incompleteMessage) {
throw new Error(incompleteMessage);
}

const paper = PdfPaperDocumentSchema.parse({
schemaVersion: "exam-questions-v1",
questions,
Expand Down Expand Up @@ -731,7 +746,7 @@ export async function POST(request: NextRequest) {
fileBytes: pdfBuffer.byteLength,
fileName: parsedBody.fileName,
fileUrl: fileUrl.href,
finishReason: result.finishReason,
finishReason: Promise.resolve(finishReason),
httpStatus: 200,
isError: false,
latencySeconds: Math.max(Date.now() - llmStartedAt, 0) / 1000,
Expand Down Expand Up @@ -762,6 +777,8 @@ export async function POST(request: NextRequest) {
fileBytes: pdfBuffer.byteLength,
fileName: parsedBody.fileName,
fileUrl: fileUrl.href,
finishReason:
finishReason === undefined ? result.finishReason : Promise.resolve(finishReason),
httpStatus: request.signal.aborted ? 499 : 500,
isError: true,
latencySeconds: Math.max(Date.now() - llmStartedAt, 0) / 1000,
Expand Down
2 changes: 1 addition & 1 deletion app/api/realtime/session/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { NextResponse, type NextRequest } from "next/server";
import { auth } from "@/app/auth";

const DEFAULT_REALTIME_MODEL = "gpt-realtime-mini";
const DEFAULT_REALTIME_MODEL = "gpt-realtime-2";

function isAbortLikeError(error: unknown) {
return (
Expand Down
4 changes: 2 additions & 2 deletions app/components/voice/voice-agent-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export default function VoiceAgentProvider({
auth: { sessionEndpoint: "/api/realtime/session" },
debug: VOICE_DEBUG,
instructions: "Voice guide is preparing.",
model: "gpt-realtime-mini",
model: "gpt-realtime-2",
maxOutputTokens: 90,
outputMode: "audio",
postToolResponse: true,
Expand Down Expand Up @@ -780,7 +780,7 @@ export default function VoiceAgentProvider({
debug: VOICE_DEBUG,
instructions: VOICE_GUIDE_INSTRUCTIONS,
maxOutputTokens: 400,
model: "gpt-realtime-mini",
model: "gpt-realtime-2",
onGenerationCompleted: (generation) => {
const voiceSessionId = voiceAnalyticsSessionIdRef.current;
if (!voiceSessionId) {
Expand Down
2 changes: 1 addition & 1 deletion app/components/voice/voice-runtime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ type MutableRealtimeSession<TContext = unknown> = RealtimeSession<TContext> & {
};
};

const DEFAULT_MODEL = "gpt-realtime-mini";
const DEFAULT_MODEL = "gpt-realtime-2";
const DEFAULT_INSTRUCTIONS =
"You are a voice control agent for a web app. Use registered tools whenever you can take action. Keep any spoken reply brief.";
const TOOL_INPUT_RECOVERY_PROMPT =
Expand Down
18 changes: 14 additions & 4 deletions lib/data/past-paper-detail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,12 @@ const loadPastPaperDetail = cache(async (id: string) => {
.leftJoin(course, eq(pastPaper.courseId, course.id))
.leftJoin(pastPaperToTag, eq(pastPaperToTag.a, pastPaper.id))
.leftJoin(tag, eq(pastPaperToTag.b, tag.id))
.where(eq(pastPaper.id, id))
.where(
and(
eq(pastPaper.id, id),
eq(pastPaper.isClear, true),
),
)
.orderBy(asc(tag.name));

const firstRow = rows[0];
Expand Down Expand Up @@ -157,7 +162,7 @@ export async function getPastPaperDetail(id: string) {

return withPastPapersSurfaceRedisCache(
{
keyParts: ["past-paper-detail", { id }],
keyParts: ["published-past-paper-detail", { id }],
deserialize: deserializePastPaperDetail,
},
async () => loadPastPaperDetail(id),
Expand All @@ -182,7 +187,7 @@ export async function getSiblingPastPaper(input: {

return withPastPapersSurfaceRedisCache(
{
keyParts: ["sibling-past-paper", input],
keyParts: ["published-sibling-past-paper", input],
},
async () => {
const select = {
Expand All @@ -201,7 +206,12 @@ export async function getSiblingPastPaper(input: {
.select(select)
.from(pastPaper)
.leftJoin(course, eq(pastPaper.courseId, course.id))
.where(eq(pastPaper.id, input.questionPaperId))
.where(
and(
eq(pastPaper.id, input.questionPaperId),
eq(pastPaper.isClear, true),
),
)
.limit(1);

const linkedQuestionPaper = linkedQuestionPaperRows[0];
Expand Down
Loading
Loading