馃 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 removeUserFromClass, 41 toggleClassArchive, 42 updateMeetingTime, 43} from "./lib/classes"; 44import { handleError, ValidationErrors } from "./lib/errors"; 45import { requireAdmin, requireAuth } from "./lib/middleware"; 46import { 47 createAuthenticationOptions, 48 createRegistrationOptions, 49 deletePasskey, 50 getPasskeysForUser, 51 updatePasskeyName, 52 verifyAndAuthenticatePasskey, 53 verifyAndCreatePasskey, 54} from "./lib/passkey"; 55import { enforceRateLimit } from "./lib/rate-limit"; 56import { getTranscriptVTT } from "./lib/transcript-storage"; 57import { 58 MAX_FILE_SIZE, 59 TranscriptionEventEmitter, 60 type TranscriptionUpdate, 61 WhisperServiceManager, 62} from "./lib/transcription"; 63import adminHTML from "./pages/admin.html"; 64import classHTML from "./pages/class.html"; 65import classesHTML from "./pages/classes.html"; 66import indexHTML from "./pages/index.html"; 67import settingsHTML from "./pages/settings.html"; 68import transcribeHTML from "./pages/transcribe.html"; 69 70// Environment variables 71const WHISPER_SERVICE_URL = 72 process.env.WHISPER_SERVICE_URL || "http://localhost:8000"; 73 74// Create uploads and transcripts directories if they don't exist 75await Bun.write("./uploads/.gitkeep", ""); 76await Bun.write("./transcripts/.gitkeep", ""); 77 78// Initialize transcription system 79console.log( 80 `[Transcription] Connecting to Murmur at ${WHISPER_SERVICE_URL}...`, 81); 82const transcriptionEvents = new TranscriptionEventEmitter(); 83const whisperService = new WhisperServiceManager( 84 WHISPER_SERVICE_URL, 85 db, 86 transcriptionEvents, 87); 88 89// Clean up expired sessions every hour 90setInterval(cleanupExpiredSessions, 60 * 60 * 1000); 91 92// Sync with Whisper DB on startup 93try { 94 await whisperService.syncWithWhisper(); 95 console.log("[Transcription] Successfully connected to Murmur"); 96} catch (error) { 97 console.warn( 98 "[Transcription] Murmur unavailable at startup:", 99 error instanceof Error ? error.message : "Unknown error", 100 ); 101} 102 103// Periodic sync every 5 minutes as backup (SSE handles real-time updates) 104setInterval( 105 async () => { 106 try { 107 await whisperService.syncWithWhisper(); 108 } catch (error) { 109 console.warn( 110 "[Sync] Failed to sync with Murmur:", 111 error instanceof Error ? error.message : "Unknown error", 112 ); 113 } 114 }, 115 5 * 60 * 1000, 116); 117 118// Clean up stale files daily 119setInterval(() => whisperService.cleanupStaleFiles(), 24 * 60 * 60 * 1000); 120 121const server = Bun.serve({ 122 port: process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 3000, 123 idleTimeout: 120, // 120 seconds for SSE connections 124 routes: { 125 "/": indexHTML, 126 "/admin": adminHTML, 127 "/settings": settingsHTML, 128 "/transcribe": transcribeHTML, 129 "/classes": classesHTML, 130 "/class/:className": classHTML, 131 "/api/auth/register": { 132 POST: async (req) => { 133 try { 134 // Rate limiting 135 const rateLimitError = enforceRateLimit(req, "register", { 136 ip: { max: 5, windowSeconds: 60 * 60 }, 137 }); 138 if (rateLimitError) return rateLimitError; 139 140 const body = await req.json(); 141 const { email, password, name } = body; 142 if (!email || !password) { 143 return Response.json( 144 { error: "Email and password required" }, 145 { status: 400 }, 146 ); 147 } 148 // Password is client-side hashed (PBKDF2), should be 64 char hex 149 if (password.length !== 64 || !/^[0-9a-f]+$/.test(password)) { 150 return Response.json( 151 { error: "Invalid password format" }, 152 { status: 400 }, 153 ); 154 } 155 const user = await createUser(email, password, name); 156 const ipAddress = 157 req.headers.get("x-forwarded-for") ?? 158 req.headers.get("x-real-ip") ?? 159 "unknown"; 160 const userAgent = req.headers.get("user-agent") ?? "unknown"; 161 const sessionId = createSession(user.id, ipAddress, userAgent); 162 return Response.json( 163 { user: { id: user.id, email: user.email } }, 164 { 165 headers: { 166 "Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`, 167 }, 168 }, 169 ); 170 } catch (err: unknown) { 171 const error = err as { message?: string }; 172 if (error.message?.includes("UNIQUE constraint failed")) { 173 return Response.json( 174 { error: "Email already registered" }, 175 { status: 400 }, 176 ); 177 } 178 return Response.json( 179 { error: "Registration failed" }, 180 { status: 500 }, 181 ); 182 } 183 }, 184 }, 185 "/api/auth/login": { 186 POST: async (req) => { 187 try { 188 const body = await req.json(); 189 const { email, password } = body; 190 if (!email || !password) { 191 return Response.json( 192 { error: "Email and password required" }, 193 { status: 400 }, 194 ); 195 } 196 197 // Rate limiting: Per IP and per account 198 const rateLimitError = enforceRateLimit(req, "login", { 199 ip: { max: 10, windowSeconds: 15 * 60 }, 200 account: { max: 5, windowSeconds: 15 * 60, email }, 201 }); 202 if (rateLimitError) return rateLimitError; 203 204 // Password is client-side hashed (PBKDF2), should be 64 char hex 205 if (password.length !== 64 || !/^[0-9a-f]+$/.test(password)) { 206 return Response.json( 207 { error: "Invalid password format" }, 208 { status: 400 }, 209 ); 210 } 211 const user = await authenticateUser(email, password); 212 if (!user) { 213 return Response.json( 214 { error: "Invalid email or password" }, 215 { status: 401 }, 216 ); 217 } 218 const ipAddress = 219 req.headers.get("x-forwarded-for") ?? 220 req.headers.get("x-real-ip") ?? 221 "unknown"; 222 const userAgent = req.headers.get("user-agent") ?? "unknown"; 223 const sessionId = createSession(user.id, ipAddress, userAgent); 224 return Response.json( 225 { user: { id: user.id, email: user.email } }, 226 { 227 headers: { 228 "Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`, 229 }, 230 }, 231 ); 232 } catch { 233 return Response.json({ error: "Login failed" }, { status: 500 }); 234 } 235 }, 236 }, 237 "/api/auth/logout": { 238 POST: async (req) => { 239 const sessionId = getSessionFromRequest(req); 240 if (sessionId) { 241 deleteSession(sessionId); 242 } 243 return Response.json( 244 { success: true }, 245 { 246 headers: { 247 "Set-Cookie": 248 "session=; HttpOnly; Secure; Path=/; Max-Age=0; SameSite=Lax", 249 }, 250 }, 251 ); 252 }, 253 }, 254 "/api/auth/me": { 255 GET: (req) => { 256 const sessionId = getSessionFromRequest(req); 257 if (!sessionId) { 258 return Response.json({ error: "Not authenticated" }, { status: 401 }); 259 } 260 const user = getUserBySession(sessionId); 261 if (!user) { 262 return Response.json({ error: "Invalid session" }, { status: 401 }); 263 } 264 return Response.json({ 265 email: user.email, 266 name: user.name, 267 avatar: user.avatar, 268 created_at: user.created_at, 269 role: user.role, 270 }); 271 }, 272 }, 273 "/api/passkeys/register/options": { 274 POST: async (req) => { 275 try { 276 const user = requireAuth(req); 277 const options = await createRegistrationOptions(user); 278 return Response.json(options); 279 } catch (err) { 280 return handleError(err); 281 } 282 }, 283 }, 284 "/api/passkeys/register/verify": { 285 POST: async (req) => { 286 try { 287 const _user = requireAuth(req); 288 const body = await req.json(); 289 const { response: credentialResponse, challenge, name } = body; 290 291 const passkey = await verifyAndCreatePasskey( 292 credentialResponse, 293 challenge, 294 name, 295 ); 296 297 return Response.json({ 298 success: true, 299 passkey: { 300 id: passkey.id, 301 name: passkey.name, 302 created_at: passkey.created_at, 303 }, 304 }); 305 } catch (err) { 306 return handleError(err); 307 } 308 }, 309 }, 310 "/api/passkeys/authenticate/options": { 311 POST: async (req) => { 312 try { 313 const body = await req.json(); 314 const { email } = body; 315 316 const options = await createAuthenticationOptions(email); 317 return Response.json(options); 318 } catch (err) { 319 return handleError(err); 320 } 321 }, 322 }, 323 "/api/passkeys/authenticate/verify": { 324 POST: async (req) => { 325 try { 326 const body = await req.json(); 327 const { response: credentialResponse, challenge } = body; 328 329 const { user } = await verifyAndAuthenticatePasskey( 330 credentialResponse, 331 challenge, 332 ); 333 334 // Create session 335 const ipAddress = 336 req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || 337 req.headers.get("x-real-ip") || 338 "unknown"; 339 const userAgent = req.headers.get("user-agent") || "unknown"; 340 const sessionId = createSession(user.id, ipAddress, userAgent); 341 342 return Response.json( 343 { 344 email: user.email, 345 name: user.name, 346 avatar: user.avatar, 347 created_at: user.created_at, 348 role: user.role, 349 }, 350 { 351 headers: { 352 "Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`, 353 }, 354 }, 355 ); 356 } catch (err) { 357 return handleError(err); 358 } 359 }, 360 }, 361 "/api/passkeys": { 362 GET: async (req) => { 363 try { 364 const user = requireAuth(req); 365 const passkeys = getPasskeysForUser(user.id); 366 return Response.json({ 367 passkeys: passkeys.map((p) => ({ 368 id: p.id, 369 name: p.name, 370 created_at: p.created_at, 371 last_used_at: p.last_used_at, 372 })), 373 }); 374 } catch (err) { 375 return handleError(err); 376 } 377 }, 378 }, 379 "/api/passkeys/:id": { 380 PUT: async (req) => { 381 try { 382 const user = requireAuth(req); 383 const body = await req.json(); 384 const { name } = body; 385 const passkeyId = req.params.id; 386 387 if (!name) { 388 return Response.json({ error: "Name required" }, { status: 400 }); 389 } 390 391 updatePasskeyName(passkeyId, user.id, name); 392 return Response.json({ success: true }); 393 } catch (err) { 394 return handleError(err); 395 } 396 }, 397 DELETE: async (req) => { 398 try { 399 const user = requireAuth(req); 400 const passkeyId = req.params.id; 401 deletePasskey(passkeyId, user.id); 402 return Response.json({ success: true }); 403 } catch (err) { 404 return handleError(err); 405 } 406 }, 407 }, 408 "/api/sessions": { 409 GET: (req) => { 410 const sessionId = getSessionFromRequest(req); 411 if (!sessionId) { 412 return Response.json({ error: "Not authenticated" }, { status: 401 }); 413 } 414 const user = getUserBySession(sessionId); 415 if (!user) { 416 return Response.json({ error: "Invalid session" }, { status: 401 }); 417 } 418 const sessions = getUserSessionsForUser(user.id); 419 return Response.json({ 420 sessions: sessions.map((s) => ({ 421 id: s.id, 422 ip_address: s.ip_address, 423 user_agent: s.user_agent, 424 created_at: s.created_at, 425 expires_at: s.expires_at, 426 })), 427 }); 428 }, 429 DELETE: async (req) => { 430 const currentSessionId = getSessionFromRequest(req); 431 if (!currentSessionId) { 432 return Response.json({ error: "Not authenticated" }, { status: 401 }); 433 } 434 const user = getUserBySession(currentSessionId); 435 if (!user) { 436 return Response.json({ error: "Invalid session" }, { status: 401 }); 437 } 438 const body = await req.json(); 439 const targetSessionId = body.sessionId; 440 if (!targetSessionId) { 441 return Response.json( 442 { error: "Session ID required" }, 443 { status: 400 }, 444 ); 445 } 446 // Verify the session belongs to the user 447 const targetSession = getSession(targetSessionId); 448 if (!targetSession || targetSession.user_id !== user.id) { 449 return Response.json({ error: "Session not found" }, { status: 404 }); 450 } 451 deleteSession(targetSessionId); 452 return Response.json({ success: true }); 453 }, 454 }, 455 "/api/user": { 456 DELETE: (req) => { 457 const sessionId = getSessionFromRequest(req); 458 if (!sessionId) { 459 return Response.json({ error: "Not authenticated" }, { status: 401 }); 460 } 461 const user = getUserBySession(sessionId); 462 if (!user) { 463 return Response.json({ error: "Invalid session" }, { status: 401 }); 464 } 465 466 // Rate limiting 467 const rateLimitError = enforceRateLimit(req, "delete-user", { 468 ip: { max: 3, windowSeconds: 60 * 60 }, 469 }); 470 if (rateLimitError) return rateLimitError; 471 472 deleteUser(user.id); 473 return Response.json( 474 { success: true }, 475 { 476 headers: { 477 "Set-Cookie": 478 "session=; HttpOnly; Secure; Path=/; Max-Age=0; SameSite=Lax", 479 }, 480 }, 481 ); 482 }, 483 }, 484 "/api/user/email": { 485 PUT: async (req) => { 486 const sessionId = getSessionFromRequest(req); 487 if (!sessionId) { 488 return Response.json({ error: "Not authenticated" }, { status: 401 }); 489 } 490 const user = getUserBySession(sessionId); 491 if (!user) { 492 return Response.json({ error: "Invalid session" }, { status: 401 }); 493 } 494 495 // Rate limiting 496 const rateLimitError = enforceRateLimit(req, "update-email", { 497 ip: { max: 5, windowSeconds: 60 * 60 }, 498 }); 499 if (rateLimitError) return rateLimitError; 500 501 const body = await req.json(); 502 const { email } = body; 503 if (!email) { 504 return Response.json({ error: "Email required" }, { status: 400 }); 505 } 506 try { 507 updateUserEmail(user.id, email); 508 return Response.json({ success: true }); 509 } catch (err: unknown) { 510 const error = err as { message?: string }; 511 if (error.message?.includes("UNIQUE constraint failed")) { 512 return Response.json( 513 { error: "Email already in use" }, 514 { status: 400 }, 515 ); 516 } 517 return Response.json( 518 { error: "Failed to update email" }, 519 { status: 500 }, 520 ); 521 } 522 }, 523 }, 524 "/api/user/password": { 525 PUT: async (req) => { 526 const sessionId = getSessionFromRequest(req); 527 if (!sessionId) { 528 return Response.json({ error: "Not authenticated" }, { status: 401 }); 529 } 530 const user = getUserBySession(sessionId); 531 if (!user) { 532 return Response.json({ error: "Invalid session" }, { status: 401 }); 533 } 534 535 // Rate limiting 536 const rateLimitError = enforceRateLimit(req, "update-password", { 537 ip: { max: 5, windowSeconds: 60 * 60 }, 538 }); 539 if (rateLimitError) return rateLimitError; 540 541 const body = await req.json(); 542 const { password } = body; 543 if (!password) { 544 return Response.json({ error: "Password required" }, { status: 400 }); 545 } 546 // Password is client-side hashed (PBKDF2), should be 64 char hex 547 if (password.length !== 64 || !/^[0-9a-f]+$/.test(password)) { 548 return Response.json( 549 { error: "Invalid password format" }, 550 { status: 400 }, 551 ); 552 } 553 try { 554 await updateUserPassword(user.id, password); 555 return Response.json({ success: true }); 556 } catch { 557 return Response.json( 558 { error: "Failed to update password" }, 559 { status: 500 }, 560 ); 561 } 562 }, 563 }, 564 "/api/user/name": { 565 PUT: async (req) => { 566 const sessionId = getSessionFromRequest(req); 567 if (!sessionId) { 568 return Response.json({ error: "Not authenticated" }, { status: 401 }); 569 } 570 const user = getUserBySession(sessionId); 571 if (!user) { 572 return Response.json({ error: "Invalid session" }, { status: 401 }); 573 } 574 const body = await req.json(); 575 const { name } = body; 576 if (!name) { 577 return Response.json({ error: "Name required" }, { status: 400 }); 578 } 579 try { 580 updateUserName(user.id, name); 581 return Response.json({ success: true }); 582 } catch { 583 return Response.json( 584 { error: "Failed to update name" }, 585 { status: 500 }, 586 ); 587 } 588 }, 589 }, 590 "/api/user/avatar": { 591 PUT: async (req) => { 592 const sessionId = getSessionFromRequest(req); 593 if (!sessionId) { 594 return Response.json({ error: "Not authenticated" }, { status: 401 }); 595 } 596 const user = getUserBySession(sessionId); 597 if (!user) { 598 return Response.json({ error: "Invalid session" }, { status: 401 }); 599 } 600 const body = await req.json(); 601 const { avatar } = body; 602 if (!avatar) { 603 return Response.json({ error: "Avatar required" }, { status: 400 }); 604 } 605 try { 606 updateUserAvatar(user.id, avatar); 607 return Response.json({ success: true }); 608 } catch { 609 return Response.json( 610 { error: "Failed to update avatar" }, 611 { status: 500 }, 612 ); 613 } 614 }, 615 }, 616 "/api/transcriptions/:id/stream": { 617 GET: async (req) => { 618 const sessionId = getSessionFromRequest(req); 619 if (!sessionId) { 620 return Response.json({ error: "Not authenticated" }, { status: 401 }); 621 } 622 const user = getUserBySession(sessionId); 623 if (!user) { 624 return Response.json({ error: "Invalid session" }, { status: 401 }); 625 } 626 const transcriptionId = req.params.id; 627 // Verify ownership 628 const transcription = db 629 .query<{ id: string; user_id: number; status: string }, [string]>( 630 "SELECT id, user_id, status FROM transcriptions WHERE id = ?", 631 ) 632 .get(transcriptionId); 633 if (!transcription || transcription.user_id !== user.id) { 634 return Response.json( 635 { error: "Transcription not found" }, 636 { status: 404 }, 637 ); 638 } 639 // Event-driven SSE stream with reconnection support 640 const stream = new ReadableStream({ 641 async start(controller) { 642 const encoder = new TextEncoder(); 643 let isClosed = false; 644 let lastEventId = Math.floor(Date.now() / 1000); 645 646 const sendEvent = (data: Partial<TranscriptionUpdate>) => { 647 if (isClosed) return; 648 try { 649 // Send event ID for reconnection support 650 lastEventId = Math.floor(Date.now() / 1000); 651 controller.enqueue( 652 encoder.encode( 653 `id: ${lastEventId}\nevent: update\ndata: ${JSON.stringify(data)}\n\n`, 654 ), 655 ); 656 } catch { 657 // Controller already closed (client disconnected) 658 isClosed = true; 659 } 660 }; 661 662 const sendHeartbeat = () => { 663 if (isClosed) return; 664 try { 665 controller.enqueue(encoder.encode(": heartbeat\n\n")); 666 } catch { 667 isClosed = true; 668 } 669 }; 670 // Send initial state from DB and file 671 const current = db 672 .query< 673 { 674 status: string; 675 progress: number; 676 }, 677 [string] 678 >("SELECT status, progress FROM transcriptions WHERE id = ?") 679 .get(transcriptionId); 680 if (current) { 681 sendEvent({ 682 status: current.status as TranscriptionUpdate["status"], 683 progress: current.progress, 684 }); 685 } 686 // If already complete, close immediately 687 if ( 688 current?.status === "completed" || 689 current?.status === "failed" 690 ) { 691 isClosed = true; 692 controller.close(); 693 return; 694 } 695 // Send heartbeats every 2.5 seconds to keep connection alive 696 const heartbeatInterval = setInterval(sendHeartbeat, 2500); 697 698 // Subscribe to EventEmitter for live updates 699 const updateHandler = (data: TranscriptionUpdate) => { 700 if (isClosed) return; 701 702 // Only send changed fields to save bandwidth 703 const payload: Partial<TranscriptionUpdate> = { 704 status: data.status, 705 progress: data.progress, 706 }; 707 708 if (data.transcript !== undefined) { 709 payload.transcript = data.transcript; 710 } 711 if (data.error_message !== undefined) { 712 payload.error_message = data.error_message; 713 } 714 715 sendEvent(payload); 716 717 // Close stream when done 718 if (data.status === "completed" || data.status === "failed") { 719 isClosed = true; 720 clearInterval(heartbeatInterval); 721 transcriptionEvents.off(transcriptionId, updateHandler); 722 controller.close(); 723 } 724 }; 725 transcriptionEvents.on(transcriptionId, updateHandler); 726 // Cleanup on client disconnect 727 return () => { 728 isClosed = true; 729 clearInterval(heartbeatInterval); 730 transcriptionEvents.off(transcriptionId, updateHandler); 731 }; 732 }, 733 }); 734 return new Response(stream, { 735 headers: { 736 "Content-Type": "text/event-stream", 737 "Cache-Control": "no-cache", 738 Connection: "keep-alive", 739 }, 740 }); 741 }, 742 }, 743 "/api/transcriptions/health": { 744 GET: async () => { 745 const isHealthy = await whisperService.checkHealth(); 746 return Response.json({ available: isHealthy }); 747 }, 748 }, 749 "/api/transcriptions/:id": { 750 GET: async (req) => { 751 try { 752 const user = requireAuth(req); 753 const transcriptionId = req.params.id; 754 755 // Verify ownership or admin 756 const transcription = db 757 .query< 758 { 759 id: string; 760 user_id: number; 761 filename: string; 762 original_filename: string; 763 status: string; 764 progress: number; 765 created_at: number; 766 }, 767 [string] 768 >( 769 "SELECT id, user_id, filename, original_filename, status, progress, created_at FROM transcriptions WHERE id = ?", 770 ) 771 .get(transcriptionId); 772 773 if (!transcription) { 774 return Response.json( 775 { error: "Transcription not found" }, 776 { status: 404 }, 777 ); 778 } 779 780 // Allow access if user owns it or is admin 781 if (transcription.user_id !== user.id && user.role !== "admin") { 782 return Response.json( 783 { error: "Transcription not found" }, 784 { status: 404 }, 785 ); 786 } 787 788 if (transcription.status !== "completed") { 789 return Response.json( 790 { error: "Transcription not completed yet" }, 791 { status: 400 }, 792 ); 793 } 794 795 // Get format from query parameter 796 const url = new URL(req.url); 797 const format = url.searchParams.get("format"); 798 799 // Return WebVTT format if requested 800 if (format === "vtt") { 801 const vttContent = await getTranscriptVTT(transcriptionId); 802 803 if (!vttContent) { 804 return Response.json( 805 { error: "VTT transcript not available" }, 806 { status: 404 }, 807 ); 808 } 809 810 return new Response(vttContent, { 811 headers: { 812 "Content-Type": "text/vtt", 813 "Content-Disposition": `attachment; filename="${transcription.original_filename}.vtt"`, 814 }, 815 }); 816 } 817 818 // return info on transcript 819 const transcript = { 820 id: transcription.id, 821 filename: transcription.original_filename, 822 status: transcription.status, 823 progress: transcription.progress, 824 created_at: transcription.created_at, 825 }; 826 return new Response(JSON.stringify(transcript), { 827 headers: { 828 "Content-Type": "application/json", 829 }, 830 }); 831 } catch (error) { 832 return handleError(error); 833 } 834 }, 835 }, 836 "/api/transcriptions/:id/audio": { 837 GET: async (req) => { 838 try { 839 const user = requireAuth(req); 840 const transcriptionId = req.params.id; 841 842 // Verify ownership or admin 843 const transcription = db 844 .query< 845 { 846 id: string; 847 user_id: number; 848 filename: string; 849 status: string; 850 }, 851 [string] 852 >( 853 "SELECT id, user_id, filename, status FROM transcriptions WHERE id = ?", 854 ) 855 .get(transcriptionId); 856 857 if (!transcription) { 858 return Response.json( 859 { error: "Transcription not found" }, 860 { status: 404 }, 861 ); 862 } 863 864 // Allow access if user owns it or is admin 865 if (transcription.user_id !== user.id && user.role !== "admin") { 866 return Response.json( 867 { error: "Transcription not found" }, 868 { status: 404 }, 869 ); 870 } 871 872 if (transcription.status !== "completed") { 873 return Response.json( 874 { error: "Transcription not completed yet" }, 875 { status: 400 }, 876 ); 877 } 878 879 // Serve the audio file with range request support 880 const filePath = `./uploads/${transcription.filename}`; 881 const file = Bun.file(filePath); 882 883 if (!(await file.exists())) { 884 return Response.json( 885 { error: "Audio file not found" }, 886 { status: 404 }, 887 ); 888 } 889 890 const fileSize = file.size; 891 const range = req.headers.get("range"); 892 893 // Handle range requests for seeking 894 if (range) { 895 const parts = range.replace(/bytes=/, "").split("-"); 896 const start = Number.parseInt(parts[0] || "0", 10); 897 const end = parts[1] ? Number.parseInt(parts[1], 10) : fileSize - 1; 898 const chunkSize = end - start + 1; 899 900 const fileSlice = file.slice(start, end + 1); 901 902 return new Response(fileSlice, { 903 status: 206, 904 headers: { 905 "Content-Range": `bytes ${start}-${end}/${fileSize}`, 906 "Accept-Ranges": "bytes", 907 "Content-Length": chunkSize.toString(), 908 "Content-Type": file.type || "audio/mpeg", 909 }, 910 }); 911 } 912 913 // No range request, send entire file 914 return new Response(file, { 915 headers: { 916 "Content-Type": file.type || "audio/mpeg", 917 "Accept-Ranges": "bytes", 918 "Content-Length": fileSize.toString(), 919 }, 920 }); 921 } catch (error) { 922 return handleError(error); 923 } 924 }, 925 }, 926 "/api/transcriptions": { 927 GET: async (req) => { 928 try { 929 const user = requireAuth(req); 930 931 const transcriptions = db 932 .query< 933 { 934 id: string; 935 filename: string; 936 original_filename: string; 937 class_id: string | null; 938 status: string; 939 progress: number; 940 created_at: number; 941 }, 942 [number] 943 >( 944 "SELECT id, filename, original_filename, class_id, status, progress, created_at FROM transcriptions WHERE user_id = ? ORDER BY created_at DESC", 945 ) 946 .all(user.id); 947 948 // Load transcripts from files for completed jobs 949 const jobs = await Promise.all( 950 transcriptions.map(async (t) => { 951 return { 952 id: t.id, 953 filename: t.original_filename, 954 class_id: t.class_id, 955 status: t.status, 956 progress: t.progress, 957 created_at: t.created_at, 958 }; 959 }), 960 ); 961 962 return Response.json({ jobs }); 963 } catch (error) { 964 return handleError(error); 965 } 966 }, 967 POST: async (req) => { 968 try { 969 const user = requireAuth(req); 970 971 const formData = await req.formData(); 972 const file = formData.get("audio") as File; 973 const classId = formData.get("class_id") as string | null; 974 const meetingTimeId = formData.get("meeting_time_id") as string | null; 975 976 if (!file) throw ValidationErrors.missingField("audio"); 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 1006 // Validate file type 1007 const fileExtension = file.name.split(".").pop()?.toLowerCase(); 1008 const allowedExtensions = [ 1009 "mp3", 1010 "wav", 1011 "m4a", 1012 "aac", 1013 "ogg", 1014 "webm", 1015 "flac", 1016 "mp4", 1017 ]; 1018 const isAudioType = 1019 file.type.startsWith("audio/") || file.type === "video/mp4"; 1020 const isAudioExtension = 1021 fileExtension && allowedExtensions.includes(fileExtension); 1022 1023 if (!isAudioType && !isAudioExtension) { 1024 throw ValidationErrors.unsupportedFileType( 1025 "MP3, WAV, M4A, AAC, OGG, WebM, FLAC", 1026 ); 1027 } 1028 1029 if (file.size > MAX_FILE_SIZE) { 1030 throw ValidationErrors.fileTooLarge("100MB"); 1031 } 1032 1033 // Generate unique filename 1034 const transcriptionId = crypto.randomUUID(); 1035 const filename = `${transcriptionId}.${fileExtension}`; 1036 1037 // Save file to disk 1038 const uploadDir = "./uploads"; 1039 await Bun.write(`${uploadDir}/${filename}`, file); 1040 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 ); 1054 1055 // Don't auto-start transcription - admin will select recordings 1056 // whisperService.startTranscription(transcriptionId, filename); 1057 1058 return Response.json({ 1059 id: transcriptionId, 1060 message: "Upload successful", 1061 }); 1062 } catch (error) { 1063 return handleError(error); 1064 } 1065 }, 1066 }, 1067 "/api/admin/transcriptions": { 1068 GET: async (req) => { 1069 try { 1070 requireAdmin(req); 1071 const transcriptions = getAllTranscriptions(); 1072 return Response.json(transcriptions); 1073 } catch (error) { 1074 return handleError(error); 1075 } 1076 }, 1077 }, 1078 "/api/admin/users": { 1079 GET: async (req) => { 1080 try { 1081 requireAdmin(req); 1082 const users = getAllUsersWithStats(); 1083 return Response.json(users); 1084 } catch (error) { 1085 return handleError(error); 1086 } 1087 }, 1088 }, 1089 "/api/admin/transcriptions/:id": { 1090 DELETE: async (req) => { 1091 try { 1092 requireAdmin(req); 1093 const transcriptionId = req.params.id; 1094 deleteTranscription(transcriptionId); 1095 return Response.json({ success: true }); 1096 } catch (error) { 1097 return handleError(error); 1098 } 1099 }, 1100 }, 1101 "/api/admin/users/:id": { 1102 DELETE: async (req) => { 1103 try { 1104 requireAdmin(req); 1105 const userId = Number.parseInt(req.params.id, 10); 1106 if (Number.isNaN(userId)) { 1107 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1108 } 1109 deleteUser(userId); 1110 return Response.json({ success: true }); 1111 } catch (error) { 1112 return handleError(error); 1113 } 1114 }, 1115 }, 1116 "/api/admin/users/:id/role": { 1117 PUT: async (req) => { 1118 try { 1119 requireAdmin(req); 1120 const userId = Number.parseInt(req.params.id, 10); 1121 if (Number.isNaN(userId)) { 1122 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1123 } 1124 1125 const body = await req.json(); 1126 const { role } = body as { role: UserRole }; 1127 1128 if (!role || (role !== "user" && role !== "admin")) { 1129 return Response.json( 1130 { error: "Invalid role. Must be 'user' or 'admin'" }, 1131 { status: 400 }, 1132 ); 1133 } 1134 1135 updateUserRole(userId, role); 1136 return Response.json({ success: true }); 1137 } catch (error) { 1138 return handleError(error); 1139 } 1140 }, 1141 }, 1142 "/api/admin/users/:id/details": { 1143 GET: async (req) => { 1144 try { 1145 requireAdmin(req); 1146 const userId = Number.parseInt(req.params.id, 10); 1147 if (Number.isNaN(userId)) { 1148 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1149 } 1150 1151 const user = db 1152 .query< 1153 { 1154 id: number; 1155 email: string; 1156 name: string | null; 1157 avatar: string; 1158 created_at: number; 1159 role: UserRole; 1160 password_hash: string | null; 1161 last_login: number | null; 1162 }, 1163 [number] 1164 >( 1165 "SELECT id, email, name, avatar, created_at, role, password_hash, last_login FROM users WHERE id = ?", 1166 ) 1167 .get(userId); 1168 1169 if (!user) { 1170 return Response.json({ error: "User not found" }, { status: 404 }); 1171 } 1172 1173 const passkeys = getPasskeysForUser(userId); 1174 const sessions = getSessionsForUser(userId); 1175 1176 // Get transcription count 1177 const transcriptionCount = 1178 db 1179 .query<{ count: number }, [number]>( 1180 "SELECT COUNT(*) as count FROM transcriptions WHERE user_id = ?", 1181 ) 1182 .get(userId)?.count ?? 0; 1183 1184 return Response.json({ 1185 id: user.id, 1186 email: user.email, 1187 name: user.name, 1188 avatar: user.avatar, 1189 created_at: user.created_at, 1190 role: user.role, 1191 last_login: user.last_login, 1192 hasPassword: !!user.password_hash, 1193 transcriptionCount, 1194 passkeys: passkeys.map((pk) => ({ 1195 id: pk.id, 1196 name: pk.name, 1197 created_at: pk.created_at, 1198 last_used_at: pk.last_used_at, 1199 })), 1200 sessions: sessions.map((s) => ({ 1201 id: s.id, 1202 ip_address: s.ip_address, 1203 user_agent: s.user_agent, 1204 created_at: s.created_at, 1205 expires_at: s.expires_at, 1206 })), 1207 }); 1208 } catch (error) { 1209 return handleError(error); 1210 } 1211 }, 1212 }, 1213 "/api/admin/users/:id/password": { 1214 PUT: async (req) => { 1215 try { 1216 requireAdmin(req); 1217 const userId = Number.parseInt(req.params.id, 10); 1218 if (Number.isNaN(userId)) { 1219 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1220 } 1221 1222 const body = await req.json(); 1223 const { password } = body as { password: string }; 1224 1225 if (!password || password.length < 8) { 1226 return Response.json( 1227 { error: "Password must be at least 8 characters" }, 1228 { status: 400 }, 1229 ); 1230 } 1231 1232 await updateUserPassword(userId, password); 1233 return Response.json({ success: true }); 1234 } catch (error) { 1235 return handleError(error); 1236 } 1237 }, 1238 }, 1239 "/api/admin/users/:id/passkeys/:passkeyId": { 1240 DELETE: async (req) => { 1241 try { 1242 requireAdmin(req); 1243 const userId = Number.parseInt(req.params.id, 10); 1244 if (Number.isNaN(userId)) { 1245 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1246 } 1247 1248 const { passkeyId } = req.params; 1249 deletePasskey(passkeyId, userId); 1250 return Response.json({ success: true }); 1251 } catch (error) { 1252 return handleError(error); 1253 } 1254 }, 1255 }, 1256 "/api/admin/users/:id/name": { 1257 PUT: async (req) => { 1258 try { 1259 requireAdmin(req); 1260 const userId = Number.parseInt(req.params.id, 10); 1261 if (Number.isNaN(userId)) { 1262 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1263 } 1264 1265 const body = await req.json(); 1266 const { name } = body as { name: string }; 1267 1268 if (!name || name.trim().length === 0) { 1269 return Response.json( 1270 { error: "Name cannot be empty" }, 1271 { status: 400 }, 1272 ); 1273 } 1274 1275 updateUserName(userId, name.trim()); 1276 return Response.json({ success: true }); 1277 } catch (error) { 1278 return handleError(error); 1279 } 1280 }, 1281 }, 1282 "/api/admin/users/:id/email": { 1283 PUT: async (req) => { 1284 try { 1285 requireAdmin(req); 1286 const userId = Number.parseInt(req.params.id, 10); 1287 if (Number.isNaN(userId)) { 1288 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1289 } 1290 1291 const body = await req.json(); 1292 const { email } = body as { email: string }; 1293 1294 if (!email || !email.includes("@")) { 1295 return Response.json( 1296 { error: "Invalid email address" }, 1297 { status: 400 }, 1298 ); 1299 } 1300 1301 // Check if email already exists 1302 const existing = db 1303 .query<{ id: number }, [string, number]>( 1304 "SELECT id FROM users WHERE email = ? AND id != ?", 1305 ) 1306 .get(email, userId); 1307 1308 if (existing) { 1309 return Response.json( 1310 { error: "Email already in use" }, 1311 { status: 400 }, 1312 ); 1313 } 1314 1315 updateUserEmailAddress(userId, email); 1316 return Response.json({ success: true }); 1317 } catch (error) { 1318 return handleError(error); 1319 } 1320 }, 1321 }, 1322 "/api/admin/users/:id/sessions": { 1323 GET: async (req) => { 1324 try { 1325 requireAdmin(req); 1326 const userId = Number.parseInt(req.params.id, 10); 1327 if (Number.isNaN(userId)) { 1328 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1329 } 1330 1331 const sessions = getSessionsForUser(userId); 1332 return Response.json(sessions); 1333 } catch (error) { 1334 return handleError(error); 1335 } 1336 }, 1337 DELETE: async (req) => { 1338 try { 1339 requireAdmin(req); 1340 const userId = Number.parseInt(req.params.id, 10); 1341 if (Number.isNaN(userId)) { 1342 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1343 } 1344 1345 deleteAllUserSessions(userId); 1346 return Response.json({ success: true }); 1347 } catch (error) { 1348 return handleError(error); 1349 } 1350 }, 1351 }, 1352 "/api/admin/users/:id/sessions/:sessionId": { 1353 DELETE: async (req) => { 1354 try { 1355 requireAdmin(req); 1356 const userId = Number.parseInt(req.params.id, 10); 1357 if (Number.isNaN(userId)) { 1358 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1359 } 1360 1361 const { sessionId } = req.params; 1362 const success = deleteSessionById(sessionId, userId); 1363 1364 if (!success) { 1365 return Response.json( 1366 { error: "Session not found" }, 1367 { status: 404 }, 1368 ); 1369 } 1370 1371 return Response.json({ success: true }); 1372 } catch (error) { 1373 return handleError(error); 1374 } 1375 }, 1376 }, 1377 "/api/admin/transcriptions/:id/details": { 1378 GET: async (req) => { 1379 try { 1380 requireAdmin(req); 1381 const transcriptionId = req.params.id; 1382 1383 const transcription = db 1384 .query< 1385 { 1386 id: string; 1387 original_filename: string; 1388 status: string; 1389 created_at: number; 1390 updated_at: number; 1391 error_message: string | null; 1392 user_id: number; 1393 }, 1394 [string] 1395 >( 1396 "SELECT id, original_filename, status, created_at, updated_at, error_message, user_id FROM transcriptions WHERE id = ?", 1397 ) 1398 .get(transcriptionId); 1399 1400 if (!transcription) { 1401 return Response.json( 1402 { error: "Transcription not found" }, 1403 { status: 404 }, 1404 ); 1405 } 1406 1407 const user = db 1408 .query<{ email: string; name: string | null }, [number]>( 1409 "SELECT email, name FROM users WHERE id = ?", 1410 ) 1411 .get(transcription.user_id); 1412 1413 return Response.json({ 1414 id: transcription.id, 1415 original_filename: transcription.original_filename, 1416 status: transcription.status, 1417 created_at: transcription.created_at, 1418 completed_at: transcription.updated_at, 1419 error_message: transcription.error_message, 1420 user_id: transcription.user_id, 1421 user_email: user?.email || "Unknown", 1422 user_name: user?.name || null, 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 }); 1709 } catch (error) { 1710 return handleError(error); 1711 } 1712 }, 1713 }, 1714 }, 1715 development: { 1716 hmr: true, 1717 console: true, 1718 }, 1719}); 1720console.log(`馃 Thistle running at http://localhost:${server.port}`);