Skip to content

Commit b9cbe63

Browse files
authored
feat: SIS deep-link from student detail view (#78) (#95)
feat: SIS deep-link from student detail view (#78) Closes #78
1 parent 996f3d7 commit b9cbe63

File tree

9 files changed

+6419
-1
lines changed

9 files changed

+6419
-1
lines changed
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { type NextRequest, NextResponse } from "next/server"
2+
import { mkdir, appendFile } from "fs/promises"
3+
import path from "path"
4+
import { getPool } from "@/lib/db"
5+
import { canAccess, type Role } from "@/lib/roles"
6+
7+
const LOGS_DIR = path.join(process.cwd(), "logs")
8+
const LOG_FILE = path.join(LOGS_DIR, "query-history.jsonl")
9+
const SIS_ID_PARAM = process.env.SIS_ID_PARAM || "id"
10+
11+
let logDirReady = false
12+
13+
function writeAuditLog(entry: Record<string, unknown>) {
14+
const doWrite = async () => {
15+
if (!logDirReady) {
16+
await mkdir(LOGS_DIR, { recursive: true })
17+
logDirReady = true
18+
}
19+
await appendFile(LOG_FILE, JSON.stringify(entry) + "\n", "utf8")
20+
}
21+
doWrite().catch(err => console.error("SIS audit log write failed:", err))
22+
}
23+
24+
export async function GET(
25+
request: NextRequest,
26+
{ params }: { params: Promise<{ guid: string }> }
27+
) {
28+
const sisBaseUrl = process.env.SIS_BASE_URL
29+
if (!sisBaseUrl) {
30+
return NextResponse.json({ url: null }, { status: 404 })
31+
}
32+
33+
const role = request.headers.get("x-user-role") as Role | null
34+
if (!role || !canAccess("/api/students", role)) {
35+
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
36+
}
37+
38+
const { guid } = await params
39+
40+
let url: string
41+
42+
try {
43+
const pool = getPool()
44+
const result = await pool.query(
45+
"SELECT sis_id FROM guid_sis_map WHERE student_guid = $1 LIMIT 1",
46+
[guid]
47+
)
48+
49+
if (result.rows.length === 0) {
50+
return NextResponse.json({ url: null }, { status: 404 })
51+
}
52+
53+
// SIS ID is embedded in the URL but never returned as a standalone field
54+
const sisId = result.rows[0].sis_id
55+
const urlObj = new URL(sisBaseUrl)
56+
urlObj.searchParams.set(SIS_ID_PARAM, sisId)
57+
url = urlObj.toString()
58+
} catch (error) {
59+
console.error("SIS link lookup error:", error)
60+
return NextResponse.json(
61+
{ error: "Failed to look up SIS link" },
62+
{ status: 500 }
63+
)
64+
}
65+
66+
writeAuditLog({ event: "sis_link_accessed", guid, role, timestamp: new Date().toISOString() })
67+
68+
return NextResponse.json({ url })
69+
}

codebenders-dashboard/app/students/[guid]/page.tsx

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

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

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

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

107+
useEffect(() => {
108+
if (!guid) return
109+
const controller = new AbortController()
110+
fetch(`/api/students/${encodeURIComponent(guid)}/sis-link`, { signal: controller.signal })
111+
.then(r => {
112+
if (r.status === 403) {
113+
setSisStatus("hidden")
114+
return null
115+
}
116+
if (r.status === 404) {
117+
setSisStatus("unavailable")
118+
return null
119+
}
120+
if (!r.ok) {
121+
setSisStatus("hidden")
122+
return null
123+
}
124+
return r.json()
125+
})
126+
.then(data => {
127+
if (data?.url) {
128+
setSisLink(data.url)
129+
setSisStatus("available")
130+
} else if (data !== null) {
131+
setSisStatus("unavailable")
132+
}
133+
})
134+
.catch(err => {
135+
if (err.name !== "AbortError") setSisStatus("hidden")
136+
})
137+
return () => controller.abort()
138+
}, [guid])
139+
105140
// ─── Loading skeleton ────────────────────────────────────────────────────
106141

107142
if (loading) {
@@ -179,6 +214,22 @@ export default function StudentDetailPage() {
179214
</div>
180215
</div>
181216
<div className="flex items-center gap-2">
217+
{sisStatus === "loading" && (
218+
<div className="h-7 w-24 rounded bg-muted animate-pulse" />
219+
)}
220+
{(sisStatus === "available" || sisStatus === "unavailable") && (
221+
<Button
222+
variant="outline"
223+
size="sm"
224+
className="gap-1.5"
225+
disabled={sisStatus === "unavailable"}
226+
title={sisStatus === "unavailable" ? "No SIS record linked for this student" : undefined}
227+
onClick={sisLink ? () => window.open(sisLink, "_blank", "noopener,noreferrer") : undefined}
228+
>
229+
<ExternalLink className="h-3.5 w-3.5" />
230+
Open in SIS
231+
</Button>
232+
)}
182233
{student.at_risk_alert && (
183234
<Badge
184235
label={student.at_risk_alert}
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
# Design: Self-Service Data Upload (Issue #86)
2+
3+
**Date:** 2026-02-24
4+
**Author:** Claude Code
5+
6+
---
7+
8+
## Overview
9+
10+
Allow admin and IR users to upload institutional data files directly from the dashboard without
11+
needing direct database or server access. Two upload paths: course enrollment CSVs (end-to-end
12+
to Postgres) and PDP cohort/AR files (to Supabase Storage + GitHub Actions ML pipeline trigger).
13+
14+
---
15+
16+
## Scope
17+
18+
**In scope:**
19+
- Course enrollment CSV → `course_enrollments` Postgres table (upsert)
20+
- PDP Cohort CSV / PDP AR (.xlsx) → Supabase Storage + GitHub Actions `repository_dispatch`
21+
- Preview step (first 10 rows + column validation) before commit
22+
- Role guard: admin and ir only
23+
24+
**Out of scope:**
25+
- Upload history log (future issue)
26+
- Column remapping UI (columns must match known schema)
27+
- ML experiment tracking / MLflow (future issue)
28+
- Auto-triggering ML pipeline without a server (GitHub Actions is the trigger mechanism)
29+
30+
---
31+
32+
## Pages & Routing
33+
34+
**New page:** `codebenders-dashboard/app/admin/upload/page.tsx`
35+
36+
**Role guard:** Add to `lib/roles.ts` `ROUTE_PERMISSIONS`:
37+
```ts
38+
{ prefix: "/admin", roles: ["admin", "ir"] },
39+
{ prefix: "/api/admin", roles: ["admin", "ir"] },
40+
```
41+
Middleware already enforces this pattern via `x-user-role` header — no other auth code needed.
42+
43+
**Nav link:** Add "Upload Data" to `nav-header.tsx`, visible only to admin/ir roles.
44+
45+
**New API routes:**
46+
- `POST /api/admin/upload/preview` — parse first 10 rows, return sample + validation summary
47+
- `POST /api/admin/upload/commit` — full ingest (course → Postgres; PDP/AR → Storage + Actions)
48+
49+
---
50+
51+
## UI Flow (3 States)
52+
53+
### State 1 — Select & Drop
54+
- Dropdown: file type (`Course Enrollment CSV` | `PDP Cohort CSV` | `PDP AR File (.xlsx)`)
55+
- Drag-and-drop zone (click to pick; `.csv` for course/cohort, `.csv`+`.xlsx` for AR)
56+
- "Preview" button → calls `/api/admin/upload/preview`
57+
58+
### State 2 — Preview
59+
- Shows: detected file type, estimated row count, first 10 rows in a table
60+
- Validation banner: lists missing required columns or warnings
61+
- "Confirm & Upload" → calls `/api/admin/upload/commit`
62+
- "Back" link to return to State 1
63+
64+
### State 3 — Result
65+
- Course enrollments: `{ inserted, skipped, errors[] }` summary card
66+
- PDP/AR: "File accepted — ML pipeline queued in GitHub Actions" + link to Actions run
67+
- "Upload another file" resets to State 1
68+
69+
---
70+
71+
## API Routes
72+
73+
### `POST /api/admin/upload/preview`
74+
75+
**Input:** `multipart/form-data` with `file` and `fileType` fields
76+
77+
**Logic:**
78+
1. Parse first 50 rows with `csv-parse` (CSV) or `xlsx` (Excel)
79+
2. Validate required columns exist for the given `fileType`
80+
3. Return `{ columns, sampleRows (first 10), rowCount (estimated), warnings[] }`
81+
82+
### `POST /api/admin/upload/commit`
83+
84+
**Input:** Same multipart form
85+
86+
**Course enrollment path:**
87+
1. Stream-parse full CSV with `csv-parse` async iterator
88+
2. Batch-upsert 500 rows at a time into `course_enrollments` via `pg`
89+
3. Conflict target: `(student_guid, course_prefix, course_number, academic_term)`
90+
4. Return `{ inserted, skipped, errors[] }`
91+
92+
**PDP/AR path:**
93+
1. Upload file to Supabase Storage bucket `pdp-uploads` via `@supabase/supabase-js`
94+
2. Call GitHub API `POST /repos/{owner}/{repo}/dispatches` with:
95+
```json
96+
{ "event_type": "ml-pipeline", "client_payload": { "file_path": "<storage-path>" } }
97+
```
98+
3. Return `{ status: "processing", actionsUrl: "https://github.com/{owner}/{repo}/actions" }`
99+
100+
**Role enforcement:** Read `x-user-role` header (set by middleware); return 403 if not admin/ir.
101+
102+
---
103+
104+
## GitHub Actions Workflow
105+
106+
**File:** `.github/workflows/ml-pipeline.yml`
107+
108+
**Trigger:** `repository_dispatch` with `event_type: ml-pipeline`
109+
110+
**Steps:**
111+
1. Checkout repo
112+
2. Set up Python with `venv`
113+
3. Install dependencies (`pip install -r requirements.txt`)
114+
4. Download uploaded file from Supabase Storage using `SUPABASE_SERVICE_KEY` secret
115+
5. Run `venv/bin/python ai_model/complete_ml_pipeline.py --input <downloaded-file-path>`
116+
6. Upload `ML_PIPELINE_REPORT.txt` as a GitHub Actions artifact (retained 90 days)
117+
118+
**Required secrets:** `SUPABASE_URL`, `SUPABASE_SERVICE_KEY`, `GITHUB_TOKEN` (auto-provided)
119+
120+
---
121+
122+
## Required Column Schemas
123+
124+
### Course Enrollment CSV
125+
Must include: `student_guid`, `course_prefix`, `course_number`, `academic_year`, `academic_term`
126+
Optional (all other `course_enrollments` columns): filled as NULL if absent
127+
128+
### PDP Cohort CSV
129+
Must include: `Institution_ID`, `Cohort`, `Student_GUID`, `Cohort_Term`
130+
131+
### PDP AR File (.xlsx)
132+
Must include: `Institution_ID`, `Cohort`, `Student_GUID` (first sheet parsed)
133+
134+
---
135+
136+
## New Packages
137+
138+
| Package | Purpose |
139+
|---------|---------|
140+
| `csv-parse` | Streaming CSV parsing (async iterator mode) |
141+
| `xlsx` | Excel (.xlsx) parsing |
142+
143+
---
144+
145+
## New Files
146+
147+
| File | Purpose |
148+
|------|---------|
149+
| `codebenders-dashboard/app/admin/upload/page.tsx` | Upload UI page |
150+
| `codebenders-dashboard/app/api/admin/upload/preview/route.ts` | Preview API route |
151+
| `codebenders-dashboard/app/api/admin/upload/commit/route.ts` | Commit API route |
152+
| `.github/workflows/ml-pipeline.yml` | GitHub Actions ML pipeline trigger |
153+
154+
---
155+
156+
## Supabase Changes
157+
158+
**Storage bucket:** Create `pdp-uploads` bucket (private, authenticated access only).
159+
No new database migrations required — `course_enrollments` table already exists.
160+
161+
**Bucket policy:** Only service role key can read/write. Signed URLs used for pipeline download.
162+
163+
---
164+
165+
## Constraints & Known Limitations
166+
167+
- ML pipeline trigger via GitHub Actions means a ~30-60s delay before the pipeline starts
168+
- Vercel free tier has a 4.5 MB request body limit — large files should use Supabase Storage direct upload in a future iteration
169+
- No upload history log in this version (deferred)
170+
- Column remapping is out of scope — files must match the known schema

0 commit comments

Comments
 (0)