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