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