🪻 distributed transcription service thistle.dunkirk.sh
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: implement class system backend (database & API)

- Consolidate migrations into single schema with class system
- Add tables: classes, class_members, meeting_times
- Update transcriptions table with class_id, meeting_time_id, status
- Add class management lib with CRUD operations
- Add complete REST API for classes, enrollment, meetings
- Implement admin-driven transcription selection workflow
- Recordings default to 'pending' status (admin must select to transcribe)
- Add enrollment verification for class access
- Add archive support for classes
- Add getUserByEmail() to auth lib
- Include test suite and test script

💘 Generated with Crush

Co-Authored-By: Crush <crush@charm.land>

+1426 -121
+3
bun.lock
··· 9 9 "@simplewebauthn/server": "^13.2.2", 10 10 "eventsource-client": "^1.2.0", 11 11 "lit": "^3.3.1", 12 + "nanoid": "^5.1.6", 12 13 "ua-parser-js": "^2.0.6", 13 14 }, 14 15 "devDependencies": { ··· 105 106 "lit-element": ["lit-element@4.2.1", "", { "dependencies": { "@lit-labs/ssr-dom-shim": "^1.4.0", "@lit/reactive-element": "^2.1.0", "lit-html": "^3.3.0" } }, "sha512-WGAWRGzirAgyphK2urmYOV72tlvnxw7YfyLDgQ+OZnM9vQQBQnumQ7jUJe6unEzwGU3ahFOjuz1iz1jjrpCPuw=="], 106 107 107 108 "lit-html": ["lit-html@3.3.1", "", { "dependencies": { "@types/trusted-types": "^2.0.2" } }, "sha512-S9hbyDu/vs1qNrithiNyeyv64c9yqiW9l+DBgI18fL+MTvOtWoFR0FWiyq1TxaYef5wNlpEmzlXoBlZEO+WjoA=="], 109 + 110 + "nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="], 108 111 109 112 "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], 110 113
+264
docs/CLASS_SYSTEM_SPEC.md
··· 1 + # Class System Specification 2 + 3 + ## Overview 4 + 5 + Restructure Thistle from individual transcript management to class-based transcript organization. Users will manage transcripts grouped by classes, with scheduled meeting times and selective transcription. 6 + 7 + ## User Flow 8 + 9 + ### 1. Classes Page (Home) 10 + - Replaces the transcript page as the main view after signup 11 + - Displays grid of class cards organized by semester/year 12 + - Each section (semester/year combo) separated by horizontal rules 13 + - Each card shows: 14 + - Course code (e.g., "CS 101") 15 + - Course name (e.g., "Introduction to Computer Science") 16 + - Professor name 17 + - Semester and year (e.g., "Fall 2024") 18 + - Archive indicator (if archived) 19 + - Final card in grid is "Register for Class" with centered plus icon 20 + - Empty state: Only shows register button if user has no classes 21 + 22 + ### 2. Individual Class Page (`/classes/:id`) 23 + - Lists all recordings and transcripts for the class 24 + - Shows meeting schedule (flexible text, e.g., "Monday Lecture", "Wednesday Lab") 25 + - Displays recordings with statuses: 26 + - **Pending**: Uploaded but not selected for transcription 27 + - **Selected**: Marked for transcription by admin 28 + - **Transcribed**: Processing complete, ready to view 29 + - **Failed**: Transcription failed 30 + - Upload button to add new recordings 31 + - Each recording tagged with meeting time 32 + 33 + ### 3. Recording Upload 34 + - Any enrolled student can upload recordings 35 + - Must select which meeting time the recording is for 36 + - Recording enters "pending" state 37 + - Does not auto-transcribe 38 + 39 + ### 4. Admin Workflow 40 + - Admin views pending recordings 41 + - Selects specific recording to transcribe for each meeting 42 + - Only selected recordings get processed 43 + - Can manage classes (create, archive, enrollments) 44 + 45 + ## Database Schema 46 + 47 + ### Classes Table 48 + ```sql 49 + CREATE TABLE classes ( 50 + id TEXT PRIMARY KEY, -- stable random ID (nanoid or similar) 51 + course_code TEXT NOT NULL, -- e.g., "CS 101" 52 + name TEXT NOT NULL, -- e.g., "Introduction to Computer Science" 53 + professor TEXT NOT NULL, 54 + semester TEXT NOT NULL, -- e.g., "Fall", "Spring", "Summer" 55 + year INTEGER NOT NULL, -- e.g., 2024 56 + archived BOOLEAN DEFAULT FALSE, 57 + created_at INTEGER NOT NULL 58 + ); 59 + ``` 60 + 61 + ### Class Members Table 62 + ```sql 63 + CREATE TABLE class_members ( 64 + class_id TEXT NOT NULL, 65 + user_id TEXT NOT NULL, 66 + enrolled_at INTEGER NOT NULL, 67 + PRIMARY KEY (class_id, user_id), 68 + FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE, 69 + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 70 + ); 71 + ``` 72 + 73 + ### Meeting Times Table 74 + ```sql 75 + CREATE TABLE meeting_times ( 76 + id TEXT PRIMARY KEY, 77 + class_id TEXT NOT NULL, 78 + label TEXT NOT NULL, -- flexible text: "Monday Lecture", "Wednesday Lab", etc. 79 + created_at INTEGER NOT NULL, 80 + FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE 81 + ); 82 + ``` 83 + 84 + ### Updated Transcripts Table 85 + ```sql 86 + -- Add new columns to existing transcripts table: 87 + ALTER TABLE transcripts ADD COLUMN class_id TEXT; 88 + ALTER TABLE transcripts ADD COLUMN meeting_time_id TEXT; 89 + ALTER TABLE transcripts ADD COLUMN status TEXT DEFAULT 'pending'; 90 + -- status: 'pending' | 'selected' | 'transcribed' | 'failed' 91 + 92 + -- Add foreign keys: 93 + FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE 94 + FOREIGN KEY (meeting_time_id) REFERENCES meeting_times(id) ON DELETE SET NULL 95 + ``` 96 + 97 + **Note**: Add indexes for performance: 98 + - `class_members(user_id)` - lookup user's classes 99 + - `class_members(class_id)` - lookup class members 100 + - `transcripts(class_id)` - lookup class transcripts 101 + - `transcripts(status)` - filter by status 102 + - `meeting_times(class_id)` - lookup class schedule 103 + 104 + ## Permissions 105 + 106 + ### Class Access 107 + - Users can only view classes they're enrolled in 108 + - Admins can view all classes 109 + - Non-enrolled users get 403 when accessing `/classes/:id` 110 + 111 + ### Recording Permissions 112 + - **Upload**: Any enrolled student can upload recordings 113 + - **Delete**: Students can delete their own recordings 114 + - **Select for transcription**: Admin only 115 + - **View**: All enrolled students can view all transcripts in their classes 116 + 117 + ### Class Management 118 + - **Create**: Admin only (via admin UI) 119 + - **Archive**: Admin only (via admin UI) 120 + - **Enroll students**: Admin only (via admin UI) 121 + - **Remove students**: Admin only (via admin UI) 122 + 123 + ## Archive Behavior 124 + 125 + When a class is archived: 126 + - Students can still view the class and all transcripts 127 + - No new recordings can be uploaded 128 + - No recordings can be deleted 129 + - No transcription selection allowed 130 + - No enrollment changes 131 + - Class appears with archive indicator in UI 132 + - Organized with active classes by semester/year 133 + 134 + ## API Endpoints 135 + 136 + ### Classes 137 + - `GET /api/classes` - List user's classes (grouped by semester/year) 138 + - `GET /api/classes/:id` - Get class details (info, meeting times, transcripts) 139 + - `POST /api/classes` (admin) - Create new class 140 + - `PUT /api/classes/:id/archive` (admin) - Archive/unarchive class 141 + - `DELETE /api/classes/:id` (admin) - Delete class 142 + 143 + ### Class Members 144 + - `POST /api/classes/:id/members` (admin) - Enroll student(s) 145 + - `DELETE /api/classes/:id/members/:userId` (admin) - Remove student 146 + - `GET /api/classes/:id/members` (admin) - List class members 147 + 148 + ### Meeting Times 149 + - `GET /api/classes/:id/meetings` - List meeting times 150 + - `POST /api/classes/:id/meetings` (admin) - Create meeting time 151 + - `PUT /api/meetings/:id` (admin) - Update meeting time label 152 + - `DELETE /api/meetings/:id` (admin) - Delete meeting time 153 + 154 + ### Recordings/Transcripts 155 + - `GET /api/classes/:id/transcripts` - List all transcripts for class 156 + - `POST /api/classes/:id/recordings` - Upload recording (enrolled students) 157 + - `PUT /api/transcripts/:id/select` (admin) - Mark recording for transcription 158 + - `DELETE /api/transcripts/:id` - Delete recording (owner or admin) 159 + - `GET /api/transcripts/:id` - View transcript (enrolled students) 160 + 161 + ## Frontend Components 162 + 163 + ### Pages 164 + - `/classes` - Classes grid (home page, replaces transcripts page) 165 + - `/classes/:id` - Individual class view 166 + - `/admin` - Update to include class management 167 + 168 + ### New Components 169 + - `class-card.ts` - Class card component 170 + - `register-card.ts` - Register for class card (plus icon) 171 + - `class-detail.ts` - Individual class page 172 + - `recording-upload.ts` - Recording upload form 173 + - `recording-list.ts` - List of recordings with status 174 + - `admin-classes.ts` - Admin class management interface 175 + 176 + ### Navigation Updates 177 + - Remove transcript page links 178 + - Add classes link (make it home) 179 + - Update auth redirect after signup to `/classes` 180 + 181 + ## Migration Strategy 182 + 183 + **Breaking change**: Reset database schema to consolidate all migrations. 184 + 185 + 1. Export any critical production data (if needed) 186 + 2. Drop all tables 187 + 3. Consolidate migrations in `src/db/schema.ts`: 188 + - Include all previous migrations 189 + - Add new class system tables 190 + - Add new columns to transcripts 191 + 4. Restart with version 1 192 + 5. Existing transcripts will be lost (acceptable for this phase) 193 + 194 + ## Admin UI Updates 195 + 196 + ### Class Management Tab 197 + - Create new class form: 198 + - Course code 199 + - Course name 200 + - Professor 201 + - Semester dropdown (Fall/Spring/Summer/Winter) 202 + - Year input 203 + - List all classes (with archive status) 204 + - Archive/unarchive button per class 205 + - Delete class button 206 + 207 + ### Enrollment Management 208 + - Search for class 209 + - Add student by email 210 + - Remove enrolled students 211 + - View enrollment list per class 212 + - Future: Bulk CSV import 213 + 214 + ### Recording Selection 215 + - View pending recordings per class 216 + - Select recording to transcribe for each meeting 217 + - View transcription status 218 + - Handle failed transcriptions 219 + 220 + ## Empty States 221 + 222 + - **No classes**: Show only register card with message "No classes yet" 223 + - **No recordings in class**: Show message "No recordings yet" with upload button 224 + - **No pending recordings**: Show message in admin "All recordings processed" 225 + 226 + ## Future Enhancements (Out of Scope) 227 + 228 + - Share/enrollment links for self-enrollment 229 + - Notifications when transcripts ready 230 + - Auto-transcribe settings per class 231 + - Student/instructor roles 232 + - Search/filter classes 233 + - Bulk enrollment via CSV 234 + - Meeting time templates (MWF, TTh patterns) 235 + - Download all transcripts for a class 236 + 237 + ## Open Questions 238 + 239 + None - spec is complete for initial implementation. 240 + 241 + ## Implementation Phases 242 + 243 + ### Phase 1: Database & Backend 244 + 1. Consolidate migrations and add new schema 245 + 2. Add API endpoints for classes and members 246 + 3. Update permissions middleware 247 + 4. Add admin endpoints 248 + 249 + ### Phase 2: Admin UI 250 + 1. Class management interface 251 + 2. Enrollment management 252 + 3. Recording selection interface 253 + 254 + ### Phase 3: Student UI 255 + 1. Classes page with cards 256 + 2. Individual class pages 257 + 3. Recording upload 258 + 4. Update navigation 259 + 260 + ### Phase 4: Testing & Polish 261 + 1. Test permissions thoroughly 262 + 2. Test archive behavior 263 + 3. Empty states 264 + 4. Error handling
+1
package.json
··· 22 22 "@simplewebauthn/server": "^13.2.2", 23 23 "eventsource-client": "^1.2.0", 24 24 "lit": "^3.3.1", 25 + "nanoid": "^5.1.6", 25 26 "ua-parser-js": "^2.0.6" 26 27 } 27 28 }
+54
scripts/test-classes.ts
··· 1 + #!/usr/bin/env bun 2 + import db from "../src/db/schema"; 3 + import { createClass, enrollUserInClass, getMeetingTimesForClass, createMeetingTime } from "../src/lib/classes"; 4 + 5 + // Create a test user (admin) 6 + const email = "admin@thistle.test"; 7 + const existingUser = db 8 + .query<{ id: number }, [string]>("SELECT id FROM users WHERE email = ?") 9 + .get(email); 10 + 11 + let userId: number; 12 + 13 + if (!existingUser) { 14 + db.run( 15 + "INSERT INTO users (email, password_hash, role) VALUES (?, ?, ?)", 16 + [email, "test-hash", "admin"], 17 + ); 18 + userId = db.query<{ id: number }, []>("SELECT last_insert_rowid() as id").get()!.id; 19 + console.log(`✅ Created admin user: ${email} (ID: ${userId})`); 20 + } else { 21 + userId = existingUser.id; 22 + console.log(`✅ Using existing admin user: ${email} (ID: ${userId})`); 23 + } 24 + 25 + // Create a test class 26 + const cls = createClass({ 27 + course_code: "CS 101", 28 + name: "Introduction to Computer Science", 29 + professor: "Dr. Jane Smith", 30 + semester: "Fall", 31 + year: 2024, 32 + }); 33 + 34 + console.log(`✅ Created class: ${cls.course_code} - ${cls.name} (ID: ${cls.id})`); 35 + 36 + // Enroll the admin in the class 37 + enrollUserInClass(userId, cls.id); 38 + console.log(`✅ Enrolled admin in class`); 39 + 40 + // Create meeting times 41 + const meeting1 = createMeetingTime(cls.id, "Monday Lecture"); 42 + const meeting2 = createMeetingTime(cls.id, "Wednesday Lab"); 43 + console.log(`✅ Created meeting times: ${meeting1.label}, ${meeting2.label}`); 44 + 45 + // Verify 46 + const meetings = getMeetingTimesForClass(cls.id); 47 + console.log(`✅ Class has ${meetings.length} meeting times`); 48 + 49 + console.log("\n📊 Test Summary:"); 50 + console.log(`- Class ID: ${cls.id}`); 51 + console.log(`- Course: ${cls.course_code} - ${cls.name}`); 52 + console.log(`- Professor: ${cls.professor}`); 53 + console.log(`- Semester: ${cls.semester} ${cls.year}`); 54 + console.log(`- Meetings: ${meetings.map(m => m.label).join(", ")}`);
+232
src/db/schema.test.ts
··· 1 + import { Database } from "bun:sqlite"; 2 + import { expect, test, afterEach } from "bun:test"; 3 + import { unlinkSync } from "node:fs"; 4 + 5 + const TEST_DB = "test-schema.db"; 6 + 7 + afterEach(() => { 8 + try { 9 + unlinkSync(TEST_DB); 10 + } catch { 11 + // File may not exist 12 + } 13 + }); 14 + 15 + test("schema creates all required tables", () => { 16 + const db = new Database(TEST_DB); 17 + 18 + // Create schema_migrations table 19 + db.run(` 20 + CREATE TABLE IF NOT EXISTS schema_migrations ( 21 + version INTEGER PRIMARY KEY, 22 + applied_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) 23 + ) 24 + `); 25 + 26 + // Apply migration (simplified version of migration 1) 27 + const migration = ` 28 + CREATE TABLE IF NOT EXISTS users ( 29 + id INTEGER PRIMARY KEY AUTOINCREMENT, 30 + email TEXT UNIQUE NOT NULL, 31 + password_hash TEXT, 32 + name TEXT, 33 + avatar TEXT DEFAULT 'd', 34 + role TEXT NOT NULL DEFAULT 'user', 35 + last_login INTEGER, 36 + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) 37 + ); 38 + 39 + CREATE TABLE IF NOT EXISTS classes ( 40 + id TEXT PRIMARY KEY, 41 + course_code TEXT NOT NULL, 42 + name TEXT NOT NULL, 43 + professor TEXT NOT NULL, 44 + semester TEXT NOT NULL, 45 + year INTEGER NOT NULL, 46 + archived BOOLEAN DEFAULT 0, 47 + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) 48 + ); 49 + 50 + CREATE TABLE IF NOT EXISTS class_members ( 51 + class_id TEXT NOT NULL, 52 + user_id INTEGER NOT NULL, 53 + enrolled_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 54 + PRIMARY KEY (class_id, user_id), 55 + FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE, 56 + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 57 + ); 58 + 59 + CREATE TABLE IF NOT EXISTS meeting_times ( 60 + id TEXT PRIMARY KEY, 61 + class_id TEXT NOT NULL, 62 + label TEXT NOT NULL, 63 + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 64 + FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE 65 + ); 66 + 67 + CREATE TABLE IF NOT EXISTS transcriptions ( 68 + id TEXT PRIMARY KEY, 69 + user_id INTEGER NOT NULL, 70 + class_id TEXT, 71 + meeting_time_id TEXT, 72 + filename TEXT NOT NULL, 73 + original_filename TEXT NOT NULL, 74 + status TEXT NOT NULL DEFAULT 'pending', 75 + progress INTEGER NOT NULL DEFAULT 0, 76 + error_message TEXT, 77 + whisper_job_id TEXT, 78 + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 79 + updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 80 + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, 81 + FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE, 82 + FOREIGN KEY (meeting_time_id) REFERENCES meeting_times(id) ON DELETE SET NULL 83 + ); 84 + `; 85 + 86 + db.run(migration); 87 + 88 + // Verify tables exist 89 + const tables = db 90 + .query<{ name: string }, []>( 91 + "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name", 92 + ) 93 + .all(); 94 + 95 + const tableNames = tables.map((t) => t.name); 96 + 97 + expect(tableNames).toContain("users"); 98 + expect(tableNames).toContain("classes"); 99 + expect(tableNames).toContain("class_members"); 100 + expect(tableNames).toContain("meeting_times"); 101 + expect(tableNames).toContain("transcriptions"); 102 + 103 + db.close(); 104 + }); 105 + 106 + test("class foreign key constraints work", () => { 107 + const db = new Database(TEST_DB); 108 + 109 + // Create tables 110 + db.run(` 111 + CREATE TABLE users ( 112 + id INTEGER PRIMARY KEY AUTOINCREMENT, 113 + email TEXT UNIQUE NOT NULL, 114 + password_hash TEXT, 115 + role TEXT NOT NULL DEFAULT 'user', 116 + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) 117 + ); 118 + 119 + CREATE TABLE classes ( 120 + id TEXT PRIMARY KEY, 121 + course_code TEXT NOT NULL, 122 + name TEXT NOT NULL, 123 + professor TEXT NOT NULL, 124 + semester TEXT NOT NULL, 125 + year INTEGER NOT NULL, 126 + archived BOOLEAN DEFAULT 0, 127 + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) 128 + ); 129 + 130 + CREATE TABLE class_members ( 131 + class_id TEXT NOT NULL, 132 + user_id INTEGER NOT NULL, 133 + enrolled_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 134 + PRIMARY KEY (class_id, user_id), 135 + FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE, 136 + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 137 + ); 138 + `); 139 + 140 + db.run("PRAGMA foreign_keys = ON"); 141 + 142 + // Create test data 143 + db.run("INSERT INTO users (email, password_hash) VALUES (?, ?)", [ 144 + "test@example.com", 145 + "hash", 146 + ]); 147 + const userId = db 148 + .query<{ id: number }, []>("SELECT last_insert_rowid() as id") 149 + .get()?.id; 150 + 151 + db.run( 152 + "INSERT INTO classes (id, course_code, name, professor, semester, year) VALUES (?, ?, ?, ?, ?, ?)", 153 + ["class-1", "CS 101", "Intro to CS", "Dr. Smith", "Fall", 2024], 154 + ); 155 + 156 + // Enroll user in class 157 + db.run("INSERT INTO class_members (class_id, user_id) VALUES (?, ?)", [ 158 + "class-1", 159 + userId, 160 + ]); 161 + 162 + const enrollment = db 163 + .query< 164 + { class_id: string; user_id: number }, 165 + [] 166 + >("SELECT class_id, user_id FROM class_members") 167 + .get(); 168 + 169 + expect(enrollment?.class_id).toBe("class-1"); 170 + expect(enrollment?.user_id).toBe(userId); 171 + 172 + // Delete class should cascade delete enrollment 173 + db.run("DELETE FROM classes WHERE id = ?", ["class-1"]); 174 + 175 + const enrollments = db 176 + .query<{ class_id: string }, []>("SELECT class_id FROM class_members") 177 + .all(); 178 + expect(enrollments.length).toBe(0); 179 + 180 + db.close(); 181 + }); 182 + 183 + test("transcription status defaults to pending", () => { 184 + const db = new Database(TEST_DB); 185 + 186 + db.run(` 187 + CREATE TABLE users ( 188 + id INTEGER PRIMARY KEY AUTOINCREMENT, 189 + email TEXT UNIQUE NOT NULL, 190 + password_hash TEXT 191 + ); 192 + 193 + CREATE TABLE transcriptions ( 194 + id TEXT PRIMARY KEY, 195 + user_id INTEGER NOT NULL, 196 + class_id TEXT, 197 + meeting_time_id TEXT, 198 + filename TEXT NOT NULL, 199 + original_filename TEXT NOT NULL, 200 + status TEXT NOT NULL DEFAULT 'pending', 201 + progress INTEGER NOT NULL DEFAULT 0, 202 + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 203 + updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 204 + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 205 + ); 206 + `); 207 + 208 + db.run("INSERT INTO users (email, password_hash) VALUES (?, ?)", [ 209 + "test@example.com", 210 + "hash", 211 + ]); 212 + const userId = db 213 + .query<{ id: number }, []>("SELECT last_insert_rowid() as id") 214 + .get()?.id; 215 + 216 + db.run( 217 + "INSERT INTO transcriptions (id, user_id, filename, original_filename) VALUES (?, ?, ?, ?)", 218 + ["trans-1", userId, "file.mp3", "original.mp3"], 219 + ); 220 + 221 + const transcription = db 222 + .query< 223 + { status: string; progress: number }, 224 + [] 225 + >("SELECT status, progress FROM transcriptions WHERE id = 'trans-1'") 226 + .get(); 227 + 228 + expect(transcription?.status).toBe("pending"); 229 + expect(transcription?.progress).toBe(0); 230 + 231 + db.close(); 232 + });
+75 -95
src/db/schema.ts
··· 13 13 const migrations = [ 14 14 { 15 15 version: 1, 16 - name: "Complete user schema", 16 + name: "Complete schema with class system", 17 17 sql: ` 18 + -- Users table 18 19 CREATE TABLE IF NOT EXISTS users ( 19 20 id INTEGER PRIMARY KEY AUTOINCREMENT, 20 21 email TEXT UNIQUE NOT NULL, 21 - password_hash TEXT NOT NULL, 22 + password_hash TEXT, 22 23 name TEXT, 23 24 avatar TEXT DEFAULT 'd', 25 + role TEXT NOT NULL DEFAULT 'user', 26 + last_login INTEGER, 24 27 created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) 25 28 ); 26 29 30 + CREATE INDEX IF NOT EXISTS idx_users_role ON users(role); 31 + CREATE INDEX IF NOT EXISTS idx_users_last_login ON users(last_login); 32 + 33 + -- Sessions table 27 34 CREATE TABLE IF NOT EXISTS sessions ( 28 35 id TEXT PRIMARY KEY, 29 36 user_id INTEGER NOT NULL, ··· 36 43 37 44 CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); 38 45 CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); 39 - `, 40 - }, 41 - { 42 - version: 2, 43 - name: "Add transcriptions table", 44 - sql: ` 45 - CREATE TABLE IF NOT EXISTS transcriptions ( 46 + 47 + -- Passkeys table 48 + CREATE TABLE IF NOT EXISTS passkeys ( 46 49 id TEXT PRIMARY KEY, 47 50 user_id INTEGER NOT NULL, 48 - filename TEXT NOT NULL, 49 - original_filename TEXT NOT NULL, 50 - status TEXT NOT NULL DEFAULT 'uploading', 51 - progress INTEGER NOT NULL DEFAULT 0, 52 - transcript TEXT, 53 - error_message TEXT, 51 + credential_id TEXT NOT NULL UNIQUE, 52 + public_key TEXT NOT NULL, 53 + counter INTEGER NOT NULL DEFAULT 0, 54 + transports TEXT, 55 + name TEXT, 54 56 created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 55 - updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 57 + last_used_at INTEGER, 56 58 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 57 59 ); 58 60 59 - CREATE INDEX IF NOT EXISTS idx_transcriptions_user_id ON transcriptions(user_id); 60 - CREATE INDEX IF NOT EXISTS idx_transcriptions_status ON transcriptions(status); 61 - `, 62 - }, 63 - { 64 - version: 3, 65 - name: "Add whisper_job_id to transcriptions", 66 - sql: ` 67 - ALTER TABLE transcriptions ADD COLUMN whisper_job_id TEXT; 68 - CREATE INDEX IF NOT EXISTS idx_transcriptions_whisper_job_id ON transcriptions(whisper_job_id); 69 - `, 70 - }, 71 - { 72 - version: 4, 73 - name: "Remove transcript column from transcriptions", 74 - sql: ` 75 - -- SQLite 3.35.0+ supports DROP COLUMN 76 - ALTER TABLE transcriptions DROP COLUMN transcript; 77 - `, 78 - }, 79 - { 80 - version: 5, 81 - name: "Add rate limiting table", 82 - sql: ` 61 + CREATE INDEX IF NOT EXISTS idx_passkeys_user_id ON passkeys(user_id); 62 + CREATE INDEX IF NOT EXISTS idx_passkeys_credential_id ON passkeys(credential_id); 63 + 64 + -- Rate limiting table 83 65 CREATE TABLE IF NOT EXISTS rate_limit_attempts ( 84 66 id INTEGER PRIMARY KEY AUTOINCREMENT, 85 67 key TEXT NOT NULL, ··· 87 69 ); 88 70 89 71 CREATE INDEX IF NOT EXISTS idx_rate_limit_key_timestamp ON rate_limit_attempts(key, timestamp); 90 - `, 91 - }, 92 - { 93 - version: 6, 94 - name: "Add role-based auth system", 95 - sql: ` 96 - -- Add role column (default to 'user') 97 - ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'; 98 - 99 - -- Create index on role 100 - CREATE INDEX IF NOT EXISTS idx_users_role ON users(role); 101 - `, 102 - }, 103 - { 104 - version: 7, 105 - name: "Add WebAuthn passkey support", 106 - sql: ` 107 - CREATE TABLE IF NOT EXISTS passkeys ( 72 + 73 + -- Classes table 74 + CREATE TABLE IF NOT EXISTS classes ( 108 75 id TEXT PRIMARY KEY, 76 + course_code TEXT NOT NULL, 77 + name TEXT NOT NULL, 78 + professor TEXT NOT NULL, 79 + semester TEXT NOT NULL, 80 + year INTEGER NOT NULL, 81 + archived BOOLEAN DEFAULT 0, 82 + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) 83 + ); 84 + 85 + CREATE INDEX IF NOT EXISTS idx_classes_semester_year ON classes(semester, year); 86 + CREATE INDEX IF NOT EXISTS idx_classes_archived ON classes(archived); 87 + 88 + -- Class members table 89 + CREATE TABLE IF NOT EXISTS class_members ( 90 + class_id TEXT NOT NULL, 109 91 user_id INTEGER NOT NULL, 110 - credential_id TEXT NOT NULL UNIQUE, 111 - public_key TEXT NOT NULL, 112 - counter INTEGER NOT NULL DEFAULT 0, 113 - transports TEXT, 114 - name TEXT, 115 - created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 116 - last_used_at INTEGER, 92 + enrolled_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 93 + PRIMARY KEY (class_id, user_id), 94 + FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE, 117 95 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 118 96 ); 119 97 120 - CREATE INDEX IF NOT EXISTS idx_passkeys_user_id ON passkeys(user_id); 121 - CREATE INDEX IF NOT EXISTS idx_passkeys_credential_id ON passkeys(credential_id); 98 + CREATE INDEX IF NOT EXISTS idx_class_members_user_id ON class_members(user_id); 99 + CREATE INDEX IF NOT EXISTS idx_class_members_class_id ON class_members(class_id); 122 100 123 - -- Make password optional for users who only use passkeys 124 - CREATE TABLE users_new ( 125 - id INTEGER PRIMARY KEY AUTOINCREMENT, 126 - email TEXT UNIQUE NOT NULL, 127 - password_hash TEXT, 128 - name TEXT, 129 - avatar TEXT DEFAULT 'd', 101 + -- Meeting times table 102 + CREATE TABLE IF NOT EXISTS meeting_times ( 103 + id TEXT PRIMARY KEY, 104 + class_id TEXT NOT NULL, 105 + label TEXT NOT NULL, 130 106 created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 131 - role TEXT NOT NULL DEFAULT 'user' 107 + FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE 132 108 ); 133 109 134 - INSERT INTO users_new SELECT * FROM users; 135 - DROP TABLE users; 136 - ALTER TABLE users_new RENAME TO users; 110 + CREATE INDEX IF NOT EXISTS idx_meeting_times_class_id ON meeting_times(class_id); 111 + 112 + -- Transcriptions table 113 + CREATE TABLE IF NOT EXISTS transcriptions ( 114 + id TEXT PRIMARY KEY, 115 + user_id INTEGER NOT NULL, 116 + class_id TEXT, 117 + meeting_time_id TEXT, 118 + filename TEXT NOT NULL, 119 + original_filename TEXT NOT NULL, 120 + status TEXT NOT NULL DEFAULT 'pending', 121 + progress INTEGER NOT NULL DEFAULT 0, 122 + error_message TEXT, 123 + whisper_job_id TEXT, 124 + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 125 + updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 126 + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, 127 + FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE, 128 + FOREIGN KEY (meeting_time_id) REFERENCES meeting_times(id) ON DELETE SET NULL 129 + ); 137 130 138 - CREATE INDEX IF NOT EXISTS idx_users_role ON users(role); 139 - `, 140 - }, 141 - { 142 - version: 8, 143 - name: "Add last_login to users", 144 - sql: ` 145 - ALTER TABLE users ADD COLUMN last_login INTEGER; 146 - CREATE INDEX IF NOT EXISTS idx_users_last_login ON users(last_login); 147 - `, 148 - }, 149 - { 150 - version: 9, 151 - name: "Add class_name to transcriptions", 152 - sql: ` 153 - ALTER TABLE transcriptions ADD COLUMN class_name TEXT; 154 - CREATE INDEX IF NOT EXISTS idx_transcriptions_class_name ON transcriptions(class_name); 131 + CREATE INDEX IF NOT EXISTS idx_transcriptions_user_id ON transcriptions(user_id); 132 + CREATE INDEX IF NOT EXISTS idx_transcriptions_class_id ON transcriptions(class_id); 133 + CREATE INDEX IF NOT EXISTS idx_transcriptions_status ON transcriptions(status); 134 + CREATE INDEX IF NOT EXISTS idx_transcriptions_whisper_job_id ON transcriptions(whisper_job_id); 155 135 `, 156 136 }, 157 137 ];
+351 -26
src/index.ts
··· 14 14 getSession, 15 15 getSessionFromRequest, 16 16 getSessionsForUser, 17 + getUserByEmail, 17 18 getUserBySession, 18 19 getUserSessionsForUser, 19 20 type UserRole, ··· 24 25 updateUserPassword, 25 26 updateUserRole, 26 27 } from "./lib/auth"; 28 + import { 29 + createClass, 30 + createMeetingTime, 31 + deleteClass, 32 + deleteMeetingTime, 33 + enrollUserInClass, 34 + getClassById, 35 + getClassesForUser, 36 + getClassMembers, 37 + getMeetingTimesForClass, 38 + getTranscriptionsForClass, 39 + isUserEnrolledInClass, 40 + removeUserFromClass, 41 + toggleClassArchive, 42 + updateMeetingTime, 43 + } from "./lib/classes"; 27 44 import { handleError, ValidationErrors } from "./lib/errors"; 28 45 import { requireAdmin, requireAuth } from "./lib/middleware"; 29 46 import { ··· 917 934 id: string; 918 935 filename: string; 919 936 original_filename: string; 920 - class_name: string | null; 937 + class_id: string | null; 921 938 status: string; 922 939 progress: number; 923 940 created_at: number; 924 941 }, 925 942 [number] 926 943 >( 927 - "SELECT id, filename, original_filename, class_name, status, progress, created_at FROM transcriptions WHERE user_id = ? ORDER BY created_at DESC", 944 + "SELECT id, filename, original_filename, class_id, status, progress, created_at FROM transcriptions WHERE user_id = ? ORDER BY created_at DESC", 928 945 ) 929 946 .all(user.id); 930 947 ··· 934 951 return { 935 952 id: t.id, 936 953 filename: t.original_filename, 937 - class_name: t.class_name, 954 + class_id: t.class_id, 938 955 status: t.status, 939 956 progress: t.progress, 940 957 created_at: t.created_at, ··· 953 970 954 971 const formData = await req.formData(); 955 972 const file = formData.get("audio") as File; 956 - const className = formData.get("class_name") as string | null; 973 + const classId = formData.get("class_id") as string | null; 974 + const meetingTimeId = formData.get("meeting_time_id") as string | null; 957 975 958 976 if (!file) throw ValidationErrors.missingField("audio"); 959 977 978 + // If class_id provided, verify user is enrolled (or admin) 979 + if (classId) { 980 + const enrolled = isUserEnrolledInClass(user.id, classId); 981 + if (!enrolled && user.role !== "admin") { 982 + return Response.json( 983 + { error: "Not enrolled in this class" }, 984 + { status: 403 }, 985 + ); 986 + } 987 + 988 + // Verify class exists 989 + const classInfo = getClassById(classId); 990 + if (!classInfo) { 991 + return Response.json( 992 + { error: "Class not found" }, 993 + { status: 404 }, 994 + ); 995 + } 996 + 997 + // Check if class is archived 998 + if (classInfo.archived) { 999 + return Response.json( 1000 + { error: "Cannot upload to archived class" }, 1001 + { status: 400 }, 1002 + ); 1003 + } 1004 + } 1005 + 960 1006 // Validate file type 961 1007 const fileExtension = file.name.split(".").pop()?.toLowerCase(); 962 1008 const allowedExtensions = [ ··· 992 1038 const uploadDir = "./uploads"; 993 1039 await Bun.write(`${uploadDir}/${filename}`, file); 994 1040 995 - // Create database record with optional class_name 996 - if (className?.trim()) { 997 - db.run( 998 - "INSERT INTO transcriptions (id, user_id, filename, original_filename, class_name, status) VALUES (?, ?, ?, ?, ?, ?)", 999 - [ 1000 - transcriptionId, 1001 - user.id, 1002 - filename, 1003 - file.name, 1004 - className.trim(), 1005 - "uploading", 1006 - ], 1007 - ); 1008 - } else { 1009 - db.run( 1010 - "INSERT INTO transcriptions (id, user_id, filename, original_filename, status) VALUES (?, ?, ?, ?, ?)", 1011 - [transcriptionId, user.id, filename, file.name, "uploading"], 1012 - ); 1013 - } 1041 + // Create database record 1042 + db.run( 1043 + "INSERT INTO transcriptions (id, user_id, class_id, meeting_time_id, filename, original_filename, status) VALUES (?, ?, ?, ?, ?, ?, ?)", 1044 + [ 1045 + transcriptionId, 1046 + user.id, 1047 + classId, 1048 + meetingTimeId, 1049 + filename, 1050 + file.name, 1051 + "pending", 1052 + ], 1053 + ); 1014 1054 1015 - // Start transcription in background 1016 - whisperService.startTranscription(transcriptionId, filename); 1055 + // Don't auto-start transcription - admin will select recordings 1056 + // whisperService.startTranscription(transcriptionId, filename); 1017 1057 1018 1058 return Response.json({ 1019 1059 id: transcriptionId, 1020 - message: "Upload successful, transcription started", 1060 + message: "Upload successful", 1021 1061 }); 1022 1062 } catch (error) { 1023 1063 return handleError(error); ··· 1381 1421 user_email: user?.email || "Unknown", 1382 1422 user_name: user?.name || null, 1383 1423 }); 1424 + } catch (error) { 1425 + return handleError(error); 1426 + } 1427 + }, 1428 + }, 1429 + "/api/classes": { 1430 + GET: async (req) => { 1431 + try { 1432 + const user = requireAuth(req); 1433 + const classes = getClassesForUser(user.id, user.role === "admin"); 1434 + 1435 + // Group by semester/year 1436 + const grouped: Record< 1437 + string, 1438 + Array<{ 1439 + id: string; 1440 + course_code: string; 1441 + name: string; 1442 + professor: string; 1443 + semester: string; 1444 + year: number; 1445 + archived: boolean; 1446 + }> 1447 + > = {}; 1448 + 1449 + for (const cls of classes) { 1450 + const key = `${cls.semester} ${cls.year}`; 1451 + if (!grouped[key]) { 1452 + grouped[key] = []; 1453 + } 1454 + grouped[key]?.push({ 1455 + id: cls.id, 1456 + course_code: cls.course_code, 1457 + name: cls.name, 1458 + professor: cls.professor, 1459 + semester: cls.semester, 1460 + year: cls.year, 1461 + archived: cls.archived, 1462 + }); 1463 + } 1464 + 1465 + return Response.json({ classes: grouped }); 1466 + } catch (error) { 1467 + return handleError(error); 1468 + } 1469 + }, 1470 + POST: async (req) => { 1471 + try { 1472 + requireAdmin(req); 1473 + const body = await req.json(); 1474 + const { course_code, name, professor, semester, year } = body; 1475 + 1476 + if (!course_code || !name || !professor || !semester || !year) { 1477 + return Response.json( 1478 + { error: "Missing required fields" }, 1479 + { status: 400 }, 1480 + ); 1481 + } 1482 + 1483 + const newClass = createClass({ 1484 + course_code, 1485 + name, 1486 + professor, 1487 + semester, 1488 + year, 1489 + }); 1490 + 1491 + return Response.json(newClass); 1492 + } catch (error) { 1493 + return handleError(error); 1494 + } 1495 + }, 1496 + }, 1497 + "/api/classes/:id": { 1498 + GET: async (req) => { 1499 + try { 1500 + const user = requireAuth(req); 1501 + const classId = req.params.id; 1502 + 1503 + const classInfo = getClassById(classId); 1504 + if (!classInfo) { 1505 + return Response.json({ error: "Class not found" }, { status: 404 }); 1506 + } 1507 + 1508 + // Check enrollment or admin 1509 + const isEnrolled = isUserEnrolledInClass(user.id, classId); 1510 + if (!isEnrolled && user.role !== "admin") { 1511 + return Response.json( 1512 + { error: "Not enrolled in this class" }, 1513 + { status: 403 }, 1514 + ); 1515 + } 1516 + 1517 + const meetingTimes = getMeetingTimesForClass(classId); 1518 + const transcriptions = getTranscriptionsForClass(classId); 1519 + 1520 + return Response.json({ 1521 + class: classInfo, 1522 + meetingTimes, 1523 + transcriptions, 1524 + }); 1525 + } catch (error) { 1526 + return handleError(error); 1527 + } 1528 + }, 1529 + DELETE: async (req) => { 1530 + try { 1531 + requireAdmin(req); 1532 + const classId = req.params.id; 1533 + 1534 + deleteClass(classId); 1535 + return Response.json({ success: true }); 1536 + } catch (error) { 1537 + return handleError(error); 1538 + } 1539 + }, 1540 + }, 1541 + "/api/classes/:id/archive": { 1542 + PUT: async (req) => { 1543 + try { 1544 + requireAdmin(req); 1545 + const classId = req.params.id; 1546 + const body = await req.json(); 1547 + const { archived } = body; 1548 + 1549 + if (typeof archived !== "boolean") { 1550 + return Response.json( 1551 + { error: "archived must be a boolean" }, 1552 + { status: 400 }, 1553 + ); 1554 + } 1555 + 1556 + toggleClassArchive(classId, archived); 1557 + return Response.json({ success: true }); 1558 + } catch (error) { 1559 + return handleError(error); 1560 + } 1561 + }, 1562 + }, 1563 + "/api/classes/:id/members": { 1564 + GET: async (req) => { 1565 + try { 1566 + requireAdmin(req); 1567 + const classId = req.params.id; 1568 + 1569 + const members = getClassMembers(classId); 1570 + return Response.json({ members }); 1571 + } catch (error) { 1572 + return handleError(error); 1573 + } 1574 + }, 1575 + POST: async (req) => { 1576 + try { 1577 + requireAdmin(req); 1578 + const classId = req.params.id; 1579 + const body = await req.json(); 1580 + const { email } = body; 1581 + 1582 + if (!email) { 1583 + return Response.json({ error: "Email required" }, { status: 400 }); 1584 + } 1585 + 1586 + const user = getUserByEmail(email); 1587 + if (!user) { 1588 + return Response.json({ error: "User not found" }, { status: 404 }); 1589 + } 1590 + 1591 + enrollUserInClass(user.id, classId); 1592 + return Response.json({ success: true }); 1593 + } catch (error) { 1594 + return handleError(error); 1595 + } 1596 + }, 1597 + }, 1598 + "/api/classes/:id/members/:userId": { 1599 + DELETE: async (req) => { 1600 + try { 1601 + requireAdmin(req); 1602 + const classId = req.params.id; 1603 + const userId = Number.parseInt(req.params.userId, 10); 1604 + 1605 + if (Number.isNaN(userId)) { 1606 + return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1607 + } 1608 + 1609 + removeUserFromClass(userId, classId); 1610 + return Response.json({ success: true }); 1611 + } catch (error) { 1612 + return handleError(error); 1613 + } 1614 + }, 1615 + }, 1616 + "/api/classes/:id/meetings": { 1617 + GET: async (req) => { 1618 + try { 1619 + const user = requireAuth(req); 1620 + const classId = req.params.id; 1621 + 1622 + // Check enrollment or admin 1623 + const isEnrolled = isUserEnrolledInClass(user.id, classId); 1624 + if (!isEnrolled && user.role !== "admin") { 1625 + return Response.json( 1626 + { error: "Not enrolled in this class" }, 1627 + { status: 403 }, 1628 + ); 1629 + } 1630 + 1631 + const meetingTimes = getMeetingTimesForClass(classId); 1632 + return Response.json({ meetings: meetingTimes }); 1633 + } catch (error) { 1634 + return handleError(error); 1635 + } 1636 + }, 1637 + POST: async (req) => { 1638 + try { 1639 + requireAdmin(req); 1640 + const classId = req.params.id; 1641 + const body = await req.json(); 1642 + const { label } = body; 1643 + 1644 + if (!label) { 1645 + return Response.json({ error: "Label required" }, { status: 400 }); 1646 + } 1647 + 1648 + const meetingTime = createMeetingTime(classId, label); 1649 + return Response.json(meetingTime); 1650 + } catch (error) { 1651 + return handleError(error); 1652 + } 1653 + }, 1654 + }, 1655 + "/api/meetings/:id": { 1656 + PUT: async (req) => { 1657 + try { 1658 + requireAdmin(req); 1659 + const meetingId = req.params.id; 1660 + const body = await req.json(); 1661 + const { label } = body; 1662 + 1663 + if (!label) { 1664 + return Response.json({ error: "Label required" }, { status: 400 }); 1665 + } 1666 + 1667 + updateMeetingTime(meetingId, label); 1668 + return Response.json({ success: true }); 1669 + } catch (error) { 1670 + return handleError(error); 1671 + } 1672 + }, 1673 + DELETE: async (req) => { 1674 + try { 1675 + requireAdmin(req); 1676 + const meetingId = req.params.id; 1677 + 1678 + deleteMeetingTime(meetingId); 1679 + return Response.json({ success: true }); 1680 + } catch (error) { 1681 + return handleError(error); 1682 + } 1683 + }, 1684 + }, 1685 + "/api/transcripts/:id/select": { 1686 + PUT: async (req) => { 1687 + try { 1688 + requireAdmin(req); 1689 + const transcriptId = req.params.id; 1690 + 1691 + // Update status to 'selected' and start transcription 1692 + db.run("UPDATE transcriptions SET status = ? WHERE id = ?", [ 1693 + "selected", 1694 + transcriptId, 1695 + ]); 1696 + 1697 + // Get filename to start transcription 1698 + const transcription = db 1699 + .query<{ filename: string }, [string]>( 1700 + "SELECT filename FROM transcriptions WHERE id = ?", 1701 + ) 1702 + .get(transcriptId); 1703 + 1704 + if (transcription) { 1705 + whisperService.startTranscription(transcriptId, transcription.filename); 1706 + } 1707 + 1708 + return Response.json({ success: true }); 1384 1709 } catch (error) { 1385 1710 return handleError(error); 1386 1711 }
+10
src/lib/auth.ts
··· 64 64 return user ?? null; 65 65 } 66 66 67 + export function getUserByEmail(email: string): User | null { 68 + const user = db 69 + .query<User, [string]>( 70 + "SELECT id, email, name, avatar, created_at, role, last_login FROM users WHERE email = ?", 71 + ) 72 + .get(email); 73 + 74 + return user ?? null; 75 + } 76 + 67 77 export function deleteSession(sessionId: string): void { 68 78 db.run("DELETE FROM sessions WHERE id = ?", [sessionId]); 69 79 }
+187
src/lib/classes.test.ts
··· 1 + import { afterEach, beforeEach, expect, test } from "bun:test"; 2 + import { unlinkSync } from "node:fs"; 3 + import { 4 + createClass, 5 + createMeetingTime, 6 + enrollUserInClass, 7 + getClassById, 8 + getClassesForUser, 9 + getMeetingTimesForClass, 10 + getTranscriptionsForClass, 11 + isUserEnrolledInClass, 12 + } from "./classes"; 13 + import { Database } from "bun:sqlite"; 14 + 15 + const TEST_DB = "test-classes.db"; 16 + let db: Database; 17 + 18 + beforeEach(() => { 19 + db = new Database(TEST_DB); 20 + 21 + // Create minimal schema for testing 22 + db.run(` 23 + CREATE TABLE users ( 24 + id INTEGER PRIMARY KEY AUTOINCREMENT, 25 + email TEXT UNIQUE NOT NULL, 26 + password_hash TEXT, 27 + role TEXT NOT NULL DEFAULT 'user', 28 + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) 29 + ); 30 + 31 + CREATE TABLE classes ( 32 + id TEXT PRIMARY KEY, 33 + course_code TEXT NOT NULL, 34 + name TEXT NOT NULL, 35 + professor TEXT NOT NULL, 36 + semester TEXT NOT NULL, 37 + year INTEGER NOT NULL, 38 + archived BOOLEAN DEFAULT 0, 39 + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) 40 + ); 41 + 42 + CREATE TABLE class_members ( 43 + class_id TEXT NOT NULL, 44 + user_id INTEGER NOT NULL, 45 + enrolled_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 46 + PRIMARY KEY (class_id, user_id), 47 + FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE, 48 + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 49 + ); 50 + 51 + CREATE TABLE meeting_times ( 52 + id TEXT PRIMARY KEY, 53 + class_id TEXT NOT NULL, 54 + label TEXT NOT NULL, 55 + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 56 + FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE 57 + ); 58 + 59 + CREATE TABLE transcriptions ( 60 + id TEXT PRIMARY KEY, 61 + user_id INTEGER NOT NULL, 62 + class_id TEXT, 63 + meeting_time_id TEXT, 64 + filename TEXT NOT NULL, 65 + original_filename TEXT NOT NULL, 66 + status TEXT NOT NULL DEFAULT 'pending', 67 + progress INTEGER NOT NULL DEFAULT 0, 68 + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 69 + updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 70 + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, 71 + FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE, 72 + FOREIGN KEY (meeting_time_id) REFERENCES meeting_times(id) ON DELETE SET NULL 73 + ); 74 + `); 75 + }); 76 + 77 + afterEach(() => { 78 + db.close(); 79 + try { 80 + unlinkSync(TEST_DB); 81 + } catch { 82 + // File may not exist 83 + } 84 + }); 85 + 86 + test("creates a class with all required fields", () => { 87 + const cls = createClass({ 88 + course_code: "CS 101", 89 + name: "Intro to CS", 90 + professor: "Dr. Smith", 91 + semester: "Fall", 92 + year: 2024, 93 + }); 94 + 95 + expect(cls.id).toBeTruthy(); 96 + expect(cls.course_code).toBe("CS 101"); 97 + expect(cls.name).toBe("Intro to CS"); 98 + expect(cls.professor).toBe("Dr. Smith"); 99 + expect(cls.semester).toBe("Fall"); 100 + expect(cls.year).toBe(2024); 101 + expect(cls.archived).toBe(false); 102 + }); 103 + 104 + test("enrolls user in class", () => { 105 + // Create user 106 + db.run("INSERT INTO users (email, password_hash) VALUES (?, ?)", [ 107 + "test@example.com", 108 + "hash", 109 + ]); 110 + const userId = db 111 + .query<{ id: number }, []>("SELECT last_insert_rowid() as id") 112 + .get()?.id; 113 + 114 + // Create class 115 + const cls = createClass({ 116 + course_code: "CS 101", 117 + name: "Intro to CS", 118 + professor: "Dr. Smith", 119 + semester: "Fall", 120 + year: 2024, 121 + }); 122 + 123 + // Enroll user 124 + enrollUserInClass(userId!, cls.id); 125 + 126 + // Verify enrollment 127 + const isEnrolled = isUserEnrolledInClass(userId!, cls.id); 128 + expect(isEnrolled).toBe(true); 129 + }); 130 + 131 + test("gets classes for enrolled user", () => { 132 + // Create user 133 + db.run("INSERT INTO users (email, password_hash) VALUES (?, ?)", [ 134 + "test@example.com", 135 + "hash", 136 + ]); 137 + const userId = db 138 + .query<{ id: number }, []>("SELECT last_insert_rowid() as id") 139 + .get()?.id; 140 + 141 + // Create two classes 142 + const cls1 = createClass({ 143 + course_code: "CS 101", 144 + name: "Intro to CS", 145 + professor: "Dr. Smith", 146 + semester: "Fall", 147 + year: 2024, 148 + }); 149 + 150 + const cls2 = createClass({ 151 + course_code: "CS 102", 152 + name: "Data Structures", 153 + professor: "Dr. Jones", 154 + semester: "Fall", 155 + year: 2024, 156 + }); 157 + 158 + // Enroll user in only one class 159 + enrollUserInClass(userId!, cls1.id); 160 + 161 + // Get classes for user 162 + const classes = getClassesForUser(userId!, false); 163 + expect(classes.length).toBe(1); 164 + expect(classes[0]?.id).toBe(cls1.id); 165 + 166 + // Admin should see all 167 + const allClasses = getClassesForUser(userId!, true); 168 + expect(allClasses.length).toBe(2); 169 + }); 170 + 171 + test("creates and retrieves meeting times", () => { 172 + const cls = createClass({ 173 + course_code: "CS 101", 174 + name: "Intro to CS", 175 + professor: "Dr. Smith", 176 + semester: "Fall", 177 + year: 2024, 178 + }); 179 + 180 + const meeting1 = createMeetingTime(cls.id, "Monday Lecture"); 181 + const meeting2 = createMeetingTime(cls.id, "Wednesday Lab"); 182 + 183 + const meetings = getMeetingTimesForClass(cls.id); 184 + expect(meetings.length).toBe(2); 185 + expect(meetings[0]?.label).toBe("Monday Lecture"); 186 + expect(meetings[1]?.label).toBe("Wednesday Lab"); 187 + });
+249
src/lib/classes.ts
··· 1 + import { nanoid } from "nanoid"; 2 + import db from "../db/schema"; 3 + 4 + export interface Class { 5 + id: string; 6 + course_code: string; 7 + name: string; 8 + professor: string; 9 + semester: string; 10 + year: number; 11 + archived: boolean; 12 + created_at: number; 13 + } 14 + 15 + export interface MeetingTime { 16 + id: string; 17 + class_id: string; 18 + label: string; 19 + created_at: number; 20 + } 21 + 22 + export interface ClassMember { 23 + class_id: string; 24 + user_id: number; 25 + enrolled_at: number; 26 + } 27 + 28 + /** 29 + * Get all classes for a user (either enrolled or admin sees all) 30 + */ 31 + export function getClassesForUser( 32 + userId: number, 33 + isAdmin: boolean, 34 + ): Class[] { 35 + if (isAdmin) { 36 + return db 37 + .query<Class, []>( 38 + "SELECT * FROM classes ORDER BY year DESC, semester DESC, course_code ASC", 39 + ) 40 + .all(); 41 + } 42 + 43 + return db 44 + .query<Class, [number]>( 45 + `SELECT c.* FROM classes c 46 + INNER JOIN class_members cm ON c.id = cm.class_id 47 + WHERE cm.user_id = ? 48 + ORDER BY c.year DESC, c.semester DESC, c.course_code ASC`, 49 + ) 50 + .all(userId); 51 + } 52 + 53 + /** 54 + * Get a single class by ID 55 + */ 56 + export function getClassById(classId: string): Class | null { 57 + const result = db 58 + .query<Class, [string]>("SELECT * FROM classes WHERE id = ?") 59 + .get(classId); 60 + return result ?? null; 61 + } 62 + 63 + /** 64 + * Check if user is enrolled in a class 65 + */ 66 + export function isUserEnrolledInClass( 67 + userId: number, 68 + classId: string, 69 + ): boolean { 70 + const result = db 71 + .query<{ count: number }, [string, number]>( 72 + "SELECT COUNT(*) as count FROM class_members WHERE class_id = ? AND user_id = ?", 73 + ) 74 + .get(classId, userId); 75 + return (result?.count ?? 0) > 0; 76 + } 77 + 78 + /** 79 + * Create a new class 80 + */ 81 + export function createClass(data: { 82 + course_code: string; 83 + name: string; 84 + professor: string; 85 + semester: string; 86 + year: number; 87 + }): Class { 88 + const id = nanoid(); 89 + const now = Math.floor(Date.now() / 1000); 90 + 91 + db.run( 92 + "INSERT INTO classes (id, course_code, name, professor, semester, year, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", 93 + [ 94 + id, 95 + data.course_code, 96 + data.name, 97 + data.professor, 98 + data.semester, 99 + data.year, 100 + now, 101 + ], 102 + ); 103 + 104 + return { 105 + id, 106 + course_code: data.course_code, 107 + name: data.name, 108 + professor: data.professor, 109 + semester: data.semester, 110 + year: data.year, 111 + archived: false, 112 + created_at: now, 113 + }; 114 + } 115 + 116 + /** 117 + * Archive or unarchive a class 118 + */ 119 + export function toggleClassArchive(classId: string, archived: boolean): void { 120 + db.run("UPDATE classes SET archived = ? WHERE id = ?", [ 121 + archived ? 1 : 0, 122 + classId, 123 + ]); 124 + } 125 + 126 + /** 127 + * Delete a class (cascades to members, meeting times, and transcriptions) 128 + */ 129 + export function deleteClass(classId: string): void { 130 + db.run("DELETE FROM classes WHERE id = ?", [classId]); 131 + } 132 + 133 + /** 134 + * Enroll a user in a class 135 + */ 136 + export function enrollUserInClass(userId: number, classId: string): void { 137 + const now = Math.floor(Date.now() / 1000); 138 + db.run( 139 + "INSERT OR IGNORE INTO class_members (class_id, user_id, enrolled_at) VALUES (?, ?, ?)", 140 + [classId, userId, now], 141 + ); 142 + } 143 + 144 + /** 145 + * Remove a user from a class 146 + */ 147 + export function removeUserFromClass(userId: number, classId: string): void { 148 + db.run("DELETE FROM class_members WHERE class_id = ? AND user_id = ?", [ 149 + classId, 150 + userId, 151 + ]); 152 + } 153 + 154 + /** 155 + * Get all members of a class 156 + */ 157 + export function getClassMembers(classId: string) { 158 + return db 159 + .query< 160 + { 161 + user_id: number; 162 + email: string; 163 + name: string | null; 164 + avatar: string; 165 + enrolled_at: number; 166 + }, 167 + [string] 168 + >( 169 + `SELECT cm.user_id, u.email, u.name, u.avatar, cm.enrolled_at 170 + FROM class_members cm 171 + INNER JOIN users u ON cm.user_id = u.id 172 + WHERE cm.class_id = ? 173 + ORDER BY cm.enrolled_at DESC`, 174 + ) 175 + .all(classId); 176 + } 177 + 178 + /** 179 + * Create a meeting time for a class 180 + */ 181 + export function createMeetingTime(classId: string, label: string): MeetingTime { 182 + const id = nanoid(); 183 + const now = Math.floor(Date.now() / 1000); 184 + 185 + db.run( 186 + "INSERT INTO meeting_times (id, class_id, label, created_at) VALUES (?, ?, ?, ?)", 187 + [id, classId, label, now], 188 + ); 189 + 190 + return { 191 + id, 192 + class_id: classId, 193 + label, 194 + created_at: now, 195 + }; 196 + } 197 + 198 + /** 199 + * Get all meeting times for a class 200 + */ 201 + export function getMeetingTimesForClass(classId: string): MeetingTime[] { 202 + return db 203 + .query<MeetingTime, [string]>( 204 + "SELECT * FROM meeting_times WHERE class_id = ? ORDER BY created_at ASC", 205 + ) 206 + .all(classId); 207 + } 208 + 209 + /** 210 + * Update a meeting time label 211 + */ 212 + export function updateMeetingTime(meetingId: string, label: string): void { 213 + db.run("UPDATE meeting_times SET label = ? WHERE id = ?", [label, meetingId]); 214 + } 215 + 216 + /** 217 + * Delete a meeting time 218 + */ 219 + export function deleteMeetingTime(meetingId: string): void { 220 + db.run("DELETE FROM meeting_times WHERE id = ?", [meetingId]); 221 + } 222 + 223 + /** 224 + * Get transcriptions for a class 225 + */ 226 + export function getTranscriptionsForClass(classId: string) { 227 + return db 228 + .query< 229 + { 230 + id: string; 231 + user_id: number; 232 + meeting_time_id: string | null; 233 + filename: string; 234 + original_filename: string; 235 + status: string; 236 + progress: number; 237 + error_message: string | null; 238 + created_at: number; 239 + updated_at: number; 240 + }, 241 + [string] 242 + >( 243 + `SELECT id, user_id, meeting_time_id, filename, original_filename, status, progress, error_message, created_at, updated_at 244 + FROM transcriptions 245 + WHERE class_id = ? 246 + ORDER BY created_at DESC`, 247 + ) 248 + .all(classId); 249 + }