馃 distributed transcription service thistle.dunkirk.sh
1import db from "./db/schema"; 2import { 3 authenticateUser, 4 cleanupExpiredSessions, 5 createSession, 6 createUser, 7 deleteAllUserSessions, 8 deleteSession, 9 deleteSessionById, 10 deleteTranscription, 11 deleteUser, 12 getAllTranscriptions, 13 getAllUsersWithStats, 14 getSession, 15 getSessionFromRequest, 16 getSessionsForUser, 17 getUserByEmail, 18 getUserBySession, 19 getUserSessionsForUser, 20 type UserRole, 21 updateUserAvatar, 22 updateUserEmail, 23 updateUserEmailAddress, 24 updateUserName, 25 updateUserPassword, 26 updateUserRole, 27} from "./lib/auth"; 28import { 29 createClass, 30 createMeetingTime, 31 deleteClass, 32 deleteMeetingTime, 33 enrollUserInClass, 34 getClassById, 35 getClassesForUser, 36 getClassMembers, 37 getMeetingTimesForClass, 38 getTranscriptionsForClass, 39 isUserEnrolledInClass, 40 joinClass, 41 removeUserFromClass, 42 searchClassesByCourseCode, 43 toggleClassArchive, 44 updateMeetingTime, 45 addToWaitlist, 46 getAllWaitlistEntries, 47 deleteWaitlistEntry, 48} from "./lib/classes"; 49import { handleError, ValidationErrors } from "./lib/errors"; 50import { requireAdmin, requireAuth } from "./lib/middleware"; 51import { 52 createAuthenticationOptions, 53 createRegistrationOptions, 54 deletePasskey, 55 getPasskeysForUser, 56 updatePasskeyName, 57 verifyAndAuthenticatePasskey, 58 verifyAndCreatePasskey, 59} from "./lib/passkey"; 60import { enforceRateLimit } from "./lib/rate-limit"; 61import { getTranscriptVTT } from "./lib/transcript-storage"; 62import { 63 MAX_FILE_SIZE, 64 TranscriptionEventEmitter, 65 type TranscriptionUpdate, 66 WhisperServiceManager, 67} from "./lib/transcription"; 68import adminHTML from "./pages/admin.html"; 69import classHTML from "./pages/class.html"; 70import classesHTML from "./pages/classes.html"; 71import indexHTML from "./pages/index.html"; 72import settingsHTML from "./pages/settings.html"; 73import transcribeHTML from "./pages/transcribe.html"; 74 75// Environment variables 76const WHISPER_SERVICE_URL = 77 process.env.WHISPER_SERVICE_URL || "http://localhost:8000"; 78 79// Create uploads and transcripts directories if they don't exist 80await Bun.write("./uploads/.gitkeep", ""); 81await Bun.write("./transcripts/.gitkeep", ""); 82 83// Initialize transcription system 84console.log( 85 `[Transcription] Connecting to Murmur at ${WHISPER_SERVICE_URL}...`, 86); 87const transcriptionEvents = new TranscriptionEventEmitter(); 88const whisperService = new WhisperServiceManager( 89 WHISPER_SERVICE_URL, 90 db, 91 transcriptionEvents, 92); 93 94// Clean up expired sessions every hour 95setInterval(cleanupExpiredSessions, 60 * 60 * 1000); 96 97// Sync with Whisper DB on startup 98try { 99 await whisperService.syncWithWhisper(); 100 console.log("[Transcription] Successfully connected to Murmur"); 101} catch (error) { 102 console.warn( 103 "[Transcription] Murmur unavailable at startup:", 104 error instanceof Error ? error.message : "Unknown error", 105 ); 106} 107 108// Periodic sync every 5 minutes as backup (SSE handles real-time updates) 109setInterval( 110 async () => { 111 try { 112 await whisperService.syncWithWhisper(); 113 } catch (error) { 114 console.warn( 115 "[Sync] Failed to sync with Murmur:", 116 error instanceof Error ? error.message : "Unknown error", 117 ); 118 } 119 }, 120 5 * 60 * 1000, 121); 122 123// Clean up stale files daily 124setInterval(() => whisperService.cleanupStaleFiles(), 24 * 60 * 60 * 1000); 125 126const server = Bun.serve({ 127 port: process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 3000, 128 idleTimeout: 120, // 120 seconds for SSE connections 129 routes: { 130 "/": indexHTML, 131 "/admin": adminHTML, 132 "/settings": settingsHTML, 133 "/transcribe": transcribeHTML, 134 "/classes": classesHTML, 135 "/classes/*": classHTML, 136 "/api/auth/register": { 137 POST: async (req) => { 138 try { 139 // Rate limiting 140 const rateLimitError = enforceRateLimit(req, "register", { 141 ip: { max: 5, windowSeconds: 60 * 60 }, 142 }); 143 if (rateLimitError) return rateLimitError; 144 145 const body = await req.json(); 146 const { email, password, name } = body; 147 if (!email || !password) { 148 return Response.json( 149 { error: "Email and password required" }, 150 { status: 400 }, 151 ); 152 } 153 // Password is client-side hashed (PBKDF2), should be 64 char hex 154 if (password.length !== 64 || !/^[0-9a-f]+$/.test(password)) { 155 return Response.json( 156 { error: "Invalid password format" }, 157 { status: 400 }, 158 ); 159 } 160 const user = await createUser(email, password, name); 161 const ipAddress = 162 req.headers.get("x-forwarded-for") ?? 163 req.headers.get("x-real-ip") ?? 164 "unknown"; 165 const userAgent = req.headers.get("user-agent") ?? "unknown"; 166 const sessionId = createSession(user.id, ipAddress, userAgent); 167 return Response.json( 168 { user: { id: user.id, email: user.email } }, 169 { 170 headers: { 171 "Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`, 172 }, 173 }, 174 ); 175 } catch (err: unknown) { 176 const error = err as { message?: string }; 177 if (error.message?.includes("UNIQUE constraint failed")) { 178 return Response.json( 179 { error: "Email already registered" }, 180 { status: 400 }, 181 ); 182 } 183 return Response.json( 184 { error: "Registration failed" }, 185 { status: 500 }, 186 ); 187 } 188 }, 189 }, 190 "/api/auth/login": { 191 POST: async (req) => { 192 try { 193 const body = await req.json(); 194 const { email, password } = body; 195 if (!email || !password) { 196 return Response.json( 197 { error: "Email and password required" }, 198 { status: 400 }, 199 ); 200 } 201 202 // Rate limiting: Per IP and per account 203 const rateLimitError = enforceRateLimit(req, "login", { 204 ip: { max: 10, windowSeconds: 15 * 60 }, 205 account: { max: 5, windowSeconds: 15 * 60, email }, 206 }); 207 if (rateLimitError) return rateLimitError; 208 209 // Password is client-side hashed (PBKDF2), should be 64 char hex 210 if (password.length !== 64 || !/^[0-9a-f]+$/.test(password)) { 211 return Response.json( 212 { error: "Invalid password format" }, 213 { status: 400 }, 214 ); 215 } 216 const user = await authenticateUser(email, password); 217 if (!user) { 218 return Response.json( 219 { error: "Invalid email or password" }, 220 { status: 401 }, 221 ); 222 } 223 const ipAddress = 224 req.headers.get("x-forwarded-for") ?? 225 req.headers.get("x-real-ip") ?? 226 "unknown"; 227 const userAgent = req.headers.get("user-agent") ?? "unknown"; 228 const sessionId = createSession(user.id, ipAddress, userAgent); 229 return Response.json( 230 { user: { id: user.id, email: user.email } }, 231 { 232 headers: { 233 "Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`, 234 }, 235 }, 236 ); 237 } catch { 238 return Response.json({ error: "Login failed" }, { status: 500 }); 239 } 240 }, 241 }, 242 "/api/auth/logout": { 243 POST: async (req) => { 244 const sessionId = getSessionFromRequest(req); 245 if (sessionId) { 246 deleteSession(sessionId); 247 } 248 return Response.json( 249 { success: true }, 250 { 251 headers: { 252 "Set-Cookie": 253 "session=; HttpOnly; Secure; Path=/; Max-Age=0; SameSite=Lax", 254 }, 255 }, 256 ); 257 }, 258 }, 259 "/api/auth/me": { 260 GET: (req) => { 261 const sessionId = getSessionFromRequest(req); 262 if (!sessionId) { 263 return Response.json({ error: "Not authenticated" }, { status: 401 }); 264 } 265 const user = getUserBySession(sessionId); 266 if (!user) { 267 return Response.json({ error: "Invalid session" }, { status: 401 }); 268 } 269 return Response.json({ 270 email: user.email, 271 name: user.name, 272 avatar: user.avatar, 273 created_at: user.created_at, 274 role: user.role, 275 }); 276 }, 277 }, 278 "/api/passkeys/register/options": { 279 POST: async (req) => { 280 try { 281 const user = requireAuth(req); 282 const options = await createRegistrationOptions(user); 283 return Response.json(options); 284 } catch (err) { 285 return handleError(err); 286 } 287 }, 288 }, 289 "/api/passkeys/register/verify": { 290 POST: async (req) => { 291 try { 292 const _user = requireAuth(req); 293 const body = await req.json(); 294 const { response: credentialResponse, challenge, name } = body; 295 296 const passkey = await verifyAndCreatePasskey( 297 credentialResponse, 298 challenge, 299 name, 300 ); 301 302 return Response.json({ 303 success: true, 304 passkey: { 305 id: passkey.id, 306 name: passkey.name, 307 created_at: passkey.created_at, 308 }, 309 }); 310 } catch (err) { 311 return handleError(err); 312 } 313 }, 314 }, 315 "/api/passkeys/authenticate/options": { 316 POST: async (req) => { 317 try { 318 const body = await req.json(); 319 const { email } = body; 320 321 const options = await createAuthenticationOptions(email); 322 return Response.json(options); 323 } catch (err) { 324 return handleError(err); 325 } 326 }, 327 }, 328 "/api/passkeys/authenticate/verify": { 329 POST: async (req) => { 330 try { 331 const body = await req.json(); 332 const { response: credentialResponse, challenge } = body; 333 334 const { user } = await verifyAndAuthenticatePasskey( 335 credentialResponse, 336 challenge, 337 ); 338 339 // Create session 340 const ipAddress = 341 req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || 342 req.headers.get("x-real-ip") || 343 "unknown"; 344 const userAgent = req.headers.get("user-agent") || "unknown"; 345 const sessionId = createSession(user.id, ipAddress, userAgent); 346 347 return Response.json( 348 { 349 email: user.email, 350 name: user.name, 351 avatar: user.avatar, 352 created_at: user.created_at, 353 role: user.role, 354 }, 355 { 356 headers: { 357 "Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`, 358 }, 359 }, 360 ); 361 } catch (err) { 362 return handleError(err); 363 } 364 }, 365 }, 366 "/api/passkeys": { 367 GET: async (req) => { 368 try { 369 const user = requireAuth(req); 370 const passkeys = getPasskeysForUser(user.id); 371 return Response.json({ 372 passkeys: passkeys.map((p) => ({ 373 id: p.id, 374 name: p.name, 375 created_at: p.created_at, 376 last_used_at: p.last_used_at, 377 })), 378 }); 379 } catch (err) { 380 return handleError(err); 381 } 382 }, 383 }, 384 "/api/passkeys/:id": { 385 PUT: async (req) => { 386 try { 387 const user = requireAuth(req); 388 const body = await req.json(); 389 const { name } = body; 390 const passkeyId = req.params.id; 391 392 if (!name) { 393 return Response.json({ error: "Name required" }, { status: 400 }); 394 } 395 396 updatePasskeyName(passkeyId, user.id, name); 397 return Response.json({ success: true }); 398 } catch (err) { 399 return handleError(err); 400 } 401 }, 402 DELETE: async (req) => { 403 try { 404 const user = requireAuth(req); 405 const passkeyId = req.params.id; 406 deletePasskey(passkeyId, user.id); 407 return Response.json({ success: true }); 408 } catch (err) { 409 return handleError(err); 410 } 411 }, 412 }, 413 "/api/sessions": { 414 GET: (req) => { 415 const sessionId = getSessionFromRequest(req); 416 if (!sessionId) { 417 return Response.json({ error: "Not authenticated" }, { status: 401 }); 418 } 419 const user = getUserBySession(sessionId); 420 if (!user) { 421 return Response.json({ error: "Invalid session" }, { status: 401 }); 422 } 423 const sessions = getUserSessionsForUser(user.id); 424 return Response.json({ 425 sessions: sessions.map((s) => ({ 426 id: s.id, 427 ip_address: s.ip_address, 428 user_agent: s.user_agent, 429 created_at: s.created_at, 430 expires_at: s.expires_at, 431 })), 432 }); 433 }, 434 DELETE: async (req) => { 435 const currentSessionId = getSessionFromRequest(req); 436 if (!currentSessionId) { 437 return Response.json({ error: "Not authenticated" }, { status: 401 }); 438 } 439 const user = getUserBySession(currentSessionId); 440 if (!user) { 441 return Response.json({ error: "Invalid session" }, { status: 401 }); 442 } 443 const body = await req.json(); 444 const targetSessionId = body.sessionId; 445 if (!targetSessionId) { 446 return Response.json( 447 { error: "Session ID required" }, 448 { status: 400 }, 449 ); 450 } 451 // Verify the session belongs to the user 452 const targetSession = getSession(targetSessionId); 453 if (!targetSession || targetSession.user_id !== user.id) { 454 return Response.json({ error: "Session not found" }, { status: 404 }); 455 } 456 deleteSession(targetSessionId); 457 return Response.json({ success: true }); 458 }, 459 }, 460 "/api/user": { 461 DELETE: (req) => { 462 const sessionId = getSessionFromRequest(req); 463 if (!sessionId) { 464 return Response.json({ error: "Not authenticated" }, { status: 401 }); 465 } 466 const user = getUserBySession(sessionId); 467 if (!user) { 468 return Response.json({ error: "Invalid session" }, { status: 401 }); 469 } 470 471 // Rate limiting 472 const rateLimitError = enforceRateLimit(req, "delete-user", { 473 ip: { max: 3, windowSeconds: 60 * 60 }, 474 }); 475 if (rateLimitError) return rateLimitError; 476 477 deleteUser(user.id); 478 return Response.json( 479 { success: true }, 480 { 481 headers: { 482 "Set-Cookie": 483 "session=; HttpOnly; Secure; Path=/; Max-Age=0; SameSite=Lax", 484 }, 485 }, 486 ); 487 }, 488 }, 489 "/api/user/email": { 490 PUT: async (req) => { 491 const sessionId = getSessionFromRequest(req); 492 if (!sessionId) { 493 return Response.json({ error: "Not authenticated" }, { status: 401 }); 494 } 495 const user = getUserBySession(sessionId); 496 if (!user) { 497 return Response.json({ error: "Invalid session" }, { status: 401 }); 498 } 499 500 // Rate limiting 501 const rateLimitError = enforceRateLimit(req, "update-email", { 502 ip: { max: 5, windowSeconds: 60 * 60 }, 503 }); 504 if (rateLimitError) return rateLimitError; 505 506 const body = await req.json(); 507 const { email } = body; 508 if (!email) { 509 return Response.json({ error: "Email required" }, { status: 400 }); 510 } 511 try { 512 updateUserEmail(user.id, email); 513 return Response.json({ success: true }); 514 } catch (err: unknown) { 515 const error = err as { message?: string }; 516 if (error.message?.includes("UNIQUE constraint failed")) { 517 return Response.json( 518 { error: "Email already in use" }, 519 { status: 400 }, 520 ); 521 } 522 return Response.json( 523 { error: "Failed to update email" }, 524 { status: 500 }, 525 ); 526 } 527 }, 528 }, 529 "/api/user/password": { 530 PUT: async (req) => { 531 const sessionId = getSessionFromRequest(req); 532 if (!sessionId) { 533 return Response.json({ error: "Not authenticated" }, { status: 401 }); 534 } 535 const user = getUserBySession(sessionId); 536 if (!user) { 537 return Response.json({ error: "Invalid session" }, { status: 401 }); 538 } 539 540 // Rate limiting 541 const rateLimitError = enforceRateLimit(req, "update-password", { 542 ip: { max: 5, windowSeconds: 60 * 60 }, 543 }); 544 if (rateLimitError) return rateLimitError; 545 546 const body = await req.json(); 547 const { password } = body; 548 if (!password) { 549 return Response.json({ error: "Password required" }, { status: 400 }); 550 } 551 // Password is client-side hashed (PBKDF2), should be 64 char hex 552 if (password.length !== 64 || !/^[0-9a-f]+$/.test(password)) { 553 return Response.json( 554 { error: "Invalid password format" }, 555 { status: 400 }, 556 ); 557 } 558 try { 559 await updateUserPassword(user.id, password); 560 return Response.json({ success: true }); 561 } catch { 562 return Response.json( 563 { error: "Failed to update password" }, 564 { status: 500 }, 565 ); 566 } 567 }, 568 }, 569 "/api/user/name": { 570 PUT: async (req) => { 571 const sessionId = getSessionFromRequest(req); 572 if (!sessionId) { 573 return Response.json({ error: "Not authenticated" }, { status: 401 }); 574 } 575 const user = getUserBySession(sessionId); 576 if (!user) { 577 return Response.json({ error: "Invalid session" }, { status: 401 }); 578 } 579 const body = await req.json(); 580 const { name } = body; 581 if (!name) { 582 return Response.json({ error: "Name required" }, { status: 400 }); 583 } 584 try { 585 updateUserName(user.id, name); 586 return Response.json({ success: true }); 587 } catch { 588 return Response.json( 589 { error: "Failed to update name" }, 590 { status: 500 }, 591 ); 592 } 593 }, 594 }, 595 "/api/user/avatar": { 596 PUT: async (req) => { 597 const sessionId = getSessionFromRequest(req); 598 if (!sessionId) { 599 return Response.json({ error: "Not authenticated" }, { status: 401 }); 600 } 601 const user = getUserBySession(sessionId); 602 if (!user) { 603 return Response.json({ error: "Invalid session" }, { status: 401 }); 604 } 605 const body = await req.json(); 606 const { avatar } = body; 607 if (!avatar) { 608 return Response.json({ error: "Avatar required" }, { status: 400 }); 609 } 610 try { 611 updateUserAvatar(user.id, avatar); 612 return Response.json({ success: true }); 613 } catch { 614 return Response.json( 615 { error: "Failed to update avatar" }, 616 { status: 500 }, 617 ); 618 } 619 }, 620 }, 621 "/api/transcriptions/:id/stream": { 622 GET: async (req) => { 623 const sessionId = getSessionFromRequest(req); 624 if (!sessionId) { 625 return Response.json({ error: "Not authenticated" }, { status: 401 }); 626 } 627 const user = getUserBySession(sessionId); 628 if (!user) { 629 return Response.json({ error: "Invalid session" }, { status: 401 }); 630 } 631 const transcriptionId = req.params.id; 632 // Verify ownership 633 const transcription = db 634 .query<{ id: string; user_id: number; status: string }, [string]>( 635 "SELECT id, user_id, status FROM transcriptions WHERE id = ?", 636 ) 637 .get(transcriptionId); 638 if (!transcription || transcription.user_id !== user.id) { 639 return Response.json( 640 { error: "Transcription not found" }, 641 { status: 404 }, 642 ); 643 } 644 // Event-driven SSE stream with reconnection support 645 const stream = new ReadableStream({ 646 async start(controller) { 647 const encoder = new TextEncoder(); 648 let isClosed = false; 649 let lastEventId = Math.floor(Date.now() / 1000); 650 651 const sendEvent = (data: Partial<TranscriptionUpdate>) => { 652 if (isClosed) return; 653 try { 654 // Send event ID for reconnection support 655 lastEventId = Math.floor(Date.now() / 1000); 656 controller.enqueue( 657 encoder.encode( 658 `id: ${lastEventId}\nevent: update\ndata: ${JSON.stringify(data)}\n\n`, 659 ), 660 ); 661 } catch { 662 // Controller already closed (client disconnected) 663 isClosed = true; 664 } 665 }; 666 667 const sendHeartbeat = () => { 668 if (isClosed) return; 669 try { 670 controller.enqueue(encoder.encode(": heartbeat\n\n")); 671 } catch { 672 isClosed = true; 673 } 674 }; 675 // Send initial state from DB and file 676 const current = db 677 .query< 678 { 679 status: string; 680 progress: number; 681 }, 682 [string] 683 >("SELECT status, progress FROM transcriptions WHERE id = ?") 684 .get(transcriptionId); 685 if (current) { 686 sendEvent({ 687 status: current.status as TranscriptionUpdate["status"], 688 progress: current.progress, 689 }); 690 } 691 // If already complete, close immediately 692 if ( 693 current?.status === "completed" || 694 current?.status === "failed" 695 ) { 696 isClosed = true; 697 controller.close(); 698 return; 699 } 700 // Send heartbeats every 2.5 seconds to keep connection alive 701 const heartbeatInterval = setInterval(sendHeartbeat, 2500); 702 703 // Subscribe to EventEmitter for live updates 704 const updateHandler = (data: TranscriptionUpdate) => { 705 if (isClosed) return; 706 707 // Only send changed fields to save bandwidth 708 const payload: Partial<TranscriptionUpdate> = { 709 status: data.status, 710 progress: data.progress, 711 }; 712 713 if (data.transcript !== undefined) { 714 payload.transcript = data.transcript; 715 } 716 if (data.error_message !== undefined) { 717 payload.error_message = data.error_message; 718 } 719 720 sendEvent(payload); 721 722 // Close stream when done 723 if (data.status === "completed" || data.status === "failed") { 724 isClosed = true; 725 clearInterval(heartbeatInterval); 726 transcriptionEvents.off(transcriptionId, updateHandler); 727 controller.close(); 728 } 729 }; 730 transcriptionEvents.on(transcriptionId, updateHandler); 731 // Cleanup on client disconnect 732 return () => { 733 isClosed = true; 734 clearInterval(heartbeatInterval); 735 transcriptionEvents.off(transcriptionId, updateHandler); 736 }; 737 }, 738 }); 739 return new Response(stream, { 740 headers: { 741 "Content-Type": "text/event-stream", 742 "Cache-Control": "no-cache", 743 Connection: "keep-alive", 744 }, 745 }); 746 }, 747 }, 748 "/api/transcriptions/health": { 749 GET: async () => { 750 const isHealthy = await whisperService.checkHealth(); 751 return Response.json({ available: isHealthy }); 752 }, 753 }, 754 "/api/transcriptions/:id": { 755 GET: async (req) => { 756 try { 757 const user = requireAuth(req); 758 const transcriptionId = req.params.id; 759 760 // Verify ownership or admin 761 const transcription = db 762 .query< 763 { 764 id: string; 765 user_id: number; 766 filename: string; 767 original_filename: string; 768 status: string; 769 progress: number; 770 created_at: number; 771 }, 772 [string] 773 >( 774 "SELECT id, user_id, filename, original_filename, status, progress, created_at FROM transcriptions WHERE id = ?", 775 ) 776 .get(transcriptionId); 777 778 if (!transcription) { 779 return Response.json( 780 { error: "Transcription not found" }, 781 { status: 404 }, 782 ); 783 } 784 785 // Allow access if user owns it or is admin 786 if (transcription.user_id !== user.id && user.role !== "admin") { 787 return Response.json( 788 { error: "Transcription not found" }, 789 { status: 404 }, 790 ); 791 } 792 793 if (transcription.status !== "completed") { 794 return Response.json( 795 { error: "Transcription not completed yet" }, 796 { status: 400 }, 797 ); 798 } 799 800 // Get format from query parameter 801 const url = new URL(req.url); 802 const format = url.searchParams.get("format"); 803 804 // Return WebVTT format if requested 805 if (format === "vtt") { 806 const vttContent = await getTranscriptVTT(transcriptionId); 807 808 if (!vttContent) { 809 return Response.json( 810 { error: "VTT transcript not available" }, 811 { status: 404 }, 812 ); 813 } 814 815 return new Response(vttContent, { 816 headers: { 817 "Content-Type": "text/vtt", 818 "Content-Disposition": `attachment; filename="${transcription.original_filename}.vtt"`, 819 }, 820 }); 821 } 822 823 // return info on transcript 824 const transcript = { 825 id: transcription.id, 826 filename: transcription.original_filename, 827 status: transcription.status, 828 progress: transcription.progress, 829 created_at: transcription.created_at, 830 }; 831 return new Response(JSON.stringify(transcript), { 832 headers: { 833 "Content-Type": "application/json", 834 }, 835 }); 836 } catch (error) { 837 return handleError(error); 838 } 839 }, 840 }, 841 "/api/transcriptions/:id/audio": { 842 GET: async (req) => { 843 try { 844 const user = requireAuth(req); 845 const transcriptionId = req.params.id; 846 847 // Verify ownership or admin 848 const transcription = db 849 .query< 850 { 851 id: string; 852 user_id: number; 853 filename: string; 854 status: string; 855 }, 856 [string] 857 >( 858 "SELECT id, user_id, filename, status FROM transcriptions WHERE id = ?", 859 ) 860 .get(transcriptionId); 861 862 if (!transcription) { 863 return Response.json( 864 { error: "Transcription not found" }, 865 { status: 404 }, 866 ); 867 } 868 869 // Allow access if user owns it or is admin 870 if (transcription.user_id !== user.id && user.role !== "admin") { 871 return Response.json( 872 { error: "Transcription not found" }, 873 { status: 404 }, 874 ); 875 } 876 877 // For pending recordings, audio file exists even though transcription isn't complete 878 // Allow audio access for pending and completed statuses 879 if ( 880 transcription.status !== "completed" && 881 transcription.status !== "pending" 882 ) { 883 return Response.json( 884 { error: "Audio not available yet" }, 885 { status: 400 }, 886 ); 887 } 888 889 // Serve the audio file with range request support 890 const filePath = `./uploads/${transcription.filename}`; 891 const file = Bun.file(filePath); 892 893 if (!(await file.exists())) { 894 return Response.json( 895 { error: "Audio file not found" }, 896 { status: 404 }, 897 ); 898 } 899 900 const fileSize = file.size; 901 const range = req.headers.get("range"); 902 903 // Handle range requests for seeking 904 if (range) { 905 const parts = range.replace(/bytes=/, "").split("-"); 906 const start = Number.parseInt(parts[0] || "0", 10); 907 const end = parts[1] ? Number.parseInt(parts[1], 10) : fileSize - 1; 908 const chunkSize = end - start + 1; 909 910 const fileSlice = file.slice(start, end + 1); 911 912 return new Response(fileSlice, { 913 status: 206, 914 headers: { 915 "Content-Range": `bytes ${start}-${end}/${fileSize}`, 916 "Accept-Ranges": "bytes", 917 "Content-Length": chunkSize.toString(), 918 "Content-Type": file.type || "audio/mpeg", 919 }, 920 }); 921 } 922 923 // No range request, send entire file 924 return new Response(file, { 925 headers: { 926 "Content-Type": file.type || "audio/mpeg", 927 "Accept-Ranges": "bytes", 928 "Content-Length": fileSize.toString(), 929 }, 930 }); 931 } catch (error) { 932 return handleError(error); 933 } 934 }, 935 }, 936 "/api/transcriptions": { 937 GET: async (req) => { 938 try { 939 const user = requireAuth(req); 940 941 const transcriptions = db 942 .query< 943 { 944 id: string; 945 filename: string; 946 original_filename: string; 947 class_id: string | null; 948 status: string; 949 progress: number; 950 created_at: number; 951 }, 952 [number] 953 >( 954 "SELECT id, filename, original_filename, class_id, status, progress, created_at FROM transcriptions WHERE user_id = ? ORDER BY created_at DESC", 955 ) 956 .all(user.id); 957 958 // Load transcripts from files for completed jobs 959 const jobs = await Promise.all( 960 transcriptions.map(async (t) => { 961 return { 962 id: t.id, 963 filename: t.original_filename, 964 class_id: t.class_id, 965 status: t.status, 966 progress: t.progress, 967 created_at: t.created_at, 968 }; 969 }), 970 ); 971 972 return Response.json({ jobs }); 973 } catch (error) { 974 return handleError(error); 975 } 976 }, 977 POST: async (req) => { 978 try { 979 const user = requireAuth(req); 980 981 const formData = await req.formData(); 982 const file = formData.get("audio") as File; 983 const classId = formData.get("class_id") as string | null; 984 const meetingTimeId = formData.get("meeting_time_id") as string | null; 985 986 if (!file) throw ValidationErrors.missingField("audio"); 987 988 // If class_id provided, verify user is enrolled (or admin) 989 if (classId) { 990 const enrolled = isUserEnrolledInClass(user.id, classId); 991 if (!enrolled && user.role !== "admin") { 992 return Response.json( 993 { error: "Not enrolled in this class" }, 994 { status: 403 }, 995 ); 996 } 997 998 // Verify class exists 999 const classInfo = getClassById(classId); 1000 if (!classInfo) { 1001 return Response.json( 1002 { error: "Class not found" }, 1003 { status: 404 }, 1004 ); 1005 } 1006 1007 // Check if class is archived 1008 if (classInfo.archived) { 1009 return Response.json( 1010 { error: "Cannot upload to archived class" }, 1011 { status: 400 }, 1012 ); 1013 } 1014 } 1015 1016 // Validate file type 1017 const fileExtension = file.name.split(".").pop()?.toLowerCase(); 1018 const allowedExtensions = [ 1019 "mp3", 1020 "wav", 1021 "m4a", 1022 "aac", 1023 "ogg", 1024 "webm", 1025 "flac", 1026 "mp4", 1027 ]; 1028 const isAudioType = 1029 file.type.startsWith("audio/") || file.type === "video/mp4"; 1030 const isAudioExtension = 1031 fileExtension && allowedExtensions.includes(fileExtension); 1032 1033 if (!isAudioType && !isAudioExtension) { 1034 throw ValidationErrors.unsupportedFileType( 1035 "MP3, WAV, M4A, AAC, OGG, WebM, FLAC", 1036 ); 1037 } 1038 1039 if (file.size > MAX_FILE_SIZE) { 1040 throw ValidationErrors.fileTooLarge("100MB"); 1041 } 1042 1043 // Generate unique filename 1044 const transcriptionId = crypto.randomUUID(); 1045 const filename = `${transcriptionId}.${fileExtension}`; 1046 1047 // Save file to disk 1048 const uploadDir = "./uploads"; 1049 await Bun.write(`${uploadDir}/${filename}`, file); 1050 1051 // Create database record 1052 db.run( 1053 "INSERT INTO transcriptions (id, user_id, class_id, meeting_time_id, filename, original_filename, status) VALUES (?, ?, ?, ?, ?, ?, ?)", 1054 [ 1055 transcriptionId, 1056 user.id, 1057 classId, 1058 meetingTimeId, 1059 filename, 1060 file.name, 1061 "pending", 1062 ], 1063 ); 1064 1065 // Don't auto-start transcription - admin will select recordings 1066 // whisperService.startTranscription(transcriptionId, filename); 1067 1068 return Response.json({ 1069 id: transcriptionId, 1070 message: "Upload successful", 1071 }); 1072 } catch (error) { 1073 return handleError(error); 1074 } 1075 }, 1076 }, 1077 "/api/admin/transcriptions": { 1078 GET: async (req) => { 1079 try { 1080 requireAdmin(req); 1081 const transcriptions = getAllTranscriptions(); 1082 return Response.json(transcriptions); 1083 } catch (error) { 1084 return handleError(error); 1085 } 1086 }, 1087 }, 1088 "/api/admin/users": { 1089 GET: async (req) => { 1090 try { 1091 requireAdmin(req); 1092 const users = getAllUsersWithStats(); 1093 return Response.json(users); 1094 } catch (error) { 1095 return handleError(error); 1096 } 1097 }, 1098 }, 1099 "/api/admin/classes": { 1100 GET: async (req) => { 1101 try { 1102 requireAdmin(req); 1103 const classes = getClassesForUser(0, true); // Admin sees all classes 1104 return Response.json({ classes }); 1105 } catch (error) { 1106 return handleError(error); 1107 } 1108 }, 1109 }, 1110 "/api/admin/waitlist": { 1111 GET: async (req) => { 1112 try { 1113 requireAdmin(req); 1114 const waitlist = getAllWaitlistEntries(); 1115 return Response.json({ waitlist }); 1116 } catch (error) { 1117 return handleError(error); 1118 } 1119 }, 1120 }, 1121 "/api/admin/waitlist/:id": { 1122 DELETE: async (req) => { 1123 try { 1124 requireAdmin(req); 1125 const id = req.params.id; 1126 deleteWaitlistEntry(id); 1127 return Response.json({ success: true }); 1128 } catch (error) { 1129 return handleError(error); 1130 } 1131 }, 1132 }, 1133 "/api/admin/transcriptions/:id": { 1134 DELETE: async (req) => { 1135 try { 1136 requireAdmin(req); 1137 const transcriptionId = req.params.id; 1138 deleteTranscription(transcriptionId); 1139 return Response.json({ success: true }); 1140 } catch (error) { 1141 return handleError(error); 1142 } 1143 }, 1144 }, 1145 "/api/admin/users/:id": { 1146 DELETE: async (req) => { 1147 try { 1148 requireAdmin(req); 1149 const userId = Number.parseInt(req.params.id, 10); 1150 if (Number.isNaN(userId)) { 1151 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1152 } 1153 deleteUser(userId); 1154 return Response.json({ success: true }); 1155 } catch (error) { 1156 return handleError(error); 1157 } 1158 }, 1159 }, 1160 "/api/admin/users/:id/role": { 1161 PUT: async (req) => { 1162 try { 1163 requireAdmin(req); 1164 const userId = Number.parseInt(req.params.id, 10); 1165 if (Number.isNaN(userId)) { 1166 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1167 } 1168 1169 const body = await req.json(); 1170 const { role } = body as { role: UserRole }; 1171 1172 if (!role || (role !== "user" && role !== "admin")) { 1173 return Response.json( 1174 { error: "Invalid role. Must be 'user' or 'admin'" }, 1175 { status: 400 }, 1176 ); 1177 } 1178 1179 updateUserRole(userId, role); 1180 return Response.json({ success: true }); 1181 } catch (error) { 1182 return handleError(error); 1183 } 1184 }, 1185 }, 1186 "/api/admin/users/:id/details": { 1187 GET: async (req) => { 1188 try { 1189 requireAdmin(req); 1190 const userId = Number.parseInt(req.params.id, 10); 1191 if (Number.isNaN(userId)) { 1192 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1193 } 1194 1195 const user = db 1196 .query< 1197 { 1198 id: number; 1199 email: string; 1200 name: string | null; 1201 avatar: string; 1202 created_at: number; 1203 role: UserRole; 1204 password_hash: string | null; 1205 last_login: number | null; 1206 }, 1207 [number] 1208 >( 1209 "SELECT id, email, name, avatar, created_at, role, password_hash, last_login FROM users WHERE id = ?", 1210 ) 1211 .get(userId); 1212 1213 if (!user) { 1214 return Response.json({ error: "User not found" }, { status: 404 }); 1215 } 1216 1217 const passkeys = getPasskeysForUser(userId); 1218 const sessions = getSessionsForUser(userId); 1219 1220 // Get transcription count 1221 const transcriptionCount = 1222 db 1223 .query<{ count: number }, [number]>( 1224 "SELECT COUNT(*) as count FROM transcriptions WHERE user_id = ?", 1225 ) 1226 .get(userId)?.count ?? 0; 1227 1228 return Response.json({ 1229 id: user.id, 1230 email: user.email, 1231 name: user.name, 1232 avatar: user.avatar, 1233 created_at: user.created_at, 1234 role: user.role, 1235 last_login: user.last_login, 1236 hasPassword: !!user.password_hash, 1237 transcriptionCount, 1238 passkeys: passkeys.map((pk) => ({ 1239 id: pk.id, 1240 name: pk.name, 1241 created_at: pk.created_at, 1242 last_used_at: pk.last_used_at, 1243 })), 1244 sessions: sessions.map((s) => ({ 1245 id: s.id, 1246 ip_address: s.ip_address, 1247 user_agent: s.user_agent, 1248 created_at: s.created_at, 1249 expires_at: s.expires_at, 1250 })), 1251 }); 1252 } catch (error) { 1253 return handleError(error); 1254 } 1255 }, 1256 }, 1257 "/api/admin/users/:id/password": { 1258 PUT: async (req) => { 1259 try { 1260 requireAdmin(req); 1261 const userId = Number.parseInt(req.params.id, 10); 1262 if (Number.isNaN(userId)) { 1263 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1264 } 1265 1266 const body = await req.json(); 1267 const { password } = body as { password: string }; 1268 1269 if (!password || password.length < 8) { 1270 return Response.json( 1271 { error: "Password must be at least 8 characters" }, 1272 { status: 400 }, 1273 ); 1274 } 1275 1276 await updateUserPassword(userId, password); 1277 return Response.json({ success: true }); 1278 } catch (error) { 1279 return handleError(error); 1280 } 1281 }, 1282 }, 1283 "/api/admin/users/:id/passkeys/:passkeyId": { 1284 DELETE: async (req) => { 1285 try { 1286 requireAdmin(req); 1287 const userId = Number.parseInt(req.params.id, 10); 1288 if (Number.isNaN(userId)) { 1289 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1290 } 1291 1292 const { passkeyId } = req.params; 1293 deletePasskey(passkeyId, userId); 1294 return Response.json({ success: true }); 1295 } catch (error) { 1296 return handleError(error); 1297 } 1298 }, 1299 }, 1300 "/api/admin/users/:id/name": { 1301 PUT: async (req) => { 1302 try { 1303 requireAdmin(req); 1304 const userId = Number.parseInt(req.params.id, 10); 1305 if (Number.isNaN(userId)) { 1306 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1307 } 1308 1309 const body = await req.json(); 1310 const { name } = body as { name: string }; 1311 1312 if (!name || name.trim().length === 0) { 1313 return Response.json( 1314 { error: "Name cannot be empty" }, 1315 { status: 400 }, 1316 ); 1317 } 1318 1319 updateUserName(userId, name.trim()); 1320 return Response.json({ success: true }); 1321 } catch (error) { 1322 return handleError(error); 1323 } 1324 }, 1325 }, 1326 "/api/admin/users/:id/email": { 1327 PUT: async (req) => { 1328 try { 1329 requireAdmin(req); 1330 const userId = Number.parseInt(req.params.id, 10); 1331 if (Number.isNaN(userId)) { 1332 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1333 } 1334 1335 const body = await req.json(); 1336 const { email } = body as { email: string }; 1337 1338 if (!email || !email.includes("@")) { 1339 return Response.json( 1340 { error: "Invalid email address" }, 1341 { status: 400 }, 1342 ); 1343 } 1344 1345 // Check if email already exists 1346 const existing = db 1347 .query<{ id: number }, [string, number]>( 1348 "SELECT id FROM users WHERE email = ? AND id != ?", 1349 ) 1350 .get(email, userId); 1351 1352 if (existing) { 1353 return Response.json( 1354 { error: "Email already in use" }, 1355 { status: 400 }, 1356 ); 1357 } 1358 1359 updateUserEmailAddress(userId, email); 1360 return Response.json({ success: true }); 1361 } catch (error) { 1362 return handleError(error); 1363 } 1364 }, 1365 }, 1366 "/api/admin/users/:id/sessions": { 1367 GET: async (req) => { 1368 try { 1369 requireAdmin(req); 1370 const userId = Number.parseInt(req.params.id, 10); 1371 if (Number.isNaN(userId)) { 1372 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1373 } 1374 1375 const sessions = getSessionsForUser(userId); 1376 return Response.json(sessions); 1377 } catch (error) { 1378 return handleError(error); 1379 } 1380 }, 1381 DELETE: async (req) => { 1382 try { 1383 requireAdmin(req); 1384 const userId = Number.parseInt(req.params.id, 10); 1385 if (Number.isNaN(userId)) { 1386 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1387 } 1388 1389 deleteAllUserSessions(userId); 1390 return Response.json({ success: true }); 1391 } catch (error) { 1392 return handleError(error); 1393 } 1394 }, 1395 }, 1396 "/api/admin/users/:id/sessions/:sessionId": { 1397 DELETE: async (req) => { 1398 try { 1399 requireAdmin(req); 1400 const userId = Number.parseInt(req.params.id, 10); 1401 if (Number.isNaN(userId)) { 1402 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1403 } 1404 1405 const { sessionId } = req.params; 1406 const success = deleteSessionById(sessionId, userId); 1407 1408 if (!success) { 1409 return Response.json( 1410 { error: "Session not found" }, 1411 { status: 404 }, 1412 ); 1413 } 1414 1415 return Response.json({ success: true }); 1416 } catch (error) { 1417 return handleError(error); 1418 } 1419 }, 1420 }, 1421 "/api/admin/transcriptions/:id/details": { 1422 GET: async (req) => { 1423 try { 1424 requireAdmin(req); 1425 const transcriptionId = req.params.id; 1426 1427 const transcription = db 1428 .query< 1429 { 1430 id: string; 1431 original_filename: string; 1432 status: string; 1433 created_at: number; 1434 updated_at: number; 1435 error_message: string | null; 1436 user_id: number; 1437 }, 1438 [string] 1439 >( 1440 "SELECT id, original_filename, status, created_at, updated_at, error_message, user_id FROM transcriptions WHERE id = ?", 1441 ) 1442 .get(transcriptionId); 1443 1444 if (!transcription) { 1445 return Response.json( 1446 { error: "Transcription not found" }, 1447 { status: 404 }, 1448 ); 1449 } 1450 1451 const user = db 1452 .query<{ email: string; name: string | null }, [number]>( 1453 "SELECT email, name FROM users WHERE id = ?", 1454 ) 1455 .get(transcription.user_id); 1456 1457 return Response.json({ 1458 id: transcription.id, 1459 original_filename: transcription.original_filename, 1460 status: transcription.status, 1461 created_at: transcription.created_at, 1462 completed_at: transcription.updated_at, 1463 error_message: transcription.error_message, 1464 user_id: transcription.user_id, 1465 user_email: user?.email || "Unknown", 1466 user_name: user?.name || null, 1467 }); 1468 } catch (error) { 1469 return handleError(error); 1470 } 1471 }, 1472 }, 1473 "/api/classes": { 1474 GET: async (req) => { 1475 try { 1476 const user = requireAuth(req); 1477 const classes = getClassesForUser(user.id, user.role === "admin"); 1478 1479 // Group by semester/year 1480 const grouped: Record< 1481 string, 1482 Array<{ 1483 id: string; 1484 course_code: string; 1485 name: string; 1486 professor: string; 1487 semester: string; 1488 year: number; 1489 archived: boolean; 1490 }> 1491 > = {}; 1492 1493 for (const cls of classes) { 1494 const key = `${cls.semester} ${cls.year}`; 1495 if (!grouped[key]) { 1496 grouped[key] = []; 1497 } 1498 grouped[key]?.push({ 1499 id: cls.id, 1500 course_code: cls.course_code, 1501 name: cls.name, 1502 professor: cls.professor, 1503 semester: cls.semester, 1504 year: cls.year, 1505 archived: cls.archived, 1506 }); 1507 } 1508 1509 return Response.json({ classes: grouped }); 1510 } catch (error) { 1511 return handleError(error); 1512 } 1513 }, 1514 POST: async (req) => { 1515 try { 1516 requireAdmin(req); 1517 const body = await req.json(); 1518 const { 1519 course_code, 1520 name, 1521 professor, 1522 semester, 1523 year, 1524 meeting_times, 1525 } = body; 1526 1527 if (!course_code || !name || !professor || !semester || !year) { 1528 return Response.json( 1529 { error: "Missing required fields" }, 1530 { status: 400 }, 1531 ); 1532 } 1533 1534 const newClass = createClass({ 1535 course_code, 1536 name, 1537 professor, 1538 semester, 1539 year, 1540 meeting_times, 1541 }); 1542 1543 return Response.json(newClass); 1544 } catch (error) { 1545 return handleError(error); 1546 } 1547 }, 1548 }, 1549 "/api/classes/search": { 1550 GET: async (req) => { 1551 try { 1552 const user = requireAuth(req); 1553 const url = new URL(req.url); 1554 const query = url.searchParams.get("q"); 1555 1556 if (!query) { 1557 return Response.json({ classes: [] }); 1558 } 1559 1560 const classes = searchClassesByCourseCode(query); 1561 1562 // Get user's enrolled classes to mark them 1563 const enrolledClassIds = db 1564 .query<{ class_id: string }, [number]>( 1565 "SELECT class_id FROM class_members WHERE user_id = ?", 1566 ) 1567 .all(user.id) 1568 .map((row) => row.class_id); 1569 1570 // Add is_enrolled flag to each class 1571 const classesWithEnrollment = classes.map((cls) => ({ 1572 ...cls, 1573 is_enrolled: enrolledClassIds.includes(cls.id), 1574 })); 1575 1576 return Response.json({ classes: classesWithEnrollment }); 1577 } catch (error) { 1578 return handleError(error); 1579 } 1580 }, 1581 }, 1582 "/api/classes/join": { 1583 POST: async (req) => { 1584 try { 1585 const user = requireAuth(req); 1586 const body = await req.json(); 1587 const classId = body.class_id; 1588 1589 if (!classId || typeof classId !== "string") { 1590 return Response.json( 1591 { error: "Class ID required" }, 1592 { status: 400 }, 1593 ); 1594 } 1595 1596 const result = joinClass(classId, user.id); 1597 1598 if (!result.success) { 1599 return Response.json({ error: result.error }, { status: 400 }); 1600 } 1601 1602 return Response.json({ success: true }); 1603 } catch (error) { 1604 return handleError(error); 1605 } 1606 }, 1607 }, 1608 "/api/classes/waitlist": { 1609 POST: async (req) => { 1610 try { 1611 const user = requireAuth(req); 1612 const body = await req.json(); 1613 1614 const { 1615 courseCode, 1616 courseName, 1617 professor, 1618 semester, 1619 year, 1620 additionalInfo, 1621 meetingTimes, 1622 } = body; 1623 1624 if (!courseCode || !courseName || !professor || !semester || !year) { 1625 return Response.json( 1626 { error: "Missing required fields" }, 1627 { status: 400 }, 1628 ); 1629 } 1630 1631 const id = addToWaitlist( 1632 user.id, 1633 courseCode, 1634 courseName, 1635 professor, 1636 semester, 1637 Number.parseInt(year, 10), 1638 additionalInfo || null, 1639 meetingTimes || null, 1640 ); 1641 1642 return Response.json({ success: true, id }); 1643 } catch (error) { 1644 return handleError(error); 1645 } 1646 }, 1647 }, 1648 "/api/classes/:id": { 1649 GET: async (req) => { 1650 try { 1651 const user = requireAuth(req); 1652 const classId = req.params.id; 1653 1654 const classInfo = getClassById(classId); 1655 if (!classInfo) { 1656 return Response.json({ error: "Class not found" }, { status: 404 }); 1657 } 1658 1659 // Check enrollment or admin 1660 const isEnrolled = isUserEnrolledInClass(user.id, classId); 1661 if (!isEnrolled && user.role !== "admin") { 1662 return Response.json( 1663 { error: "Not enrolled in this class" }, 1664 { status: 403 }, 1665 ); 1666 } 1667 1668 const meetingTimes = getMeetingTimesForClass(classId); 1669 const transcriptions = getTranscriptionsForClass(classId); 1670 1671 return Response.json({ 1672 class: classInfo, 1673 meetingTimes, 1674 transcriptions, 1675 }); 1676 } catch (error) { 1677 return handleError(error); 1678 } 1679 }, 1680 DELETE: async (req) => { 1681 try { 1682 requireAdmin(req); 1683 const classId = req.params.id; 1684 1685 deleteClass(classId); 1686 return Response.json({ success: true }); 1687 } catch (error) { 1688 return handleError(error); 1689 } 1690 }, 1691 }, 1692 "/api/classes/:id/archive": { 1693 PUT: async (req) => { 1694 try { 1695 requireAdmin(req); 1696 const classId = req.params.id; 1697 const body = await req.json(); 1698 const { archived } = body; 1699 1700 if (typeof archived !== "boolean") { 1701 return Response.json( 1702 { error: "archived must be a boolean" }, 1703 { status: 400 }, 1704 ); 1705 } 1706 1707 toggleClassArchive(classId, archived); 1708 return Response.json({ success: true }); 1709 } catch (error) { 1710 return handleError(error); 1711 } 1712 }, 1713 }, 1714 "/api/classes/:id/members": { 1715 GET: async (req) => { 1716 try { 1717 requireAdmin(req); 1718 const classId = req.params.id; 1719 1720 const members = getClassMembers(classId); 1721 return Response.json({ members }); 1722 } catch (error) { 1723 return handleError(error); 1724 } 1725 }, 1726 POST: async (req) => { 1727 try { 1728 requireAdmin(req); 1729 const classId = req.params.id; 1730 const body = await req.json(); 1731 const { email } = body; 1732 1733 if (!email) { 1734 return Response.json({ error: "Email required" }, { status: 400 }); 1735 } 1736 1737 const user = getUserByEmail(email); 1738 if (!user) { 1739 return Response.json({ error: "User not found" }, { status: 404 }); 1740 } 1741 1742 enrollUserInClass(user.id, classId); 1743 return Response.json({ success: true }); 1744 } catch (error) { 1745 return handleError(error); 1746 } 1747 }, 1748 }, 1749 "/api/classes/:id/members/:userId": { 1750 DELETE: async (req) => { 1751 try { 1752 requireAdmin(req); 1753 const classId = req.params.id; 1754 const userId = Number.parseInt(req.params.userId, 10); 1755 1756 if (Number.isNaN(userId)) { 1757 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1758 } 1759 1760 removeUserFromClass(userId, classId); 1761 return Response.json({ success: true }); 1762 } catch (error) { 1763 return handleError(error); 1764 } 1765 }, 1766 }, 1767 "/api/classes/:id/meetings": { 1768 GET: async (req) => { 1769 try { 1770 const user = requireAuth(req); 1771 const classId = req.params.id; 1772 1773 // Check enrollment or admin 1774 const isEnrolled = isUserEnrolledInClass(user.id, classId); 1775 if (!isEnrolled && user.role !== "admin") { 1776 return Response.json( 1777 { error: "Not enrolled in this class" }, 1778 { status: 403 }, 1779 ); 1780 } 1781 1782 const meetingTimes = getMeetingTimesForClass(classId); 1783 return Response.json({ meetings: meetingTimes }); 1784 } catch (error) { 1785 return handleError(error); 1786 } 1787 }, 1788 POST: async (req) => { 1789 try { 1790 requireAdmin(req); 1791 const classId = req.params.id; 1792 const body = await req.json(); 1793 const { label } = body; 1794 1795 if (!label) { 1796 return Response.json({ error: "Label required" }, { status: 400 }); 1797 } 1798 1799 const meetingTime = createMeetingTime(classId, label); 1800 return Response.json(meetingTime); 1801 } catch (error) { 1802 return handleError(error); 1803 } 1804 }, 1805 }, 1806 "/api/meetings/:id": { 1807 PUT: async (req) => { 1808 try { 1809 requireAdmin(req); 1810 const meetingId = req.params.id; 1811 const body = await req.json(); 1812 const { label } = body; 1813 1814 if (!label) { 1815 return Response.json({ error: "Label required" }, { status: 400 }); 1816 } 1817 1818 updateMeetingTime(meetingId, label); 1819 return Response.json({ success: true }); 1820 } catch (error) { 1821 return handleError(error); 1822 } 1823 }, 1824 DELETE: async (req) => { 1825 try { 1826 requireAdmin(req); 1827 const meetingId = req.params.id; 1828 1829 deleteMeetingTime(meetingId); 1830 return Response.json({ success: true }); 1831 } catch (error) { 1832 return handleError(error); 1833 } 1834 }, 1835 }, 1836 "/api/transcripts/:id/select": { 1837 PUT: async (req) => { 1838 try { 1839 requireAdmin(req); 1840 const transcriptId = req.params.id; 1841 1842 // Update status to 'selected' and start transcription 1843 db.run("UPDATE transcriptions SET status = ? WHERE id = ?", [ 1844 "selected", 1845 transcriptId, 1846 ]); 1847 1848 // Get filename to start transcription 1849 const transcription = db 1850 .query<{ filename: string }, [string]>( 1851 "SELECT filename FROM transcriptions WHERE id = ?", 1852 ) 1853 .get(transcriptId); 1854 1855 if (transcription) { 1856 whisperService.startTranscription(transcriptId, transcription.filename); 1857 } 1858 1859 return Response.json({ success: true }); 1860 } catch (error) { 1861 return handleError(error); 1862 } 1863 }, 1864 }, 1865 }, 1866 development: { 1867 hmr: true, 1868 console: true, 1869 }, 1870}); 1871console.log(`馃 Thistle running at http://localhost:${server.port}`);