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