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