Skip to content

Commit 65c5d21

Browse files
authored
feat: course sequencing insights and DFWI analysis (#85) (#87)
* feat: add course_enrollments migration and data ingestion script (#85) * fix: transaction safety and validation in course enrollment ingestion (#85) * feat: add course DFWI, gateway funnel, and sequence API routes (#85) * fix: sequence join granularity, gateway funnel clarity, DFWI result cap (#85) * feat: add /courses page with DFWI table, gateway funnel, and co-enrollment pairs (#85) * fix: percentage display and component cleanup in /courses page (#85) * fix: gateway type label values (M/E) and add RBAC to gateway-funnel route (#85) * feat: sortable column headers, info popovers for DFWI/pass rate, pairings table sort (#85) * feat: tabbed courses page with AI-powered co-enrollment explainability - Redesign /courses page with 3 tabs: DFWI Rates, Gateway Funnel, Co-enrollment Insights - Add POST /api/courses/explain-pairing route: queries per-pair stats (individual DFWI/pass rates, breakdown by delivery method and instructor type) then calls gpt-4o-mini to generate an advisor-friendly narrative - Co-enrollment Insights tab shows sortable pairings table with per-row Explain button that fetches and renders stats chips + LLM analysis inline - Tab state is client-side (no Radix Tabs dependency needed) * ci: trigger re-run after workflow fix * fix: update ESLint for Next.js 16 (next lint removed) - Replace next lint with direct eslint . in package.json lint script - Rewrite eslint.config.mjs to use eslint-config-next flat config exports directly instead of deprecated FlatCompat bridge - Add eslint and eslint-config-next as devDependencies - Suppress pre-existing rule violations (no-explicit-any, no-unescaped-entities, set-state-in-effect) to avoid CI failures on legacy code
1 parent 9247784 commit 65c5d21

File tree

11 files changed

+1500
-12
lines changed

11 files changed

+1500
-12
lines changed
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { type NextRequest, NextResponse } from "next/server"
2+
import { getPool } from "@/lib/db"
3+
import { canAccess, type Role } from "@/lib/roles"
4+
5+
export async function GET(request: NextRequest) {
6+
const role = request.headers.get("x-user-role") as Role | null
7+
if (!role || !canAccess("/api/courses/dfwi", role)) {
8+
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
9+
}
10+
11+
const { searchParams } = new URL(request.url)
12+
13+
const gatewayOnly = searchParams.get("gatewayOnly") === "true"
14+
const minEnrollments = Math.max(1, Number(searchParams.get("minEnrollments") || 10))
15+
const cohort = searchParams.get("cohort") || ""
16+
const term = searchParams.get("term") || ""
17+
const sortBy = searchParams.get("sortBy") || "dfwi_rate"
18+
const sortDir = searchParams.get("sortDir") === "asc" ? "ASC" : "DESC"
19+
20+
// Whitelist sort columns to prevent injection
21+
const SORT_COLS: Record<string, string> = {
22+
dfwi_rate: "dfwi_rate",
23+
enrollments: "enrollments",
24+
}
25+
const orderExpr = SORT_COLS[sortBy] ?? "dfwi_rate"
26+
27+
const conditions: string[] = []
28+
const params: unknown[] = []
29+
30+
if (gatewayOnly) {
31+
conditions.push("gateway_type IN ('M', 'E')")
32+
}
33+
34+
if (cohort) {
35+
params.push(cohort)
36+
conditions.push(`cohort = $${params.length}`)
37+
}
38+
39+
if (term) {
40+
params.push(term)
41+
conditions.push(`academic_term = $${params.length}`)
42+
}
43+
44+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""
45+
46+
// minEnrollments goes into HAVING — bind as a param
47+
params.push(minEnrollments)
48+
const minEnrollmentsParam = `$${params.length}`
49+
50+
const sql = `
51+
SELECT
52+
course_prefix,
53+
course_number,
54+
MAX(course_name) AS course_name,
55+
MAX(gateway_type) AS gateway_type,
56+
COUNT(*) AS enrollments,
57+
COUNT(*) FILTER (WHERE grade IN ('D', 'F', 'W', 'I')) AS dfwi_count,
58+
ROUND(
59+
COUNT(*) FILTER (WHERE grade IN ('D', 'F', 'W', 'I')) * 100.0 / NULLIF(COUNT(*), 0),
60+
1
61+
) AS dfwi_rate,
62+
ROUND(
63+
COUNT(*) FILTER (
64+
WHERE grade NOT IN ('D', 'F', 'W', 'I')
65+
AND grade IS NOT NULL
66+
AND grade != ''
67+
) * 100.0 / NULLIF(COUNT(*), 0),
68+
1
69+
) AS pass_rate
70+
FROM course_enrollments
71+
${where}
72+
GROUP BY course_prefix, course_number
73+
HAVING COUNT(*) >= ${minEnrollmentsParam}
74+
ORDER BY ${orderExpr} ${sortDir}
75+
LIMIT 200 -- capped at 200 rows; add pagination if needed
76+
`
77+
78+
try {
79+
const pool = getPool()
80+
const result = await pool.query(sql, params)
81+
82+
return NextResponse.json({
83+
courses: result.rows,
84+
total: result.rows.length,
85+
})
86+
} catch (error) {
87+
console.error("DFWI fetch error:", error)
88+
return NextResponse.json(
89+
{ error: "Failed to fetch DFWI data", details: error instanceof Error ? error.message : String(error) },
90+
{ status: 500 }
91+
)
92+
}
93+
}
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import { type NextRequest, NextResponse } from "next/server"
2+
import { getPool } from "@/lib/db"
3+
import { canAccess, type Role } from "@/lib/roles"
4+
import { generateText } from "ai"
5+
import { createOpenAI } from "@ai-sdk/openai"
6+
7+
const openai = createOpenAI({ apiKey: process.env.OPENAI_API_KEY || "" })
8+
9+
const DELIVERY_LABELS: Record<string, string> = {
10+
F: "Face-to-Face",
11+
O: "Online",
12+
H: "Hybrid",
13+
}
14+
15+
export async function POST(request: NextRequest) {
16+
const role = request.headers.get("x-user-role") as Role | null
17+
if (!role || !canAccess("/api/courses/explain-pairing", role)) {
18+
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
19+
}
20+
21+
if (!process.env.OPENAI_API_KEY) {
22+
return NextResponse.json({ error: "OpenAI API key not configured" }, { status: 500 })
23+
}
24+
25+
const body = await request.json()
26+
const { prefix_a, number_a, name_a, prefix_b, number_b, name_b } = body
27+
28+
if (!prefix_a || !number_a || !prefix_b || !number_b) {
29+
return NextResponse.json({ error: "Missing course identifiers" }, { status: 400 })
30+
}
31+
32+
const pool = getPool()
33+
34+
try {
35+
// Query 1: Individual DFWI + pass rates for each course
36+
const [indivRes, deliveryRes, instrRes] = await Promise.all([
37+
pool.query(
38+
`SELECT
39+
course_prefix,
40+
course_number,
41+
COUNT(*) AS enrollments,
42+
ROUND(
43+
COUNT(*) FILTER (WHERE grade IN ('D','F','W','I') AND grade IS NOT NULL AND grade <> '')
44+
* 100.0 / NULLIF(COUNT(*), 0), 1
45+
) AS dfwi_rate,
46+
ROUND(
47+
COUNT(*) FILTER (WHERE grade NOT IN ('D','F','W','I') AND grade IS NOT NULL AND grade <> '')
48+
* 100.0 / NULLIF(COUNT(*), 0), 1
49+
) AS pass_rate
50+
FROM course_enrollments
51+
WHERE (course_prefix = $1 AND course_number = $2)
52+
OR (course_prefix = $3 AND course_number = $4)
53+
GROUP BY course_prefix, course_number`,
54+
[prefix_a, number_a, prefix_b, number_b],
55+
),
56+
57+
// Query 2: Co-enrollment stats by delivery method
58+
pool.query(
59+
`SELECT
60+
a.delivery_method,
61+
COUNT(*) AS co_count,
62+
ROUND(
63+
COUNT(*) FILTER (
64+
WHERE a.grade NOT IN ('D','F','W','I') AND a.grade IS NOT NULL AND a.grade <> ''
65+
AND b.grade NOT IN ('D','F','W','I') AND b.grade IS NOT NULL AND b.grade <> ''
66+
) * 100.0 / NULLIF(COUNT(*), 0), 1
67+
) AS both_pass_rate
68+
FROM course_enrollments a
69+
JOIN course_enrollments b
70+
ON a.student_guid = b.student_guid
71+
AND a.academic_year = b.academic_year
72+
AND a.academic_term = b.academic_term
73+
WHERE a.course_prefix = $1 AND a.course_number = $2
74+
AND b.course_prefix = $3 AND b.course_number = $4
75+
GROUP BY a.delivery_method
76+
ORDER BY co_count DESC`,
77+
[prefix_a, number_a, prefix_b, number_b],
78+
),
79+
80+
// Query 3: Co-enrollment stats by instructor status
81+
pool.query(
82+
`SELECT
83+
a.instructor_status,
84+
COUNT(*) AS co_count,
85+
ROUND(
86+
COUNT(*) FILTER (
87+
WHERE a.grade NOT IN ('D','F','W','I') AND a.grade IS NOT NULL AND a.grade <> ''
88+
AND b.grade NOT IN ('D','F','W','I') AND b.grade IS NOT NULL AND b.grade <> ''
89+
) * 100.0 / NULLIF(COUNT(*), 0), 1
90+
) AS both_pass_rate
91+
FROM course_enrollments a
92+
JOIN course_enrollments b
93+
ON a.student_guid = b.student_guid
94+
AND a.academic_year = b.academic_year
95+
AND a.academic_term = b.academic_term
96+
WHERE a.course_prefix = $1 AND a.course_number = $2
97+
AND b.course_prefix = $3 AND b.course_number = $4
98+
GROUP BY a.instructor_status
99+
ORDER BY co_count DESC`,
100+
[prefix_a, number_a, prefix_b, number_b],
101+
),
102+
])
103+
104+
const courseA = indivRes.rows.find(
105+
r => r.course_prefix === prefix_a && r.course_number === number_a,
106+
)
107+
const courseB = indivRes.rows.find(
108+
r => r.course_prefix === prefix_b && r.course_number === number_b,
109+
)
110+
111+
const byDelivery = deliveryRes.rows.map(r => ({
112+
delivery_method: DELIVERY_LABELS[r.delivery_method] ?? r.delivery_method ?? "Unknown",
113+
co_count: Number(r.co_count),
114+
both_pass_rate: parseFloat(r.both_pass_rate),
115+
}))
116+
117+
const byInstructor = instrRes.rows.map(r => ({
118+
instructor_status:
119+
r.instructor_status === "FT"
120+
? "Full-Time Instructor"
121+
: r.instructor_status === "PT"
122+
? "Part-Time Instructor"
123+
: (r.instructor_status ?? "Unknown"),
124+
co_count: Number(r.co_count),
125+
both_pass_rate: parseFloat(r.both_pass_rate),
126+
}))
127+
128+
const stats = {
129+
courseA: courseA
130+
? {
131+
dfwi_rate: parseFloat(courseA.dfwi_rate),
132+
pass_rate: parseFloat(courseA.pass_rate),
133+
enrollments: Number(courseA.enrollments),
134+
}
135+
: null,
136+
courseB: courseB
137+
? {
138+
dfwi_rate: parseFloat(courseB.dfwi_rate),
139+
pass_rate: parseFloat(courseB.pass_rate),
140+
enrollments: Number(courseB.enrollments),
141+
}
142+
: null,
143+
byDelivery,
144+
byInstructor,
145+
}
146+
147+
// Build prompt context
148+
const labelA = `${prefix_a} ${number_a}${name_a ? ` (${name_a})` : ""}`
149+
const labelB = `${prefix_b} ${number_b}${name_b ? ` (${name_b})` : ""}`
150+
151+
const statsLineA = courseA
152+
? `${labelA}: ${courseA.dfwi_rate}% DFWI, ${courseA.pass_rate}% pass rate, ${Number(courseA.enrollments).toLocaleString()} total enrollments`
153+
: `${labelA}: no individual stats available`
154+
155+
const statsLineB = courseB
156+
? `${labelB}: ${courseB.dfwi_rate}% DFWI, ${courseB.pass_rate}% pass rate, ${Number(courseB.enrollments).toLocaleString()} total enrollments`
157+
: `${labelB}: no individual stats available`
158+
159+
const deliverySection = byDelivery.length
160+
? byDelivery
161+
.map(d => ` ${d.delivery_method}: ${d.co_count} co-enrolled, ${d.both_pass_rate}% both passed`)
162+
.join("\n")
163+
: " No delivery breakdown available"
164+
165+
const instrSection = byInstructor.length
166+
? byInstructor
167+
.map(i => ` ${i.instructor_status}: ${i.co_count} co-enrolled, ${i.both_pass_rate}% both passed`)
168+
.join("\n")
169+
: " No instructor breakdown available"
170+
171+
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.
172+
173+
INDIVIDUAL COURSE STATS:
174+
- ${statsLineA}
175+
- ${statsLineB}
176+
177+
CO-ENROLLMENT BREAKDOWN (students taking both courses in the same term):
178+
179+
By delivery method:
180+
${deliverySection}
181+
182+
By instructor type:
183+
${instrSection}
184+
185+
Write a concise analysis (3-4 sentences) that:
186+
1. Explains why students might struggle when taking both courses together (consider workload, cognitive load, prerequisite overlap, or scheduling demands)
187+
2. Highlights which conditions show better or worse outcomes based on the data
188+
3. Ends with one specific, actionable recommendation for advisors
189+
190+
Be practical and data-driven. Do not speculate beyond what the numbers show.`
191+
192+
const result = await generateText({
193+
model: openai("gpt-4o-mini"),
194+
prompt: llmPrompt,
195+
maxOutputTokens: 320,
196+
})
197+
198+
return NextResponse.json({ stats, explanation: result.text })
199+
} catch (error) {
200+
console.error("[explain-pairing] Error:", error)
201+
return NextResponse.json(
202+
{
203+
error: "Failed to generate explanation",
204+
details: error instanceof Error ? error.message : String(error),
205+
},
206+
{ status: 500 },
207+
)
208+
}
209+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { type NextRequest, NextResponse } from "next/server"
2+
import { getPool } from "@/lib/db"
3+
import { canAccess, type Role } from "@/lib/roles"
4+
5+
export async function GET(request: NextRequest) {
6+
const role = request.headers.get("x-user-role") as Role | null
7+
if (!role || !canAccess("/api/courses/gateway-funnel", role)) {
8+
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
9+
}
10+
11+
const mathSql = `
12+
SELECT
13+
cohort,
14+
COUNT(*) AS attempted,
15+
COUNT(*) FILTER (WHERE grade NOT IN ('D','F','W','I')
16+
AND grade IS NOT NULL AND grade <> '') AS passed,
17+
COUNT(*) FILTER (WHERE grade IN ('D','F','W','I')) AS dfwi
18+
FROM course_enrollments
19+
WHERE gateway_type = 'M'
20+
GROUP BY cohort
21+
ORDER BY cohort
22+
`
23+
24+
const englishSql = `
25+
SELECT
26+
cohort,
27+
COUNT(*) AS attempted,
28+
COUNT(*) FILTER (WHERE grade NOT IN ('D','F','W','I')
29+
AND grade IS NOT NULL AND grade <> '') AS passed,
30+
COUNT(*) FILTER (WHERE grade IN ('D','F','W','I')) AS dfwi
31+
FROM course_enrollments
32+
WHERE gateway_type = 'E'
33+
GROUP BY cohort
34+
ORDER BY cohort
35+
`
36+
37+
try {
38+
const pool = getPool()
39+
const [mathResult, englishResult] = await Promise.all([
40+
pool.query(mathSql),
41+
pool.query(englishSql),
42+
])
43+
44+
return NextResponse.json({
45+
math: mathResult.rows,
46+
english: englishResult.rows,
47+
})
48+
} catch (error) {
49+
console.error("Gateway funnel fetch error:", error)
50+
return NextResponse.json(
51+
{ error: "Failed to fetch gateway funnel data", details: error instanceof Error ? error.message : String(error) },
52+
{ status: 500 }
53+
)
54+
}
55+
}

0 commit comments

Comments
 (0)