diff --git a/bunkialo-landing/src/app/api/bunkx/session/[sid]/route.ts b/bunkialo-landing/src/app/api/bunkx/session/[sid]/route.ts
deleted file mode 100644
index 7b0581e..0000000
--- a/bunkialo-landing/src/app/api/bunkx/session/[sid]/route.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { consumeSession } from "@/lib/bunkx-session-store";
-import { NextResponse } from "next/server";
-
-export async function GET(
- _request: Request,
- context: { params: Promise<{ sid: string }> },
-) {
- const params = await context.params;
- const payload = consumeSession(params.sid);
-
- if (!payload) {
- return NextResponse.json(
- {
- error: "Session not found or expired",
- },
- {
- status: 404,
- headers: {
- "Cache-Control": "no-store",
- },
- },
- );
- }
-
- return NextResponse.json(payload, {
- status: 200,
- headers: {
- "Cache-Control": "no-store",
- },
- });
-}
diff --git a/bunkialo-landing/src/app/api/bunkx/session/route.ts b/bunkialo-landing/src/app/api/bunkx/session/route.ts
deleted file mode 100644
index 2190f5c..0000000
--- a/bunkialo-landing/src/app/api/bunkx/session/route.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-import { parseBunkxAttendancePayload } from "@/lib/bunkx-payload";
-import { createSession } from "@/lib/bunkx-session-store";
-import { NextResponse } from "next/server";
-
-const toSafeErrorMessage = (error: unknown): string => {
- if (!(error instanceof Error)) {
- return "Invalid request payload";
- }
-
- const knownValidationMessages = new Set([
- "Payload must be an object",
- "attendance_rows must be an array",
- "Invalid attendance row",
- "Invalid period_date",
- "Invalid session_time",
- "Invalid course_code",
- "Invalid subject_name",
- "Invalid faculty",
- "Invalid faculty_email",
- "Invalid score",
- ]);
-
- if (knownValidationMessages.has(error.message)) {
- return error.message;
- }
-
- return "An unexpected error occurred";
-};
-
-export async function POST(request: Request) {
- try {
- const payload = parseBunkxAttendancePayload(
- (await request.json()) as unknown,
- );
- const session = createSession(payload);
-
- return NextResponse.json(session, {
- status: 201,
- headers: {
- "Cache-Control": "no-store",
- },
- });
- } catch (error) {
- const message = toSafeErrorMessage(error);
-
- return NextResponse.json(
- {
- error: message,
- },
- {
- status: 400,
- headers: {
- "Cache-Control": "no-store",
- },
- },
- );
- }
-}
diff --git a/bunkialo-landing/src/app/bunkialo/page.tsx b/bunkialo-landing/src/app/bunkialo/page.tsx
deleted file mode 100644
index 9e11a89..0000000
--- a/bunkialo-landing/src/app/bunkialo/page.tsx
+++ /dev/null
@@ -1,85 +0,0 @@
-import type { BunkxAttendancePayload } from "@/lib/bunkx-payload";
-import { headers } from "next/headers";
-
-interface BunkialoPageProps {
- searchParams: Promise<{ sid?: string }>;
-}
-
-export default async function BunkialoPage({
- searchParams,
-}: BunkialoPageProps) {
- const params = await searchParams;
- const sid = params.sid?.trim();
-
- if (!sid) {
- return (
-
- Missing session id
-
- Open this page from the app so attendance can be transferred securely.
-
-
- );
- }
-
- const requestHeaders = await headers();
- const host =
- requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host");
- const proto = requestHeaders.get("x-forwarded-proto") ?? "https";
- const fallbackHost = process.env.DEFAULT_HOST?.trim();
-
- if (!host && !fallbackHost) {
- return (
-
- Session unavailable
-
- Unable to resolve server host. Please try again in a moment.
-
-
- );
- }
-
- const resolvedHost = host ?? fallbackHost!;
- const apiUrl = `${proto}://${resolvedHost}/api/bunkx/session/${encodeURIComponent(sid)}`;
-
- const response = await fetch(apiUrl, {
- cache: "no-store",
- });
-
- let payload: BunkxAttendancePayload | null = null;
- if (response.ok) {
- try {
- payload = (await response.json()) as BunkxAttendancePayload;
- } catch {
- payload = null;
- }
- }
-
- if (!payload) {
- return (
-
- Session unavailable
-
- This session has expired or was already used. Please launch Bunkx
- again from the app.
-
-
- );
- }
-
- const attendanceCount = Array.isArray(payload.attendance_rows)
- ? payload.attendance_rows.length
- : 0;
-
- return (
-
- Bunkialo attendance handoff
-
- Received {attendanceCount} records from the mobile app.
-
-
- {JSON.stringify(payload, null, 2)}
-
-
- );
-}
diff --git a/bunkialo-landing/src/lib/bunkx-payload.ts b/bunkialo-landing/src/lib/bunkx-payload.ts
deleted file mode 100644
index f9048fd..0000000
--- a/bunkialo-landing/src/lib/bunkx-payload.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-export interface BunkxAttendanceRow {
- period_date: string;
- session_time: string;
- course_code: string;
- subject_name: string;
- faculty: string;
- faculty_email: string;
- course?: string;
- score: string;
- record_id?: string;
-}
-
-export interface BunkxAttendancePayload {
- attendance_rows: BunkxAttendanceRow[];
- dataset_id?: string;
- dataset_expires_at?: string;
-}
-
-const parseString = (value: unknown, field: string): string => {
- if (typeof value !== "string") {
- throw new Error(`Invalid ${field}`);
- }
- return value;
-};
-
-const parseOptionalString = (value: unknown): string | undefined => {
- return typeof value === "string" ? value : undefined;
-};
-
-const isRecord = (value: unknown): value is Record =>
- typeof value === "object" && value !== null;
-
-const parseRow = (value: unknown): BunkxAttendanceRow => {
- if (!isRecord(value)) {
- throw new Error("Invalid attendance row");
- }
-
- return {
- period_date: parseString(value.period_date, "period_date"),
- session_time: parseString(value.session_time, "session_time"),
- course_code: parseString(value.course_code, "course_code"),
- subject_name: parseString(value.subject_name, "subject_name"),
- faculty: parseString(value.faculty, "faculty"),
- faculty_email: parseString(value.faculty_email, "faculty_email"),
- course: parseOptionalString(value.course),
- score: parseString(value.score, "score"),
- record_id: parseOptionalString(value.record_id),
- };
-};
-
-export const parseBunkxAttendancePayload = (
- value: unknown,
-): BunkxAttendancePayload => {
- if (!isRecord(value)) {
- throw new Error("Payload must be an object");
- }
-
- const rows = value.attendance_rows;
- if (!Array.isArray(rows)) {
- throw new Error("attendance_rows must be an array");
- }
-
- return {
- attendance_rows: rows.map(parseRow),
- dataset_id: parseOptionalString(value.dataset_id),
- dataset_expires_at: parseOptionalString(value.dataset_expires_at),
- };
-};
diff --git a/bunkialo-landing/src/lib/bunkx-session-store.ts b/bunkialo-landing/src/lib/bunkx-session-store.ts
deleted file mode 100644
index e9ca8fe..0000000
--- a/bunkialo-landing/src/lib/bunkx-session-store.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-import type { BunkxAttendancePayload } from "@/lib/bunkx-payload";
-
-interface SessionRecord {
- payload: BunkxAttendancePayload;
- expiresAtMs: number;
-}
-
-interface CreatedSession {
- sid: string;
- expiresAt: string;
-}
-
-const DEFAULT_TTL_MS = 10 * 60 * 1000;
-const sessionStore = new Map();
-
-const cleanupExpiredSessions = (): void => {
- const now = Date.now();
- for (const [sid, record] of sessionStore.entries()) {
- if (record.expiresAtMs <= now) {
- sessionStore.delete(sid);
- }
- }
-};
-
-const generateSessionId = (): string => {
- return crypto.randomUUID();
-};
-
-const generateUniqueSessionId = (): string => {
- const maxAttempts = 8;
-
- for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
- const sid = generateSessionId();
- if (!sessionStore.has(sid)) {
- return sid;
- }
- }
-
- throw new Error("Could not generate unique session id");
-};
-
-export const createSession = (
- payload: BunkxAttendancePayload,
- ttlMs: number = DEFAULT_TTL_MS,
-): CreatedSession => {
- cleanupExpiredSessions();
-
- const now = Date.now();
- const expiresAtMs = now + Math.max(60_000, ttlMs);
- const sid = generateUniqueSessionId();
-
- sessionStore.set(sid, {
- payload,
- expiresAtMs,
- });
-
- return {
- sid,
- expiresAt: new Date(expiresAtMs).toISOString(),
- };
-};
-
-export const consumeSession = (sid: string): BunkxAttendancePayload | null => {
- cleanupExpiredSessions();
-
- const existing = sessionStore.get(sid);
- if (!existing) {
- return null;
- }
-
- sessionStore.delete(sid);
- return existing.payload;
-};
diff --git a/src/scripts/test-bunkx-sid-e2e.mjs b/src/scripts/test-bunkx-sid-e2e.mjs
new file mode 100644
index 0000000..923316c
--- /dev/null
+++ b/src/scripts/test-bunkx-sid-e2e.mjs
@@ -0,0 +1,327 @@
+/**
+ * End-to-end Bunkx sid handoff test.
+ *
+ * Flow:
+ * 1) Login to LMS
+ * 2) Fetch in-progress courses
+ * 3) Scrape attendance records
+ * 4) Build Bunkx payload
+ * 5) POST /api/bunkx/session
+ * 6) Open /bunkialo?sid=...
+ * 7) Check consume endpoint behavior
+ *
+ * Run:
+ * LMS_TEST_USERNAME=... LMS_TEST_PASSWORD=... node src/scripts/test-bunkx-sid-e2e.mjs
+ */
+
+import { createLmsSession, loadEnvFromRoot } from "./utils/lms-session.mjs";
+
+const cheerio = await import("cheerio");
+
+loadEnvFromRoot();
+
+const BUNKX_BASE_URL =
+ process.env.BUNKX_BASE_URL || "https://bunkx-iiitk.vercel.app";
+
+const username = process.env.LMS_TEST_USERNAME;
+const password = process.env.LMS_TEST_PASSWORD;
+
+if (!username || !password) {
+ console.error("Missing LMS_TEST_USERNAME/LMS_TEST_PASSWORD");
+ process.exit(1);
+}
+
+const session = createLmsSession({ username, password });
+const BASE_URL = session.baseUrl;
+
+const safeJsonParse = async (response) => {
+ try {
+ return await response.json();
+ } catch {
+ return null;
+ }
+};
+
+const normalizeCourseCode = (courseName) => {
+ const match = String(courseName).match(/\b([A-Z]{2,}\d{2,}[A-Z0-9]*)\b/);
+ return match ? match[1] : "UNKNOWN";
+};
+
+const normalizeSubjectName = (courseName, courseCode) => {
+ const name = String(courseName).replace(courseCode, "").trim();
+ return name || String(courseName).trim() || "Unknown Subject";
+};
+
+const parsePeriodDate = (rawDate) => {
+ const months = {
+ jan: "01",
+ feb: "02",
+ mar: "03",
+ apr: "04",
+ may: "05",
+ jun: "06",
+ jul: "07",
+ aug: "08",
+ sep: "09",
+ oct: "10",
+ nov: "11",
+ dec: "12",
+ };
+
+ const match = String(rawDate).match(/(\d{1,2})\s+([A-Za-z]{3})\s+(\d{4})/);
+ if (!match) {
+ return new Date().toISOString().slice(0, 10);
+ }
+
+ const day = String(Number(match[1])).padStart(2, "0");
+ const month = months[match[2].toLowerCase()] || "01";
+ const year = match[3];
+ return `${year}-${month}-${day}`;
+};
+
+const parseSessionTime = (rawDate) => {
+ const match = String(rawDate).match(
+ /(\d{1,2}(?::\d{2})?\s*(?:AM|PM))\s*-\s*(\d{1,2}(?::\d{2})?\s*(?:AM|PM))/i,
+ );
+ if (!match) return "";
+ return `${match[1].replace(/\s+/g, " ").toUpperCase()} - ${match[2]
+ .replace(/\s+/g, " ")
+ .toUpperCase()}`;
+};
+
+const fetchInProgressCourses = async () => {
+ const sesskey = await session.getSesskey();
+ if (!sesskey) {
+ throw new Error("No sesskey found");
+ }
+
+ const payload = [
+ {
+ index: 0,
+ methodname: "core_course_get_enrolled_courses_by_timeline_classification",
+ args: {
+ offset: 0,
+ limit: 0,
+ classification: "inprogress",
+ sort: "fullname",
+ },
+ },
+ ];
+
+ const res = await session.fetchWithSession(
+ `${BASE_URL}/lib/ajax/service.php?sesskey=${sesskey}&info=core_course_get_enrolled_courses_by_timeline_classification`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Accept: "application/json",
+ },
+ body: JSON.stringify(payload),
+ },
+ );
+
+ const data = await safeJsonParse(res);
+ if (!data || data[0]?.error) {
+ throw new Error("Failed to fetch in-progress courses");
+ }
+
+ const courses = data[0]?.data?.courses || [];
+ return courses.map((course) => ({
+ id: String(course.id),
+ name: course.fullname || course.shortname || `Course ${course.id}`,
+ }));
+};
+
+const scrapeAttendanceRowsForCourse = async (courseId, courseName) => {
+ const courseRes = await session.fetchWithSession(
+ `${BASE_URL}/course/view.php?id=${courseId}`,
+ );
+ const courseHtml = await courseRes.text();
+ const $ = cheerio.load(courseHtml);
+
+ let attendanceModuleId = null;
+ $('a[href*="/mod/attendance/view.php"]').each((_, el) => {
+ const href = $(el).attr("href") || "";
+ const match = href.match(/id=(\d+)/);
+ if (match && !attendanceModuleId) {
+ attendanceModuleId = match[1];
+ }
+ });
+
+ if (!attendanceModuleId) {
+ return [];
+ }
+
+ const attendanceRes = await session.fetchWithSession(
+ `${BASE_URL}/mod/attendance/view.php?id=${attendanceModuleId}&view=5`,
+ );
+ const attendanceHtml = await attendanceRes.text();
+ const $att = cheerio.load(attendanceHtml);
+
+ const courseCode = normalizeCourseCode(courseName);
+ const subjectName = normalizeSubjectName(courseName, courseCode);
+
+ const rows = [];
+
+ $att("table").each((_, table) => {
+ const text = $att(table).text().toLowerCase();
+ const isAttendanceTable =
+ text.includes("date") &&
+ (text.includes("status") ||
+ text.includes("points") ||
+ text.includes("present"));
+
+ if (!isAttendanceTable) return;
+
+ $att(table)
+ .find("tr")
+ .each((rowIndex, row) => {
+ if (rowIndex === 0) return;
+
+ const cells = $att(row).find("td");
+ if (cells.length < 3) return;
+
+ const date = $att(cells[0]).text().trim();
+ const description = $att(cells[1]).text().trim();
+ const status = $att(cells[2]).text().trim();
+ const points = cells.length > 3 ? $att(cells[3]).text().trim() : "";
+
+ if (!/\d/.test(date)) return;
+
+ rows.push({
+ period_date: parsePeriodDate(date),
+ session_time: parseSessionTime(date),
+ course_code: courseCode,
+ subject_name: subjectName,
+ faculty: "Unknown",
+ faculty_email: "",
+ course: `${courseCode} ${subjectName}`.trim(),
+ score:
+ points.replace(/\s+/g, "") ||
+ (status.toLowerCase().includes("present") ? "1/1" : "0/1"),
+ record_id: `${courseId}-${rowIndex}`,
+ description,
+ });
+ });
+ });
+
+ return rows;
+};
+
+const createBunkxSession = async (payload) => {
+ const response = await fetch(`${BUNKX_BASE_URL}/api/bunkx/session`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+
+ const bodyText = await response.text();
+ let bodyJson = null;
+ try {
+ bodyJson = JSON.parse(bodyText);
+ } catch {
+ bodyJson = null;
+ }
+
+ return {
+ status: response.status,
+ bodyText,
+ bodyJson,
+ };
+};
+
+const checkLaunchPage = async (sid) => {
+ const url = `${BUNKX_BASE_URL}/bunkialo?sid=${encodeURIComponent(sid)}`;
+ const response = await fetch(url);
+ const html = await response.text();
+ return {
+ status: response.status,
+ hasReceivedText: /Received\s+\d+\s+records/i.test(html),
+ hasSessionUnavailableText: /Session unavailable/i.test(html),
+ htmlHead: html.slice(0, 240),
+ };
+};
+
+const checkConsumeApi = async (sid) => {
+ const url = `${BUNKX_BASE_URL}/api/bunkx/session/${encodeURIComponent(sid)}`;
+
+ const first = await fetch(url);
+ const firstText = await first.text();
+
+ const second = await fetch(url);
+ const secondText = await second.text();
+
+ return {
+ firstStatus: first.status,
+ secondStatus: second.status,
+ firstBodyHead: firstText.slice(0, 200),
+ secondBodyHead: secondText.slice(0, 200),
+ };
+};
+
+async function main() {
+ console.log("=== BUNKX SID E2E TEST ===");
+ console.log(`LMS Base: ${BASE_URL}`);
+ console.log(`Bunkx Base: ${BUNKX_BASE_URL}`);
+
+ const loginOk = await session.login();
+ const cookieCount = await session.getCookieCount();
+ console.log(
+ `Login: ${loginOk ? "SUCCESS" : "FAILED"} (cookies=${cookieCount})`,
+ );
+
+ if (!loginOk) {
+ process.exit(1);
+ }
+
+ const courses = await fetchInProgressCourses();
+ console.log(`In-progress courses: ${courses.length}`);
+
+ const allRows = [];
+ for (const course of courses) {
+ const rows = await scrapeAttendanceRowsForCourse(course.id, course.name);
+ allRows.push(...rows);
+ console.log(`- ${course.name}: ${rows.length} rows`);
+ }
+
+ const payload = {
+ attendance_rows: allRows.map(({ description, ...row }) => row),
+ dataset_id: `bunkialo-e2e-${Date.now()}`,
+ dataset_expires_at: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
+ };
+
+ console.log(`Total attendance rows: ${payload.attendance_rows.length}`);
+
+ const create = await createBunkxSession(payload);
+ console.log(`Create session status: ${create.status}`);
+ console.log(`Create session body head: ${create.bodyText.slice(0, 220)}`);
+
+ const sid = create.bodyJson?.sid;
+ if (!sid) {
+ console.error("No sid returned from session create endpoint");
+ process.exit(1);
+ }
+
+ console.log(`SID: ${sid}`);
+
+ const page = await checkLaunchPage(sid);
+ console.log(`Page status: ${page.status}`);
+ console.log(`Page has 'Received X records': ${page.hasReceivedText}`);
+ console.log(
+ `Page has 'Session unavailable': ${page.hasSessionUnavailableText}`,
+ );
+
+ const consume = await checkConsumeApi(sid);
+ console.log(`Consume first status: ${consume.firstStatus}`);
+ console.log(`Consume second status: ${consume.secondStatus}`);
+ console.log(`Consume first body head: ${consume.firstBodyHead}`);
+ console.log(`Consume second body head: ${consume.secondBodyHead}`);
+
+ console.log("=== E2E TEST COMPLETE ===");
+}
+
+main().catch((error) => {
+ const message = error instanceof Error ? error.message : String(error);
+ console.error(`E2E test failed: ${message}`);
+ process.exit(1);
+});