Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions codebenders-dashboard/app/api/students/[guid]/sis-link/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { type NextRequest, NextResponse } from "next/server"
import { mkdir, appendFile } from "fs/promises"
import path from "path"
import { getPool } from "@/lib/db"
import { canAccess, type Role } from "@/lib/roles"

const LOGS_DIR = path.join(process.cwd(), "logs")
const LOG_FILE = path.join(LOGS_DIR, "query-history.jsonl")
const SIS_ID_PARAM = process.env.SIS_ID_PARAM || "id"

let logDirReady = false

function writeAuditLog(entry: Record<string, unknown>) {
const doWrite = async () => {
if (!logDirReady) {
await mkdir(LOGS_DIR, { recursive: true })
logDirReady = true
}
await appendFile(LOG_FILE, JSON.stringify(entry) + "\n", "utf8")
}
doWrite().catch(err => console.error("SIS audit log write failed:", err))
}

export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ guid: string }> }
) {
const sisBaseUrl = process.env.SIS_BASE_URL
if (!sisBaseUrl) {
return NextResponse.json({ url: null }, { status: 404 })
}

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

let url: string

try {
const pool = getPool()
const result = await pool.query(
"SELECT sis_id FROM guid_sis_map WHERE student_guid = $1 LIMIT 1",
[guid]
)

if (result.rows.length === 0) {
return NextResponse.json({ url: null }, { status: 404 })
}

// SIS ID is embedded in the URL but never returned as a standalone field
const sisId = result.rows[0].sis_id
const urlObj = new URL(sisBaseUrl)
urlObj.searchParams.set(SIS_ID_PARAM, sisId)
url = urlObj.toString()
} catch (error) {
console.error("SIS link lookup error:", error)
return NextResponse.json(
{ error: "Failed to look up SIS link" },
{ status: 500 }
)
}

writeAuditLog({ event: "sis_link_accessed", guid, role, timestamp: new Date().toISOString() })

return NextResponse.json({ url })
}
53 changes: 52 additions & 1 deletion codebenders-dashboard/app/students/[guid]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { useEffect, useState } from "react"
import { useParams, useRouter } from "next/navigation"
import { ArrowLeft, ShieldCheck } from "lucide-react"
import { ArrowLeft, ExternalLink, ShieldCheck } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"

Expand Down Expand Up @@ -88,6 +88,8 @@ export default function StudentDetailPage() {
const [student, setStudent] = useState<StudentDetail | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [sisLink, setSisLink] = useState<string | null>(null)
const [sisStatus, setSisStatus] = useState<"loading" | "available" | "unavailable" | "hidden">("loading")

useEffect(() => {
if (!guid) return
Expand All @@ -102,6 +104,39 @@ export default function StudentDetailPage() {
.catch(e => { setError(e.message); setLoading(false) })
}, [guid])

useEffect(() => {
if (!guid) return
const controller = new AbortController()
fetch(`/api/students/${encodeURIComponent(guid)}/sis-link`, { signal: controller.signal })
.then(r => {
if (r.status === 403) {
setSisStatus("hidden")
return null
}
if (r.status === 404) {
setSisStatus("unavailable")
return null
}
if (!r.ok) {
setSisStatus("hidden")
return null
}
return r.json()
})
.then(data => {
if (data?.url) {
setSisLink(data.url)
setSisStatus("available")
} else if (data !== null) {
setSisStatus("unavailable")
}
})
.catch(err => {
if (err.name !== "AbortError") setSisStatus("hidden")
})
return () => controller.abort()
}, [guid])

// ─── Loading skeleton ────────────────────────────────────────────────────

if (loading) {
Expand Down Expand Up @@ -179,6 +214,22 @@ export default function StudentDetailPage() {
</div>
</div>
<div className="flex items-center gap-2">
{sisStatus === "loading" && (
<div className="h-7 w-24 rounded bg-muted animate-pulse" />
)}
{(sisStatus === "available" || sisStatus === "unavailable") && (
<Button
variant="outline"
size="sm"
className="gap-1.5"
disabled={sisStatus === "unavailable"}
title={sisStatus === "unavailable" ? "No SIS record linked for this student" : undefined}
onClick={sisLink ? () => window.open(sisLink, "_blank", "noopener,noreferrer") : undefined}
>
<ExternalLink className="h-3.5 w-3.5" />
Open in SIS
</Button>
)}
{student.at_risk_alert && (
<Badge
label={student.at_risk_alert}
Expand Down
170 changes: 170 additions & 0 deletions docs/plans/2026-02-24-self-service-upload-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
# Design: Self-Service Data Upload (Issue #86)

**Date:** 2026-02-24
**Author:** Claude Code

---

## Overview

Allow admin and IR users to upload institutional data files directly from the dashboard without
needing direct database or server access. Two upload paths: course enrollment CSVs (end-to-end
to Postgres) and PDP cohort/AR files (to Supabase Storage + GitHub Actions ML pipeline trigger).

---

## Scope

**In scope:**
- Course enrollment CSV → `course_enrollments` Postgres table (upsert)
- PDP Cohort CSV / PDP AR (.xlsx) → Supabase Storage + GitHub Actions `repository_dispatch`
- Preview step (first 10 rows + column validation) before commit
- Role guard: admin and ir only

**Out of scope:**
- Upload history log (future issue)
- Column remapping UI (columns must match known schema)
- ML experiment tracking / MLflow (future issue)
- Auto-triggering ML pipeline without a server (GitHub Actions is the trigger mechanism)

---

## Pages & Routing

**New page:** `codebenders-dashboard/app/admin/upload/page.tsx`

**Role guard:** Add to `lib/roles.ts` `ROUTE_PERMISSIONS`:
```ts
{ prefix: "/admin", roles: ["admin", "ir"] },
{ prefix: "/api/admin", roles: ["admin", "ir"] },
```
Middleware already enforces this pattern via `x-user-role` header — no other auth code needed.

**Nav link:** Add "Upload Data" to `nav-header.tsx`, visible only to admin/ir roles.

**New API routes:**
- `POST /api/admin/upload/preview` — parse first 10 rows, return sample + validation summary
- `POST /api/admin/upload/commit` — full ingest (course → Postgres; PDP/AR → Storage + Actions)

---

## UI Flow (3 States)

### State 1 — Select & Drop
- Dropdown: file type (`Course Enrollment CSV` | `PDP Cohort CSV` | `PDP AR File (.xlsx)`)
- Drag-and-drop zone (click to pick; `.csv` for course/cohort, `.csv`+`.xlsx` for AR)
- "Preview" button → calls `/api/admin/upload/preview`

### State 2 — Preview
- Shows: detected file type, estimated row count, first 10 rows in a table
- Validation banner: lists missing required columns or warnings
- "Confirm & Upload" → calls `/api/admin/upload/commit`
- "Back" link to return to State 1

### State 3 — Result
- Course enrollments: `{ inserted, skipped, errors[] }` summary card
- PDP/AR: "File accepted — ML pipeline queued in GitHub Actions" + link to Actions run
- "Upload another file" resets to State 1

---

## API Routes

### `POST /api/admin/upload/preview`

**Input:** `multipart/form-data` with `file` and `fileType` fields

**Logic:**
1. Parse first 50 rows with `csv-parse` (CSV) or `xlsx` (Excel)
2. Validate required columns exist for the given `fileType`
3. Return `{ columns, sampleRows (first 10), rowCount (estimated), warnings[] }`

### `POST /api/admin/upload/commit`

**Input:** Same multipart form

**Course enrollment path:**
1. Stream-parse full CSV with `csv-parse` async iterator
2. Batch-upsert 500 rows at a time into `course_enrollments` via `pg`
3. Conflict target: `(student_guid, course_prefix, course_number, academic_term)`
4. Return `{ inserted, skipped, errors[] }`

**PDP/AR path:**
1. Upload file to Supabase Storage bucket `pdp-uploads` via `@supabase/supabase-js`
2. Call GitHub API `POST /repos/{owner}/{repo}/dispatches` with:
```json
{ "event_type": "ml-pipeline", "client_payload": { "file_path": "<storage-path>" } }
```
3. Return `{ status: "processing", actionsUrl: "https://github.com/{owner}/{repo}/actions" }`

**Role enforcement:** Read `x-user-role` header (set by middleware); return 403 if not admin/ir.

---

## GitHub Actions Workflow

**File:** `.github/workflows/ml-pipeline.yml`

**Trigger:** `repository_dispatch` with `event_type: ml-pipeline`

**Steps:**
1. Checkout repo
2. Set up Python with `venv`
3. Install dependencies (`pip install -r requirements.txt`)
4. Download uploaded file from Supabase Storage using `SUPABASE_SERVICE_KEY` secret
5. Run `venv/bin/python ai_model/complete_ml_pipeline.py --input <downloaded-file-path>`
6. Upload `ML_PIPELINE_REPORT.txt` as a GitHub Actions artifact (retained 90 days)

**Required secrets:** `SUPABASE_URL`, `SUPABASE_SERVICE_KEY`, `GITHUB_TOKEN` (auto-provided)

---

## Required Column Schemas

### Course Enrollment CSV
Must include: `student_guid`, `course_prefix`, `course_number`, `academic_year`, `academic_term`
Optional (all other `course_enrollments` columns): filled as NULL if absent

### PDP Cohort CSV
Must include: `Institution_ID`, `Cohort`, `Student_GUID`, `Cohort_Term`

### PDP AR File (.xlsx)
Must include: `Institution_ID`, `Cohort`, `Student_GUID` (first sheet parsed)

---

## New Packages

| Package | Purpose |
|---------|---------|
| `csv-parse` | Streaming CSV parsing (async iterator mode) |
| `xlsx` | Excel (.xlsx) parsing |

---

## New Files

| File | Purpose |
|------|---------|
| `codebenders-dashboard/app/admin/upload/page.tsx` | Upload UI page |
| `codebenders-dashboard/app/api/admin/upload/preview/route.ts` | Preview API route |
| `codebenders-dashboard/app/api/admin/upload/commit/route.ts` | Commit API route |
| `.github/workflows/ml-pipeline.yml` | GitHub Actions ML pipeline trigger |

---

## Supabase Changes

**Storage bucket:** Create `pdp-uploads` bucket (private, authenticated access only).
No new database migrations required — `course_enrollments` table already exists.

**Bucket policy:** Only service role key can read/write. Signed URLs used for pipeline download.

---

## Constraints & Known Limitations

- ML pipeline trigger via GitHub Actions means a ~30-60s delay before the pipeline starts
- Vercel free tier has a 4.5 MB request body limit — large files should use Supabase Storage direct upload in a future iteration
- No upload history log in this version (deferred)
- Column remapping is out of scope — files must match the known schema
Loading
Loading