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