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