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