馃 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 = process.env.ORIGIN || "http://localhost:3000"; 637 const resetToken = createPasswordResetToken(user.id); 638 const resetLink = `${origin}/reset-password?token=${resetToken}`; 639 640 await sendEmail({ 641 to: user.email, 642 subject: "Reset your password - Thistle", 643 html: passwordResetTemplate({ 644 name: user.name, 645 resetLink, 646 }), 647 }).catch((err) => { 648 console.error("[Email] Failed to send password reset:", err); 649 }); 650 } 651 652 return Response.json({ 653 message: 654 "If an account exists with that email, a password reset link has been sent", 655 }); 656 } catch (error) { 657 console.error("[Email] Forgot password error:", error); 658 return Response.json( 659 { error: "Failed to process request" }, 660 { status: 500 }, 661 ); 662 } 663 }, 664 }, 665 "/api/auth/reset-password": { 666 GET: async (req) => { 667 try { 668 const url = new URL(req.url); 669 const token = url.searchParams.get("token"); 670 671 if (!token) { 672 return Response.json( 673 { error: "Token required" }, 674 { status: 400 }, 675 ); 676 } 677 678 const userId = verifyPasswordResetToken(token); 679 if (!userId) { 680 return Response.json( 681 { error: "Invalid or expired reset token" }, 682 { status: 400 }, 683 ); 684 } 685 686 // Get user's email for client-side password hashing 687 const user = db 688 .query<{ email: string }, [number]>("SELECT email FROM users WHERE id = ?") 689 .get(userId); 690 691 if (!user) { 692 return Response.json({ error: "User not found" }, { status: 404 }); 693 } 694 695 return Response.json({ email: user.email }); 696 } catch (error) { 697 console.error("[Email] Get reset token info error:", error); 698 return Response.json( 699 { error: "Failed to verify token" }, 700 { status: 500 }, 701 ); 702 } 703 }, 704 POST: async (req) => { 705 try { 706 const body = await req.json(); 707 const { token, password } = body; 708 709 if (!token || !password) { 710 return Response.json( 711 { error: "Token and password required" }, 712 { status: 400 }, 713 ); 714 } 715 716 // Validate password format (client-side hashed PBKDF2) 717 if (password.length !== 64 || !/^[0-9a-f]+$/.test(password)) { 718 return Response.json( 719 { error: "Invalid password format" }, 720 { status: 400 }, 721 ); 722 } 723 724 const userId = verifyPasswordResetToken(token); 725 if (!userId) { 726 return Response.json( 727 { error: "Invalid or expired reset token" }, 728 { status: 400 }, 729 ); 730 } 731 732 // Update password and consume token 733 await updateUserPassword(userId, password); 734 consumePasswordResetToken(token); 735 736 return Response.json({ message: "Password reset successfully" }); 737 } catch (error) { 738 console.error("[Email] Reset password error:", error); 739 return Response.json( 740 { error: "Failed to reset password" }, 741 { status: 500 }, 742 ); 743 } 744 }, 745 }, 746 "/api/auth/logout": { 747 POST: async (req) => { 748 const sessionId = getSessionFromRequest(req); 749 if (sessionId) { 750 deleteSession(sessionId); 751 } 752 return Response.json( 753 { success: true }, 754 { 755 headers: { 756 "Set-Cookie": 757 "session=; HttpOnly; Secure; Path=/; Max-Age=0; SameSite=Lax", 758 }, 759 }, 760 ); 761 }, 762 }, 763 "/api/auth/me": { 764 GET: (req) => { 765 const sessionId = getSessionFromRequest(req); 766 if (!sessionId) { 767 return Response.json({ error: "Not authenticated" }, { status: 401 }); 768 } 769 const user = getUserBySession(sessionId); 770 if (!user) { 771 return Response.json({ error: "Invalid session" }, { status: 401 }); 772 } 773 774 // Check subscription status 775 const subscription = db 776 .query<{ status: string }, [number]>( 777 "SELECT status FROM subscriptions WHERE user_id = ? AND status IN ('active', 'trialing', 'past_due') ORDER BY created_at DESC LIMIT 1", 778 ) 779 .get(user.id); 780 781 // Get notification preferences 782 const prefs = db 783 .query<{ email_notifications_enabled: number }, [number]>( 784 "SELECT email_notifications_enabled FROM users WHERE id = ?", 785 ) 786 .get(user.id); 787 788 return Response.json({ 789 email: user.email, 790 name: user.name, 791 avatar: user.avatar, 792 created_at: user.created_at, 793 role: user.role, 794 has_subscription: !!subscription, 795 email_verified: isEmailVerified(user.id), 796 email_notifications_enabled: prefs?.email_notifications_enabled === 1, 797 }); 798 }, 799 }, 800 "/api/passkeys/register/options": { 801 POST: async (req) => { 802 try { 803 const user = requireAuth(req); 804 const options = await createRegistrationOptions(user); 805 return Response.json(options); 806 } catch (err) { 807 return handleError(err); 808 } 809 }, 810 }, 811 "/api/passkeys/register/verify": { 812 POST: async (req) => { 813 try { 814 const _user = requireAuth(req); 815 const body = await req.json(); 816 const { response: credentialResponse, challenge, name } = body; 817 818 const passkey = await verifyAndCreatePasskey( 819 credentialResponse, 820 challenge, 821 name, 822 ); 823 824 return Response.json({ 825 success: true, 826 passkey: { 827 id: passkey.id, 828 name: passkey.name, 829 created_at: passkey.created_at, 830 }, 831 }); 832 } catch (err) { 833 return handleError(err); 834 } 835 }, 836 }, 837 "/api/passkeys/authenticate/options": { 838 POST: async (req) => { 839 try { 840 const body = await req.json(); 841 const { email } = body; 842 843 const options = await createAuthenticationOptions(email); 844 return Response.json(options); 845 } catch (err) { 846 return handleError(err); 847 } 848 }, 849 }, 850 "/api/passkeys/authenticate/verify": { 851 POST: async (req) => { 852 try { 853 const body = await req.json(); 854 const { response: credentialResponse, challenge } = body; 855 856 const result = await verifyAndAuthenticatePasskey( 857 credentialResponse, 858 challenge, 859 ); 860 861 if ("error" in result) { 862 return new Response(JSON.stringify({ error: result.error }), { 863 status: 401, 864 }); 865 } 866 867 const { user } = result; 868 869 // Create session 870 const ipAddress = 871 req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || 872 req.headers.get("x-real-ip") || 873 "unknown"; 874 const userAgent = req.headers.get("user-agent") || "unknown"; 875 const sessionId = createSession(user.id, ipAddress, userAgent); 876 877 return Response.json( 878 { 879 email: user.email, 880 name: user.name, 881 avatar: user.avatar, 882 created_at: user.created_at, 883 role: user.role, 884 }, 885 { 886 headers: { 887 "Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`, 888 }, 889 }, 890 ); 891 } catch (err) { 892 return handleError(err); 893 } 894 }, 895 }, 896 "/api/passkeys": { 897 GET: async (req) => { 898 try { 899 const user = requireAuth(req); 900 const passkeys = getPasskeysForUser(user.id); 901 return Response.json({ 902 passkeys: passkeys.map((p) => ({ 903 id: p.id, 904 name: p.name, 905 created_at: p.created_at, 906 last_used_at: p.last_used_at, 907 })), 908 }); 909 } catch (err) { 910 return handleError(err); 911 } 912 }, 913 }, 914 "/api/passkeys/:id": { 915 PUT: async (req) => { 916 try { 917 const user = requireAuth(req); 918 const body = await req.json(); 919 const { name } = body; 920 const passkeyId = req.params.id; 921 922 if (!name) { 923 return Response.json({ error: "Name required" }, { status: 400 }); 924 } 925 926 updatePasskeyName(passkeyId, user.id, name); 927 return Response.json({ success: true }); 928 } catch (err) { 929 return handleError(err); 930 } 931 }, 932 DELETE: async (req) => { 933 try { 934 const user = requireAuth(req); 935 const passkeyId = req.params.id; 936 deletePasskey(passkeyId, user.id); 937 return Response.json({ success: true }); 938 } catch (err) { 939 return handleError(err); 940 } 941 }, 942 }, 943 "/api/sessions": { 944 GET: (req) => { 945 const sessionId = getSessionFromRequest(req); 946 if (!sessionId) { 947 return Response.json({ error: "Not authenticated" }, { status: 401 }); 948 } 949 const user = getUserBySession(sessionId); 950 if (!user) { 951 return Response.json({ error: "Invalid session" }, { status: 401 }); 952 } 953 const sessions = getUserSessionsForUser(user.id); 954 return Response.json({ 955 sessions: sessions.map((s) => ({ 956 id: s.id, 957 ip_address: s.ip_address, 958 user_agent: s.user_agent, 959 created_at: s.created_at, 960 expires_at: s.expires_at, 961 is_current: s.id === sessionId, 962 })), 963 }); 964 }, 965 DELETE: async (req) => { 966 const currentSessionId = getSessionFromRequest(req); 967 if (!currentSessionId) { 968 return Response.json({ error: "Not authenticated" }, { status: 401 }); 969 } 970 const user = getUserBySession(currentSessionId); 971 if (!user) { 972 return Response.json({ error: "Invalid session" }, { status: 401 }); 973 } 974 const body = await req.json(); 975 const targetSessionId = body.sessionId; 976 if (!targetSessionId) { 977 return Response.json( 978 { error: "Session ID required" }, 979 { status: 400 }, 980 ); 981 } 982 // Prevent deleting current session 983 if (targetSessionId === currentSessionId) { 984 return Response.json( 985 { error: "Cannot kill current session. Use logout instead." }, 986 { status: 400 }, 987 ); 988 } 989 // Verify the session belongs to the user 990 const targetSession = getSession(targetSessionId); 991 if (!targetSession || targetSession.user_id !== user.id) { 992 return Response.json({ error: "Session not found" }, { status: 404 }); 993 } 994 deleteSession(targetSessionId); 995 return Response.json({ success: true }); 996 }, 997 }, 998 "/api/user": { 999 DELETE: async (req) => { 1000 const sessionId = getSessionFromRequest(req); 1001 if (!sessionId) { 1002 return Response.json({ error: "Not authenticated" }, { status: 401 }); 1003 } 1004 const user = getUserBySession(sessionId); 1005 if (!user) { 1006 return Response.json({ error: "Invalid session" }, { status: 401 }); 1007 } 1008 1009 // Rate limiting 1010 const rateLimitError = enforceRateLimit(req, "delete-user", { 1011 ip: { max: 3, windowSeconds: 60 * 60 }, 1012 }); 1013 if (rateLimitError) return rateLimitError; 1014 1015 await deleteUser(user.id); 1016 return Response.json( 1017 { success: true }, 1018 { 1019 headers: { 1020 "Set-Cookie": 1021 "session=; HttpOnly; Secure; Path=/; Max-Age=0; SameSite=Lax", 1022 }, 1023 }, 1024 ); 1025 }, 1026 }, 1027 "/api/user/email": { 1028 PUT: async (req) => { 1029 const sessionId = getSessionFromRequest(req); 1030 if (!sessionId) { 1031 return Response.json({ error: "Not authenticated" }, { status: 401 }); 1032 } 1033 const user = getUserBySession(sessionId); 1034 if (!user) { 1035 return Response.json({ error: "Invalid session" }, { status: 401 }); 1036 } 1037 1038 // Rate limiting 1039 const rateLimitError = enforceRateLimit(req, "update-email", { 1040 ip: { max: 5, windowSeconds: 60 * 60 }, 1041 }); 1042 if (rateLimitError) return rateLimitError; 1043 1044 const body = await req.json(); 1045 const { email } = body; 1046 if (!email) { 1047 return Response.json({ error: "Email required" }, { status: 400 }); 1048 } 1049 try { 1050 updateUserEmail(user.id, email); 1051 return Response.json({ success: true }); 1052 } catch (err: unknown) { 1053 const error = err as { message?: string }; 1054 if (error.message?.includes("UNIQUE constraint failed")) { 1055 return Response.json( 1056 { error: "Email already in use" }, 1057 { status: 400 }, 1058 ); 1059 } 1060 return Response.json( 1061 { error: "Failed to update email" }, 1062 { status: 500 }, 1063 ); 1064 } 1065 }, 1066 }, 1067 "/api/user/password": { 1068 PUT: async (req) => { 1069 const sessionId = getSessionFromRequest(req); 1070 if (!sessionId) { 1071 return Response.json({ error: "Not authenticated" }, { status: 401 }); 1072 } 1073 const user = getUserBySession(sessionId); 1074 if (!user) { 1075 return Response.json({ error: "Invalid session" }, { status: 401 }); 1076 } 1077 1078 // Rate limiting 1079 const rateLimitError = enforceRateLimit(req, "update-password", { 1080 ip: { max: 5, windowSeconds: 60 * 60 }, 1081 }); 1082 if (rateLimitError) return rateLimitError; 1083 1084 const body = await req.json(); 1085 const { password } = body; 1086 if (!password) { 1087 return Response.json({ error: "Password required" }, { status: 400 }); 1088 } 1089 // Password is client-side hashed (PBKDF2), should be 64 char hex 1090 if (password.length !== 64 || !/^[0-9a-f]+$/.test(password)) { 1091 return Response.json( 1092 { error: "Invalid password format" }, 1093 { status: 400 }, 1094 ); 1095 } 1096 try { 1097 await updateUserPassword(user.id, password); 1098 return Response.json({ success: true }); 1099 } catch { 1100 return Response.json( 1101 { error: "Failed to update password" }, 1102 { status: 500 }, 1103 ); 1104 } 1105 }, 1106 }, 1107 "/api/user/name": { 1108 PUT: async (req) => { 1109 const sessionId = getSessionFromRequest(req); 1110 if (!sessionId) { 1111 return Response.json({ error: "Not authenticated" }, { status: 401 }); 1112 } 1113 const user = getUserBySession(sessionId); 1114 if (!user) { 1115 return Response.json({ error: "Invalid session" }, { status: 401 }); 1116 } 1117 const body = await req.json(); 1118 const { name } = body; 1119 if (!name) { 1120 return Response.json({ error: "Name required" }, { status: 400 }); 1121 } 1122 try { 1123 updateUserName(user.id, name); 1124 return Response.json({ success: true }); 1125 } catch { 1126 return Response.json( 1127 { error: "Failed to update name" }, 1128 { status: 500 }, 1129 ); 1130 } 1131 }, 1132 }, 1133 "/api/user/avatar": { 1134 PUT: async (req) => { 1135 const sessionId = getSessionFromRequest(req); 1136 if (!sessionId) { 1137 return Response.json({ error: "Not authenticated" }, { status: 401 }); 1138 } 1139 const user = getUserBySession(sessionId); 1140 if (!user) { 1141 return Response.json({ error: "Invalid session" }, { status: 401 }); 1142 } 1143 const body = await req.json(); 1144 const { avatar } = body; 1145 if (!avatar) { 1146 return Response.json({ error: "Avatar required" }, { status: 400 }); 1147 } 1148 try { 1149 updateUserAvatar(user.id, avatar); 1150 return Response.json({ success: true }); 1151 } catch { 1152 return Response.json( 1153 { error: "Failed to update avatar" }, 1154 { status: 500 }, 1155 ); 1156 } 1157 }, 1158 }, 1159 "/api/user/notifications": { 1160 PUT: async (req) => { 1161 const sessionId = getSessionFromRequest(req); 1162 if (!sessionId) { 1163 return Response.json({ error: "Not authenticated" }, { status: 401 }); 1164 } 1165 const user = getUserBySession(sessionId); 1166 if (!user) { 1167 return Response.json({ error: "Invalid session" }, { status: 401 }); 1168 } 1169 const body = await req.json(); 1170 const { email_notifications_enabled } = body; 1171 if (typeof email_notifications_enabled !== "boolean") { 1172 return Response.json({ error: "email_notifications_enabled must be a boolean" }, { status: 400 }); 1173 } 1174 try { 1175 db.run("UPDATE users SET email_notifications_enabled = ? WHERE id = ?", [email_notifications_enabled ? 1 : 0, user.id]); 1176 return Response.json({ success: true }); 1177 } catch { 1178 return Response.json( 1179 { error: "Failed to update notification settings" }, 1180 { status: 500 }, 1181 ); 1182 } 1183 }, 1184 }, 1185 "/api/billing/checkout": { 1186 POST: async (req) => { 1187 const sessionId = getSessionFromRequest(req); 1188 if (!sessionId) { 1189 return Response.json({ error: "Not authenticated" }, { status: 401 }); 1190 } 1191 const user = getUserBySession(sessionId); 1192 if (!user) { 1193 return Response.json({ error: "Invalid session" }, { status: 401 }); 1194 } 1195 1196 try { 1197 const { polar } = await import("./lib/polar"); 1198 1199 const productId = process.env.POLAR_PRODUCT_ID; 1200 if (!productId) { 1201 return Response.json( 1202 { error: "Product not configured" }, 1203 { status: 500 }, 1204 ); 1205 } 1206 1207 const successUrl = process.env.POLAR_SUCCESS_URL; 1208 if (!successUrl) { 1209 return Response.json( 1210 { error: "Success URL not configured" }, 1211 { status: 500 }, 1212 ); 1213 } 1214 1215 const checkout = await polar.checkouts.create({ 1216 products: [productId], 1217 successUrl, 1218 customerEmail: user.email, 1219 customerName: user.name ?? undefined, 1220 metadata: { 1221 userId: user.id.toString(), 1222 }, 1223 }); 1224 1225 return Response.json({ url: checkout.url }); 1226 } catch (error) { 1227 console.error("Failed to create checkout:", error); 1228 return Response.json( 1229 { error: "Failed to create checkout session" }, 1230 { status: 500 }, 1231 ); 1232 } 1233 }, 1234 }, 1235 "/api/billing/subscription": { 1236 GET: async (req) => { 1237 const sessionId = getSessionFromRequest(req); 1238 if (!sessionId) { 1239 return Response.json({ error: "Not authenticated" }, { status: 401 }); 1240 } 1241 const user = getUserBySession(sessionId); 1242 if (!user) { 1243 return Response.json({ error: "Invalid session" }, { status: 401 }); 1244 } 1245 1246 try { 1247 // Get subscription from database 1248 const subscription = db 1249 .query< 1250 { 1251 id: string; 1252 status: string; 1253 current_period_start: number | null; 1254 current_period_end: number | null; 1255 cancel_at_period_end: number; 1256 canceled_at: number | null; 1257 }, 1258 [number] 1259 >( 1260 "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", 1261 ) 1262 .get(user.id); 1263 1264 if (!subscription) { 1265 return Response.json({ subscription: null }); 1266 } 1267 1268 return Response.json({ subscription }); 1269 } catch (error) { 1270 console.error("Failed to fetch subscription:", error); 1271 return Response.json( 1272 { error: "Failed to fetch subscription" }, 1273 { status: 500 }, 1274 ); 1275 } 1276 }, 1277 }, 1278 "/api/billing/portal": { 1279 POST: async (req) => { 1280 const sessionId = getSessionFromRequest(req); 1281 if (!sessionId) { 1282 return Response.json({ error: "Not authenticated" }, { status: 401 }); 1283 } 1284 const user = getUserBySession(sessionId); 1285 if (!user) { 1286 return Response.json({ error: "Invalid session" }, { status: 401 }); 1287 } 1288 1289 try { 1290 const { polar } = await import("./lib/polar"); 1291 1292 // Get subscription to find customer ID 1293 const subscription = db 1294 .query< 1295 { 1296 customer_id: string; 1297 }, 1298 [number] 1299 >( 1300 "SELECT customer_id FROM subscriptions WHERE user_id = ? ORDER BY created_at DESC LIMIT 1", 1301 ) 1302 .get(user.id); 1303 1304 if (!subscription || !subscription.customer_id) { 1305 return Response.json( 1306 { error: "No subscription found" }, 1307 { status: 404 }, 1308 ); 1309 } 1310 1311 // Create customer portal session 1312 const session = await polar.customerSessions.create({ 1313 customerId: subscription.customer_id, 1314 }); 1315 1316 return Response.json({ url: session.customerPortalUrl }); 1317 } catch (error) { 1318 console.error("Failed to create portal session:", error); 1319 return Response.json( 1320 { error: "Failed to create portal session" }, 1321 { status: 500 }, 1322 ); 1323 } 1324 }, 1325 }, 1326 "/api/webhooks/polar": { 1327 POST: async (req) => { 1328 try { 1329 const { validateEvent } = await import("@polar-sh/sdk/webhooks"); 1330 1331 // Get raw body as string 1332 const rawBody = await req.text(); 1333 const headers = Object.fromEntries(req.headers.entries()); 1334 1335 // Validate webhook signature 1336 const webhookSecret = process.env.POLAR_WEBHOOK_SECRET; 1337 if (!webhookSecret) { 1338 console.error("[Webhook] POLAR_WEBHOOK_SECRET not configured"); 1339 return Response.json( 1340 { error: "Webhook secret not configured" }, 1341 { status: 500 }, 1342 ); 1343 } 1344 1345 const event = validateEvent(rawBody, headers, webhookSecret); 1346 1347 console.log(`[Webhook] Received event: ${event.type}`); 1348 1349 // Handle different event types 1350 switch (event.type) { 1351 case "subscription.updated": { 1352 const { id, status, customerId, metadata } = event.data; 1353 const userId = metadata?.userId 1354 ? Number.parseInt(metadata.userId as string, 10) 1355 : null; 1356 1357 if (!userId) { 1358 console.warn("[Webhook] No userId in subscription metadata"); 1359 break; 1360 } 1361 1362 // Upsert subscription 1363 db.run( 1364 `INSERT INTO subscriptions (id, user_id, customer_id, status, current_period_start, current_period_end, cancel_at_period_end, canceled_at, updated_at) 1365 VALUES (?, ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now')) 1366 ON CONFLICT(id) DO UPDATE SET 1367 status = excluded.status, 1368 current_period_start = excluded.current_period_start, 1369 current_period_end = excluded.current_period_end, 1370 cancel_at_period_end = excluded.cancel_at_period_end, 1371 canceled_at = excluded.canceled_at, 1372 updated_at = strftime('%s', 'now')`, 1373 [ 1374 id, 1375 userId, 1376 customerId, 1377 status, 1378 event.data.currentPeriodStart 1379 ? Math.floor( 1380 new Date(event.data.currentPeriodStart).getTime() / 1381 1000, 1382 ) 1383 : null, 1384 event.data.currentPeriodEnd 1385 ? Math.floor( 1386 new Date(event.data.currentPeriodEnd).getTime() / 1000, 1387 ) 1388 : null, 1389 event.data.cancelAtPeriodEnd ? 1 : 0, 1390 event.data.canceledAt 1391 ? Math.floor( 1392 new Date(event.data.canceledAt).getTime() / 1000, 1393 ) 1394 : null, 1395 ], 1396 ); 1397 1398 console.log( 1399 `[Webhook] Updated subscription ${id} for user ${userId}`, 1400 ); 1401 break; 1402 } 1403 1404 default: 1405 console.log(`[Webhook] Unhandled event type: ${event.type}`); 1406 } 1407 1408 return Response.json({ received: true }); 1409 } catch (error) { 1410 console.error("[Webhook] Error processing webhook:", error); 1411 return Response.json( 1412 { error: "Webhook processing failed" }, 1413 { status: 400 }, 1414 ); 1415 } 1416 }, 1417 }, 1418 "/api/transcriptions/:id/stream": { 1419 GET: async (req) => { 1420 try { 1421 const user = requireAuth(req); 1422 const transcriptionId = req.params.id; 1423 // Verify ownership 1424 const transcription = db 1425 .query<{ id: string; user_id: number; class_id: string | null; status: string }, [string]>( 1426 "SELECT id, user_id, class_id, status FROM transcriptions WHERE id = ?", 1427 ) 1428 .get(transcriptionId); 1429 1430 if (!transcription) { 1431 return Response.json( 1432 { error: "Transcription not found" }, 1433 { status: 404 }, 1434 ); 1435 } 1436 1437 // Check access permissions 1438 const isOwner = transcription.user_id === user.id; 1439 const isAdmin = user.role === "admin"; 1440 let isClassMember = false; 1441 1442 // If transcription belongs to a class, check enrollment 1443 if (transcription.class_id) { 1444 isClassMember = isUserEnrolledInClass(user.id, transcription.class_id); 1445 } 1446 1447 // Allow access if: owner, admin, or enrolled in the class 1448 if (!isOwner && !isAdmin && !isClassMember) { 1449 return Response.json( 1450 { error: "Transcription not found" }, 1451 { status: 404 }, 1452 ); 1453 } 1454 1455 // Require subscription only if accessing own transcription (not class) 1456 if (isOwner && !transcription.class_id && !isAdmin && !hasActiveSubscription(user.id)) { 1457 throw AuthErrors.subscriptionRequired(); 1458 } 1459 // Event-driven SSE stream with reconnection support 1460 const stream = new ReadableStream({ 1461 async start(controller) { 1462 const encoder = new TextEncoder(); 1463 let isClosed = false; 1464 let lastEventId = Math.floor(Date.now() / 1000); 1465 1466 const sendEvent = (data: Partial<TranscriptionUpdate>) => { 1467 if (isClosed) return; 1468 try { 1469 // Send event ID for reconnection support 1470 lastEventId = Math.floor(Date.now() / 1000); 1471 controller.enqueue( 1472 encoder.encode( 1473 `id: ${lastEventId}\nevent: update\ndata: ${JSON.stringify(data)}\n\n`, 1474 ), 1475 ); 1476 } catch { 1477 // Controller already closed (client disconnected) 1478 isClosed = true; 1479 } 1480 }; 1481 1482 const sendHeartbeat = () => { 1483 if (isClosed) return; 1484 try { 1485 controller.enqueue(encoder.encode(": heartbeat\n\n")); 1486 } catch { 1487 isClosed = true; 1488 } 1489 }; 1490 // Send initial state from DB and file 1491 const current = db 1492 .query< 1493 { 1494 status: string; 1495 progress: number; 1496 }, 1497 [string] 1498 >("SELECT status, progress FROM transcriptions WHERE id = ?") 1499 .get(transcriptionId); 1500 if (current) { 1501 sendEvent({ 1502 status: current.status as TranscriptionUpdate["status"], 1503 progress: current.progress, 1504 }); 1505 } 1506 // If already complete, close immediately 1507 if ( 1508 current?.status === "completed" || 1509 current?.status === "failed" 1510 ) { 1511 isClosed = true; 1512 controller.close(); 1513 return; 1514 } 1515 // Send heartbeats every 2.5 seconds to keep connection alive 1516 const heartbeatInterval = setInterval(sendHeartbeat, 2500); 1517 1518 // Subscribe to EventEmitter for live updates 1519 const updateHandler = (data: TranscriptionUpdate) => { 1520 if (isClosed) return; 1521 1522 // Only send changed fields to save bandwidth 1523 const payload: Partial<TranscriptionUpdate> = { 1524 status: data.status, 1525 progress: data.progress, 1526 }; 1527 1528 if (data.transcript !== undefined) { 1529 payload.transcript = data.transcript; 1530 } 1531 if (data.error_message !== undefined) { 1532 payload.error_message = data.error_message; 1533 } 1534 1535 sendEvent(payload); 1536 1537 // Close stream when done 1538 if (data.status === "completed" || data.status === "failed") { 1539 isClosed = true; 1540 clearInterval(heartbeatInterval); 1541 transcriptionEvents.off(transcriptionId, updateHandler); 1542 controller.close(); 1543 } 1544 }; 1545 transcriptionEvents.on(transcriptionId, updateHandler); 1546 // Cleanup on client disconnect 1547 return () => { 1548 isClosed = true; 1549 clearInterval(heartbeatInterval); 1550 transcriptionEvents.off(transcriptionId, updateHandler); 1551 }; 1552 }, 1553 }); 1554 return new Response(stream, { 1555 headers: { 1556 "Content-Type": "text/event-stream", 1557 "Cache-Control": "no-cache", 1558 Connection: "keep-alive", 1559 }, 1560 }); 1561 } catch (error) { 1562 return handleError(error); 1563 } 1564 }, 1565 }, 1566 "/api/transcriptions/health": { 1567 GET: async () => { 1568 const isHealthy = await whisperService.checkHealth(); 1569 return Response.json({ available: isHealthy }); 1570 }, 1571 }, 1572 "/api/transcriptions/:id": { 1573 GET: async (req) => { 1574 try { 1575 const user = requireAuth(req); 1576 const transcriptionId = req.params.id; 1577 1578 // Verify ownership or admin 1579 const transcription = db 1580 .query< 1581 { 1582 id: string; 1583 user_id: number; 1584 class_id: string | null; 1585 filename: string; 1586 original_filename: string; 1587 status: string; 1588 progress: number; 1589 created_at: number; 1590 }, 1591 [string] 1592 >( 1593 "SELECT id, user_id, class_id, filename, original_filename, status, progress, created_at FROM transcriptions WHERE id = ?", 1594 ) 1595 .get(transcriptionId); 1596 1597 if (!transcription) { 1598 return Response.json( 1599 { error: "Transcription not found" }, 1600 { status: 404 }, 1601 ); 1602 } 1603 1604 // Check access permissions 1605 const isOwner = transcription.user_id === user.id; 1606 const isAdmin = user.role === "admin"; 1607 let isClassMember = false; 1608 1609 // If transcription belongs to a class, check enrollment 1610 if (transcription.class_id) { 1611 isClassMember = isUserEnrolledInClass(user.id, transcription.class_id); 1612 } 1613 1614 // Allow access if: owner, admin, or enrolled in the class 1615 if (!isOwner && !isAdmin && !isClassMember) { 1616 return Response.json( 1617 { error: "Transcription not found" }, 1618 { status: 404 }, 1619 ); 1620 } 1621 1622 // Require subscription only if accessing own transcription (not class) 1623 if (isOwner && !transcription.class_id && !isAdmin && !hasActiveSubscription(user.id)) { 1624 throw AuthErrors.subscriptionRequired(); 1625 } 1626 1627 if (transcription.status !== "completed") { 1628 return Response.json( 1629 { error: "Transcription not completed yet" }, 1630 { status: 400 }, 1631 ); 1632 } 1633 1634 // Get format from query parameter 1635 const url = new URL(req.url); 1636 const format = url.searchParams.get("format"); 1637 1638 // Return WebVTT format if requested 1639 if (format === "vtt") { 1640 const vttContent = await getTranscriptVTT(transcriptionId); 1641 1642 if (!vttContent) { 1643 return Response.json( 1644 { error: "VTT transcript not available" }, 1645 { status: 404 }, 1646 ); 1647 } 1648 1649 return new Response(vttContent, { 1650 headers: { 1651 "Content-Type": "text/vtt", 1652 "Content-Disposition": `attachment; filename="${transcription.original_filename}.vtt"`, 1653 }, 1654 }); 1655 } 1656 1657 // return info on transcript 1658 const transcript = { 1659 id: transcription.id, 1660 filename: transcription.original_filename, 1661 status: transcription.status, 1662 progress: transcription.progress, 1663 created_at: transcription.created_at, 1664 }; 1665 return new Response(JSON.stringify(transcript), { 1666 headers: { 1667 "Content-Type": "application/json", 1668 }, 1669 }); 1670 } catch (error) { 1671 return handleError(error); 1672 } 1673 }, 1674 }, 1675 "/api/transcriptions/:id/audio": { 1676 GET: async (req) => { 1677 try { 1678 const user = requireAuth(req); 1679 const transcriptionId = req.params.id; 1680 1681 // Verify ownership or admin 1682 const transcription = db 1683 .query< 1684 { 1685 id: string; 1686 user_id: number; 1687 class_id: string | null; 1688 filename: string; 1689 status: string; 1690 }, 1691 [string] 1692 >( 1693 "SELECT id, user_id, class_id, filename, status FROM transcriptions WHERE id = ?", 1694 ) 1695 .get(transcriptionId); 1696 1697 if (!transcription) { 1698 return Response.json( 1699 { error: "Transcription not found" }, 1700 { status: 404 }, 1701 ); 1702 } 1703 1704 // Check access permissions 1705 const isOwner = transcription.user_id === user.id; 1706 const isAdmin = user.role === "admin"; 1707 let isClassMember = false; 1708 1709 // If transcription belongs to a class, check enrollment 1710 if (transcription.class_id) { 1711 isClassMember = isUserEnrolledInClass(user.id, transcription.class_id); 1712 } 1713 1714 // Allow access if: owner, admin, or enrolled in the class 1715 if (!isOwner && !isAdmin && !isClassMember) { 1716 return Response.json( 1717 { error: "Transcription not found" }, 1718 { status: 404 }, 1719 ); 1720 } 1721 1722 // Require subscription only if accessing own transcription (not class) 1723 if (isOwner && !transcription.class_id && !isAdmin && !hasActiveSubscription(user.id)) { 1724 throw AuthErrors.subscriptionRequired(); 1725 } 1726 1727 // For pending recordings, audio file exists even though transcription isn't complete 1728 // Allow audio access for pending and completed statuses 1729 if ( 1730 transcription.status !== "completed" && 1731 transcription.status !== "pending" 1732 ) { 1733 return Response.json( 1734 { error: "Audio not available yet" }, 1735 { status: 400 }, 1736 ); 1737 } 1738 1739 // Serve the audio file with range request support 1740 const filePath = `./uploads/${transcription.filename}`; 1741 const file = Bun.file(filePath); 1742 1743 if (!(await file.exists())) { 1744 return Response.json( 1745 { error: "Audio file not found" }, 1746 { status: 404 }, 1747 ); 1748 } 1749 1750 const fileSize = file.size; 1751 const range = req.headers.get("range"); 1752 1753 // Handle range requests for seeking 1754 if (range) { 1755 const parts = range.replace(/bytes=/, "").split("-"); 1756 const start = Number.parseInt(parts[0] || "0", 10); 1757 const end = parts[1] ? Number.parseInt(parts[1], 10) : fileSize - 1; 1758 const chunkSize = end - start + 1; 1759 1760 const fileSlice = file.slice(start, end + 1); 1761 1762 return new Response(fileSlice, { 1763 status: 206, 1764 headers: { 1765 "Content-Range": `bytes ${start}-${end}/${fileSize}`, 1766 "Accept-Ranges": "bytes", 1767 "Content-Length": chunkSize.toString(), 1768 "Content-Type": file.type || "audio/mpeg", 1769 }, 1770 }); 1771 } 1772 1773 // No range request, send entire file 1774 return new Response(file, { 1775 headers: { 1776 "Content-Type": file.type || "audio/mpeg", 1777 "Accept-Ranges": "bytes", 1778 "Content-Length": fileSize.toString(), 1779 }, 1780 }); 1781 } catch (error) { 1782 return handleError(error); 1783 } 1784 }, 1785 }, 1786 "/api/transcriptions": { 1787 GET: async (req) => { 1788 try { 1789 const user = requireSubscription(req); 1790 1791 const transcriptions = db 1792 .query< 1793 { 1794 id: string; 1795 filename: string; 1796 original_filename: string; 1797 class_id: string | null; 1798 status: string; 1799 progress: number; 1800 created_at: number; 1801 }, 1802 [number] 1803 >( 1804 "SELECT id, filename, original_filename, class_id, status, progress, created_at FROM transcriptions WHERE user_id = ? ORDER BY created_at DESC", 1805 ) 1806 .all(user.id); 1807 1808 // Load transcripts from files for completed jobs 1809 const jobs = await Promise.all( 1810 transcriptions.map(async (t) => { 1811 return { 1812 id: t.id, 1813 filename: t.original_filename, 1814 class_id: t.class_id, 1815 status: t.status, 1816 progress: t.progress, 1817 created_at: t.created_at, 1818 }; 1819 }), 1820 ); 1821 1822 return Response.json({ jobs }); 1823 } catch (error) { 1824 return handleError(error); 1825 } 1826 }, 1827 POST: async (req) => { 1828 try { 1829 const user = requireSubscription(req); 1830 1831 const formData = await req.formData(); 1832 const file = formData.get("audio") as File; 1833 const classId = formData.get("class_id") as string | null; 1834 const meetingTimeId = formData.get("meeting_time_id") as 1835 | string 1836 | null; 1837 1838 if (!file) throw ValidationErrors.missingField("audio"); 1839 1840 // If class_id provided, verify user is enrolled (or admin) 1841 if (classId) { 1842 const enrolled = isUserEnrolledInClass(user.id, classId); 1843 if (!enrolled && user.role !== "admin") { 1844 return Response.json( 1845 { error: "Not enrolled in this class" }, 1846 { status: 403 }, 1847 ); 1848 } 1849 1850 // Verify class exists 1851 const classInfo = getClassById(classId); 1852 if (!classInfo) { 1853 return Response.json( 1854 { error: "Class not found" }, 1855 { status: 404 }, 1856 ); 1857 } 1858 1859 // Check if class is archived 1860 if (classInfo.archived) { 1861 return Response.json( 1862 { error: "Cannot upload to archived class" }, 1863 { status: 400 }, 1864 ); 1865 } 1866 } 1867 1868 // Validate file type 1869 const fileExtension = file.name.split(".").pop()?.toLowerCase(); 1870 const allowedExtensions = [ 1871 "mp3", 1872 "wav", 1873 "m4a", 1874 "aac", 1875 "ogg", 1876 "webm", 1877 "flac", 1878 "mp4", 1879 ]; 1880 const isAudioType = 1881 file.type.startsWith("audio/") || file.type === "video/mp4"; 1882 const isAudioExtension = 1883 fileExtension && allowedExtensions.includes(fileExtension); 1884 1885 if (!isAudioType && !isAudioExtension) { 1886 throw ValidationErrors.unsupportedFileType( 1887 "MP3, WAV, M4A, AAC, OGG, WebM, FLAC", 1888 ); 1889 } 1890 1891 if (file.size > MAX_FILE_SIZE) { 1892 throw ValidationErrors.fileTooLarge("100MB"); 1893 } 1894 1895 // Generate unique filename 1896 const transcriptionId = crypto.randomUUID(); 1897 const filename = `${transcriptionId}.${fileExtension}`; 1898 1899 // Save file to disk 1900 const uploadDir = "./uploads"; 1901 await Bun.write(`${uploadDir}/${filename}`, file); 1902 1903 // Create database record 1904 db.run( 1905 "INSERT INTO transcriptions (id, user_id, class_id, meeting_time_id, filename, original_filename, status) VALUES (?, ?, ?, ?, ?, ?, ?)", 1906 [ 1907 transcriptionId, 1908 user.id, 1909 classId, 1910 meetingTimeId, 1911 filename, 1912 file.name, 1913 "pending", 1914 ], 1915 ); 1916 1917 // Don't auto-start transcription - admin will select recordings 1918 // whisperService.startTranscription(transcriptionId, filename); 1919 1920 return Response.json({ 1921 id: transcriptionId, 1922 message: "Upload successful", 1923 }); 1924 } catch (error) { 1925 return handleError(error); 1926 } 1927 }, 1928 }, 1929 "/api/admin/transcriptions": { 1930 GET: async (req) => { 1931 try { 1932 requireAdmin(req); 1933 const transcriptions = getAllTranscriptions(); 1934 return Response.json(transcriptions); 1935 } catch (error) { 1936 return handleError(error); 1937 } 1938 }, 1939 }, 1940 "/api/admin/users": { 1941 GET: async (req) => { 1942 try { 1943 requireAdmin(req); 1944 const users = getAllUsersWithStats(); 1945 return Response.json(users); 1946 } catch (error) { 1947 return handleError(error); 1948 } 1949 }, 1950 }, 1951 "/api/admin/classes": { 1952 GET: async (req) => { 1953 try { 1954 requireAdmin(req); 1955 const classes = getClassesForUser(0, true); // Admin sees all classes 1956 return Response.json({ classes }); 1957 } catch (error) { 1958 return handleError(error); 1959 } 1960 }, 1961 }, 1962 "/api/admin/waitlist": { 1963 GET: async (req) => { 1964 try { 1965 requireAdmin(req); 1966 const waitlist = getAllWaitlistEntries(); 1967 return Response.json({ waitlist }); 1968 } catch (error) { 1969 return handleError(error); 1970 } 1971 }, 1972 }, 1973 "/api/admin/waitlist/:id": { 1974 DELETE: async (req) => { 1975 try { 1976 requireAdmin(req); 1977 const id = req.params.id; 1978 deleteWaitlistEntry(id); 1979 return Response.json({ success: true }); 1980 } catch (error) { 1981 return handleError(error); 1982 } 1983 }, 1984 }, 1985 "/api/admin/transcriptions/:id": { 1986 DELETE: async (req) => { 1987 try { 1988 requireAdmin(req); 1989 const transcriptionId = req.params.id; 1990 deleteTranscription(transcriptionId); 1991 return Response.json({ success: true }); 1992 } catch (error) { 1993 return handleError(error); 1994 } 1995 }, 1996 }, 1997 "/api/admin/users/:id": { 1998 DELETE: async (req) => { 1999 try { 2000 requireAdmin(req); 2001 const userId = Number.parseInt(req.params.id, 10); 2002 if (Number.isNaN(userId)) { 2003 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2004 } 2005 await deleteUser(userId); 2006 return Response.json({ success: true }); 2007 } catch (error) { 2008 return handleError(error); 2009 } 2010 }, 2011 }, 2012 "/api/admin/users/:id/role": { 2013 PUT: async (req) => { 2014 try { 2015 requireAdmin(req); 2016 const userId = Number.parseInt(req.params.id, 10); 2017 if (Number.isNaN(userId)) { 2018 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2019 } 2020 2021 const body = await req.json(); 2022 const { role } = body as { role: UserRole }; 2023 2024 if (!role || (role !== "user" && role !== "admin")) { 2025 return Response.json( 2026 { error: "Invalid role. Must be 'user' or 'admin'" }, 2027 { status: 400 }, 2028 ); 2029 } 2030 2031 updateUserRole(userId, role); 2032 return Response.json({ success: true }); 2033 } catch (error) { 2034 return handleError(error); 2035 } 2036 }, 2037 }, 2038 "/api/admin/users/:id/subscription": { 2039 DELETE: async (req) => { 2040 try { 2041 requireAdmin(req); 2042 const userId = Number.parseInt(req.params.id, 10); 2043 if (Number.isNaN(userId)) { 2044 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2045 } 2046 2047 const body = await req.json(); 2048 const { subscriptionId } = body as { subscriptionId: string }; 2049 2050 if (!subscriptionId) { 2051 return Response.json( 2052 { error: "Subscription ID required" }, 2053 { status: 400 }, 2054 ); 2055 } 2056 2057 try { 2058 const { polar } = await import("./lib/polar"); 2059 await polar.subscriptions.revoke({ id: subscriptionId }); 2060 return Response.json({ 2061 success: true, 2062 message: "Subscription revoked successfully", 2063 }); 2064 } catch (error) { 2065 console.error( 2066 `[Admin] Failed to revoke subscription ${subscriptionId}:`, 2067 error, 2068 ); 2069 return Response.json( 2070 { 2071 error: 2072 error instanceof Error 2073 ? error.message 2074 : "Failed to revoke subscription", 2075 }, 2076 { status: 500 }, 2077 ); 2078 } 2079 } catch (error) { 2080 return handleError(error); 2081 } 2082 }, 2083 PUT: async (req) => { 2084 try { 2085 requireAdmin(req); 2086 const userId = Number.parseInt(req.params.id, 10); 2087 if (Number.isNaN(userId)) { 2088 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2089 } 2090 2091 // Get user email 2092 const user = db 2093 .query<{ email: string }, [number]>( 2094 "SELECT email FROM users WHERE id = ?", 2095 ) 2096 .get(userId); 2097 2098 if (!user) { 2099 return Response.json( 2100 { error: "User not found" }, 2101 { status: 404 }, 2102 ); 2103 } 2104 2105 try { 2106 await syncUserSubscriptionsFromPolar(userId, user.email); 2107 return Response.json({ 2108 success: true, 2109 message: "Subscription synced successfully", 2110 }); 2111 } catch (error) { 2112 console.error( 2113 `[Admin] Failed to sync subscription for user ${userId}:`, 2114 error, 2115 ); 2116 return Response.json( 2117 { 2118 error: 2119 error instanceof Error 2120 ? error.message 2121 : "Failed to sync subscription", 2122 }, 2123 { status: 500 }, 2124 ); 2125 } 2126 } catch (error) { 2127 return handleError(error); 2128 } 2129 }, 2130 }, 2131 "/api/admin/users/:id/details": { 2132 GET: async (req) => { 2133 try { 2134 requireAdmin(req); 2135 const userId = Number.parseInt(req.params.id, 10); 2136 if (Number.isNaN(userId)) { 2137 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2138 } 2139 2140 const user = db 2141 .query< 2142 { 2143 id: number; 2144 email: string; 2145 name: string | null; 2146 avatar: string; 2147 created_at: number; 2148 role: UserRole; 2149 password_hash: string | null; 2150 last_login: number | null; 2151 }, 2152 [number] 2153 >( 2154 "SELECT id, email, name, avatar, created_at, role, password_hash, last_login FROM users WHERE id = ?", 2155 ) 2156 .get(userId); 2157 2158 if (!user) { 2159 return Response.json({ error: "User not found" }, { status: 404 }); 2160 } 2161 2162 const passkeys = getPasskeysForUser(userId); 2163 const sessions = getSessionsForUser(userId); 2164 2165 // Get transcription count 2166 const transcriptionCount = 2167 db 2168 .query<{ count: number }, [number]>( 2169 "SELECT COUNT(*) as count FROM transcriptions WHERE user_id = ?", 2170 ) 2171 .get(userId)?.count ?? 0; 2172 2173 return Response.json({ 2174 id: user.id, 2175 email: user.email, 2176 name: user.name, 2177 avatar: user.avatar, 2178 created_at: user.created_at, 2179 role: user.role, 2180 last_login: user.last_login, 2181 hasPassword: !!user.password_hash, 2182 transcriptionCount, 2183 passkeys: passkeys.map((pk) => ({ 2184 id: pk.id, 2185 name: pk.name, 2186 created_at: pk.created_at, 2187 last_used_at: pk.last_used_at, 2188 })), 2189 sessions: sessions.map((s) => ({ 2190 id: s.id, 2191 ip_address: s.ip_address, 2192 user_agent: s.user_agent, 2193 created_at: s.created_at, 2194 expires_at: s.expires_at, 2195 })), 2196 }); 2197 } catch (error) { 2198 return handleError(error); 2199 } 2200 }, 2201 }, 2202 "/api/admin/users/:id/password-reset": { 2203 POST: async (req) => { 2204 try { 2205 requireAdmin(req); 2206 const userId = Number.parseInt(req.params.id, 10); 2207 if (Number.isNaN(userId)) { 2208 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2209 } 2210 2211 // Get user details 2212 const user = db 2213 .query< 2214 { id: number; email: string; name: string | null }, 2215 [number] 2216 >("SELECT id, email, name FROM users WHERE id = ?") 2217 .get(userId); 2218 2219 if (!user) { 2220 return Response.json({ error: "User not found" }, { status: 404 }); 2221 } 2222 2223 // Create password reset token 2224 const origin = process.env.ORIGIN || "http://localhost:3000"; 2225 const resetToken = createPasswordResetToken(user.id); 2226 const resetLink = `${origin}/reset-password?token=${resetToken}`; 2227 2228 // Send password reset email 2229 await sendEmail({ 2230 to: user.email, 2231 subject: "Reset your password - Thistle", 2232 html: passwordResetTemplate({ 2233 name: user.name, 2234 resetLink, 2235 }), 2236 }); 2237 2238 return Response.json({ 2239 success: true, 2240 message: "Password reset email sent" 2241 }); 2242 } catch (error) { 2243 console.error("[Admin] Password reset error:", error); 2244 return handleError(error); 2245 } 2246 }, 2247 }, 2248 "/api/admin/users/:id/passkeys/:passkeyId": { 2249 DELETE: async (req) => { 2250 try { 2251 requireAdmin(req); 2252 const userId = Number.parseInt(req.params.id, 10); 2253 if (Number.isNaN(userId)) { 2254 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2255 } 2256 2257 const { passkeyId } = req.params; 2258 deletePasskey(passkeyId, userId); 2259 return Response.json({ success: true }); 2260 } catch (error) { 2261 return handleError(error); 2262 } 2263 }, 2264 }, 2265 "/api/admin/users/:id/name": { 2266 PUT: async (req) => { 2267 try { 2268 requireAdmin(req); 2269 const userId = Number.parseInt(req.params.id, 10); 2270 if (Number.isNaN(userId)) { 2271 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2272 } 2273 2274 const body = await req.json(); 2275 const { name } = body as { name: string }; 2276 2277 if (!name || name.trim().length === 0) { 2278 return Response.json( 2279 { error: "Name cannot be empty" }, 2280 { status: 400 }, 2281 ); 2282 } 2283 2284 updateUserName(userId, name.trim()); 2285 return Response.json({ success: true }); 2286 } catch (error) { 2287 return handleError(error); 2288 } 2289 }, 2290 }, 2291 "/api/admin/users/:id/email": { 2292 PUT: async (req) => { 2293 try { 2294 requireAdmin(req); 2295 const userId = Number.parseInt(req.params.id, 10); 2296 if (Number.isNaN(userId)) { 2297 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2298 } 2299 2300 const body = await req.json(); 2301 const { email } = body as { email: string }; 2302 2303 if (!email || !email.includes("@")) { 2304 return Response.json( 2305 { error: "Invalid email address" }, 2306 { status: 400 }, 2307 ); 2308 } 2309 2310 // Check if email already exists 2311 const existing = db 2312 .query<{ id: number }, [string, number]>( 2313 "SELECT id FROM users WHERE email = ? AND id != ?", 2314 ) 2315 .get(email, userId); 2316 2317 if (existing) { 2318 return Response.json( 2319 { error: "Email already in use" }, 2320 { status: 400 }, 2321 ); 2322 } 2323 2324 updateUserEmailAddress(userId, email); 2325 return Response.json({ success: true }); 2326 } catch (error) { 2327 return handleError(error); 2328 } 2329 }, 2330 }, 2331 "/api/admin/users/:id/sessions": { 2332 GET: async (req) => { 2333 try { 2334 requireAdmin(req); 2335 const userId = Number.parseInt(req.params.id, 10); 2336 if (Number.isNaN(userId)) { 2337 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2338 } 2339 2340 const sessions = getSessionsForUser(userId); 2341 return Response.json(sessions); 2342 } catch (error) { 2343 return handleError(error); 2344 } 2345 }, 2346 DELETE: async (req) => { 2347 try { 2348 requireAdmin(req); 2349 const userId = Number.parseInt(req.params.id, 10); 2350 if (Number.isNaN(userId)) { 2351 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2352 } 2353 2354 deleteAllUserSessions(userId); 2355 return Response.json({ success: true }); 2356 } catch (error) { 2357 return handleError(error); 2358 } 2359 }, 2360 }, 2361 "/api/admin/users/:id/sessions/:sessionId": { 2362 DELETE: async (req) => { 2363 try { 2364 requireAdmin(req); 2365 const userId = Number.parseInt(req.params.id, 10); 2366 if (Number.isNaN(userId)) { 2367 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2368 } 2369 2370 const { sessionId } = req.params; 2371 const success = deleteSessionById(sessionId, userId); 2372 2373 if (!success) { 2374 return Response.json( 2375 { error: "Session not found" }, 2376 { status: 404 }, 2377 ); 2378 } 2379 2380 return Response.json({ success: true }); 2381 } catch (error) { 2382 return handleError(error); 2383 } 2384 }, 2385 }, 2386 "/api/admin/transcriptions/:id/details": { 2387 GET: async (req) => { 2388 try { 2389 requireAdmin(req); 2390 const transcriptionId = req.params.id; 2391 2392 const transcription = db 2393 .query< 2394 { 2395 id: string; 2396 original_filename: string; 2397 status: string; 2398 created_at: number; 2399 updated_at: number; 2400 error_message: string | null; 2401 user_id: number; 2402 }, 2403 [string] 2404 >( 2405 "SELECT id, original_filename, status, created_at, updated_at, error_message, user_id FROM transcriptions WHERE id = ?", 2406 ) 2407 .get(transcriptionId); 2408 2409 if (!transcription) { 2410 return Response.json( 2411 { error: "Transcription not found" }, 2412 { status: 404 }, 2413 ); 2414 } 2415 2416 const user = db 2417 .query<{ email: string; name: string | null }, [number]>( 2418 "SELECT email, name FROM users WHERE id = ?", 2419 ) 2420 .get(transcription.user_id); 2421 2422 return Response.json({ 2423 id: transcription.id, 2424 original_filename: transcription.original_filename, 2425 status: transcription.status, 2426 created_at: transcription.created_at, 2427 completed_at: transcription.updated_at, 2428 error_message: transcription.error_message, 2429 user_id: transcription.user_id, 2430 user_email: user?.email || "Unknown", 2431 user_name: user?.name || null, 2432 }); 2433 } catch (error) { 2434 return handleError(error); 2435 } 2436 }, 2437 }, 2438 "/api/classes": { 2439 GET: async (req) => { 2440 try { 2441 const user = requireAuth(req); 2442 const classes = getClassesForUser(user.id, user.role === "admin"); 2443 2444 // Group by semester/year 2445 const grouped: Record< 2446 string, 2447 Array<{ 2448 id: string; 2449 course_code: string; 2450 name: string; 2451 professor: string; 2452 semester: string; 2453 year: number; 2454 archived: boolean; 2455 }> 2456 > = {}; 2457 2458 for (const cls of classes) { 2459 const key = `${cls.semester} ${cls.year}`; 2460 if (!grouped[key]) { 2461 grouped[key] = []; 2462 } 2463 grouped[key]?.push({ 2464 id: cls.id, 2465 course_code: cls.course_code, 2466 name: cls.name, 2467 professor: cls.professor, 2468 semester: cls.semester, 2469 year: cls.year, 2470 archived: cls.archived, 2471 }); 2472 } 2473 2474 return Response.json({ classes: grouped }); 2475 } catch (error) { 2476 return handleError(error); 2477 } 2478 }, 2479 POST: async (req) => { 2480 try { 2481 requireAdmin(req); 2482 const body = await req.json(); 2483 const { 2484 course_code, 2485 name, 2486 professor, 2487 semester, 2488 year, 2489 meeting_times, 2490 } = body; 2491 2492 if (!course_code || !name || !professor || !semester || !year) { 2493 return Response.json( 2494 { error: "Missing required fields" }, 2495 { status: 400 }, 2496 ); 2497 } 2498 2499 const newClass = createClass({ 2500 course_code, 2501 name, 2502 professor, 2503 semester, 2504 year, 2505 meeting_times, 2506 }); 2507 2508 return Response.json(newClass); 2509 } catch (error) { 2510 return handleError(error); 2511 } 2512 }, 2513 }, 2514 "/api/classes/search": { 2515 GET: async (req) => { 2516 try { 2517 const user = requireAuth(req); 2518 const url = new URL(req.url); 2519 const query = url.searchParams.get("q"); 2520 2521 if (!query) { 2522 return Response.json({ classes: [] }); 2523 } 2524 2525 const classes = searchClassesByCourseCode(query); 2526 2527 // Get user's enrolled classes to mark them 2528 const enrolledClassIds = db 2529 .query<{ class_id: string }, [number]>( 2530 "SELECT class_id FROM class_members WHERE user_id = ?", 2531 ) 2532 .all(user.id) 2533 .map((row) => row.class_id); 2534 2535 // Add is_enrolled flag to each class 2536 const classesWithEnrollment = classes.map((cls) => ({ 2537 ...cls, 2538 is_enrolled: enrolledClassIds.includes(cls.id), 2539 })); 2540 2541 return Response.json({ classes: classesWithEnrollment }); 2542 } catch (error) { 2543 return handleError(error); 2544 } 2545 }, 2546 }, 2547 "/api/classes/join": { 2548 POST: async (req) => { 2549 try { 2550 const user = requireAuth(req); 2551 const body = await req.json(); 2552 const classId = body.class_id; 2553 2554 if (!classId || typeof classId !== "string") { 2555 return Response.json( 2556 { error: "Class ID required" }, 2557 { status: 400 }, 2558 ); 2559 } 2560 2561 const result = joinClass(classId, user.id); 2562 2563 if (!result.success) { 2564 return Response.json({ error: result.error }, { status: 400 }); 2565 } 2566 2567 return Response.json({ success: true }); 2568 } catch (error) { 2569 return handleError(error); 2570 } 2571 }, 2572 }, 2573 "/api/classes/waitlist": { 2574 POST: async (req) => { 2575 try { 2576 const user = requireAuth(req); 2577 const body = await req.json(); 2578 2579 const { 2580 courseCode, 2581 courseName, 2582 professor, 2583 semester, 2584 year, 2585 additionalInfo, 2586 meetingTimes, 2587 } = body; 2588 2589 if (!courseCode || !courseName || !professor || !semester || !year) { 2590 return Response.json( 2591 { error: "Missing required fields" }, 2592 { status: 400 }, 2593 ); 2594 } 2595 2596 const id = addToWaitlist( 2597 user.id, 2598 courseCode, 2599 courseName, 2600 professor, 2601 semester, 2602 Number.parseInt(year, 10), 2603 additionalInfo || null, 2604 meetingTimes || null, 2605 ); 2606 2607 return Response.json({ success: true, id }); 2608 } catch (error) { 2609 return handleError(error); 2610 } 2611 }, 2612 }, 2613 "/api/classes/:id": { 2614 GET: async (req) => { 2615 try { 2616 const user = requireAuth(req); 2617 const classId = req.params.id; 2618 2619 const classInfo = getClassById(classId); 2620 if (!classInfo) { 2621 return Response.json({ error: "Class not found" }, { status: 404 }); 2622 } 2623 2624 // Check enrollment or admin 2625 const isEnrolled = isUserEnrolledInClass(user.id, classId); 2626 if (!isEnrolled && user.role !== "admin") { 2627 return Response.json( 2628 { error: "Not enrolled in this class" }, 2629 { status: 403 }, 2630 ); 2631 } 2632 2633 const meetingTimes = getMeetingTimesForClass(classId); 2634 const transcriptions = getTranscriptionsForClass(classId); 2635 2636 return Response.json({ 2637 class: classInfo, 2638 meetingTimes, 2639 transcriptions, 2640 }); 2641 } catch (error) { 2642 return handleError(error); 2643 } 2644 }, 2645 DELETE: async (req) => { 2646 try { 2647 requireAdmin(req); 2648 const classId = req.params.id; 2649 2650 deleteClass(classId); 2651 return Response.json({ success: true }); 2652 } catch (error) { 2653 return handleError(error); 2654 } 2655 }, 2656 }, 2657 "/api/classes/:id/archive": { 2658 PUT: async (req) => { 2659 try { 2660 requireAdmin(req); 2661 const classId = req.params.id; 2662 const body = await req.json(); 2663 const { archived } = body; 2664 2665 if (typeof archived !== "boolean") { 2666 return Response.json( 2667 { error: "archived must be a boolean" }, 2668 { status: 400 }, 2669 ); 2670 } 2671 2672 toggleClassArchive(classId, archived); 2673 return Response.json({ success: true }); 2674 } catch (error) { 2675 return handleError(error); 2676 } 2677 }, 2678 }, 2679 "/api/classes/:id/members": { 2680 GET: async (req) => { 2681 try { 2682 requireAdmin(req); 2683 const classId = req.params.id; 2684 2685 const members = getClassMembers(classId); 2686 return Response.json({ members }); 2687 } catch (error) { 2688 return handleError(error); 2689 } 2690 }, 2691 POST: async (req) => { 2692 try { 2693 requireAdmin(req); 2694 const classId = req.params.id; 2695 const body = await req.json(); 2696 const { email } = body; 2697 2698 if (!email) { 2699 return Response.json({ error: "Email required" }, { status: 400 }); 2700 } 2701 2702 const user = getUserByEmail(email); 2703 if (!user) { 2704 return Response.json({ error: "User not found" }, { status: 404 }); 2705 } 2706 2707 enrollUserInClass(user.id, classId); 2708 return Response.json({ success: true }); 2709 } catch (error) { 2710 return handleError(error); 2711 } 2712 }, 2713 }, 2714 "/api/classes/:id/members/:userId": { 2715 DELETE: async (req) => { 2716 try { 2717 requireAdmin(req); 2718 const classId = req.params.id; 2719 const userId = Number.parseInt(req.params.userId, 10); 2720 2721 if (Number.isNaN(userId)) { 2722 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2723 } 2724 2725 removeUserFromClass(userId, classId); 2726 return Response.json({ success: true }); 2727 } catch (error) { 2728 return handleError(error); 2729 } 2730 }, 2731 }, 2732 "/api/classes/:id/meetings": { 2733 GET: async (req) => { 2734 try { 2735 const user = requireAuth(req); 2736 const classId = req.params.id; 2737 2738 // Check enrollment or admin 2739 const isEnrolled = isUserEnrolledInClass(user.id, classId); 2740 if (!isEnrolled && user.role !== "admin") { 2741 return Response.json( 2742 { error: "Not enrolled in this class" }, 2743 { status: 403 }, 2744 ); 2745 } 2746 2747 const meetingTimes = getMeetingTimesForClass(classId); 2748 return Response.json({ meetings: meetingTimes }); 2749 } catch (error) { 2750 return handleError(error); 2751 } 2752 }, 2753 POST: async (req) => { 2754 try { 2755 requireAdmin(req); 2756 const classId = req.params.id; 2757 const body = await req.json(); 2758 const { label } = body; 2759 2760 if (!label) { 2761 return Response.json({ error: "Label required" }, { status: 400 }); 2762 } 2763 2764 const meetingTime = createMeetingTime(classId, label); 2765 return Response.json(meetingTime); 2766 } catch (error) { 2767 return handleError(error); 2768 } 2769 }, 2770 }, 2771 "/api/meetings/:id": { 2772 PUT: async (req) => { 2773 try { 2774 requireAdmin(req); 2775 const meetingId = req.params.id; 2776 const body = await req.json(); 2777 const { label } = body; 2778 2779 if (!label) { 2780 return Response.json({ error: "Label required" }, { status: 400 }); 2781 } 2782 2783 updateMeetingTime(meetingId, label); 2784 return Response.json({ success: true }); 2785 } catch (error) { 2786 return handleError(error); 2787 } 2788 }, 2789 DELETE: async (req) => { 2790 try { 2791 requireAdmin(req); 2792 const meetingId = req.params.id; 2793 2794 deleteMeetingTime(meetingId); 2795 return Response.json({ success: true }); 2796 } catch (error) { 2797 return handleError(error); 2798 } 2799 }, 2800 }, 2801 "/api/transcripts/:id/select": { 2802 PUT: async (req) => { 2803 try { 2804 requireAdmin(req); 2805 const transcriptId = req.params.id; 2806 2807 // Update status to 'selected' and start transcription 2808 db.run("UPDATE transcriptions SET status = ? WHERE id = ?", [ 2809 "selected", 2810 transcriptId, 2811 ]); 2812 2813 // Get filename to start transcription 2814 const transcription = db 2815 .query<{ filename: string }, [string]>( 2816 "SELECT filename FROM transcriptions WHERE id = ?", 2817 ) 2818 .get(transcriptId); 2819 2820 if (transcription) { 2821 whisperService.startTranscription( 2822 transcriptId, 2823 transcription.filename, 2824 ); 2825 } 2826 2827 return Response.json({ success: true }); 2828 } catch (error) { 2829 return handleError(error); 2830 } 2831 }, 2832 }, 2833 }, 2834 development: { 2835 hmr: true, 2836 console: true, 2837 }, 2838}); 2839console.log(`馃 Thistle running at http://localhost:${server.port}`);