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