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