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