馃 distributed transcription service thistle.dunkirk.sh
1import { nanoid } from "nanoid"; 2import db from "../db/schema"; 3 4export 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 15export interface MeetingTime { 16 id: string; 17 class_id: string; 18 label: string; 19 created_at: number; 20} 21 22export interface ClassMember { 23 class_id: string; 24 user_id: number; 25 enrolled_at: number; 26} 27 28export interface ClassWithStats extends Class { 29 student_count?: number; 30 transcript_count?: number; 31} 32 33/** 34 * Get all classes for a user (either enrolled or admin sees all) 35 */ 36export function getClassesForUser( 37 userId: number, 38 isAdmin: boolean, 39 limit = 50, 40 cursor?: string, 41): { 42 data: ClassWithStats[]; 43 pagination: { 44 limit: number; 45 hasMore: boolean; 46 nextCursor: string | null; 47 }; 48} { 49 let classes: ClassWithStats[]; 50 51 if (isAdmin) { 52 if (cursor) { 53 const { decodeClassCursor } = require("./cursor"); 54 const { year, semester, courseCode, id } = decodeClassCursor(cursor); 55 56 classes = db 57 .query<ClassWithStats, [number, string, string, string, number]>( 58 `SELECT 59 c.*, 60 (SELECT COUNT(*) FROM class_members WHERE class_id = c.id) as student_count, 61 (SELECT COUNT(*) FROM transcriptions WHERE class_id = c.id) as transcript_count 62 FROM classes c 63 WHERE (c.year < ? OR 64 (c.year = ? AND c.semester < ?) OR 65 (c.year = ? AND c.semester = ? AND c.course_code > ?) OR 66 (c.year = ? AND c.semester = ? AND c.course_code = ? AND c.id > ?)) 67 ORDER BY c.year DESC, c.semester DESC, c.course_code ASC, c.id ASC 68 LIMIT ?`, 69 ) 70 .all( 71 year, 72 year, 73 semester, 74 year, 75 semester, 76 courseCode, 77 year, 78 semester, 79 courseCode, 80 id, 81 limit + 1, 82 ); 83 } else { 84 classes = db 85 .query<ClassWithStats, [number]>( 86 `SELECT 87 c.*, 88 (SELECT COUNT(*) FROM class_members WHERE class_id = c.id) as student_count, 89 (SELECT COUNT(*) FROM transcriptions WHERE class_id = c.id) as transcript_count 90 FROM classes c 91 ORDER BY c.year DESC, c.semester DESC, c.course_code ASC, c.id ASC 92 LIMIT ?`, 93 ) 94 .all(limit + 1); 95 } 96 } else { 97 if (cursor) { 98 const { decodeClassCursor } = require("./cursor"); 99 const { year, semester, courseCode, id } = decodeClassCursor(cursor); 100 101 classes = db 102 .query< 103 ClassWithStats, 104 [number, number, string, string, string, number] 105 >( 106 `SELECT c.* FROM classes c 107 INNER JOIN class_members cm ON c.id = cm.class_id 108 WHERE cm.user_id = ? AND 109 (c.year < ? OR 110 (c.year = ? AND c.semester < ?) OR 111 (c.year = ? AND c.semester = ? AND c.course_code > ?) OR 112 (c.year = ? AND c.semester = ? AND c.course_code = ? AND c.id > ?)) 113 ORDER BY c.year DESC, c.semester DESC, c.course_code ASC, c.id ASC 114 LIMIT ?`, 115 ) 116 .all( 117 userId, 118 year, 119 year, 120 semester, 121 year, 122 semester, 123 courseCode, 124 year, 125 semester, 126 courseCode, 127 id, 128 limit + 1, 129 ); 130 } else { 131 classes = db 132 .query<ClassWithStats, [number, number]>( 133 `SELECT c.* FROM classes c 134 INNER JOIN class_members cm ON c.id = cm.class_id 135 WHERE cm.user_id = ? 136 ORDER BY c.year DESC, c.semester DESC, c.course_code ASC, c.id ASC 137 LIMIT ?`, 138 ) 139 .all(userId, limit + 1); 140 } 141 } 142 143 const hasMore = classes.length > limit; 144 if (hasMore) { 145 classes.pop(); 146 } 147 148 let nextCursor: string | null = null; 149 if (hasMore && classes.length > 0) { 150 const { encodeClassCursor } = require("./cursor"); 151 const last = classes[classes.length - 1]; 152 if (last) { 153 nextCursor = encodeClassCursor( 154 last.year, 155 last.semester, 156 last.course_code, 157 last.id, 158 ); 159 } 160 } 161 162 return { 163 data: classes, 164 pagination: { 165 limit, 166 hasMore, 167 nextCursor, 168 }, 169 }; 170} 171 172/** 173 * Get a single class by ID 174 */ 175export function getClassById(classId: string): Class | null { 176 const result = db 177 .query<Class, [string]>("SELECT * FROM classes WHERE id = ?") 178 .get(classId); 179 return result ?? null; 180} 181 182/** 183 * Check if user is enrolled in a class 184 */ 185export function isUserEnrolledInClass( 186 userId: number, 187 classId: string, 188): boolean { 189 const result = db 190 .query<{ count: number }, [string, number]>( 191 "SELECT COUNT(*) as count FROM class_members WHERE class_id = ? AND user_id = ?", 192 ) 193 .get(classId, userId); 194 return (result?.count ?? 0) > 0; 195} 196 197/** 198 * Create a new class 199 */ 200export function createClass(data: { 201 course_code: string; 202 name: string; 203 professor: string; 204 semester: string; 205 year: number; 206 meeting_times?: string[]; 207}): Class { 208 const id = nanoid(); 209 const now = Math.floor(Date.now() / 1000); 210 211 db.run( 212 "INSERT INTO classes (id, course_code, name, professor, semester, year, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", 213 [ 214 id, 215 data.course_code, 216 data.name, 217 data.professor, 218 data.semester, 219 data.year, 220 now, 221 ], 222 ); 223 224 // Create meeting times if provided 225 if (data.meeting_times && data.meeting_times.length > 0) { 226 for (const label of data.meeting_times) { 227 createMeetingTime(id, label); 228 } 229 } 230 231 return { 232 id, 233 course_code: data.course_code, 234 name: data.name, 235 professor: data.professor, 236 semester: data.semester, 237 year: data.year, 238 archived: false, 239 created_at: now, 240 }; 241} 242 243/** 244 * Archive or unarchive a class 245 */ 246export function toggleClassArchive(classId: string, archived: boolean): void { 247 const result = db.run("UPDATE classes SET archived = ? WHERE id = ?", [ 248 archived ? 1 : 0, 249 classId, 250 ]); 251 252 if (result.changes === 0) { 253 throw new Error("Class not found"); 254 } 255} 256 257/** 258 * Delete a class (cascades to members, meeting times, and transcriptions) 259 */ 260export function deleteClass(classId: string): void { 261 db.run("DELETE FROM classes WHERE id = ?", [classId]); 262} 263 264/** 265 * Enroll a user in a class 266 */ 267export function enrollUserInClass(userId: number, classId: string): void { 268 const now = Math.floor(Date.now() / 1000); 269 db.run( 270 "INSERT OR IGNORE INTO class_members (class_id, user_id, enrolled_at) VALUES (?, ?, ?)", 271 [classId, userId, now], 272 ); 273} 274 275/** 276 * Remove a user from a class 277 */ 278export function removeUserFromClass(userId: number, classId: string): void { 279 db.run("DELETE FROM class_members WHERE class_id = ? AND user_id = ?", [ 280 classId, 281 userId, 282 ]); 283} 284 285/** 286 * Get all members of a class 287 */ 288export function getClassMembers(classId: string) { 289 return db 290 .query< 291 { 292 user_id: number; 293 email: string; 294 name: string | null; 295 avatar: string; 296 enrolled_at: number; 297 }, 298 [string] 299 >( 300 `SELECT cm.user_id, u.email, u.name, u.avatar, cm.enrolled_at 301 FROM class_members cm 302 INNER JOIN users u ON cm.user_id = u.id 303 WHERE cm.class_id = ? 304 ORDER BY cm.enrolled_at DESC`, 305 ) 306 .all(classId); 307} 308 309/** 310 * Create a meeting time for a class 311 */ 312export function createMeetingTime(classId: string, label: string): MeetingTime { 313 const id = nanoid(); 314 const now = Math.floor(Date.now() / 1000); 315 316 db.run( 317 "INSERT INTO meeting_times (id, class_id, label, created_at) VALUES (?, ?, ?, ?)", 318 [id, classId, label, now], 319 ); 320 321 return { 322 id, 323 class_id: classId, 324 label, 325 created_at: now, 326 }; 327} 328 329/** 330 * Get all meeting times for a class 331 */ 332export function getMeetingTimesForClass(classId: string): MeetingTime[] { 333 return db 334 .query<MeetingTime, [string]>( 335 "SELECT * FROM meeting_times WHERE class_id = ? ORDER BY created_at ASC", 336 ) 337 .all(classId); 338} 339 340/** 341 * Get a single meeting time by ID 342 */ 343export function getMeetingById(meetingId: string): MeetingTime | null { 344 const result = db 345 .query<MeetingTime, [string]>("SELECT * FROM meeting_times WHERE id = ?") 346 .get(meetingId); 347 return result ?? null; 348} 349 350/** 351 * Update a meeting time label 352 */ 353export function updateMeetingTime(meetingId: string, label: string): void { 354 db.run("UPDATE meeting_times SET label = ? WHERE id = ?", [label, meetingId]); 355} 356 357/** 358 * Delete a meeting time 359 */ 360export function deleteMeetingTime(meetingId: string): void { 361 db.run("DELETE FROM meeting_times WHERE id = ?", [meetingId]); 362} 363 364/** 365 * Get transcriptions for a class 366 */ 367export function getTranscriptionsForClass(classId: string) { 368 return db 369 .query< 370 { 371 id: string; 372 user_id: number; 373 meeting_time_id: string | null; 374 filename: string; 375 original_filename: string; 376 status: string; 377 progress: number; 378 error_message: string | null; 379 created_at: number; 380 updated_at: number; 381 }, 382 [string] 383 >( 384 `SELECT id, user_id, meeting_time_id, filename, original_filename, status, progress, error_message, created_at, updated_at 385 FROM transcriptions 386 WHERE class_id = ? 387 ORDER BY created_at DESC`, 388 ) 389 .all(classId); 390} 391 392/** 393 * Search for classes by course code 394 */ 395export function searchClassesByCourseCode(courseCode: string): Class[] { 396 return db 397 .query<Class, [string]>( 398 `SELECT * FROM classes 399 WHERE UPPER(course_code) LIKE UPPER(?) 400 AND archived = 0 401 ORDER BY year DESC, semester DESC, professor ASC`, 402 ) 403 .all(`%${courseCode}%`); 404} 405 406/** 407 * Join a class by class ID 408 */ 409export function joinClass( 410 classId: string, 411 userId: number, 412): { success: boolean; error?: string } { 413 // Find class by ID 414 const cls = db 415 .query<Class, [string]>("SELECT * FROM classes WHERE id = ?") 416 .get(classId); 417 418 if (!cls) { 419 return { success: false, error: "Class not found" }; 420 } 421 422 if (cls.archived) { 423 return { success: false, error: "This class is archived" }; 424 } 425 426 // Check if already enrolled 427 const existing = db 428 .query<ClassMember, [string, number]>( 429 "SELECT * FROM class_members WHERE class_id = ? AND user_id = ?", 430 ) 431 .get(cls.id, userId); 432 433 if (existing) { 434 return { success: false, error: "Already enrolled in this class" }; 435 } 436 437 // Enroll user 438 db.query( 439 "INSERT INTO class_members (class_id, user_id, enrolled_at) VALUES (?, ?, ?)", 440 ).run(cls.id, userId, Math.floor(Date.now() / 1000)); 441 442 return { success: true }; 443} 444 445/** 446 * Waitlist entry interface 447 */ 448export interface WaitlistEntry { 449 id: string; 450 user_id: number; 451 course_code: string; 452 course_name: string; 453 professor: string; 454 semester: string; 455 year: number; 456 additional_info: string | null; 457 meeting_times: string | null; 458 created_at: number; 459} 460 461/** 462 * Add a class to the waitlist 463 */ 464export function addToWaitlist( 465 userId: number, 466 courseCode: string, 467 courseName: string, 468 professor: string, 469 semester: string, 470 year: number, 471 additionalInfo: string | null, 472 meetingTimes: string[] | null, 473): string { 474 const id = nanoid(); 475 const meetingTimesJson = meetingTimes ? JSON.stringify(meetingTimes) : null; 476 477 db.query( 478 `INSERT INTO class_waitlist 479 (id, user_id, course_code, course_name, professor, semester, year, additional_info, meeting_times, created_at) 480 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 481 ).run( 482 id, 483 userId, 484 courseCode, 485 courseName, 486 professor, 487 semester, 488 year, 489 additionalInfo, 490 meetingTimesJson, 491 Math.floor(Date.now() / 1000), 492 ); 493 return id; 494} 495 496/** 497 * Get all waitlist entries 498 */ 499export function getAllWaitlistEntries(): WaitlistEntry[] { 500 return db 501 .query<WaitlistEntry, []>( 502 "SELECT * FROM class_waitlist ORDER BY created_at DESC", 503 ) 504 .all(); 505} 506 507/** 508 * Delete a waitlist entry 509 */ 510export function deleteWaitlistEntry(id: string): void { 511 db.query("DELETE FROM class_waitlist WHERE id = ?").run(id); 512}