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