馃 distributed transcription service thistle.dunkirk.sh
1import db from "./db/schema"; 2import { 3 authenticateUser, 4 cleanupExpiredSessions, 5 consumeEmailChangeToken, 6 consumePasswordResetToken, 7 createEmailChangeToken, 8 createEmailVerificationToken, 9 createPasswordResetToken, 10 createSession, 11 createUser, 12 deleteAllUserSessions, 13 deleteSession, 14 deleteSessionById, 15 deleteTranscription, 16 deleteUser, 17 getAllTranscriptions, 18 getAllUsersWithStats, 19 getSession, 20 getSessionFromRequest, 21 getSessionsForUser, 22 getUserByEmail, 23 getUserBySession, 24 getUserSessionsForUser, 25 getVerificationCodeSentAt, 26 isEmailVerified, 27 type UserRole, 28 updateUserAvatar, 29 updateUserEmail, 30 updateUserEmailAddress, 31 updateUserName, 32 updateUserPassword, 33 updateUserRole, 34 verifyEmailChangeToken, 35 verifyEmailCode, 36 verifyEmailToken, 37 verifyPasswordResetToken, 38} from "./lib/auth"; 39import { 40 addToWaitlist, 41 createClass, 42 createMeetingTime, 43 deleteClass, 44 deleteMeetingTime, 45 deleteWaitlistEntry, 46 enrollUserInClass, 47 getAllWaitlistEntries, 48 getClassById, 49 getClassesForUser, 50 getClassMembers, 51 getClassSections, 52 getMeetingById, 53 getMeetingTimesForClass, 54 getTranscriptionsForClass, 55 getUserSection, 56 isUserEnrolledInClass, 57 joinClass, 58 removeUserFromClass, 59 searchClassesByCourseCode, 60 toggleClassArchive, 61 updateMeetingTime, 62 createClassSection, 63} from "./lib/classes"; 64import { sendEmail } from "./lib/email"; 65import { 66 emailChangeTemplate, 67 passwordResetTemplate, 68 verifyEmailTemplate, 69} from "./lib/email-templates"; 70import { AuthErrors, handleError, ValidationErrors } from "./lib/errors"; 71import { 72 hasActiveSubscription, 73 requireAdmin, 74 requireAuth, 75 requireSubscription, 76} from "./lib/middleware"; 77import { 78 createAuthenticationOptions, 79 createRegistrationOptions, 80 deletePasskey, 81 getPasskeysForUser, 82 updatePasskeyName, 83 verifyAndAuthenticatePasskey, 84 verifyAndCreatePasskey, 85} from "./lib/passkey"; 86import { clearRateLimit, enforceRateLimit } from "./lib/rate-limit"; 87import { getTranscriptVTT } from "./lib/transcript-storage"; 88import { 89 MAX_FILE_SIZE, 90 TranscriptionEventEmitter, 91 type TranscriptionUpdate, 92 WhisperServiceManager, 93} from "./lib/transcription"; 94import { 95 findMatchingMeetingTime, 96 getDayName, 97} from "./lib/audio-metadata"; 98import { 99 checkAutoSubmit, 100 deletePendingRecording, 101 getEnrolledUserCount, 102 getPendingRecordings, 103 getUserVoteForMeeting, 104 markAsAutoSubmitted, 105 removeVote, 106 voteForRecording, 107} from "./lib/voting"; 108import { 109 validateClassId, 110 validateCourseCode, 111 validateCourseName, 112 validateEmail, 113 validateName, 114 validatePasswordHash, 115 validateSemester, 116 validateYear, 117} from "./lib/validation"; 118import adminHTML from "./pages/admin.html"; 119import checkoutHTML from "./pages/checkout.html"; 120import classHTML from "./pages/class.html"; 121import classesHTML from "./pages/classes.html"; 122import indexHTML from "./pages/index.html"; 123import resetPasswordHTML from "./pages/reset-password.html"; 124import settingsHTML from "./pages/settings.html"; 125import transcribeHTML from "./pages/transcribe.html"; 126 127// Validate required environment variables at startup 128function validateEnvVars() { 129 const required = [ 130 "POLAR_ORGANIZATION_ID", 131 "POLAR_PRODUCT_ID", 132 "POLAR_SUCCESS_URL", 133 "POLAR_WEBHOOK_SECRET", 134 "MAILCHANNELS_API_KEY", 135 "DKIM_PRIVATE_KEY", 136 "LLM_API_KEY", 137 "LLM_API_BASE_URL", 138 "LLM_MODEL", 139 ]; 140 141 const missing = required.filter((key) => !process.env[key]); 142 143 if (missing.length > 0) { 144 console.error( 145 `[Startup] Missing required environment variables: ${missing.join(", ")}`, 146 ); 147 console.error("[Startup] Please check your .env file"); 148 process.exit(1); 149 } 150 151 // Validate ORIGIN is set for production 152 if (!process.env.ORIGIN) { 153 console.warn( 154 "[Startup] ORIGIN not set, defaulting to http://localhost:3000", 155 ); 156 console.warn("[Startup] Set ORIGIN in production for correct email links"); 157 } 158 159 console.log("[Startup] Environment variable validation passed"); 160} 161 162validateEnvVars(); 163 164// Environment variables 165const WHISPER_SERVICE_URL = 166 process.env.WHISPER_SERVICE_URL || "http://localhost:8000"; 167 168// Create uploads and transcripts directories if they don't exist 169await Bun.write("./uploads/.gitkeep", ""); 170await Bun.write("./transcripts/.gitkeep", ""); 171 172// Initialize transcription system 173console.log( 174 `[Transcription] Connecting to Murmur at ${WHISPER_SERVICE_URL}...`, 175); 176const transcriptionEvents = new TranscriptionEventEmitter(); 177const whisperService = new WhisperServiceManager( 178 WHISPER_SERVICE_URL, 179 db, 180 transcriptionEvents, 181); 182 183// Clean up expired sessions every 15 minutes 184const sessionCleanupInterval = setInterval( 185 cleanupExpiredSessions, 186 15 * 60 * 1000, 187); 188 189// Helper function to sync user subscriptions from Polar 190async function syncUserSubscriptionsFromPolar( 191 userId: number, 192 email: string, 193): Promise<void> { 194 // Skip Polar sync in test mode 195 if ( 196 process.env.NODE_ENV === "test" || 197 process.env.SKIP_POLAR_SYNC === "true" 198 ) { 199 return; 200 } 201 202 try { 203 const { polar } = await import("./lib/polar"); 204 205 // Search for customer by email (validated at startup) 206 const customers = await polar.customers.list({ 207 organizationId: process.env.POLAR_ORGANIZATION_ID as string, 208 query: email, 209 }); 210 211 if (!customers.result.items || customers.result.items.length === 0) { 212 console.log(`[Sync] No Polar customer found for ${email}`); 213 return; 214 } 215 216 const customer = customers.result.items[0]; 217 218 // Get all subscriptions for this customer 219 const subscriptions = await polar.subscriptions.list({ 220 customerId: customer.id, 221 }); 222 223 if ( 224 !subscriptions.result.items || 225 subscriptions.result.items.length === 0 226 ) { 227 console.log(`[Sync] No subscriptions found for customer ${customer.id}`); 228 return; 229 } 230 231 // Filter to only active/trialing/past_due subscriptions (not canceled/expired) 232 const currentSubscriptions = subscriptions.result.items.filter( 233 (sub) => 234 sub.status === "active" || 235 sub.status === "trialing" || 236 sub.status === "past_due", 237 ); 238 239 if (currentSubscriptions.length === 0) { 240 console.log( 241 `[Sync] No current subscriptions found for customer ${customer.id}`, 242 ); 243 return; 244 } 245 246 // Update each current subscription in the database 247 for (const subscription of currentSubscriptions) { 248 db.run( 249 `INSERT INTO subscriptions (id, user_id, customer_id, status, current_period_start, current_period_end, cancel_at_period_end, canceled_at, updated_at) 250 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) 251 ON CONFLICT(id) DO UPDATE SET 252 user_id = excluded.user_id, 253 status = excluded.status, 254 current_period_start = excluded.current_period_start, 255 current_period_end = excluded.current_period_end, 256 cancel_at_period_end = excluded.cancel_at_period_end, 257 canceled_at = excluded.canceled_at, 258 updated_at = excluded.updated_at`, 259 [ 260 subscription.id, 261 userId, 262 subscription.customerId, 263 subscription.status, 264 subscription.currentPeriodStart 265 ? Math.floor( 266 new Date(subscription.currentPeriodStart).getTime() / 1000, 267 ) 268 : null, 269 subscription.currentPeriodEnd 270 ? Math.floor( 271 new Date(subscription.currentPeriodEnd).getTime() / 1000, 272 ) 273 : null, 274 subscription.cancelAtPeriodEnd ? 1 : 0, 275 subscription.canceledAt 276 ? Math.floor(new Date(subscription.canceledAt).getTime() / 1000) 277 : null, 278 Math.floor(Date.now() / 1000), 279 ], 280 ); 281 } 282 283 console.log( 284 `[Sync] Linked ${currentSubscriptions.length} current subscription(s) to user ${userId} (${email})`, 285 ); 286 } catch (error) { 287 console.error( 288 `[Sync] Failed to sync subscriptions for ${email}:`, 289 error instanceof Error ? error.message : "Unknown error", 290 ); 291 // Don't throw - registration should succeed even if sync fails 292 } 293} 294 295// Sync with Whisper DB on startup 296try { 297 await whisperService.syncWithWhisper(); 298 console.log("[Transcription] Successfully connected to Murmur"); 299} catch (error) { 300 console.warn( 301 "[Transcription] Murmur unavailable at startup:", 302 error instanceof Error ? error.message : "Unknown error", 303 ); 304} 305 306// Periodic sync every 5 minutes as backup (SSE handles real-time updates) 307const syncInterval = setInterval( 308 async () => { 309 try { 310 await whisperService.syncWithWhisper(); 311 } catch (error) { 312 console.warn( 313 "[Sync] Failed to sync with Murmur:", 314 error instanceof Error ? error.message : "Unknown error", 315 ); 316 } 317 }, 318 5 * 60 * 1000, 319); 320 321// Clean up stale files hourly 322const fileCleanupInterval = setInterval( 323 () => whisperService.cleanupStaleFiles(), 324 60 * 60 * 1000, // 1 hour 325); 326 327const server = Bun.serve({ 328 port: 329 process.env.NODE_ENV === "test" 330 ? 3001 331 : process.env.PORT 332 ? Number.parseInt(process.env.PORT, 10) 333 : 3000, 334 idleTimeout: 120, // 120 seconds for SSE connections 335 routes: { 336 "/": indexHTML, 337 "/admin": adminHTML, 338 "/checkout": checkoutHTML, 339 "/settings": settingsHTML, 340 "/reset-password": resetPasswordHTML, 341 "/transcribe": transcribeHTML, 342 "/classes": classesHTML, 343 "/classes/*": classHTML, 344 "/apple-touch-icon.png": Bun.file("./public/favicon/apple-touch-icon.png"), 345 "/favicon-32x32.png": Bun.file("./public/favicon/favicon-32x32.png"), 346 "/favicon-16x16.png": Bun.file("./public/favicon/favicon-16x16.png"), 347 "/site.webmanifest": Bun.file("./public/favicon/site.webmanifest"), 348 "/favicon.ico": Bun.file("./public/favicon/favicon.ico"), 349 "/api/auth/register": { 350 POST: async (req) => { 351 try { 352 // Rate limiting 353 const rateLimitError = enforceRateLimit(req, "register", { 354 ip: { max: 5, windowSeconds: 30 * 60 }, 355 }); 356 if (rateLimitError) return rateLimitError; 357 358 const body = await req.json(); 359 const { email, password, name } = body; 360 if (!email || !password) { 361 return Response.json( 362 { error: "Email and password required" }, 363 { status: 400 }, 364 ); 365 } 366 // Validate password format (client-side hashed PBKDF2) 367 const passwordValidation = validatePasswordHash(password); 368 if (!passwordValidation.valid) { 369 return Response.json( 370 { error: passwordValidation.error }, 371 { status: 400 }, 372 ); 373 } 374 const user = await createUser(email, password, name); 375 376 // Send verification email - MUST succeed for registration to complete 377 const { code, token, sentAt } = createEmailVerificationToken(user.id); 378 379 try { 380 await sendEmail({ 381 to: user.email, 382 subject: "Verify your email - Thistle", 383 html: verifyEmailTemplate({ 384 name: user.name, 385 code, 386 token, 387 }), 388 }); 389 } catch (err) { 390 console.error("[Email] Failed to send verification email:", err); 391 // Rollback user creation - direct DB delete since user was just created 392 db.run("DELETE FROM email_verification_tokens WHERE user_id = ?", [ 393 user.id, 394 ]); 395 db.run("DELETE FROM sessions WHERE user_id = ?", [user.id]); 396 db.run("DELETE FROM users WHERE id = ?", [user.id]); 397 return Response.json( 398 { 399 error: 400 "Failed to send verification email. Please try again later.", 401 }, 402 { status: 500 }, 403 ); 404 } 405 406 // Attempt to sync existing Polar subscriptions (after email succeeds) 407 syncUserSubscriptionsFromPolar(user.id, user.email).catch(() => { 408 // Silent fail - don't block registration 409 }); 410 411 // Clear rate limits on successful registration 412 const ipAddress = 413 req.headers.get("x-forwarded-for") ?? 414 req.headers.get("x-real-ip") ?? 415 "unknown"; 416 clearRateLimit("register", email, ipAddress); 417 418 // Return success but indicate email verification is needed 419 // Don't create session yet - they need to verify first 420 return Response.json( 421 { 422 user: { id: user.id, email: user.email }, 423 email_verification_required: true, 424 verification_code_sent_at: sentAt, 425 }, 426 { status: 201 }, 427 ); 428 } catch (err: unknown) { 429 const error = err as { message?: string }; 430 if (error.message?.includes("UNIQUE constraint failed")) { 431 return Response.json( 432 { error: "Email already registered" }, 433 { status: 409 }, 434 ); 435 } 436 console.error("[Auth] Registration error:", err); 437 return Response.json( 438 { error: "Registration failed" }, 439 { status: 500 }, 440 ); 441 } 442 }, 443 }, 444 "/api/auth/login": { 445 POST: async (req) => { 446 try { 447 const body = await req.json(); 448 const { email, password } = body; 449 if (!email || !password) { 450 return Response.json( 451 { error: "Email and password required" }, 452 { status: 400 }, 453 ); 454 } 455 456 // Rate limiting: Per IP and per account 457 const rateLimitError = enforceRateLimit(req, "login", { 458 ip: { max: 10, windowSeconds: 5 * 60 }, 459 account: { max: 5, windowSeconds: 5 * 60, email }, 460 }); 461 if (rateLimitError) return rateLimitError; 462 463 // Validate password format (client-side hashed PBKDF2) 464 const passwordValidation = validatePasswordHash(password); 465 if (!passwordValidation.valid) { 466 return Response.json( 467 { error: passwordValidation.error }, 468 { status: 400 }, 469 ); 470 } 471 const user = await authenticateUser(email, password); 472 if (!user) { 473 return Response.json( 474 { error: "Invalid email or password" }, 475 { status: 401 }, 476 ); 477 } 478 479 // Clear rate limits on successful authentication 480 const ipAddress = 481 req.headers.get("x-forwarded-for") ?? 482 req.headers.get("x-real-ip") ?? 483 "unknown"; 484 clearRateLimit("login", email, ipAddress); 485 486 // Check if email is verified 487 if (!isEmailVerified(user.id)) { 488 let codeSentAt = getVerificationCodeSentAt(user.id); 489 490 // If no verification code exists, auto-send one 491 if (!codeSentAt) { 492 const { code, token, sentAt } = createEmailVerificationToken( 493 user.id, 494 ); 495 codeSentAt = sentAt; 496 497 try { 498 await sendEmail({ 499 to: user.email, 500 subject: "Verify your email - Thistle", 501 html: verifyEmailTemplate({ 502 name: user.name, 503 code, 504 token, 505 }), 506 }); 507 } catch (err) { 508 console.error( 509 "[Email] Failed to send verification email on login:", 510 err, 511 ); 512 // Don't fail login - just return null timestamp so client can try resend 513 codeSentAt = null; 514 } 515 } 516 517 return Response.json( 518 { 519 user: { id: user.id, email: user.email }, 520 email_verification_required: true, 521 verification_code_sent_at: codeSentAt, 522 }, 523 { status: 200 }, 524 ); 525 } 526 527 const userAgent = req.headers.get("user-agent") ?? "unknown"; 528 const sessionId = createSession(user.id, ipAddress, userAgent); 529 return Response.json( 530 { user: { id: user.id, email: user.email } }, 531 { 532 headers: { 533 "Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`, 534 }, 535 }, 536 ); 537 } catch (error) { 538 console.error("[Auth] Login error:", error); 539 return Response.json({ error: "Login failed" }, { status: 500 }); 540 } 541 }, 542 }, 543 "/api/auth/verify-email": { 544 GET: async (req) => { 545 try { 546 const url = new URL(req.url); 547 const token = url.searchParams.get("token"); 548 549 if (!token) { 550 return Response.redirect("/", 302); 551 } 552 553 const result = verifyEmailToken(token); 554 555 if (!result) { 556 return Response.redirect("/", 302); 557 } 558 559 // Create session for the verified user 560 const ipAddress = 561 req.headers.get("x-forwarded-for") ?? 562 req.headers.get("x-real-ip") ?? 563 "unknown"; 564 const userAgent = req.headers.get("user-agent") ?? "unknown"; 565 const sessionId = createSession(result.userId, ipAddress, userAgent); 566 567 // Redirect to classes with session cookie 568 return new Response(null, { 569 status: 302, 570 headers: { 571 Location: "/classes", 572 "Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`, 573 }, 574 }); 575 } catch (error) { 576 console.error("[Email] Verification error:", error); 577 return Response.redirect("/", 302); 578 } 579 }, 580 POST: async (req) => { 581 try { 582 const body = await req.json(); 583 const { email, code } = body; 584 585 if (!email || !code) { 586 return Response.json( 587 { error: "Email and verification code required" }, 588 { status: 400 }, 589 ); 590 } 591 592 // Get user by email 593 const user = getUserByEmail(email); 594 if (!user) { 595 return Response.json({ error: "User not found" }, { status: 404 }); 596 } 597 598 // Check if already verified 599 if (isEmailVerified(user.id)) { 600 return Response.json( 601 { error: "Email already verified" }, 602 { status: 400 }, 603 ); 604 } 605 606 const success = verifyEmailCode(user.id, code); 607 608 if (!success) { 609 return Response.json( 610 { error: "Invalid or expired verification code" }, 611 { status: 400 }, 612 ); 613 } 614 615 // Create session after successful verification 616 const ipAddress = 617 req.headers.get("x-forwarded-for") ?? 618 req.headers.get("x-real-ip") ?? 619 "unknown"; 620 const userAgent = req.headers.get("user-agent") ?? "unknown"; 621 const sessionId = createSession(user.id, ipAddress, userAgent); 622 623 return Response.json( 624 { 625 success: true, 626 message: "Email verified successfully", 627 email_verified: true, 628 user: { id: user.id, email: user.email }, 629 }, 630 { 631 headers: { 632 "Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`, 633 }, 634 }, 635 ); 636 } catch (error) { 637 return handleError(error); 638 } 639 }, 640 }, 641 "/api/auth/resend-verification": { 642 POST: async (req) => { 643 try { 644 const user = requireAuth(req); 645 646 // Rate limiting 647 const rateLimitError = enforceRateLimit(req, "resend-verification", { 648 account: { max: 3, windowSeconds: 60 * 60, email: user.email }, 649 }); 650 if (rateLimitError) return rateLimitError; 651 652 // Check if already verified 653 if (isEmailVerified(user.id)) { 654 return Response.json( 655 { error: "Email already verified" }, 656 { status: 400 }, 657 ); 658 } 659 660 // Generate new code and send email 661 const { code, token } = createEmailVerificationToken(user.id); 662 663 await sendEmail({ 664 to: user.email, 665 subject: "Verify your email - Thistle", 666 html: verifyEmailTemplate({ 667 name: user.name, 668 code, 669 token, 670 }), 671 }); 672 673 return Response.json({ 674 success: true, 675 message: "Verification email sent", 676 }); 677 } catch (error) { 678 return handleError(error); 679 } 680 }, 681 }, 682 "/api/auth/resend-verification-code": { 683 POST: async (req) => { 684 try { 685 const body = await req.json(); 686 const { email } = body; 687 688 if (!email) { 689 return Response.json({ error: "Email required" }, { status: 400 }); 690 } 691 692 // Rate limiting by email 693 const rateLimitError = enforceRateLimit( 694 req, 695 "resend-verification-code", 696 { 697 account: { max: 3, windowSeconds: 5 * 60, email }, 698 }, 699 ); 700 if (rateLimitError) return rateLimitError; 701 702 // Get user by email 703 const user = getUserByEmail(email); 704 if (!user) { 705 // Don't reveal if user exists 706 return Response.json({ 707 success: true, 708 message: 709 "If an account exists with that email, a verification code has been sent", 710 }); 711 } 712 713 // Check if already verified 714 if (isEmailVerified(user.id)) { 715 return Response.json( 716 { error: "Email already verified" }, 717 { status: 400 }, 718 ); 719 } 720 721 // Generate new code and send email 722 const { code, token, sentAt } = createEmailVerificationToken(user.id); 723 724 await sendEmail({ 725 to: user.email, 726 subject: "Verify your email - Thistle", 727 html: verifyEmailTemplate({ 728 name: user.name, 729 code, 730 token, 731 }), 732 }); 733 734 return Response.json({ 735 success: true, 736 message: "Verification code sent", 737 verification_code_sent_at: sentAt, 738 }); 739 } catch (error) { 740 return handleError(error); 741 } 742 }, 743 }, 744 "/api/auth/forgot-password": { 745 POST: async (req) => { 746 try { 747 // Rate limiting 748 const rateLimitError = enforceRateLimit(req, "forgot-password", { 749 ip: { max: 5, windowSeconds: 60 * 60 }, 750 }); 751 if (rateLimitError) return rateLimitError; 752 753 const body = await req.json(); 754 const { email } = body; 755 756 if (!email) { 757 return Response.json({ error: "Email required" }, { status: 400 }); 758 } 759 760 // Always return success to prevent email enumeration 761 const user = getUserByEmail(email); 762 if (user) { 763 const origin = process.env.ORIGIN || "http://localhost:3000"; 764 const resetToken = createPasswordResetToken(user.id); 765 const resetLink = `${origin}/reset-password?token=${resetToken}`; 766 767 await sendEmail({ 768 to: user.email, 769 subject: "Reset your password - Thistle", 770 html: passwordResetTemplate({ 771 name: user.name, 772 resetLink, 773 }), 774 }).catch((err) => { 775 console.error("[Email] Failed to send password reset:", err); 776 }); 777 } 778 779 return Response.json({ 780 success: true, 781 message: 782 "If an account exists with that email, a password reset link has been sent", 783 }); 784 } catch (error) { 785 console.error("[Email] Forgot password error:", error); 786 return Response.json( 787 { error: "Failed to process request" }, 788 { status: 500 }, 789 ); 790 } 791 }, 792 }, 793 "/api/auth/reset-password": { 794 GET: async (req) => { 795 try { 796 const url = new URL(req.url); 797 const token = url.searchParams.get("token"); 798 799 if (!token) { 800 return Response.json({ error: "Token required" }, { status: 400 }); 801 } 802 803 const userId = verifyPasswordResetToken(token); 804 if (!userId) { 805 return Response.json( 806 { error: "Invalid or expired reset token" }, 807 { status: 400 }, 808 ); 809 } 810 811 // Get user's email for client-side password hashing 812 const user = db 813 .query<{ email: string }, [number]>( 814 "SELECT email FROM users WHERE id = ?", 815 ) 816 .get(userId); 817 818 if (!user) { 819 return Response.json({ error: "User not found" }, { status: 404 }); 820 } 821 822 return Response.json({ email: user.email }); 823 } catch (error) { 824 console.error("[Email] Get reset token info error:", error); 825 return Response.json( 826 { error: "Failed to verify token" }, 827 { status: 500 }, 828 ); 829 } 830 }, 831 POST: async (req) => { 832 try { 833 const body = await req.json(); 834 const { token, password } = body; 835 836 if (!token || !password) { 837 return Response.json( 838 { error: "Token and password required" }, 839 { status: 400 }, 840 ); 841 } 842 843 // Validate password format (client-side hashed PBKDF2) 844 const passwordValidation = validatePasswordHash(password); 845 if (!passwordValidation.valid) { 846 return Response.json( 847 { error: passwordValidation.error }, 848 { status: 400 }, 849 ); 850 } 851 852 const userId = verifyPasswordResetToken(token); 853 if (!userId) { 854 return Response.json( 855 { error: "Invalid or expired reset token" }, 856 { status: 400 }, 857 ); 858 } 859 860 // Update password and consume token 861 await updateUserPassword(userId, password); 862 consumePasswordResetToken(token); 863 864 return Response.json({ 865 success: true, 866 message: "Password reset successfully", 867 }); 868 } catch (error) { 869 console.error("[Email] Reset password error:", error); 870 return Response.json( 871 { error: "Failed to reset password" }, 872 { status: 500 }, 873 ); 874 } 875 }, 876 }, 877 "/api/auth/logout": { 878 POST: async (req) => { 879 const sessionId = getSessionFromRequest(req); 880 if (sessionId) { 881 deleteSession(sessionId); 882 } 883 return Response.json( 884 { success: true }, 885 { 886 headers: { 887 "Set-Cookie": 888 "session=; HttpOnly; Secure; Path=/; Max-Age=0; SameSite=Lax", 889 }, 890 }, 891 ); 892 }, 893 }, 894 "/api/auth/me": { 895 GET: (req) => { 896 try { 897 const user = requireAuth(req); 898 899 // Check subscription status 900 const subscription = db 901 .query<{ status: string }, [number]>( 902 "SELECT status FROM subscriptions WHERE user_id = ? AND status IN ('active', 'trialing', 'past_due') ORDER BY created_at DESC LIMIT 1", 903 ) 904 .get(user.id); 905 906 // Get notification preferences 907 const prefs = db 908 .query<{ email_notifications_enabled: number }, [number]>( 909 "SELECT email_notifications_enabled FROM users WHERE id = ?", 910 ) 911 .get(user.id); 912 913 return Response.json({ 914 email: user.email, 915 name: user.name, 916 avatar: user.avatar, 917 created_at: user.created_at, 918 role: user.role, 919 has_subscription: !!subscription, 920 email_verified: isEmailVerified(user.id), 921 email_notifications_enabled: 922 prefs?.email_notifications_enabled === 1, 923 }); 924 } catch (err) { 925 return handleError(err); 926 } 927 }, 928 }, 929 "/api/passkeys/register/options": { 930 POST: async (req) => { 931 try { 932 const user = requireAuth(req); 933 934 const rateLimitError = enforceRateLimit( 935 req, 936 "passkey-register-options", 937 { 938 ip: { max: 10, windowSeconds: 5 * 60 }, 939 }, 940 ); 941 if (rateLimitError) return rateLimitError; 942 943 const options = await createRegistrationOptions(user); 944 return Response.json(options); 945 } catch (err) { 946 return handleError(err); 947 } 948 }, 949 }, 950 "/api/passkeys/register/verify": { 951 POST: async (req) => { 952 try { 953 const _user = requireAuth(req); 954 955 const rateLimitError = enforceRateLimit( 956 req, 957 "passkey-register-verify", 958 { 959 ip: { max: 10, windowSeconds: 5 * 60 }, 960 }, 961 ); 962 if (rateLimitError) return rateLimitError; 963 964 const body = await req.json(); 965 const { response: credentialResponse, challenge, name } = body; 966 967 const passkey = await verifyAndCreatePasskey( 968 credentialResponse, 969 challenge, 970 name, 971 ); 972 973 return Response.json({ 974 success: true, 975 passkey: { 976 id: passkey.id, 977 name: passkey.name, 978 created_at: passkey.created_at, 979 }, 980 }); 981 } catch (err) { 982 return handleError(err); 983 } 984 }, 985 }, 986 "/api/passkeys/authenticate/options": { 987 POST: async (req) => { 988 try { 989 const rateLimitError = enforceRateLimit(req, "passkey-auth-options", { 990 ip: { max: 10, windowSeconds: 5 * 60 }, 991 }); 992 if (rateLimitError) return rateLimitError; 993 994 const body = await req.json(); 995 const { email } = body; 996 997 const options = await createAuthenticationOptions(email); 998 return Response.json(options); 999 } catch (err) { 1000 return handleError(err); 1001 } 1002 }, 1003 }, 1004 "/api/passkeys/authenticate/verify": { 1005 POST: async (req) => { 1006 try { 1007 const rateLimitError = enforceRateLimit(req, "passkey-auth-verify", { 1008 ip: { max: 10, windowSeconds: 5 * 60 }, 1009 }); 1010 if (rateLimitError) return rateLimitError; 1011 1012 const body = await req.json(); 1013 const { response: credentialResponse, challenge } = body; 1014 1015 const result = await verifyAndAuthenticatePasskey( 1016 credentialResponse, 1017 challenge, 1018 ); 1019 1020 if ("error" in result) { 1021 return new Response(JSON.stringify({ error: result.error }), { 1022 status: 401, 1023 }); 1024 } 1025 1026 const { user } = result; 1027 1028 // Create session 1029 const ipAddress = 1030 req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || 1031 req.headers.get("x-real-ip") || 1032 "unknown"; 1033 const userAgent = req.headers.get("user-agent") || "unknown"; 1034 const sessionId = createSession(user.id, ipAddress, userAgent); 1035 1036 return Response.json( 1037 { 1038 email: user.email, 1039 name: user.name, 1040 avatar: user.avatar, 1041 created_at: user.created_at, 1042 role: user.role, 1043 }, 1044 { 1045 headers: { 1046 "Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`, 1047 }, 1048 }, 1049 ); 1050 } catch (err) { 1051 return handleError(err); 1052 } 1053 }, 1054 }, 1055 "/api/passkeys": { 1056 GET: async (req) => { 1057 try { 1058 const user = requireAuth(req); 1059 const passkeys = getPasskeysForUser(user.id); 1060 return Response.json({ 1061 passkeys: passkeys.map((p) => ({ 1062 id: p.id, 1063 name: p.name, 1064 created_at: p.created_at, 1065 last_used_at: p.last_used_at, 1066 })), 1067 }); 1068 } catch (err) { 1069 return handleError(err); 1070 } 1071 }, 1072 }, 1073 "/api/passkeys/:id": { 1074 PUT: async (req) => { 1075 try { 1076 const user = requireAuth(req); 1077 1078 const rateLimitError = enforceRateLimit(req, "passkey-update", { 1079 ip: { max: 10, windowSeconds: 60 * 60 }, 1080 }); 1081 if (rateLimitError) return rateLimitError; 1082 1083 const body = await req.json(); 1084 const { name } = body; 1085 const passkeyId = req.params.id; 1086 1087 if (!name) { 1088 return Response.json({ error: "Name required" }, { status: 400 }); 1089 } 1090 1091 updatePasskeyName(passkeyId, user.id, name); 1092 return new Response(null, { status: 204 }); 1093 } catch (err) { 1094 return handleError(err); 1095 } 1096 }, 1097 DELETE: async (req) => { 1098 try { 1099 const user = requireAuth(req); 1100 1101 const rateLimitError = enforceRateLimit(req, "passkey-delete", { 1102 ip: { max: 10, windowSeconds: 60 * 60 }, 1103 }); 1104 if (rateLimitError) return rateLimitError; 1105 1106 const passkeyId = req.params.id; 1107 deletePasskey(passkeyId, user.id); 1108 return new Response(null, { status: 204 }); 1109 } catch (err) { 1110 return handleError(err); 1111 } 1112 }, 1113 }, 1114 "/api/sessions": { 1115 GET: (req) => { 1116 try { 1117 const sessionId = getSessionFromRequest(req); 1118 if (!sessionId) { 1119 return Response.json( 1120 { error: "Not authenticated" }, 1121 { status: 401 }, 1122 ); 1123 } 1124 const user = getUserBySession(sessionId); 1125 if (!user) { 1126 return Response.json({ error: "Invalid session" }, { status: 401 }); 1127 } 1128 const sessions = getUserSessionsForUser(user.id); 1129 return Response.json({ 1130 sessions: sessions.map((s) => ({ 1131 id: s.id, 1132 ip_address: s.ip_address, 1133 user_agent: s.user_agent, 1134 created_at: s.created_at, 1135 expires_at: s.expires_at, 1136 is_current: s.id === sessionId, 1137 })), 1138 }); 1139 } catch (err) { 1140 return handleError(err); 1141 } 1142 }, 1143 DELETE: async (req) => { 1144 try { 1145 const currentSessionId = getSessionFromRequest(req); 1146 if (!currentSessionId) { 1147 return Response.json( 1148 { error: "Not authenticated" }, 1149 { status: 401 }, 1150 ); 1151 } 1152 const user = getUserBySession(currentSessionId); 1153 if (!user) { 1154 return Response.json({ error: "Invalid session" }, { status: 401 }); 1155 } 1156 1157 const rateLimitError = enforceRateLimit(req, "delete-session", { 1158 ip: { max: 20, windowSeconds: 60 * 60 }, 1159 }); 1160 if (rateLimitError) return rateLimitError; 1161 1162 const body = await req.json(); 1163 const targetSessionId = body.sessionId; 1164 if (!targetSessionId) { 1165 return Response.json( 1166 { error: "Session ID required" }, 1167 { status: 400 }, 1168 ); 1169 } 1170 // Prevent deleting current session 1171 if (targetSessionId === currentSessionId) { 1172 return Response.json( 1173 { error: "Cannot kill current session. Use logout instead." }, 1174 { status: 400 }, 1175 ); 1176 } 1177 // Verify the session belongs to the user 1178 const targetSession = getSession(targetSessionId); 1179 if (!targetSession || targetSession.user_id !== user.id) { 1180 return Response.json({ error: "Forbidden" }, { status: 403 }); 1181 } 1182 deleteSession(targetSessionId); 1183 return new Response(null, { status: 204 }); 1184 } catch (err) { 1185 return handleError(err); 1186 } 1187 }, 1188 }, 1189 "/api/user": { 1190 DELETE: async (req) => { 1191 try { 1192 const user = requireAuth(req); 1193 1194 // Rate limiting 1195 const rateLimitError = enforceRateLimit(req, "delete-user", { 1196 ip: { max: 3, windowSeconds: 60 * 60 }, 1197 }); 1198 if (rateLimitError) return rateLimitError; 1199 1200 await deleteUser(user.id); 1201 return new Response(null, { 1202 status: 204, 1203 headers: { 1204 "Set-Cookie": 1205 "session=; HttpOnly; Secure; Path=/; Max-Age=0; SameSite=Lax", 1206 }, 1207 }); 1208 } catch (err) { 1209 return handleError(err); 1210 } 1211 }, 1212 }, 1213 "/api/user/email": { 1214 PUT: async (req) => { 1215 try { 1216 const user = requireAuth(req); 1217 1218 // Rate limiting 1219 const rateLimitError = enforceRateLimit(req, "update-email", { 1220 ip: { max: 5, windowSeconds: 60 * 60 }, 1221 }); 1222 if (rateLimitError) return rateLimitError; 1223 1224 const body = await req.json(); 1225 const { email } = body; 1226 if (!email) { 1227 return Response.json({ error: "Email required" }, { status: 400 }); 1228 } 1229 1230 // Check if email is already in use 1231 const existingUser = getUserByEmail(email); 1232 if (existingUser) { 1233 return Response.json( 1234 { error: "Email already in use" }, 1235 { status: 409 }, 1236 ); 1237 } 1238 1239 try { 1240 // Create email change token 1241 const token = createEmailChangeToken(user.id, email); 1242 1243 // Send verification email to the CURRENT address 1244 const origin = process.env.ORIGIN || "http://localhost:3000"; 1245 const verifyUrl = `${origin}/api/user/email/verify?token=${token}`; 1246 1247 await sendEmail({ 1248 to: user.email, 1249 subject: "Verify your email change", 1250 html: emailChangeTemplate({ 1251 name: user.name, 1252 currentEmail: user.email, 1253 newEmail: email, 1254 verifyLink: verifyUrl, 1255 }), 1256 }); 1257 1258 return Response.json({ 1259 success: true, 1260 message: `Verification email sent to ${user.email}`, 1261 pendingEmail: email, 1262 }); 1263 } catch (error) { 1264 console.error( 1265 "[Email] Failed to send email change verification:", 1266 error, 1267 ); 1268 return Response.json( 1269 { error: "Failed to send verification email" }, 1270 { status: 500 }, 1271 ); 1272 } 1273 } catch (err) { 1274 return handleError(err); 1275 } 1276 }, 1277 }, 1278 "/api/user/email/verify": { 1279 GET: async (req) => { 1280 try { 1281 const url = new URL(req.url); 1282 const token = url.searchParams.get("token"); 1283 1284 if (!token) { 1285 return Response.redirect( 1286 "/settings?tab=account&error=invalid-token", 1287 302, 1288 ); 1289 } 1290 1291 const result = verifyEmailChangeToken(token); 1292 1293 if (!result) { 1294 return Response.redirect( 1295 "/settings?tab=account&error=expired-token", 1296 302, 1297 ); 1298 } 1299 1300 // Update the user's email 1301 updateUserEmail(result.userId, result.newEmail); 1302 1303 // Consume the token 1304 consumeEmailChangeToken(token); 1305 1306 // Redirect to settings with success message 1307 return Response.redirect( 1308 "/settings?tab=account&success=email-changed", 1309 302, 1310 ); 1311 } catch (error) { 1312 console.error("[Email] Email change verification error:", error); 1313 return Response.redirect( 1314 "/settings?tab=account&error=verification-failed", 1315 302, 1316 ); 1317 } 1318 }, 1319 }, 1320 "/api/user/password": { 1321 PUT: async (req) => { 1322 try { 1323 const user = requireAuth(req); 1324 1325 // Rate limiting 1326 const rateLimitError = enforceRateLimit(req, "update-password", { 1327 ip: { max: 5, windowSeconds: 60 * 60 }, 1328 }); 1329 if (rateLimitError) return rateLimitError; 1330 1331 const body = await req.json(); 1332 const { password } = body; 1333 if (!password) { 1334 return Response.json( 1335 { error: "Password required" }, 1336 { status: 400 }, 1337 ); 1338 } 1339 // Validate password format (client-side hashed PBKDF2) 1340 const passwordValidation = validatePasswordHash(password); 1341 if (!passwordValidation.valid) { 1342 return Response.json( 1343 { error: passwordValidation.error }, 1344 { status: 400 }, 1345 ); 1346 } 1347 try { 1348 await updateUserPassword(user.id, password); 1349 return Response.json({ success: true }); 1350 } catch { 1351 return Response.json( 1352 { error: "Failed to update password" }, 1353 { status: 500 }, 1354 ); 1355 } 1356 } catch (err) { 1357 return handleError(err); 1358 } 1359 }, 1360 }, 1361 "/api/user/name": { 1362 PUT: async (req) => { 1363 try { 1364 const user = requireAuth(req); 1365 1366 const rateLimitError = enforceRateLimit(req, "update-name", { 1367 ip: { max: 10, windowSeconds: 5 * 60 }, 1368 }); 1369 if (rateLimitError) return rateLimitError; 1370 1371 const body = await req.json(); 1372 const { name } = body; 1373 if (!name) { 1374 return Response.json({ error: "Name required" }, { status: 400 }); 1375 } 1376 try { 1377 updateUserName(user.id, name); 1378 return Response.json({ success: true }); 1379 } catch { 1380 return Response.json( 1381 { error: "Failed to update name" }, 1382 { status: 500 }, 1383 ); 1384 } 1385 } catch (err) { 1386 return handleError(err); 1387 } 1388 }, 1389 }, 1390 "/api/user/avatar": { 1391 PUT: async (req) => { 1392 try { 1393 const user = requireAuth(req); 1394 1395 const rateLimitError = enforceRateLimit(req, "update-avatar", { 1396 ip: { max: 10, windowSeconds: 5 * 60 }, 1397 }); 1398 if (rateLimitError) return rateLimitError; 1399 1400 const body = await req.json(); 1401 const { avatar } = body; 1402 if (!avatar) { 1403 return Response.json({ error: "Avatar required" }, { status: 400 }); 1404 } 1405 try { 1406 updateUserAvatar(user.id, avatar); 1407 return Response.json({ success: true }); 1408 } catch { 1409 return Response.json( 1410 { error: "Failed to update avatar" }, 1411 { status: 500 }, 1412 ); 1413 } 1414 } catch (err) { 1415 return handleError(err); 1416 } 1417 }, 1418 }, 1419 "/api/user/notifications": { 1420 PUT: async (req) => { 1421 try { 1422 const user = requireAuth(req); 1423 1424 const rateLimitError = enforceRateLimit(req, "update-notifications", { 1425 ip: { max: 10, windowSeconds: 5 * 60 }, 1426 }); 1427 if (rateLimitError) return rateLimitError; 1428 1429 const body = await req.json(); 1430 const { email_notifications_enabled } = body; 1431 if (typeof email_notifications_enabled !== "boolean") { 1432 return Response.json( 1433 { error: "email_notifications_enabled must be a boolean" }, 1434 { status: 400 }, 1435 ); 1436 } 1437 try { 1438 db.run( 1439 "UPDATE users SET email_notifications_enabled = ? WHERE id = ?", 1440 [email_notifications_enabled ? 1 : 0, user.id], 1441 ); 1442 return Response.json({ success: true }); 1443 } catch { 1444 return Response.json( 1445 { error: "Failed to update notification settings" }, 1446 { status: 500 }, 1447 ); 1448 } 1449 } catch (err) { 1450 return handleError(err); 1451 } 1452 }, 1453 }, 1454 "/api/billing/checkout": { 1455 POST: async (req) => { 1456 try { 1457 const user = requireAuth(req); 1458 1459 const { polar } = await import("./lib/polar"); 1460 1461 // Validated at startup 1462 const productId = process.env.POLAR_PRODUCT_ID as string; 1463 const successUrl = 1464 process.env.POLAR_SUCCESS_URL || "http://localhost:3000"; 1465 1466 const checkout = await polar.checkouts.create({ 1467 products: [productId], 1468 successUrl, 1469 customerEmail: user.email, 1470 customerName: user.name ?? undefined, 1471 metadata: { 1472 userId: user.id.toString(), 1473 }, 1474 }); 1475 1476 return Response.json({ url: checkout.url }); 1477 } catch (err) { 1478 return handleError(err); 1479 } 1480 }, 1481 }, 1482 "/api/billing/subscription": { 1483 GET: async (req) => { 1484 try { 1485 const user = requireAuth(req); 1486 1487 // Get subscription from database 1488 const subscription = db 1489 .query< 1490 { 1491 id: string; 1492 status: string; 1493 current_period_start: number | null; 1494 current_period_end: number | null; 1495 cancel_at_period_end: number; 1496 canceled_at: number | null; 1497 }, 1498 [number] 1499 >( 1500 "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", 1501 ) 1502 .get(user.id); 1503 1504 if (!subscription) { 1505 return Response.json({ subscription: null }); 1506 } 1507 1508 return Response.json({ subscription }); 1509 } catch (err) { 1510 return handleError(err); 1511 } 1512 }, 1513 }, 1514 "/api/billing/portal": { 1515 POST: async (req) => { 1516 try { 1517 const user = requireAuth(req); 1518 1519 const { polar } = await import("./lib/polar"); 1520 1521 // Get subscription to find customer ID 1522 const subscription = db 1523 .query< 1524 { 1525 customer_id: string; 1526 }, 1527 [number] 1528 >( 1529 "SELECT customer_id FROM subscriptions WHERE user_id = ? ORDER BY created_at DESC LIMIT 1", 1530 ) 1531 .get(user.id); 1532 1533 if (!subscription || !subscription.customer_id) { 1534 return Response.json( 1535 { error: "No subscription found" }, 1536 { status: 404 }, 1537 ); 1538 } 1539 1540 // Create customer portal session 1541 const session = await polar.customerSessions.create({ 1542 customerId: subscription.customer_id, 1543 }); 1544 1545 return Response.json({ url: session.customerPortalUrl }); 1546 } catch (err) { 1547 return handleError(err); 1548 } 1549 }, 1550 }, 1551 "/api/webhooks/polar": { 1552 POST: async (req) => { 1553 const { validateEvent } = await import("@polar-sh/sdk/webhooks"); 1554 1555 // Get raw body as string 1556 const rawBody = await req.text(); 1557 const headers = Object.fromEntries(req.headers.entries()); 1558 1559 // Validate webhook signature (validated at startup) 1560 const webhookSecret = process.env.POLAR_WEBHOOK_SECRET as string; 1561 let event: ReturnType<typeof validateEvent>; 1562 try { 1563 event = validateEvent(rawBody, headers, webhookSecret); 1564 } catch (error) { 1565 // Validation failed - log but return generic response 1566 console.error("[Webhook] Signature validation failed:", error); 1567 return Response.json({ error: "Invalid webhook" }, { status: 400 }); 1568 } 1569 1570 console.log(`[Webhook] Received event: ${event.type}`); 1571 1572 // Handle different event types 1573 try { 1574 switch (event.type) { 1575 case "subscription.updated": { 1576 const { id, status, customerId, metadata } = event.data; 1577 const userId = metadata?.userId 1578 ? Number.parseInt(metadata.userId as string, 10) 1579 : null; 1580 1581 if (!userId) { 1582 console.warn("[Webhook] No userId in subscription metadata"); 1583 break; 1584 } 1585 1586 // Upsert subscription 1587 db.run( 1588 `INSERT INTO subscriptions (id, user_id, customer_id, status, current_period_start, current_period_end, cancel_at_period_end, canceled_at, updated_at) 1589 VALUES (?, ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now')) 1590 ON CONFLICT(id) DO UPDATE SET 1591 status = excluded.status, 1592 current_period_start = excluded.current_period_start, 1593 current_period_end = excluded.current_period_end, 1594 cancel_at_period_end = excluded.cancel_at_period_end, 1595 canceled_at = excluded.canceled_at, 1596 updated_at = strftime('%s', 'now')`, 1597 [ 1598 id, 1599 userId, 1600 customerId, 1601 status, 1602 event.data.currentPeriodStart 1603 ? Math.floor( 1604 new Date(event.data.currentPeriodStart).getTime() / 1605 1000, 1606 ) 1607 : null, 1608 event.data.currentPeriodEnd 1609 ? Math.floor( 1610 new Date(event.data.currentPeriodEnd).getTime() / 1000, 1611 ) 1612 : null, 1613 event.data.cancelAtPeriodEnd ? 1 : 0, 1614 event.data.canceledAt 1615 ? Math.floor( 1616 new Date(event.data.canceledAt).getTime() / 1000, 1617 ) 1618 : null, 1619 ], 1620 ); 1621 1622 console.log( 1623 `[Webhook] Updated subscription ${id} for user ${userId}`, 1624 ); 1625 break; 1626 } 1627 1628 default: 1629 console.log(`[Webhook] Unhandled event type: ${event.type}`); 1630 } 1631 1632 return Response.json({ received: true }); 1633 } catch (error) { 1634 // Processing failed - log with detail but return generic response 1635 console.error("[Webhook] Event processing failed:", error); 1636 return Response.json({ error: "Invalid webhook" }, { status: 400 }); 1637 } 1638 }, 1639 }, 1640 "/api/transcriptions/:id/stream": { 1641 GET: async (req) => { 1642 try { 1643 const user = requireAuth(req); 1644 const transcriptionId = req.params.id; 1645 // Verify ownership 1646 const transcription = db 1647 .query< 1648 { 1649 id: string; 1650 user_id: number; 1651 class_id: string | null; 1652 status: string; 1653 }, 1654 [string] 1655 >( 1656 "SELECT id, user_id, class_id, status FROM transcriptions WHERE id = ?", 1657 ) 1658 .get(transcriptionId); 1659 1660 if (!transcription) { 1661 return Response.json( 1662 { error: "Transcription not found" }, 1663 { status: 404 }, 1664 ); 1665 } 1666 1667 // Check access permissions 1668 const isOwner = transcription.user_id === user.id; 1669 const isAdmin = user.role === "admin"; 1670 let isClassMember = false; 1671 1672 // If transcription belongs to a class, check enrollment 1673 if (transcription.class_id) { 1674 isClassMember = isUserEnrolledInClass( 1675 user.id, 1676 transcription.class_id, 1677 ); 1678 } 1679 1680 // Allow access if: owner, admin, or enrolled in the class 1681 if (!isOwner && !isAdmin && !isClassMember) { 1682 return Response.json({ error: "Forbidden" }, { status: 403 }); 1683 } 1684 1685 // Require subscription only if accessing own transcription (not class) 1686 if ( 1687 isOwner && 1688 !transcription.class_id && 1689 !isAdmin && 1690 !hasActiveSubscription(user.id) 1691 ) { 1692 throw AuthErrors.subscriptionRequired(); 1693 } 1694 // Event-driven SSE stream with reconnection support 1695 const stream = new ReadableStream({ 1696 async start(controller) { 1697 // Track this stream for graceful shutdown 1698 activeSSEStreams.add(controller); 1699 1700 const encoder = new TextEncoder(); 1701 let isClosed = false; 1702 let lastEventId = Math.floor(Date.now() / 1000); 1703 1704 const sendEvent = (data: Partial<TranscriptionUpdate>) => { 1705 if (isClosed) return; 1706 try { 1707 // Send event ID for reconnection support 1708 lastEventId = Math.floor(Date.now() / 1000); 1709 controller.enqueue( 1710 encoder.encode( 1711 `id: ${lastEventId}\nevent: update\ndata: ${JSON.stringify(data)}\n\n`, 1712 ), 1713 ); 1714 } catch { 1715 // Controller already closed (client disconnected) 1716 isClosed = true; 1717 } 1718 }; 1719 1720 const sendHeartbeat = () => { 1721 if (isClosed) return; 1722 try { 1723 controller.enqueue(encoder.encode(": heartbeat\n\n")); 1724 } catch { 1725 isClosed = true; 1726 } 1727 }; 1728 // Send initial state from DB and file 1729 const current = db 1730 .query< 1731 { 1732 status: string; 1733 progress: number; 1734 }, 1735 [string] 1736 >("SELECT status, progress FROM transcriptions WHERE id = ?") 1737 .get(transcriptionId); 1738 if (current) { 1739 sendEvent({ 1740 status: current.status as TranscriptionUpdate["status"], 1741 progress: current.progress, 1742 }); 1743 } 1744 // If already complete, close immediately 1745 if ( 1746 current?.status === "completed" || 1747 current?.status === "failed" 1748 ) { 1749 isClosed = true; 1750 activeSSEStreams.delete(controller); 1751 controller.close(); 1752 return; 1753 } 1754 // Send heartbeats every 2.5 seconds to keep connection alive 1755 const heartbeatInterval = setInterval(sendHeartbeat, 2500); 1756 1757 // Subscribe to EventEmitter for live updates 1758 const updateHandler = (data: TranscriptionUpdate) => { 1759 if (isClosed) return; 1760 1761 // Only send changed fields to save bandwidth 1762 const payload: Partial<TranscriptionUpdate> = { 1763 status: data.status, 1764 progress: data.progress, 1765 }; 1766 1767 if (data.transcript !== undefined) { 1768 payload.transcript = data.transcript; 1769 } 1770 if (data.error_message !== undefined) { 1771 payload.error_message = data.error_message; 1772 } 1773 1774 sendEvent(payload); 1775 1776 // Close stream when done 1777 if (data.status === "completed" || data.status === "failed") { 1778 isClosed = true; 1779 clearInterval(heartbeatInterval); 1780 transcriptionEvents.off(transcriptionId, updateHandler); 1781 activeSSEStreams.delete(controller); 1782 controller.close(); 1783 } 1784 }; 1785 transcriptionEvents.on(transcriptionId, updateHandler); 1786 // Cleanup on client disconnect 1787 return () => { 1788 isClosed = true; 1789 clearInterval(heartbeatInterval); 1790 transcriptionEvents.off(transcriptionId, updateHandler); 1791 activeSSEStreams.delete(controller); 1792 }; 1793 }, 1794 }); 1795 return new Response(stream, { 1796 headers: { 1797 "Content-Type": "text/event-stream", 1798 "Cache-Control": "no-cache", 1799 Connection: "keep-alive", 1800 }, 1801 }); 1802 } catch (error) { 1803 return handleError(error); 1804 } 1805 }, 1806 }, 1807 "/api/health": { 1808 GET: async () => { 1809 const health = { 1810 status: "healthy", 1811 timestamp: new Date().toISOString(), 1812 services: { 1813 database: false, 1814 whisper: false, 1815 storage: false, 1816 }, 1817 details: {} as Record<string, unknown>, 1818 }; 1819 1820 // Check database 1821 try { 1822 db.query("SELECT 1").get(); 1823 health.services.database = true; 1824 } catch (error) { 1825 health.status = "unhealthy"; 1826 health.details.databaseError = 1827 error instanceof Error ? error.message : String(error); 1828 } 1829 1830 // Check Whisper service 1831 try { 1832 const whisperHealthy = await whisperService.checkHealth(); 1833 health.services.whisper = whisperHealthy; 1834 if (!whisperHealthy) { 1835 health.status = "degraded"; 1836 health.details.whisperNote = "Whisper service unavailable"; 1837 } 1838 } catch (error) { 1839 health.status = "degraded"; 1840 health.details.whisperError = 1841 error instanceof Error ? error.message : String(error); 1842 } 1843 1844 // Check storage (uploads and transcripts directories) 1845 try { 1846 const fs = await import("node:fs/promises"); 1847 const uploadsExists = await fs 1848 .access("./uploads") 1849 .then(() => true) 1850 .catch(() => false); 1851 const transcriptsExists = await fs 1852 .access("./transcripts") 1853 .then(() => true) 1854 .catch(() => false); 1855 health.services.storage = uploadsExists && transcriptsExists; 1856 if (!health.services.storage) { 1857 health.status = "unhealthy"; 1858 health.details.storageNote = `Missing directories: ${[ 1859 !uploadsExists && "uploads", 1860 !transcriptsExists && "transcripts", 1861 ] 1862 .filter(Boolean) 1863 .join(", ")}`; 1864 } 1865 } catch (error) { 1866 health.status = "unhealthy"; 1867 health.details.storageError = 1868 error instanceof Error ? error.message : String(error); 1869 } 1870 1871 const statusCode = health.status === "healthy" ? 200 : 503; 1872 return Response.json(health, { status: statusCode }); 1873 }, 1874 }, 1875 "/api/transcriptions/:id": { 1876 GET: async (req) => { 1877 try { 1878 const user = requireAuth(req); 1879 const transcriptionId = req.params.id; 1880 1881 // Verify ownership or admin 1882 const transcription = db 1883 .query< 1884 { 1885 id: string; 1886 user_id: number; 1887 class_id: string | null; 1888 filename: string; 1889 original_filename: string; 1890 status: string; 1891 progress: number; 1892 created_at: number; 1893 }, 1894 [string] 1895 >( 1896 "SELECT id, user_id, class_id, filename, original_filename, status, progress, created_at FROM transcriptions WHERE id = ?", 1897 ) 1898 .get(transcriptionId); 1899 1900 if (!transcription) { 1901 return Response.json( 1902 { error: "Transcription not found" }, 1903 { status: 404 }, 1904 ); 1905 } 1906 1907 // Check access permissions 1908 const isOwner = transcription.user_id === user.id; 1909 const isAdmin = user.role === "admin"; 1910 let isClassMember = false; 1911 1912 // If transcription belongs to a class, check enrollment 1913 if (transcription.class_id) { 1914 isClassMember = isUserEnrolledInClass( 1915 user.id, 1916 transcription.class_id, 1917 ); 1918 } 1919 1920 // Allow access if: owner, admin, or enrolled in the class 1921 if (!isOwner && !isAdmin && !isClassMember) { 1922 return Response.json({ error: "Forbidden" }, { status: 403 }); 1923 } 1924 1925 // Require subscription only if accessing own transcription (not class) 1926 if ( 1927 isOwner && 1928 !transcription.class_id && 1929 !isAdmin && 1930 !hasActiveSubscription(user.id) 1931 ) { 1932 throw AuthErrors.subscriptionRequired(); 1933 } 1934 1935 if (transcription.status !== "completed") { 1936 return Response.json( 1937 { error: "Transcription not completed yet" }, 1938 { status: 409 }, 1939 ); 1940 } 1941 1942 // Get format from query parameter 1943 const url = new URL(req.url); 1944 const format = url.searchParams.get("format"); 1945 1946 // Return WebVTT format if requested 1947 if (format === "vtt") { 1948 const vttContent = await getTranscriptVTT(transcriptionId); 1949 1950 if (!vttContent) { 1951 return Response.json( 1952 { error: "VTT transcript not available" }, 1953 { status: 404 }, 1954 ); 1955 } 1956 1957 return new Response(vttContent, { 1958 headers: { 1959 "Content-Type": "text/vtt", 1960 "Content-Disposition": `attachment; filename="${transcription.original_filename}.vtt"`, 1961 }, 1962 }); 1963 } 1964 1965 // return info on transcript 1966 const transcript = { 1967 id: transcription.id, 1968 filename: transcription.original_filename, 1969 status: transcription.status, 1970 progress: transcription.progress, 1971 created_at: transcription.created_at, 1972 }; 1973 return new Response(JSON.stringify(transcript), { 1974 headers: { 1975 "Content-Type": "application/json", 1976 }, 1977 }); 1978 } catch (error) { 1979 return handleError(error); 1980 } 1981 }, 1982 }, 1983 "/api/transcriptions/:id/audio": { 1984 GET: async (req) => { 1985 try { 1986 const user = requireAuth(req); 1987 const transcriptionId = req.params.id; 1988 1989 // Verify ownership or admin 1990 const transcription = db 1991 .query< 1992 { 1993 id: string; 1994 user_id: number; 1995 class_id: string | null; 1996 filename: string; 1997 status: string; 1998 }, 1999 [string] 2000 >( 2001 "SELECT id, user_id, class_id, filename, status FROM transcriptions WHERE id = ?", 2002 ) 2003 .get(transcriptionId); 2004 2005 if (!transcription) { 2006 return Response.json( 2007 { error: "Transcription not found" }, 2008 { status: 404 }, 2009 ); 2010 } 2011 2012 // Check access permissions 2013 const isOwner = transcription.user_id === user.id; 2014 const isAdmin = user.role === "admin"; 2015 let isClassMember = false; 2016 2017 // If transcription belongs to a class, check enrollment 2018 if (transcription.class_id) { 2019 isClassMember = isUserEnrolledInClass( 2020 user.id, 2021 transcription.class_id, 2022 ); 2023 } 2024 2025 // Allow access if: owner, admin, or enrolled in the class 2026 if (!isOwner && !isAdmin && !isClassMember) { 2027 return Response.json({ error: "Forbidden" }, { status: 403 }); 2028 } 2029 2030 // Require subscription only if accessing own transcription (not class) 2031 if ( 2032 isOwner && 2033 !transcription.class_id && 2034 !isAdmin && 2035 !hasActiveSubscription(user.id) 2036 ) { 2037 throw AuthErrors.subscriptionRequired(); 2038 } 2039 2040 // For pending recordings, audio file exists even though transcription isn't complete 2041 // Allow audio access for pending and completed statuses 2042 if ( 2043 transcription.status !== "completed" && 2044 transcription.status !== "pending" 2045 ) { 2046 return Response.json( 2047 { error: "Audio not available yet" }, 2048 { status: 400 }, 2049 ); 2050 } 2051 2052 // Serve the audio file with range request support 2053 const filePath = `./uploads/${transcription.filename}`; 2054 const file = Bun.file(filePath); 2055 2056 if (!(await file.exists())) { 2057 return Response.json( 2058 { error: "Audio file not found" }, 2059 { status: 404 }, 2060 ); 2061 } 2062 2063 const fileSize = file.size; 2064 const range = req.headers.get("range"); 2065 2066 // Handle range requests for seeking 2067 if (range) { 2068 const parts = range.replace(/bytes=/, "").split("-"); 2069 const start = Number.parseInt(parts[0] || "0", 10); 2070 const end = parts[1] ? Number.parseInt(parts[1], 10) : fileSize - 1; 2071 const chunkSize = end - start + 1; 2072 2073 const fileSlice = file.slice(start, end + 1); 2074 2075 return new Response(fileSlice, { 2076 status: 206, 2077 headers: { 2078 "Content-Range": `bytes ${start}-${end}/${fileSize}`, 2079 "Accept-Ranges": "bytes", 2080 "Content-Length": chunkSize.toString(), 2081 "Content-Type": file.type || "audio/mpeg", 2082 }, 2083 }); 2084 } 2085 2086 // No range request, send entire file 2087 return new Response(file, { 2088 headers: { 2089 "Content-Type": file.type || "audio/mpeg", 2090 "Accept-Ranges": "bytes", 2091 "Content-Length": fileSize.toString(), 2092 }, 2093 }); 2094 } catch (error) { 2095 return handleError(error); 2096 } 2097 }, 2098 }, 2099 "/api/transcriptions/detect-meeting-time": { 2100 POST: async (req) => { 2101 try { 2102 const user = requireAuth(req); 2103 2104 const formData = await req.formData(); 2105 const file = formData.get("audio") as File; 2106 const classId = formData.get("class_id") as string | null; 2107 const fileTimestampStr = formData.get("file_timestamp") as 2108 | string 2109 | null; 2110 2111 if (!file) throw ValidationErrors.missingField("audio"); 2112 if (!classId) throw ValidationErrors.missingField("class_id"); 2113 2114 // Verify user is enrolled in the class 2115 const enrolled = isUserEnrolledInClass(user.id, classId); 2116 if (!enrolled && user.role !== "admin") { 2117 return Response.json( 2118 { error: "Not enrolled in this class" }, 2119 { status: 403 }, 2120 ); 2121 } 2122 2123 let creationDate: Date | null = null; 2124 2125 // Use client-provided timestamp (from File.lastModified) 2126 if (fileTimestampStr) { 2127 const timestamp = Number.parseInt(fileTimestampStr, 10); 2128 if (!Number.isNaN(timestamp)) { 2129 creationDate = new Date(timestamp); 2130 console.log( 2131 `[Upload] Using file timestamp: ${creationDate.toISOString()}`, 2132 ); 2133 } 2134 } 2135 2136 if (!creationDate) { 2137 return Response.json({ 2138 detected: false, 2139 meeting_time_id: null, 2140 message: "Could not extract creation date from file", 2141 }); 2142 } 2143 2144 // Get meeting times for this class 2145 const meetingTimes = getMeetingTimesForClass(classId); 2146 2147 if (meetingTimes.length === 0) { 2148 return Response.json({ 2149 detected: false, 2150 meeting_time_id: null, 2151 message: "No meeting times configured for this class", 2152 }); 2153 } 2154 2155 // Find matching meeting time based on day of week 2156 const matchedId = findMatchingMeetingTime( 2157 creationDate, 2158 meetingTimes, 2159 ); 2160 2161 if (matchedId) { 2162 const dayName = getDayName(creationDate); 2163 return Response.json({ 2164 detected: true, 2165 meeting_time_id: matchedId, 2166 day: dayName, 2167 date: creationDate.toISOString(), 2168 }); 2169 } 2170 2171 const dayName = getDayName(creationDate); 2172 return Response.json({ 2173 detected: false, 2174 meeting_time_id: null, 2175 day: dayName, 2176 date: creationDate.toISOString(), 2177 message: `No meeting time matches ${dayName}`, 2178 }); 2179 } catch (error) { 2180 return handleError(error); 2181 } 2182 }, 2183 }, 2184 "/api/transcriptions/:id/meeting-time": { 2185 PATCH: async (req) => { 2186 try { 2187 const user = requireAuth(req); 2188 const transcriptionId = req.params.id; 2189 2190 const body = await req.json(); 2191 const meetingTimeId = body.meeting_time_id; 2192 2193 if (!meetingTimeId) { 2194 return Response.json( 2195 { error: "meeting_time_id required" }, 2196 { status: 400 }, 2197 ); 2198 } 2199 2200 // Verify transcription ownership 2201 const transcription = db 2202 .query< 2203 { id: string; user_id: number; class_id: string | null }, 2204 [string] 2205 >("SELECT id, user_id, class_id FROM transcriptions WHERE id = ?") 2206 .get(transcriptionId); 2207 2208 if (!transcription) { 2209 return Response.json( 2210 { error: "Transcription not found" }, 2211 { status: 404 }, 2212 ); 2213 } 2214 2215 if (transcription.user_id !== user.id && user.role !== "admin") { 2216 return Response.json({ error: "Forbidden" }, { status: 403 }); 2217 } 2218 2219 // Verify meeting time belongs to the class 2220 if (transcription.class_id) { 2221 const meetingTime = db 2222 .query<{ id: string }, [string, string]>( 2223 "SELECT id FROM meeting_times WHERE id = ? AND class_id = ?", 2224 ) 2225 .get(meetingTimeId, transcription.class_id); 2226 2227 if (!meetingTime) { 2228 return Response.json( 2229 { 2230 error: 2231 "Meeting time does not belong to the class for this transcription", 2232 }, 2233 { status: 400 }, 2234 ); 2235 } 2236 } 2237 2238 // Update meeting time 2239 db.run( 2240 "UPDATE transcriptions SET meeting_time_id = ? WHERE id = ?", 2241 [meetingTimeId, transcriptionId], 2242 ); 2243 2244 return Response.json({ 2245 success: true, 2246 message: "Meeting time updated successfully", 2247 }); 2248 } catch (error) { 2249 return handleError(error); 2250 } 2251 }, 2252 }, 2253 "/api/classes/:classId/meetings/:meetingTimeId/recordings": { 2254 GET: async (req) => { 2255 try { 2256 const user = requireAuth(req); 2257 const classId = req.params.classId; 2258 const meetingTimeId = req.params.meetingTimeId; 2259 2260 // Verify user is enrolled in the class 2261 const enrolled = isUserEnrolledInClass(user.id, classId); 2262 if (!enrolled && user.role !== "admin") { 2263 return Response.json( 2264 { error: "Not enrolled in this class" }, 2265 { status: 403 }, 2266 ); 2267 } 2268 2269 // Get user's section for filtering (admins see all) 2270 const userSection = 2271 user.role === "admin" ? null : getUserSection(user.id, classId); 2272 2273 const recordings = getPendingRecordings( 2274 classId, 2275 meetingTimeId, 2276 userSection, 2277 ); 2278 const totalUsers = getEnrolledUserCount(classId); 2279 const userVote = getUserVoteForMeeting( 2280 user.id, 2281 classId, 2282 meetingTimeId, 2283 ); 2284 2285 // Check if any recording should be auto-submitted 2286 const winningId = checkAutoSubmit( 2287 classId, 2288 meetingTimeId, 2289 userSection, 2290 ); 2291 2292 return Response.json({ 2293 recordings, 2294 total_users: totalUsers, 2295 user_vote: userVote, 2296 vote_threshold: Math.ceil(totalUsers * 0.4), 2297 winning_recording_id: winningId, 2298 }); 2299 } catch (error) { 2300 return handleError(error); 2301 } 2302 }, 2303 }, 2304 "/api/recordings/:id/vote": { 2305 POST: async (req) => { 2306 try { 2307 const user = requireAuth(req); 2308 const recordingId = req.params.id; 2309 2310 // Verify user is enrolled in the recording's class 2311 const recording = db 2312 .query< 2313 { class_id: string; meeting_time_id: string; status: string }, 2314 [string] 2315 >( 2316 "SELECT class_id, meeting_time_id, status FROM transcriptions WHERE id = ?", 2317 ) 2318 .get(recordingId); 2319 2320 if (!recording) { 2321 return Response.json( 2322 { error: "Recording not found" }, 2323 { status: 404 }, 2324 ); 2325 } 2326 2327 if (recording.status !== "pending") { 2328 return Response.json( 2329 { error: "Can only vote on pending recordings" }, 2330 { status: 400 }, 2331 ); 2332 } 2333 2334 const enrolled = isUserEnrolledInClass(user.id, recording.class_id); 2335 if (!enrolled && user.role !== "admin") { 2336 return Response.json( 2337 { error: "Not enrolled in this class" }, 2338 { status: 403 }, 2339 ); 2340 } 2341 2342 // Remove existing vote for this meeting time 2343 const existingVote = getUserVoteForMeeting( 2344 user.id, 2345 recording.class_id, 2346 recording.meeting_time_id, 2347 ); 2348 if (existingVote) { 2349 removeVote(existingVote, user.id); 2350 } 2351 2352 // Add new vote 2353 const success = voteForRecording(recordingId, user.id); 2354 2355 // Get user's section for auto-submit check 2356 const userSection = 2357 user.role === "admin" 2358 ? null 2359 : getUserSection(user.id, recording.class_id); 2360 2361 // Check if auto-submit threshold reached 2362 const winningId = checkAutoSubmit( 2363 recording.class_id, 2364 recording.meeting_time_id, 2365 userSection, 2366 ); 2367 if (winningId) { 2368 markAsAutoSubmitted(winningId); 2369 // Start transcription 2370 const winningRecording = db 2371 .query<{ filename: string }, [string]>( 2372 "SELECT filename FROM transcriptions WHERE id = ?", 2373 ) 2374 .get(winningId); 2375 if (winningRecording) { 2376 whisperService.startTranscription( 2377 winningId, 2378 winningRecording.filename, 2379 ); 2380 } 2381 } 2382 2383 return Response.json({ 2384 success, 2385 winning_recording_id: winningId, 2386 }); 2387 } catch (error) { 2388 return handleError(error); 2389 } 2390 }, 2391 }, 2392 "/api/recordings/:id": { 2393 DELETE: async (req) => { 2394 try { 2395 const user = requireAuth(req); 2396 const recordingId = req.params.id; 2397 2398 const success = deletePendingRecording( 2399 recordingId, 2400 user.id, 2401 user.role === "admin", 2402 ); 2403 2404 if (!success) { 2405 return Response.json( 2406 { error: "Cannot delete this recording" }, 2407 { status: 403 }, 2408 ); 2409 } 2410 2411 return new Response(null, { status: 204 }); 2412 } catch (error) { 2413 return handleError(error); 2414 } 2415 }, 2416 }, 2417 "/api/transcriptions": { 2418 GET: async (req) => { 2419 try { 2420 const user = requireSubscription(req); 2421 const url = new URL(req.url); 2422 2423 // Parse pagination params 2424 const limit = Math.min( 2425 Number.parseInt(url.searchParams.get("limit") || "50", 10), 2426 100, 2427 ); 2428 const cursorParam = url.searchParams.get("cursor"); 2429 2430 let transcriptions: Array<{ 2431 id: string; 2432 filename: string; 2433 original_filename: string; 2434 class_id: string | null; 2435 status: string; 2436 progress: number; 2437 created_at: number; 2438 }>; 2439 2440 if (cursorParam) { 2441 // Decode cursor 2442 const { decodeCursor } = await import("./lib/cursor"); 2443 const parts = decodeCursor(cursorParam); 2444 2445 if (parts.length !== 2) { 2446 return Response.json( 2447 { error: "Invalid cursor format" }, 2448 { status: 400 }, 2449 ); 2450 } 2451 2452 const cursorTime = Number.parseInt(parts[0] || "", 10); 2453 const id = parts[1] || ""; 2454 2455 if (Number.isNaN(cursorTime) || !id) { 2456 return Response.json( 2457 { error: "Invalid cursor format" }, 2458 { status: 400 }, 2459 ); 2460 } 2461 2462 transcriptions = db 2463 .query< 2464 { 2465 id: string; 2466 filename: string; 2467 original_filename: string; 2468 class_id: string | null; 2469 status: string; 2470 progress: number; 2471 created_at: number; 2472 }, 2473 [number, number, string, number] 2474 >( 2475 `SELECT id, filename, original_filename, class_id, status, progress, created_at 2476 FROM transcriptions 2477 WHERE user_id = ? AND (created_at < ? OR (created_at = ? AND id < ?)) 2478 ORDER BY created_at DESC, id DESC 2479 LIMIT ?`, 2480 ) 2481 .all(user.id, cursorTime, cursorTime, id, limit + 1); 2482 } else { 2483 transcriptions = db 2484 .query< 2485 { 2486 id: string; 2487 filename: string; 2488 original_filename: string; 2489 class_id: string | null; 2490 status: string; 2491 progress: number; 2492 created_at: number; 2493 }, 2494 [number, number] 2495 >( 2496 `SELECT id, filename, original_filename, class_id, status, progress, created_at 2497 FROM transcriptions 2498 WHERE user_id = ? 2499 ORDER BY created_at DESC, id DESC 2500 LIMIT ?`, 2501 ) 2502 .all(user.id, limit + 1); 2503 } 2504 2505 // Check if there are more results 2506 const hasMore = transcriptions.length > limit; 2507 if (hasMore) { 2508 transcriptions.pop(); // Remove extra item 2509 } 2510 2511 // Build next cursor 2512 let nextCursor: string | null = null; 2513 if (hasMore && transcriptions.length > 0) { 2514 const { encodeCursor } = await import("./lib/cursor"); 2515 const last = transcriptions[transcriptions.length - 1]; 2516 if (last) { 2517 nextCursor = encodeCursor([last.created_at.toString(), last.id]); 2518 } 2519 } 2520 2521 // Load transcripts from files for completed jobs 2522 const jobs = await Promise.all( 2523 transcriptions.map(async (t) => { 2524 return { 2525 id: t.id, 2526 filename: t.original_filename, 2527 class_id: t.class_id, 2528 status: t.status, 2529 progress: t.progress, 2530 created_at: t.created_at, 2531 }; 2532 }), 2533 ); 2534 2535 return Response.json({ 2536 jobs, 2537 pagination: { 2538 limit, 2539 hasMore, 2540 nextCursor, 2541 }, 2542 }); 2543 } catch (error) { 2544 return handleError(error); 2545 } 2546 }, 2547 POST: async (req) => { 2548 try { 2549 const user = requireSubscription(req); 2550 2551 const rateLimitError = enforceRateLimit(req, "upload-transcription", { 2552 ip: { max: 20, windowSeconds: 60 * 60 }, 2553 }); 2554 if (rateLimitError) return rateLimitError; 2555 2556 const formData = await req.formData(); 2557 const file = formData.get("audio") as File; 2558 const classId = formData.get("class_id") as string | null; 2559 const sectionId = formData.get("section_id") as string | null; 2560 2561 if (!file) throw ValidationErrors.missingField("audio"); 2562 2563 // If class_id provided, verify user is enrolled (or admin) 2564 if (classId) { 2565 const enrolled = isUserEnrolledInClass(user.id, classId); 2566 if (!enrolled && user.role !== "admin") { 2567 return Response.json( 2568 { error: "Not enrolled in this class" }, 2569 { status: 403 }, 2570 ); 2571 } 2572 2573 // Verify class exists 2574 const classInfo = getClassById(classId); 2575 if (!classInfo) { 2576 return Response.json( 2577 { error: "Class not found" }, 2578 { status: 404 }, 2579 ); 2580 } 2581 2582 // Check if class is archived 2583 if (classInfo.archived) { 2584 return Response.json( 2585 { error: "Cannot upload to archived class" }, 2586 { status: 400 }, 2587 ); 2588 } 2589 } 2590 2591 // Validate file type 2592 const fileExtension = file.name.split(".").pop()?.toLowerCase(); 2593 const allowedExtensions = [ 2594 "mp3", 2595 "wav", 2596 "m4a", 2597 "aac", 2598 "ogg", 2599 "webm", 2600 "flac", 2601 "mp4", 2602 ]; 2603 const isAudioType = 2604 file.type.startsWith("audio/") || file.type === "video/mp4"; 2605 const isAudioExtension = 2606 fileExtension && allowedExtensions.includes(fileExtension); 2607 2608 if (!isAudioType && !isAudioExtension) { 2609 throw ValidationErrors.unsupportedFileType( 2610 "MP3, WAV, M4A, AAC, OGG, WebM, FLAC", 2611 ); 2612 } 2613 2614 if (file.size > MAX_FILE_SIZE) { 2615 throw ValidationErrors.fileTooLarge("100MB"); 2616 } 2617 2618 // Generate unique filename 2619 const transcriptionId = crypto.randomUUID(); 2620 const filename = `${transcriptionId}.${fileExtension}`; 2621 2622 // Save file to disk 2623 const uploadDir = "./uploads"; 2624 await Bun.write(`${uploadDir}/${filename}`, file); 2625 2626 // Create database record (without meeting_time_id - will be set later via PATCH) 2627 db.run( 2628 "INSERT INTO transcriptions (id, user_id, class_id, meeting_time_id, section_id, filename, original_filename, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", 2629 [ 2630 transcriptionId, 2631 user.id, 2632 classId, 2633 null, // meeting_time_id will be set via PATCH endpoint 2634 sectionId, 2635 filename, 2636 file.name, 2637 "pending", 2638 ], 2639 ); 2640 2641 // Don't auto-start transcription - admin will select recordings 2642 // whisperService.startTranscription(transcriptionId, filename); 2643 2644 return Response.json( 2645 { 2646 id: transcriptionId, 2647 message: "Upload successful", 2648 }, 2649 { status: 201 }, 2650 ); 2651 } catch (error) { 2652 return handleError(error); 2653 } 2654 }, 2655 }, 2656 "/api/admin/transcriptions": { 2657 GET: async (req) => { 2658 try { 2659 requireAdmin(req); 2660 const url = new URL(req.url); 2661 2662 const limit = Math.min( 2663 Number.parseInt(url.searchParams.get("limit") || "50", 10), 2664 100, 2665 ); 2666 const cursor = url.searchParams.get("cursor") || undefined; 2667 2668 const result = getAllTranscriptions(limit, cursor); 2669 return Response.json(result.data); // Return just the array for now, can add pagination UI later 2670 } catch (error) { 2671 return handleError(error); 2672 } 2673 }, 2674 }, 2675 "/api/admin/users": { 2676 GET: async (req) => { 2677 try { 2678 requireAdmin(req); 2679 const url = new URL(req.url); 2680 2681 const limit = Math.min( 2682 Number.parseInt(url.searchParams.get("limit") || "50", 10), 2683 100, 2684 ); 2685 const cursor = url.searchParams.get("cursor") || undefined; 2686 2687 const result = getAllUsersWithStats(limit, cursor); 2688 return Response.json(result.data); // Return just the array for now, can add pagination UI later 2689 } catch (error) { 2690 return handleError(error); 2691 } 2692 }, 2693 }, 2694 "/api/admin/classes": { 2695 GET: async (req) => { 2696 try { 2697 requireAdmin(req); 2698 const classes = getClassesForUser(0, true); // Admin sees all classes 2699 return Response.json({ classes }); 2700 } catch (error) { 2701 return handleError(error); 2702 } 2703 }, 2704 }, 2705 "/api/admin/waitlist": { 2706 GET: async (req) => { 2707 try { 2708 requireAdmin(req); 2709 const waitlist = getAllWaitlistEntries(); 2710 return Response.json({ waitlist }); 2711 } catch (error) { 2712 return handleError(error); 2713 } 2714 }, 2715 }, 2716 "/api/admin/waitlist/:id": { 2717 DELETE: async (req) => { 2718 try { 2719 requireAdmin(req); 2720 const id = req.params.id; 2721 deleteWaitlistEntry(id); 2722 return new Response(null, { status: 204 }); 2723 } catch (error) { 2724 return handleError(error); 2725 } 2726 }, 2727 }, 2728 "/api/admin/transcriptions/:id": { 2729 DELETE: async (req) => { 2730 try { 2731 requireAdmin(req); 2732 const transcriptionId = req.params.id; 2733 deleteTranscription(transcriptionId); 2734 return new Response(null, { status: 204 }); 2735 } catch (error) { 2736 return handleError(error); 2737 } 2738 }, 2739 }, 2740 "/api/admin/users/:id": { 2741 DELETE: async (req) => { 2742 try { 2743 requireAdmin(req); 2744 const userId = Number.parseInt(req.params.id, 10); 2745 if (Number.isNaN(userId)) { 2746 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2747 } 2748 await deleteUser(userId); 2749 return new Response(null, { status: 204 }); 2750 } catch (error) { 2751 return handleError(error); 2752 } 2753 }, 2754 }, 2755 "/api/admin/users/:id/role": { 2756 PUT: async (req) => { 2757 try { 2758 requireAdmin(req); 2759 const userId = Number.parseInt(req.params.id, 10); 2760 if (Number.isNaN(userId)) { 2761 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2762 } 2763 2764 const body = await req.json(); 2765 const { role } = body as { role: UserRole }; 2766 2767 if (!role || (role !== "user" && role !== "admin")) { 2768 return Response.json( 2769 { error: "Invalid role. Must be 'user' or 'admin'" }, 2770 { status: 400 }, 2771 ); 2772 } 2773 2774 updateUserRole(userId, role); 2775 return Response.json({ success: true }); 2776 } catch (error) { 2777 return handleError(error); 2778 } 2779 }, 2780 }, 2781 "/api/admin/users/:id/subscription": { 2782 DELETE: async (req) => { 2783 try { 2784 requireAdmin(req); 2785 const userId = Number.parseInt(req.params.id, 10); 2786 if (Number.isNaN(userId)) { 2787 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2788 } 2789 2790 const body = await req.json(); 2791 const { subscriptionId } = body as { subscriptionId: string }; 2792 2793 if (!subscriptionId) { 2794 return Response.json( 2795 { error: "Subscription ID required" }, 2796 { status: 400 }, 2797 ); 2798 } 2799 2800 try { 2801 const { polar } = await import("./lib/polar"); 2802 await polar.subscriptions.revoke({ id: subscriptionId }); 2803 return Response.json({ 2804 success: true, 2805 message: "Subscription revoked successfully", 2806 }); 2807 } catch (error) { 2808 console.error( 2809 `[Admin] Failed to revoke subscription ${subscriptionId}:`, 2810 error, 2811 ); 2812 return Response.json( 2813 { 2814 error: 2815 error instanceof Error 2816 ? error.message 2817 : "Failed to revoke subscription", 2818 }, 2819 { status: 500 }, 2820 ); 2821 } 2822 } catch (error) { 2823 return handleError(error); 2824 } 2825 }, 2826 PUT: async (req) => { 2827 try { 2828 requireAdmin(req); 2829 const userId = Number.parseInt(req.params.id, 10); 2830 if (Number.isNaN(userId)) { 2831 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2832 } 2833 2834 // Get user email 2835 const user = db 2836 .query<{ email: string }, [number]>( 2837 "SELECT email FROM users WHERE id = ?", 2838 ) 2839 .get(userId); 2840 2841 if (!user) { 2842 return Response.json({ error: "User not found" }, { status: 404 }); 2843 } 2844 2845 try { 2846 await syncUserSubscriptionsFromPolar(userId, user.email); 2847 return Response.json({ 2848 success: true, 2849 message: "Subscription synced successfully", 2850 }); 2851 } catch (error) { 2852 console.error( 2853 `[Admin] Failed to sync subscription for user ${userId}:`, 2854 error, 2855 ); 2856 return Response.json( 2857 { 2858 error: 2859 error instanceof Error 2860 ? error.message 2861 : "Failed to sync subscription", 2862 }, 2863 { status: 500 }, 2864 ); 2865 } 2866 } catch (error) { 2867 return handleError(error); 2868 } 2869 }, 2870 }, 2871 "/api/admin/users/:id/details": { 2872 GET: async (req) => { 2873 try { 2874 requireAdmin(req); 2875 const userId = Number.parseInt(req.params.id, 10); 2876 if (Number.isNaN(userId)) { 2877 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2878 } 2879 2880 const user = db 2881 .query< 2882 { 2883 id: number; 2884 email: string; 2885 name: string | null; 2886 avatar: string; 2887 created_at: number; 2888 role: UserRole; 2889 password_hash: string | null; 2890 last_login: number | null; 2891 }, 2892 [number] 2893 >( 2894 "SELECT id, email, name, avatar, created_at, role, password_hash, last_login FROM users WHERE id = ?", 2895 ) 2896 .get(userId); 2897 2898 if (!user) { 2899 return Response.json({ error: "User not found" }, { status: 404 }); 2900 } 2901 2902 const passkeys = getPasskeysForUser(userId); 2903 const sessions = getSessionsForUser(userId); 2904 2905 // Get transcription count 2906 const transcriptionCount = 2907 db 2908 .query<{ count: number }, [number]>( 2909 "SELECT COUNT(*) as count FROM transcriptions WHERE user_id = ?", 2910 ) 2911 .get(userId)?.count ?? 0; 2912 2913 return Response.json({ 2914 id: user.id, 2915 email: user.email, 2916 name: user.name, 2917 avatar: user.avatar, 2918 created_at: user.created_at, 2919 role: user.role, 2920 last_login: user.last_login, 2921 hasPassword: !!user.password_hash, 2922 transcriptionCount, 2923 passkeys: passkeys.map((pk) => ({ 2924 id: pk.id, 2925 name: pk.name, 2926 created_at: pk.created_at, 2927 last_used_at: pk.last_used_at, 2928 })), 2929 sessions: sessions.map((s) => ({ 2930 id: s.id, 2931 ip_address: s.ip_address, 2932 user_agent: s.user_agent, 2933 created_at: s.created_at, 2934 expires_at: s.expires_at, 2935 })), 2936 }); 2937 } catch (error) { 2938 return handleError(error); 2939 } 2940 }, 2941 }, 2942 "/api/admin/users/:id/password-reset": { 2943 POST: async (req) => { 2944 try { 2945 requireAdmin(req); 2946 const userId = Number.parseInt(req.params.id, 10); 2947 if (Number.isNaN(userId)) { 2948 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2949 } 2950 2951 // Get user details 2952 const user = db 2953 .query< 2954 { id: number; email: string; name: string | null }, 2955 [number] 2956 >("SELECT id, email, name FROM users WHERE id = ?") 2957 .get(userId); 2958 2959 if (!user) { 2960 return Response.json({ error: "User not found" }, { status: 404 }); 2961 } 2962 2963 // Create password reset token 2964 const origin = process.env.ORIGIN || "http://localhost:3000"; 2965 const resetToken = createPasswordResetToken(user.id); 2966 const resetLink = `${origin}/reset-password?token=${resetToken}`; 2967 2968 // Send password reset email 2969 await sendEmail({ 2970 to: user.email, 2971 subject: "Reset your password - Thistle", 2972 html: passwordResetTemplate({ 2973 name: user.name, 2974 resetLink, 2975 }), 2976 }); 2977 2978 return Response.json({ 2979 success: true, 2980 message: "Password reset email sent", 2981 }); 2982 } catch (error) { 2983 console.error("[Admin] Password reset error:", error); 2984 return handleError(error); 2985 } 2986 }, 2987 }, 2988 "/api/admin/users/:id/passkeys/:passkeyId": { 2989 DELETE: async (req) => { 2990 try { 2991 requireAdmin(req); 2992 const userId = Number.parseInt(req.params.id, 10); 2993 if (Number.isNaN(userId)) { 2994 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2995 } 2996 2997 const { passkeyId } = req.params; 2998 deletePasskey(passkeyId, userId); 2999 return new Response(null, { status: 204 }); 3000 } catch (error) { 3001 return handleError(error); 3002 } 3003 }, 3004 }, 3005 "/api/admin/users/:id/name": { 3006 PUT: async (req) => { 3007 try { 3008 requireAdmin(req); 3009 const userId = Number.parseInt(req.params.id, 10); 3010 if (Number.isNaN(userId)) { 3011 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 3012 } 3013 3014 const body = await req.json(); 3015 const { name } = body as { name: string }; 3016 3017 const nameValidation = validateName(name); 3018 if (!nameValidation.valid) { 3019 return Response.json( 3020 { error: nameValidation.error }, 3021 { status: 400 }, 3022 ); 3023 } 3024 3025 updateUserName(userId, name.trim()); 3026 return Response.json({ success: true }); 3027 } catch (error) { 3028 return handleError(error); 3029 } 3030 }, 3031 }, 3032 "/api/admin/users/:id/email": { 3033 PUT: async (req) => { 3034 try { 3035 requireAdmin(req); 3036 const userId = Number.parseInt(req.params.id, 10); 3037 if (Number.isNaN(userId)) { 3038 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 3039 } 3040 3041 const body = await req.json(); 3042 const { email, skipVerification } = body as { 3043 email: string; 3044 skipVerification?: boolean; 3045 }; 3046 3047 const emailValidation = validateEmail(email); 3048 if (!emailValidation.valid) { 3049 return Response.json( 3050 { error: emailValidation.error }, 3051 { status: 400 }, 3052 ); 3053 } 3054 3055 // Check if email already exists 3056 const existing = db 3057 .query<{ id: number }, [string, number]>( 3058 "SELECT id FROM users WHERE email = ? AND id != ?", 3059 ) 3060 .get(email, userId); 3061 3062 if (existing) { 3063 return Response.json( 3064 { error: "Email already in use" }, 3065 { status: 409 }, 3066 ); 3067 } 3068 3069 if (skipVerification) { 3070 // Admin override: change email immediately without verification 3071 updateUserEmailAddress(userId, email); 3072 return Response.json({ 3073 success: true, 3074 message: "Email updated immediately (verification skipped)", 3075 }); 3076 } 3077 3078 // Get user's current email 3079 const user = db 3080 .query<{ email: string; name: string | null }, [number]>( 3081 "SELECT email, name FROM users WHERE id = ?", 3082 ) 3083 .get(userId); 3084 3085 if (!user) { 3086 return Response.json({ error: "User not found" }, { status: 404 }); 3087 } 3088 3089 // Send verification email to user's current email 3090 try { 3091 const token = createEmailChangeToken(userId, email); 3092 const origin = process.env.ORIGIN || "http://localhost:3000"; 3093 const verifyUrl = `${origin}/api/user/email/verify?token=${token}`; 3094 3095 await sendEmail({ 3096 to: user.email, 3097 subject: "Verify your email change", 3098 html: emailChangeTemplate({ 3099 name: user.name, 3100 currentEmail: user.email, 3101 newEmail: email, 3102 verifyLink: verifyUrl, 3103 }), 3104 }); 3105 3106 return Response.json({ 3107 success: true, 3108 message: `Verification email sent to ${user.email}`, 3109 pendingEmail: email, 3110 }); 3111 } catch (emailError) { 3112 console.error( 3113 "[Admin] Failed to send email change verification:", 3114 emailError, 3115 ); 3116 return Response.json( 3117 { error: "Failed to send verification email" }, 3118 { status: 500 }, 3119 ); 3120 } 3121 } catch (error) { 3122 return handleError(error); 3123 } 3124 }, 3125 }, 3126 "/api/admin/users/:id/sessions": { 3127 GET: async (req) => { 3128 try { 3129 requireAdmin(req); 3130 const userId = Number.parseInt(req.params.id, 10); 3131 if (Number.isNaN(userId)) { 3132 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 3133 } 3134 3135 const sessions = getSessionsForUser(userId); 3136 return Response.json(sessions); 3137 } catch (error) { 3138 return handleError(error); 3139 } 3140 }, 3141 DELETE: async (req) => { 3142 try { 3143 requireAdmin(req); 3144 const userId = Number.parseInt(req.params.id, 10); 3145 if (Number.isNaN(userId)) { 3146 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 3147 } 3148 3149 deleteAllUserSessions(userId); 3150 return new Response(null, { status: 204 }); 3151 } catch (error) { 3152 return handleError(error); 3153 } 3154 }, 3155 }, 3156 "/api/admin/users/:id/sessions/:sessionId": { 3157 DELETE: async (req) => { 3158 try { 3159 requireAdmin(req); 3160 const userId = Number.parseInt(req.params.id, 10); 3161 if (Number.isNaN(userId)) { 3162 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 3163 } 3164 3165 const { sessionId } = req.params; 3166 const success = deleteSessionById(sessionId, userId); 3167 3168 if (!success) { 3169 return Response.json( 3170 { error: "Session not found" }, 3171 { status: 404 }, 3172 ); 3173 } 3174 3175 return new Response(null, { status: 204 }); 3176 } catch (error) { 3177 return handleError(error); 3178 } 3179 }, 3180 }, 3181 "/api/admin/transcriptions/:id/details": { 3182 GET: async (req) => { 3183 try { 3184 requireAdmin(req); 3185 const transcriptionId = req.params.id; 3186 3187 const transcription = db 3188 .query< 3189 { 3190 id: string; 3191 original_filename: string; 3192 status: string; 3193 created_at: number; 3194 updated_at: number; 3195 error_message: string | null; 3196 user_id: number; 3197 }, 3198 [string] 3199 >( 3200 "SELECT id, original_filename, status, created_at, updated_at, error_message, user_id FROM transcriptions WHERE id = ?", 3201 ) 3202 .get(transcriptionId); 3203 3204 if (!transcription) { 3205 return Response.json( 3206 { error: "Transcription not found" }, 3207 { status: 404 }, 3208 ); 3209 } 3210 3211 const user = db 3212 .query<{ email: string; name: string | null }, [number]>( 3213 "SELECT email, name FROM users WHERE id = ?", 3214 ) 3215 .get(transcription.user_id); 3216 3217 return Response.json({ 3218 id: transcription.id, 3219 original_filename: transcription.original_filename, 3220 status: transcription.status, 3221 created_at: transcription.created_at, 3222 completed_at: transcription.updated_at, 3223 error_message: transcription.error_message, 3224 user_id: transcription.user_id, 3225 user_email: user?.email || "Unknown", 3226 user_name: user?.name || null, 3227 }); 3228 } catch (error) { 3229 return handleError(error); 3230 } 3231 }, 3232 }, 3233 "/api/classes": { 3234 GET: async (req) => { 3235 try { 3236 const user = requireAuth(req); 3237 const url = new URL(req.url); 3238 3239 const limit = Math.min( 3240 Number.parseInt(url.searchParams.get("limit") || "50", 10), 3241 100, 3242 ); 3243 const cursor = url.searchParams.get("cursor") || undefined; 3244 3245 const result = getClassesForUser( 3246 user.id, 3247 user.role === "admin", 3248 limit, 3249 cursor, 3250 ); 3251 3252 // Group by semester/year for all users 3253 const grouped: Record< 3254 string, 3255 Array<{ 3256 id: string; 3257 course_code: string; 3258 name: string; 3259 professor: string; 3260 semester: string; 3261 year: number; 3262 archived: boolean; 3263 }> 3264 > = {}; 3265 3266 for (const cls of result.data) { 3267 const key = `${cls.semester} ${cls.year}`; 3268 if (!grouped[key]) { 3269 grouped[key] = []; 3270 } 3271 grouped[key]?.push({ 3272 id: cls.id, 3273 course_code: cls.course_code, 3274 name: cls.name, 3275 professor: cls.professor, 3276 semester: cls.semester, 3277 year: cls.year, 3278 archived: cls.archived, 3279 }); 3280 } 3281 3282 return Response.json({ 3283 classes: grouped, 3284 pagination: result.pagination, 3285 }); 3286 } catch (error) { 3287 return handleError(error); 3288 } 3289 }, 3290 POST: async (req) => { 3291 try { 3292 requireAdmin(req); 3293 const body = await req.json(); 3294 const { 3295 course_code, 3296 name, 3297 professor, 3298 semester, 3299 year, 3300 meeting_times, 3301 } = body; 3302 3303 // Validate all required fields 3304 const courseCodeValidation = validateCourseCode(course_code); 3305 if (!courseCodeValidation.valid) { 3306 return Response.json( 3307 { error: courseCodeValidation.error }, 3308 { status: 400 }, 3309 ); 3310 } 3311 3312 const nameValidation = validateCourseName(name); 3313 if (!nameValidation.valid) { 3314 return Response.json( 3315 { error: nameValidation.error }, 3316 { status: 400 }, 3317 ); 3318 } 3319 3320 const professorValidation = validateName(professor, "Professor name"); 3321 if (!professorValidation.valid) { 3322 return Response.json( 3323 { error: professorValidation.error }, 3324 { status: 400 }, 3325 ); 3326 } 3327 3328 const semesterValidation = validateSemester(semester); 3329 if (!semesterValidation.valid) { 3330 return Response.json( 3331 { error: semesterValidation.error }, 3332 { status: 400 }, 3333 ); 3334 } 3335 3336 const yearValidation = validateYear(year); 3337 if (!yearValidation.valid) { 3338 return Response.json( 3339 { error: yearValidation.error }, 3340 { status: 400 }, 3341 ); 3342 } 3343 3344 const newClass = createClass({ 3345 course_code, 3346 name, 3347 professor, 3348 semester, 3349 year, 3350 meeting_times, 3351 sections: body.sections, 3352 }); 3353 3354 return Response.json(newClass, { status: 201 }); 3355 } catch (error) { 3356 return handleError(error); 3357 } 3358 }, 3359 }, 3360 "/api/classes/search": { 3361 GET: async (req) => { 3362 try { 3363 const user = requireAuth(req); 3364 const url = new URL(req.url); 3365 const query = url.searchParams.get("q"); 3366 3367 if (!query) { 3368 return Response.json({ classes: [] }); 3369 } 3370 3371 const classes = searchClassesByCourseCode(query); 3372 3373 // Get user's enrolled classes to mark them 3374 const enrolledClassIds = db 3375 .query<{ class_id: string }, [number]>( 3376 "SELECT class_id FROM class_members WHERE user_id = ?", 3377 ) 3378 .all(user.id) 3379 .map((row) => row.class_id); 3380 3381 // Add is_enrolled flag and sections to each class 3382 const classesWithEnrollment = classes.map((cls) => ({ 3383 ...cls, 3384 is_enrolled: enrolledClassIds.includes(cls.id), 3385 sections: getClassSections(cls.id), 3386 })); 3387 3388 return Response.json({ classes: classesWithEnrollment }); 3389 } catch (error) { 3390 return handleError(error); 3391 } 3392 }, 3393 }, 3394 "/api/classes/join": { 3395 POST: async (req) => { 3396 try { 3397 const user = requireAuth(req); 3398 const body = await req.json(); 3399 const classId = body.class_id; 3400 const sectionId = body.section_id || null; 3401 3402 const classIdValidation = validateClassId(classId); 3403 if (!classIdValidation.valid) { 3404 return Response.json( 3405 { error: classIdValidation.error }, 3406 { status: 400 }, 3407 ); 3408 } 3409 3410 const result = joinClass(classId, user.id, sectionId); 3411 3412 if (!result.success) { 3413 return Response.json({ error: result.error }, { status: 400 }); 3414 } 3415 3416 return new Response(null, { status: 204 }); 3417 } catch (error) { 3418 return handleError(error); 3419 } 3420 }, 3421 }, 3422 "/api/classes/waitlist": { 3423 POST: async (req) => { 3424 try { 3425 const user = requireAuth(req); 3426 const body = await req.json(); 3427 3428 const { 3429 courseCode, 3430 courseName, 3431 professor, 3432 semester, 3433 year, 3434 additionalInfo, 3435 meetingTimes, 3436 } = body; 3437 3438 // Validate all required fields 3439 const courseCodeValidation = validateCourseCode(courseCode); 3440 if (!courseCodeValidation.valid) { 3441 return Response.json( 3442 { error: courseCodeValidation.error }, 3443 { status: 400 }, 3444 ); 3445 } 3446 3447 const nameValidation = validateCourseName(courseName); 3448 if (!nameValidation.valid) { 3449 return Response.json( 3450 { error: nameValidation.error }, 3451 { status: 400 }, 3452 ); 3453 } 3454 3455 const professorValidation = validateName(professor, "Professor name"); 3456 if (!professorValidation.valid) { 3457 return Response.json( 3458 { error: professorValidation.error }, 3459 { status: 400 }, 3460 ); 3461 } 3462 3463 const semesterValidation = validateSemester(semester); 3464 if (!semesterValidation.valid) { 3465 return Response.json( 3466 { error: semesterValidation.error }, 3467 { status: 400 }, 3468 ); 3469 } 3470 3471 const yearValidation = validateYear( 3472 typeof year === "string" ? Number.parseInt(year, 10) : year, 3473 ); 3474 if (!yearValidation.valid) { 3475 return Response.json( 3476 { error: yearValidation.error }, 3477 { status: 400 }, 3478 ); 3479 } 3480 3481 const id = addToWaitlist( 3482 user.id, 3483 courseCode, 3484 courseName, 3485 professor, 3486 semester, 3487 Number.parseInt(year, 10), 3488 additionalInfo || null, 3489 meetingTimes || null, 3490 ); 3491 3492 return Response.json({ success: true, id }, { status: 201 }); 3493 } catch (error) { 3494 return handleError(error); 3495 } 3496 }, 3497 }, 3498 "/api/classes/:id": { 3499 GET: async (req) => { 3500 try { 3501 const user = requireAuth(req); 3502 const classId = req.params.id; 3503 3504 const classInfo = getClassById(classId); 3505 if (!classInfo) { 3506 return Response.json({ error: "Class not found" }, { status: 404 }); 3507 } 3508 3509 // Check enrollment or admin 3510 const isEnrolled = isUserEnrolledInClass(user.id, classId); 3511 if (!isEnrolled && user.role !== "admin") { 3512 return Response.json( 3513 { error: "Not enrolled in this class" }, 3514 { status: 403 }, 3515 ); 3516 } 3517 3518 const meetingTimes = getMeetingTimesForClass(classId); 3519 const sections = getClassSections(classId); 3520 const transcriptions = getTranscriptionsForClass(classId); 3521 const userSection = getUserSection(user.id, classId); 3522 3523 return Response.json({ 3524 class: classInfo, 3525 meetingTimes, 3526 sections, 3527 userSection, 3528 transcriptions, 3529 }); 3530 } catch (error) { 3531 return handleError(error); 3532 } 3533 }, 3534 DELETE: async (req) => { 3535 try { 3536 requireAdmin(req); 3537 const classId = req.params.id; 3538 3539 // Verify class exists 3540 const existingClass = getClassById(classId); 3541 if (!existingClass) { 3542 return Response.json({ error: "Class not found" }, { status: 404 }); 3543 } 3544 3545 deleteClass(classId); 3546 return new Response(null, { status: 204 }); 3547 } catch (error) { 3548 return handleError(error); 3549 } 3550 }, 3551 }, 3552 "/api/classes/:id/archive": { 3553 PUT: async (req) => { 3554 try { 3555 requireAdmin(req); 3556 const classId = req.params.id; 3557 const body = await req.json(); 3558 const { archived } = body; 3559 3560 if (typeof archived !== "boolean") { 3561 return Response.json( 3562 { error: "archived must be a boolean" }, 3563 { status: 400 }, 3564 ); 3565 } 3566 3567 // Verify class exists 3568 const existingClass = getClassById(classId); 3569 if (!existingClass) { 3570 return Response.json({ error: "Class not found" }, { status: 404 }); 3571 } 3572 3573 toggleClassArchive(classId, archived); 3574 return new Response(null, { status: 204 }); 3575 } catch (error) { 3576 return handleError(error); 3577 } 3578 }, 3579 }, 3580 "/api/classes/:id/members": { 3581 GET: async (req) => { 3582 try { 3583 requireAdmin(req); 3584 const classId = req.params.id; 3585 3586 const members = getClassMembers(classId); 3587 return Response.json({ members }); 3588 } catch (error) { 3589 return handleError(error); 3590 } 3591 }, 3592 POST: async (req) => { 3593 try { 3594 requireAdmin(req); 3595 const classId = req.params.id; 3596 const body = await req.json(); 3597 const { email } = body; 3598 3599 if (!email) { 3600 return Response.json({ error: "Email required" }, { status: 400 }); 3601 } 3602 3603 // Verify class exists 3604 const existingClass = getClassById(classId); 3605 if (!existingClass) { 3606 return Response.json({ error: "Class not found" }, { status: 404 }); 3607 } 3608 3609 const user = getUserByEmail(email); 3610 if (!user) { 3611 return Response.json({ error: "User not found" }, { status: 404 }); 3612 } 3613 3614 enrollUserInClass(user.id, classId); 3615 return new Response(null, { status: 201 }); 3616 } catch (error) { 3617 return handleError(error); 3618 } 3619 }, 3620 }, 3621 "/api/classes/:id/members/:userId": { 3622 DELETE: async (req) => { 3623 try { 3624 requireAdmin(req); 3625 const classId = req.params.id; 3626 const userId = Number.parseInt(req.params.userId, 10); 3627 3628 if (Number.isNaN(userId)) { 3629 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 3630 } 3631 3632 // Verify class exists 3633 const existingClass = getClassById(classId); 3634 if (!existingClass) { 3635 return Response.json({ error: "Class not found" }, { status: 404 }); 3636 } 3637 3638 removeUserFromClass(userId, classId); 3639 return new Response(null, { status: 204 }); 3640 } catch (error) { 3641 return handleError(error); 3642 } 3643 }, 3644 }, 3645 "/api/classes/:id/meetings": { 3646 GET: async (req) => { 3647 try { 3648 const user = requireAuth(req); 3649 const classId = req.params.id; 3650 3651 // Check enrollment or admin 3652 const isEnrolled = isUserEnrolledInClass(user.id, classId); 3653 if (!isEnrolled && user.role !== "admin") { 3654 return Response.json( 3655 { error: "Not enrolled in this class" }, 3656 { status: 403 }, 3657 ); 3658 } 3659 3660 const meetingTimes = getMeetingTimesForClass(classId); 3661 return Response.json({ meetings: meetingTimes }); 3662 } catch (error) { 3663 return handleError(error); 3664 } 3665 }, 3666 POST: async (req) => { 3667 try { 3668 requireAdmin(req); 3669 const classId = req.params.id; 3670 const body = await req.json(); 3671 const { label } = body; 3672 3673 if (!label) { 3674 return Response.json({ error: "Label required" }, { status: 400 }); 3675 } 3676 3677 // Verify class exists 3678 const existingClass = getClassById(classId); 3679 if (!existingClass) { 3680 return Response.json({ error: "Class not found" }, { status: 404 }); 3681 } 3682 3683 const meetingTime = createMeetingTime(classId, label); 3684 return Response.json(meetingTime, { status: 201 }); 3685 } catch (error) { 3686 return handleError(error); 3687 } 3688 }, 3689 }, 3690 "/api/classes/:id/sections": { 3691 POST: async (req) => { 3692 try { 3693 requireAdmin(req); 3694 const classId = req.params.id; 3695 const body = await req.json(); 3696 const { section_number } = body; 3697 3698 if (!section_number) { 3699 return Response.json({ error: "Section number required" }, { status: 400 }); 3700 } 3701 3702 const section = createClassSection(classId, section_number); 3703 return Response.json(section); 3704 } catch (error) { 3705 return handleError(error); 3706 } 3707 }, 3708 }, 3709 "/api/classes/:classId/sections/:sectionId": { 3710 DELETE: async (req) => { 3711 try { 3712 requireAdmin(req); 3713 const sectionId = req.params.sectionId; 3714 3715 // Check if any students are in this section 3716 const studentsInSection = db 3717 .query<{ count: number }, [string]>( 3718 "SELECT COUNT(*) as count FROM class_members WHERE section_id = ?", 3719 ) 3720 .get(sectionId); 3721 3722 if (studentsInSection && studentsInSection.count > 0) { 3723 return Response.json( 3724 { error: "Cannot delete section with enrolled students" }, 3725 { status: 400 }, 3726 ); 3727 } 3728 3729 db.run("DELETE FROM class_sections WHERE id = ?", [sectionId]); 3730 return new Response(null, { status: 204 }); 3731 } catch (error) { 3732 return handleError(error); 3733 } 3734 }, 3735 }, 3736 "/api/meetings/:id": { 3737 PUT: async (req) => { 3738 try { 3739 requireAdmin(req); 3740 const meetingId = req.params.id; 3741 const body = await req.json(); 3742 const { label } = body; 3743 3744 if (!label) { 3745 return Response.json({ error: "Label required" }, { status: 400 }); 3746 } 3747 3748 // Verify meeting exists 3749 const existingMeeting = getMeetingById(meetingId); 3750 if (!existingMeeting) { 3751 return Response.json( 3752 { error: "Meeting not found" }, 3753 { status: 404 }, 3754 ); 3755 } 3756 3757 updateMeetingTime(meetingId, label); 3758 return new Response(null, { status: 204 }); 3759 } catch (error) { 3760 return handleError(error); 3761 } 3762 }, 3763 DELETE: async (req) => { 3764 try { 3765 requireAdmin(req); 3766 const meetingId = req.params.id; 3767 3768 // Verify meeting exists 3769 const existingMeeting = getMeetingById(meetingId); 3770 if (!existingMeeting) { 3771 return Response.json( 3772 { error: "Meeting not found" }, 3773 { status: 404 }, 3774 ); 3775 } 3776 3777 deleteMeetingTime(meetingId); 3778 return new Response(null, { status: 204 }); 3779 } catch (error) { 3780 return handleError(error); 3781 } 3782 }, 3783 }, 3784 "/api/transcripts/:id/select": { 3785 PUT: async (req) => { 3786 try { 3787 requireAdmin(req); 3788 const transcriptId = req.params.id; 3789 3790 // Check if transcription exists and get its current status 3791 const transcription = db 3792 .query<{ filename: string; status: string }, [string]>( 3793 "SELECT filename, status FROM transcriptions WHERE id = ?", 3794 ) 3795 .get(transcriptId); 3796 3797 if (!transcription) { 3798 return Response.json( 3799 { error: "Transcription not found" }, 3800 { status: 404 }, 3801 ); 3802 } 3803 3804 // Validate that status is appropriate for selection (e.g., 'uploading' or 'pending') 3805 const validStatuses = ["uploading", "pending", "failed"]; 3806 if (!validStatuses.includes(transcription.status)) { 3807 return Response.json( 3808 { 3809 error: `Cannot select transcription with status: ${transcription.status}`, 3810 }, 3811 { status: 400 }, 3812 ); 3813 } 3814 3815 // Update status to 'selected' and start transcription 3816 db.run("UPDATE transcriptions SET status = ? WHERE id = ?", [ 3817 "selected", 3818 transcriptId, 3819 ]); 3820 3821 whisperService.startTranscription( 3822 transcriptId, 3823 transcription.filename, 3824 ); 3825 3826 return new Response(null, { status: 204 }); 3827 } catch (error) { 3828 return handleError(error); 3829 } 3830 }, 3831 }, 3832 }, 3833 development: process.env.NODE_ENV === "dev", 3834 fetch(req, server) { 3835 const response = server.fetch(req); 3836 3837 // Add security headers to all responses 3838 if (response instanceof Response) { 3839 const headers = new Headers(response.headers); 3840 headers.set("Permissions-Policy", "interest-cohort=()"); 3841 headers.set("X-Content-Type-Options", "nosniff"); 3842 headers.set("X-Frame-Options", "DENY"); 3843 headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); 3844 3845 // Set CSP that allows inline styles with unsafe-inline (needed for Lit components) 3846 // and script-src 'self' for bundled scripts 3847 headers.set( 3848 "Content-Security-Policy", 3849 "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://hostedboringavatars.vercel.app; font-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'self'; frame-ancestors 'none'; object-src 'none';", 3850 ); 3851 3852 return new Response(response.body, { 3853 status: response.status, 3854 statusText: response.statusText, 3855 headers, 3856 }); 3857 } 3858 3859 return response; 3860 }, 3861}); 3862console.log(`馃 Thistle running at http://localhost:${server.port}`); 3863 3864// Track active SSE streams for graceful shutdown 3865const activeSSEStreams = new Set<ReadableStreamDefaultController>(); 3866 3867// Graceful shutdown handler 3868let isShuttingDown = false; 3869 3870async function shutdown(signal: string) { 3871 if (isShuttingDown) return; 3872 isShuttingDown = true; 3873 3874 console.log(`\n${signal} received, starting graceful shutdown...`); 3875 3876 // 1. Stop accepting new requests 3877 console.log("[Shutdown] Closing server..."); 3878 server.stop(); 3879 3880 // 2. Close all active SSE streams (safe to kill - sync will handle reconnection) 3881 console.log( 3882 `[Shutdown] Closing ${activeSSEStreams.size} active SSE streams...`, 3883 ); 3884 for (const controller of activeSSEStreams) { 3885 try { 3886 controller.close(); 3887 } catch { 3888 // Already closed 3889 } 3890 } 3891 activeSSEStreams.clear(); 3892 3893 // 3. Stop transcription service (closes streams to Murmur) 3894 whisperService.stop(); 3895 3896 // 4. Stop cleanup intervals 3897 console.log("[Shutdown] Stopping cleanup intervals..."); 3898 clearInterval(sessionCleanupInterval); 3899 clearInterval(syncInterval); 3900 clearInterval(fileCleanupInterval); 3901 3902 // 5. Close database connections 3903 console.log("[Shutdown] Closing database..."); 3904 db.close(); 3905 3906 console.log("[Shutdown] Complete"); 3907 process.exit(0); 3908} 3909 3910// Register shutdown handlers 3911process.on("SIGTERM", () => shutdown("SIGTERM")); 3912process.on("SIGINT", () => shutdown("SIGINT"));