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