Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
DATABASE_URL=postgres://user:password@host:5432/dbname
DIRECT_URL=postgres://user:password@host:5432/dbname
MHT_CET_ADMIN_EMAILS=admin@example.com

# Compatibility and Migration Workflows
# The application runtime uses Supabase, but several legacy ingestion scripts
Expand Down
233 changes: 233 additions & 0 deletions app/api/mht-cet/admin/imports/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
import { createAdminClient } from "@/app/lib/supabase/admin";
import { MhtCetAdminError, requireMhtCetAdmin } from "@/lib/mht-cet/admin/auth";
import { validateQuestionImportRows } from "@/lib/mht-cet/questions/validate-question-import";
import { NextResponse } from "next/server";

function blocksToText(blocks: unknown) {
return JSON.stringify(blocks)
.replace(/[{}\[\]":,]/g, " ")
.replace(/\s+/g, " ")
.trim();
}

function jsonError(error: MhtCetAdminError) {
return NextResponse.json(
{ error: { code: error.code, message: error.message } },
{ status: error.status },
);
}

export async function POST(request: Request) {
try {
const user = await requireMhtCetAdmin();
const payload = (await request.json()) as
| { questions?: unknown[]; fileName?: string }
| unknown[];
const rows = Array.isArray(payload) ? payload : payload.questions;

if (!Array.isArray(rows)) {
throw new MhtCetAdminError(
422,
"invalid_payload",
"Provide an array or questions array.",
);
}

const validation = validateQuestionImportRows(rows, { mode: "production" });
const errorCount = validation.errors.filter(
(error) => error.severity === "error",
).length;

if (errorCount > 0) {
return NextResponse.json(
{
acceptedRows: 0,
rejectedRows: rows.length,
errors: validation.errors,
},
{ status: 422 },
);
}

const supabase = createAdminClient();
let firstSourceId: string | null = null;

for (const row of validation.validRows) {
const sourcePayload = {
source_type: row.source.sourceType,
title: row.source.title,
year: row.source.year ?? row.year,
exam_group: row.source.examGroup ?? row.examGroup ?? "pcm",
source_url: row.source.sourceUrl,
file_name: row.source.fileName,
file_sha256: row.source.fileSha256,
license_note: row.source.licenseNote,
verification_status: "validated",
};
const { data: source, error: sourceError } = row.source.fileSha256
? await supabase
.from("mht_cet_question_sources")
.upsert(sourcePayload, { onConflict: "file_sha256" })
.select("id")
.single()
: await supabase
.from("mht_cet_question_sources")
.insert(sourcePayload)
.select("id")
.single();

if (sourceError || !source) {
throw new MhtCetAdminError(
500,
"source_insert_failed",
"Could not insert question source.",
);
}

firstSourceId ??= (source as { id: string }).id;

const { data: chapter } = await supabase
.from("mht_cet_chapters")
.select("id")
.eq("subject", row.subject)
.eq("slug", row.chapterSlug ?? "")
.maybeSingle();
const { data: question, error: questionError } = await supabase
.from("mht_cet_questions")
.insert({
source_id: (source as { id: string }).id,
chapter_id: (chapter as { id?: string } | null)?.id,
year: row.year,
exam_group: row.examGroup ?? "pcm",
subject: row.subject,
difficulty: row.difficulty ?? "unknown",
question_type: row.questionType,
marks: row.marks ?? (row.subject === "mathematics" ? 2 : 1),
negative_marks: row.negativeMarks ?? 0,
body: row.body,
body_text: blocksToText(row.body),
body_sha256: row.bodySha256,
verification_status: "validated",
})
.select("id")
.single();

if (questionError || !question) {
throw new MhtCetAdminError(
500,
"question_insert_failed",
"Could not insert question.",
);
}

const questionId = (question as { id: string }).id;
const { data: options, error: optionsError } = await supabase
.from("mht_cet_question_options")
.insert(
row.options.map((option, optionIndex) => ({
question_id: questionId,
option_order: optionIndex + 1,
body: option.body,
body_text: blocksToText(option.body),
})),
)
.select("id, option_order");

if (optionsError || !options) {
throw new MhtCetAdminError(
500,
"option_insert_failed",
"Could not insert options.",
);
}

const insertedOptions = options as Array<{
id: string;
option_order: number;
}>;
const optionByImportId = new Map<string, string>();

for (const [optionIndex, option] of row.options.entries()) {
const insertedOption = insertedOptions.find(
(item) => item.option_order === optionIndex + 1,
);

if (!insertedOption) {
throw new MhtCetAdminError(
500,
"option_mapping_failed",
"Could not map imported options to saved options.",
);
}

optionByImportId.set(option.id, insertedOption.id);
}

const correctOptionIds = row.correctOptionIds
.map((optionId) => optionByImportId.get(optionId))
.filter((optionId): optionId is string => Boolean(optionId));

if (correctOptionIds.length !== row.correctOptionIds.length) {
throw new MhtCetAdminError(
500,
"answer_mapping_failed",
"Could not map correct answers to saved options.",
);
}

const { error: answerError } = await supabase
.from("mht_cet_question_answers")
.insert({
question_id: questionId,
correct_option_ids: correctOptionIds,
explanation: row.explanation,
explanation_text: row.explanation
? blocksToText(row.explanation)
: null,
});

if (answerError) {
throw new MhtCetAdminError(
500,
"answer_insert_failed",
"Could not insert answer key.",
);
}
}

if (validation.validRows[0] && firstSourceId) {
const firstRow = validation.validRows[0];
await supabase.from("mht_cet_question_import_batches").insert({
source_id: firstSourceId,
imported_by: user.id,
file_name: Array.isArray(payload)
? "json-upload"
: (payload.fileName ?? "json-upload"),
file_sha256: firstRow.source.fileSha256 ?? firstRow.bodySha256,
total_rows: rows.length,
accepted_rows: validation.validRows.length,
rejected_rows: 0,
status: "imported",
});
}

return NextResponse.json({
acceptedRows: validation.validRows.length,
rejectedRows: 0,
});
} catch (error) {
if (error instanceof MhtCetAdminError) {
return jsonError(error);
}

return NextResponse.json(
{
error: {
code: "import_failed",
message: "Could not import questions.",
},
},
{ status: 500 },
);
}
}
104 changes: 104 additions & 0 deletions app/api/mht-cet/admin/questions/[questionId]/review/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { createAdminClient } from "@/app/lib/supabase/admin";
import { MhtCetAdminError, requireMhtCetAdmin } from "@/lib/mht-cet/admin/auth";
import { NextResponse } from "next/server";

function jsonError(error: MhtCetAdminError) {
return NextResponse.json(
{ error: { code: error.code, message: error.message } },
{ status: error.status },
);
}

export async function POST(
request: Request,
{ params }: { params: Promise<{ questionId: string }> },
) {
try {
const user = await requireMhtCetAdmin();
const { questionId } = await params;
const body = (await request.json()) as { action?: string };
const nextStatus = body.action === "approved" ? "approved" : "rejected";
const supabase = createAdminClient();
const { data: question, error: loadError } = await supabase
.from("mht_cet_questions")
.select("source_id, source:mht_cet_question_sources(verification_status)")
.eq("id", questionId)
.single();

if (loadError || !question) {
throw new MhtCetAdminError(
404,
"question_not_found",
"Question not found.",
);
}

const questionRecord = question as {
source_id?: string;
source?: { verification_status?: string };
};
const source = questionRecord.source;

if (body.action === "approve_source") {
const { error } = await supabase
.from("mht_cet_question_sources")
.update({
verification_status: "approved",
reviewed_by: user.id,
reviewed_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
.eq("id", questionRecord.source_id);

if (error) {
throw new MhtCetAdminError(
500,
"source_review_failed",
"Could not approve source.",
);
}

return NextResponse.json({ status: "source_approved" });
}

if (
nextStatus === "approved" &&
source?.verification_status !== "approved"
) {
throw new MhtCetAdminError(
409,
"source_not_approved",
"Approve the source before approving questions.",
);
}

const { error } = await supabase
.from("mht_cet_questions")
.update({
verification_status: nextStatus,
updated_at: new Date().toISOString(),
})
.eq("id", questionId);

if (error) {
throw new MhtCetAdminError(
500,
"review_failed",
"Could not update review status.",
);
}

return NextResponse.json({ status: nextStatus });
} catch (error) {
if (error instanceof MhtCetAdminError) {
return jsonError(error);
}

return NextResponse.json(
{
error: { code: "review_failed", message: "Could not review question." },
},
{ status: 500 },
);
}
}
Loading