Skip to content

Commit 4f99aa3

Browse files
authored
Merge pull request #48 from devcolor/rebranding/task-6-nextjs-pg-pool
Task 6: Create shared Postgres pool for Next.js, swap mysql2 for pg
2 parents 044b987 + 9d80acd commit 4f99aa3

7 files changed

Lines changed: 380 additions & 1 deletion

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ lerna-debug.log*
5353
package-lock.json
5454
yarn.lock
5555
pnpm-lock.yaml
56+
57+
# Exceptions: dashboard source files that conflict with Python ignores above
58+
!codebenders-dashboard/lib/
59+
!codebenders-dashboard/lib/**
5660
dist/
5761
dist-ssr/
5862
*.local

codebenders-dashboard/lib/db.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { Pool } from "pg"
2+
3+
let pool: Pool | null = null
4+
5+
export function getPool(): Pool {
6+
if (!pool) {
7+
if (!process.env.DB_HOST || !process.env.DB_USER || !process.env.DB_PASSWORD) {
8+
throw new Error(
9+
"Missing required database environment variables: DB_HOST, DB_USER, DB_PASSWORD"
10+
)
11+
}
12+
pool = new Pool({
13+
host: process.env.DB_HOST,
14+
user: process.env.DB_USER,
15+
password: process.env.DB_PASSWORD,
16+
port: Number.parseInt(process.env.DB_PORT || "6543"),
17+
database: process.env.DB_NAME || "postgres",
18+
ssl: process.env.DB_SSL === "true" ? { rejectUnauthorized: false } : false,
19+
max: 10,
20+
})
21+
}
22+
return pool
23+
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import type { QueryPlan } from "./types"
2+
3+
// Database schema mapping
4+
const SCHEMA_CONFIG = {
5+
// Map institution codes to database names
6+
institutionDbMap: {
7+
kctcs: "Kentucky_Community_and_Technical_College_System",
8+
akron: "University_of_Akron",
9+
},
10+
// Primary table for student-level analytics
11+
mainTable: "cohort",
12+
// Map metric names to actual column names
13+
metricColumnMap: {
14+
retention_rate: "Retention",
15+
completion_rate: "Persistence",
16+
gpa: "GPA_Group_Year_1",
17+
credits_earned: "Number_of_Credits_Earned_Year_1",
18+
count: "COUNT(*)",
19+
},
20+
// Map groupBy fields to actual column names
21+
groupByColumnMap: {
22+
cohort: "Cohort",
23+
term: "Cohort_Term",
24+
program: "Program_of_Study_Year_1",
25+
course_code: "Course_Prefix",
26+
enrollment_status: "Enrollment_Type",
27+
},
28+
}
29+
30+
export function analyzePrompt(prompt: string, institutionCode: string): QueryPlan {
31+
const lowerPrompt = prompt.toLowerCase()
32+
33+
let metric: string | undefined
34+
if (lowerPrompt.includes("retention")) {
35+
metric = "retention_rate"
36+
} else if (lowerPrompt.includes("completion") || lowerPrompt.includes("persistence")) {
37+
metric = "completion_rate"
38+
} else if (lowerPrompt.includes("gpa")) {
39+
metric = "gpa"
40+
} else if (lowerPrompt.includes("credit")) {
41+
metric = "credits_earned"
42+
} else if (lowerPrompt.includes("enrollment") || lowerPrompt.includes("count")) {
43+
metric = "count"
44+
}
45+
46+
let groupBy: string | undefined
47+
if (lowerPrompt.includes("by cohort") || lowerPrompt.includes("cohort")) {
48+
groupBy = "cohort"
49+
} else if (lowerPrompt.includes("by term") || lowerPrompt.includes("term")) {
50+
groupBy = "term"
51+
} else if (lowerPrompt.includes("by program") || lowerPrompt.includes("program")) {
52+
groupBy = "program"
53+
} else if (lowerPrompt.includes("by course") || lowerPrompt.includes("course")) {
54+
groupBy = "course_code"
55+
} else if (lowerPrompt.includes("by status") || lowerPrompt.includes("status")) {
56+
groupBy = "enrollment_status"
57+
}
58+
59+
// Determine filters
60+
const filters: Record<string, any> = {}
61+
62+
if (lowerPrompt.includes("last two terms") || lowerPrompt.includes("last 2 terms")) {
63+
if (groupBy === "cohort") {
64+
filters.cohort = ["2024-Fall", "2025-Spring"]
65+
} else {
66+
filters.term = ["Fall 2024", "Spring 2025"]
67+
}
68+
} else if (lowerPrompt.includes("2024")) {
69+
filters.term = ["Spring 2024", "Fall 2024"]
70+
} else if (lowerPrompt.includes("2025")) {
71+
filters.term = ["Spring 2025", "Fall 2025"]
72+
}
73+
74+
// Status filters
75+
if (lowerPrompt.includes("enrolled")) {
76+
filters.enrollment_status = "enrolled"
77+
} else if (lowerPrompt.includes("completed")) {
78+
filters.enrollment_status = "completed"
79+
}
80+
81+
// Time hint
82+
let timeHint: string | undefined
83+
if (lowerPrompt.includes("last two terms") || lowerPrompt.includes("last 2 terms")) {
84+
timeHint = "Last two terms"
85+
} else if (lowerPrompt.includes("current")) {
86+
timeHint = "Current term"
87+
}
88+
89+
let vizType: QueryPlan["vizType"] = "bar"
90+
91+
if (groupBy === "term" || groupBy === "cohort") {
92+
vizType = "line"
93+
} else if (lowerPrompt.includes("share") || lowerPrompt.includes("percentage") || lowerPrompt.includes("breakdown")) {
94+
vizType = "pie"
95+
} else if (!groupBy) {
96+
vizType = "kpi"
97+
} else if (groupBy === "course_code") {
98+
vizType = "table"
99+
}
100+
101+
// Map to actual database columns
102+
const actualMetricColumn = metric ? SCHEMA_CONFIG.metricColumnMap[metric as keyof typeof SCHEMA_CONFIG.metricColumnMap] : undefined
103+
const actualGroupByColumn = groupBy ? SCHEMA_CONFIG.groupByColumnMap[groupBy as keyof typeof SCHEMA_CONFIG.groupByColumnMap] : undefined
104+
105+
// Generate SQL with actual schema
106+
const selectClause = actualGroupByColumn
107+
? actualMetricColumn && actualMetricColumn !== "COUNT(*)"
108+
? `${actualGroupByColumn}, AVG(${actualMetricColumn}) as ${metric}`
109+
: `${actualGroupByColumn}, COUNT(*) as count`
110+
: actualMetricColumn
111+
? actualMetricColumn === "COUNT(*)"
112+
? "COUNT(*) as count"
113+
: `AVG(${actualMetricColumn}) as ${metric}`
114+
: "COUNT(*) as count"
115+
116+
// Map filter keys to actual column names
117+
const actualFilters: Record<string, any> = {}
118+
Object.entries(filters).forEach(([key, value]) => {
119+
const actualKey = SCHEMA_CONFIG.groupByColumnMap[key as keyof typeof SCHEMA_CONFIG.groupByColumnMap] || key
120+
actualFilters[actualKey] = value
121+
})
122+
123+
const whereClause = Object.entries(actualFilters)
124+
.map(([key, value]) => {
125+
if (Array.isArray(value)) {
126+
return `${key} IN (${value.map((v) => `'${v}'`).join(", ")})`
127+
}
128+
return `${key} = '${value}'`
129+
})
130+
.join(" AND ")
131+
132+
const groupByClause = actualGroupByColumn ? `GROUP BY ${actualGroupByColumn}` : ""
133+
const orderByColumn = actualGroupByColumn || (actualMetricColumn && actualMetricColumn !== "COUNT(*)" ? actualMetricColumn : "count")
134+
135+
// Get database name for institution
136+
const dbName = SCHEMA_CONFIG.institutionDbMap[institutionCode as keyof typeof SCHEMA_CONFIG.institutionDbMap] || institutionCode
137+
const tableName = SCHEMA_CONFIG.mainTable
138+
139+
const sql = `SELECT ${selectClause}
140+
FROM \`${dbName}\`.${tableName}
141+
${whereClause ? `WHERE ${whereClause}` : ""}
142+
${groupByClause}
143+
ORDER BY ${orderByColumn}`.trim()
144+
145+
const queryParams = new URLSearchParams()
146+
147+
// Add limit parameter
148+
queryParams.append("limit", "1000")
149+
queryParams.append("offset", "0")
150+
151+
// Convert filters to query parameters
152+
if (filters && Object.keys(filters).length > 0) {
153+
Object.entries(filters).forEach(([key, value]) => {
154+
if (Array.isArray(value)) {
155+
// For array values, add multiple parameters with the same key
156+
value.forEach((v) => queryParams.append(key, String(v)))
157+
} else {
158+
queryParams.append(key, String(value))
159+
}
160+
})
161+
}
162+
163+
const queryString = `https://schools.syntex-ai.com/${institutionCode}/analysis-ready?${queryParams.toString()}`
164+
165+
return {
166+
metric,
167+
groupBy,
168+
filters: Object.keys(filters).length > 0 ? filters : undefined,
169+
timeHint,
170+
vizType,
171+
sql,
172+
queryString,
173+
}
174+
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import type { QueryPlan, QueryResult } from "./types"
2+
3+
const API_BASE_URL = "https://schools.syntex-ai.com"
4+
5+
export async function executeQuery(
6+
plan: QueryPlan,
7+
institutionCode: string,
8+
useDirectDB = false,
9+
): Promise<QueryResult> {
10+
try {
11+
console.log("[v0] Executing query for institution:", institutionCode)
12+
console.log("[v0] Query plan:", plan)
13+
console.log("[v0] Using direct DB:", useDirectDB)
14+
15+
if (useDirectDB) {
16+
return await executeDirectDB(plan, institutionCode)
17+
}
18+
19+
const url = plan.queryString
20+
console.log("[v0] Fetching from:", url)
21+
22+
const response = await fetch(url)
23+
if (!response.ok) {
24+
throw new Error(`API error: ${response.status}`)
25+
}
26+
27+
const data: any[] = await response.json()
28+
console.log("[v0] Received data:", data.length, "records")
29+
30+
// If no groupBy, return raw data or single aggregate
31+
if (!plan.groupBy) {
32+
if (plan.metric && data.length > 0) {
33+
const value = calculateAggregate(data, plan.metric)
34+
return {
35+
data: [{ [plan.metric]: value }],
36+
rowCount: 1,
37+
}
38+
}
39+
return {
40+
data: data.slice(0, 100),
41+
rowCount: data.length,
42+
}
43+
}
44+
45+
if (data.length > 0 && plan.groupBy in data[0]) {
46+
const grouped = groupBy(data, plan.groupBy)
47+
console.log("[v0] Grouped into", Object.keys(grouped).length, "groups")
48+
49+
const results = Object.entries(grouped).map(([key, records]) => {
50+
const result: Record<string, any> = {
51+
[plan.groupBy!]: key,
52+
}
53+
54+
if (plan.metric) {
55+
result[plan.metric] = calculateAggregate(records, plan.metric)
56+
} else {
57+
result.count = records.length
58+
}
59+
60+
return result
61+
})
62+
63+
results.sort((a, b) => {
64+
const aVal = a[plan.groupBy!]
65+
const bVal = b[plan.groupBy!]
66+
if (typeof aVal === "string" && typeof bVal === "string") {
67+
return aVal.localeCompare(bVal)
68+
}
69+
return aVal < bVal ? -1 : aVal > bVal ? 1 : 0
70+
})
71+
72+
console.log("[v0] Final results:", results)
73+
74+
return {
75+
data: results,
76+
rowCount: results.length,
77+
}
78+
}
79+
80+
return {
81+
data: data.slice(0, 100),
82+
rowCount: data.length,
83+
}
84+
} catch (error) {
85+
console.error("[v0] Query execution error:", error)
86+
throw error
87+
}
88+
}
89+
90+
async function executeDirectDB(plan: QueryPlan, institutionCode: string): Promise<QueryResult> {
91+
console.log("[v0] Executing SQL directly:", plan.sql)
92+
93+
const response = await fetch("/api/execute-sql", {
94+
method: "POST",
95+
headers: { "Content-Type": "application/json" },
96+
body: JSON.stringify({
97+
sql: plan.sql,
98+
institution: institutionCode,
99+
}),
100+
})
101+
102+
if (!response.ok) {
103+
const error = await response.json()
104+
throw new Error(error.details || "Database query failed")
105+
}
106+
107+
const result = await response.json()
108+
console.log("[v0] Direct DB returned:", result.rowCount, "records")
109+
110+
return {
111+
data: result.data,
112+
rowCount: result.rowCount,
113+
}
114+
}
115+
116+
function groupBy(data: any[], field: string): Record<string, any[]> {
117+
return data.reduce(
118+
(acc, record) => {
119+
const key = String(record[field] ?? "Unknown")
120+
if (!acc[key]) {
121+
acc[key] = []
122+
}
123+
acc[key].push(record)
124+
return acc
125+
},
126+
{} as Record<string, any[]>,
127+
)
128+
}
129+
130+
function calculateAggregate(records: any[], metric: string): number {
131+
if (records.length === 0) return 0
132+
133+
const values = records.map((r) => Number(r[metric])).filter((v) => !isNaN(v))
134+
135+
if (values.length === 0) {
136+
return records.length
137+
}
138+
139+
const sum = values.reduce((acc, val) => acc + val, 0)
140+
const avg = sum / values.length
141+
142+
return Math.round(avg * 100) / 100
143+
}

codebenders-dashboard/lib/types.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
export interface QueryPlan {
2+
metric?: string
3+
groupBy?: string
4+
filters?: Record<string, any>
5+
timeHint?: string
6+
vizType: "line" | "bar" | "pie" | "kpi" | "table"
7+
sql: string
8+
queryString: string
9+
}
10+
11+
export interface QueryResult {
12+
data: Record<string, any>[]
13+
rowCount: number
14+
}
15+
16+
export interface PDPRecord {
17+
student_id: string
18+
institution: string
19+
cohort: string
20+
term: string
21+
program: string
22+
course_code: string
23+
enrollment_status: string
24+
retention_rate: number
25+
completion_rate: number
26+
gpa: number
27+
credits_earned: number
28+
}

codebenders-dashboard/lib/utils.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { clsx, type ClassValue } from "clsx"
2+
import { twMerge } from "tailwind-merge"
3+
4+
export function cn(...inputs: ClassValue[]) {
5+
return twMerge(clsx(inputs))
6+
}

0 commit comments

Comments
 (0)