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