馃 distributed transcription service thistle.dunkirk.sh
at main 105 kB view raw
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 const sectionId = body.section_id; 2193 2194 if (!meetingTimeId) { 2195 return Response.json( 2196 { error: "meeting_time_id required" }, 2197 { status: 400 }, 2198 ); 2199 } 2200 2201 // Verify transcription ownership 2202 const transcription = db 2203 .query< 2204 { id: string; user_id: number; class_id: string | null }, 2205 [string] 2206 >("SELECT id, user_id, class_id FROM transcriptions WHERE id = ?") 2207 .get(transcriptionId); 2208 2209 if (!transcription) { 2210 return Response.json( 2211 { error: "Transcription not found" }, 2212 { status: 404 }, 2213 ); 2214 } 2215 2216 if (transcription.user_id !== user.id && user.role !== "admin") { 2217 return Response.json({ error: "Forbidden" }, { status: 403 }); 2218 } 2219 2220 // Verify meeting time belongs to the class 2221 if (transcription.class_id) { 2222 const meetingTime = db 2223 .query<{ id: string }, [string, string]>( 2224 "SELECT id FROM meeting_times WHERE id = ? AND class_id = ?", 2225 ) 2226 .get(meetingTimeId, transcription.class_id); 2227 2228 if (!meetingTime) { 2229 return Response.json( 2230 { 2231 error: 2232 "Meeting time does not belong to the class for this transcription", 2233 }, 2234 { status: 400 }, 2235 ); 2236 } 2237 } 2238 2239 // Update meeting time and optionally section_id 2240 if (sectionId !== undefined) { 2241 db.run( 2242 "UPDATE transcriptions SET meeting_time_id = ?, section_id = ? WHERE id = ?", 2243 [meetingTimeId, sectionId, transcriptionId], 2244 ); 2245 } else { 2246 db.run( 2247 "UPDATE transcriptions SET meeting_time_id = ? WHERE id = ?", 2248 [meetingTimeId, transcriptionId], 2249 ); 2250 } 2251 2252 return Response.json({ 2253 success: true, 2254 message: "Meeting time updated successfully", 2255 }); 2256 } catch (error) { 2257 return handleError(error); 2258 } 2259 }, 2260 }, 2261 "/api/classes/:classId/meetings/:meetingTimeId/recordings": { 2262 GET: async (req) => { 2263 try { 2264 const user = requireAuth(req); 2265 const classId = req.params.classId; 2266 const meetingTimeId = req.params.meetingTimeId; 2267 2268 // Verify user is enrolled in the class 2269 const enrolled = isUserEnrolledInClass(user.id, classId); 2270 if (!enrolled && user.role !== "admin") { 2271 return Response.json( 2272 { error: "Not enrolled in this class" }, 2273 { status: 403 }, 2274 ); 2275 } 2276 2277 // Get section filter from query params or use user's section 2278 const url = new URL(req.url); 2279 const sectionParam = url.searchParams.get("section_id"); 2280 const sectionFilter = 2281 sectionParam !== null 2282 ? sectionParam || null // empty string becomes null 2283 : user.role === "admin" 2284 ? null 2285 : getUserSection(user.id, classId); 2286 2287 const recordings = getPendingRecordings( 2288 classId, 2289 meetingTimeId, 2290 sectionFilter, 2291 ); 2292 const totalUsers = getEnrolledUserCount(classId); 2293 const userVote = getUserVoteForMeeting( 2294 user.id, 2295 classId, 2296 meetingTimeId, 2297 ); 2298 2299 // Check if any recording should be auto-submitted 2300 const winningId = checkAutoSubmit( 2301 classId, 2302 meetingTimeId, 2303 sectionFilter, 2304 ); 2305 2306 return Response.json({ 2307 recordings, 2308 total_users: totalUsers, 2309 user_vote: userVote, 2310 vote_threshold: Math.ceil(totalUsers * 0.4), 2311 winning_recording_id: winningId, 2312 }); 2313 } catch (error) { 2314 return handleError(error); 2315 } 2316 }, 2317 }, 2318 "/api/recordings/:id/vote": { 2319 POST: async (req) => { 2320 try { 2321 const user = requireAuth(req); 2322 const recordingId = req.params.id; 2323 2324 // Verify user is enrolled in the recording's class 2325 const recording = db 2326 .query< 2327 { class_id: string; meeting_time_id: string; status: string }, 2328 [string] 2329 >( 2330 "SELECT class_id, meeting_time_id, status FROM transcriptions WHERE id = ?", 2331 ) 2332 .get(recordingId); 2333 2334 if (!recording) { 2335 return Response.json( 2336 { error: "Recording not found" }, 2337 { status: 404 }, 2338 ); 2339 } 2340 2341 if (recording.status !== "pending") { 2342 return Response.json( 2343 { error: "Can only vote on pending recordings" }, 2344 { status: 400 }, 2345 ); 2346 } 2347 2348 const enrolled = isUserEnrolledInClass(user.id, recording.class_id); 2349 if (!enrolled && user.role !== "admin") { 2350 return Response.json( 2351 { error: "Not enrolled in this class" }, 2352 { status: 403 }, 2353 ); 2354 } 2355 2356 // Remove existing vote for this meeting time 2357 const existingVote = getUserVoteForMeeting( 2358 user.id, 2359 recording.class_id, 2360 recording.meeting_time_id, 2361 ); 2362 if (existingVote) { 2363 removeVote(existingVote, user.id); 2364 } 2365 2366 // Add new vote 2367 const success = voteForRecording(recordingId, user.id); 2368 2369 // Get user's section for auto-submit check 2370 const userSection = 2371 user.role === "admin" 2372 ? null 2373 : getUserSection(user.id, recording.class_id); 2374 2375 // Check if auto-submit threshold reached 2376 const winningId = checkAutoSubmit( 2377 recording.class_id, 2378 recording.meeting_time_id, 2379 userSection, 2380 ); 2381 if (winningId) { 2382 markAsAutoSubmitted(winningId); 2383 // Start transcription 2384 const winningRecording = db 2385 .query<{ filename: string }, [string]>( 2386 "SELECT filename FROM transcriptions WHERE id = ?", 2387 ) 2388 .get(winningId); 2389 if (winningRecording) { 2390 whisperService.startTranscription( 2391 winningId, 2392 winningRecording.filename, 2393 ); 2394 } 2395 } 2396 2397 return Response.json({ 2398 success, 2399 winning_recording_id: winningId, 2400 }); 2401 } catch (error) { 2402 return handleError(error); 2403 } 2404 }, 2405 }, 2406 "/api/recordings/:id": { 2407 DELETE: async (req) => { 2408 try { 2409 const user = requireAuth(req); 2410 const recordingId = req.params.id; 2411 2412 const success = deletePendingRecording( 2413 recordingId, 2414 user.id, 2415 user.role === "admin", 2416 ); 2417 2418 if (!success) { 2419 return Response.json( 2420 { error: "Cannot delete this recording" }, 2421 { status: 403 }, 2422 ); 2423 } 2424 2425 return new Response(null, { status: 204 }); 2426 } catch (error) { 2427 return handleError(error); 2428 } 2429 }, 2430 }, 2431 "/api/transcriptions": { 2432 GET: async (req) => { 2433 try { 2434 const user = requireSubscription(req); 2435 const url = new URL(req.url); 2436 2437 // Parse pagination params 2438 const limit = Math.min( 2439 Number.parseInt(url.searchParams.get("limit") || "50", 10), 2440 100, 2441 ); 2442 const cursorParam = url.searchParams.get("cursor"); 2443 2444 let transcriptions: Array<{ 2445 id: string; 2446 filename: string; 2447 original_filename: string; 2448 class_id: string | null; 2449 status: string; 2450 progress: number; 2451 created_at: number; 2452 }>; 2453 2454 if (cursorParam) { 2455 // Decode cursor 2456 const { decodeCursor } = await import("./lib/cursor"); 2457 const parts = decodeCursor(cursorParam); 2458 2459 if (parts.length !== 2) { 2460 return Response.json( 2461 { error: "Invalid cursor format" }, 2462 { status: 400 }, 2463 ); 2464 } 2465 2466 const cursorTime = Number.parseInt(parts[0] || "", 10); 2467 const id = parts[1] || ""; 2468 2469 if (Number.isNaN(cursorTime) || !id) { 2470 return Response.json( 2471 { error: "Invalid cursor format" }, 2472 { status: 400 }, 2473 ); 2474 } 2475 2476 transcriptions = db 2477 .query< 2478 { 2479 id: string; 2480 filename: string; 2481 original_filename: string; 2482 class_id: string | null; 2483 status: string; 2484 progress: number; 2485 created_at: number; 2486 }, 2487 [number, number, string, number] 2488 >( 2489 `SELECT id, filename, original_filename, class_id, status, progress, created_at 2490 FROM transcriptions 2491 WHERE user_id = ? AND (created_at < ? OR (created_at = ? AND id < ?)) 2492 ORDER BY created_at DESC, id DESC 2493 LIMIT ?`, 2494 ) 2495 .all(user.id, cursorTime, cursorTime, id, limit + 1); 2496 } else { 2497 transcriptions = db 2498 .query< 2499 { 2500 id: string; 2501 filename: string; 2502 original_filename: string; 2503 class_id: string | null; 2504 status: string; 2505 progress: number; 2506 created_at: number; 2507 }, 2508 [number, number] 2509 >( 2510 `SELECT id, filename, original_filename, class_id, status, progress, created_at 2511 FROM transcriptions 2512 WHERE user_id = ? 2513 ORDER BY created_at DESC, id DESC 2514 LIMIT ?`, 2515 ) 2516 .all(user.id, limit + 1); 2517 } 2518 2519 // Check if there are more results 2520 const hasMore = transcriptions.length > limit; 2521 if (hasMore) { 2522 transcriptions.pop(); // Remove extra item 2523 } 2524 2525 // Build next cursor 2526 let nextCursor: string | null = null; 2527 if (hasMore && transcriptions.length > 0) { 2528 const { encodeCursor } = await import("./lib/cursor"); 2529 const last = transcriptions[transcriptions.length - 1]; 2530 if (last) { 2531 nextCursor = encodeCursor([last.created_at.toString(), last.id]); 2532 } 2533 } 2534 2535 // Load transcripts from files for completed jobs 2536 const jobs = await Promise.all( 2537 transcriptions.map(async (t) => { 2538 return { 2539 id: t.id, 2540 filename: t.original_filename, 2541 class_id: t.class_id, 2542 status: t.status, 2543 progress: t.progress, 2544 created_at: t.created_at, 2545 }; 2546 }), 2547 ); 2548 2549 return Response.json({ 2550 jobs, 2551 pagination: { 2552 limit, 2553 hasMore, 2554 nextCursor, 2555 }, 2556 }); 2557 } catch (error) { 2558 return handleError(error); 2559 } 2560 }, 2561 POST: async (req) => { 2562 try { 2563 const user = requireSubscription(req); 2564 2565 const rateLimitError = enforceRateLimit(req, "upload-transcription", { 2566 ip: { max: 20, windowSeconds: 60 * 60 }, 2567 }); 2568 if (rateLimitError) return rateLimitError; 2569 2570 const formData = await req.formData(); 2571 const file = formData.get("audio") as File; 2572 const classId = formData.get("class_id") as string | null; 2573 const sectionId = formData.get("section_id") as string | null; 2574 const recordingDateStr = formData.get("recording_date") as 2575 | string 2576 | null; 2577 2578 if (!file) throw ValidationErrors.missingField("audio"); 2579 2580 // If class_id provided, verify user is enrolled (or admin) 2581 if (classId) { 2582 const enrolled = isUserEnrolledInClass(user.id, classId); 2583 if (!enrolled && user.role !== "admin") { 2584 return Response.json( 2585 { error: "Not enrolled in this class" }, 2586 { status: 403 }, 2587 ); 2588 } 2589 2590 // Verify class exists 2591 const classInfo = getClassById(classId); 2592 if (!classInfo) { 2593 return Response.json( 2594 { error: "Class not found" }, 2595 { status: 404 }, 2596 ); 2597 } 2598 2599 // Check if class is archived 2600 if (classInfo.archived) { 2601 return Response.json( 2602 { error: "Cannot upload to archived class" }, 2603 { status: 400 }, 2604 ); 2605 } 2606 } 2607 2608 // Validate file type 2609 const fileExtension = file.name.split(".").pop()?.toLowerCase(); 2610 const allowedExtensions = [ 2611 "mp3", 2612 "wav", 2613 "m4a", 2614 "aac", 2615 "ogg", 2616 "webm", 2617 "flac", 2618 "mp4", 2619 ]; 2620 const isAudioType = 2621 file.type.startsWith("audio/") || file.type === "video/mp4"; 2622 const isAudioExtension = 2623 fileExtension && allowedExtensions.includes(fileExtension); 2624 2625 if (!isAudioType && !isAudioExtension) { 2626 throw ValidationErrors.unsupportedFileType( 2627 "MP3, WAV, M4A, AAC, OGG, WebM, FLAC", 2628 ); 2629 } 2630 2631 if (file.size > MAX_FILE_SIZE) { 2632 throw ValidationErrors.fileTooLarge("100MB"); 2633 } 2634 2635 // Generate unique filename 2636 const transcriptionId = crypto.randomUUID(); 2637 const filename = `${transcriptionId}.${fileExtension}`; 2638 2639 // Save file to disk 2640 const uploadDir = "./uploads"; 2641 await Bun.write(`${uploadDir}/${filename}`, file); 2642 2643 // Parse recording date (default to current time if not provided) 2644 const recordingDate = recordingDateStr 2645 ? Number.parseInt(recordingDateStr, 10) 2646 : Math.floor(Date.now() / 1000); 2647 2648 // Create database record (without meeting_time_id - will be set later via PATCH) 2649 db.run( 2650 "INSERT INTO transcriptions (id, user_id, class_id, meeting_time_id, section_id, filename, original_filename, status, recording_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", 2651 [ 2652 transcriptionId, 2653 user.id, 2654 classId, 2655 null, // meeting_time_id will be set via PATCH endpoint 2656 sectionId, 2657 filename, 2658 file.name, 2659 "pending", 2660 recordingDate, 2661 ], 2662 ); 2663 2664 // Don't auto-start transcription - admin will select recordings 2665 // whisperService.startTranscription(transcriptionId, filename); 2666 2667 return Response.json( 2668 { 2669 id: transcriptionId, 2670 message: "Upload successful", 2671 }, 2672 { status: 201 }, 2673 ); 2674 } catch (error) { 2675 return handleError(error); 2676 } 2677 }, 2678 }, 2679 "/api/admin/transcriptions": { 2680 GET: async (req) => { 2681 try { 2682 requireAdmin(req); 2683 const url = new URL(req.url); 2684 2685 const limit = Math.min( 2686 Number.parseInt(url.searchParams.get("limit") || "50", 10), 2687 100, 2688 ); 2689 const cursor = url.searchParams.get("cursor") || undefined; 2690 2691 const result = getAllTranscriptions(limit, cursor); 2692 return Response.json(result.data); // Return just the array for now, can add pagination UI later 2693 } catch (error) { 2694 return handleError(error); 2695 } 2696 }, 2697 }, 2698 "/api/admin/users": { 2699 GET: async (req) => { 2700 try { 2701 requireAdmin(req); 2702 const url = new URL(req.url); 2703 2704 const limit = Math.min( 2705 Number.parseInt(url.searchParams.get("limit") || "50", 10), 2706 100, 2707 ); 2708 const cursor = url.searchParams.get("cursor") || undefined; 2709 2710 const result = getAllUsersWithStats(limit, cursor); 2711 return Response.json(result.data); // Return just the array for now, can add pagination UI later 2712 } catch (error) { 2713 return handleError(error); 2714 } 2715 }, 2716 }, 2717 "/api/admin/classes": { 2718 GET: async (req) => { 2719 try { 2720 requireAdmin(req); 2721 const classes = getClassesForUser(0, true); // Admin sees all classes 2722 return Response.json({ classes }); 2723 } catch (error) { 2724 return handleError(error); 2725 } 2726 }, 2727 }, 2728 "/api/admin/waitlist": { 2729 GET: async (req) => { 2730 try { 2731 requireAdmin(req); 2732 const waitlist = getAllWaitlistEntries(); 2733 return Response.json({ waitlist }); 2734 } catch (error) { 2735 return handleError(error); 2736 } 2737 }, 2738 }, 2739 "/api/admin/waitlist/:id": { 2740 DELETE: async (req) => { 2741 try { 2742 requireAdmin(req); 2743 const id = req.params.id; 2744 deleteWaitlistEntry(id); 2745 return new Response(null, { status: 204 }); 2746 } catch (error) { 2747 return handleError(error); 2748 } 2749 }, 2750 }, 2751 "/api/admin/transcriptions/:id": { 2752 DELETE: async (req) => { 2753 try { 2754 requireAdmin(req); 2755 const transcriptionId = req.params.id; 2756 deleteTranscription(transcriptionId); 2757 return new Response(null, { status: 204 }); 2758 } catch (error) { 2759 return handleError(error); 2760 } 2761 }, 2762 }, 2763 "/api/admin/users/:id": { 2764 DELETE: async (req) => { 2765 try { 2766 requireAdmin(req); 2767 const userId = Number.parseInt(req.params.id, 10); 2768 if (Number.isNaN(userId)) { 2769 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2770 } 2771 await deleteUser(userId); 2772 return new Response(null, { status: 204 }); 2773 } catch (error) { 2774 return handleError(error); 2775 } 2776 }, 2777 }, 2778 "/api/admin/users/:id/role": { 2779 PUT: async (req) => { 2780 try { 2781 requireAdmin(req); 2782 const userId = Number.parseInt(req.params.id, 10); 2783 if (Number.isNaN(userId)) { 2784 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2785 } 2786 2787 const body = await req.json(); 2788 const { role } = body as { role: UserRole }; 2789 2790 if (!role || (role !== "user" && role !== "admin")) { 2791 return Response.json( 2792 { error: "Invalid role. Must be 'user' or 'admin'" }, 2793 { status: 400 }, 2794 ); 2795 } 2796 2797 updateUserRole(userId, role); 2798 return Response.json({ success: true }); 2799 } catch (error) { 2800 return handleError(error); 2801 } 2802 }, 2803 }, 2804 "/api/admin/users/:id/subscription": { 2805 DELETE: async (req) => { 2806 try { 2807 requireAdmin(req); 2808 const userId = Number.parseInt(req.params.id, 10); 2809 if (Number.isNaN(userId)) { 2810 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2811 } 2812 2813 const body = await req.json(); 2814 const { subscriptionId } = body as { subscriptionId: string }; 2815 2816 if (!subscriptionId) { 2817 return Response.json( 2818 { error: "Subscription ID required" }, 2819 { status: 400 }, 2820 ); 2821 } 2822 2823 try { 2824 const { polar } = await import("./lib/polar"); 2825 await polar.subscriptions.revoke({ id: subscriptionId }); 2826 return Response.json({ 2827 success: true, 2828 message: "Subscription revoked successfully", 2829 }); 2830 } catch (error) { 2831 console.error( 2832 `[Admin] Failed to revoke subscription ${subscriptionId}:`, 2833 error, 2834 ); 2835 return Response.json( 2836 { 2837 error: 2838 error instanceof Error 2839 ? error.message 2840 : "Failed to revoke subscription", 2841 }, 2842 { status: 500 }, 2843 ); 2844 } 2845 } catch (error) { 2846 return handleError(error); 2847 } 2848 }, 2849 PUT: async (req) => { 2850 try { 2851 requireAdmin(req); 2852 const userId = Number.parseInt(req.params.id, 10); 2853 if (Number.isNaN(userId)) { 2854 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2855 } 2856 2857 // Get user email 2858 const user = db 2859 .query<{ email: string }, [number]>( 2860 "SELECT email FROM users WHERE id = ?", 2861 ) 2862 .get(userId); 2863 2864 if (!user) { 2865 return Response.json({ error: "User not found" }, { status: 404 }); 2866 } 2867 2868 try { 2869 await syncUserSubscriptionsFromPolar(userId, user.email); 2870 return Response.json({ 2871 success: true, 2872 message: "Subscription synced successfully", 2873 }); 2874 } catch (error) { 2875 console.error( 2876 `[Admin] Failed to sync subscription for user ${userId}:`, 2877 error, 2878 ); 2879 return Response.json( 2880 { 2881 error: 2882 error instanceof Error 2883 ? error.message 2884 : "Failed to sync subscription", 2885 }, 2886 { status: 500 }, 2887 ); 2888 } 2889 } catch (error) { 2890 return handleError(error); 2891 } 2892 }, 2893 }, 2894 "/api/admin/users/:id/details": { 2895 GET: async (req) => { 2896 try { 2897 requireAdmin(req); 2898 const userId = Number.parseInt(req.params.id, 10); 2899 if (Number.isNaN(userId)) { 2900 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2901 } 2902 2903 const user = db 2904 .query< 2905 { 2906 id: number; 2907 email: string; 2908 name: string | null; 2909 avatar: string; 2910 created_at: number; 2911 role: UserRole; 2912 password_hash: string | null; 2913 last_login: number | null; 2914 }, 2915 [number] 2916 >( 2917 "SELECT id, email, name, avatar, created_at, role, password_hash, last_login FROM users WHERE id = ?", 2918 ) 2919 .get(userId); 2920 2921 if (!user) { 2922 return Response.json({ error: "User not found" }, { status: 404 }); 2923 } 2924 2925 const passkeys = getPasskeysForUser(userId); 2926 const sessions = getSessionsForUser(userId); 2927 2928 // Get transcription count 2929 const transcriptionCount = 2930 db 2931 .query<{ count: number }, [number]>( 2932 "SELECT COUNT(*) as count FROM transcriptions WHERE user_id = ?", 2933 ) 2934 .get(userId)?.count ?? 0; 2935 2936 return Response.json({ 2937 id: user.id, 2938 email: user.email, 2939 name: user.name, 2940 avatar: user.avatar, 2941 created_at: user.created_at, 2942 role: user.role, 2943 last_login: user.last_login, 2944 hasPassword: !!user.password_hash, 2945 transcriptionCount, 2946 passkeys: passkeys.map((pk) => ({ 2947 id: pk.id, 2948 name: pk.name, 2949 created_at: pk.created_at, 2950 last_used_at: pk.last_used_at, 2951 })), 2952 sessions: sessions.map((s) => ({ 2953 id: s.id, 2954 ip_address: s.ip_address, 2955 user_agent: s.user_agent, 2956 created_at: s.created_at, 2957 expires_at: s.expires_at, 2958 })), 2959 }); 2960 } catch (error) { 2961 return handleError(error); 2962 } 2963 }, 2964 }, 2965 "/api/admin/users/:id/password-reset": { 2966 POST: async (req) => { 2967 try { 2968 requireAdmin(req); 2969 const userId = Number.parseInt(req.params.id, 10); 2970 if (Number.isNaN(userId)) { 2971 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2972 } 2973 2974 // Get user details 2975 const user = db 2976 .query< 2977 { id: number; email: string; name: string | null }, 2978 [number] 2979 >("SELECT id, email, name FROM users WHERE id = ?") 2980 .get(userId); 2981 2982 if (!user) { 2983 return Response.json({ error: "User not found" }, { status: 404 }); 2984 } 2985 2986 // Create password reset token 2987 const origin = process.env.ORIGIN || "http://localhost:3000"; 2988 const resetToken = createPasswordResetToken(user.id); 2989 const resetLink = `${origin}/reset-password?token=${resetToken}`; 2990 2991 // Send password reset email 2992 await sendEmail({ 2993 to: user.email, 2994 subject: "Reset your password - Thistle", 2995 html: passwordResetTemplate({ 2996 name: user.name, 2997 resetLink, 2998 }), 2999 }); 3000 3001 return Response.json({ 3002 success: true, 3003 message: "Password reset email sent", 3004 }); 3005 } catch (error) { 3006 console.error("[Admin] Password reset error:", error); 3007 return handleError(error); 3008 } 3009 }, 3010 }, 3011 "/api/admin/users/:id/passkeys/:passkeyId": { 3012 DELETE: async (req) => { 3013 try { 3014 requireAdmin(req); 3015 const userId = Number.parseInt(req.params.id, 10); 3016 if (Number.isNaN(userId)) { 3017 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 3018 } 3019 3020 const { passkeyId } = req.params; 3021 deletePasskey(passkeyId, userId); 3022 return new Response(null, { status: 204 }); 3023 } catch (error) { 3024 return handleError(error); 3025 } 3026 }, 3027 }, 3028 "/api/admin/users/:id/name": { 3029 PUT: async (req) => { 3030 try { 3031 requireAdmin(req); 3032 const userId = Number.parseInt(req.params.id, 10); 3033 if (Number.isNaN(userId)) { 3034 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 3035 } 3036 3037 const body = await req.json(); 3038 const { name } = body as { name: string }; 3039 3040 const nameValidation = validateName(name); 3041 if (!nameValidation.valid) { 3042 return Response.json( 3043 { error: nameValidation.error }, 3044 { status: 400 }, 3045 ); 3046 } 3047 3048 updateUserName(userId, name.trim()); 3049 return Response.json({ success: true }); 3050 } catch (error) { 3051 return handleError(error); 3052 } 3053 }, 3054 }, 3055 "/api/admin/users/:id/email": { 3056 PUT: async (req) => { 3057 try { 3058 requireAdmin(req); 3059 const userId = Number.parseInt(req.params.id, 10); 3060 if (Number.isNaN(userId)) { 3061 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 3062 } 3063 3064 const body = await req.json(); 3065 const { email, skipVerification } = body as { 3066 email: string; 3067 skipVerification?: boolean; 3068 }; 3069 3070 const emailValidation = validateEmail(email); 3071 if (!emailValidation.valid) { 3072 return Response.json( 3073 { error: emailValidation.error }, 3074 { status: 400 }, 3075 ); 3076 } 3077 3078 // Check if email already exists 3079 const existing = db 3080 .query<{ id: number }, [string, number]>( 3081 "SELECT id FROM users WHERE email = ? AND id != ?", 3082 ) 3083 .get(email, userId); 3084 3085 if (existing) { 3086 return Response.json( 3087 { error: "Email already in use" }, 3088 { status: 409 }, 3089 ); 3090 } 3091 3092 if (skipVerification) { 3093 // Admin override: change email immediately without verification 3094 updateUserEmailAddress(userId, email); 3095 return Response.json({ 3096 success: true, 3097 message: "Email updated immediately (verification skipped)", 3098 }); 3099 } 3100 3101 // Get user's current email 3102 const user = db 3103 .query<{ email: string; name: string | null }, [number]>( 3104 "SELECT email, name FROM users WHERE id = ?", 3105 ) 3106 .get(userId); 3107 3108 if (!user) { 3109 return Response.json({ error: "User not found" }, { status: 404 }); 3110 } 3111 3112 // Send verification email to user's current email 3113 try { 3114 const token = createEmailChangeToken(userId, email); 3115 const origin = process.env.ORIGIN || "http://localhost:3000"; 3116 const verifyUrl = `${origin}/api/user/email/verify?token=${token}`; 3117 3118 await sendEmail({ 3119 to: user.email, 3120 subject: "Verify your email change", 3121 html: emailChangeTemplate({ 3122 name: user.name, 3123 currentEmail: user.email, 3124 newEmail: email, 3125 verifyLink: verifyUrl, 3126 }), 3127 }); 3128 3129 return Response.json({ 3130 success: true, 3131 message: `Verification email sent to ${user.email}`, 3132 pendingEmail: email, 3133 }); 3134 } catch (emailError) { 3135 console.error( 3136 "[Admin] Failed to send email change verification:", 3137 emailError, 3138 ); 3139 return Response.json( 3140 { error: "Failed to send verification email" }, 3141 { status: 500 }, 3142 ); 3143 } 3144 } catch (error) { 3145 return handleError(error); 3146 } 3147 }, 3148 }, 3149 "/api/admin/users/:id/sessions": { 3150 GET: async (req) => { 3151 try { 3152 requireAdmin(req); 3153 const userId = Number.parseInt(req.params.id, 10); 3154 if (Number.isNaN(userId)) { 3155 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 3156 } 3157 3158 const sessions = getSessionsForUser(userId); 3159 return Response.json(sessions); 3160 } catch (error) { 3161 return handleError(error); 3162 } 3163 }, 3164 DELETE: async (req) => { 3165 try { 3166 requireAdmin(req); 3167 const userId = Number.parseInt(req.params.id, 10); 3168 if (Number.isNaN(userId)) { 3169 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 3170 } 3171 3172 deleteAllUserSessions(userId); 3173 return new Response(null, { status: 204 }); 3174 } catch (error) { 3175 return handleError(error); 3176 } 3177 }, 3178 }, 3179 "/api/admin/users/:id/sessions/:sessionId": { 3180 DELETE: async (req) => { 3181 try { 3182 requireAdmin(req); 3183 const userId = Number.parseInt(req.params.id, 10); 3184 if (Number.isNaN(userId)) { 3185 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 3186 } 3187 3188 const { sessionId } = req.params; 3189 const success = deleteSessionById(sessionId, userId); 3190 3191 if (!success) { 3192 return Response.json( 3193 { error: "Session not found" }, 3194 { status: 404 }, 3195 ); 3196 } 3197 3198 return new Response(null, { status: 204 }); 3199 } catch (error) { 3200 return handleError(error); 3201 } 3202 }, 3203 }, 3204 "/api/admin/transcriptions/:id/details": { 3205 GET: async (req) => { 3206 try { 3207 requireAdmin(req); 3208 const transcriptionId = req.params.id; 3209 3210 const transcription = db 3211 .query< 3212 { 3213 id: string; 3214 original_filename: string; 3215 status: string; 3216 created_at: number; 3217 updated_at: number; 3218 error_message: string | null; 3219 user_id: number; 3220 }, 3221 [string] 3222 >( 3223 "SELECT id, original_filename, status, created_at, updated_at, error_message, user_id FROM transcriptions WHERE id = ?", 3224 ) 3225 .get(transcriptionId); 3226 3227 if (!transcription) { 3228 return Response.json( 3229 { error: "Transcription not found" }, 3230 { status: 404 }, 3231 ); 3232 } 3233 3234 const user = db 3235 .query<{ email: string; name: string | null }, [number]>( 3236 "SELECT email, name FROM users WHERE id = ?", 3237 ) 3238 .get(transcription.user_id); 3239 3240 return Response.json({ 3241 id: transcription.id, 3242 original_filename: transcription.original_filename, 3243 status: transcription.status, 3244 created_at: transcription.created_at, 3245 completed_at: transcription.updated_at, 3246 error_message: transcription.error_message, 3247 user_id: transcription.user_id, 3248 user_email: user?.email || "Unknown", 3249 user_name: user?.name || null, 3250 }); 3251 } catch (error) { 3252 return handleError(error); 3253 } 3254 }, 3255 }, 3256 "/api/classes": { 3257 GET: async (req) => { 3258 try { 3259 const user = requireAuth(req); 3260 const url = new URL(req.url); 3261 3262 const limit = Math.min( 3263 Number.parseInt(url.searchParams.get("limit") || "50", 10), 3264 100, 3265 ); 3266 const cursor = url.searchParams.get("cursor") || undefined; 3267 3268 const result = getClassesForUser( 3269 user.id, 3270 user.role === "admin", 3271 limit, 3272 cursor, 3273 ); 3274 3275 // Group by semester/year for all users 3276 const grouped: Record< 3277 string, 3278 Array<{ 3279 id: string; 3280 course_code: string; 3281 name: string; 3282 professor: string; 3283 semester: string; 3284 year: number; 3285 archived: boolean; 3286 }> 3287 > = {}; 3288 3289 for (const cls of result.data) { 3290 const key = `${cls.semester} ${cls.year}`; 3291 if (!grouped[key]) { 3292 grouped[key] = []; 3293 } 3294 grouped[key]?.push({ 3295 id: cls.id, 3296 course_code: cls.course_code, 3297 name: cls.name, 3298 professor: cls.professor, 3299 semester: cls.semester, 3300 year: cls.year, 3301 archived: cls.archived, 3302 }); 3303 } 3304 3305 return Response.json({ 3306 classes: grouped, 3307 pagination: result.pagination, 3308 }); 3309 } catch (error) { 3310 return handleError(error); 3311 } 3312 }, 3313 POST: async (req) => { 3314 try { 3315 requireAdmin(req); 3316 const body = await req.json(); 3317 const { 3318 course_code, 3319 name, 3320 professor, 3321 semester, 3322 year, 3323 meeting_times, 3324 } = body; 3325 3326 // Validate all required fields 3327 const courseCodeValidation = validateCourseCode(course_code); 3328 if (!courseCodeValidation.valid) { 3329 return Response.json( 3330 { error: courseCodeValidation.error }, 3331 { status: 400 }, 3332 ); 3333 } 3334 3335 const nameValidation = validateCourseName(name); 3336 if (!nameValidation.valid) { 3337 return Response.json( 3338 { error: nameValidation.error }, 3339 { status: 400 }, 3340 ); 3341 } 3342 3343 const professorValidation = validateName(professor, "Professor name"); 3344 if (!professorValidation.valid) { 3345 return Response.json( 3346 { error: professorValidation.error }, 3347 { status: 400 }, 3348 ); 3349 } 3350 3351 const semesterValidation = validateSemester(semester); 3352 if (!semesterValidation.valid) { 3353 return Response.json( 3354 { error: semesterValidation.error }, 3355 { status: 400 }, 3356 ); 3357 } 3358 3359 const yearValidation = validateYear(year); 3360 if (!yearValidation.valid) { 3361 return Response.json( 3362 { error: yearValidation.error }, 3363 { status: 400 }, 3364 ); 3365 } 3366 3367 const newClass = createClass({ 3368 course_code, 3369 name, 3370 professor, 3371 semester, 3372 year, 3373 meeting_times, 3374 sections: body.sections, 3375 }); 3376 3377 return Response.json(newClass, { status: 201 }); 3378 } catch (error) { 3379 return handleError(error); 3380 } 3381 }, 3382 }, 3383 "/api/classes/search": { 3384 GET: async (req) => { 3385 try { 3386 const user = requireAuth(req); 3387 const url = new URL(req.url); 3388 const query = url.searchParams.get("q"); 3389 3390 if (!query) { 3391 return Response.json({ classes: [] }); 3392 } 3393 3394 const classes = searchClassesByCourseCode(query); 3395 3396 // Get user's enrolled classes to mark them 3397 const enrolledClassIds = db 3398 .query<{ class_id: string }, [number]>( 3399 "SELECT class_id FROM class_members WHERE user_id = ?", 3400 ) 3401 .all(user.id) 3402 .map((row) => row.class_id); 3403 3404 // Add is_enrolled flag and sections to each class 3405 const classesWithEnrollment = classes.map((cls) => ({ 3406 ...cls, 3407 is_enrolled: enrolledClassIds.includes(cls.id), 3408 sections: getClassSections(cls.id), 3409 })); 3410 3411 return Response.json({ classes: classesWithEnrollment }); 3412 } catch (error) { 3413 return handleError(error); 3414 } 3415 }, 3416 }, 3417 "/api/classes/join": { 3418 POST: async (req) => { 3419 try { 3420 const user = requireAuth(req); 3421 const body = await req.json(); 3422 const classId = body.class_id; 3423 const sectionId = body.section_id || null; 3424 3425 const classIdValidation = validateClassId(classId); 3426 if (!classIdValidation.valid) { 3427 return Response.json( 3428 { error: classIdValidation.error }, 3429 { status: 400 }, 3430 ); 3431 } 3432 3433 const result = joinClass(classId, user.id, sectionId); 3434 3435 if (!result.success) { 3436 return Response.json({ error: result.error }, { status: 400 }); 3437 } 3438 3439 return new Response(null, { status: 204 }); 3440 } catch (error) { 3441 return handleError(error); 3442 } 3443 }, 3444 }, 3445 "/api/classes/waitlist": { 3446 POST: async (req) => { 3447 try { 3448 const user = requireAuth(req); 3449 const body = await req.json(); 3450 3451 const { 3452 courseCode, 3453 courseName, 3454 professor, 3455 semester, 3456 year, 3457 additionalInfo, 3458 meetingTimes, 3459 } = body; 3460 3461 // Validate all required fields 3462 const courseCodeValidation = validateCourseCode(courseCode); 3463 if (!courseCodeValidation.valid) { 3464 return Response.json( 3465 { error: courseCodeValidation.error }, 3466 { status: 400 }, 3467 ); 3468 } 3469 3470 const nameValidation = validateCourseName(courseName); 3471 if (!nameValidation.valid) { 3472 return Response.json( 3473 { error: nameValidation.error }, 3474 { status: 400 }, 3475 ); 3476 } 3477 3478 const professorValidation = validateName(professor, "Professor name"); 3479 if (!professorValidation.valid) { 3480 return Response.json( 3481 { error: professorValidation.error }, 3482 { status: 400 }, 3483 ); 3484 } 3485 3486 const semesterValidation = validateSemester(semester); 3487 if (!semesterValidation.valid) { 3488 return Response.json( 3489 { error: semesterValidation.error }, 3490 { status: 400 }, 3491 ); 3492 } 3493 3494 const yearValidation = validateYear( 3495 typeof year === "string" ? Number.parseInt(year, 10) : year, 3496 ); 3497 if (!yearValidation.valid) { 3498 return Response.json( 3499 { error: yearValidation.error }, 3500 { status: 400 }, 3501 ); 3502 } 3503 3504 const id = addToWaitlist( 3505 user.id, 3506 courseCode, 3507 courseName, 3508 professor, 3509 semester, 3510 Number.parseInt(year, 10), 3511 additionalInfo || null, 3512 meetingTimes || null, 3513 ); 3514 3515 return Response.json({ success: true, id }, { status: 201 }); 3516 } catch (error) { 3517 return handleError(error); 3518 } 3519 }, 3520 }, 3521 "/api/classes/:id": { 3522 GET: async (req) => { 3523 try { 3524 const user = requireAuth(req); 3525 const classId = req.params.id; 3526 3527 const classInfo = getClassById(classId); 3528 if (!classInfo) { 3529 return Response.json({ error: "Class not found" }, { status: 404 }); 3530 } 3531 3532 // Check enrollment or admin 3533 const isEnrolled = isUserEnrolledInClass(user.id, classId); 3534 if (!isEnrolled && user.role !== "admin") { 3535 return Response.json( 3536 { error: "Not enrolled in this class" }, 3537 { status: 403 }, 3538 ); 3539 } 3540 3541 const meetingTimes = getMeetingTimesForClass(classId); 3542 const sections = getClassSections(classId); 3543 const transcriptions = getTranscriptionsForClass(classId); 3544 const userSection = getUserSection(user.id, classId); 3545 3546 return Response.json({ 3547 class: classInfo, 3548 meetingTimes, 3549 sections, 3550 userSection, 3551 transcriptions, 3552 }); 3553 } catch (error) { 3554 return handleError(error); 3555 } 3556 }, 3557 DELETE: async (req) => { 3558 try { 3559 requireAdmin(req); 3560 const classId = req.params.id; 3561 3562 // Verify class exists 3563 const existingClass = getClassById(classId); 3564 if (!existingClass) { 3565 return Response.json({ error: "Class not found" }, { status: 404 }); 3566 } 3567 3568 deleteClass(classId); 3569 return new Response(null, { status: 204 }); 3570 } catch (error) { 3571 return handleError(error); 3572 } 3573 }, 3574 }, 3575 "/api/classes/:id/archive": { 3576 PUT: async (req) => { 3577 try { 3578 requireAdmin(req); 3579 const classId = req.params.id; 3580 const body = await req.json(); 3581 const { archived } = body; 3582 3583 if (typeof archived !== "boolean") { 3584 return Response.json( 3585 { error: "archived must be a boolean" }, 3586 { status: 400 }, 3587 ); 3588 } 3589 3590 // Verify class exists 3591 const existingClass = getClassById(classId); 3592 if (!existingClass) { 3593 return Response.json({ error: "Class not found" }, { status: 404 }); 3594 } 3595 3596 toggleClassArchive(classId, archived); 3597 return new Response(null, { status: 204 }); 3598 } catch (error) { 3599 return handleError(error); 3600 } 3601 }, 3602 }, 3603 "/api/classes/:id/members": { 3604 GET: async (req) => { 3605 try { 3606 requireAdmin(req); 3607 const classId = req.params.id; 3608 3609 const members = getClassMembers(classId); 3610 return Response.json({ members }); 3611 } catch (error) { 3612 return handleError(error); 3613 } 3614 }, 3615 POST: async (req) => { 3616 try { 3617 requireAdmin(req); 3618 const classId = req.params.id; 3619 const body = await req.json(); 3620 const { email } = body; 3621 3622 if (!email) { 3623 return Response.json({ error: "Email required" }, { status: 400 }); 3624 } 3625 3626 // Verify class exists 3627 const existingClass = getClassById(classId); 3628 if (!existingClass) { 3629 return Response.json({ error: "Class not found" }, { status: 404 }); 3630 } 3631 3632 const user = getUserByEmail(email); 3633 if (!user) { 3634 return Response.json({ error: "User not found" }, { status: 404 }); 3635 } 3636 3637 enrollUserInClass(user.id, classId); 3638 return new Response(null, { status: 201 }); 3639 } catch (error) { 3640 return handleError(error); 3641 } 3642 }, 3643 }, 3644 "/api/classes/:id/members/:userId": { 3645 DELETE: async (req) => { 3646 try { 3647 requireAdmin(req); 3648 const classId = req.params.id; 3649 const userId = Number.parseInt(req.params.userId, 10); 3650 3651 if (Number.isNaN(userId)) { 3652 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 3653 } 3654 3655 // Verify class exists 3656 const existingClass = getClassById(classId); 3657 if (!existingClass) { 3658 return Response.json({ error: "Class not found" }, { status: 404 }); 3659 } 3660 3661 removeUserFromClass(userId, classId); 3662 return new Response(null, { status: 204 }); 3663 } catch (error) { 3664 return handleError(error); 3665 } 3666 }, 3667 }, 3668 "/api/classes/:id/meetings": { 3669 GET: async (req) => { 3670 try { 3671 const user = requireAuth(req); 3672 const classId = req.params.id; 3673 3674 // Check enrollment or admin 3675 const isEnrolled = isUserEnrolledInClass(user.id, classId); 3676 if (!isEnrolled && user.role !== "admin") { 3677 return Response.json( 3678 { error: "Not enrolled in this class" }, 3679 { status: 403 }, 3680 ); 3681 } 3682 3683 const meetingTimes = getMeetingTimesForClass(classId); 3684 return Response.json({ meetings: meetingTimes }); 3685 } catch (error) { 3686 return handleError(error); 3687 } 3688 }, 3689 POST: async (req) => { 3690 try { 3691 requireAdmin(req); 3692 const classId = req.params.id; 3693 const body = await req.json(); 3694 const { label } = body; 3695 3696 if (!label) { 3697 return Response.json({ error: "Label required" }, { status: 400 }); 3698 } 3699 3700 // Verify class exists 3701 const existingClass = getClassById(classId); 3702 if (!existingClass) { 3703 return Response.json({ error: "Class not found" }, { status: 404 }); 3704 } 3705 3706 const meetingTime = createMeetingTime(classId, label); 3707 return Response.json(meetingTime, { status: 201 }); 3708 } catch (error) { 3709 return handleError(error); 3710 } 3711 }, 3712 }, 3713 "/api/classes/:id/sections": { 3714 POST: async (req) => { 3715 try { 3716 requireAdmin(req); 3717 const classId = req.params.id; 3718 const body = await req.json(); 3719 const { section_number } = body; 3720 3721 if (!section_number) { 3722 return Response.json({ error: "Section number required" }, { status: 400 }); 3723 } 3724 3725 const section = createClassSection(classId, section_number); 3726 return Response.json(section); 3727 } catch (error) { 3728 return handleError(error); 3729 } 3730 }, 3731 }, 3732 "/api/classes/:classId/sections/:sectionId": { 3733 DELETE: async (req) => { 3734 try { 3735 requireAdmin(req); 3736 const sectionId = req.params.sectionId; 3737 3738 // Check if any students are in this section 3739 const studentsInSection = db 3740 .query<{ count: number }, [string]>( 3741 "SELECT COUNT(*) as count FROM class_members WHERE section_id = ?", 3742 ) 3743 .get(sectionId); 3744 3745 if (studentsInSection && studentsInSection.count > 0) { 3746 return Response.json( 3747 { error: "Cannot delete section with enrolled students" }, 3748 { status: 400 }, 3749 ); 3750 } 3751 3752 db.run("DELETE FROM class_sections WHERE id = ?", [sectionId]); 3753 return new Response(null, { status: 204 }); 3754 } catch (error) { 3755 return handleError(error); 3756 } 3757 }, 3758 }, 3759 "/api/meetings/:id": { 3760 PUT: async (req) => { 3761 try { 3762 requireAdmin(req); 3763 const meetingId = req.params.id; 3764 const body = await req.json(); 3765 const { label } = body; 3766 3767 if (!label) { 3768 return Response.json({ error: "Label required" }, { status: 400 }); 3769 } 3770 3771 // Verify meeting exists 3772 const existingMeeting = getMeetingById(meetingId); 3773 if (!existingMeeting) { 3774 return Response.json( 3775 { error: "Meeting not found" }, 3776 { status: 404 }, 3777 ); 3778 } 3779 3780 updateMeetingTime(meetingId, label); 3781 return new Response(null, { status: 204 }); 3782 } catch (error) { 3783 return handleError(error); 3784 } 3785 }, 3786 DELETE: async (req) => { 3787 try { 3788 requireAdmin(req); 3789 const meetingId = req.params.id; 3790 3791 // Verify meeting exists 3792 const existingMeeting = getMeetingById(meetingId); 3793 if (!existingMeeting) { 3794 return Response.json( 3795 { error: "Meeting not found" }, 3796 { status: 404 }, 3797 ); 3798 } 3799 3800 deleteMeetingTime(meetingId); 3801 return new Response(null, { status: 204 }); 3802 } catch (error) { 3803 return handleError(error); 3804 } 3805 }, 3806 }, 3807 "/api/transcripts/:id/select": { 3808 PUT: async (req) => { 3809 try { 3810 requireAdmin(req); 3811 const transcriptId = req.params.id; 3812 3813 // Check if transcription exists and get its current status 3814 const transcription = db 3815 .query<{ filename: string; status: string }, [string]>( 3816 "SELECT filename, status FROM transcriptions WHERE id = ?", 3817 ) 3818 .get(transcriptId); 3819 3820 if (!transcription) { 3821 return Response.json( 3822 { error: "Transcription not found" }, 3823 { status: 404 }, 3824 ); 3825 } 3826 3827 // Validate that status is appropriate for selection (e.g., 'uploading' or 'pending') 3828 const validStatuses = ["uploading", "pending", "failed"]; 3829 if (!validStatuses.includes(transcription.status)) { 3830 return Response.json( 3831 { 3832 error: `Cannot select transcription with status: ${transcription.status}`, 3833 }, 3834 { status: 400 }, 3835 ); 3836 } 3837 3838 // Update status to 'selected' and start transcription 3839 db.run("UPDATE transcriptions SET status = ? WHERE id = ?", [ 3840 "selected", 3841 transcriptId, 3842 ]); 3843 3844 whisperService.startTranscription( 3845 transcriptId, 3846 transcription.filename, 3847 ); 3848 3849 return new Response(null, { status: 204 }); 3850 } catch (error) { 3851 return handleError(error); 3852 } 3853 }, 3854 }, 3855 }, 3856 development: process.env.NODE_ENV === "dev", 3857 fetch(req, server) { 3858 const response = server.fetch(req); 3859 3860 // Add security headers to all responses 3861 if (response instanceof Response) { 3862 const headers = new Headers(response.headers); 3863 headers.set("Permissions-Policy", "interest-cohort=()"); 3864 headers.set("X-Content-Type-Options", "nosniff"); 3865 headers.set("X-Frame-Options", "DENY"); 3866 headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); 3867 3868 // Set CSP that allows inline styles with unsafe-inline (needed for Lit components) 3869 // and script-src 'self' for bundled scripts 3870 headers.set( 3871 "Content-Security-Policy", 3872 "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';", 3873 ); 3874 3875 return new Response(response.body, { 3876 status: response.status, 3877 statusText: response.statusText, 3878 headers, 3879 }); 3880 } 3881 3882 return response; 3883 }, 3884}); 3885console.log(`馃 Thistle running at http://localhost:${server.port}`); 3886 3887// Track active SSE streams for graceful shutdown 3888const activeSSEStreams = new Set<ReadableStreamDefaultController>(); 3889 3890// Graceful shutdown handler 3891let isShuttingDown = false; 3892 3893async function shutdown(signal: string) { 3894 if (isShuttingDown) return; 3895 isShuttingDown = true; 3896 3897 console.log(`\n${signal} received, starting graceful shutdown...`); 3898 3899 // 1. Stop accepting new requests 3900 console.log("[Shutdown] Closing server..."); 3901 server.stop(); 3902 3903 // 2. Close all active SSE streams (safe to kill - sync will handle reconnection) 3904 console.log( 3905 `[Shutdown] Closing ${activeSSEStreams.size} active SSE streams...`, 3906 ); 3907 for (const controller of activeSSEStreams) { 3908 try { 3909 controller.close(); 3910 } catch { 3911 // Already closed 3912 } 3913 } 3914 activeSSEStreams.clear(); 3915 3916 // 3. Stop transcription service (closes streams to Murmur) 3917 whisperService.stop(); 3918 3919 // 4. Stop cleanup intervals 3920 console.log("[Shutdown] Stopping cleanup intervals..."); 3921 clearInterval(sessionCleanupInterval); 3922 clearInterval(syncInterval); 3923 clearInterval(fileCleanupInterval); 3924 3925 // 5. Close database connections 3926 console.log("[Shutdown] Closing database..."); 3927 db.close(); 3928 3929 console.log("[Shutdown] Complete"); 3930 process.exit(0); 3931} 3932 3933// Register shutdown handlers 3934process.on("SIGTERM", () => shutdown("SIGTERM")); 3935process.on("SIGINT", () => shutdown("SIGINT"));