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