馃 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 is_current: s.id === sessionId, 460 })), 461 }); 462 }, 463 DELETE: async (req) => { 464 const currentSessionId = getSessionFromRequest(req); 465 if (!currentSessionId) { 466 return Response.json({ error: "Not authenticated" }, { status: 401 }); 467 } 468 const user = getUserBySession(currentSessionId); 469 if (!user) { 470 return Response.json({ error: "Invalid session" }, { status: 401 }); 471 } 472 const body = await req.json(); 473 const targetSessionId = body.sessionId; 474 if (!targetSessionId) { 475 return Response.json( 476 { error: "Session ID required" }, 477 { status: 400 }, 478 ); 479 } 480 // Prevent deleting current session 481 if (targetSessionId === currentSessionId) { 482 return Response.json( 483 { error: "Cannot kill current session. Use logout instead." }, 484 { status: 400 }, 485 ); 486 } 487 // Verify the session belongs to the user 488 const targetSession = getSession(targetSessionId); 489 if (!targetSession || targetSession.user_id !== user.id) { 490 return Response.json({ error: "Session not found" }, { status: 404 }); 491 } 492 deleteSession(targetSessionId); 493 return Response.json({ success: true }); 494 }, 495 }, 496 "/api/user": { 497 DELETE: async (req) => { 498 const sessionId = getSessionFromRequest(req); 499 if (!sessionId) { 500 return Response.json({ error: "Not authenticated" }, { status: 401 }); 501 } 502 const user = getUserBySession(sessionId); 503 if (!user) { 504 return Response.json({ error: "Invalid session" }, { status: 401 }); 505 } 506 507 // Rate limiting 508 const rateLimitError = enforceRateLimit(req, "delete-user", { 509 ip: { max: 3, windowSeconds: 60 * 60 }, 510 }); 511 if (rateLimitError) return rateLimitError; 512 513 await deleteUser(user.id); 514 return Response.json( 515 { success: true }, 516 { 517 headers: { 518 "Set-Cookie": 519 "session=; HttpOnly; Secure; Path=/; Max-Age=0; SameSite=Lax", 520 }, 521 }, 522 ); 523 }, 524 }, 525 "/api/user/email": { 526 PUT: async (req) => { 527 const sessionId = getSessionFromRequest(req); 528 if (!sessionId) { 529 return Response.json({ error: "Not authenticated" }, { status: 401 }); 530 } 531 const user = getUserBySession(sessionId); 532 if (!user) { 533 return Response.json({ error: "Invalid session" }, { status: 401 }); 534 } 535 536 // Rate limiting 537 const rateLimitError = enforceRateLimit(req, "update-email", { 538 ip: { max: 5, windowSeconds: 60 * 60 }, 539 }); 540 if (rateLimitError) return rateLimitError; 541 542 const body = await req.json(); 543 const { email } = body; 544 if (!email) { 545 return Response.json({ error: "Email required" }, { status: 400 }); 546 } 547 try { 548 updateUserEmail(user.id, email); 549 return Response.json({ success: true }); 550 } catch (err: unknown) { 551 const error = err as { message?: string }; 552 if (error.message?.includes("UNIQUE constraint failed")) { 553 return Response.json( 554 { error: "Email already in use" }, 555 { status: 400 }, 556 ); 557 } 558 return Response.json( 559 { error: "Failed to update email" }, 560 { status: 500 }, 561 ); 562 } 563 }, 564 }, 565 "/api/user/password": { 566 PUT: async (req) => { 567 const sessionId = getSessionFromRequest(req); 568 if (!sessionId) { 569 return Response.json({ error: "Not authenticated" }, { status: 401 }); 570 } 571 const user = getUserBySession(sessionId); 572 if (!user) { 573 return Response.json({ error: "Invalid session" }, { status: 401 }); 574 } 575 576 // Rate limiting 577 const rateLimitError = enforceRateLimit(req, "update-password", { 578 ip: { max: 5, windowSeconds: 60 * 60 }, 579 }); 580 if (rateLimitError) return rateLimitError; 581 582 const body = await req.json(); 583 const { password } = body; 584 if (!password) { 585 return Response.json({ error: "Password required" }, { status: 400 }); 586 } 587 // Password is client-side hashed (PBKDF2), should be 64 char hex 588 if (password.length !== 64 || !/^[0-9a-f]+$/.test(password)) { 589 return Response.json( 590 { error: "Invalid password format" }, 591 { status: 400 }, 592 ); 593 } 594 try { 595 await updateUserPassword(user.id, password); 596 return Response.json({ success: true }); 597 } catch { 598 return Response.json( 599 { error: "Failed to update password" }, 600 { status: 500 }, 601 ); 602 } 603 }, 604 }, 605 "/api/user/name": { 606 PUT: async (req) => { 607 const sessionId = getSessionFromRequest(req); 608 if (!sessionId) { 609 return Response.json({ error: "Not authenticated" }, { status: 401 }); 610 } 611 const user = getUserBySession(sessionId); 612 if (!user) { 613 return Response.json({ error: "Invalid session" }, { status: 401 }); 614 } 615 const body = await req.json(); 616 const { name } = body; 617 if (!name) { 618 return Response.json({ error: "Name required" }, { status: 400 }); 619 } 620 try { 621 updateUserName(user.id, name); 622 return Response.json({ success: true }); 623 } catch { 624 return Response.json( 625 { error: "Failed to update name" }, 626 { status: 500 }, 627 ); 628 } 629 }, 630 }, 631 "/api/user/avatar": { 632 PUT: async (req) => { 633 const sessionId = getSessionFromRequest(req); 634 if (!sessionId) { 635 return Response.json({ error: "Not authenticated" }, { status: 401 }); 636 } 637 const user = getUserBySession(sessionId); 638 if (!user) { 639 return Response.json({ error: "Invalid session" }, { status: 401 }); 640 } 641 const body = await req.json(); 642 const { avatar } = body; 643 if (!avatar) { 644 return Response.json({ error: "Avatar required" }, { status: 400 }); 645 } 646 try { 647 updateUserAvatar(user.id, avatar); 648 return Response.json({ success: true }); 649 } catch { 650 return Response.json( 651 { error: "Failed to update avatar" }, 652 { status: 500 }, 653 ); 654 } 655 }, 656 }, 657 "/api/billing/checkout": { 658 POST: async (req) => { 659 const sessionId = getSessionFromRequest(req); 660 if (!sessionId) { 661 return Response.json({ error: "Not authenticated" }, { status: 401 }); 662 } 663 const user = getUserBySession(sessionId); 664 if (!user) { 665 return Response.json({ error: "Invalid session" }, { status: 401 }); 666 } 667 668 try { 669 const { polar } = await import("./lib/polar"); 670 671 const productId = process.env.POLAR_PRODUCT_ID; 672 if (!productId) { 673 return Response.json( 674 { error: "Product not configured" }, 675 { status: 500 }, 676 ); 677 } 678 679 const successUrl = process.env.POLAR_SUCCESS_URL; 680 if (!successUrl) { 681 return Response.json( 682 { error: "Success URL not configured" }, 683 { status: 500 }, 684 ); 685 } 686 687 const checkout = await polar.checkouts.create({ 688 products: [productId], 689 successUrl, 690 customerEmail: user.email, 691 customerName: user.name ?? undefined, 692 metadata: { 693 userId: user.id.toString(), 694 }, 695 }); 696 697 return Response.json({ url: checkout.url }); 698 } catch (error) { 699 console.error("Failed to create checkout:", error); 700 return Response.json( 701 { error: "Failed to create checkout session" }, 702 { status: 500 }, 703 ); 704 } 705 }, 706 }, 707 "/api/billing/subscription": { 708 GET: async (req) => { 709 const sessionId = getSessionFromRequest(req); 710 if (!sessionId) { 711 return Response.json({ error: "Not authenticated" }, { status: 401 }); 712 } 713 const user = getUserBySession(sessionId); 714 if (!user) { 715 return Response.json({ error: "Invalid session" }, { status: 401 }); 716 } 717 718 try { 719 // Get subscription from database 720 const subscription = db 721 .query< 722 { 723 id: string; 724 status: string; 725 current_period_start: number | null; 726 current_period_end: number | null; 727 cancel_at_period_end: number; 728 canceled_at: number | null; 729 }, 730 [number] 731 >( 732 "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", 733 ) 734 .get(user.id); 735 736 if (!subscription) { 737 return Response.json({ subscription: null }); 738 } 739 740 return Response.json({ subscription }); 741 } catch (error) { 742 console.error("Failed to fetch subscription:", error); 743 return Response.json( 744 { error: "Failed to fetch subscription" }, 745 { status: 500 }, 746 ); 747 } 748 }, 749 }, 750 "/api/billing/portal": { 751 POST: async (req) => { 752 const sessionId = getSessionFromRequest(req); 753 if (!sessionId) { 754 return Response.json({ error: "Not authenticated" }, { status: 401 }); 755 } 756 const user = getUserBySession(sessionId); 757 if (!user) { 758 return Response.json({ error: "Invalid session" }, { status: 401 }); 759 } 760 761 try { 762 const { polar } = await import("./lib/polar"); 763 764 // Get subscription to find customer ID 765 const subscription = db 766 .query< 767 { 768 customer_id: string; 769 }, 770 [number] 771 >( 772 "SELECT customer_id FROM subscriptions WHERE user_id = ? ORDER BY created_at DESC LIMIT 1", 773 ) 774 .get(user.id); 775 776 if (!subscription || !subscription.customer_id) { 777 return Response.json( 778 { error: "No subscription found" }, 779 { status: 404 }, 780 ); 781 } 782 783 // Create customer portal session 784 const session = await polar.customerSessions.create({ 785 customerId: subscription.customer_id, 786 }); 787 788 return Response.json({ url: session.customerPortalUrl }); 789 } catch (error) { 790 console.error("Failed to create portal session:", error); 791 return Response.json( 792 { error: "Failed to create portal session" }, 793 { status: 500 }, 794 ); 795 } 796 }, 797 }, 798 "/api/webhooks/polar": { 799 POST: async (req) => { 800 try { 801 const { validateEvent } = await import("@polar-sh/sdk/webhooks"); 802 803 // Get raw body as string 804 const rawBody = await req.text(); 805 const headers = Object.fromEntries(req.headers.entries()); 806 807 // Validate webhook signature 808 const webhookSecret = process.env.POLAR_WEBHOOK_SECRET; 809 if (!webhookSecret) { 810 console.error("[Webhook] POLAR_WEBHOOK_SECRET not configured"); 811 return Response.json( 812 { error: "Webhook secret not configured" }, 813 { status: 500 }, 814 ); 815 } 816 817 const event = validateEvent(rawBody, headers, webhookSecret); 818 819 console.log(`[Webhook] Received event: ${event.type}`); 820 821 // Handle different event types 822 switch (event.type) { 823 case "subscription.updated": { 824 const { id, status, customerId, metadata } = event.data; 825 const userId = metadata?.userId 826 ? Number.parseInt(metadata.userId as string, 10) 827 : null; 828 829 if (!userId) { 830 console.warn("[Webhook] No userId in subscription metadata"); 831 break; 832 } 833 834 // Upsert subscription 835 db.run( 836 `INSERT INTO subscriptions (id, user_id, customer_id, status, current_period_start, current_period_end, cancel_at_period_end, canceled_at, updated_at) 837 VALUES (?, ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now')) 838 ON CONFLICT(id) DO UPDATE SET 839 status = excluded.status, 840 current_period_start = excluded.current_period_start, 841 current_period_end = excluded.current_period_end, 842 cancel_at_period_end = excluded.cancel_at_period_end, 843 canceled_at = excluded.canceled_at, 844 updated_at = strftime('%s', 'now')`, 845 [ 846 id, 847 userId, 848 customerId, 849 status, 850 event.data.currentPeriodStart 851 ? Math.floor( 852 new Date(event.data.currentPeriodStart).getTime() / 853 1000, 854 ) 855 : null, 856 event.data.currentPeriodEnd 857 ? Math.floor( 858 new Date(event.data.currentPeriodEnd).getTime() / 1000, 859 ) 860 : null, 861 event.data.cancelAtPeriodEnd ? 1 : 0, 862 event.data.canceledAt 863 ? Math.floor( 864 new Date(event.data.canceledAt).getTime() / 1000, 865 ) 866 : null, 867 ], 868 ); 869 870 console.log( 871 `[Webhook] Updated subscription ${id} for user ${userId}`, 872 ); 873 break; 874 } 875 876 default: 877 console.log(`[Webhook] Unhandled event type: ${event.type}`); 878 } 879 880 return Response.json({ received: true }); 881 } catch (error) { 882 console.error("[Webhook] Error processing webhook:", error); 883 return Response.json( 884 { error: "Webhook processing failed" }, 885 { status: 400 }, 886 ); 887 } 888 }, 889 }, 890 "/api/transcriptions/:id/stream": { 891 GET: async (req) => { 892 try { 893 const user = requireSubscription(req); 894 const transcriptionId = req.params.id; 895 // Verify ownership 896 const transcription = db 897 .query<{ id: string; user_id: number; status: string }, [string]>( 898 "SELECT id, user_id, status FROM transcriptions WHERE id = ?", 899 ) 900 .get(transcriptionId); 901 if (!transcription || transcription.user_id !== user.id) { 902 return Response.json( 903 { error: "Transcription not found" }, 904 { status: 404 }, 905 ); 906 } 907 // Event-driven SSE stream with reconnection support 908 const stream = new ReadableStream({ 909 async start(controller) { 910 const encoder = new TextEncoder(); 911 let isClosed = false; 912 let lastEventId = Math.floor(Date.now() / 1000); 913 914 const sendEvent = (data: Partial<TranscriptionUpdate>) => { 915 if (isClosed) return; 916 try { 917 // Send event ID for reconnection support 918 lastEventId = Math.floor(Date.now() / 1000); 919 controller.enqueue( 920 encoder.encode( 921 `id: ${lastEventId}\nevent: update\ndata: ${JSON.stringify(data)}\n\n`, 922 ), 923 ); 924 } catch { 925 // Controller already closed (client disconnected) 926 isClosed = true; 927 } 928 }; 929 930 const sendHeartbeat = () => { 931 if (isClosed) return; 932 try { 933 controller.enqueue(encoder.encode(": heartbeat\n\n")); 934 } catch { 935 isClosed = true; 936 } 937 }; 938 // Send initial state from DB and file 939 const current = db 940 .query< 941 { 942 status: string; 943 progress: number; 944 }, 945 [string] 946 >("SELECT status, progress FROM transcriptions WHERE id = ?") 947 .get(transcriptionId); 948 if (current) { 949 sendEvent({ 950 status: current.status as TranscriptionUpdate["status"], 951 progress: current.progress, 952 }); 953 } 954 // If already complete, close immediately 955 if ( 956 current?.status === "completed" || 957 current?.status === "failed" 958 ) { 959 isClosed = true; 960 controller.close(); 961 return; 962 } 963 // Send heartbeats every 2.5 seconds to keep connection alive 964 const heartbeatInterval = setInterval(sendHeartbeat, 2500); 965 966 // Subscribe to EventEmitter for live updates 967 const updateHandler = (data: TranscriptionUpdate) => { 968 if (isClosed) return; 969 970 // Only send changed fields to save bandwidth 971 const payload: Partial<TranscriptionUpdate> = { 972 status: data.status, 973 progress: data.progress, 974 }; 975 976 if (data.transcript !== undefined) { 977 payload.transcript = data.transcript; 978 } 979 if (data.error_message !== undefined) { 980 payload.error_message = data.error_message; 981 } 982 983 sendEvent(payload); 984 985 // Close stream when done 986 if (data.status === "completed" || data.status === "failed") { 987 isClosed = true; 988 clearInterval(heartbeatInterval); 989 transcriptionEvents.off(transcriptionId, updateHandler); 990 controller.close(); 991 } 992 }; 993 transcriptionEvents.on(transcriptionId, updateHandler); 994 // Cleanup on client disconnect 995 return () => { 996 isClosed = true; 997 clearInterval(heartbeatInterval); 998 transcriptionEvents.off(transcriptionId, updateHandler); 999 }; 1000 }, 1001 }); 1002 return new Response(stream, { 1003 headers: { 1004 "Content-Type": "text/event-stream", 1005 "Cache-Control": "no-cache", 1006 Connection: "keep-alive", 1007 }, 1008 }); 1009 } catch (error) { 1010 return handleError(error); 1011 } 1012 }, 1013 }, 1014 "/api/transcriptions/health": { 1015 GET: async () => { 1016 const isHealthy = await whisperService.checkHealth(); 1017 return Response.json({ available: isHealthy }); 1018 }, 1019 }, 1020 "/api/transcriptions/:id": { 1021 GET: async (req) => { 1022 try { 1023 const user = requireSubscription(req); 1024 const transcriptionId = req.params.id; 1025 1026 // Verify ownership or admin 1027 const transcription = db 1028 .query< 1029 { 1030 id: string; 1031 user_id: number; 1032 filename: string; 1033 original_filename: string; 1034 status: string; 1035 progress: number; 1036 created_at: number; 1037 }, 1038 [string] 1039 >( 1040 "SELECT id, user_id, filename, original_filename, status, progress, created_at FROM transcriptions WHERE id = ?", 1041 ) 1042 .get(transcriptionId); 1043 1044 if (!transcription) { 1045 return Response.json( 1046 { error: "Transcription not found" }, 1047 { status: 404 }, 1048 ); 1049 } 1050 1051 // Allow access if user owns it or is admin 1052 if (transcription.user_id !== user.id && user.role !== "admin") { 1053 return Response.json( 1054 { error: "Transcription not found" }, 1055 { status: 404 }, 1056 ); 1057 } 1058 1059 if (transcription.status !== "completed") { 1060 return Response.json( 1061 { error: "Transcription not completed yet" }, 1062 { status: 400 }, 1063 ); 1064 } 1065 1066 // Get format from query parameter 1067 const url = new URL(req.url); 1068 const format = url.searchParams.get("format"); 1069 1070 // Return WebVTT format if requested 1071 if (format === "vtt") { 1072 const vttContent = await getTranscriptVTT(transcriptionId); 1073 1074 if (!vttContent) { 1075 return Response.json( 1076 { error: "VTT transcript not available" }, 1077 { status: 404 }, 1078 ); 1079 } 1080 1081 return new Response(vttContent, { 1082 headers: { 1083 "Content-Type": "text/vtt", 1084 "Content-Disposition": `attachment; filename="${transcription.original_filename}.vtt"`, 1085 }, 1086 }); 1087 } 1088 1089 // return info on transcript 1090 const transcript = { 1091 id: transcription.id, 1092 filename: transcription.original_filename, 1093 status: transcription.status, 1094 progress: transcription.progress, 1095 created_at: transcription.created_at, 1096 }; 1097 return new Response(JSON.stringify(transcript), { 1098 headers: { 1099 "Content-Type": "application/json", 1100 }, 1101 }); 1102 } catch (error) { 1103 return handleError(error); 1104 } 1105 }, 1106 }, 1107 "/api/transcriptions/:id/audio": { 1108 GET: async (req) => { 1109 try { 1110 const user = requireSubscription(req); 1111 const transcriptionId = req.params.id; 1112 1113 // Verify ownership or admin 1114 const transcription = db 1115 .query< 1116 { 1117 id: string; 1118 user_id: number; 1119 filename: string; 1120 status: string; 1121 }, 1122 [string] 1123 >( 1124 "SELECT id, user_id, filename, status FROM transcriptions WHERE id = ?", 1125 ) 1126 .get(transcriptionId); 1127 1128 if (!transcription) { 1129 return Response.json( 1130 { error: "Transcription not found" }, 1131 { status: 404 }, 1132 ); 1133 } 1134 1135 // Allow access if user owns it or is admin 1136 if (transcription.user_id !== user.id && user.role !== "admin") { 1137 return Response.json( 1138 { error: "Transcription not found" }, 1139 { status: 404 }, 1140 ); 1141 } 1142 1143 // For pending recordings, audio file exists even though transcription isn't complete 1144 // Allow audio access for pending and completed statuses 1145 if ( 1146 transcription.status !== "completed" && 1147 transcription.status !== "pending" 1148 ) { 1149 return Response.json( 1150 { error: "Audio not available yet" }, 1151 { status: 400 }, 1152 ); 1153 } 1154 1155 // Serve the audio file with range request support 1156 const filePath = `./uploads/${transcription.filename}`; 1157 const file = Bun.file(filePath); 1158 1159 if (!(await file.exists())) { 1160 return Response.json( 1161 { error: "Audio file not found" }, 1162 { status: 404 }, 1163 ); 1164 } 1165 1166 const fileSize = file.size; 1167 const range = req.headers.get("range"); 1168 1169 // Handle range requests for seeking 1170 if (range) { 1171 const parts = range.replace(/bytes=/, "").split("-"); 1172 const start = Number.parseInt(parts[0] || "0", 10); 1173 const end = parts[1] ? Number.parseInt(parts[1], 10) : fileSize - 1; 1174 const chunkSize = end - start + 1; 1175 1176 const fileSlice = file.slice(start, end + 1); 1177 1178 return new Response(fileSlice, { 1179 status: 206, 1180 headers: { 1181 "Content-Range": `bytes ${start}-${end}/${fileSize}`, 1182 "Accept-Ranges": "bytes", 1183 "Content-Length": chunkSize.toString(), 1184 "Content-Type": file.type || "audio/mpeg", 1185 }, 1186 }); 1187 } 1188 1189 // No range request, send entire file 1190 return new Response(file, { 1191 headers: { 1192 "Content-Type": file.type || "audio/mpeg", 1193 "Accept-Ranges": "bytes", 1194 "Content-Length": fileSize.toString(), 1195 }, 1196 }); 1197 } catch (error) { 1198 return handleError(error); 1199 } 1200 }, 1201 }, 1202 "/api/transcriptions": { 1203 GET: async (req) => { 1204 try { 1205 const user = requireSubscription(req); 1206 1207 const transcriptions = db 1208 .query< 1209 { 1210 id: string; 1211 filename: string; 1212 original_filename: string; 1213 class_id: string | null; 1214 status: string; 1215 progress: number; 1216 created_at: number; 1217 }, 1218 [number] 1219 >( 1220 "SELECT id, filename, original_filename, class_id, status, progress, created_at FROM transcriptions WHERE user_id = ? ORDER BY created_at DESC", 1221 ) 1222 .all(user.id); 1223 1224 // Load transcripts from files for completed jobs 1225 const jobs = await Promise.all( 1226 transcriptions.map(async (t) => { 1227 return { 1228 id: t.id, 1229 filename: t.original_filename, 1230 class_id: t.class_id, 1231 status: t.status, 1232 progress: t.progress, 1233 created_at: t.created_at, 1234 }; 1235 }), 1236 ); 1237 1238 return Response.json({ jobs }); 1239 } catch (error) { 1240 return handleError(error); 1241 } 1242 }, 1243 POST: async (req) => { 1244 try { 1245 const user = requireSubscription(req); 1246 1247 const formData = await req.formData(); 1248 const file = formData.get("audio") as File; 1249 const classId = formData.get("class_id") as string | null; 1250 const meetingTimeId = formData.get("meeting_time_id") as 1251 | string 1252 | null; 1253 1254 if (!file) throw ValidationErrors.missingField("audio"); 1255 1256 // If class_id provided, verify user is enrolled (or admin) 1257 if (classId) { 1258 const enrolled = isUserEnrolledInClass(user.id, classId); 1259 if (!enrolled && user.role !== "admin") { 1260 return Response.json( 1261 { error: "Not enrolled in this class" }, 1262 { status: 403 }, 1263 ); 1264 } 1265 1266 // Verify class exists 1267 const classInfo = getClassById(classId); 1268 if (!classInfo) { 1269 return Response.json( 1270 { error: "Class not found" }, 1271 { status: 404 }, 1272 ); 1273 } 1274 1275 // Check if class is archived 1276 if (classInfo.archived) { 1277 return Response.json( 1278 { error: "Cannot upload to archived class" }, 1279 { status: 400 }, 1280 ); 1281 } 1282 } 1283 1284 // Validate file type 1285 const fileExtension = file.name.split(".").pop()?.toLowerCase(); 1286 const allowedExtensions = [ 1287 "mp3", 1288 "wav", 1289 "m4a", 1290 "aac", 1291 "ogg", 1292 "webm", 1293 "flac", 1294 "mp4", 1295 ]; 1296 const isAudioType = 1297 file.type.startsWith("audio/") || file.type === "video/mp4"; 1298 const isAudioExtension = 1299 fileExtension && allowedExtensions.includes(fileExtension); 1300 1301 if (!isAudioType && !isAudioExtension) { 1302 throw ValidationErrors.unsupportedFileType( 1303 "MP3, WAV, M4A, AAC, OGG, WebM, FLAC", 1304 ); 1305 } 1306 1307 if (file.size > MAX_FILE_SIZE) { 1308 throw ValidationErrors.fileTooLarge("100MB"); 1309 } 1310 1311 // Generate unique filename 1312 const transcriptionId = crypto.randomUUID(); 1313 const filename = `${transcriptionId}.${fileExtension}`; 1314 1315 // Save file to disk 1316 const uploadDir = "./uploads"; 1317 await Bun.write(`${uploadDir}/${filename}`, file); 1318 1319 // Create database record 1320 db.run( 1321 "INSERT INTO transcriptions (id, user_id, class_id, meeting_time_id, filename, original_filename, status) VALUES (?, ?, ?, ?, ?, ?, ?)", 1322 [ 1323 transcriptionId, 1324 user.id, 1325 classId, 1326 meetingTimeId, 1327 filename, 1328 file.name, 1329 "pending", 1330 ], 1331 ); 1332 1333 // Don't auto-start transcription - admin will select recordings 1334 // whisperService.startTranscription(transcriptionId, filename); 1335 1336 return Response.json({ 1337 id: transcriptionId, 1338 message: "Upload successful", 1339 }); 1340 } catch (error) { 1341 return handleError(error); 1342 } 1343 }, 1344 }, 1345 "/api/admin/transcriptions": { 1346 GET: async (req) => { 1347 try { 1348 requireAdmin(req); 1349 const transcriptions = getAllTranscriptions(); 1350 return Response.json(transcriptions); 1351 } catch (error) { 1352 return handleError(error); 1353 } 1354 }, 1355 }, 1356 "/api/admin/users": { 1357 GET: async (req) => { 1358 try { 1359 requireAdmin(req); 1360 const users = getAllUsersWithStats(); 1361 return Response.json(users); 1362 } catch (error) { 1363 return handleError(error); 1364 } 1365 }, 1366 }, 1367 "/api/admin/classes": { 1368 GET: async (req) => { 1369 try { 1370 requireAdmin(req); 1371 const classes = getClassesForUser(0, true); // Admin sees all classes 1372 return Response.json({ classes }); 1373 } catch (error) { 1374 return handleError(error); 1375 } 1376 }, 1377 }, 1378 "/api/admin/waitlist": { 1379 GET: async (req) => { 1380 try { 1381 requireAdmin(req); 1382 const waitlist = getAllWaitlistEntries(); 1383 return Response.json({ waitlist }); 1384 } catch (error) { 1385 return handleError(error); 1386 } 1387 }, 1388 }, 1389 "/api/admin/waitlist/:id": { 1390 DELETE: async (req) => { 1391 try { 1392 requireAdmin(req); 1393 const id = req.params.id; 1394 deleteWaitlistEntry(id); 1395 return Response.json({ success: true }); 1396 } catch (error) { 1397 return handleError(error); 1398 } 1399 }, 1400 }, 1401 "/api/admin/transcriptions/:id": { 1402 DELETE: async (req) => { 1403 try { 1404 requireAdmin(req); 1405 const transcriptionId = req.params.id; 1406 deleteTranscription(transcriptionId); 1407 return Response.json({ success: true }); 1408 } catch (error) { 1409 return handleError(error); 1410 } 1411 }, 1412 }, 1413 "/api/admin/users/:id": { 1414 DELETE: async (req) => { 1415 try { 1416 requireAdmin(req); 1417 const userId = Number.parseInt(req.params.id, 10); 1418 if (Number.isNaN(userId)) { 1419 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1420 } 1421 await deleteUser(userId); 1422 return Response.json({ success: true }); 1423 } catch (error) { 1424 return handleError(error); 1425 } 1426 }, 1427 }, 1428 "/api/admin/users/:id/role": { 1429 PUT: async (req) => { 1430 try { 1431 requireAdmin(req); 1432 const userId = Number.parseInt(req.params.id, 10); 1433 if (Number.isNaN(userId)) { 1434 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1435 } 1436 1437 const body = await req.json(); 1438 const { role } = body as { role: UserRole }; 1439 1440 if (!role || (role !== "user" && role !== "admin")) { 1441 return Response.json( 1442 { error: "Invalid role. Must be 'user' or 'admin'" }, 1443 { status: 400 }, 1444 ); 1445 } 1446 1447 updateUserRole(userId, role); 1448 return Response.json({ success: true }); 1449 } catch (error) { 1450 return handleError(error); 1451 } 1452 }, 1453 }, 1454 "/api/admin/users/:id/subscription": { 1455 DELETE: async (req) => { 1456 try { 1457 requireAdmin(req); 1458 const userId = Number.parseInt(req.params.id, 10); 1459 if (Number.isNaN(userId)) { 1460 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1461 } 1462 1463 const body = await req.json(); 1464 const { subscriptionId } = body as { subscriptionId: string }; 1465 1466 if (!subscriptionId) { 1467 return Response.json( 1468 { error: "Subscription ID required" }, 1469 { status: 400 }, 1470 ); 1471 } 1472 1473 try { 1474 const { polar } = await import("./lib/polar"); 1475 await polar.subscriptions.revoke({ id: subscriptionId }); 1476 return Response.json({ 1477 success: true, 1478 message: "Subscription revoked successfully", 1479 }); 1480 } catch (error) { 1481 console.error( 1482 `[Admin] Failed to revoke subscription ${subscriptionId}:`, 1483 error, 1484 ); 1485 return Response.json( 1486 { 1487 error: 1488 error instanceof Error 1489 ? error.message 1490 : "Failed to revoke subscription", 1491 }, 1492 { status: 500 }, 1493 ); 1494 } 1495 } catch (error) { 1496 return handleError(error); 1497 } 1498 }, 1499 PUT: async (req) => { 1500 try { 1501 requireAdmin(req); 1502 const userId = Number.parseInt(req.params.id, 10); 1503 if (Number.isNaN(userId)) { 1504 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1505 } 1506 1507 try { 1508 const { polar } = await import("./lib/polar"); 1509 1510 // Get user email 1511 const user = db 1512 .query<{ email: string }, [number]>( 1513 "SELECT email FROM users WHERE id = ?", 1514 ) 1515 .get(userId); 1516 1517 if (!user) { 1518 return Response.json( 1519 { error: "User not found" }, 1520 { status: 404 }, 1521 ); 1522 } 1523 1524 console.log(`[Admin] Looking for Polar customer with email: ${user.email}`); 1525 1526 // Search for customer by email 1527 const customers = await polar.customers.list({ 1528 organizationId: process.env.POLAR_ORGANIZATION_ID, 1529 query: user.email, 1530 }); 1531 1532 console.log( 1533 `[Admin] Found ${customers.result.items?.length || 0} customer(s) matching email`, 1534 ); 1535 1536 if (!customers.result.items || customers.result.items.length === 0) { 1537 return Response.json( 1538 { error: "No Polar customer found with this email" }, 1539 { status: 404 }, 1540 ); 1541 } 1542 1543 const customer = customers.result.items[0]; 1544 console.log(`[Admin] Customer ID: ${customer.id}`); 1545 1546 // Get all subscriptions for this customer 1547 const subscriptions = await polar.subscriptions.list({ 1548 customerId: customer.id, 1549 }); 1550 1551 console.log( 1552 `[Admin] Found ${subscriptions.result.items?.length || 0} subscription(s) for customer`, 1553 ); 1554 1555 if (!subscriptions.result.items || subscriptions.result.items.length === 0) { 1556 return Response.json( 1557 { error: "No subscriptions found for this customer" }, 1558 { status: 404 }, 1559 ); 1560 } 1561 1562 // Update each subscription in the database 1563 for (const subscription of subscriptions.result.items) { 1564 db.run( 1565 `INSERT INTO subscriptions (id, user_id, customer_id, status, current_period_start, current_period_end, cancel_at_period_end, canceled_at, updated_at) 1566 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) 1567 ON CONFLICT(id) DO UPDATE SET 1568 user_id = excluded.user_id, 1569 status = excluded.status, 1570 current_period_start = excluded.current_period_start, 1571 current_period_end = excluded.current_period_end, 1572 cancel_at_period_end = excluded.cancel_at_period_end, 1573 canceled_at = excluded.canceled_at, 1574 updated_at = excluded.updated_at`, 1575 [ 1576 subscription.id, 1577 userId, 1578 subscription.customerId, 1579 subscription.status, 1580 subscription.currentPeriodStart 1581 ? Math.floor( 1582 new Date(subscription.currentPeriodStart).getTime() / 1583 1000, 1584 ) 1585 : null, 1586 subscription.currentPeriodEnd 1587 ? Math.floor( 1588 new Date(subscription.currentPeriodEnd).getTime() / 1589 1000, 1590 ) 1591 : null, 1592 subscription.cancelAtPeriodEnd ? 1 : 0, 1593 subscription.canceledAt 1594 ? Math.floor( 1595 new Date(subscription.canceledAt).getTime() / 1000, 1596 ) 1597 : null, 1598 Math.floor(Date.now() / 1000), 1599 ], 1600 ); 1601 } 1602 1603 console.log( 1604 `[Admin] Synced ${subscriptions.result.items.length} subscription(s) for user ${userId} (${user.email})`, 1605 ); 1606 return Response.json({ 1607 success: true, 1608 message: "Subscription synced successfully", 1609 }); 1610 } catch (error) { 1611 console.error( 1612 `[Admin] Failed to sync subscription for user ${userId}:`, 1613 error, 1614 ); 1615 return Response.json( 1616 { 1617 error: 1618 error instanceof Error 1619 ? error.message 1620 : "Failed to sync subscription", 1621 }, 1622 { status: 500 }, 1623 ); 1624 } 1625 } catch (error) { 1626 return handleError(error); 1627 } 1628 }, 1629 }, 1630 "/api/admin/users/:id/details": { 1631 GET: async (req) => { 1632 try { 1633 requireAdmin(req); 1634 const userId = Number.parseInt(req.params.id, 10); 1635 if (Number.isNaN(userId)) { 1636 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1637 } 1638 1639 const user = db 1640 .query< 1641 { 1642 id: number; 1643 email: string; 1644 name: string | null; 1645 avatar: string; 1646 created_at: number; 1647 role: UserRole; 1648 password_hash: string | null; 1649 last_login: number | null; 1650 }, 1651 [number] 1652 >( 1653 "SELECT id, email, name, avatar, created_at, role, password_hash, last_login FROM users WHERE id = ?", 1654 ) 1655 .get(userId); 1656 1657 if (!user) { 1658 return Response.json({ error: "User not found" }, { status: 404 }); 1659 } 1660 1661 const passkeys = getPasskeysForUser(userId); 1662 const sessions = getSessionsForUser(userId); 1663 1664 // Get transcription count 1665 const transcriptionCount = 1666 db 1667 .query<{ count: number }, [number]>( 1668 "SELECT COUNT(*) as count FROM transcriptions WHERE user_id = ?", 1669 ) 1670 .get(userId)?.count ?? 0; 1671 1672 return Response.json({ 1673 id: user.id, 1674 email: user.email, 1675 name: user.name, 1676 avatar: user.avatar, 1677 created_at: user.created_at, 1678 role: user.role, 1679 last_login: user.last_login, 1680 hasPassword: !!user.password_hash, 1681 transcriptionCount, 1682 passkeys: passkeys.map((pk) => ({ 1683 id: pk.id, 1684 name: pk.name, 1685 created_at: pk.created_at, 1686 last_used_at: pk.last_used_at, 1687 })), 1688 sessions: sessions.map((s) => ({ 1689 id: s.id, 1690 ip_address: s.ip_address, 1691 user_agent: s.user_agent, 1692 created_at: s.created_at, 1693 expires_at: s.expires_at, 1694 })), 1695 }); 1696 } catch (error) { 1697 return handleError(error); 1698 } 1699 }, 1700 }, 1701 "/api/admin/users/:id/password": { 1702 PUT: async (req) => { 1703 try { 1704 requireAdmin(req); 1705 const userId = Number.parseInt(req.params.id, 10); 1706 if (Number.isNaN(userId)) { 1707 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1708 } 1709 1710 const body = await req.json(); 1711 const { password } = body as { password: string }; 1712 1713 if (!password || password.length < 8) { 1714 return Response.json( 1715 { error: "Password must be at least 8 characters" }, 1716 { status: 400 }, 1717 ); 1718 } 1719 1720 await updateUserPassword(userId, password); 1721 return Response.json({ success: true }); 1722 } catch (error) { 1723 return handleError(error); 1724 } 1725 }, 1726 }, 1727 "/api/admin/users/:id/passkeys/:passkeyId": { 1728 DELETE: async (req) => { 1729 try { 1730 requireAdmin(req); 1731 const userId = Number.parseInt(req.params.id, 10); 1732 if (Number.isNaN(userId)) { 1733 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1734 } 1735 1736 const { passkeyId } = req.params; 1737 deletePasskey(passkeyId, userId); 1738 return Response.json({ success: true }); 1739 } catch (error) { 1740 return handleError(error); 1741 } 1742 }, 1743 }, 1744 "/api/admin/users/:id/name": { 1745 PUT: async (req) => { 1746 try { 1747 requireAdmin(req); 1748 const userId = Number.parseInt(req.params.id, 10); 1749 if (Number.isNaN(userId)) { 1750 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1751 } 1752 1753 const body = await req.json(); 1754 const { name } = body as { name: string }; 1755 1756 if (!name || name.trim().length === 0) { 1757 return Response.json( 1758 { error: "Name cannot be empty" }, 1759 { status: 400 }, 1760 ); 1761 } 1762 1763 updateUserName(userId, name.trim()); 1764 return Response.json({ success: true }); 1765 } catch (error) { 1766 return handleError(error); 1767 } 1768 }, 1769 }, 1770 "/api/admin/users/:id/email": { 1771 PUT: async (req) => { 1772 try { 1773 requireAdmin(req); 1774 const userId = Number.parseInt(req.params.id, 10); 1775 if (Number.isNaN(userId)) { 1776 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1777 } 1778 1779 const body = await req.json(); 1780 const { email } = body as { email: string }; 1781 1782 if (!email || !email.includes("@")) { 1783 return Response.json( 1784 { error: "Invalid email address" }, 1785 { status: 400 }, 1786 ); 1787 } 1788 1789 // Check if email already exists 1790 const existing = db 1791 .query<{ id: number }, [string, number]>( 1792 "SELECT id FROM users WHERE email = ? AND id != ?", 1793 ) 1794 .get(email, userId); 1795 1796 if (existing) { 1797 return Response.json( 1798 { error: "Email already in use" }, 1799 { status: 400 }, 1800 ); 1801 } 1802 1803 updateUserEmailAddress(userId, email); 1804 return Response.json({ success: true }); 1805 } catch (error) { 1806 return handleError(error); 1807 } 1808 }, 1809 }, 1810 "/api/admin/users/:id/sessions": { 1811 GET: async (req) => { 1812 try { 1813 requireAdmin(req); 1814 const userId = Number.parseInt(req.params.id, 10); 1815 if (Number.isNaN(userId)) { 1816 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1817 } 1818 1819 const sessions = getSessionsForUser(userId); 1820 return Response.json(sessions); 1821 } catch (error) { 1822 return handleError(error); 1823 } 1824 }, 1825 DELETE: async (req) => { 1826 try { 1827 requireAdmin(req); 1828 const userId = Number.parseInt(req.params.id, 10); 1829 if (Number.isNaN(userId)) { 1830 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1831 } 1832 1833 deleteAllUserSessions(userId); 1834 return Response.json({ success: true }); 1835 } catch (error) { 1836 return handleError(error); 1837 } 1838 }, 1839 }, 1840 "/api/admin/users/:id/sessions/:sessionId": { 1841 DELETE: async (req) => { 1842 try { 1843 requireAdmin(req); 1844 const userId = Number.parseInt(req.params.id, 10); 1845 if (Number.isNaN(userId)) { 1846 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 1847 } 1848 1849 const { sessionId } = req.params; 1850 const success = deleteSessionById(sessionId, userId); 1851 1852 if (!success) { 1853 return Response.json( 1854 { error: "Session not found" }, 1855 { status: 404 }, 1856 ); 1857 } 1858 1859 return Response.json({ success: true }); 1860 } catch (error) { 1861 return handleError(error); 1862 } 1863 }, 1864 }, 1865 "/api/admin/transcriptions/:id/details": { 1866 GET: async (req) => { 1867 try { 1868 requireAdmin(req); 1869 const transcriptionId = req.params.id; 1870 1871 const transcription = db 1872 .query< 1873 { 1874 id: string; 1875 original_filename: string; 1876 status: string; 1877 created_at: number; 1878 updated_at: number; 1879 error_message: string | null; 1880 user_id: number; 1881 }, 1882 [string] 1883 >( 1884 "SELECT id, original_filename, status, created_at, updated_at, error_message, user_id FROM transcriptions WHERE id = ?", 1885 ) 1886 .get(transcriptionId); 1887 1888 if (!transcription) { 1889 return Response.json( 1890 { error: "Transcription not found" }, 1891 { status: 404 }, 1892 ); 1893 } 1894 1895 const user = db 1896 .query<{ email: string; name: string | null }, [number]>( 1897 "SELECT email, name FROM users WHERE id = ?", 1898 ) 1899 .get(transcription.user_id); 1900 1901 return Response.json({ 1902 id: transcription.id, 1903 original_filename: transcription.original_filename, 1904 status: transcription.status, 1905 created_at: transcription.created_at, 1906 completed_at: transcription.updated_at, 1907 error_message: transcription.error_message, 1908 user_id: transcription.user_id, 1909 user_email: user?.email || "Unknown", 1910 user_name: user?.name || null, 1911 }); 1912 } catch (error) { 1913 return handleError(error); 1914 } 1915 }, 1916 }, 1917 "/api/classes": { 1918 GET: async (req) => { 1919 try { 1920 const user = requireAuth(req); 1921 const classes = getClassesForUser(user.id, user.role === "admin"); 1922 1923 // Group by semester/year 1924 const grouped: Record< 1925 string, 1926 Array<{ 1927 id: string; 1928 course_code: string; 1929 name: string; 1930 professor: string; 1931 semester: string; 1932 year: number; 1933 archived: boolean; 1934 }> 1935 > = {}; 1936 1937 for (const cls of classes) { 1938 const key = `${cls.semester} ${cls.year}`; 1939 if (!grouped[key]) { 1940 grouped[key] = []; 1941 } 1942 grouped[key]?.push({ 1943 id: cls.id, 1944 course_code: cls.course_code, 1945 name: cls.name, 1946 professor: cls.professor, 1947 semester: cls.semester, 1948 year: cls.year, 1949 archived: cls.archived, 1950 }); 1951 } 1952 1953 return Response.json({ classes: grouped }); 1954 } catch (error) { 1955 return handleError(error); 1956 } 1957 }, 1958 POST: async (req) => { 1959 try { 1960 requireAdmin(req); 1961 const body = await req.json(); 1962 const { 1963 course_code, 1964 name, 1965 professor, 1966 semester, 1967 year, 1968 meeting_times, 1969 } = body; 1970 1971 if (!course_code || !name || !professor || !semester || !year) { 1972 return Response.json( 1973 { error: "Missing required fields" }, 1974 { status: 400 }, 1975 ); 1976 } 1977 1978 const newClass = createClass({ 1979 course_code, 1980 name, 1981 professor, 1982 semester, 1983 year, 1984 meeting_times, 1985 }); 1986 1987 return Response.json(newClass); 1988 } catch (error) { 1989 return handleError(error); 1990 } 1991 }, 1992 }, 1993 "/api/classes/search": { 1994 GET: async (req) => { 1995 try { 1996 const user = requireAuth(req); 1997 const url = new URL(req.url); 1998 const query = url.searchParams.get("q"); 1999 2000 if (!query) { 2001 return Response.json({ classes: [] }); 2002 } 2003 2004 const classes = searchClassesByCourseCode(query); 2005 2006 // Get user's enrolled classes to mark them 2007 const enrolledClassIds = db 2008 .query<{ class_id: string }, [number]>( 2009 "SELECT class_id FROM class_members WHERE user_id = ?", 2010 ) 2011 .all(user.id) 2012 .map((row) => row.class_id); 2013 2014 // Add is_enrolled flag to each class 2015 const classesWithEnrollment = classes.map((cls) => ({ 2016 ...cls, 2017 is_enrolled: enrolledClassIds.includes(cls.id), 2018 })); 2019 2020 return Response.json({ classes: classesWithEnrollment }); 2021 } catch (error) { 2022 return handleError(error); 2023 } 2024 }, 2025 }, 2026 "/api/classes/join": { 2027 POST: async (req) => { 2028 try { 2029 const user = requireAuth(req); 2030 const body = await req.json(); 2031 const classId = body.class_id; 2032 2033 if (!classId || typeof classId !== "string") { 2034 return Response.json( 2035 { error: "Class ID required" }, 2036 { status: 400 }, 2037 ); 2038 } 2039 2040 const result = joinClass(classId, user.id); 2041 2042 if (!result.success) { 2043 return Response.json({ error: result.error }, { status: 400 }); 2044 } 2045 2046 return Response.json({ success: true }); 2047 } catch (error) { 2048 return handleError(error); 2049 } 2050 }, 2051 }, 2052 "/api/classes/waitlist": { 2053 POST: async (req) => { 2054 try { 2055 const user = requireAuth(req); 2056 const body = await req.json(); 2057 2058 const { 2059 courseCode, 2060 courseName, 2061 professor, 2062 semester, 2063 year, 2064 additionalInfo, 2065 meetingTimes, 2066 } = body; 2067 2068 if (!courseCode || !courseName || !professor || !semester || !year) { 2069 return Response.json( 2070 { error: "Missing required fields" }, 2071 { status: 400 }, 2072 ); 2073 } 2074 2075 const id = addToWaitlist( 2076 user.id, 2077 courseCode, 2078 courseName, 2079 professor, 2080 semester, 2081 Number.parseInt(year, 10), 2082 additionalInfo || null, 2083 meetingTimes || null, 2084 ); 2085 2086 return Response.json({ success: true, id }); 2087 } catch (error) { 2088 return handleError(error); 2089 } 2090 }, 2091 }, 2092 "/api/classes/:id": { 2093 GET: async (req) => { 2094 try { 2095 const user = requireAuth(req); 2096 const classId = req.params.id; 2097 2098 const classInfo = getClassById(classId); 2099 if (!classInfo) { 2100 return Response.json({ error: "Class not found" }, { status: 404 }); 2101 } 2102 2103 // Check enrollment or admin 2104 const isEnrolled = isUserEnrolledInClass(user.id, classId); 2105 if (!isEnrolled && user.role !== "admin") { 2106 return Response.json( 2107 { error: "Not enrolled in this class" }, 2108 { status: 403 }, 2109 ); 2110 } 2111 2112 const meetingTimes = getMeetingTimesForClass(classId); 2113 const transcriptions = getTranscriptionsForClass(classId); 2114 2115 return Response.json({ 2116 class: classInfo, 2117 meetingTimes, 2118 transcriptions, 2119 }); 2120 } catch (error) { 2121 return handleError(error); 2122 } 2123 }, 2124 DELETE: async (req) => { 2125 try { 2126 requireAdmin(req); 2127 const classId = req.params.id; 2128 2129 deleteClass(classId); 2130 return Response.json({ success: true }); 2131 } catch (error) { 2132 return handleError(error); 2133 } 2134 }, 2135 }, 2136 "/api/classes/:id/archive": { 2137 PUT: async (req) => { 2138 try { 2139 requireAdmin(req); 2140 const classId = req.params.id; 2141 const body = await req.json(); 2142 const { archived } = body; 2143 2144 if (typeof archived !== "boolean") { 2145 return Response.json( 2146 { error: "archived must be a boolean" }, 2147 { status: 400 }, 2148 ); 2149 } 2150 2151 toggleClassArchive(classId, archived); 2152 return Response.json({ success: true }); 2153 } catch (error) { 2154 return handleError(error); 2155 } 2156 }, 2157 }, 2158 "/api/classes/:id/members": { 2159 GET: async (req) => { 2160 try { 2161 requireAdmin(req); 2162 const classId = req.params.id; 2163 2164 const members = getClassMembers(classId); 2165 return Response.json({ members }); 2166 } catch (error) { 2167 return handleError(error); 2168 } 2169 }, 2170 POST: async (req) => { 2171 try { 2172 requireAdmin(req); 2173 const classId = req.params.id; 2174 const body = await req.json(); 2175 const { email } = body; 2176 2177 if (!email) { 2178 return Response.json({ error: "Email required" }, { status: 400 }); 2179 } 2180 2181 const user = getUserByEmail(email); 2182 if (!user) { 2183 return Response.json({ error: "User not found" }, { status: 404 }); 2184 } 2185 2186 enrollUserInClass(user.id, classId); 2187 return Response.json({ success: true }); 2188 } catch (error) { 2189 return handleError(error); 2190 } 2191 }, 2192 }, 2193 "/api/classes/:id/members/:userId": { 2194 DELETE: async (req) => { 2195 try { 2196 requireAdmin(req); 2197 const classId = req.params.id; 2198 const userId = Number.parseInt(req.params.userId, 10); 2199 2200 if (Number.isNaN(userId)) { 2201 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2202 } 2203 2204 removeUserFromClass(userId, classId); 2205 return Response.json({ success: true }); 2206 } catch (error) { 2207 return handleError(error); 2208 } 2209 }, 2210 }, 2211 "/api/classes/:id/meetings": { 2212 GET: async (req) => { 2213 try { 2214 const user = requireAuth(req); 2215 const classId = req.params.id; 2216 2217 // Check enrollment or admin 2218 const isEnrolled = isUserEnrolledInClass(user.id, classId); 2219 if (!isEnrolled && user.role !== "admin") { 2220 return Response.json( 2221 { error: "Not enrolled in this class" }, 2222 { status: 403 }, 2223 ); 2224 } 2225 2226 const meetingTimes = getMeetingTimesForClass(classId); 2227 return Response.json({ meetings: meetingTimes }); 2228 } catch (error) { 2229 return handleError(error); 2230 } 2231 }, 2232 POST: async (req) => { 2233 try { 2234 requireAdmin(req); 2235 const classId = req.params.id; 2236 const body = await req.json(); 2237 const { label } = body; 2238 2239 if (!label) { 2240 return Response.json({ error: "Label required" }, { status: 400 }); 2241 } 2242 2243 const meetingTime = createMeetingTime(classId, label); 2244 return Response.json(meetingTime); 2245 } catch (error) { 2246 return handleError(error); 2247 } 2248 }, 2249 }, 2250 "/api/meetings/:id": { 2251 PUT: async (req) => { 2252 try { 2253 requireAdmin(req); 2254 const meetingId = req.params.id; 2255 const body = await req.json(); 2256 const { label } = body; 2257 2258 if (!label) { 2259 return Response.json({ error: "Label required" }, { status: 400 }); 2260 } 2261 2262 updateMeetingTime(meetingId, label); 2263 return Response.json({ success: true }); 2264 } catch (error) { 2265 return handleError(error); 2266 } 2267 }, 2268 DELETE: async (req) => { 2269 try { 2270 requireAdmin(req); 2271 const meetingId = req.params.id; 2272 2273 deleteMeetingTime(meetingId); 2274 return Response.json({ success: true }); 2275 } catch (error) { 2276 return handleError(error); 2277 } 2278 }, 2279 }, 2280 "/api/transcripts/:id/select": { 2281 PUT: async (req) => { 2282 try { 2283 requireAdmin(req); 2284 const transcriptId = req.params.id; 2285 2286 // Update status to 'selected' and start transcription 2287 db.run("UPDATE transcriptions SET status = ? WHERE id = ?", [ 2288 "selected", 2289 transcriptId, 2290 ]); 2291 2292 // Get filename to start transcription 2293 const transcription = db 2294 .query<{ filename: string }, [string]>( 2295 "SELECT filename FROM transcriptions WHERE id = ?", 2296 ) 2297 .get(transcriptId); 2298 2299 if (transcription) { 2300 whisperService.startTranscription( 2301 transcriptId, 2302 transcription.filename, 2303 ); 2304 } 2305 2306 return Response.json({ success: true }); 2307 } catch (error) { 2308 return handleError(error); 2309 } 2310 }, 2311 }, 2312 }, 2313 development: { 2314 hmr: true, 2315 console: true, 2316 }, 2317}); 2318console.log(`馃 Thistle running at http://localhost:${server.port}`);