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