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