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