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