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