From 4ef692363c69a46f52d94c4be202fda101bc0205 Mon Sep 17 00:00:00 2001 From: Vishnunath A Suresh Date: Tue, 31 Mar 2026 15:51:16 +0530 Subject: [PATCH 1/2] refactor(bunkx): remove session handling and attendance payload logic accidentally added to the bunkialo-landing page --- .../src/app/api/bunkx/session/[sid]/route.ts | 31 ------- .../src/app/api/bunkx/session/route.ts | 58 ------------- bunkialo-landing/src/app/bunkialo/page.tsx | 85 ------------------- bunkialo-landing/src/lib/bunkx-payload.ts | 68 --------------- .../src/lib/bunkx-session-store.ts | 73 ---------------- 5 files changed, 315 deletions(-) delete mode 100644 bunkialo-landing/src/app/api/bunkx/session/[sid]/route.ts delete mode 100644 bunkialo-landing/src/app/api/bunkx/session/route.ts delete mode 100644 bunkialo-landing/src/app/bunkialo/page.tsx delete mode 100644 bunkialo-landing/src/lib/bunkx-payload.ts delete mode 100644 bunkialo-landing/src/lib/bunkx-session-store.ts 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; -}; From 4d5362aaa2fe3b010b2bd3003efa90bc30463640 Mon Sep 17 00:00:00 2001 From: Vishnunath A Suresh Date: Tue, 31 Mar 2026 16:07:05 +0530 Subject: [PATCH 2/2] feat(bunkx): add end-to-end test for Bunkx SID handoff process --- src/scripts/test-bunkx-sid-e2e.mjs | 327 +++++++++++++++++++++++++++++ 1 file changed, 327 insertions(+) create mode 100644 src/scripts/test-bunkx-sid-e2e.mjs 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); +});