diff --git a/.github/workflows/ci-dashboard.yml b/.github/workflows/ci-dashboard.yml new file mode 100644 index 0000000..56a0f4f --- /dev/null +++ b/.github/workflows/ci-dashboard.yml @@ -0,0 +1,51 @@ +name: Dashboard CI + +on: + push: + branches: [main, rebranding/bishop-state] + paths: + - "codebenders-dashboard/**" + - ".github/workflows/ci-dashboard.yml" + pull_request: + branches: [main, rebranding/bishop-state] + paths: + - "codebenders-dashboard/**" + - ".github/workflows/ci-dashboard.yml" + +defaults: + run: + working-directory: codebenders-dashboard + +jobs: + ci: + name: Type check · Lint · Build + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install dependencies + run: npm install + + - name: TypeScript type check + run: npx tsc --noEmit + + - name: Lint + run: npm run lint + + - name: Build + run: npm run build + env: + # Provide placeholder values so the build doesn't fail on missing env assertions. + # API routes and Supabase calls are opt-in at runtime; they are not executed during build. + NEXT_PUBLIC_SUPABASE_URL: https://placeholder.supabase.co + NEXT_PUBLIC_SUPABASE_ANON_KEY: placeholder-anon-key + DB_HOST: localhost + DB_PORT: 5432 + DB_USER: postgres + DB_PASSWORD: postgres + DB_NAME: postgres diff --git a/.github/workflows/ci-python.yml b/.github/workflows/ci-python.yml new file mode 100644 index 0000000..f5defab --- /dev/null +++ b/.github/workflows/ci-python.yml @@ -0,0 +1,44 @@ +name: Python CI + +on: + push: + branches: [main, rebranding/bishop-state] + paths: + - "ai_model/**" + - "operations/**" + - "requirements.txt" + - ".github/workflows/ci-python.yml" + pull_request: + branches: [main, rebranding/bishop-state] + paths: + - "ai_model/**" + - "operations/**" + - "requirements.txt" + - ".github/workflows/ci-python.yml" + +jobs: + ci: + name: Lint · Deps · Syntax check + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: "pip" + + - name: Install ruff + run: pip install ruff + + - name: Lint (ruff) + run: ruff check ai_model/ operations/ + + - name: Install dependencies + run: pip install -r requirements.txt + + - name: Syntax check — entry points + run: | + python -m py_compile ai_model/complete_ml_pipeline.py + python -m py_compile operations/db_config.py diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml new file mode 100644 index 0000000..eb7674e --- /dev/null +++ b/.github/workflows/deploy-preview.yml @@ -0,0 +1,57 @@ +name: Deploy Preview + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + deploy: + name: Vercel preview deployment + runs-on: ubuntu-latest + # Only run when Vercel secrets are configured (skips forks / contributors without access) + if: ${{ vars.VERCEL_PROJECT_ID != '' }} + + permissions: + pull-requests: write # needed to post the preview URL comment + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install Vercel CLI + run: npm install --global vercel@latest + + - name: Pull Vercel environment (preview) + run: vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_TOKEN }} + env: + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + + - name: Build + run: vercel build --token=${{ secrets.VERCEL_TOKEN }} + env: + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + + - name: Deploy + id: deploy + run: | + url=$(vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }}) + echo "url=$url" >> "$GITHUB_OUTPUT" + env: + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + + - name: Post preview URL to PR + uses: actions/github-script@v7 + with: + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: `## Preview deployment\n\n🚀 **${{ steps.deploy.outputs.url }}**\n\nDeployed from ${context.sha.slice(0, 7)}.`, + }) diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml new file mode 100644 index 0000000..8f0c491 --- /dev/null +++ b/.github/workflows/deploy-production.yml @@ -0,0 +1,47 @@ +name: Deploy Production + +on: + push: + branches: [main] + +jobs: + deploy: + name: Vercel production deployment + runs-on: ubuntu-latest + # Only run when Vercel secrets are configured + if: ${{ vars.VERCEL_PROJECT_ID != '' }} + + environment: + name: production + url: ${{ steps.deploy.outputs.url }} + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install Vercel CLI + run: npm install --global vercel@latest + + - name: Pull Vercel environment (production) + run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }} + env: + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + + - name: Build + run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }} + env: + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + + - name: Deploy + id: deploy + run: | + url=$(vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }}) + echo "url=$url" >> "$GITHUB_OUTPUT" + env: + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml new file mode 100644 index 0000000..480af77 --- /dev/null +++ b/.github/workflows/security-audit.yml @@ -0,0 +1,48 @@ +name: Security Audit + +on: + schedule: + - cron: "0 9 * * 1" # Every Monday at 09:00 UTC + push: + branches: [main] + workflow_dispatch: + +jobs: + audit-npm: + name: npm audit (dashboard) + runs-on: ubuntu-latest + + defaults: + run: + working-directory: codebenders-dashboard + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install dependencies + run: npm install + + - name: Audit + run: npm audit --audit-level=high + + audit-python: + name: pip-audit (ML pipeline) + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: "pip" + + - name: Install pip-audit + run: pip install pip-audit + + - name: Audit + run: pip-audit -r requirements.txt diff --git a/.gitignore b/.gitignore index a8b64cf..6fc7b59 100644 --- a/.gitignore +++ b/.gitignore @@ -106,6 +106,7 @@ Desktop.ini *.csv *.xlsx *.xls +*.pptx *.json !package.json !tsconfig.json @@ -158,6 +159,12 @@ docker-compose.override.yml # Git worktrees .worktrees/ +# Large presentation/doc files +docs/AI-Powered-Student-Success-Analytics.pptx +docs/Copy-of-AI-Powered-Student-Success-Analytics.pdf +docs/CodeBenders-PRD_Student_Success_Analytics.pdf +DOCUMENTATION_ISSUES.md + # Misc .cache/ *.seed diff --git a/ai_model/complete_ml_pipeline.py b/ai_model/complete_ml_pipeline.py index 3e66a31..acb1158 100644 --- a/ai_model/complete_ml_pipeline.py +++ b/ai_model/complete_ml_pipeline.py @@ -173,7 +173,17 @@ def assign_credential_type(row): else: return 2 # Default to Associate's (most common at community colleges) - # No credential completed + # Priority 5: No completion data — fall back to credential type sought as proxy + # (represents "what credential is this student on track for") + credential_sought = str(row.get('Credential_Type_Sought_Year_1', '')) + if credential_sought in ['01', '02', '03', 'C1', 'C2']: + return 1 # Certificate-track + elif credential_sought in ['A', '04', '05']: + return 2 # Associate-track + elif credential_sought in ['B', '06', '07', '08']: + return 3 # Bachelor-track + + # No credential completed or sought return 0 # No credential df['target_credential_type'] = df.apply(assign_credential_type, axis=1) @@ -646,6 +656,7 @@ def assign_alert_level(risk_score): n_estimators=50, max_depth=5, min_samples_split=30, + class_weight='balanced', random_state=42, n_jobs=-1 ) @@ -734,7 +745,7 @@ def assign_alert_level(risk_score): # Only include students who attempted gateway math (not NaN) gateway_math_raw = df['CompletedGatewayMathYear1'] valid_idx = gateway_math_raw.notna() -y_gateway_math = (gateway_math_raw[valid_idx] == 'C').astype(int) +y_gateway_math = (gateway_math_raw[valid_idx] == 'Y').astype(int) X_gateway_math = X_gateway_math_clean[valid_idx] print(f"\nDataset size: {len(X_gateway_math):,} students") @@ -845,7 +856,7 @@ def assign_alert_level(risk_score): # Only include students who attempted gateway English (not NaN) gateway_english_raw = df['CompletedGatewayEnglishYear1'] valid_idx = gateway_english_raw.notna() -y_gateway_english = (gateway_english_raw[valid_idx] == 'C').astype(int) +y_gateway_english = (gateway_english_raw[valid_idx] == 'Y').astype(int) X_gateway_english = X_gateway_english_clean[valid_idx] print(f"\nDataset size: {len(X_gateway_english):,} students") diff --git a/codebenders-dashboard/app/actions/auth.ts b/codebenders-dashboard/app/actions/auth.ts new file mode 100644 index 0000000..98507c4 --- /dev/null +++ b/codebenders-dashboard/app/actions/auth.ts @@ -0,0 +1,10 @@ +"use server" + +import { createClient } from "@/lib/supabase/server" +import { redirect } from "next/navigation" + +export async function signOut() { + const supabase = await createClient() + await supabase.auth.signOut() + redirect("/login") +} diff --git a/codebenders-dashboard/app/api/analyze/route.ts b/codebenders-dashboard/app/api/analyze/route.ts index a04a54d..042b26b 100644 --- a/codebenders-dashboard/app/api/analyze/route.ts +++ b/codebenders-dashboard/app/api/analyze/route.ts @@ -21,7 +21,17 @@ const queryPlanSchema = z.object({ // IMPORTANT: Column names listed here are the EXACT case-sensitive names in PostgreSQL. // Mixed-case columns (e.g. "Cohort", "Retention") must be double-quoted in generated SQL. // All-lowercase columns (e.g. retention_probability) do not require quoting. -const SCHEMA_INFO = { +interface SchemaEntry { + database: string + mainTable: string + description?: string + columns: Record + courseTable?: string + courseColumns?: Record + ferpaExcluded?: string[] +} + +const SCHEMA_INFO: Record = { bscc: { database: "postgres", mainTable: "student_level_with_predictions", @@ -68,6 +78,22 @@ const SCHEMA_INFO = { course_completion_rate: "Course completion rate (0-1)", passing_rate: "Course passing rate (0-1)", }, + courseTable: "course_enrollments", + courseColumns: { + course_prefix: "Course dept code ('MAT','ENG','NUR','CIS', etc.) — lowercase, no quoting", + course_number: "Course number ('100','201', etc.) — lowercase, no quoting", + course_name: "Full course name — lowercase, no quoting", + grade: "Student grade: 'A','B','C','D','F','W','I','AU','P' — lowercase, no quoting", + delivery_method: "Delivery: 'F'=face-to-face, 'O'=online, 'H'=hybrid — lowercase, no quoting", + instructor_status: "Instructor type: 'FT'=full-time, 'PT'=part-time — lowercase, no quoting", + gateway_type: "Gateway: 'M'=math gateway, 'E'=English gateway, 'N'=not a gateway — lowercase", + credits_attempted: "Credits attempted (numeric)", + credits_earned: "Credits earned (numeric)", + cohort: "Cohort year as text — lowercase, no quoting", + academic_year: "Academic year e.g. '2021-22' — lowercase, no quoting", + academic_term: "Term e.g. 'FALL','SPRING','SUMMER' — lowercase, no quoting", + }, + ferpaExcluded: ["Student_GUID", "student_guid"], }, akron: { database: "University_of_Akron", @@ -104,14 +130,22 @@ export async function POST(request: NextRequest) { schema: queryPlanSchema, prompt: `You are a SQL query generator for student success analytics using PostgreSQL. -DATABASE SCHEMA: -- Main Table: ${schemaInfo.mainTable} -- Description: ${schemaInfo.description} +AVAILABLE TABLES: + +1. ${schemaInfo.mainTable} — student-level analytics + USE FOR: retention rates, persistence, GPA, demographics, risk scores, credential predictions, enrollment counts + COLUMNS: +${Object.entries(schemaInfo.columns).map(([col, desc]) => ` - ${col}: ${desc}`).join("\n")} -KEY COLUMNS: -${Object.entries(schemaInfo.columns).map(([col, desc]) => `- ${col}: ${desc}`).join("\n")} +2. ${schemaInfo.courseTable ?? "course_enrollments"} — individual course enrollment records + USE FOR: DFW/DFWI rates by course, pass rates by course, gateway course outcomes, delivery method analysis, instructor type analysis + NOTE: This table has NO institution_id column. Do NOT add institution filters — data is already scoped to this institution. + COLUMNS: +${Object.entries(schemaInfo.courseColumns ?? {}).map(([col, desc]) => ` - ${col}: ${desc}`).join("\n")} -CRITICAL SCHEMA NOTES: +TABLE SELECTION RULE: If the question mentions "courses", "DFW", "DFWI", "withdrawal rate", "pass rate by course", "gateway course", "failing courses", "course outcomes" → use ${schemaInfo.courseTable ?? "course_enrollments"}. Otherwise use ${schemaInfo.mainTable}. + +CRITICAL SCHEMA NOTES (for ${schemaInfo.mainTable}): - Column names with uppercase letters MUST be double-quoted in PostgreSQL SQL or the query will fail. CORRECT: WHERE "Cohort" = 2023 AND "Cohort_Term" = 'Fall' INCORRECT: WHERE cohort = 2023 AND cohort_term = 'Fall' @@ -122,6 +156,16 @@ CRITICAL SCHEMA NOTES: - Lowercase ML columns (retention_probability, at_risk_alert, etc.) do NOT need quoting. - Use standard PostgreSQL syntax — no backtick quoting, no cross-database references +COMPUTING DFWI RATE from course_enrollments (returns 0–1, display layer multiplies by 100): + ROUND(COUNT(*) FILTER (WHERE grade IN ('D','F','W','I'))::numeric / NULLIF(COUNT(*), 0), 4) AS dfwi_rate + +COMPUTING PASS RATE from course_enrollments (returns 0–1, display layer multiplies by 100): + ROUND(COUNT(*) FILTER (WHERE grade NOT IN ('D','F','W','I') AND grade IS NOT NULL AND grade != '')::numeric / NULLIF(COUNT(*), 0), 4) AS pass_rate + +FERPA COMPLIANCE — NEVER include these in SELECT output: + Student_GUID, student_guid +Do not expose individual student identifiers in query results. + IMPORTANT QUERY INTERPRETATION RULES: 1. METRIC SELECTION: @@ -151,7 +195,7 @@ IMPORTANT QUERY INTERPRETATION RULES: - Age filters: use numeric comparisons directly (e.g., "Student_Age" >= 25) 4. VISUALIZATION: - - Comparing groups (age, gender, race) → "bar" + - Comparing groups (age, gender, race, courses) → "bar" - Time series (cohort, term over time) → "line" - Single number → "kpi" - Percentages/shares → "pie" @@ -166,7 +210,7 @@ Generate a query plan with: - filters: any filters to apply [OPTIONAL] - timeHint: human-readable time description [OPTIONAL] - vizType: appropriate visualization [REQUIRED] -- sql: VALID executable PostgreSQL query against table "${schemaInfo.mainTable}" [REQUIRED] +- sql: VALID executable PostgreSQL query against the appropriate table (${schemaInfo.mainTable} or course_enrollments) [REQUIRED] - queryString: empty string [OPTIONAL] EXAMPLE for "segment students over 25 and under 25 in 2023 cohort": @@ -176,6 +220,13 @@ EXAMPLE for "segment students over 25 and under 25 in 2023 cohort": "queryString": "" } +EXAMPLE for "top 5 courses with highest DFW rates": +{ + "vizType": "bar", + "sql": "SELECT course_prefix || ' ' || course_number AS course, MAX(course_name) AS course_name, COUNT(*) AS enrollments, ROUND(COUNT(*) FILTER (WHERE grade IN ('D','F','W','I'))::numeric / NULLIF(COUNT(*), 0), 4) AS dfwi_rate FROM course_enrollments GROUP BY course_prefix, course_number HAVING COUNT(*) >= 10 ORDER BY dfwi_rate DESC LIMIT 5", + "queryString": "" +} + Make sure the SQL is valid PostgreSQL and addresses exactly what the user asked for!`, }) diff --git a/codebenders-dashboard/app/api/courses/dfwi/route.ts b/codebenders-dashboard/app/api/courses/dfwi/route.ts new file mode 100644 index 0000000..b077d75 --- /dev/null +++ b/codebenders-dashboard/app/api/courses/dfwi/route.ts @@ -0,0 +1,93 @@ +import { type NextRequest, NextResponse } from "next/server" +import { getPool } from "@/lib/db" +import { canAccess, type Role } from "@/lib/roles" + +export async function GET(request: NextRequest) { + const role = request.headers.get("x-user-role") as Role | null + if (!role || !canAccess("/api/courses/dfwi", role)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } + + const { searchParams } = new URL(request.url) + + const gatewayOnly = searchParams.get("gatewayOnly") === "true" + const minEnrollments = Math.max(1, Number(searchParams.get("minEnrollments") || 10)) + const cohort = searchParams.get("cohort") || "" + const term = searchParams.get("term") || "" + const sortBy = searchParams.get("sortBy") || "dfwi_rate" + const sortDir = searchParams.get("sortDir") === "asc" ? "ASC" : "DESC" + + // Whitelist sort columns to prevent injection + const SORT_COLS: Record = { + dfwi_rate: "dfwi_rate", + enrollments: "enrollments", + } + const orderExpr = SORT_COLS[sortBy] ?? "dfwi_rate" + + const conditions: string[] = [] + const params: unknown[] = [] + + if (gatewayOnly) { + conditions.push("gateway_type IN ('M', 'E')") + } + + if (cohort) { + params.push(cohort) + conditions.push(`cohort = $${params.length}`) + } + + if (term) { + params.push(term) + conditions.push(`academic_term = $${params.length}`) + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "" + + // minEnrollments goes into HAVING — bind as a param + params.push(minEnrollments) + const minEnrollmentsParam = `$${params.length}` + + const sql = ` + SELECT + course_prefix, + course_number, + MAX(course_name) AS course_name, + MAX(gateway_type) AS gateway_type, + COUNT(*) AS enrollments, + COUNT(*) FILTER (WHERE grade IN ('D', 'F', 'W', 'I')) AS dfwi_count, + ROUND( + COUNT(*) FILTER (WHERE grade IN ('D', 'F', 'W', 'I')) * 100.0 / NULLIF(COUNT(*), 0), + 1 + ) AS dfwi_rate, + ROUND( + COUNT(*) FILTER ( + WHERE grade NOT IN ('D', 'F', 'W', 'I') + AND grade IS NOT NULL + AND grade != '' + ) * 100.0 / NULLIF(COUNT(*), 0), + 1 + ) AS pass_rate + FROM course_enrollments + ${where} + GROUP BY course_prefix, course_number + HAVING COUNT(*) >= ${minEnrollmentsParam} + ORDER BY ${orderExpr} ${sortDir} + LIMIT 200 -- capped at 200 rows; add pagination if needed + ` + + try { + const pool = getPool() + const result = await pool.query(sql, params) + + return NextResponse.json({ + courses: result.rows, + total: result.rows.length, + }) + } catch (error) { + console.error("DFWI fetch error:", error) + return NextResponse.json( + { error: "Failed to fetch DFWI data", details: error instanceof Error ? error.message : String(error) }, + { status: 500 } + ) + } +} diff --git a/codebenders-dashboard/app/api/courses/explain-pairing/route.ts b/codebenders-dashboard/app/api/courses/explain-pairing/route.ts new file mode 100644 index 0000000..fc560c0 --- /dev/null +++ b/codebenders-dashboard/app/api/courses/explain-pairing/route.ts @@ -0,0 +1,209 @@ +import { type NextRequest, NextResponse } from "next/server" +import { getPool } from "@/lib/db" +import { canAccess, type Role } from "@/lib/roles" +import { generateText } from "ai" +import { createOpenAI } from "@ai-sdk/openai" + +const openai = createOpenAI({ apiKey: process.env.OPENAI_API_KEY || "" }) + +const DELIVERY_LABELS: Record = { + F: "Face-to-Face", + O: "Online", + H: "Hybrid", +} + +export async function POST(request: NextRequest) { + const role = request.headers.get("x-user-role") as Role | null + if (!role || !canAccess("/api/courses/explain-pairing", role)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } + + if (!process.env.OPENAI_API_KEY) { + return NextResponse.json({ error: "OpenAI API key not configured" }, { status: 500 }) + } + + const body = await request.json() + const { prefix_a, number_a, name_a, prefix_b, number_b, name_b } = body + + if (!prefix_a || !number_a || !prefix_b || !number_b) { + return NextResponse.json({ error: "Missing course identifiers" }, { status: 400 }) + } + + const pool = getPool() + + try { + // Query 1: Individual DFWI + pass rates for each course + const [indivRes, deliveryRes, instrRes] = await Promise.all([ + pool.query( + `SELECT + course_prefix, + course_number, + COUNT(*) AS enrollments, + ROUND( + COUNT(*) FILTER (WHERE grade IN ('D','F','W','I') AND grade IS NOT NULL AND grade <> '') + * 100.0 / NULLIF(COUNT(*), 0), 1 + ) AS dfwi_rate, + ROUND( + COUNT(*) FILTER (WHERE grade NOT IN ('D','F','W','I') AND grade IS NOT NULL AND grade <> '') + * 100.0 / NULLIF(COUNT(*), 0), 1 + ) AS pass_rate + FROM course_enrollments + WHERE (course_prefix = $1 AND course_number = $2) + OR (course_prefix = $3 AND course_number = $4) + GROUP BY course_prefix, course_number`, + [prefix_a, number_a, prefix_b, number_b], + ), + + // Query 2: Co-enrollment stats by delivery method + pool.query( + `SELECT + a.delivery_method, + COUNT(*) AS co_count, + ROUND( + COUNT(*) FILTER ( + WHERE a.grade NOT IN ('D','F','W','I') AND a.grade IS NOT NULL AND a.grade <> '' + AND b.grade NOT IN ('D','F','W','I') AND b.grade IS NOT NULL AND b.grade <> '' + ) * 100.0 / NULLIF(COUNT(*), 0), 1 + ) AS both_pass_rate + FROM course_enrollments a + JOIN course_enrollments b + ON a.student_guid = b.student_guid + AND a.academic_year = b.academic_year + AND a.academic_term = b.academic_term + WHERE a.course_prefix = $1 AND a.course_number = $2 + AND b.course_prefix = $3 AND b.course_number = $4 + GROUP BY a.delivery_method + ORDER BY co_count DESC`, + [prefix_a, number_a, prefix_b, number_b], + ), + + // Query 3: Co-enrollment stats by instructor status + pool.query( + `SELECT + a.instructor_status, + COUNT(*) AS co_count, + ROUND( + COUNT(*) FILTER ( + WHERE a.grade NOT IN ('D','F','W','I') AND a.grade IS NOT NULL AND a.grade <> '' + AND b.grade NOT IN ('D','F','W','I') AND b.grade IS NOT NULL AND b.grade <> '' + ) * 100.0 / NULLIF(COUNT(*), 0), 1 + ) AS both_pass_rate + FROM course_enrollments a + JOIN course_enrollments b + ON a.student_guid = b.student_guid + AND a.academic_year = b.academic_year + AND a.academic_term = b.academic_term + WHERE a.course_prefix = $1 AND a.course_number = $2 + AND b.course_prefix = $3 AND b.course_number = $4 + GROUP BY a.instructor_status + ORDER BY co_count DESC`, + [prefix_a, number_a, prefix_b, number_b], + ), + ]) + + const courseA = indivRes.rows.find( + r => r.course_prefix === prefix_a && r.course_number === number_a, + ) + const courseB = indivRes.rows.find( + r => r.course_prefix === prefix_b && r.course_number === number_b, + ) + + const byDelivery = deliveryRes.rows.map(r => ({ + delivery_method: DELIVERY_LABELS[r.delivery_method] ?? r.delivery_method ?? "Unknown", + co_count: Number(r.co_count), + both_pass_rate: parseFloat(r.both_pass_rate), + })) + + const byInstructor = instrRes.rows.map(r => ({ + instructor_status: + r.instructor_status === "FT" + ? "Full-Time Instructor" + : r.instructor_status === "PT" + ? "Part-Time Instructor" + : (r.instructor_status ?? "Unknown"), + co_count: Number(r.co_count), + both_pass_rate: parseFloat(r.both_pass_rate), + })) + + const stats = { + courseA: courseA + ? { + dfwi_rate: parseFloat(courseA.dfwi_rate), + pass_rate: parseFloat(courseA.pass_rate), + enrollments: Number(courseA.enrollments), + } + : null, + courseB: courseB + ? { + dfwi_rate: parseFloat(courseB.dfwi_rate), + pass_rate: parseFloat(courseB.pass_rate), + enrollments: Number(courseB.enrollments), + } + : null, + byDelivery, + byInstructor, + } + + // Build prompt context + const labelA = `${prefix_a} ${number_a}${name_a ? ` (${name_a})` : ""}` + const labelB = `${prefix_b} ${number_b}${name_b ? ` (${name_b})` : ""}` + + const statsLineA = courseA + ? `${labelA}: ${courseA.dfwi_rate}% DFWI, ${courseA.pass_rate}% pass rate, ${Number(courseA.enrollments).toLocaleString()} total enrollments` + : `${labelA}: no individual stats available` + + const statsLineB = courseB + ? `${labelB}: ${courseB.dfwi_rate}% DFWI, ${courseB.pass_rate}% pass rate, ${Number(courseB.enrollments).toLocaleString()} total enrollments` + : `${labelB}: no individual stats available` + + const deliverySection = byDelivery.length + ? byDelivery + .map(d => ` ${d.delivery_method}: ${d.co_count} co-enrolled, ${d.both_pass_rate}% both passed`) + .join("\n") + : " No delivery breakdown available" + + const instrSection = byInstructor.length + ? byInstructor + .map(i => ` ${i.instructor_status}: ${i.co_count} co-enrolled, ${i.both_pass_rate}% both passed`) + .join("\n") + : " No instructor breakdown available" + + const llmPrompt = `You are an academic success analyst at a community college. An advisor is reviewing co-enrollment data for two courses students frequently take in the same term. + +INDIVIDUAL COURSE STATS: +- ${statsLineA} +- ${statsLineB} + +CO-ENROLLMENT BREAKDOWN (students taking both courses in the same term): + +By delivery method: +${deliverySection} + +By instructor type: +${instrSection} + +Write a concise analysis (3-4 sentences) that: +1. Explains why students might struggle when taking both courses together (consider workload, cognitive load, prerequisite overlap, or scheduling demands) +2. Highlights which conditions show better or worse outcomes based on the data +3. Ends with one specific, actionable recommendation for advisors + +Be practical and data-driven. Do not speculate beyond what the numbers show.` + + const result = await generateText({ + model: openai("gpt-4o-mini"), + prompt: llmPrompt, + maxOutputTokens: 320, + }) + + return NextResponse.json({ stats, explanation: result.text }) + } catch (error) { + console.error("[explain-pairing] Error:", error) + return NextResponse.json( + { + error: "Failed to generate explanation", + details: error instanceof Error ? error.message : String(error), + }, + { status: 500 }, + ) + } +} diff --git a/codebenders-dashboard/app/api/courses/gateway-funnel/route.ts b/codebenders-dashboard/app/api/courses/gateway-funnel/route.ts new file mode 100644 index 0000000..eae069c --- /dev/null +++ b/codebenders-dashboard/app/api/courses/gateway-funnel/route.ts @@ -0,0 +1,55 @@ +import { type NextRequest, NextResponse } from "next/server" +import { getPool } from "@/lib/db" +import { canAccess, type Role } from "@/lib/roles" + +export async function GET(request: NextRequest) { + const role = request.headers.get("x-user-role") as Role | null + if (!role || !canAccess("/api/courses/gateway-funnel", role)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } + + const mathSql = ` + SELECT + cohort, + COUNT(*) AS attempted, + COUNT(*) FILTER (WHERE grade NOT IN ('D','F','W','I') + AND grade IS NOT NULL AND grade <> '') AS passed, + COUNT(*) FILTER (WHERE grade IN ('D','F','W','I')) AS dfwi + FROM course_enrollments + WHERE gateway_type = 'M' + GROUP BY cohort + ORDER BY cohort + ` + + const englishSql = ` + SELECT + cohort, + COUNT(*) AS attempted, + COUNT(*) FILTER (WHERE grade NOT IN ('D','F','W','I') + AND grade IS NOT NULL AND grade <> '') AS passed, + COUNT(*) FILTER (WHERE grade IN ('D','F','W','I')) AS dfwi + FROM course_enrollments + WHERE gateway_type = 'E' + GROUP BY cohort + ORDER BY cohort + ` + + try { + const pool = getPool() + const [mathResult, englishResult] = await Promise.all([ + pool.query(mathSql), + pool.query(englishSql), + ]) + + return NextResponse.json({ + math: mathResult.rows, + english: englishResult.rows, + }) + } catch (error) { + console.error("Gateway funnel fetch error:", error) + return NextResponse.json( + { error: "Failed to fetch gateway funnel data", details: error instanceof Error ? error.message : String(error) }, + { status: 500 } + ) + } +} diff --git a/codebenders-dashboard/app/api/courses/sequences/route.ts b/codebenders-dashboard/app/api/courses/sequences/route.ts new file mode 100644 index 0000000..e9b418b --- /dev/null +++ b/codebenders-dashboard/app/api/courses/sequences/route.ts @@ -0,0 +1,56 @@ +import { type NextRequest, NextResponse } from "next/server" +import { getPool } from "@/lib/db" +import { canAccess, type Role } from "@/lib/roles" + +export async function GET(request: NextRequest) { + const role = request.headers.get("x-user-role") as Role | null + if (!role || !canAccess("/api/courses/sequences", role)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } + + const sql = ` + SELECT + a.course_prefix AS prefix_a, + a.course_number AS number_a, + b.course_prefix AS prefix_b, + b.course_number AS number_b, + MAX(a.course_name) AS name_a, + MAX(b.course_name) AS name_b, + COUNT(*) AS co_enrollment_count, + ROUND( + COUNT(*) FILTER ( + WHERE a.grade NOT IN ('D','F','W','I') AND a.grade IS NOT NULL AND a.grade <> '' + AND b.grade NOT IN ('D','F','W','I') AND b.grade IS NOT NULL AND b.grade <> '' + ) * 100.0 / NULLIF(COUNT(*), 0), + 1 + ) AS both_pass_rate + FROM course_enrollments a + JOIN course_enrollments b + ON a.student_guid = b.student_guid + AND a.academic_year = b.academic_year + AND a.academic_term = b.academic_term + AND ( + a.course_prefix < b.course_prefix + OR (a.course_prefix = b.course_prefix AND a.course_number < b.course_number) + ) + GROUP BY a.course_prefix, a.course_number, b.course_prefix, b.course_number + HAVING COUNT(*) >= 20 + ORDER BY co_enrollment_count DESC + LIMIT 20 + ` + + try { + const pool = getPool() + const result = await pool.query(sql) + + return NextResponse.json({ + pairs: result.rows, + }) + } catch (error) { + console.error("Course sequences fetch error:", error) + return NextResponse.json( + { error: "Failed to fetch course sequences", details: error instanceof Error ? error.message : String(error) }, + { status: 500 } + ) + } +} diff --git a/codebenders-dashboard/app/api/dashboard/kpis/route.ts b/codebenders-dashboard/app/api/dashboard/kpis/route.ts index e5b7d70..928932f 100644 --- a/codebenders-dashboard/app/api/dashboard/kpis/route.ts +++ b/codebenders-dashboard/app/api/dashboard/kpis/route.ts @@ -3,20 +3,44 @@ import { getPool } from "@/lib/db" export async function GET(request: NextRequest) { try { + const { searchParams } = new URL(request.url) + const cohort = searchParams.get("cohort") || "" + const enrollmentType = searchParams.get("enrollmentType") || "" + const credentialType = searchParams.get("credentialType") || "" + const pool = getPool() + const conditions: string[] = [] + const params: unknown[] = [] + + if (cohort) { + params.push(cohort) + conditions.push(`"Cohort" = $${params.length}`) + } + if (enrollmentType) { + params.push(enrollmentType) + conditions.push(`"Enrollment_Intensity_First_Term" = $${params.length}`) + } + if (credentialType) { + params.push(credentialType) + conditions.push(`predicted_credential_label = $${params.length}`) + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "" + const sql = ` SELECT - AVG("Retention") * 100 as overall_retention_rate, - AVG(retention_probability) * 100 as avg_predicted_retention, - SUM(CASE WHEN at_risk_alert IN ('HIGH', 'URGENT') THEN 1 ELSE 0 END) as high_critical_risk_count, - AVG(course_completion_rate) * 100 as avg_course_completion_rate, - COUNT(*) as total_students + AVG("Retention") * 100 AS overall_retention_rate, + AVG(retention_probability) * 100 AS avg_predicted_retention, + SUM(CASE WHEN at_risk_alert IN ('HIGH', 'URGENT') THEN 1 ELSE 0 END) AS high_critical_risk_count, + AVG(course_completion_rate) * 100 AS avg_course_completion_rate, + COUNT(*) AS total_students FROM student_level_with_predictions + ${where} LIMIT 1 ` - const result = await pool.query(sql) + const result = await pool.query(sql, params) const kpis = result.rows[0] ?? null if (!kpis) { @@ -24,17 +48,17 @@ export async function GET(request: NextRequest) { } return NextResponse.json({ - overallRetentionRate: Number(kpis.overall_retention_rate || 0).toFixed(1), - avgPredictedRetention: Number(kpis.avg_predicted_retention || 0).toFixed(1), - highCriticalRiskCount: Number(kpis.high_critical_risk_count || 0), + overallRetentionRate: Number(kpis.overall_retention_rate || 0).toFixed(1), + avgPredictedRetention: Number(kpis.avg_predicted_retention || 0).toFixed(1), + highCriticalRiskCount: Number(kpis.high_critical_risk_count || 0), avgCourseCompletionRate: Number(kpis.avg_course_completion_rate || 0).toFixed(1), - totalStudents: Number(kpis.total_students || 0), + totalStudents: Number(kpis.total_students || 0), }) } catch (error) { console.error("KPI fetch error:", error) return NextResponse.json( { - error: "Failed to fetch KPIs", + error: "Failed to fetch KPIs", details: error instanceof Error ? error.message : String(error), }, { status: 500 } diff --git a/codebenders-dashboard/app/api/dashboard/readiness/route.ts b/codebenders-dashboard/app/api/dashboard/readiness/route.ts index 6e5c690..b06844c 100644 --- a/codebenders-dashboard/app/api/dashboard/readiness/route.ts +++ b/codebenders-dashboard/app/api/dashboard/readiness/route.ts @@ -4,31 +4,49 @@ import { getPool } from "@/lib/db" export async function GET(request: Request) { try { const { searchParams } = new URL(request.url) - const institution = searchParams.get("institution") - const cohort = searchParams.get("cohort") - const level = searchParams.get("level") // high, medium, low + const institution = searchParams.get("institution") + const cohort = searchParams.get("cohort") + const level = searchParams.get("level") // high, medium, low + const enrollmentType = searchParams.get("enrollmentType") + const credentialType = searchParams.get("credentialType") const pool = getPool() - // Build WHERE clause with $N Postgres placeholders + // Build WHERE clause with $N Postgres placeholders. + // llm_recommendations is aliased as lr; when enrollment/credential filters are + // present we JOIN student_level_with_predictions as s. const conditions: string[] = [] - const params: any[] = [] + const params: unknown[] = [] if (institution) { params.push(institution) - conditions.push(`"Institution_ID" = $${params.length}`) + conditions.push(`lr."Institution_ID" = $${params.length}`) } if (cohort) { params.push(cohort) - conditions.push(`"Cohort" = $${params.length}`) + conditions.push(`lr."Cohort" = $${params.length}`) } if (level) { params.push(level) - conditions.push(`readiness_level = $${params.length}`) + conditions.push(`lr.readiness_level = $${params.length}`) } + if (enrollmentType) { + params.push(enrollmentType) + conditions.push(`s."Enrollment_Intensity_First_Term" = $${params.length}`) + } + + if (credentialType) { + params.push(credentialType) + conditions.push(`s.predicted_credential_label = $${params.length}`) + } + + const needsJoin = !!(enrollmentType || credentialType) + const joinClause = needsJoin + ? `JOIN student_level_with_predictions s ON s."Student_GUID" = lr."Student_GUID"` + : "" const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "" // Get overall statistics @@ -36,13 +54,14 @@ export async function GET(request: Request) { ` SELECT COUNT(*) as total_students, - AVG(readiness_score) as avg_score, - MIN(readiness_score) as min_score, - MAX(readiness_score) as max_score, - SUM(CASE WHEN readiness_level = 'high' THEN 1 ELSE 0 END) as high_count, - SUM(CASE WHEN readiness_level = 'medium' THEN 1 ELSE 0 END) as medium_count, - SUM(CASE WHEN readiness_level = 'low' THEN 1 ELSE 0 END) as low_count - FROM llm_recommendations + AVG(lr.readiness_score) as avg_score, + MIN(lr.readiness_score) as min_score, + MAX(lr.readiness_score) as max_score, + SUM(CASE WHEN lr.readiness_level = 'high' THEN 1 ELSE 0 END) as high_count, + SUM(CASE WHEN lr.readiness_level = 'medium' THEN 1 ELSE 0 END) as medium_count, + SUM(CASE WHEN lr.readiness_level = 'low' THEN 1 ELSE 0 END) as low_count + FROM llm_recommendations lr + ${joinClause} ${whereClause} `, params @@ -58,19 +77,20 @@ export async function GET(request: Request) { const distributionResult = await pool.query( ` SELECT - readiness_level, + lr.readiness_level, COUNT(*) as count, - AVG(readiness_score) as avg_score, - MIN(readiness_score) as min_score, - MAX(readiness_score) as max_score - FROM llm_recommendations + AVG(lr.readiness_score) as avg_score, + MIN(lr.readiness_score) as min_score, + MAX(lr.readiness_score) as max_score + FROM llm_recommendations lr + ${joinClause} ${whereClause} - GROUP BY readiness_level + GROUP BY lr.readiness_level ORDER BY - CASE readiness_level - WHEN 'high' THEN 1 + CASE lr.readiness_level + WHEN 'high' THEN 1 WHEN 'medium' THEN 2 - WHEN 'low' THEN 3 + WHEN 'low' THEN 3 END `, params @@ -81,21 +101,22 @@ export async function GET(request: Request) { ` SELECT CASE - WHEN readiness_score >= 0.8 THEN '0.8-1.0' - WHEN readiness_score >= 0.6 THEN '0.6-0.8' - WHEN readiness_score >= 0.4 THEN '0.4-0.6' - WHEN readiness_score >= 0.2 THEN '0.2-0.4' + WHEN lr.readiness_score >= 0.8 THEN '0.8-1.0' + WHEN lr.readiness_score >= 0.6 THEN '0.6-0.8' + WHEN lr.readiness_score >= 0.4 THEN '0.4-0.6' + WHEN lr.readiness_score >= 0.2 THEN '0.2-0.4' ELSE '0.0-0.2' END as score_range, COUNT(*) as count - FROM llm_recommendations + FROM llm_recommendations lr + ${joinClause} ${whereClause} GROUP BY CASE - WHEN readiness_score >= 0.8 THEN '0.8-1.0' - WHEN readiness_score >= 0.6 THEN '0.6-0.8' - WHEN readiness_score >= 0.4 THEN '0.4-0.6' - WHEN readiness_score >= 0.2 THEN '0.2-0.4' + WHEN lr.readiness_score >= 0.8 THEN '0.8-1.0' + WHEN lr.readiness_score >= 0.6 THEN '0.6-0.8' + WHEN lr.readiness_score >= 0.4 THEN '0.4-0.6' + WHEN lr.readiness_score >= 0.2 THEN '0.2-0.4' ELSE '0.0-0.2' END ORDER BY score_range DESC @@ -120,6 +141,7 @@ export async function GET(request: Request) { lr.generated_at, lr.model_name FROM llm_recommendations lr + ${joinClause} ${whereClause} ORDER BY lr.generated_at DESC LIMIT 100 @@ -130,15 +152,16 @@ export async function GET(request: Request) { // Parse JSON fields in recent assessments const assessments = recentResult.rows.map((row) => ({ ...row, - risk_factors: row.risk_factors ? JSON.parse(row.risk_factors) : [], + risk_factors: row.risk_factors ? JSON.parse(row.risk_factors) : [], suggested_actions: row.suggested_actions ? JSON.parse(row.suggested_actions) : [], })) // Get most common risk factors const riskFactorResult = await pool.query( ` - SELECT risk_factors - FROM llm_recommendations + SELECT lr.risk_factors + FROM llm_recommendations lr + ${joinClause} ${whereClause} `, params @@ -172,16 +195,17 @@ export async function GET(request: Request) { const cohortResult = await pool.query( ` SELECT - "Cohort", + lr."Cohort", COUNT(*) as total, - AVG(readiness_score) as avg_score, - SUM(CASE WHEN readiness_level = 'high' THEN 1 ELSE 0 END) as high_count, - SUM(CASE WHEN readiness_level = 'medium' THEN 1 ELSE 0 END) as medium_count, - SUM(CASE WHEN readiness_level = 'low' THEN 1 ELSE 0 END) as low_count - FROM llm_recommendations + AVG(lr.readiness_score) as avg_score, + SUM(CASE WHEN lr.readiness_level = 'high' THEN 1 ELSE 0 END) as high_count, + SUM(CASE WHEN lr.readiness_level = 'medium' THEN 1 ELSE 0 END) as medium_count, + SUM(CASE WHEN lr.readiness_level = 'low' THEN 1 ELSE 0 END) as low_count + FROM llm_recommendations lr + ${joinClause} ${whereClause} - GROUP BY "Cohort" - ORDER BY "Cohort" DESC + GROUP BY lr."Cohort" + ORDER BY lr."Cohort" DESC `, params ) @@ -191,16 +215,16 @@ export async function GET(request: Request) { data: { summary: { total_students: stats.total_students, - avg_score: parseFloat(stats.avg_score || 0).toFixed(4), - min_score: parseFloat(stats.min_score || 0).toFixed(4), - max_score: parseFloat(stats.max_score || 0).toFixed(4), - high_count: stats.high_count, - medium_count: stats.medium_count, - low_count: stats.low_count, + avg_score: parseFloat(stats.avg_score || 0).toFixed(4), + min_score: parseFloat(stats.min_score || 0).toFixed(4), + max_score: parseFloat(stats.max_score || 0).toFixed(4), + high_count: stats.high_count, + medium_count: stats.medium_count, + low_count: stats.low_count, }, - distribution: distributionResult.rows, + distribution: distributionResult.rows, score_distribution: scoreDistResult.rows, - assessments: assessments, + assessments: assessments, top_risk_factors: topRiskFactors, cohort_breakdown: cohortResult.rows, }, @@ -211,7 +235,7 @@ export async function GET(request: Request) { return NextResponse.json( { success: false, - error: "Failed to fetch readiness assessment data", + error: "Failed to fetch readiness assessment data", details: error instanceof Error ? error.message : "Unknown error", }, { status: 500 } diff --git a/codebenders-dashboard/app/api/dashboard/retention-risk/route.ts b/codebenders-dashboard/app/api/dashboard/retention-risk/route.ts index ded139b..a087bcb 100644 --- a/codebenders-dashboard/app/api/dashboard/retention-risk/route.ts +++ b/codebenders-dashboard/app/api/dashboard/retention-risk/route.ts @@ -3,27 +3,57 @@ import { getPool } from "@/lib/db" export async function GET(request: NextRequest) { try { + const { searchParams } = new URL(request.url) + const cohort = searchParams.get("cohort") || "" + const enrollmentType = searchParams.get("enrollmentType") || "" + const credentialType = searchParams.get("credentialType") || "" + const pool = getPool() + const conditions: string[] = [] + const params: unknown[] = [] + + if (cohort) { + params.push(cohort) + conditions.push(`"Cohort" = $${params.length}`) + } + if (enrollmentType) { + params.push(enrollmentType) + conditions.push(`"Enrollment_Intensity_First_Term" = $${params.length}`) + } + if (credentialType) { + params.push(credentialType) + conditions.push(`predicted_credential_label = $${params.length}`) + } + + const baseWhere = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "" + + // Use a CTE so the percentage denominator is relative to the filtered set const sql = ` + WITH filtered AS ( + SELECT retention_risk_category + FROM student_level_with_predictions + ${baseWhere} + ), + total AS (SELECT COUNT(*) AS n FROM filtered) SELECT - retention_risk_category as category, - COUNT(*) as count, - ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM student_level_with_predictions), 1) as percentage - FROM student_level_with_predictions + retention_risk_category AS category, + COUNT(*) AS count, + ROUND(COUNT(*) * 100.0 / NULLIF((SELECT n FROM total), 0), 1) AS percentage + FROM filtered WHERE retention_risk_category IS NOT NULL GROUP BY retention_risk_category ORDER BY CASE retention_risk_category - WHEN 'Critical Risk' THEN 1 - WHEN 'High Risk' THEN 2 - WHEN 'Moderate Risk' THEN 3 - WHEN 'Low Risk' THEN 4 + WHEN 'Critical Risk' THEN 1 + WHEN 'High Risk' THEN 2 + WHEN 'Moderate Risk' THEN 3 + WHEN 'Low Risk' THEN 4 ELSE 5 END ` - const result = await pool.query(sql) + const result = await pool.query(sql, params) return NextResponse.json({ data: result.rows, @@ -32,7 +62,7 @@ export async function GET(request: NextRequest) { console.error("Retention risk fetch error:", error) return NextResponse.json( { - error: "Failed to fetch retention risk data", + error: "Failed to fetch retention risk data", details: error instanceof Error ? error.message : String(error), }, { status: 500 } diff --git a/codebenders-dashboard/app/api/dashboard/risk-alerts/route.ts b/codebenders-dashboard/app/api/dashboard/risk-alerts/route.ts index 544e5db..51e5a4f 100644 --- a/codebenders-dashboard/app/api/dashboard/risk-alerts/route.ts +++ b/codebenders-dashboard/app/api/dashboard/risk-alerts/route.ts @@ -3,27 +3,57 @@ import { getPool } from "@/lib/db" export async function GET(request: NextRequest) { try { + const { searchParams } = new URL(request.url) + const cohort = searchParams.get("cohort") || "" + const enrollmentType = searchParams.get("enrollmentType") || "" + const credentialType = searchParams.get("credentialType") || "" + const pool = getPool() + const conditions: string[] = [] + const params: unknown[] = [] + + if (cohort) { + params.push(cohort) + conditions.push(`"Cohort" = $${params.length}`) + } + if (enrollmentType) { + params.push(enrollmentType) + conditions.push(`"Enrollment_Intensity_First_Term" = $${params.length}`) + } + if (credentialType) { + params.push(credentialType) + conditions.push(`predicted_credential_label = $${params.length}`) + } + + const baseWhere = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "" + + // Use a CTE so the percentage denominator is relative to the filtered set const sql = ` + WITH filtered AS ( + SELECT at_risk_alert + FROM student_level_with_predictions + ${baseWhere} + ), + total AS (SELECT COUNT(*) AS n FROM filtered) SELECT - at_risk_alert as category, - COUNT(*) as count, - ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM student_level_with_predictions), 1) as percentage - FROM student_level_with_predictions + at_risk_alert AS category, + COUNT(*) AS count, + ROUND(COUNT(*) * 100.0 / NULLIF((SELECT n FROM total), 0), 1) AS percentage + FROM filtered WHERE at_risk_alert IS NOT NULL GROUP BY at_risk_alert ORDER BY CASE at_risk_alert - WHEN 'URGENT' THEN 1 - WHEN 'HIGH' THEN 2 + WHEN 'URGENT' THEN 1 + WHEN 'HIGH' THEN 2 WHEN 'MODERATE' THEN 3 - WHEN 'LOW' THEN 4 + WHEN 'LOW' THEN 4 ELSE 5 END ` - const result = await pool.query(sql) + const result = await pool.query(sql, params) return NextResponse.json({ data: result.rows, @@ -32,7 +62,7 @@ export async function GET(request: NextRequest) { console.error("Risk alerts fetch error:", error) return NextResponse.json( { - error: "Failed to fetch risk alerts", + error: "Failed to fetch risk alerts", details: error instanceof Error ? error.message : String(error), }, { status: 500 } diff --git a/codebenders-dashboard/app/api/query-history/export/route.ts b/codebenders-dashboard/app/api/query-history/export/route.ts new file mode 100644 index 0000000..27b503f --- /dev/null +++ b/codebenders-dashboard/app/api/query-history/export/route.ts @@ -0,0 +1,81 @@ +import { type NextRequest, NextResponse } from "next/server" +import { readFile } from "fs/promises" +import path from "path" +import { canAccess, type Role } from "@/lib/roles" + +const LOG_FILE = path.join(process.cwd(), "logs", "query-history.jsonl") + +function escapeCsvField(value: unknown): string { + const str = String(value ?? "") + if (str.includes(",") || str.includes('"') || str.includes("\n")) { + return `"${str.replace(/"/g, '""')}"` + } + return str +} + +export async function GET(request: NextRequest) { + const role = request.headers.get("x-user-role") as Role | null + if (!role || !canAccess("/api/query-history/export", role)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } + + const { searchParams } = new URL(request.url) + const fromParam = searchParams.get("from") + const toParam = searchParams.get("to") + + const fromDate = fromParam ? new Date(fromParam) : null + const toDate = toParam ? new Date(toParam) : null + + let raw: string + try { + raw = await readFile(LOG_FILE, "utf-8") + } catch (err: unknown) { + const code = (err as NodeJS.ErrnoException).code + if (code === "ENOENT") { + return NextResponse.json({ error: "Audit log does not exist yet" }, { status: 404 }) + } + return NextResponse.json({ error: "Failed to read audit log" }, { status: 500 }) + } + + const lines = raw.split("\n").filter(Boolean) + + const rows: string[] = [ + ["timestamp", "institution", "prompt", "vizType", "rowCount"].join(","), + ] + + for (const line of lines) { + let entry: Record + try { + entry = JSON.parse(line) + } catch { + continue + } + + // Date-range filter + if (fromDate || toDate) { + const ts = new Date(entry.timestamp as string) + if (fromDate && ts < fromDate) continue + if (toDate && ts > toDate) continue + } + + rows.push( + [ + escapeCsvField(entry.timestamp), + escapeCsvField(entry.institution), + escapeCsvField(entry.prompt), + escapeCsvField(entry.vizType), + escapeCsvField(entry.rowCount), + ].join(",") + ) + } + + const csv = rows.join("\n") + + return new NextResponse(csv, { + status: 200, + headers: { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": 'attachment; filename="query-audit-log.csv"', + }, + }) +} diff --git a/codebenders-dashboard/app/api/query-summary/route.ts b/codebenders-dashboard/app/api/query-summary/route.ts new file mode 100644 index 0000000..6fa563d --- /dev/null +++ b/codebenders-dashboard/app/api/query-summary/route.ts @@ -0,0 +1,63 @@ +import { type NextRequest, NextResponse } from "next/server" +import { canAccess, type Role } from "@/lib/roles" +import { generateText } from "ai" +import { createOpenAI } from "@ai-sdk/openai" + +const openai = createOpenAI({ apiKey: process.env.OPENAI_API_KEY || "" }) + +export async function POST(request: NextRequest) { + const role = request.headers.get("x-user-role") as Role | null + if (!role || !canAccess("/api/query-summary", role)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } + + if (!process.env.OPENAI_API_KEY) { + return NextResponse.json({ error: "OpenAI API key not configured" }, { status: 500 }) + } + + let prompt: string + let data: unknown[] + let rowCount: number + let vizType: string + + try { + const body = await request.json() + prompt = body.prompt + data = body.data + rowCount = body.rowCount ?? 0 + vizType = body.vizType ?? "unknown" + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }) + } + + if (!prompt || !Array.isArray(data)) { + return NextResponse.json({ error: "prompt and data are required" }, { status: 400 }) + } + + // Cap rows sent to LLM to avoid token overflow + const sampleRows = data.slice(0, 50) + + const llmPrompt = `You are a student success analyst at a community college. An advisor ran the following query and got these results. + +QUERY: "${prompt.slice(0, 2000)}" +RESULT: ${rowCount} rows, visualization type: ${vizType} +DATA SAMPLE: +${JSON.stringify(sampleRows, null, 2)} + +Write a 2-3 sentence plain-English summary of what these results show. Be specific about the numbers. Do not speculate beyond the data. Address the advisor directly.` + + try { + const result = await generateText({ + model: openai("gpt-4o-mini"), + prompt: llmPrompt, + maxOutputTokens: 200, + }) + return NextResponse.json({ summary: result.text }) + } catch (error) { + console.error("[query-summary] Error:", error) + return NextResponse.json( + { error: "Failed to generate summary", details: error instanceof Error ? error.message : String(error) }, + { status: 500 }, + ) + } +} diff --git a/codebenders-dashboard/app/api/students/[guid]/route.ts b/codebenders-dashboard/app/api/students/[guid]/route.ts new file mode 100644 index 0000000..f1ff2d4 --- /dev/null +++ b/codebenders-dashboard/app/api/students/[guid]/route.ts @@ -0,0 +1,66 @@ +import { type NextRequest, NextResponse } from "next/server" +import { getPool } from "@/lib/db" +import { canAccess, type Role } from "@/lib/roles" + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ guid: string }> } +) { + const role = request.headers.get("x-user-role") as Role | null + if (!role || !canAccess("/api/students", role)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } + + const { guid } = await params + + if (!guid) { + return NextResponse.json({ error: "Missing student GUID" }, { status: 400 }) + } + + const sql = ` + SELECT + s."Student_GUID" AS student_guid, + s."Cohort" AS cohort, + s."Enrollment_Intensity_First_Term" AS enrollment_intensity, + s.at_risk_alert, + ROUND((s.retention_probability * 100)::numeric, 1) AS retention_pct, + ROUND((s.gateway_math_probability * 100)::numeric, 1) AS gateway_math_pct, + ROUND((s.gateway_english_probability * 100)::numeric, 1) AS gateway_english_pct, + ROUND((s.low_gpa_probability * 100)::numeric, 1) AS gpa_risk_pct, + ROUND(s.predicted_time_to_credential::numeric, 1) AS time_to_credential, + s.predicted_credential_label AS credential_type, + ROUND((r.readiness_score * 100)::numeric, 1) AS readiness_pct, + r.readiness_level, + r.rationale, + r.risk_factors, + r.suggested_actions, + r.generated_at, + r.model_name + FROM student_level_with_predictions s + LEFT JOIN llm_recommendations r ON r."Student_GUID" = s."Student_GUID" + WHERE s."Student_GUID" = $1 + LIMIT 1 + ` + + try { + const pool = getPool() + const result = await pool.query(sql, [guid]) + + if (result.rows.length === 0) { + return NextResponse.json({ error: "Student not found" }, { status: 404 }) + } + + const row = result.rows[0] + return NextResponse.json({ + ...row, + risk_factors: row.risk_factors ? JSON.parse(row.risk_factors) : [], + suggested_actions: row.suggested_actions ? JSON.parse(row.suggested_actions) : [], + }) + } catch (error) { + console.error("Student detail fetch error:", error) + return NextResponse.json( + { error: "Failed to fetch student", details: error instanceof Error ? error.message : String(error) }, + { status: 500 } + ) + } +} diff --git a/codebenders-dashboard/app/api/students/route.ts b/codebenders-dashboard/app/api/students/route.ts new file mode 100644 index 0000000..b68b588 --- /dev/null +++ b/codebenders-dashboard/app/api/students/route.ts @@ -0,0 +1,140 @@ +import { type NextRequest, NextResponse } from "next/server" +import { getPool } from "@/lib/db" +import { canAccess, type Role } from "@/lib/roles" + +export async function GET(request: NextRequest) { + const role = request.headers.get("x-user-role") as Role | null + if (!role || !canAccess("/api/students", role)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } + + const { searchParams } = new URL(request.url) + + const page = Math.max(1, Number(searchParams.get("page") || 1)) + const pageSize = Math.min(100, Math.max(1, Number(searchParams.get("pageSize") || 50))) + const offset = (page - 1) * pageSize + + const search = searchParams.get("search") || "" + const alertLevels = searchParams.get("alertLevel") || "" // comma-separated + const readinessTier = searchParams.get("readinessTier") || "" + const credentialType = searchParams.get("credentialType") || "" + const cohort = searchParams.get("cohort") || "" + const enrollmentType = searchParams.get("enrollmentType") || "" + const sortBy = searchParams.get("sortBy") || "at_risk_alert" + const sortDir = searchParams.get("sortDir") === "asc" ? "ASC" : "DESC" + + // Whitelist sortable columns to prevent injection + const SORT_COLS: Record = { + at_risk_alert: "s.at_risk_alert", + retention_probability: "s.retention_probability", + readiness_score: "r.readiness_score", + gateway_math_probability: "s.gateway_math_probability", + gateway_english_probability: "s.gateway_english_probability", + low_gpa_probability: "s.low_gpa_probability", + predicted_time_to_credential: "s.predicted_time_to_credential", + "Cohort": `s."Cohort"`, + enrollment_intensity: `s."Enrollment_Intensity_First_Term"`, + credential_type: "s.predicted_credential_label", + } + const orderExpr = SORT_COLS[sortBy] ?? "s.at_risk_alert" + + // Risk order for default sort (URGENT first) + const riskOrder = sortBy === "at_risk_alert" + ? `CASE s.at_risk_alert WHEN 'URGENT' THEN 0 WHEN 'HIGH' THEN 1 WHEN 'MODERATE' THEN 2 WHEN 'LOW' THEN 3 ELSE 4 END` + : null + + const conditions: string[] = [] + const params: unknown[] = [] + + if (search) { + params.push(`%${search}%`) + conditions.push(`s."Student_GUID" ILIKE $${params.length}`) + } + + if (alertLevels) { + const levels = alertLevels.split(",").filter(Boolean) + if (levels.length > 0) { + const placeholders = levels.map((_, i) => `$${params.length + i + 1}`).join(", ") + params.push(...levels) + conditions.push(`s.at_risk_alert IN (${placeholders})`) + } + } + + if (readinessTier) { + params.push(readinessTier) + conditions.push(`r.readiness_level = $${params.length}`) + } + + if (credentialType) { + params.push(credentialType) + conditions.push(`s.predicted_credential_label = $${params.length}`) + } + + if (cohort) { + params.push(cohort) + conditions.push(`s."Cohort" = $${params.length}`) + } + + if (enrollmentType) { + params.push(enrollmentType) + conditions.push(`s."Enrollment_Intensity_First_Term" = $${params.length}`) + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "" + + const orderClause = riskOrder + ? `ORDER BY ${riskOrder} ${sortDir}, s.retention_probability DESC` + : `ORDER BY ${orderExpr} ${sortDir}` + + const dataSql = ` + SELECT + s."Student_GUID" AS student_guid, + s."Cohort" AS cohort, + s."Enrollment_Intensity_First_Term" AS enrollment_intensity, + s.at_risk_alert, + ROUND((s.retention_probability * 100)::numeric, 1) AS retention_pct, + ROUND((r.readiness_score * 100)::numeric, 1) AS readiness_pct, + r.readiness_level, + ROUND((s.gateway_math_probability * 100)::numeric, 1) AS gateway_math_pct, + ROUND((s.gateway_english_probability * 100)::numeric, 1) AS gateway_english_pct, + ROUND((s.low_gpa_probability * 100)::numeric, 1) AS gpa_risk_pct, + ROUND(s.predicted_time_to_credential::numeric, 1) AS time_to_credential, + s.predicted_credential_label AS credential_type + FROM student_level_with_predictions s + LEFT JOIN llm_recommendations r ON r."Student_GUID" = s."Student_GUID" + ${where} + ${orderClause} + LIMIT $${params.length + 1} OFFSET $${params.length + 2} + ` + + const countSql = ` + SELECT COUNT(*) AS total + FROM student_level_with_predictions s + LEFT JOIN llm_recommendations r ON r."Student_GUID" = s."Student_GUID" + ${where} + ` + + try { + const pool = getPool() + const [dataResult, countResult] = await Promise.all([ + pool.query(dataSql, [...params, pageSize, offset]), + pool.query(countSql, params), + ]) + + const total = Number(countResult.rows[0]?.total ?? 0) + + return NextResponse.json({ + students: dataResult.rows, + total, + page, + pageSize, + pageCount: Math.ceil(total / pageSize), + }) + } catch (error) { + console.error("Students fetch error:", error) + return NextResponse.json( + { error: "Failed to fetch students", details: error instanceof Error ? error.message : String(error) }, + { status: 500 } + ) + } +} diff --git a/codebenders-dashboard/app/auth/callback/route.ts b/codebenders-dashboard/app/auth/callback/route.ts new file mode 100644 index 0000000..956fd02 --- /dev/null +++ b/codebenders-dashboard/app/auth/callback/route.ts @@ -0,0 +1,18 @@ +import { type NextRequest, NextResponse } from "next/server" +import { createClient } from "@/lib/supabase/server" + +export async function GET(request: NextRequest) { + const { searchParams, origin } = new URL(request.url) + const code = searchParams.get("code") + const next = searchParams.get("next") ?? "/" + + if (code) { + const supabase = await createClient() + const { error } = await supabase.auth.exchangeCodeForSession(code) + if (!error) { + return NextResponse.redirect(`${origin}${next}`) + } + } + + return NextResponse.redirect(`${origin}/login?error=auth_callback_failed`) +} diff --git a/codebenders-dashboard/app/courses/page.tsx b/codebenders-dashboard/app/courses/page.tsx new file mode 100644 index 0000000..42eea7f --- /dev/null +++ b/codebenders-dashboard/app/courses/page.tsx @@ -0,0 +1,732 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import Link from "next/link" +import { ArrowDown, ArrowLeft, ArrowUp, ArrowUpDown, Loader2, Sparkles } from "lucide-react" +import { Button } from "@/components/ui/button" +import { InfoPopover } from "@/components/info-popover" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + ResponsiveContainer, + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + Legend, +} from "recharts" + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface CourseRow { + course_prefix: string + course_number: string + course_name: string + gateway_type: string | null + enrollments: number + dfwi_count: number + dfwi_rate: number + pass_rate: number +} + +interface CoursesResponse { + courses: CourseRow[] + total: number +} + +interface FunnelCohort { + cohort: string + attempted: number + passed: number + dfwi: number +} + +interface GatewayFunnelResponse { + math: FunnelCohort[] + english: FunnelCohort[] +} + +interface CoursePair { + prefix_a: string + number_a: string + name_a: string + prefix_b: string + number_b: string + name_b: string + co_enrollment_count: number + both_pass_rate: number +} + +interface SequencesResponse { + pairs: CoursePair[] +} + +interface PairStats { + courseA: { dfwi_rate: number; pass_rate: number; enrollments: number } | null + courseB: { dfwi_rate: number; pass_rate: number; enrollments: number } | null + byDelivery: { delivery_method: string; co_count: number; both_pass_rate: number }[] + byInstructor: { instructor_status: string; co_count: number; both_pass_rate: number }[] +} + +interface ExplainState { + loading: boolean + stats?: PairStats + explanation?: string + error?: string +} + +// ─── Color helpers ──────────────────────────────────────────────────────────── + +function DfwiRate({ value }: { value: number }) { + const v = parseFloat(String(value)) + const pct = v.toFixed(1) + let color = "text-green-600" + if (v >= 50) color = "text-red-600" + else if (v >= 30) color = "text-orange-600" + return {pct}% +} + +function PassRate({ value }: { value: number }) { + const v = parseFloat(String(value)) + const pct = v.toFixed(1) + let color = "text-red-600" + if (v >= 70) color = "text-green-600" + else if (v >= 50) color = "text-yellow-600" + return {pct}% +} + +function GatewayTypeLabel({ type }: { type: string | null }) { + if (!type) return + if (type === "M") return Math Gateway + if (type === "E") return English Gateway + return {type} +} + +// ─── Table header helpers ───────────────────────────────────────────────────── + +function Th({ label, right, info }: { label: string; right?: boolean; info?: React.ReactNode }) { + return ( + + + {label}{info} + + + ) +} + +function SortIcon({ active, dir }: { active: boolean; dir: "asc" | "desc" }) { + if (!active) return + return dir === "asc" + ? + : +} + +function ThSort({ + label, col, sortBy, sortDir, onSort, right, info, +}: { + label: string; col: T; sortBy: T; sortDir: "asc" | "desc" + onSort: (col: T) => void; right?: boolean; info?: React.ReactNode +}) { + return ( + + + + {info} + + + ) +} + +// ─── Stat chip ──────────────────────────────────────────────────────────────── + +function StatChip({ label, value, color }: { label: string; value: string; color?: string }) { + return ( +
+ {value} + {label} +
+ ) +} + +// ─── Tab helpers ────────────────────────────────────────────────────────────── + +type Tab = "dfwi" | "funnel" | "sequences" + +function TabButton({ id, label, active, onClick }: { id: Tab; label: string; active: boolean; onClick: (t: Tab) => void }) { + return ( + + ) +} + +// ─── Main component ─────────────────────────────────────────────────────────── + +export default function CoursesPage() { + // ── Tab state ── + const [activeTab, setActiveTab] = useState("dfwi") + + // ── DFWI table state ── + const [coursesData, setCoursesData] = useState(null) + const [coursesLoading, setCoursesLoading] = useState(true) + const [coursesError, setCoursesError] = useState(null) + + // ── Funnel state ── + const [funnelData, setFunnelData] = useState(null) + const [funnelLoading, setFunnelLoading] = useState(true) + const [funnelError, setFunnelError] = useState(null) + + // ── Sequences state ── + const [seqData, setSeqData] = useState(null) + const [seqLoading, setSeqLoading] = useState(true) + const [seqError, setSeqError] = useState(null) + + // ── Explain state (keyed by pairing key) ── + const [explainMap, setExplainMap] = useState>({}) + + // ── DFWI table filters + sort ── + const [gatewayOnly, setGatewayOnly] = useState(false) + const [minEnrollments, setMinEnrollments] = useState("10") + const [sortBy, setSortBy] = useState<"dfwi_rate" | "enrollments">("dfwi_rate") + const [sortDir, setSortDir] = useState<"asc" | "desc">("desc") + + // ── Pairings client-side sort ── + const [pairSortBy, setPairSortBy] = useState<"co_enrollment_count" | "both_pass_rate">("co_enrollment_count") + const [pairSortDir, setPairSortDir] = useState<"asc" | "desc">("desc") + + // ── Fetch DFWI courses ── + useEffect(() => { + setCoursesLoading(true) + setCoursesError(null) + const p = new URLSearchParams() + p.set("gatewayOnly", String(gatewayOnly)) + p.set("minEnrollments", minEnrollments) + p.set("sortBy", sortBy) + p.set("sortDir", sortDir) + fetch(`/api/courses/dfwi?${p.toString()}`) + .then(r => r.json()) + .then(d => { setCoursesData(d); setCoursesLoading(false) }) + .catch(e => { setCoursesError(e.message); setCoursesLoading(false) }) + }, [gatewayOnly, minEnrollments, sortBy, sortDir]) + + // ── Fetch gateway funnel ── + useEffect(() => { + setFunnelLoading(true) + setFunnelError(null) + fetch("/api/courses/gateway-funnel") + .then(r => r.json()) + .then(d => { setFunnelData(d); setFunnelLoading(false) }) + .catch(e => { setFunnelError(e.message); setFunnelLoading(false) }) + }, []) + + // ── Fetch sequences ── + useEffect(() => { + setSeqLoading(true) + setSeqError(null) + fetch("/api/courses/sequences") + .then(r => r.json()) + .then(d => { setSeqData(d); setSeqLoading(false) }) + .catch(e => { setSeqError(e.message); setSeqLoading(false) }) + }, []) + + const courses = coursesData?.courses ?? [] + const total = coursesData?.total ?? 0 + const mathData = funnelData?.math ?? [] + const englishData = funnelData?.english ?? [] + + // ── Sort handlers ── + function handleCourseSort(col: "dfwi_rate" | "enrollments") { + if (col === sortBy) setSortDir(d => d === "asc" ? "desc" : "asc") + else { setSortBy(col); setSortDir("desc") } + } + + function handlePairSort(col: "co_enrollment_count" | "both_pass_rate") { + if (col === pairSortBy) setPairSortDir(d => d === "asc" ? "desc" : "asc") + else { setPairSortBy(col); setPairSortDir("desc") } + } + + const sortedPairs = useMemo(() => { + const raw = (seqData?.pairs ?? []).slice(0, 20) + return [...raw].sort((a, b) => { + const av = parseFloat(String(a[pairSortBy])) + const bv = parseFloat(String(b[pairSortBy])) + return pairSortDir === "desc" ? bv - av : av - bv + }) + }, [seqData, pairSortBy, pairSortDir]) + + // ── Explain pairing ── + function pairingKey(pair: CoursePair) { + return `${pair.prefix_a}-${pair.number_a}-${pair.prefix_b}-${pair.number_b}` + } + + async function explainPairing(pair: CoursePair) { + const key = pairingKey(pair) + // Toggle collapse if already loaded + if (explainMap[key] && !explainMap[key].loading) { + setExplainMap(prev => { + const next = { ...prev } + delete next[key] + return next + }) + return + } + setExplainMap(prev => ({ ...prev, [key]: { loading: true } })) + try { + const res = await fetch("/api/courses/explain-pairing", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + prefix_a: pair.prefix_a, + number_a: pair.number_a, + name_a: pair.name_a || "", + prefix_b: pair.prefix_b, + number_b: pair.number_b, + name_b: pair.name_b || "", + }), + }) + const data = await res.json() + if (!res.ok) throw new Error(data.error || "Failed to fetch explanation") + setExplainMap(prev => ({ + ...prev, + [key]: { loading: false, stats: data.stats, explanation: data.explanation }, + })) + } catch (e) { + setExplainMap(prev => ({ + ...prev, + [key]: { loading: false, error: e instanceof Error ? e.message : String(e) }, + })) + } + } + + // ─── Render ─────────────────────────────────────────────────────────────── + + return ( +
+
+ + {/* Header */} +
+ + + +
+

Course Analytics

+

+ DFWI rates, gateway funnels, and course co-enrollment patterns +

+
+
+ + {/* Tab bar */} +
+ + + +
+ + {/* ── Tab: DFWI Rates ── */} + {activeTab === "dfwi" && ( +
+ {/* Filter bar */} +
+
+ +
+ +
+
+
+ +

+ {coursesLoading ? "Loading…" : `${total.toLocaleString()} course${total !== 1 ? "s" : ""}`} +

+ + {coursesError && ( +
+ {coursesError} +
+ )} + +
+ + + + + + + {coursesLoading ? ( + Array.from({ length: 10 }).map((_, i) => ( + + {Array.from({ length: 7 }).map((__, j) => ( + + ))} + + )) + ) : courses.length === 0 ? ( + + + + ) : ( + courses.map((c, idx) => ( + + + + + + + + + + )) + )} + +
+ + + + + +

Percentage of enrolled students who received a D, F, W (Withdraw), or I (Incomplete) grade. Higher values indicate courses where students struggle most.

+ + } + /> + +

Percentage of enrolled students who received a passing grade (A through C-). A pass rate below 50% signals a course where more than half of students are not succeeding.

+ + } + /> +
+
+
+ No courses match the current filters. +
+ {c.course_prefix} {c.course_number} + {c.course_name ?? "—"}{c.enrollments.toLocaleString()}{c.dfwi_count.toLocaleString()}
+
+
+ )} + + {/* ── Tab: Gateway Funnel ── */} + {activeTab === "funnel" && ( +
+

+ Enrollment, pass, and DFWI counts for gateway courses, broken down by cohort. +

+ + {funnelError && ( +
+ {funnelError} +
+ )} + + {funnelLoading ? ( +
+ {[0, 1].map(i => ( +
+
+
+
+ ))} +
+ ) : ( +
+
+

Math Gateway

+ {mathData.length === 0 ? ( +

No data available.

+ ) : ( + + + + + + + + + + + + )} +
+ +
+

English Gateway

+ {englishData.length === 0 ? ( +

No data available.

+ ) : ( + + + + + + + + + + + + )} +
+
+ )} +
+ )} + + {/* ── Tab: Co-enrollment Insights ── */} + {activeTab === "sequences" && ( +
+

+ Course pairs most frequently taken in the same term. Click Explain on any row for an AI-powered analysis of why students struggle with the combination. +

+ + {seqError && ( +
+ {seqError} +
+ )} + +
+ + + + + + + {seqLoading ? ( + Array.from({ length: 10 }).map((_, i) => ( + + {Array.from({ length: 5 }).map((__, j) => ( + + ))} + + )) + ) : sortedPairs.length === 0 ? ( + + + + ) : ( + sortedPairs.map(pair => { + const key = pairingKey(pair) + const explainState = explainMap[key] + const isExpanded = !!explainState && !explainState.loading + + return ( + <> + + + + + + + + + {/* Expanded explain panel */} + {isExpanded && ( + + + + )} + + ) + }) + )} + +
+ + + + +
+
+
+ No course pairing data available. +
+ {pair.prefix_a} {pair.number_a} + {pair.name_a && ( + — {pair.name_a} + )} + + {pair.prefix_b} {pair.number_b} + {pair.name_b && ( + — {pair.name_b} + )} + {pair.co_enrollment_count.toLocaleString()} + +
+ {explainState.error ? ( +

{explainState.error}

+ ) : ( +
+ {/* Individual course stats */} +
+ {explainState.stats?.courseA && ( +
+

+ {pair.prefix_a} {pair.number_a} — Individual +

+
+ = 50 ? "text-red-600" : explainState.stats.courseA.dfwi_rate >= 30 ? "text-orange-600" : "text-green-600"} + /> + = 70 ? "text-green-600" : explainState.stats.courseA.pass_rate >= 50 ? "text-yellow-600" : "text-red-600"} + /> + +
+
+ )} + {explainState.stats?.courseB && ( +
+

+ {pair.prefix_b} {pair.number_b} — Individual +

+
+ = 50 ? "text-red-600" : explainState.stats.courseB.dfwi_rate >= 30 ? "text-orange-600" : "text-green-600"} + /> + = 70 ? "text-green-600" : explainState.stats.courseB.pass_rate >= 50 ? "text-yellow-600" : "text-red-600"} + /> + +
+
+ )} +
+ + {/* Delivery + instructor breakdown */} +
+ {explainState.stats?.byDelivery && explainState.stats.byDelivery.length > 0 && ( +
+

By Delivery Method

+
+ {explainState.stats.byDelivery.map(d => ( +
+ {d.delivery_method} + {d.co_count.toLocaleString()} students + + both pass +
+ ))} +
+
+ )} + {explainState.stats?.byInstructor && explainState.stats.byInstructor.length > 0 && ( +
+

By Instructor Type

+
+ {explainState.stats.byInstructor.map(d => ( +
+ {d.instructor_status} + {d.co_count.toLocaleString()} students + + both pass +
+ ))} +
+
+ )} +
+ + {/* LLM explanation */} + {explainState.explanation && ( +
+ +

{explainState.explanation}

+
+ )} +
+ )} +
+
+
+ )} + +
+
+ ) +} diff --git a/codebenders-dashboard/app/layout.tsx b/codebenders-dashboard/app/layout.tsx index 4692fb4..91075a1 100644 --- a/codebenders-dashboard/app/layout.tsx +++ b/codebenders-dashboard/app/layout.tsx @@ -1,34 +1,51 @@ -import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; -import "./globals.css"; +import type { Metadata } from "next" +import { Geist, Geist_Mono } from "next/font/google" +import "./globals.css" +import { NavHeader } from "@/components/nav-header" +import { createClient } from "@/lib/supabase/server" +import type { Role } from "@/lib/roles" const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"], -}); +}) const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"], -}); +}) export const metadata: Metadata = { title: "Bishop State Student Success Dashboard", description: "AI-Powered Student Success Analytics & Predictive Models for Bishop State Community College", -}; +} -export default function RootLayout({ +export default async function RootLayout({ children, }: Readonly<{ - children: React.ReactNode; + children: React.ReactNode }>) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + let role: Role | null = null + if (user) { + const { data } = await supabase + .from("user_roles") + .select("role") + .eq("user_id", user.id) + .single() + role = (data?.role ?? "leadership") as Role + } + return ( - + + {user && role && ( + + )} {children} - ); + ) } diff --git a/codebenders-dashboard/app/login/page.tsx b/codebenders-dashboard/app/login/page.tsx new file mode 100644 index 0000000..6854ff6 --- /dev/null +++ b/codebenders-dashboard/app/login/page.tsx @@ -0,0 +1,101 @@ +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { createClient } from "@/lib/supabase/client" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { GraduationCap } from "lucide-react" + +export default function LoginPage() { + const router = useRouter() + const [email, setEmail] = useState("") + const [password, setPassword] = useState("") + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setLoading(true) + setError(null) + + const supabase = createClient() + const { error } = await supabase.auth.signInWithPassword({ email, password }) + + if (error) { + setError(error.message) + setLoading(false) + return + } + + router.refresh() + router.push("/") + } + + return ( +
+
+ + {/* Branding */} +
+
+ + Bishop State +
+

+ Student Success Analytics +

+
+ + {/* Login form */} +
+

Sign in

+ + {error && ( +
+ {error} +
+ )} + +
+ + setEmail(e.target.value)} + required + /> +
+ +
+ + setPassword(e.target.value)} + required + /> +
+ + +
+ +

+ Contact your administrator if you need access. +

+
+
+ ) +} diff --git a/codebenders-dashboard/app/page.tsx b/codebenders-dashboard/app/page.tsx index 6ec4fc5..9fd84a5 100644 --- a/codebenders-dashboard/app/page.tsx +++ b/codebenders-dashboard/app/page.tsx @@ -7,7 +7,14 @@ import { RetentionRiskChart } from "@/components/retention-risk-chart" import { ReadinessAssessmentChart } from "@/components/readiness-assessment-chart" import { ExportButton } from "@/components/export-button" import { Button } from "@/components/ui/button" -import { TrendingUp, Users, AlertTriangle, BookOpen, Search } from "lucide-react" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { TrendingUp, Users, AlertTriangle, BookOpen, Search, Table2, X } from "lucide-react" import Link from "next/link" interface KPIData { @@ -47,6 +54,10 @@ interface ReadinessData { cohort_breakdown: any[] } +const COHORTS = ["2019-20", "2020-21", "2021-22", "2022-23", "2023-24"] +const ENROLLMENT_TYPES = ["Full-Time", "Part-Time"] +const CREDENTIAL_TYPES = ["Certificate", "Associate", "Bachelor"] + export default function DashboardPage() { const [kpis, setKpis] = useState(null) const [riskAlerts, setRiskAlerts] = useState([]) @@ -57,17 +68,34 @@ export default function DashboardPage() { const [error, setError] = useState(null) const [readinessError, setReadinessError] = useState(null) + // Filter state + const [cohort, setCohort] = useState("") + const [enrollmentType, setEnrollmentType] = useState("") + const [credentialType, setCredentialType] = useState("") + + const hasFilters = !!(cohort || enrollmentType || credentialType) + + function buildFilterParams() { + const p = new URLSearchParams() + if (cohort) p.set("cohort", cohort) + if (enrollmentType) p.set("enrollmentType", enrollmentType) + if (credentialType) p.set("credentialType", credentialType) + const qs = p.toString() + return qs ? `?${qs}` : "" + } + useEffect(() => { + const qs = buildFilterParams() + const fetchDashboardData = async () => { try { setLoading(true) setError(null) - // Fetch all data in parallel const [kpisRes, riskAlertsRes, retentionRiskRes] = await Promise.all([ - fetch("/api/dashboard/kpis"), - fetch("/api/dashboard/risk-alerts"), - fetch("/api/dashboard/retention-risk"), + fetch(`/api/dashboard/kpis${qs}`), + fetch(`/api/dashboard/risk-alerts${qs}`), + fetch(`/api/dashboard/retention-risk${qs}`), ]) if (!kpisRes.ok || !riskAlertsRes.ok || !retentionRiskRes.ok) { @@ -96,14 +124,14 @@ export default function DashboardPage() { setReadinessLoading(true) setReadinessError(null) - const response = await fetch("/api/dashboard/readiness") - + const response = await fetch(`/api/dashboard/readiness${qs}`) + if (!response.ok) { throw new Error("Failed to fetch readiness assessment data") } const result = await response.json() - + if (result.success) { setReadinessData(result.data ?? null) } else { @@ -119,7 +147,8 @@ export default function DashboardPage() { fetchDashboardData() fetchReadinessData() - }, []) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cohort, enrollmentType, credentialType]) return (
@@ -135,11 +164,11 @@ export default function DashboardPage() {

- @@ -149,6 +178,12 @@ export default function DashboardPage() { Methodology + + +
+ {/* Filter Bar */} +
+ Filter by: + + + + + + + + {hasFilters && ( + + )} + + {hasFilters && ( + + Showing filtered results + {kpis ? ` · ${kpis.totalStudents.toLocaleString()} students` : ""} + + )} +
+ {/* Error State */} {error && (
@@ -250,8 +346,8 @@ export default function DashboardPage() { {/* Charts */}
- @@ -275,8 +371,8 @@ export default function DashboardPage() { } /> - @@ -305,7 +401,7 @@ export default function DashboardPage() { AI-powered analysis identifying student preparation levels and intervention needs

- (null) const [queryResult, setQueryResult] = useState(null) const [useDirectDB, setUseDirectDB] = useState(true) + const [sidebarOpen, setSidebarOpen] = useState(false) + const [summary, setSummary] = useState(null) + const [summaryLoading, setSummaryLoading] = useState(false) + const [summaryError, setSummaryError] = useState(null) const [history, setHistory] = useState(() => { // Read from localStorage on mount (client-only) if (typeof window === "undefined") return [] @@ -44,6 +45,9 @@ export default function QueryPage() { overridePrompt?: string, overrideInstitution?: string, ) => { + setSummary(null) + setSummaryError(null) + const activePrompt = overridePrompt ?? prompt const activeInstitution = overrideInstitution ?? institution @@ -125,100 +129,205 @@ export default function QueryPage() { localStorage.removeItem("bishop_query_history") } + const handleSummarize = async () => { + if (!queryResult || !queryPlan) return + setSummaryLoading(true) + setSummaryError(null) + try { + const res = await fetch("/api/query-summary", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + prompt, + data: queryResult.data, + rowCount: queryResult.rowCount, + vizType: queryPlan.vizType, + }), + }) + const json = await res.json() + if (!res.ok) throw new Error(json.error || "Failed") + setSummary(json.summary) + } catch (e) { + setSummaryError(e instanceof Error ? e.message : String(e)) + } finally { + setSummaryLoading(false) + } + } + return ( -
-
-
- - - Back to Dashboard - -

SQL Query Interface

-

Analyze student performance data with natural language queries

-
- - - - Query Controls - Select an institution and enter your analysis prompt - - -
+
+ {/* Slim page-level header bar */} +
+ {/* Mobile sidebar toggle */} + + + {/* Title group */} + + Query Interface + +
+ + {/* Body: sidebar + main */} +
+ {/* Desktop sidebar */} + + + {/* Mobile sidebar overlay */} + {sidebarOpen && ( +
+
setSidebarOpen(false)} + /> + +
+ )} + + {/* Main content */} +
+ {/* Query controls */} +
+ {/* DB mode toggle row */} +
-
-
-
- - -
+ {/* Institution selector */} +
+ + +
-
- - setPrompt(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault() - handleAnalyze() - } - }} - /> -
+ {/* Query textarea */} +
+ +