Skip to content

Commit 7334da4

Browse files
dsshimelclaude
andcommitted
add student portal with role-based auth
Adds a student-facing portal behind DISCORD_ALLOWED_STUDENT_IDS allowlist. Students see their own EOD messages, daily stats (attendance/PRs/EOD), and simulations. Instructors see the existing admin dashboard unchanged. All admin routes are now gated by requireInstructor middleware. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent cd6387b commit 7334da4

File tree

16 files changed

+756
-39
lines changed

16 files changed

+756
-39
lines changed

attendabot/.env.example

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@ DISCORD_CLIENT_ID=your-discord-oauth-client-id
1313
DISCORD_CLIENT_SECRET=your-discord-oauth-client-secret
1414
BETTER_AUTH_SECRET=your-32-char-secret-here
1515
BETTER_AUTH_URL=http://localhost:3001
16-
# Comma-separated Discord user IDs allowed to log in (empty = allow all)
16+
# Comma-separated Discord user IDs allowed to log in as instructors (empty = allow all)
1717
DISCORD_ALLOWED_USER_IDS=123456789,987654321
18+
# Comma-separated Discord user IDs allowed to log in as students
19+
DISCORD_ALLOWED_STUDENT_IDS=111111111,222222222
1820

1921
GEMINI_API_KEY=
2022

attendabot/backend/src/api/index.ts

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import path from "path";
99
import http from "http";
1010
import { toNodeHandler } from "better-auth/node";
1111
import { auth } from "../auth";
12+
import { authenticateToken } from "./middleware/auth";
13+
import { requireInstructor } from "./middleware/requireInstructor";
1214
import { statusRouter } from "./routes/status";
1315
import { messagesRouter } from "./routes/messages";
1416
import { channelsRouter } from "./routes/channels";
@@ -20,6 +22,8 @@ import { featureRequestsRouter } from "./routes/featureRequests";
2022
import { featureFlagsRouter } from "./routes/featureFlags";
2123
import { observersRouter } from "./routes/observers";
2224
import { databaseRouter } from "./routes/database";
25+
import { meRouter } from "./routes/me";
26+
import { studentPortalRouter } from "./routes/studentPortal";
2327
import { initializeWebSocket } from "./websocket";
2428

2529
/** Express application instance. */
@@ -39,19 +43,27 @@ app.all("/api/auth/better/*", toNodeHandler(auth));
3943

4044
app.use(express.json({ limit: "10mb" }));
4145

42-
// API routes
43-
app.use("/api/status", statusRouter);
44-
app.use("/api/messages", messagesRouter);
45-
app.use("/api/channels", channelsRouter);
46-
app.use("/api/users", usersRouter);
47-
app.use("/api/students", studentsRouter);
48-
app.use("/api/cohorts", cohortsRouter);
49-
app.use("/api/testing", testingRouter);
50-
app.use("/api/llm", llmRouter);
51-
app.use("/api/feature-requests", featureRequestsRouter);
52-
app.use("/api/feature-flags", featureFlagsRouter);
53-
app.use("/api/observers", observersRouter);
54-
app.use("/api/database", databaseRouter);
46+
// Routes accessible by both roles (auth handled inside router)
47+
app.use("/api/auth/me", meRouter);
48+
49+
// Student-only routes (auth + role check handled inside router)
50+
app.use("/api/student-portal", studentPortalRouter);
51+
52+
// Admin/instructor-only routes
53+
// authenticateToken sets req.user (with role), then requireInstructor checks it.
54+
// Each router also calls authenticateToken internally, but it short-circuits if already set.
55+
app.use("/api/status", authenticateToken, requireInstructor, statusRouter);
56+
app.use("/api/messages", authenticateToken, requireInstructor, messagesRouter);
57+
app.use("/api/channels", authenticateToken, requireInstructor, channelsRouter);
58+
app.use("/api/users", authenticateToken, requireInstructor, usersRouter);
59+
app.use("/api/students", authenticateToken, requireInstructor, studentsRouter);
60+
app.use("/api/cohorts", authenticateToken, requireInstructor, cohortsRouter);
61+
app.use("/api/testing", authenticateToken, requireInstructor, testingRouter);
62+
app.use("/api/llm", authenticateToken, requireInstructor, llmRouter);
63+
app.use("/api/feature-requests", authenticateToken, requireInstructor, featureRequestsRouter);
64+
app.use("/api/feature-flags", authenticateToken, requireInstructor, featureFlagsRouter);
65+
app.use("/api/observers", authenticateToken, requireInstructor, observersRouter);
66+
app.use("/api/database", authenticateToken, requireInstructor, databaseRouter);
5567

5668
// Serve static frontend files in production only
5769
if (process.env.NODE_ENV === "production") {
@@ -63,8 +75,10 @@ if (process.env.NODE_ENV === "production") {
6375
// Fallback: serve the correct index.html based on path
6476
app.get("*", (req, res) => {
6577
if (req.path.startsWith("/api")) return;
66-
if (req.path.startsWith("/simulations")) {
67-
res.sendFile(path.join(frontendPath, "simulations/index.html"));
78+
if (req.path.startsWith("/simulations/auth")) {
79+
res.sendFile(path.join(frontendPath, "simulations/auth/index.html"));
80+
} else if (req.path.startsWith("/simulations")) {
81+
res.sendFile(path.join(frontendPath, "simulations/auth/index.html"));
6882
} else {
6983
res.sendFile(path.join(frontendPath, "index.html"));
7084
}

attendabot/backend/src/api/middleware/auth.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,17 @@
77

88
import { Request, Response, NextFunction } from "express";
99
import { fromNodeHeaders } from "better-auth/node";
10-
import { auth } from "../../auth";
10+
import { auth, getUserRole, getAuthDatabase } from "../../auth";
1111
import { validateApiKey } from "../../services/apiKeys";
1212

1313
/** Express Request extended with authenticated user data. */
1414
export interface AuthRequest extends Request {
15-
user?: { authenticated: boolean; username: string };
15+
user?: {
16+
authenticated: boolean;
17+
username: string;
18+
role: "instructor" | "student";
19+
discordAccountId?: string;
20+
};
1621
}
1722

1823
/**
@@ -24,11 +29,16 @@ export function authenticateToken(
2429
res: Response,
2530
next: NextFunction,
2631
): void {
32+
// Skip if already authenticated (e.g., by mount-level middleware)
33+
if (req.user?.authenticated) {
34+
return next();
35+
}
36+
2737
// Check for API key first
2838
const apiKey = req.headers["x-api-key"];
2939
if (typeof apiKey === "string" && apiKey.length > 0) {
3040
if (validateApiKey(apiKey)) {
31-
req.user = { authenticated: true, username: "api-key" };
41+
req.user = { authenticated: true, username: "api-key", role: "instructor" };
3242
return next();
3343
}
3444
res.status(401).json({ error: "Invalid API key" });
@@ -40,9 +50,24 @@ export function authenticateToken(
4050
headers: fromNodeHeaders(req.headers),
4151
}).then((session) => {
4252
if (session?.user) {
53+
const username = session.user.name || session.user.email || "Discord User";
54+
55+
// Look up Discord account to determine role
56+
const authDb = getAuthDatabase();
57+
const account = authDb
58+
.prepare(
59+
`SELECT "accountId" FROM "account" WHERE "userId" = ? AND "providerId" = 'discord'`,
60+
)
61+
.get(session.user.id) as { accountId: string } | undefined;
62+
63+
const discordAccountId = account?.accountId;
64+
const role = discordAccountId ? (getUserRole(discordAccountId) ?? "instructor") : "instructor";
65+
4366
req.user = {
4467
authenticated: true,
45-
username: session.user.name || session.user.email || "Discord User",
68+
username,
69+
role,
70+
discordAccountId,
4671
};
4772
next();
4873
} else {
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* @fileoverview Middleware that restricts access to instructor-role users only.
3+
*/
4+
5+
import { Response, NextFunction } from "express";
6+
import { AuthRequest } from "./auth";
7+
8+
/** Middleware that checks if the authenticated user has the "instructor" role. Returns 403 if not. */
9+
export function requireInstructor(
10+
req: AuthRequest,
11+
res: Response,
12+
next: NextFunction,
13+
): void {
14+
if (req.user?.role !== "instructor") {
15+
res.status(403).json({ error: "Instructor access required" });
16+
return;
17+
}
18+
next();
19+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* @fileoverview Route for fetching the current user's identity and role.
3+
*/
4+
5+
import { Router, Response } from "express";
6+
import { authenticateToken, AuthRequest } from "../middleware/auth";
7+
import { getDatabase } from "../../services/db";
8+
9+
export const meRouter = Router();
10+
11+
/** GET /api/auth/me — Returns the current user's role, name, and identity info. */
12+
meRouter.get("/", authenticateToken, (req: AuthRequest, res: Response) => {
13+
const user = req.user!;
14+
const result: {
15+
role: string;
16+
name: string;
17+
discordAccountId?: string;
18+
studentId?: number;
19+
studentName?: string;
20+
} = {
21+
role: user.role,
22+
name: user.username,
23+
discordAccountId: user.discordAccountId,
24+
};
25+
26+
// If student, look up student record by discord_user_id
27+
if (user.role === "student" && user.discordAccountId) {
28+
const db = getDatabase();
29+
const student = db
30+
.prepare(`SELECT id, name FROM students WHERE discord_user_id = ?`)
31+
.get(user.discordAccountId) as { id: number; name: string } | undefined;
32+
if (student) {
33+
result.studentId = student.id;
34+
result.studentName = student.name;
35+
}
36+
}
37+
38+
res.json(result);
39+
});
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
/**
2+
* @fileoverview Student portal API routes. Provides students with read-only
3+
* access to their own EOD messages and daily stats.
4+
*/
5+
6+
import { Router, Response } from "express";
7+
import { authenticateToken, AuthRequest } from "../middleware/auth";
8+
import { getMessagesByUser, getMessagesByChannelAndDateRange } from "../../services/db";
9+
import { EOD_CHANNEL_ID, ATTENDANCE_CHANNEL_ID } from "../../bot/constants";
10+
import { countPrsInMessage, isValidEodMessage } from "../../bot/index";
11+
12+
export const studentPortalRouter = Router();
13+
14+
/** Middleware that ensures the user is a student. */
15+
function requireStudent(req: AuthRequest, res: Response, next: () => void): void {
16+
if (req.user?.role !== "student") {
17+
res.status(403).json({ error: "Student access required" });
18+
return;
19+
}
20+
if (!req.user.discordAccountId) {
21+
res.status(403).json({ error: "No Discord account linked" });
22+
return;
23+
}
24+
next();
25+
}
26+
27+
/**
28+
* GET /api/student-portal/eods?limit=100
29+
* Returns the student's own EOD messages sorted by date DESC.
30+
*/
31+
studentPortalRouter.get(
32+
"/eods",
33+
authenticateToken,
34+
requireStudent,
35+
(req: AuthRequest, res: Response) => {
36+
const limit = Math.min(parseInt(req.query.limit as string) || 100, 500);
37+
const discordId = req.user!.discordAccountId!;
38+
39+
const messages = getMessagesByUser(discordId, EOD_CHANNEL_ID, limit);
40+
res.json({
41+
messages: messages.map((m) => ({
42+
id: m.discord_message_id,
43+
content: m.content,
44+
createdAt: m.created_at,
45+
channelName: m.channel_name,
46+
})),
47+
});
48+
},
49+
);
50+
51+
/** Daily stats for the student portal. */
52+
interface DailyStats {
53+
date: string;
54+
attendancePosted: boolean;
55+
attendanceOnTime: boolean;
56+
middayPrPosted: boolean;
57+
middayPrCount: number;
58+
eodPosted: boolean;
59+
totalPrCount: number;
60+
}
61+
62+
/**
63+
* GET /api/student-portal/stats?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD
64+
* Computes daily stats for the authenticated student over the given date range.
65+
*/
66+
studentPortalRouter.get(
67+
"/stats",
68+
authenticateToken,
69+
requireStudent,
70+
(req: AuthRequest, res: Response) => {
71+
const discordId = req.user!.discordAccountId!;
72+
73+
// Default: last 30 days
74+
const endDate = (req.query.endDate as string) || new Date().toISOString().split("T")[0];
75+
const startDefault = new Date();
76+
startDefault.setDate(startDefault.getDate() - 30);
77+
const startDate = (req.query.startDate as string) || startDefault.toISOString().split("T")[0];
78+
79+
const days: DailyStats[] = [];
80+
const current = new Date(startDate);
81+
const end = new Date(endDate);
82+
83+
while (current <= end) {
84+
const dateStr = current.toISOString().split("T")[0];
85+
// Create date boundaries in ET (UTC-5)
86+
const dayStart = new Date(`${dateStr}T00:00:00-05:00`).toISOString();
87+
const dayEnd = new Date(`${dateStr}T23:59:59-05:00`).toISOString();
88+
const tenAm = new Date(`${dateStr}T10:00:00-05:00`).toISOString();
89+
const twoPm = new Date(`${dateStr}T14:00:00-05:00`).toISOString();
90+
91+
// Get messages for this day
92+
const attendanceMessages = getMessagesByChannelAndDateRange("attendance", dayStart, dayEnd);
93+
const eodMessages = getMessagesByChannelAndDateRange("eod", dayStart, dayEnd);
94+
95+
// Filter to this student's messages
96+
const myAttendance = attendanceMessages.filter((m) => m.author_id === discordId);
97+
const myEod = eodMessages.filter((m) => m.author_id === discordId);
98+
99+
// Attendance
100+
const attendancePosted = myAttendance.length > 0;
101+
const attendanceOnTime = attendancePosted && myAttendance[0].created_at < tenAm;
102+
103+
// Midday PR (PR links before 2 PM)
104+
const prBeforeTwoPm = myEod.filter(
105+
(m) => m.created_at < twoPm && countPrsInMessage(m.content ?? "") > 0,
106+
);
107+
const middayPrPosted = prBeforeTwoPm.length > 0;
108+
const middayPrCount = prBeforeTwoPm.reduce(
109+
(sum, m) => sum + countPrsInMessage(m.content ?? ""),
110+
0,
111+
);
112+
113+
// EOD
114+
const eodPosted = myEod.some((m) => isValidEodMessage(m.content ?? ""));
115+
116+
// Total unique PR count
117+
const prUrlRe = /https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+/g;
118+
const allPrUrls = new Set<string>();
119+
for (const m of myEod) {
120+
const urls = (m.content ?? "").match(prUrlRe) ?? [];
121+
for (const url of urls) {
122+
allPrUrls.add(url);
123+
}
124+
}
125+
126+
days.push({
127+
date: dateStr,
128+
attendancePosted,
129+
attendanceOnTime,
130+
middayPrPosted,
131+
middayPrCount,
132+
eodPosted,
133+
totalPrCount: allPrUrls.size,
134+
});
135+
136+
current.setDate(current.getDate() + 1);
137+
}
138+
139+
// Sort by date DESC
140+
days.sort((a, b) => b.date.localeCompare(a.date));
141+
142+
res.json({ days });
143+
},
144+
);

0 commit comments

Comments
 (0)