馃 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 daily 296const fileCleanupInterval = setInterval( 297 () => whisperService.cleanupStaleFiles(), 298 24 * 60 * 60 * 1000, 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/transcriptions/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 uploadsDir = Bun.file("./uploads"); 1857 const transcriptsDir = Bun.file("./transcripts"); 1858 const uploadsExists = await uploadsDir.exists(); 1859 const transcriptsExists = await transcriptsDir.exists(); 1860 health.services.storage = uploadsExists && transcriptsExists; 1861 if (!health.services.storage) { 1862 health.status = "unhealthy"; 1863 health.details.storageNote = `Missing directories: ${[ 1864 !uploadsExists && "uploads", 1865 !transcriptsExists && "transcripts", 1866 ] 1867 .filter(Boolean) 1868 .join(", ")}`; 1869 } 1870 } catch (error) { 1871 health.status = "unhealthy"; 1872 health.details.storageError = 1873 error instanceof Error ? error.message : String(error); 1874 } 1875 1876 const statusCode = health.status === "healthy" ? 200 : 503; 1877 return Response.json(health, { status: statusCode }); 1878 }, 1879 }, 1880 "/api/transcriptions/:id": { 1881 GET: async (req) => { 1882 try { 1883 const user = requireAuth(req); 1884 const transcriptionId = req.params.id; 1885 1886 // Verify ownership or admin 1887 const transcription = db 1888 .query< 1889 { 1890 id: string; 1891 user_id: number; 1892 class_id: string | null; 1893 filename: string; 1894 original_filename: string; 1895 status: string; 1896 progress: number; 1897 created_at: number; 1898 }, 1899 [string] 1900 >( 1901 "SELECT id, user_id, class_id, filename, original_filename, status, progress, created_at FROM transcriptions WHERE id = ?", 1902 ) 1903 .get(transcriptionId); 1904 1905 if (!transcription) { 1906 return Response.json( 1907 { error: "Transcription not found" }, 1908 { status: 404 }, 1909 ); 1910 } 1911 1912 // Check access permissions 1913 const isOwner = transcription.user_id === user.id; 1914 const isAdmin = user.role === "admin"; 1915 let isClassMember = false; 1916 1917 // If transcription belongs to a class, check enrollment 1918 if (transcription.class_id) { 1919 isClassMember = isUserEnrolledInClass( 1920 user.id, 1921 transcription.class_id, 1922 ); 1923 } 1924 1925 // Allow access if: owner, admin, or enrolled in the class 1926 if (!isOwner && !isAdmin && !isClassMember) { 1927 return Response.json( 1928 { error: "Transcription not found" }, 1929 { status: 404 }, 1930 ); 1931 } 1932 1933 // Require subscription only if accessing own transcription (not class) 1934 if ( 1935 isOwner && 1936 !transcription.class_id && 1937 !isAdmin && 1938 !hasActiveSubscription(user.id) 1939 ) { 1940 throw AuthErrors.subscriptionRequired(); 1941 } 1942 1943 if (transcription.status !== "completed") { 1944 return Response.json( 1945 { error: "Transcription not completed yet" }, 1946 { status: 400 }, 1947 ); 1948 } 1949 1950 // Get format from query parameter 1951 const url = new URL(req.url); 1952 const format = url.searchParams.get("format"); 1953 1954 // Return WebVTT format if requested 1955 if (format === "vtt") { 1956 const vttContent = await getTranscriptVTT(transcriptionId); 1957 1958 if (!vttContent) { 1959 return Response.json( 1960 { error: "VTT transcript not available" }, 1961 { status: 404 }, 1962 ); 1963 } 1964 1965 return new Response(vttContent, { 1966 headers: { 1967 "Content-Type": "text/vtt", 1968 "Content-Disposition": `attachment; filename="${transcription.original_filename}.vtt"`, 1969 }, 1970 }); 1971 } 1972 1973 // return info on transcript 1974 const transcript = { 1975 id: transcription.id, 1976 filename: transcription.original_filename, 1977 status: transcription.status, 1978 progress: transcription.progress, 1979 created_at: transcription.created_at, 1980 }; 1981 return new Response(JSON.stringify(transcript), { 1982 headers: { 1983 "Content-Type": "application/json", 1984 }, 1985 }); 1986 } catch (error) { 1987 return handleError(error); 1988 } 1989 }, 1990 }, 1991 "/api/transcriptions/:id/audio": { 1992 GET: async (req) => { 1993 try { 1994 const user = requireAuth(req); 1995 const transcriptionId = req.params.id; 1996 1997 // Verify ownership or admin 1998 const transcription = db 1999 .query< 2000 { 2001 id: string; 2002 user_id: number; 2003 class_id: string | null; 2004 filename: string; 2005 status: string; 2006 }, 2007 [string] 2008 >( 2009 "SELECT id, user_id, class_id, filename, status FROM transcriptions WHERE id = ?", 2010 ) 2011 .get(transcriptionId); 2012 2013 if (!transcription) { 2014 return Response.json( 2015 { error: "Transcription not found" }, 2016 { status: 404 }, 2017 ); 2018 } 2019 2020 // Check access permissions 2021 const isOwner = transcription.user_id === user.id; 2022 const isAdmin = user.role === "admin"; 2023 let isClassMember = false; 2024 2025 // If transcription belongs to a class, check enrollment 2026 if (transcription.class_id) { 2027 isClassMember = isUserEnrolledInClass( 2028 user.id, 2029 transcription.class_id, 2030 ); 2031 } 2032 2033 // Allow access if: owner, admin, or enrolled in the class 2034 if (!isOwner && !isAdmin && !isClassMember) { 2035 return Response.json( 2036 { error: "Transcription not found" }, 2037 { status: 404 }, 2038 ); 2039 } 2040 2041 // Require subscription only if accessing own transcription (not class) 2042 if ( 2043 isOwner && 2044 !transcription.class_id && 2045 !isAdmin && 2046 !hasActiveSubscription(user.id) 2047 ) { 2048 throw AuthErrors.subscriptionRequired(); 2049 } 2050 2051 // For pending recordings, audio file exists even though transcription isn't complete 2052 // Allow audio access for pending and completed statuses 2053 if ( 2054 transcription.status !== "completed" && 2055 transcription.status !== "pending" 2056 ) { 2057 return Response.json( 2058 { error: "Audio not available yet" }, 2059 { status: 400 }, 2060 ); 2061 } 2062 2063 // Serve the audio file with range request support 2064 const filePath = `./uploads/${transcription.filename}`; 2065 const file = Bun.file(filePath); 2066 2067 if (!(await file.exists())) { 2068 return Response.json( 2069 { error: "Audio file not found" }, 2070 { status: 404 }, 2071 ); 2072 } 2073 2074 const fileSize = file.size; 2075 const range = req.headers.get("range"); 2076 2077 // Handle range requests for seeking 2078 if (range) { 2079 const parts = range.replace(/bytes=/, "").split("-"); 2080 const start = Number.parseInt(parts[0] || "0", 10); 2081 const end = parts[1] ? Number.parseInt(parts[1], 10) : fileSize - 1; 2082 const chunkSize = end - start + 1; 2083 2084 const fileSlice = file.slice(start, end + 1); 2085 2086 return new Response(fileSlice, { 2087 status: 206, 2088 headers: { 2089 "Content-Range": `bytes ${start}-${end}/${fileSize}`, 2090 "Accept-Ranges": "bytes", 2091 "Content-Length": chunkSize.toString(), 2092 "Content-Type": file.type || "audio/mpeg", 2093 }, 2094 }); 2095 } 2096 2097 // No range request, send entire file 2098 return new Response(file, { 2099 headers: { 2100 "Content-Type": file.type || "audio/mpeg", 2101 "Accept-Ranges": "bytes", 2102 "Content-Length": fileSize.toString(), 2103 }, 2104 }); 2105 } catch (error) { 2106 return handleError(error); 2107 } 2108 }, 2109 }, 2110 "/api/transcriptions": { 2111 GET: async (req) => { 2112 try { 2113 const user = requireSubscription(req); 2114 const url = new URL(req.url); 2115 2116 // Parse pagination params 2117 const limit = Math.min( 2118 Number.parseInt(url.searchParams.get("limit") || "50", 10), 2119 100, 2120 ); 2121 const cursorParam = url.searchParams.get("cursor"); 2122 2123 let transcriptions: Array<{ 2124 id: string; 2125 filename: string; 2126 original_filename: string; 2127 class_id: string | null; 2128 status: string; 2129 progress: number; 2130 created_at: number; 2131 }>; 2132 2133 if (cursorParam) { 2134 // Decode cursor 2135 const { decodeCursor } = await import("./lib/cursor"); 2136 const parts = decodeCursor(cursorParam); 2137 2138 if (parts.length !== 2) { 2139 return Response.json( 2140 { error: "Invalid cursor format" }, 2141 { status: 400 }, 2142 ); 2143 } 2144 2145 const cursorTime = Number.parseInt(parts[0] || "", 10); 2146 const id = parts[1] || ""; 2147 2148 if (Number.isNaN(cursorTime) || !id) { 2149 return Response.json( 2150 { error: "Invalid cursor format" }, 2151 { status: 400 }, 2152 ); 2153 } 2154 2155 transcriptions = db 2156 .query< 2157 { 2158 id: string; 2159 filename: string; 2160 original_filename: string; 2161 class_id: string | null; 2162 status: string; 2163 progress: number; 2164 created_at: number; 2165 }, 2166 [number, number, string, number] 2167 >( 2168 `SELECT id, filename, original_filename, class_id, status, progress, created_at 2169 FROM transcriptions 2170 WHERE user_id = ? AND (created_at < ? OR (created_at = ? AND id < ?)) 2171 ORDER BY created_at DESC, id DESC 2172 LIMIT ?`, 2173 ) 2174 .all(user.id, cursorTime, cursorTime, id, limit + 1); 2175 } else { 2176 transcriptions = db 2177 .query< 2178 { 2179 id: string; 2180 filename: string; 2181 original_filename: string; 2182 class_id: string | null; 2183 status: string; 2184 progress: number; 2185 created_at: number; 2186 }, 2187 [number, number] 2188 >( 2189 `SELECT id, filename, original_filename, class_id, status, progress, created_at 2190 FROM transcriptions 2191 WHERE user_id = ? 2192 ORDER BY created_at DESC, id DESC 2193 LIMIT ?`, 2194 ) 2195 .all(user.id, limit + 1); 2196 } 2197 2198 // Check if there are more results 2199 const hasMore = transcriptions.length > limit; 2200 if (hasMore) { 2201 transcriptions.pop(); // Remove extra item 2202 } 2203 2204 // Build next cursor 2205 let nextCursor: string | null = null; 2206 if (hasMore && transcriptions.length > 0) { 2207 const { encodeCursor } = await import("./lib/cursor"); 2208 const last = transcriptions[transcriptions.length - 1]; 2209 if (last) { 2210 nextCursor = encodeCursor([last.created_at.toString(), last.id]); 2211 } 2212 } 2213 2214 // Load transcripts from files for completed jobs 2215 const jobs = await Promise.all( 2216 transcriptions.map(async (t) => { 2217 return { 2218 id: t.id, 2219 filename: t.original_filename, 2220 class_id: t.class_id, 2221 status: t.status, 2222 progress: t.progress, 2223 created_at: t.created_at, 2224 }; 2225 }), 2226 ); 2227 2228 return Response.json({ 2229 jobs, 2230 pagination: { 2231 limit, 2232 hasMore, 2233 nextCursor, 2234 }, 2235 }); 2236 } catch (error) { 2237 return handleError(error); 2238 } 2239 }, 2240 POST: async (req) => { 2241 try { 2242 const user = requireSubscription(req); 2243 2244 const rateLimitError = enforceRateLimit(req, "upload-transcription", { 2245 ip: { max: 20, windowSeconds: 60 * 60 }, 2246 }); 2247 if (rateLimitError) return rateLimitError; 2248 2249 const formData = await req.formData(); 2250 const file = formData.get("audio") as File; 2251 const classId = formData.get("class_id") as string | null; 2252 const meetingTimeId = formData.get("meeting_time_id") as 2253 | string 2254 | null; 2255 2256 if (!file) throw ValidationErrors.missingField("audio"); 2257 2258 // If class_id provided, verify user is enrolled (or admin) 2259 if (classId) { 2260 const enrolled = isUserEnrolledInClass(user.id, classId); 2261 if (!enrolled && user.role !== "admin") { 2262 return Response.json( 2263 { error: "Not enrolled in this class" }, 2264 { status: 403 }, 2265 ); 2266 } 2267 2268 // Verify class exists 2269 const classInfo = getClassById(classId); 2270 if (!classInfo) { 2271 return Response.json( 2272 { error: "Class not found" }, 2273 { status: 404 }, 2274 ); 2275 } 2276 2277 // Check if class is archived 2278 if (classInfo.archived) { 2279 return Response.json( 2280 { error: "Cannot upload to archived class" }, 2281 { status: 400 }, 2282 ); 2283 } 2284 } 2285 2286 // Validate file type 2287 const fileExtension = file.name.split(".").pop()?.toLowerCase(); 2288 const allowedExtensions = [ 2289 "mp3", 2290 "wav", 2291 "m4a", 2292 "aac", 2293 "ogg", 2294 "webm", 2295 "flac", 2296 "mp4", 2297 ]; 2298 const isAudioType = 2299 file.type.startsWith("audio/") || file.type === "video/mp4"; 2300 const isAudioExtension = 2301 fileExtension && allowedExtensions.includes(fileExtension); 2302 2303 if (!isAudioType && !isAudioExtension) { 2304 throw ValidationErrors.unsupportedFileType( 2305 "MP3, WAV, M4A, AAC, OGG, WebM, FLAC", 2306 ); 2307 } 2308 2309 if (file.size > MAX_FILE_SIZE) { 2310 throw ValidationErrors.fileTooLarge("100MB"); 2311 } 2312 2313 // Generate unique filename 2314 const transcriptionId = crypto.randomUUID(); 2315 const filename = `${transcriptionId}.${fileExtension}`; 2316 2317 // Save file to disk 2318 const uploadDir = "./uploads"; 2319 await Bun.write(`${uploadDir}/${filename}`, file); 2320 2321 // Create database record 2322 db.run( 2323 "INSERT INTO transcriptions (id, user_id, class_id, meeting_time_id, filename, original_filename, status) VALUES (?, ?, ?, ?, ?, ?, ?)", 2324 [ 2325 transcriptionId, 2326 user.id, 2327 classId, 2328 meetingTimeId, 2329 filename, 2330 file.name, 2331 "pending", 2332 ], 2333 ); 2334 2335 // Don't auto-start transcription - admin will select recordings 2336 // whisperService.startTranscription(transcriptionId, filename); 2337 2338 return Response.json({ 2339 id: transcriptionId, 2340 message: "Upload successful", 2341 }); 2342 } catch (error) { 2343 return handleError(error); 2344 } 2345 }, 2346 }, 2347 "/api/admin/transcriptions": { 2348 GET: async (req) => { 2349 try { 2350 requireAdmin(req); 2351 const url = new URL(req.url); 2352 2353 const limit = Math.min( 2354 Number.parseInt(url.searchParams.get("limit") || "50", 10), 2355 100, 2356 ); 2357 const cursor = url.searchParams.get("cursor") || undefined; 2358 2359 const result = getAllTranscriptions(limit, cursor); 2360 return Response.json(result.data); // Return just the array for now, can add pagination UI later 2361 } catch (error) { 2362 return handleError(error); 2363 } 2364 }, 2365 }, 2366 "/api/admin/users": { 2367 GET: async (req) => { 2368 try { 2369 requireAdmin(req); 2370 const url = new URL(req.url); 2371 2372 const limit = Math.min( 2373 Number.parseInt(url.searchParams.get("limit") || "50", 10), 2374 100, 2375 ); 2376 const cursor = url.searchParams.get("cursor") || undefined; 2377 2378 const result = getAllUsersWithStats(limit, cursor); 2379 return Response.json(result.data); // Return just the array for now, can add pagination UI later 2380 } catch (error) { 2381 return handleError(error); 2382 } 2383 }, 2384 }, 2385 "/api/admin/classes": { 2386 GET: async (req) => { 2387 try { 2388 requireAdmin(req); 2389 const classes = getClassesForUser(0, true); // Admin sees all classes 2390 return Response.json({ classes }); 2391 } catch (error) { 2392 return handleError(error); 2393 } 2394 }, 2395 }, 2396 "/api/admin/waitlist": { 2397 GET: async (req) => { 2398 try { 2399 requireAdmin(req); 2400 const waitlist = getAllWaitlistEntries(); 2401 return Response.json({ waitlist }); 2402 } catch (error) { 2403 return handleError(error); 2404 } 2405 }, 2406 }, 2407 "/api/admin/waitlist/:id": { 2408 DELETE: async (req) => { 2409 try { 2410 requireAdmin(req); 2411 const id = req.params.id; 2412 deleteWaitlistEntry(id); 2413 return Response.json({ success: true }); 2414 } catch (error) { 2415 return handleError(error); 2416 } 2417 }, 2418 }, 2419 "/api/admin/transcriptions/:id": { 2420 DELETE: async (req) => { 2421 try { 2422 requireAdmin(req); 2423 const transcriptionId = req.params.id; 2424 deleteTranscription(transcriptionId); 2425 return Response.json({ success: true }); 2426 } catch (error) { 2427 return handleError(error); 2428 } 2429 }, 2430 }, 2431 "/api/admin/users/:id": { 2432 DELETE: async (req) => { 2433 try { 2434 requireAdmin(req); 2435 const userId = Number.parseInt(req.params.id, 10); 2436 if (Number.isNaN(userId)) { 2437 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2438 } 2439 await deleteUser(userId); 2440 return Response.json({ success: true }); 2441 } catch (error) { 2442 return handleError(error); 2443 } 2444 }, 2445 }, 2446 "/api/admin/users/:id/role": { 2447 PUT: async (req) => { 2448 try { 2449 requireAdmin(req); 2450 const userId = Number.parseInt(req.params.id, 10); 2451 if (Number.isNaN(userId)) { 2452 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2453 } 2454 2455 const body = await req.json(); 2456 const { role } = body as { role: UserRole }; 2457 2458 if (!role || (role !== "user" && role !== "admin")) { 2459 return Response.json( 2460 { error: "Invalid role. Must be 'user' or 'admin'" }, 2461 { status: 400 }, 2462 ); 2463 } 2464 2465 updateUserRole(userId, role); 2466 return Response.json({ success: true }); 2467 } catch (error) { 2468 return handleError(error); 2469 } 2470 }, 2471 }, 2472 "/api/admin/users/:id/subscription": { 2473 DELETE: async (req) => { 2474 try { 2475 requireAdmin(req); 2476 const userId = Number.parseInt(req.params.id, 10); 2477 if (Number.isNaN(userId)) { 2478 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2479 } 2480 2481 const body = await req.json(); 2482 const { subscriptionId } = body as { subscriptionId: string }; 2483 2484 if (!subscriptionId) { 2485 return Response.json( 2486 { error: "Subscription ID required" }, 2487 { status: 400 }, 2488 ); 2489 } 2490 2491 try { 2492 const { polar } = await import("./lib/polar"); 2493 await polar.subscriptions.revoke({ id: subscriptionId }); 2494 return Response.json({ 2495 success: true, 2496 message: "Subscription revoked successfully", 2497 }); 2498 } catch (error) { 2499 console.error( 2500 `[Admin] Failed to revoke subscription ${subscriptionId}:`, 2501 error, 2502 ); 2503 return Response.json( 2504 { 2505 error: 2506 error instanceof Error 2507 ? error.message 2508 : "Failed to revoke subscription", 2509 }, 2510 { status: 500 }, 2511 ); 2512 } 2513 } catch (error) { 2514 return handleError(error); 2515 } 2516 }, 2517 PUT: async (req) => { 2518 try { 2519 requireAdmin(req); 2520 const userId = Number.parseInt(req.params.id, 10); 2521 if (Number.isNaN(userId)) { 2522 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2523 } 2524 2525 // Get user email 2526 const user = db 2527 .query<{ email: string }, [number]>( 2528 "SELECT email FROM users WHERE id = ?", 2529 ) 2530 .get(userId); 2531 2532 if (!user) { 2533 return Response.json({ error: "User not found" }, { status: 404 }); 2534 } 2535 2536 try { 2537 await syncUserSubscriptionsFromPolar(userId, user.email); 2538 return Response.json({ 2539 success: true, 2540 message: "Subscription synced successfully", 2541 }); 2542 } catch (error) { 2543 console.error( 2544 `[Admin] Failed to sync subscription for user ${userId}:`, 2545 error, 2546 ); 2547 return Response.json( 2548 { 2549 error: 2550 error instanceof Error 2551 ? error.message 2552 : "Failed to sync subscription", 2553 }, 2554 { status: 500 }, 2555 ); 2556 } 2557 } catch (error) { 2558 return handleError(error); 2559 } 2560 }, 2561 }, 2562 "/api/admin/users/:id/details": { 2563 GET: async (req) => { 2564 try { 2565 requireAdmin(req); 2566 const userId = Number.parseInt(req.params.id, 10); 2567 if (Number.isNaN(userId)) { 2568 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2569 } 2570 2571 const user = db 2572 .query< 2573 { 2574 id: number; 2575 email: string; 2576 name: string | null; 2577 avatar: string; 2578 created_at: number; 2579 role: UserRole; 2580 password_hash: string | null; 2581 last_login: number | null; 2582 }, 2583 [number] 2584 >( 2585 "SELECT id, email, name, avatar, created_at, role, password_hash, last_login FROM users WHERE id = ?", 2586 ) 2587 .get(userId); 2588 2589 if (!user) { 2590 return Response.json({ error: "User not found" }, { status: 404 }); 2591 } 2592 2593 const passkeys = getPasskeysForUser(userId); 2594 const sessions = getSessionsForUser(userId); 2595 2596 // Get transcription count 2597 const transcriptionCount = 2598 db 2599 .query<{ count: number }, [number]>( 2600 "SELECT COUNT(*) as count FROM transcriptions WHERE user_id = ?", 2601 ) 2602 .get(userId)?.count ?? 0; 2603 2604 return Response.json({ 2605 id: user.id, 2606 email: user.email, 2607 name: user.name, 2608 avatar: user.avatar, 2609 created_at: user.created_at, 2610 role: user.role, 2611 last_login: user.last_login, 2612 hasPassword: !!user.password_hash, 2613 transcriptionCount, 2614 passkeys: passkeys.map((pk) => ({ 2615 id: pk.id, 2616 name: pk.name, 2617 created_at: pk.created_at, 2618 last_used_at: pk.last_used_at, 2619 })), 2620 sessions: sessions.map((s) => ({ 2621 id: s.id, 2622 ip_address: s.ip_address, 2623 user_agent: s.user_agent, 2624 created_at: s.created_at, 2625 expires_at: s.expires_at, 2626 })), 2627 }); 2628 } catch (error) { 2629 return handleError(error); 2630 } 2631 }, 2632 }, 2633 "/api/admin/users/:id/password-reset": { 2634 POST: async (req) => { 2635 try { 2636 requireAdmin(req); 2637 const userId = Number.parseInt(req.params.id, 10); 2638 if (Number.isNaN(userId)) { 2639 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2640 } 2641 2642 // Get user details 2643 const user = db 2644 .query< 2645 { id: number; email: string; name: string | null }, 2646 [number] 2647 >("SELECT id, email, name FROM users WHERE id = ?") 2648 .get(userId); 2649 2650 if (!user) { 2651 return Response.json({ error: "User not found" }, { status: 404 }); 2652 } 2653 2654 // Create password reset token 2655 const origin = process.env.ORIGIN || "http://localhost:3000"; 2656 const resetToken = createPasswordResetToken(user.id); 2657 const resetLink = `${origin}/reset-password?token=${resetToken}`; 2658 2659 // Send password reset email 2660 await sendEmail({ 2661 to: user.email, 2662 subject: "Reset your password - Thistle", 2663 html: passwordResetTemplate({ 2664 name: user.name, 2665 resetLink, 2666 }), 2667 }); 2668 2669 return Response.json({ 2670 success: true, 2671 message: "Password reset email sent", 2672 }); 2673 } catch (error) { 2674 console.error("[Admin] Password reset error:", error); 2675 return handleError(error); 2676 } 2677 }, 2678 }, 2679 "/api/admin/users/:id/passkeys/:passkeyId": { 2680 DELETE: async (req) => { 2681 try { 2682 requireAdmin(req); 2683 const userId = Number.parseInt(req.params.id, 10); 2684 if (Number.isNaN(userId)) { 2685 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2686 } 2687 2688 const { passkeyId } = req.params; 2689 deletePasskey(passkeyId, userId); 2690 return Response.json({ success: true }); 2691 } catch (error) { 2692 return handleError(error); 2693 } 2694 }, 2695 }, 2696 "/api/admin/users/:id/name": { 2697 PUT: async (req) => { 2698 try { 2699 requireAdmin(req); 2700 const userId = Number.parseInt(req.params.id, 10); 2701 if (Number.isNaN(userId)) { 2702 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2703 } 2704 2705 const body = await req.json(); 2706 const { name } = body as { name: string }; 2707 2708 const nameValidation = validateName(name); 2709 if (!nameValidation.valid) { 2710 return Response.json( 2711 { error: nameValidation.error }, 2712 { status: 400 }, 2713 ); 2714 } 2715 2716 updateUserName(userId, name.trim()); 2717 return Response.json({ success: true }); 2718 } catch (error) { 2719 return handleError(error); 2720 } 2721 }, 2722 }, 2723 "/api/admin/users/:id/email": { 2724 PUT: async (req) => { 2725 try { 2726 requireAdmin(req); 2727 const userId = Number.parseInt(req.params.id, 10); 2728 if (Number.isNaN(userId)) { 2729 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2730 } 2731 2732 const body = await req.json(); 2733 const { email, skipVerification } = body as { 2734 email: string; 2735 skipVerification?: boolean; 2736 }; 2737 2738 const emailValidation = validateEmail(email); 2739 if (!emailValidation.valid) { 2740 return Response.json( 2741 { error: emailValidation.error }, 2742 { status: 400 }, 2743 ); 2744 } 2745 2746 // Check if email already exists 2747 const existing = db 2748 .query<{ id: number }, [string, number]>( 2749 "SELECT id FROM users WHERE email = ? AND id != ?", 2750 ) 2751 .get(email, userId); 2752 2753 if (existing) { 2754 return Response.json( 2755 { error: "Email already in use" }, 2756 { status: 400 }, 2757 ); 2758 } 2759 2760 if (skipVerification) { 2761 // Admin override: change email immediately without verification 2762 updateUserEmailAddress(userId, email); 2763 return Response.json({ 2764 success: true, 2765 message: "Email updated immediately (verification skipped)", 2766 }); 2767 } 2768 2769 // Get user's current email 2770 const user = db 2771 .query<{ email: string; name: string | null }, [number]>( 2772 "SELECT email, name FROM users WHERE id = ?", 2773 ) 2774 .get(userId); 2775 2776 if (!user) { 2777 return Response.json({ error: "User not found" }, { status: 404 }); 2778 } 2779 2780 // Send verification email to user's current email 2781 try { 2782 const token = createEmailChangeToken(userId, email); 2783 const origin = process.env.ORIGIN || "http://localhost:3000"; 2784 const verifyUrl = `${origin}/api/user/email/verify?token=${token}`; 2785 2786 await sendEmail({ 2787 to: user.email, 2788 subject: "Verify your email change", 2789 html: emailChangeTemplate({ 2790 name: user.name, 2791 currentEmail: user.email, 2792 newEmail: email, 2793 verifyLink: verifyUrl, 2794 }), 2795 }); 2796 2797 return Response.json({ 2798 success: true, 2799 message: `Verification email sent to ${user.email}`, 2800 pendingEmail: email, 2801 }); 2802 } catch (emailError) { 2803 console.error( 2804 "[Admin] Failed to send email change verification:", 2805 emailError, 2806 ); 2807 return Response.json( 2808 { error: "Failed to send verification email" }, 2809 { status: 500 }, 2810 ); 2811 } 2812 } catch (error) { 2813 return handleError(error); 2814 } 2815 }, 2816 }, 2817 "/api/admin/users/:id/sessions": { 2818 GET: async (req) => { 2819 try { 2820 requireAdmin(req); 2821 const userId = Number.parseInt(req.params.id, 10); 2822 if (Number.isNaN(userId)) { 2823 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2824 } 2825 2826 const sessions = getSessionsForUser(userId); 2827 return Response.json(sessions); 2828 } catch (error) { 2829 return handleError(error); 2830 } 2831 }, 2832 DELETE: async (req) => { 2833 try { 2834 requireAdmin(req); 2835 const userId = Number.parseInt(req.params.id, 10); 2836 if (Number.isNaN(userId)) { 2837 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2838 } 2839 2840 deleteAllUserSessions(userId); 2841 return Response.json({ success: true }); 2842 } catch (error) { 2843 return handleError(error); 2844 } 2845 }, 2846 }, 2847 "/api/admin/users/:id/sessions/:sessionId": { 2848 DELETE: async (req) => { 2849 try { 2850 requireAdmin(req); 2851 const userId = Number.parseInt(req.params.id, 10); 2852 if (Number.isNaN(userId)) { 2853 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2854 } 2855 2856 const { sessionId } = req.params; 2857 const success = deleteSessionById(sessionId, userId); 2858 2859 if (!success) { 2860 return Response.json( 2861 { error: "Session not found" }, 2862 { status: 404 }, 2863 ); 2864 } 2865 2866 return Response.json({ success: true }); 2867 } catch (error) { 2868 return handleError(error); 2869 } 2870 }, 2871 }, 2872 "/api/admin/transcriptions/:id/details": { 2873 GET: async (req) => { 2874 try { 2875 requireAdmin(req); 2876 const transcriptionId = req.params.id; 2877 2878 const transcription = db 2879 .query< 2880 { 2881 id: string; 2882 original_filename: string; 2883 status: string; 2884 created_at: number; 2885 updated_at: number; 2886 error_message: string | null; 2887 user_id: number; 2888 }, 2889 [string] 2890 >( 2891 "SELECT id, original_filename, status, created_at, updated_at, error_message, user_id FROM transcriptions WHERE id = ?", 2892 ) 2893 .get(transcriptionId); 2894 2895 if (!transcription) { 2896 return Response.json( 2897 { error: "Transcription not found" }, 2898 { status: 404 }, 2899 ); 2900 } 2901 2902 const user = db 2903 .query<{ email: string; name: string | null }, [number]>( 2904 "SELECT email, name FROM users WHERE id = ?", 2905 ) 2906 .get(transcription.user_id); 2907 2908 return Response.json({ 2909 id: transcription.id, 2910 original_filename: transcription.original_filename, 2911 status: transcription.status, 2912 created_at: transcription.created_at, 2913 completed_at: transcription.updated_at, 2914 error_message: transcription.error_message, 2915 user_id: transcription.user_id, 2916 user_email: user?.email || "Unknown", 2917 user_name: user?.name || null, 2918 }); 2919 } catch (error) { 2920 return handleError(error); 2921 } 2922 }, 2923 }, 2924 "/api/classes": { 2925 GET: async (req) => { 2926 try { 2927 const user = requireAuth(req); 2928 const url = new URL(req.url); 2929 2930 const limit = Math.min( 2931 Number.parseInt(url.searchParams.get("limit") || "50", 10), 2932 100, 2933 ); 2934 const cursor = url.searchParams.get("cursor") || undefined; 2935 2936 const result = getClassesForUser( 2937 user.id, 2938 user.role === "admin", 2939 limit, 2940 cursor, 2941 ); 2942 2943 // For admin, return flat array. For users, group by semester/year 2944 if (user.role === "admin") { 2945 return Response.json(result.data); 2946 } 2947 2948 // Group by semester/year for regular users 2949 const grouped: Record< 2950 string, 2951 Array<{ 2952 id: string; 2953 course_code: string; 2954 name: string; 2955 professor: string; 2956 semester: string; 2957 year: number; 2958 archived: boolean; 2959 }> 2960 > = {}; 2961 2962 for (const cls of result.data) { 2963 const key = `${cls.semester} ${cls.year}`; 2964 if (!grouped[key]) { 2965 grouped[key] = []; 2966 } 2967 grouped[key]?.push({ 2968 id: cls.id, 2969 course_code: cls.course_code, 2970 name: cls.name, 2971 professor: cls.professor, 2972 semester: cls.semester, 2973 year: cls.year, 2974 archived: cls.archived, 2975 }); 2976 } 2977 2978 return Response.json({ 2979 classes: grouped, 2980 pagination: result.pagination, 2981 }); 2982 } catch (error) { 2983 return handleError(error); 2984 } 2985 }, 2986 POST: async (req) => { 2987 try { 2988 requireAdmin(req); 2989 const body = await req.json(); 2990 const { 2991 course_code, 2992 name, 2993 professor, 2994 semester, 2995 year, 2996 meeting_times, 2997 } = body; 2998 2999 // Validate all required fields 3000 const courseCodeValidation = validateCourseCode(course_code); 3001 if (!courseCodeValidation.valid) { 3002 return Response.json( 3003 { error: courseCodeValidation.error }, 3004 { status: 400 }, 3005 ); 3006 } 3007 3008 const nameValidation = validateCourseName(name); 3009 if (!nameValidation.valid) { 3010 return Response.json( 3011 { error: nameValidation.error }, 3012 { status: 400 }, 3013 ); 3014 } 3015 3016 const professorValidation = validateName(professor, "Professor name"); 3017 if (!professorValidation.valid) { 3018 return Response.json( 3019 { error: professorValidation.error }, 3020 { status: 400 }, 3021 ); 3022 } 3023 3024 const semesterValidation = validateSemester(semester); 3025 if (!semesterValidation.valid) { 3026 return Response.json( 3027 { error: semesterValidation.error }, 3028 { status: 400 }, 3029 ); 3030 } 3031 3032 const yearValidation = validateYear(year); 3033 if (!yearValidation.valid) { 3034 return Response.json( 3035 { error: yearValidation.error }, 3036 { status: 400 }, 3037 ); 3038 } 3039 3040 const newClass = createClass({ 3041 course_code, 3042 name, 3043 professor, 3044 semester, 3045 year, 3046 meeting_times, 3047 }); 3048 3049 return Response.json(newClass); 3050 } catch (error) { 3051 return handleError(error); 3052 } 3053 }, 3054 }, 3055 "/api/classes/search": { 3056 GET: async (req) => { 3057 try { 3058 const user = requireAuth(req); 3059 const url = new URL(req.url); 3060 const query = url.searchParams.get("q"); 3061 3062 if (!query) { 3063 return Response.json({ classes: [] }); 3064 } 3065 3066 const classes = searchClassesByCourseCode(query); 3067 3068 // Get user's enrolled classes to mark them 3069 const enrolledClassIds = db 3070 .query<{ class_id: string }, [number]>( 3071 "SELECT class_id FROM class_members WHERE user_id = ?", 3072 ) 3073 .all(user.id) 3074 .map((row) => row.class_id); 3075 3076 // Add is_enrolled flag to each class 3077 const classesWithEnrollment = classes.map((cls) => ({ 3078 ...cls, 3079 is_enrolled: enrolledClassIds.includes(cls.id), 3080 })); 3081 3082 return Response.json({ classes: classesWithEnrollment }); 3083 } catch (error) { 3084 return handleError(error); 3085 } 3086 }, 3087 }, 3088 "/api/classes/join": { 3089 POST: async (req) => { 3090 try { 3091 const user = requireAuth(req); 3092 const body = await req.json(); 3093 const classId = body.class_id; 3094 3095 const classIdValidation = validateClassId(classId); 3096 if (!classIdValidation.valid) { 3097 return Response.json( 3098 { error: classIdValidation.error }, 3099 { status: 400 }, 3100 ); 3101 } 3102 3103 const result = joinClass(classId, user.id); 3104 3105 if (!result.success) { 3106 return Response.json({ error: result.error }, { status: 400 }); 3107 } 3108 3109 return Response.json({ success: true }); 3110 } catch (error) { 3111 return handleError(error); 3112 } 3113 }, 3114 }, 3115 "/api/classes/waitlist": { 3116 POST: async (req) => { 3117 try { 3118 const user = requireAuth(req); 3119 const body = await req.json(); 3120 3121 const { 3122 courseCode, 3123 courseName, 3124 professor, 3125 semester, 3126 year, 3127 additionalInfo, 3128 meetingTimes, 3129 } = body; 3130 3131 // Validate all required fields 3132 const courseCodeValidation = validateCourseCode(courseCode); 3133 if (!courseCodeValidation.valid) { 3134 return Response.json( 3135 { error: courseCodeValidation.error }, 3136 { status: 400 }, 3137 ); 3138 } 3139 3140 const nameValidation = validateCourseName(courseName); 3141 if (!nameValidation.valid) { 3142 return Response.json( 3143 { error: nameValidation.error }, 3144 { status: 400 }, 3145 ); 3146 } 3147 3148 const professorValidation = validateName(professor, "Professor name"); 3149 if (!professorValidation.valid) { 3150 return Response.json( 3151 { error: professorValidation.error }, 3152 { status: 400 }, 3153 ); 3154 } 3155 3156 const semesterValidation = validateSemester(semester); 3157 if (!semesterValidation.valid) { 3158 return Response.json( 3159 { error: semesterValidation.error }, 3160 { status: 400 }, 3161 ); 3162 } 3163 3164 const yearValidation = validateYear( 3165 typeof year === "string" ? Number.parseInt(year, 10) : year, 3166 ); 3167 if (!yearValidation.valid) { 3168 return Response.json( 3169 { error: yearValidation.error }, 3170 { status: 400 }, 3171 ); 3172 } 3173 3174 const id = addToWaitlist( 3175 user.id, 3176 courseCode, 3177 courseName, 3178 professor, 3179 semester, 3180 Number.parseInt(year, 10), 3181 additionalInfo || null, 3182 meetingTimes || null, 3183 ); 3184 3185 return Response.json({ success: true, id }); 3186 } catch (error) { 3187 return handleError(error); 3188 } 3189 }, 3190 }, 3191 "/api/classes/:id": { 3192 GET: async (req) => { 3193 try { 3194 const user = requireAuth(req); 3195 const classId = req.params.id; 3196 3197 const classInfo = getClassById(classId); 3198 if (!classInfo) { 3199 return Response.json({ error: "Class not found" }, { status: 404 }); 3200 } 3201 3202 // Check enrollment or admin 3203 const isEnrolled = isUserEnrolledInClass(user.id, classId); 3204 if (!isEnrolled && user.role !== "admin") { 3205 return Response.json( 3206 { error: "Not enrolled in this class" }, 3207 { status: 403 }, 3208 ); 3209 } 3210 3211 const meetingTimes = getMeetingTimesForClass(classId); 3212 const transcriptions = getTranscriptionsForClass(classId); 3213 3214 return Response.json({ 3215 class: classInfo, 3216 meetingTimes, 3217 transcriptions, 3218 }); 3219 } catch (error) { 3220 return handleError(error); 3221 } 3222 }, 3223 DELETE: async (req) => { 3224 try { 3225 requireAdmin(req); 3226 const classId = req.params.id; 3227 3228 deleteClass(classId); 3229 return Response.json({ success: true }); 3230 } catch (error) { 3231 return handleError(error); 3232 } 3233 }, 3234 }, 3235 "/api/classes/:id/archive": { 3236 PUT: async (req) => { 3237 try { 3238 requireAdmin(req); 3239 const classId = req.params.id; 3240 const body = await req.json(); 3241 const { archived } = body; 3242 3243 if (typeof archived !== "boolean") { 3244 return Response.json( 3245 { error: "archived must be a boolean" }, 3246 { status: 400 }, 3247 ); 3248 } 3249 3250 toggleClassArchive(classId, archived); 3251 return Response.json({ success: true }); 3252 } catch (error) { 3253 return handleError(error); 3254 } 3255 }, 3256 }, 3257 "/api/classes/:id/members": { 3258 GET: async (req) => { 3259 try { 3260 requireAdmin(req); 3261 const classId = req.params.id; 3262 3263 const members = getClassMembers(classId); 3264 return Response.json({ members }); 3265 } catch (error) { 3266 return handleError(error); 3267 } 3268 }, 3269 POST: async (req) => { 3270 try { 3271 requireAdmin(req); 3272 const classId = req.params.id; 3273 const body = await req.json(); 3274 const { email } = body; 3275 3276 if (!email) { 3277 return Response.json({ error: "Email required" }, { status: 400 }); 3278 } 3279 3280 const user = getUserByEmail(email); 3281 if (!user) { 3282 return Response.json({ error: "User not found" }, { status: 404 }); 3283 } 3284 3285 enrollUserInClass(user.id, classId); 3286 return Response.json({ success: true }); 3287 } catch (error) { 3288 return handleError(error); 3289 } 3290 }, 3291 }, 3292 "/api/classes/:id/members/:userId": { 3293 DELETE: async (req) => { 3294 try { 3295 requireAdmin(req); 3296 const classId = req.params.id; 3297 const userId = Number.parseInt(req.params.userId, 10); 3298 3299 if (Number.isNaN(userId)) { 3300 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 3301 } 3302 3303 removeUserFromClass(userId, classId); 3304 return Response.json({ success: true }); 3305 } catch (error) { 3306 return handleError(error); 3307 } 3308 }, 3309 }, 3310 "/api/classes/:id/meetings": { 3311 GET: async (req) => { 3312 try { 3313 const user = requireAuth(req); 3314 const classId = req.params.id; 3315 3316 // Check enrollment or admin 3317 const isEnrolled = isUserEnrolledInClass(user.id, classId); 3318 if (!isEnrolled && user.role !== "admin") { 3319 return Response.json( 3320 { error: "Not enrolled in this class" }, 3321 { status: 403 }, 3322 ); 3323 } 3324 3325 const meetingTimes = getMeetingTimesForClass(classId); 3326 return Response.json({ meetings: meetingTimes }); 3327 } catch (error) { 3328 return handleError(error); 3329 } 3330 }, 3331 POST: async (req) => { 3332 try { 3333 requireAdmin(req); 3334 const classId = req.params.id; 3335 const body = await req.json(); 3336 const { label } = body; 3337 3338 if (!label) { 3339 return Response.json({ error: "Label required" }, { status: 400 }); 3340 } 3341 3342 const meetingTime = createMeetingTime(classId, label); 3343 return Response.json(meetingTime); 3344 } catch (error) { 3345 return handleError(error); 3346 } 3347 }, 3348 }, 3349 "/api/meetings/:id": { 3350 PUT: async (req) => { 3351 try { 3352 requireAdmin(req); 3353 const meetingId = req.params.id; 3354 const body = await req.json(); 3355 const { label } = body; 3356 3357 if (!label) { 3358 return Response.json({ error: "Label required" }, { status: 400 }); 3359 } 3360 3361 updateMeetingTime(meetingId, label); 3362 return Response.json({ success: true }); 3363 } catch (error) { 3364 return handleError(error); 3365 } 3366 }, 3367 DELETE: async (req) => { 3368 try { 3369 requireAdmin(req); 3370 const meetingId = req.params.id; 3371 3372 deleteMeetingTime(meetingId); 3373 return Response.json({ success: true }); 3374 } catch (error) { 3375 return handleError(error); 3376 } 3377 }, 3378 }, 3379 "/api/transcripts/:id/select": { 3380 PUT: async (req) => { 3381 try { 3382 requireAdmin(req); 3383 const transcriptId = req.params.id; 3384 3385 // Check if transcription exists and get its current status 3386 const transcription = db 3387 .query<{ filename: string; status: string }, [string]>( 3388 "SELECT filename, status FROM transcriptions WHERE id = ?", 3389 ) 3390 .get(transcriptId); 3391 3392 if (!transcription) { 3393 return Response.json( 3394 { error: "Transcription not found" }, 3395 { status: 404 }, 3396 ); 3397 } 3398 3399 // Validate that status is appropriate for selection (e.g., 'uploading' or 'pending') 3400 const validStatuses = ["uploading", "pending", "failed"]; 3401 if (!validStatuses.includes(transcription.status)) { 3402 return Response.json( 3403 { 3404 error: `Cannot select transcription with status: ${transcription.status}`, 3405 }, 3406 { status: 400 }, 3407 ); 3408 } 3409 3410 // Update status to 'selected' and start transcription 3411 db.run("UPDATE transcriptions SET status = ? WHERE id = ?", [ 3412 "selected", 3413 transcriptId, 3414 ]); 3415 3416 whisperService.startTranscription( 3417 transcriptId, 3418 transcription.filename, 3419 ); 3420 3421 return Response.json({ success: true }); 3422 } catch (error) { 3423 return handleError(error); 3424 } 3425 }, 3426 }, 3427 }, 3428 development: { 3429 hmr: true, 3430 console: true, 3431 }, 3432}); 3433console.log(`馃 Thistle running at http://localhost:${server.port}`); 3434 3435// Track active SSE streams for graceful shutdown 3436const activeSSEStreams = new Set<ReadableStreamDefaultController>(); 3437 3438// Graceful shutdown handler 3439let isShuttingDown = false; 3440 3441async function shutdown(signal: string) { 3442 if (isShuttingDown) return; 3443 isShuttingDown = true; 3444 3445 console.log(`\n${signal} received, starting graceful shutdown...`); 3446 3447 // 1. Stop accepting new requests 3448 console.log("[Shutdown] Closing server..."); 3449 server.stop(); 3450 3451 // 2. Close all active SSE streams (safe to kill - sync will handle reconnection) 3452 console.log( 3453 `[Shutdown] Closing ${activeSSEStreams.size} active SSE streams...`, 3454 ); 3455 for (const controller of activeSSEStreams) { 3456 try { 3457 controller.close(); 3458 } catch { 3459 // Already closed 3460 } 3461 } 3462 activeSSEStreams.clear(); 3463 3464 // 3. Stop transcription service (closes streams to Murmur) 3465 whisperService.stop(); 3466 3467 // 4. Stop cleanup intervals 3468 console.log("[Shutdown] Stopping cleanup intervals..."); 3469 clearInterval(sessionCleanupInterval); 3470 clearInterval(syncInterval); 3471 clearInterval(fileCleanupInterval); 3472 3473 // 5. Close database connections 3474 console.log("[Shutdown] Closing database..."); 3475 db.close(); 3476 3477 console.log("[Shutdown] Complete"); 3478 process.exit(0); 3479} 3480 3481// Register shutdown handlers 3482process.on("SIGTERM", () => shutdown("SIGTERM")); 3483process.on("SIGINT", () => shutdown("SIGINT"));