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