馃 distributed transcription service thistle.dunkirk.sh
1import db from "./db/schema"; 2import { 3 authenticateUser, 4 cleanupExpiredSessions, 5 consumeEmailChangeToken, 6 consumePasswordResetToken, 7 createEmailChangeToken, 8 createEmailVerificationToken, 9 createPasswordResetToken, 10 createSession, 11 createUser, 12 deleteAllUserSessions, 13 deleteSession, 14 deleteSessionById, 15 deleteTranscription, 16 deleteUser, 17 getAllTranscriptions, 18 getAllUsersWithStats, 19 getSession, 20 getSessionFromRequest, 21 getSessionsForUser, 22 getUserByEmail, 23 getUserBySession, 24 getUserSessionsForUser, 25 getVerificationCodeSentAt, 26 isEmailVerified, 27 type UserRole, 28 updateUserAvatar, 29 updateUserEmail, 30 updateUserEmailAddress, 31 updateUserName, 32 updateUserPassword, 33 updateUserRole, 34 verifyEmailChangeToken, 35 verifyEmailCode, 36 verifyEmailToken, 37 verifyPasswordResetToken, 38} from "./lib/auth"; 39import { 40 addToWaitlist, 41 createClass, 42 createMeetingTime, 43 deleteClass, 44 deleteMeetingTime, 45 deleteWaitlistEntry, 46 enrollUserInClass, 47 getAllWaitlistEntries, 48 getClassById, 49 getClassesForUser, 50 getClassMembers, 51 getMeetingTimesForClass, 52 getTranscriptionsForClass, 53 isUserEnrolledInClass, 54 joinClass, 55 removeUserFromClass, 56 searchClassesByCourseCode, 57 toggleClassArchive, 58 updateMeetingTime, 59} from "./lib/classes"; 60import { sendEmail } from "./lib/email"; 61import { 62 emailChangeTemplate, 63 passwordResetTemplate, 64 verifyEmailTemplate, 65} from "./lib/email-templates"; 66import { AuthErrors, handleError, ValidationErrors } from "./lib/errors"; 67import { 68 hasActiveSubscription, 69 requireAdmin, 70 requireAuth, 71 requireSubscription, 72} from "./lib/middleware"; 73import { 74 createAuthenticationOptions, 75 createRegistrationOptions, 76 deletePasskey, 77 getPasskeysForUser, 78 updatePasskeyName, 79 verifyAndAuthenticatePasskey, 80 verifyAndCreatePasskey, 81} from "./lib/passkey"; 82import { clearRateLimit, enforceRateLimit } from "./lib/rate-limit"; 83import { getTranscriptVTT } from "./lib/transcript-storage"; 84import { 85 MAX_FILE_SIZE, 86 TranscriptionEventEmitter, 87 type TranscriptionUpdate, 88 WhisperServiceManager, 89} from "./lib/transcription"; 90import { 91 validateClassId, 92 validateCourseCode, 93 validateCourseName, 94 validateEmail, 95 validateName, 96 validatePasswordHash, 97 validateSemester, 98 validateYear, 99} from "./lib/validation"; 100import adminHTML from "./pages/admin.html"; 101import checkoutHTML from "./pages/checkout.html"; 102import classHTML from "./pages/class.html"; 103import classesHTML from "./pages/classes.html"; 104import indexHTML from "./pages/index.html"; 105import resetPasswordHTML from "./pages/reset-password.html"; 106import settingsHTML from "./pages/settings.html"; 107import transcribeHTML from "./pages/transcribe.html"; 108 109// Validate required environment variables at startup 110function validateEnvVars() { 111 const required = [ 112 "POLAR_ORGANIZATION_ID", 113 "POLAR_PRODUCT_ID", 114 "POLAR_SUCCESS_URL", 115 "POLAR_WEBHOOK_SECRET", 116 "MAILCHANNELS_API_KEY", 117 "DKIM_PRIVATE_KEY", 118 "LLM_API_KEY", 119 "LLM_API_BASE_URL", 120 "LLM_MODEL", 121 ]; 122 123 const missing = required.filter((key) => !process.env[key]); 124 125 if (missing.length > 0) { 126 console.error( 127 `[Startup] Missing required environment variables: ${missing.join(", ")}`, 128 ); 129 console.error("[Startup] Please check your .env file"); 130 process.exit(1); 131 } 132 133 // Validate ORIGIN is set for production 134 if (!process.env.ORIGIN) { 135 console.warn( 136 "[Startup] ORIGIN not set, defaulting to http://localhost:3000", 137 ); 138 console.warn( 139 "[Startup] Set ORIGIN in production for correct email links", 140 ); 141 } 142 143 console.log("[Startup] Environment variable validation passed"); 144} 145 146validateEnvVars(); 147 148// Environment variables 149const WHISPER_SERVICE_URL = 150 process.env.WHISPER_SERVICE_URL || "http://localhost:8000"; 151 152// Create uploads and transcripts directories if they don't exist 153await Bun.write("./uploads/.gitkeep", ""); 154await Bun.write("./transcripts/.gitkeep", ""); 155 156// Initialize transcription system 157console.log( 158 `[Transcription] Connecting to Murmur at ${WHISPER_SERVICE_URL}...`, 159); 160const transcriptionEvents = new TranscriptionEventEmitter(); 161const whisperService = new WhisperServiceManager( 162 WHISPER_SERVICE_URL, 163 db, 164 transcriptionEvents, 165); 166 167// Clean up expired sessions every 15 minutes 168const sessionCleanupInterval = setInterval( 169 cleanupExpiredSessions, 170 15 * 60 * 1000, 171); 172 173// Helper function to sync user subscriptions from Polar 174async function syncUserSubscriptionsFromPolar( 175 userId: number, 176 email: string, 177): Promise<void> { 178 try { 179 const { polar } = await import("./lib/polar"); 180 181 // Search for customer by email (validated at startup) 182 const customers = await polar.customers.list({ 183 organizationId: process.env.POLAR_ORGANIZATION_ID as string, 184 query: email, 185 }); 186 187 if (!customers.result.items || customers.result.items.length === 0) { 188 console.log(`[Sync] No Polar customer found for ${email}`); 189 return; 190 } 191 192 const customer = customers.result.items[0]; 193 194 // Get all subscriptions for this customer 195 const subscriptions = await polar.subscriptions.list({ 196 customerId: customer.id, 197 }); 198 199 if ( 200 !subscriptions.result.items || 201 subscriptions.result.items.length === 0 202 ) { 203 console.log(`[Sync] No subscriptions found for customer ${customer.id}`); 204 return; 205 } 206 207 // Filter to only active/trialing/past_due subscriptions (not canceled/expired) 208 const currentSubscriptions = subscriptions.result.items.filter( 209 (sub) => 210 sub.status === "active" || 211 sub.status === "trialing" || 212 sub.status === "past_due", 213 ); 214 215 if (currentSubscriptions.length === 0) { 216 console.log( 217 `[Sync] No current subscriptions found for customer ${customer.id}`, 218 ); 219 return; 220 } 221 222 // Update each current subscription in the database 223 for (const subscription of currentSubscriptions) { 224 db.run( 225 `INSERT INTO subscriptions (id, user_id, customer_id, status, current_period_start, current_period_end, cancel_at_period_end, canceled_at, updated_at) 226 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) 227 ON CONFLICT(id) DO UPDATE SET 228 user_id = excluded.user_id, 229 status = excluded.status, 230 current_period_start = excluded.current_period_start, 231 current_period_end = excluded.current_period_end, 232 cancel_at_period_end = excluded.cancel_at_period_end, 233 canceled_at = excluded.canceled_at, 234 updated_at = excluded.updated_at`, 235 [ 236 subscription.id, 237 userId, 238 subscription.customerId, 239 subscription.status, 240 subscription.currentPeriodStart 241 ? Math.floor( 242 new Date(subscription.currentPeriodStart).getTime() / 1000, 243 ) 244 : null, 245 subscription.currentPeriodEnd 246 ? Math.floor( 247 new Date(subscription.currentPeriodEnd).getTime() / 1000, 248 ) 249 : null, 250 subscription.cancelAtPeriodEnd ? 1 : 0, 251 subscription.canceledAt 252 ? Math.floor(new Date(subscription.canceledAt).getTime() / 1000) 253 : null, 254 Math.floor(Date.now() / 1000), 255 ], 256 ); 257 } 258 259 console.log( 260 `[Sync] Linked ${currentSubscriptions.length} current subscription(s) to user ${userId} (${email})`, 261 ); 262 } catch (error) { 263 console.error( 264 `[Sync] Failed to sync subscriptions for ${email}:`, 265 error instanceof Error ? error.message : "Unknown error", 266 ); 267 // Don't throw - registration should succeed even if sync fails 268 } 269} 270 271// Sync with Whisper DB on startup 272try { 273 await whisperService.syncWithWhisper(); 274 console.log("[Transcription] Successfully connected to Murmur"); 275} catch (error) { 276 console.warn( 277 "[Transcription] Murmur unavailable at startup:", 278 error instanceof Error ? error.message : "Unknown error", 279 ); 280} 281 282// Periodic sync every 5 minutes as backup (SSE handles real-time updates) 283const syncInterval = setInterval( 284 async () => { 285 try { 286 await whisperService.syncWithWhisper(); 287 } catch (error) { 288 console.warn( 289 "[Sync] Failed to sync with Murmur:", 290 error instanceof Error ? error.message : "Unknown error", 291 ); 292 } 293 }, 294 5 * 60 * 1000, 295); 296 297// Clean up stale files daily 298const fileCleanupInterval = setInterval( 299 () => whisperService.cleanupStaleFiles(), 300 24 * 60 * 60 * 1000, 301); 302 303const server = Bun.serve({ 304 port: process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 3000, 305 idleTimeout: 120, // 120 seconds for SSE connections 306 routes: { 307 "/": indexHTML, 308 "/admin": adminHTML, 309 "/checkout": checkoutHTML, 310 "/settings": settingsHTML, 311 "/reset-password": resetPasswordHTML, 312 "/transcribe": transcribeHTML, 313 "/classes": classesHTML, 314 "/classes/*": classHTML, 315 "/apple-touch-icon.png": Bun.file("./public/favicon/apple-touch-icon.png"), 316 "/favicon-32x32.png": Bun.file("./public/favicon/favicon-32x32.png"), 317 "/favicon-16x16.png": Bun.file("./public/favicon/favicon-16x16.png"), 318 "/site.webmanifest": Bun.file("./public/favicon/site.webmanifest"), 319 "/favicon.ico": Bun.file("./public/favicon/favicon.ico"), 320 "/api/auth/register": { 321 POST: async (req) => { 322 try { 323 // Rate limiting 324 const rateLimitError = enforceRateLimit(req, "register", { 325 ip: { max: 5, windowSeconds: 30 * 60 }, 326 }); 327 if (rateLimitError) return rateLimitError; 328 329 const body = await req.json(); 330 const { email, password, name } = body; 331 if (!email || !password) { 332 return Response.json( 333 { error: "Email and password required" }, 334 { status: 400 }, 335 ); 336 } 337 // Validate password format (client-side hashed PBKDF2) 338 const passwordValidation = validatePasswordHash(password); 339 if (!passwordValidation.valid) { 340 return Response.json( 341 { error: passwordValidation.error }, 342 { status: 400 }, 343 ); 344 } 345 const user = await createUser(email, password, name); 346 347 // Send verification email - MUST succeed for registration to complete 348 const { code, token, sentAt } = createEmailVerificationToken(user.id); 349 350 try { 351 await sendEmail({ 352 to: user.email, 353 subject: "Verify your email - Thistle", 354 html: verifyEmailTemplate({ 355 name: user.name, 356 code, 357 token, 358 }), 359 }); 360 } catch (err) { 361 console.error("[Email] Failed to send verification email:", err); 362 // Rollback user creation - direct DB delete since user was just created 363 db.run("DELETE FROM email_verification_tokens WHERE user_id = ?", [ 364 user.id, 365 ]); 366 db.run("DELETE FROM sessions WHERE user_id = ?", [user.id]); 367 db.run("DELETE FROM users WHERE id = ?", [user.id]); 368 return Response.json( 369 { 370 error: 371 "Failed to send verification email. Please try again later.", 372 }, 373 { status: 500 }, 374 ); 375 } 376 377 // Attempt to sync existing Polar subscriptions (after email succeeds) 378 syncUserSubscriptionsFromPolar(user.id, user.email).catch(() => { 379 // Silent fail - don't block registration 380 }); 381 382 // Clear rate limits on successful registration 383 const ipAddress = 384 req.headers.get("x-forwarded-for") ?? 385 req.headers.get("x-real-ip") ?? 386 "unknown"; 387 clearRateLimit("register", email, ipAddress); 388 389 // Return success but indicate email verification is needed 390 // Don't create session yet - they need to verify first 391 return Response.json( 392 { 393 user: { id: user.id, email: user.email }, 394 email_verification_required: true, 395 verification_code_sent_at: sentAt, 396 }, 397 { status: 200 }, 398 ); 399 } catch (err: unknown) { 400 const error = err as { message?: string }; 401 if (error.message?.includes("UNIQUE constraint failed")) { 402 return Response.json( 403 { error: "Email already registered" }, 404 { status: 400 }, 405 ); 406 } 407 console.error("[Auth] Registration error:", err); 408 return Response.json( 409 { error: "Registration failed" }, 410 { status: 500 }, 411 ); 412 } 413 }, 414 }, 415 "/api/auth/login": { 416 POST: async (req) => { 417 try { 418 const body = await req.json(); 419 const { email, password } = body; 420 if (!email || !password) { 421 return Response.json( 422 { error: "Email and password required" }, 423 { status: 400 }, 424 ); 425 } 426 427 // Rate limiting: Per IP and per account 428 const rateLimitError = enforceRateLimit(req, "login", { 429 ip: { max: 10, windowSeconds: 5 * 60 }, 430 account: { max: 5, windowSeconds: 5 * 60, email }, 431 }); 432 if (rateLimitError) return rateLimitError; 433 434 // Validate password format (client-side hashed PBKDF2) 435 const passwordValidation = validatePasswordHash(password); 436 if (!passwordValidation.valid) { 437 return Response.json( 438 { error: passwordValidation.error }, 439 { status: 400 }, 440 ); 441 } 442 const user = await authenticateUser(email, password); 443 if (!user) { 444 return Response.json( 445 { error: "Invalid email or password" }, 446 { status: 401 }, 447 ); 448 } 449 450 // Clear rate limits on successful authentication 451 const ipAddress = 452 req.headers.get("x-forwarded-for") ?? 453 req.headers.get("x-real-ip") ?? 454 "unknown"; 455 clearRateLimit("login", email, ipAddress); 456 457 // Check if email is verified 458 if (!isEmailVerified(user.id)) { 459 let codeSentAt = getVerificationCodeSentAt(user.id); 460 461 // If no verification code exists, auto-send one 462 if (!codeSentAt) { 463 const { code, token, sentAt } = createEmailVerificationToken( 464 user.id, 465 ); 466 codeSentAt = sentAt; 467 468 try { 469 await sendEmail({ 470 to: user.email, 471 subject: "Verify your email - Thistle", 472 html: verifyEmailTemplate({ 473 name: user.name, 474 code, 475 token, 476 }), 477 }); 478 } catch (err) { 479 console.error( 480 "[Email] Failed to send verification email on login:", 481 err, 482 ); 483 // Don't fail login - just return null timestamp so client can try resend 484 codeSentAt = null; 485 } 486 } 487 488 return Response.json( 489 { 490 user: { id: user.id, email: user.email }, 491 email_verification_required: true, 492 verification_code_sent_at: codeSentAt, 493 }, 494 { status: 200 }, 495 ); 496 } 497 498 const userAgent = req.headers.get("user-agent") ?? "unknown"; 499 const sessionId = createSession(user.id, ipAddress, userAgent); 500 return Response.json( 501 { user: { id: user.id, email: user.email } }, 502 { 503 headers: { 504 "Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`, 505 }, 506 }, 507 ); 508 } catch (error) { 509 console.error("[Auth] Login error:", error); 510 return Response.json({ error: "Login failed" }, { status: 500 }); 511 } 512 }, 513 }, 514 "/api/auth/verify-email": { 515 GET: async (req) => { 516 try { 517 const url = new URL(req.url); 518 const token = url.searchParams.get("token"); 519 520 if (!token) { 521 return Response.redirect("/", 302); 522 } 523 524 const result = verifyEmailToken(token); 525 526 if (!result) { 527 return Response.redirect("/", 302); 528 } 529 530 // Create session for the verified user 531 const ipAddress = 532 req.headers.get("x-forwarded-for") ?? 533 req.headers.get("x-real-ip") ?? 534 "unknown"; 535 const userAgent = req.headers.get("user-agent") ?? "unknown"; 536 const sessionId = createSession(result.userId, ipAddress, userAgent); 537 538 // Redirect to classes with session cookie 539 return new Response(null, { 540 status: 302, 541 headers: { 542 Location: "/classes", 543 "Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`, 544 }, 545 }); 546 } catch (error) { 547 console.error("[Email] Verification error:", error); 548 return Response.redirect("/", 302); 549 } 550 }, 551 POST: async (req) => { 552 try { 553 const body = await req.json(); 554 const { email, code } = body; 555 556 if (!email || !code) { 557 return Response.json( 558 { error: "Email and verification code required" }, 559 { status: 400 }, 560 ); 561 } 562 563 // Get user by email 564 const user = getUserByEmail(email); 565 if (!user) { 566 return Response.json({ error: "User not found" }, { status: 404 }); 567 } 568 569 // Check if already verified 570 if (isEmailVerified(user.id)) { 571 return Response.json( 572 { error: "Email already verified" }, 573 { status: 400 }, 574 ); 575 } 576 577 const success = verifyEmailCode(user.id, code); 578 579 if (!success) { 580 return Response.json( 581 { error: "Invalid or expired verification code" }, 582 { status: 400 }, 583 ); 584 } 585 586 // Create session after successful verification 587 const ipAddress = 588 req.headers.get("x-forwarded-for") ?? 589 req.headers.get("x-real-ip") ?? 590 "unknown"; 591 const userAgent = req.headers.get("user-agent") ?? "unknown"; 592 const sessionId = createSession(user.id, ipAddress, userAgent); 593 594 return Response.json( 595 { 596 success: true, 597 message: "Email verified successfully", 598 email_verified: true, 599 user: { id: user.id, email: user.email }, 600 }, 601 { 602 headers: { 603 "Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`, 604 }, 605 }, 606 ); 607 } catch (error) { 608 return handleError(error); 609 } 610 }, 611 }, 612 "/api/auth/resend-verification": { 613 POST: async (req) => { 614 try { 615 const user = requireAuth(req); 616 617 // Rate limiting 618 const rateLimitError = enforceRateLimit(req, "resend-verification", { 619 account: { max: 3, windowSeconds: 60 * 60, email: user.email }, 620 }); 621 if (rateLimitError) return rateLimitError; 622 623 // Check if already verified 624 if (isEmailVerified(user.id)) { 625 return Response.json( 626 { error: "Email already verified" }, 627 { status: 400 }, 628 ); 629 } 630 631 // Generate new code and send email 632 const { code, token } = createEmailVerificationToken(user.id); 633 634 await sendEmail({ 635 to: user.email, 636 subject: "Verify your email - Thistle", 637 html: verifyEmailTemplate({ 638 name: user.name, 639 code, 640 token, 641 }), 642 }); 643 644 return Response.json({ success: true, message: "Verification email sent" }); 645 } catch (error) { 646 return handleError(error); 647 } 648 }, 649 }, 650 "/api/auth/resend-verification-code": { 651 POST: async (req) => { 652 try { 653 const body = await req.json(); 654 const { email } = body; 655 656 if (!email) { 657 return Response.json({ error: "Email required" }, { status: 400 }); 658 } 659 660 // Rate limiting by email 661 const rateLimitError = enforceRateLimit( 662 req, 663 "resend-verification-code", 664 { 665 account: { max: 3, windowSeconds: 5 * 60, email }, 666 }, 667 ); 668 if (rateLimitError) return rateLimitError; 669 670 // Get user by email 671 const user = getUserByEmail(email); 672 if (!user) { 673 // Don't reveal if user exists 674 return Response.json({ 675 success: true, 676 message: 677 "If an account exists with that email, a verification code has been sent", 678 }); 679 } 680 681 // Check if already verified 682 if (isEmailVerified(user.id)) { 683 return Response.json( 684 { error: "Email already verified" }, 685 { status: 400 }, 686 ); 687 } 688 689 // Generate new code and send email 690 const { code, token, sentAt } = createEmailVerificationToken(user.id); 691 692 await sendEmail({ 693 to: user.email, 694 subject: "Verify your email - Thistle", 695 html: verifyEmailTemplate({ 696 name: user.name, 697 code, 698 token, 699 }), 700 }); 701 702 return Response.json({ 703 success: true, 704 message: "Verification code sent", 705 verification_code_sent_at: sentAt, 706 }); 707 } catch (error) { 708 return handleError(error); 709 } 710 }, 711 }, 712 "/api/auth/forgot-password": { 713 POST: async (req) => { 714 try { 715 // Rate limiting 716 const rateLimitError = enforceRateLimit(req, "forgot-password", { 717 ip: { max: 5, windowSeconds: 60 * 60 }, 718 }); 719 if (rateLimitError) return rateLimitError; 720 721 const body = await req.json(); 722 const { email } = body; 723 724 if (!email) { 725 return Response.json({ error: "Email required" }, { status: 400 }); 726 } 727 728 // Always return success to prevent email enumeration 729 const user = getUserByEmail(email); 730 if (user) { 731 const origin = process.env.ORIGIN || "http://localhost:3000"; 732 const resetToken = createPasswordResetToken(user.id); 733 const resetLink = `${origin}/reset-password?token=${resetToken}`; 734 735 await sendEmail({ 736 to: user.email, 737 subject: "Reset your password - Thistle", 738 html: passwordResetTemplate({ 739 name: user.name, 740 resetLink, 741 }), 742 }).catch((err) => { 743 console.error("[Email] Failed to send password reset:", err); 744 }); 745 } 746 747 return Response.json({ 748 success: true, 749 message: 750 "If an account exists with that email, a password reset link has been sent", 751 }); 752 } catch (error) { 753 console.error("[Email] Forgot password error:", error); 754 return Response.json( 755 { error: "Failed to process request" }, 756 { status: 500 }, 757 ); 758 } 759 }, 760 }, 761 "/api/auth/reset-password": { 762 GET: async (req) => { 763 try { 764 const url = new URL(req.url); 765 const token = url.searchParams.get("token"); 766 767 if (!token) { 768 return Response.json({ error: "Token required" }, { status: 400 }); 769 } 770 771 const userId = verifyPasswordResetToken(token); 772 if (!userId) { 773 return Response.json( 774 { error: "Invalid or expired reset token" }, 775 { status: 400 }, 776 ); 777 } 778 779 // Get user's email for client-side password hashing 780 const user = db 781 .query<{ email: string }, [number]>( 782 "SELECT email FROM users WHERE id = ?", 783 ) 784 .get(userId); 785 786 if (!user) { 787 return Response.json({ error: "User not found" }, { status: 404 }); 788 } 789 790 return Response.json({ email: user.email }); 791 } catch (error) { 792 console.error("[Email] Get reset token info error:", error); 793 return Response.json( 794 { error: "Failed to verify token" }, 795 { status: 500 }, 796 ); 797 } 798 }, 799 POST: async (req) => { 800 try { 801 const body = await req.json(); 802 const { token, password } = body; 803 804 if (!token || !password) { 805 return Response.json( 806 { error: "Token and password required" }, 807 { status: 400 }, 808 ); 809 } 810 811 // Validate password format (client-side hashed PBKDF2) 812 const passwordValidation = validatePasswordHash(password); 813 if (!passwordValidation.valid) { 814 return Response.json( 815 { error: passwordValidation.error }, 816 { status: 400 }, 817 ); 818 } 819 820 const userId = verifyPasswordResetToken(token); 821 if (!userId) { 822 return Response.json( 823 { error: "Invalid or expired reset token" }, 824 { status: 400 }, 825 ); 826 } 827 828 // Update password and consume token 829 await updateUserPassword(userId, password); 830 consumePasswordResetToken(token); 831 832 return Response.json({ success: true, message: "Password reset successfully" }); 833 } catch (error) { 834 console.error("[Email] Reset password error:", error); 835 return Response.json( 836 { error: "Failed to reset password" }, 837 { status: 500 }, 838 ); 839 } 840 }, 841 }, 842 "/api/auth/logout": { 843 POST: async (req) => { 844 const sessionId = getSessionFromRequest(req); 845 if (sessionId) { 846 deleteSession(sessionId); 847 } 848 return Response.json( 849 { success: true }, 850 { 851 headers: { 852 "Set-Cookie": 853 "session=; HttpOnly; Secure; Path=/; Max-Age=0; SameSite=Lax", 854 }, 855 }, 856 ); 857 }, 858 }, 859 "/api/auth/me": { 860 GET: (req) => { 861 const sessionId = getSessionFromRequest(req); 862 if (!sessionId) { 863 return Response.json({ error: "Not authenticated" }, { status: 401 }); 864 } 865 const user = getUserBySession(sessionId); 866 if (!user) { 867 return Response.json({ error: "Invalid session" }, { status: 401 }); 868 } 869 870 // Check subscription status 871 const subscription = db 872 .query<{ status: string }, [number]>( 873 "SELECT status FROM subscriptions WHERE user_id = ? AND status IN ('active', 'trialing', 'past_due') ORDER BY created_at DESC LIMIT 1", 874 ) 875 .get(user.id); 876 877 // Get notification preferences 878 const prefs = db 879 .query<{ email_notifications_enabled: number }, [number]>( 880 "SELECT email_notifications_enabled FROM users WHERE id = ?", 881 ) 882 .get(user.id); 883 884 return Response.json({ 885 email: user.email, 886 name: user.name, 887 avatar: user.avatar, 888 created_at: user.created_at, 889 role: user.role, 890 has_subscription: !!subscription, 891 email_verified: isEmailVerified(user.id), 892 email_notifications_enabled: prefs?.email_notifications_enabled === 1, 893 }); 894 }, 895 }, 896 "/api/passkeys/register/options": { 897 POST: async (req) => { 898 try { 899 const user = requireAuth(req); 900 901 const rateLimitError = enforceRateLimit(req, "passkey-register-options", { 902 ip: { max: 10, windowSeconds: 5 * 60 }, 903 }); 904 if (rateLimitError) return rateLimitError; 905 906 const options = await createRegistrationOptions(user); 907 return Response.json(options); 908 } catch (err) { 909 return handleError(err); 910 } 911 }, 912 }, 913 "/api/passkeys/register/verify": { 914 POST: async (req) => { 915 try { 916 const _user = requireAuth(req); 917 918 const rateLimitError = enforceRateLimit(req, "passkey-register-verify", { 919 ip: { max: 10, windowSeconds: 5 * 60 }, 920 }); 921 if (rateLimitError) return rateLimitError; 922 923 const body = await req.json(); 924 const { response: credentialResponse, challenge, name } = body; 925 926 const passkey = await verifyAndCreatePasskey( 927 credentialResponse, 928 challenge, 929 name, 930 ); 931 932 return Response.json({ 933 success: true, 934 passkey: { 935 id: passkey.id, 936 name: passkey.name, 937 created_at: passkey.created_at, 938 }, 939 }); 940 } catch (err) { 941 return handleError(err); 942 } 943 }, 944 }, 945 "/api/passkeys/authenticate/options": { 946 POST: async (req) => { 947 try { 948 const rateLimitError = enforceRateLimit(req, "passkey-auth-options", { 949 ip: { max: 10, windowSeconds: 5 * 60 }, 950 }); 951 if (rateLimitError) return rateLimitError; 952 953 const body = await req.json(); 954 const { email } = body; 955 956 const options = await createAuthenticationOptions(email); 957 return Response.json(options); 958 } catch (err) { 959 return handleError(err); 960 } 961 }, 962 }, 963 "/api/passkeys/authenticate/verify": { 964 POST: async (req) => { 965 try { 966 const rateLimitError = enforceRateLimit(req, "passkey-auth-verify", { 967 ip: { max: 10, windowSeconds: 5 * 60 }, 968 }); 969 if (rateLimitError) return rateLimitError; 970 971 const body = await req.json(); 972 const { response: credentialResponse, challenge } = body; 973 974 const result = await verifyAndAuthenticatePasskey( 975 credentialResponse, 976 challenge, 977 ); 978 979 if ("error" in result) { 980 return new Response(JSON.stringify({ error: result.error }), { 981 status: 401, 982 }); 983 } 984 985 const { user } = result; 986 987 // Create session 988 const ipAddress = 989 req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || 990 req.headers.get("x-real-ip") || 991 "unknown"; 992 const userAgent = req.headers.get("user-agent") || "unknown"; 993 const sessionId = createSession(user.id, ipAddress, userAgent); 994 995 return Response.json( 996 { 997 email: user.email, 998 name: user.name, 999 avatar: user.avatar, 1000 created_at: user.created_at, 1001 role: user.role, 1002 }, 1003 { 1004 headers: { 1005 "Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`, 1006 }, 1007 }, 1008 ); 1009 } catch (err) { 1010 return handleError(err); 1011 } 1012 }, 1013 }, 1014 "/api/passkeys": { 1015 GET: async (req) => { 1016 try { 1017 const user = requireAuth(req); 1018 const passkeys = getPasskeysForUser(user.id); 1019 return Response.json({ 1020 passkeys: passkeys.map((p) => ({ 1021 id: p.id, 1022 name: p.name, 1023 created_at: p.created_at, 1024 last_used_at: p.last_used_at, 1025 })), 1026 }); 1027 } catch (err) { 1028 return handleError(err); 1029 } 1030 }, 1031 }, 1032 "/api/passkeys/:id": { 1033 PUT: async (req) => { 1034 try { 1035 const user = requireAuth(req); 1036 1037 const rateLimitError = enforceRateLimit(req, "passkey-update", { 1038 ip: { max: 10, windowSeconds: 60 * 60 }, 1039 }); 1040 if (rateLimitError) return rateLimitError; 1041 1042 const body = await req.json(); 1043 const { name } = body; 1044 const passkeyId = req.params.id; 1045 1046 if (!name) { 1047 return Response.json({ error: "Name required" }, { status: 400 }); 1048 } 1049 1050 updatePasskeyName(passkeyId, user.id, name); 1051 return Response.json({ success: true }); 1052 } catch (err) { 1053 return handleError(err); 1054 } 1055 }, 1056 DELETE: async (req) => { 1057 try { 1058 const user = requireAuth(req); 1059 1060 const rateLimitError = enforceRateLimit(req, "passkey-delete", { 1061 ip: { max: 10, windowSeconds: 60 * 60 }, 1062 }); 1063 if (rateLimitError) return rateLimitError; 1064 1065 const passkeyId = req.params.id; 1066 deletePasskey(passkeyId, user.id); 1067 return Response.json({ success: true }); 1068 } catch (err) { 1069 return handleError(err); 1070 } 1071 }, 1072 }, 1073 "/api/sessions": { 1074 GET: (req) => { 1075 const sessionId = getSessionFromRequest(req); 1076 if (!sessionId) { 1077 return Response.json({ error: "Not authenticated" }, { status: 401 }); 1078 } 1079 const user = getUserBySession(sessionId); 1080 if (!user) { 1081 return Response.json({ error: "Invalid session" }, { status: 401 }); 1082 } 1083 const sessions = getUserSessionsForUser(user.id); 1084 return Response.json({ 1085 sessions: sessions.map((s) => ({ 1086 id: s.id, 1087 ip_address: s.ip_address, 1088 user_agent: s.user_agent, 1089 created_at: s.created_at, 1090 expires_at: s.expires_at, 1091 is_current: s.id === sessionId, 1092 })), 1093 }); 1094 }, 1095 DELETE: async (req) => { 1096 const currentSessionId = getSessionFromRequest(req); 1097 if (!currentSessionId) { 1098 return Response.json({ error: "Not authenticated" }, { status: 401 }); 1099 } 1100 const user = getUserBySession(currentSessionId); 1101 if (!user) { 1102 return Response.json({ error: "Invalid session" }, { status: 401 }); 1103 } 1104 1105 const rateLimitError = enforceRateLimit(req, "delete-session", { 1106 ip: { max: 20, windowSeconds: 60 * 60 }, 1107 }); 1108 if (rateLimitError) return rateLimitError; 1109 1110 const body = await req.json(); 1111 const targetSessionId = body.sessionId; 1112 if (!targetSessionId) { 1113 return Response.json( 1114 { error: "Session ID required" }, 1115 { status: 400 }, 1116 ); 1117 } 1118 // Prevent deleting current session 1119 if (targetSessionId === currentSessionId) { 1120 return Response.json( 1121 { error: "Cannot kill current session. Use logout instead." }, 1122 { status: 400 }, 1123 ); 1124 } 1125 // Verify the session belongs to the user 1126 const targetSession = getSession(targetSessionId); 1127 if (!targetSession || targetSession.user_id !== user.id) { 1128 return Response.json({ error: "Session not found" }, { status: 404 }); 1129 } 1130 deleteSession(targetSessionId); 1131 return Response.json({ success: true }); 1132 }, 1133 }, 1134 "/api/user": { 1135 DELETE: async (req) => { 1136 const sessionId = getSessionFromRequest(req); 1137 if (!sessionId) { 1138 return Response.json({ error: "Not authenticated" }, { status: 401 }); 1139 } 1140 const user = getUserBySession(sessionId); 1141 if (!user) { 1142 return Response.json({ error: "Invalid session" }, { status: 401 }); 1143 } 1144 1145 // Rate limiting 1146 const rateLimitError = enforceRateLimit(req, "delete-user", { 1147 ip: { max: 3, windowSeconds: 60 * 60 }, 1148 }); 1149 if (rateLimitError) return rateLimitError; 1150 1151 await deleteUser(user.id); 1152 return Response.json( 1153 { success: true }, 1154 { 1155 headers: { 1156 "Set-Cookie": 1157 "session=; HttpOnly; Secure; Path=/; Max-Age=0; SameSite=Lax", 1158 }, 1159 }, 1160 ); 1161 }, 1162 }, 1163 "/api/user/email": { 1164 PUT: async (req) => { 1165 const sessionId = getSessionFromRequest(req); 1166 if (!sessionId) { 1167 return Response.json({ error: "Not authenticated" }, { status: 401 }); 1168 } 1169 const user = getUserBySession(sessionId); 1170 if (!user) { 1171 return Response.json({ error: "Invalid session" }, { status: 401 }); 1172 } 1173 1174 // Rate limiting 1175 const rateLimitError = enforceRateLimit(req, "update-email", { 1176 ip: { max: 5, windowSeconds: 60 * 60 }, 1177 }); 1178 if (rateLimitError) return rateLimitError; 1179 1180 const body = await req.json(); 1181 const { email } = body; 1182 if (!email) { 1183 return Response.json({ error: "Email required" }, { status: 400 }); 1184 } 1185 1186 // Check if email is already in use 1187 const existingUser = getUserByEmail(email); 1188 if (existingUser) { 1189 return Response.json( 1190 { error: "Email already in use" }, 1191 { status: 400 }, 1192 ); 1193 } 1194 1195 try { 1196 // Create email change token 1197 const token = createEmailChangeToken(user.id, email); 1198 1199 // Send verification email to the CURRENT address 1200 const origin = process.env.ORIGIN || "http://localhost:3000"; 1201 const verifyUrl = `${origin}/api/user/email/verify?token=${token}`; 1202 1203 await sendEmail({ 1204 to: user.email, 1205 subject: "Verify your email change", 1206 html: emailChangeTemplate({ 1207 name: user.name, 1208 currentEmail: user.email, 1209 newEmail: email, 1210 verifyLink: verifyUrl, 1211 }), 1212 }); 1213 1214 return Response.json({ 1215 success: true, 1216 message: `Verification email sent to ${user.email}`, 1217 pendingEmail: email, 1218 }); 1219 } catch (error) { 1220 console.error( 1221 "[Email] Failed to send email change verification:", 1222 error, 1223 ); 1224 return Response.json( 1225 { error: "Failed to send verification email" }, 1226 { status: 500 }, 1227 ); 1228 } 1229 }, 1230 }, 1231 "/api/user/email/verify": { 1232 GET: async (req) => { 1233 try { 1234 const url = new URL(req.url); 1235 const token = url.searchParams.get("token"); 1236 1237 if (!token) { 1238 return Response.redirect( 1239 "/settings?tab=account&error=invalid-token", 1240 302, 1241 ); 1242 } 1243 1244 const result = verifyEmailChangeToken(token); 1245 1246 if (!result) { 1247 return Response.redirect( 1248 "/settings?tab=account&error=expired-token", 1249 302, 1250 ); 1251 } 1252 1253 // Update the user's email 1254 updateUserEmail(result.userId, result.newEmail); 1255 1256 // Consume the token 1257 consumeEmailChangeToken(token); 1258 1259 // Redirect to settings with success message 1260 return Response.redirect( 1261 "/settings?tab=account&success=email-changed", 1262 302, 1263 ); 1264 } catch (error) { 1265 console.error("[Email] Email change verification error:", error); 1266 return Response.redirect( 1267 "/settings?tab=account&error=verification-failed", 1268 302, 1269 ); 1270 } 1271 }, 1272 }, 1273 "/api/user/password": { 1274 PUT: async (req) => { 1275 const sessionId = getSessionFromRequest(req); 1276 if (!sessionId) { 1277 return Response.json({ error: "Not authenticated" }, { status: 401 }); 1278 } 1279 const user = getUserBySession(sessionId); 1280 if (!user) { 1281 return Response.json({ error: "Invalid session" }, { status: 401 }); 1282 } 1283 1284 // Rate limiting 1285 const rateLimitError = enforceRateLimit(req, "update-password", { 1286 ip: { max: 5, windowSeconds: 60 * 60 }, 1287 }); 1288 if (rateLimitError) return rateLimitError; 1289 1290 const body = await req.json(); 1291 const { password } = body; 1292 if (!password) { 1293 return Response.json({ error: "Password required" }, { status: 400 }); 1294 } 1295 // Validate password format (client-side hashed PBKDF2) 1296 const passwordValidation = validatePasswordHash(password); 1297 if (!passwordValidation.valid) { 1298 return Response.json( 1299 { error: passwordValidation.error }, 1300 { status: 400 }, 1301 ); 1302 } 1303 try { 1304 await updateUserPassword(user.id, password); 1305 return Response.json({ success: true }); 1306 } catch { 1307 return Response.json( 1308 { error: "Failed to update password" }, 1309 { status: 500 }, 1310 ); 1311 } 1312 }, 1313 }, 1314 "/api/user/name": { 1315 PUT: async (req) => { 1316 const sessionId = getSessionFromRequest(req); 1317 if (!sessionId) { 1318 return Response.json({ error: "Not authenticated" }, { status: 401 }); 1319 } 1320 const user = getUserBySession(sessionId); 1321 if (!user) { 1322 return Response.json({ error: "Invalid session" }, { status: 401 }); 1323 } 1324 1325 const rateLimitError = enforceRateLimit(req, "update-name", { 1326 ip: { max: 10, windowSeconds: 5 * 60 }, 1327 }); 1328 if (rateLimitError) return rateLimitError; 1329 1330 const body = await req.json(); 1331 const { name } = body; 1332 if (!name) { 1333 return Response.json({ error: "Name required" }, { status: 400 }); 1334 } 1335 try { 1336 updateUserName(user.id, name); 1337 return Response.json({ success: true }); 1338 } catch { 1339 return Response.json( 1340 { error: "Failed to update name" }, 1341 { status: 500 }, 1342 ); 1343 } 1344 }, 1345 }, 1346 "/api/user/avatar": { 1347 PUT: async (req) => { 1348 const sessionId = getSessionFromRequest(req); 1349 if (!sessionId) { 1350 return Response.json({ error: "Not authenticated" }, { status: 401 }); 1351 } 1352 const user = getUserBySession(sessionId); 1353 if (!user) { 1354 return Response.json({ error: "Invalid session" }, { status: 401 }); 1355 } 1356 1357 const rateLimitError = enforceRateLimit(req, "update-avatar", { 1358 ip: { max: 10, windowSeconds: 5 * 60 }, 1359 }); 1360 if (rateLimitError) return rateLimitError; 1361 1362 const body = await req.json(); 1363 const { avatar } = body; 1364 if (!avatar) { 1365 return Response.json({ error: "Avatar required" }, { status: 400 }); 1366 } 1367 try { 1368 updateUserAvatar(user.id, avatar); 1369 return Response.json({ success: true }); 1370 } catch { 1371 return Response.json( 1372 { error: "Failed to update avatar" }, 1373 { status: 500 }, 1374 ); 1375 } 1376 }, 1377 }, 1378 "/api/user/notifications": { 1379 PUT: async (req) => { 1380 const sessionId = getSessionFromRequest(req); 1381 if (!sessionId) { 1382 return Response.json({ error: "Not authenticated" }, { status: 401 }); 1383 } 1384 const user = getUserBySession(sessionId); 1385 if (!user) { 1386 return Response.json({ error: "Invalid session" }, { status: 401 }); 1387 } 1388 1389 const rateLimitError = enforceRateLimit(req, "update-notifications", { 1390 ip: { max: 10, windowSeconds: 5 * 60 }, 1391 }); 1392 if (rateLimitError) return rateLimitError; 1393 1394 const body = await req.json(); 1395 const { email_notifications_enabled } = body; 1396 if (typeof email_notifications_enabled !== "boolean") { 1397 return Response.json( 1398 { error: "email_notifications_enabled must be a boolean" }, 1399 { status: 400 }, 1400 ); 1401 } 1402 try { 1403 db.run( 1404 "UPDATE users SET email_notifications_enabled = ? WHERE id = ?", 1405 [email_notifications_enabled ? 1 : 0, user.id], 1406 ); 1407 return Response.json({ success: true }); 1408 } catch { 1409 return Response.json( 1410 { error: "Failed to update notification settings" }, 1411 { status: 500 }, 1412 ); 1413 } 1414 }, 1415 }, 1416 "/api/billing/checkout": { 1417 POST: async (req) => { 1418 const sessionId = getSessionFromRequest(req); 1419 if (!sessionId) { 1420 return Response.json({ error: "Not authenticated" }, { status: 401 }); 1421 } 1422 const user = getUserBySession(sessionId); 1423 if (!user) { 1424 return Response.json({ error: "Invalid session" }, { status: 401 }); 1425 } 1426 1427 try { 1428 const { polar } = await import("./lib/polar"); 1429 1430 // Validated at startup 1431 const productId = process.env.POLAR_PRODUCT_ID as string; 1432 const successUrl = 1433 process.env.POLAR_SUCCESS_URL || "http://localhost:3000"; 1434 1435 const checkout = await polar.checkouts.create({ 1436 products: [productId], 1437 successUrl, 1438 customerEmail: user.email, 1439 customerName: user.name ?? undefined, 1440 metadata: { 1441 userId: user.id.toString(), 1442 }, 1443 }); 1444 1445 return Response.json({ url: checkout.url }); 1446 } catch (error) { 1447 console.error("Failed to create checkout:", error); 1448 return Response.json( 1449 { error: "Failed to create checkout session" }, 1450 { status: 500 }, 1451 ); 1452 } 1453 }, 1454 }, 1455 "/api/billing/subscription": { 1456 GET: async (req) => { 1457 const sessionId = getSessionFromRequest(req); 1458 if (!sessionId) { 1459 return Response.json({ error: "Not authenticated" }, { status: 401 }); 1460 } 1461 const user = getUserBySession(sessionId); 1462 if (!user) { 1463 return Response.json({ error: "Invalid session" }, { status: 401 }); 1464 } 1465 1466 try { 1467 // Get subscription from database 1468 const subscription = db 1469 .query< 1470 { 1471 id: string; 1472 status: string; 1473 current_period_start: number | null; 1474 current_period_end: number | null; 1475 cancel_at_period_end: number; 1476 canceled_at: number | null; 1477 }, 1478 [number] 1479 >( 1480 "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", 1481 ) 1482 .get(user.id); 1483 1484 if (!subscription) { 1485 return Response.json({ subscription: null }); 1486 } 1487 1488 return Response.json({ subscription }); 1489 } catch (error) { 1490 console.error("Failed to fetch subscription:", error); 1491 return Response.json( 1492 { error: "Failed to fetch subscription" }, 1493 { status: 500 }, 1494 ); 1495 } 1496 }, 1497 }, 1498 "/api/billing/portal": { 1499 POST: async (req) => { 1500 const sessionId = getSessionFromRequest(req); 1501 if (!sessionId) { 1502 return Response.json({ error: "Not authenticated" }, { status: 401 }); 1503 } 1504 const user = getUserBySession(sessionId); 1505 if (!user) { 1506 return Response.json({ error: "Invalid session" }, { status: 401 }); 1507 } 1508 1509 try { 1510 const { polar } = await import("./lib/polar"); 1511 1512 // Get subscription to find customer ID 1513 const subscription = db 1514 .query< 1515 { 1516 customer_id: string; 1517 }, 1518 [number] 1519 >( 1520 "SELECT customer_id FROM subscriptions WHERE user_id = ? ORDER BY created_at DESC LIMIT 1", 1521 ) 1522 .get(user.id); 1523 1524 if (!subscription || !subscription.customer_id) { 1525 return Response.json( 1526 { error: "No subscription found" }, 1527 { status: 404 }, 1528 ); 1529 } 1530 1531 // Create customer portal session 1532 const session = await polar.customerSessions.create({ 1533 customerId: subscription.customer_id, 1534 }); 1535 1536 return Response.json({ url: session.customerPortalUrl }); 1537 } catch (error) { 1538 console.error("Failed to create portal session:", error); 1539 return Response.json( 1540 { error: "Failed to create portal session" }, 1541 { status: 500 }, 1542 ); 1543 } 1544 }, 1545 }, 1546 "/api/webhooks/polar": { 1547 POST: async (req) => { 1548 try { 1549 const { validateEvent } = await import("@polar-sh/sdk/webhooks"); 1550 1551 // Get raw body as string 1552 const rawBody = await req.text(); 1553 const headers = Object.fromEntries(req.headers.entries()); 1554 1555 // Validate webhook signature (validated at startup) 1556 const webhookSecret = process.env.POLAR_WEBHOOK_SECRET as string; 1557 const event = validateEvent(rawBody, headers, webhookSecret); 1558 1559 console.log(`[Webhook] Received event: ${event.type}`); 1560 1561 // Handle different event types 1562 switch (event.type) { 1563 case "subscription.updated": { 1564 const { id, status, customerId, metadata } = event.data; 1565 const userId = metadata?.userId 1566 ? Number.parseInt(metadata.userId as string, 10) 1567 : null; 1568 1569 if (!userId) { 1570 console.warn("[Webhook] No userId in subscription metadata"); 1571 break; 1572 } 1573 1574 // Upsert subscription 1575 db.run( 1576 `INSERT INTO subscriptions (id, user_id, customer_id, status, current_period_start, current_period_end, cancel_at_period_end, canceled_at, updated_at) 1577 VALUES (?, ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now')) 1578 ON CONFLICT(id) DO UPDATE SET 1579 status = excluded.status, 1580 current_period_start = excluded.current_period_start, 1581 current_period_end = excluded.current_period_end, 1582 cancel_at_period_end = excluded.cancel_at_period_end, 1583 canceled_at = excluded.canceled_at, 1584 updated_at = strftime('%s', 'now')`, 1585 [ 1586 id, 1587 userId, 1588 customerId, 1589 status, 1590 event.data.currentPeriodStart 1591 ? Math.floor( 1592 new Date(event.data.currentPeriodStart).getTime() / 1593 1000, 1594 ) 1595 : null, 1596 event.data.currentPeriodEnd 1597 ? Math.floor( 1598 new Date(event.data.currentPeriodEnd).getTime() / 1000, 1599 ) 1600 : null, 1601 event.data.cancelAtPeriodEnd ? 1 : 0, 1602 event.data.canceledAt 1603 ? Math.floor( 1604 new Date(event.data.canceledAt).getTime() / 1000, 1605 ) 1606 : null, 1607 ], 1608 ); 1609 1610 console.log( 1611 `[Webhook] Updated subscription ${id} for user ${userId}`, 1612 ); 1613 break; 1614 } 1615 1616 default: 1617 console.log(`[Webhook] Unhandled event type: ${event.type}`); 1618 } 1619 1620 return Response.json({ received: true }); 1621 } catch (error) { 1622 console.error("[Webhook] Error processing webhook:", error); 1623 return Response.json( 1624 { error: "Webhook processing failed" }, 1625 { status: 400 }, 1626 ); 1627 } 1628 }, 1629 }, 1630 "/api/transcriptions/:id/stream": { 1631 GET: async (req) => { 1632 try { 1633 const user = requireAuth(req); 1634 const transcriptionId = req.params.id; 1635 // Verify ownership 1636 const transcription = db 1637 .query< 1638 { 1639 id: string; 1640 user_id: number; 1641 class_id: string | null; 1642 status: string; 1643 }, 1644 [string] 1645 >( 1646 "SELECT id, user_id, class_id, status FROM transcriptions WHERE id = ?", 1647 ) 1648 .get(transcriptionId); 1649 1650 if (!transcription) { 1651 return Response.json( 1652 { error: "Transcription not found" }, 1653 { status: 404 }, 1654 ); 1655 } 1656 1657 // Check access permissions 1658 const isOwner = transcription.user_id === user.id; 1659 const isAdmin = user.role === "admin"; 1660 let isClassMember = false; 1661 1662 // If transcription belongs to a class, check enrollment 1663 if (transcription.class_id) { 1664 isClassMember = isUserEnrolledInClass( 1665 user.id, 1666 transcription.class_id, 1667 ); 1668 } 1669 1670 // Allow access if: owner, admin, or enrolled in the class 1671 if (!isOwner && !isAdmin && !isClassMember) { 1672 return Response.json( 1673 { error: "Transcription not found" }, 1674 { status: 404 }, 1675 ); 1676 } 1677 1678 // Require subscription only if accessing own transcription (not class) 1679 if ( 1680 isOwner && 1681 !transcription.class_id && 1682 !isAdmin && 1683 !hasActiveSubscription(user.id) 1684 ) { 1685 throw AuthErrors.subscriptionRequired(); 1686 } 1687 // Event-driven SSE stream with reconnection support 1688 const stream = new ReadableStream({ 1689 async start(controller) { 1690 // Track this stream for graceful shutdown 1691 activeSSEStreams.add(controller); 1692 1693 const encoder = new TextEncoder(); 1694 let isClosed = false; 1695 let lastEventId = Math.floor(Date.now() / 1000); 1696 1697 const sendEvent = (data: Partial<TranscriptionUpdate>) => { 1698 if (isClosed) return; 1699 try { 1700 // Send event ID for reconnection support 1701 lastEventId = Math.floor(Date.now() / 1000); 1702 controller.enqueue( 1703 encoder.encode( 1704 `id: ${lastEventId}\nevent: update\ndata: ${JSON.stringify(data)}\n\n`, 1705 ), 1706 ); 1707 } catch { 1708 // Controller already closed (client disconnected) 1709 isClosed = true; 1710 } 1711 }; 1712 1713 const sendHeartbeat = () => { 1714 if (isClosed) return; 1715 try { 1716 controller.enqueue(encoder.encode(": heartbeat\n\n")); 1717 } catch { 1718 isClosed = true; 1719 } 1720 }; 1721 // Send initial state from DB and file 1722 const current = db 1723 .query< 1724 { 1725 status: string; 1726 progress: number; 1727 }, 1728 [string] 1729 >("SELECT status, progress FROM transcriptions WHERE id = ?") 1730 .get(transcriptionId); 1731 if (current) { 1732 sendEvent({ 1733 status: current.status as TranscriptionUpdate["status"], 1734 progress: current.progress, 1735 }); 1736 } 1737 // If already complete, close immediately 1738 if ( 1739 current?.status === "completed" || 1740 current?.status === "failed" 1741 ) { 1742 isClosed = true; 1743 activeSSEStreams.delete(controller); 1744 controller.close(); 1745 return; 1746 } 1747 // Send heartbeats every 2.5 seconds to keep connection alive 1748 const heartbeatInterval = setInterval(sendHeartbeat, 2500); 1749 1750 // Subscribe to EventEmitter for live updates 1751 const updateHandler = (data: TranscriptionUpdate) => { 1752 if (isClosed) return; 1753 1754 // Only send changed fields to save bandwidth 1755 const payload: Partial<TranscriptionUpdate> = { 1756 status: data.status, 1757 progress: data.progress, 1758 }; 1759 1760 if (data.transcript !== undefined) { 1761 payload.transcript = data.transcript; 1762 } 1763 if (data.error_message !== undefined) { 1764 payload.error_message = data.error_message; 1765 } 1766 1767 sendEvent(payload); 1768 1769 // Close stream when done 1770 if (data.status === "completed" || data.status === "failed") { 1771 isClosed = true; 1772 clearInterval(heartbeatInterval); 1773 transcriptionEvents.off(transcriptionId, updateHandler); 1774 activeSSEStreams.delete(controller); 1775 controller.close(); 1776 } 1777 }; 1778 transcriptionEvents.on(transcriptionId, updateHandler); 1779 // Cleanup on client disconnect 1780 return () => { 1781 isClosed = true; 1782 clearInterval(heartbeatInterval); 1783 transcriptionEvents.off(transcriptionId, updateHandler); 1784 activeSSEStreams.delete(controller); 1785 }; 1786 }, 1787 }); 1788 return new Response(stream, { 1789 headers: { 1790 "Content-Type": "text/event-stream", 1791 "Cache-Control": "no-cache", 1792 Connection: "keep-alive", 1793 }, 1794 }); 1795 } catch (error) { 1796 return handleError(error); 1797 } 1798 }, 1799 }, 1800 "/api/transcriptions/health": { 1801 GET: async () => { 1802 const health = { 1803 status: "healthy", 1804 timestamp: new Date().toISOString(), 1805 services: { 1806 database: false, 1807 whisper: false, 1808 storage: false, 1809 }, 1810 details: {} as Record<string, unknown>, 1811 }; 1812 1813 // Check database 1814 try { 1815 db.query("SELECT 1").get(); 1816 health.services.database = true; 1817 } catch (error) { 1818 health.status = "unhealthy"; 1819 health.details.databaseError = 1820 error instanceof Error ? error.message : String(error); 1821 } 1822 1823 // Check Whisper service 1824 try { 1825 const whisperHealthy = await whisperService.checkHealth(); 1826 health.services.whisper = whisperHealthy; 1827 if (!whisperHealthy) { 1828 health.status = "degraded"; 1829 health.details.whisperNote = "Whisper service unavailable"; 1830 } 1831 } catch (error) { 1832 health.status = "degraded"; 1833 health.details.whisperError = 1834 error instanceof Error ? error.message : String(error); 1835 } 1836 1837 // Check storage (uploads and transcripts directories) 1838 try { 1839 const uploadsDir = Bun.file("./uploads"); 1840 const transcriptsDir = Bun.file("./transcripts"); 1841 const uploadsExists = await uploadsDir.exists(); 1842 const transcriptsExists = await transcriptsDir.exists(); 1843 health.services.storage = uploadsExists && transcriptsExists; 1844 if (!health.services.storage) { 1845 health.status = "unhealthy"; 1846 health.details.storageNote = `Missing directories: ${[ 1847 !uploadsExists && "uploads", 1848 !transcriptsExists && "transcripts", 1849 ] 1850 .filter(Boolean) 1851 .join(", ")}`; 1852 } 1853 } catch (error) { 1854 health.status = "unhealthy"; 1855 health.details.storageError = 1856 error instanceof Error ? error.message : String(error); 1857 } 1858 1859 const statusCode = health.status === "healthy" ? 200 : 503; 1860 return Response.json(health, { status: statusCode }); 1861 }, 1862 }, 1863 "/api/transcriptions/:id": { 1864 GET: async (req) => { 1865 try { 1866 const user = requireAuth(req); 1867 const transcriptionId = req.params.id; 1868 1869 // Verify ownership or admin 1870 const transcription = db 1871 .query< 1872 { 1873 id: string; 1874 user_id: number; 1875 class_id: string | null; 1876 filename: string; 1877 original_filename: string; 1878 status: string; 1879 progress: number; 1880 created_at: number; 1881 }, 1882 [string] 1883 >( 1884 "SELECT id, user_id, class_id, filename, original_filename, status, progress, created_at FROM transcriptions WHERE id = ?", 1885 ) 1886 .get(transcriptionId); 1887 1888 if (!transcription) { 1889 return Response.json( 1890 { error: "Transcription not found" }, 1891 { status: 404 }, 1892 ); 1893 } 1894 1895 // Check access permissions 1896 const isOwner = transcription.user_id === user.id; 1897 const isAdmin = user.role === "admin"; 1898 let isClassMember = false; 1899 1900 // If transcription belongs to a class, check enrollment 1901 if (transcription.class_id) { 1902 isClassMember = isUserEnrolledInClass( 1903 user.id, 1904 transcription.class_id, 1905 ); 1906 } 1907 1908 // Allow access if: owner, admin, or enrolled in the class 1909 if (!isOwner && !isAdmin && !isClassMember) { 1910 return Response.json( 1911 { error: "Transcription not found" }, 1912 { status: 404 }, 1913 ); 1914 } 1915 1916 // Require subscription only if accessing own transcription (not class) 1917 if ( 1918 isOwner && 1919 !transcription.class_id && 1920 !isAdmin && 1921 !hasActiveSubscription(user.id) 1922 ) { 1923 throw AuthErrors.subscriptionRequired(); 1924 } 1925 1926 if (transcription.status !== "completed") { 1927 return Response.json( 1928 { error: "Transcription not completed yet" }, 1929 { status: 400 }, 1930 ); 1931 } 1932 1933 // Get format from query parameter 1934 const url = new URL(req.url); 1935 const format = url.searchParams.get("format"); 1936 1937 // Return WebVTT format if requested 1938 if (format === "vtt") { 1939 const vttContent = await getTranscriptVTT(transcriptionId); 1940 1941 if (!vttContent) { 1942 return Response.json( 1943 { error: "VTT transcript not available" }, 1944 { status: 404 }, 1945 ); 1946 } 1947 1948 return new Response(vttContent, { 1949 headers: { 1950 "Content-Type": "text/vtt", 1951 "Content-Disposition": `attachment; filename="${transcription.original_filename}.vtt"`, 1952 }, 1953 }); 1954 } 1955 1956 // return info on transcript 1957 const transcript = { 1958 id: transcription.id, 1959 filename: transcription.original_filename, 1960 status: transcription.status, 1961 progress: transcription.progress, 1962 created_at: transcription.created_at, 1963 }; 1964 return new Response(JSON.stringify(transcript), { 1965 headers: { 1966 "Content-Type": "application/json", 1967 }, 1968 }); 1969 } catch (error) { 1970 return handleError(error); 1971 } 1972 }, 1973 }, 1974 "/api/transcriptions/:id/audio": { 1975 GET: async (req) => { 1976 try { 1977 const user = requireAuth(req); 1978 const transcriptionId = req.params.id; 1979 1980 // Verify ownership or admin 1981 const transcription = db 1982 .query< 1983 { 1984 id: string; 1985 user_id: number; 1986 class_id: string | null; 1987 filename: string; 1988 status: string; 1989 }, 1990 [string] 1991 >( 1992 "SELECT id, user_id, class_id, filename, status FROM transcriptions WHERE id = ?", 1993 ) 1994 .get(transcriptionId); 1995 1996 if (!transcription) { 1997 return Response.json( 1998 { error: "Transcription not found" }, 1999 { status: 404 }, 2000 ); 2001 } 2002 2003 // Check access permissions 2004 const isOwner = transcription.user_id === user.id; 2005 const isAdmin = user.role === "admin"; 2006 let isClassMember = false; 2007 2008 // If transcription belongs to a class, check enrollment 2009 if (transcription.class_id) { 2010 isClassMember = isUserEnrolledInClass( 2011 user.id, 2012 transcription.class_id, 2013 ); 2014 } 2015 2016 // Allow access if: owner, admin, or enrolled in the class 2017 if (!isOwner && !isAdmin && !isClassMember) { 2018 return Response.json( 2019 { error: "Transcription not found" }, 2020 { status: 404 }, 2021 ); 2022 } 2023 2024 // Require subscription only if accessing own transcription (not class) 2025 if ( 2026 isOwner && 2027 !transcription.class_id && 2028 !isAdmin && 2029 !hasActiveSubscription(user.id) 2030 ) { 2031 throw AuthErrors.subscriptionRequired(); 2032 } 2033 2034 // For pending recordings, audio file exists even though transcription isn't complete 2035 // Allow audio access for pending and completed statuses 2036 if ( 2037 transcription.status !== "completed" && 2038 transcription.status !== "pending" 2039 ) { 2040 return Response.json( 2041 { error: "Audio not available yet" }, 2042 { status: 400 }, 2043 ); 2044 } 2045 2046 // Serve the audio file with range request support 2047 const filePath = `./uploads/${transcription.filename}`; 2048 const file = Bun.file(filePath); 2049 2050 if (!(await file.exists())) { 2051 return Response.json( 2052 { error: "Audio file not found" }, 2053 { status: 404 }, 2054 ); 2055 } 2056 2057 const fileSize = file.size; 2058 const range = req.headers.get("range"); 2059 2060 // Handle range requests for seeking 2061 if (range) { 2062 const parts = range.replace(/bytes=/, "").split("-"); 2063 const start = Number.parseInt(parts[0] || "0", 10); 2064 const end = parts[1] ? Number.parseInt(parts[1], 10) : fileSize - 1; 2065 const chunkSize = end - start + 1; 2066 2067 const fileSlice = file.slice(start, end + 1); 2068 2069 return new Response(fileSlice, { 2070 status: 206, 2071 headers: { 2072 "Content-Range": `bytes ${start}-${end}/${fileSize}`, 2073 "Accept-Ranges": "bytes", 2074 "Content-Length": chunkSize.toString(), 2075 "Content-Type": file.type || "audio/mpeg", 2076 }, 2077 }); 2078 } 2079 2080 // No range request, send entire file 2081 return new Response(file, { 2082 headers: { 2083 "Content-Type": file.type || "audio/mpeg", 2084 "Accept-Ranges": "bytes", 2085 "Content-Length": fileSize.toString(), 2086 }, 2087 }); 2088 } catch (error) { 2089 return handleError(error); 2090 } 2091 }, 2092 }, 2093 "/api/transcriptions": { 2094 GET: async (req) => { 2095 try { 2096 const user = requireSubscription(req); 2097 const url = new URL(req.url); 2098 2099 // Parse pagination params 2100 const limit = Math.min( 2101 Number.parseInt(url.searchParams.get("limit") || "50", 10), 2102 100, 2103 ); 2104 const cursorParam = url.searchParams.get("cursor"); 2105 2106 let transcriptions: Array<{ 2107 id: string; 2108 filename: string; 2109 original_filename: string; 2110 class_id: string | null; 2111 status: string; 2112 progress: number; 2113 created_at: number; 2114 }>; 2115 2116 if (cursorParam) { 2117 // Decode cursor 2118 const { decodeCursor } = await import("./lib/cursor"); 2119 const parts = decodeCursor(cursorParam); 2120 2121 if (parts.length !== 2) { 2122 return Response.json( 2123 { error: "Invalid cursor format" }, 2124 { status: 400 }, 2125 ); 2126 } 2127 2128 const cursorTime = Number.parseInt(parts[0] || "", 10); 2129 const id = parts[1] || ""; 2130 2131 if (Number.isNaN(cursorTime) || !id) { 2132 return Response.json( 2133 { error: "Invalid cursor format" }, 2134 { status: 400 }, 2135 ); 2136 } 2137 2138 transcriptions = db 2139 .query< 2140 { 2141 id: string; 2142 filename: string; 2143 original_filename: string; 2144 class_id: string | null; 2145 status: string; 2146 progress: number; 2147 created_at: number; 2148 }, 2149 [number, number, string, number] 2150 >( 2151 `SELECT id, filename, original_filename, class_id, status, progress, created_at 2152 FROM transcriptions 2153 WHERE user_id = ? AND (created_at < ? OR (created_at = ? AND id < ?)) 2154 ORDER BY created_at DESC, id DESC 2155 LIMIT ?`, 2156 ) 2157 .all(user.id, cursorTime, cursorTime, id, limit + 1); 2158 } else { 2159 transcriptions = db 2160 .query< 2161 { 2162 id: string; 2163 filename: string; 2164 original_filename: string; 2165 class_id: string | null; 2166 status: string; 2167 progress: number; 2168 created_at: number; 2169 }, 2170 [number, number] 2171 >( 2172 `SELECT id, filename, original_filename, class_id, status, progress, created_at 2173 FROM transcriptions 2174 WHERE user_id = ? 2175 ORDER BY created_at DESC, id DESC 2176 LIMIT ?`, 2177 ) 2178 .all(user.id, limit + 1); 2179 } 2180 2181 // Check if there are more results 2182 const hasMore = transcriptions.length > limit; 2183 if (hasMore) { 2184 transcriptions.pop(); // Remove extra item 2185 } 2186 2187 // Build next cursor 2188 let nextCursor: string | null = null; 2189 if (hasMore && transcriptions.length > 0) { 2190 const { encodeCursor } = await import("./lib/cursor"); 2191 const last = transcriptions[transcriptions.length - 1]; 2192 if (last) { 2193 nextCursor = encodeCursor([ 2194 last.created_at.toString(), 2195 last.id, 2196 ]); 2197 } 2198 } 2199 2200 // Load transcripts from files for completed jobs 2201 const jobs = await Promise.all( 2202 transcriptions.map(async (t) => { 2203 return { 2204 id: t.id, 2205 filename: t.original_filename, 2206 class_id: t.class_id, 2207 status: t.status, 2208 progress: t.progress, 2209 created_at: t.created_at, 2210 }; 2211 }), 2212 ); 2213 2214 return Response.json({ 2215 jobs, 2216 pagination: { 2217 limit, 2218 hasMore, 2219 nextCursor, 2220 }, 2221 }); 2222 } catch (error) { 2223 return handleError(error); 2224 } 2225 }, 2226 POST: async (req) => { 2227 try { 2228 const user = requireSubscription(req); 2229 2230 const rateLimitError = enforceRateLimit(req, "upload-transcription", { 2231 ip: { max: 20, windowSeconds: 60 * 60 }, 2232 }); 2233 if (rateLimitError) return rateLimitError; 2234 2235 const formData = await req.formData(); 2236 const file = formData.get("audio") as File; 2237 const classId = formData.get("class_id") as string | null; 2238 const meetingTimeId = formData.get("meeting_time_id") as 2239 | string 2240 | null; 2241 2242 if (!file) throw ValidationErrors.missingField("audio"); 2243 2244 // If class_id provided, verify user is enrolled (or admin) 2245 if (classId) { 2246 const enrolled = isUserEnrolledInClass(user.id, classId); 2247 if (!enrolled && user.role !== "admin") { 2248 return Response.json( 2249 { error: "Not enrolled in this class" }, 2250 { status: 403 }, 2251 ); 2252 } 2253 2254 // Verify class exists 2255 const classInfo = getClassById(classId); 2256 if (!classInfo) { 2257 return Response.json( 2258 { error: "Class not found" }, 2259 { status: 404 }, 2260 ); 2261 } 2262 2263 // Check if class is archived 2264 if (classInfo.archived) { 2265 return Response.json( 2266 { error: "Cannot upload to archived class" }, 2267 { status: 400 }, 2268 ); 2269 } 2270 } 2271 2272 // Validate file type 2273 const fileExtension = file.name.split(".").pop()?.toLowerCase(); 2274 const allowedExtensions = [ 2275 "mp3", 2276 "wav", 2277 "m4a", 2278 "aac", 2279 "ogg", 2280 "webm", 2281 "flac", 2282 "mp4", 2283 ]; 2284 const isAudioType = 2285 file.type.startsWith("audio/") || file.type === "video/mp4"; 2286 const isAudioExtension = 2287 fileExtension && allowedExtensions.includes(fileExtension); 2288 2289 if (!isAudioType && !isAudioExtension) { 2290 throw ValidationErrors.unsupportedFileType( 2291 "MP3, WAV, M4A, AAC, OGG, WebM, FLAC", 2292 ); 2293 } 2294 2295 if (file.size > MAX_FILE_SIZE) { 2296 throw ValidationErrors.fileTooLarge("100MB"); 2297 } 2298 2299 // Generate unique filename 2300 const transcriptionId = crypto.randomUUID(); 2301 const filename = `${transcriptionId}.${fileExtension}`; 2302 2303 // Save file to disk 2304 const uploadDir = "./uploads"; 2305 await Bun.write(`${uploadDir}/${filename}`, file); 2306 2307 // Create database record 2308 db.run( 2309 "INSERT INTO transcriptions (id, user_id, class_id, meeting_time_id, filename, original_filename, status) VALUES (?, ?, ?, ?, ?, ?, ?)", 2310 [ 2311 transcriptionId, 2312 user.id, 2313 classId, 2314 meetingTimeId, 2315 filename, 2316 file.name, 2317 "pending", 2318 ], 2319 ); 2320 2321 // Don't auto-start transcription - admin will select recordings 2322 // whisperService.startTranscription(transcriptionId, filename); 2323 2324 return Response.json({ 2325 id: transcriptionId, 2326 message: "Upload successful", 2327 }); 2328 } catch (error) { 2329 return handleError(error); 2330 } 2331 }, 2332 }, 2333 "/api/admin/transcriptions": { 2334 GET: async (req) => { 2335 try { 2336 requireAdmin(req); 2337 const url = new URL(req.url); 2338 2339 const limit = Math.min( 2340 Number.parseInt(url.searchParams.get("limit") || "50", 10), 2341 100, 2342 ); 2343 const cursor = url.searchParams.get("cursor") || undefined; 2344 2345 const result = getAllTranscriptions(limit, cursor); 2346 return Response.json(result.data); // Return just the array for now, can add pagination UI later 2347 } catch (error) { 2348 return handleError(error); 2349 } 2350 }, 2351 }, 2352 "/api/admin/users": { 2353 GET: async (req) => { 2354 try { 2355 requireAdmin(req); 2356 const url = new URL(req.url); 2357 2358 const limit = Math.min( 2359 Number.parseInt(url.searchParams.get("limit") || "50", 10), 2360 100, 2361 ); 2362 const cursor = url.searchParams.get("cursor") || undefined; 2363 2364 const result = getAllUsersWithStats(limit, cursor); 2365 return Response.json(result.data); // Return just the array for now, can add pagination UI later 2366 } catch (error) { 2367 return handleError(error); 2368 } 2369 }, 2370 }, 2371 "/api/admin/classes": { 2372 GET: async (req) => { 2373 try { 2374 requireAdmin(req); 2375 const classes = getClassesForUser(0, true); // Admin sees all classes 2376 return Response.json({ classes }); 2377 } catch (error) { 2378 return handleError(error); 2379 } 2380 }, 2381 }, 2382 "/api/admin/waitlist": { 2383 GET: async (req) => { 2384 try { 2385 requireAdmin(req); 2386 const waitlist = getAllWaitlistEntries(); 2387 return Response.json({ waitlist }); 2388 } catch (error) { 2389 return handleError(error); 2390 } 2391 }, 2392 }, 2393 "/api/admin/waitlist/:id": { 2394 DELETE: async (req) => { 2395 try { 2396 requireAdmin(req); 2397 const id = req.params.id; 2398 deleteWaitlistEntry(id); 2399 return Response.json({ success: true }); 2400 } catch (error) { 2401 return handleError(error); 2402 } 2403 }, 2404 }, 2405 "/api/admin/transcriptions/:id": { 2406 DELETE: async (req) => { 2407 try { 2408 requireAdmin(req); 2409 const transcriptionId = req.params.id; 2410 deleteTranscription(transcriptionId); 2411 return Response.json({ success: true }); 2412 } catch (error) { 2413 return handleError(error); 2414 } 2415 }, 2416 }, 2417 "/api/admin/users/:id": { 2418 DELETE: async (req) => { 2419 try { 2420 requireAdmin(req); 2421 const userId = Number.parseInt(req.params.id, 10); 2422 if (Number.isNaN(userId)) { 2423 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2424 } 2425 await deleteUser(userId); 2426 return Response.json({ success: true }); 2427 } catch (error) { 2428 return handleError(error); 2429 } 2430 }, 2431 }, 2432 "/api/admin/users/:id/role": { 2433 PUT: async (req) => { 2434 try { 2435 requireAdmin(req); 2436 const userId = Number.parseInt(req.params.id, 10); 2437 if (Number.isNaN(userId)) { 2438 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2439 } 2440 2441 const body = await req.json(); 2442 const { role } = body as { role: UserRole }; 2443 2444 if (!role || (role !== "user" && role !== "admin")) { 2445 return Response.json( 2446 { error: "Invalid role. Must be 'user' or 'admin'" }, 2447 { status: 400 }, 2448 ); 2449 } 2450 2451 updateUserRole(userId, role); 2452 return Response.json({ success: true }); 2453 } catch (error) { 2454 return handleError(error); 2455 } 2456 }, 2457 }, 2458 "/api/admin/users/:id/subscription": { 2459 DELETE: async (req) => { 2460 try { 2461 requireAdmin(req); 2462 const userId = Number.parseInt(req.params.id, 10); 2463 if (Number.isNaN(userId)) { 2464 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2465 } 2466 2467 const body = await req.json(); 2468 const { subscriptionId } = body as { subscriptionId: string }; 2469 2470 if (!subscriptionId) { 2471 return Response.json( 2472 { error: "Subscription ID required" }, 2473 { status: 400 }, 2474 ); 2475 } 2476 2477 try { 2478 const { polar } = await import("./lib/polar"); 2479 await polar.subscriptions.revoke({ id: subscriptionId }); 2480 return Response.json({ 2481 success: true, 2482 message: "Subscription revoked successfully", 2483 }); 2484 } catch (error) { 2485 console.error( 2486 `[Admin] Failed to revoke subscription ${subscriptionId}:`, 2487 error, 2488 ); 2489 return Response.json( 2490 { 2491 error: 2492 error instanceof Error 2493 ? error.message 2494 : "Failed to revoke subscription", 2495 }, 2496 { status: 500 }, 2497 ); 2498 } 2499 } catch (error) { 2500 return handleError(error); 2501 } 2502 }, 2503 PUT: async (req) => { 2504 try { 2505 requireAdmin(req); 2506 const userId = Number.parseInt(req.params.id, 10); 2507 if (Number.isNaN(userId)) { 2508 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2509 } 2510 2511 // Get user email 2512 const user = db 2513 .query<{ email: string }, [number]>( 2514 "SELECT email FROM users WHERE id = ?", 2515 ) 2516 .get(userId); 2517 2518 if (!user) { 2519 return Response.json({ error: "User not found" }, { status: 404 }); 2520 } 2521 2522 try { 2523 await syncUserSubscriptionsFromPolar(userId, user.email); 2524 return Response.json({ 2525 success: true, 2526 message: "Subscription synced successfully", 2527 }); 2528 } catch (error) { 2529 console.error( 2530 `[Admin] Failed to sync subscription for user ${userId}:`, 2531 error, 2532 ); 2533 return Response.json( 2534 { 2535 error: 2536 error instanceof Error 2537 ? error.message 2538 : "Failed to sync subscription", 2539 }, 2540 { status: 500 }, 2541 ); 2542 } 2543 } catch (error) { 2544 return handleError(error); 2545 } 2546 }, 2547 }, 2548 "/api/admin/users/:id/details": { 2549 GET: async (req) => { 2550 try { 2551 requireAdmin(req); 2552 const userId = Number.parseInt(req.params.id, 10); 2553 if (Number.isNaN(userId)) { 2554 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2555 } 2556 2557 const user = db 2558 .query< 2559 { 2560 id: number; 2561 email: string; 2562 name: string | null; 2563 avatar: string; 2564 created_at: number; 2565 role: UserRole; 2566 password_hash: string | null; 2567 last_login: number | null; 2568 }, 2569 [number] 2570 >( 2571 "SELECT id, email, name, avatar, created_at, role, password_hash, last_login FROM users WHERE id = ?", 2572 ) 2573 .get(userId); 2574 2575 if (!user) { 2576 return Response.json({ error: "User not found" }, { status: 404 }); 2577 } 2578 2579 const passkeys = getPasskeysForUser(userId); 2580 const sessions = getSessionsForUser(userId); 2581 2582 // Get transcription count 2583 const transcriptionCount = 2584 db 2585 .query<{ count: number }, [number]>( 2586 "SELECT COUNT(*) as count FROM transcriptions WHERE user_id = ?", 2587 ) 2588 .get(userId)?.count ?? 0; 2589 2590 return Response.json({ 2591 id: user.id, 2592 email: user.email, 2593 name: user.name, 2594 avatar: user.avatar, 2595 created_at: user.created_at, 2596 role: user.role, 2597 last_login: user.last_login, 2598 hasPassword: !!user.password_hash, 2599 transcriptionCount, 2600 passkeys: passkeys.map((pk) => ({ 2601 id: pk.id, 2602 name: pk.name, 2603 created_at: pk.created_at, 2604 last_used_at: pk.last_used_at, 2605 })), 2606 sessions: sessions.map((s) => ({ 2607 id: s.id, 2608 ip_address: s.ip_address, 2609 user_agent: s.user_agent, 2610 created_at: s.created_at, 2611 expires_at: s.expires_at, 2612 })), 2613 }); 2614 } catch (error) { 2615 return handleError(error); 2616 } 2617 }, 2618 }, 2619 "/api/admin/users/:id/password-reset": { 2620 POST: async (req) => { 2621 try { 2622 requireAdmin(req); 2623 const userId = Number.parseInt(req.params.id, 10); 2624 if (Number.isNaN(userId)) { 2625 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2626 } 2627 2628 // Get user details 2629 const user = db 2630 .query< 2631 { id: number; email: string; name: string | null }, 2632 [number] 2633 >("SELECT id, email, name FROM users WHERE id = ?") 2634 .get(userId); 2635 2636 if (!user) { 2637 return Response.json({ error: "User not found" }, { status: 404 }); 2638 } 2639 2640 // Create password reset token 2641 const origin = process.env.ORIGIN || "http://localhost:3000"; 2642 const resetToken = createPasswordResetToken(user.id); 2643 const resetLink = `${origin}/reset-password?token=${resetToken}`; 2644 2645 // Send password reset email 2646 await sendEmail({ 2647 to: user.email, 2648 subject: "Reset your password - Thistle", 2649 html: passwordResetTemplate({ 2650 name: user.name, 2651 resetLink, 2652 }), 2653 }); 2654 2655 return Response.json({ 2656 success: true, 2657 message: "Password reset email sent", 2658 }); 2659 } catch (error) { 2660 console.error("[Admin] Password reset error:", error); 2661 return handleError(error); 2662 } 2663 }, 2664 }, 2665 "/api/admin/users/:id/passkeys/:passkeyId": { 2666 DELETE: async (req) => { 2667 try { 2668 requireAdmin(req); 2669 const userId = Number.parseInt(req.params.id, 10); 2670 if (Number.isNaN(userId)) { 2671 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2672 } 2673 2674 const { passkeyId } = req.params; 2675 deletePasskey(passkeyId, userId); 2676 return Response.json({ success: true }); 2677 } catch (error) { 2678 return handleError(error); 2679 } 2680 }, 2681 }, 2682 "/api/admin/users/:id/name": { 2683 PUT: async (req) => { 2684 try { 2685 requireAdmin(req); 2686 const userId = Number.parseInt(req.params.id, 10); 2687 if (Number.isNaN(userId)) { 2688 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2689 } 2690 2691 const body = await req.json(); 2692 const { name } = body as { name: string }; 2693 2694 const nameValidation = validateName(name); 2695 if (!nameValidation.valid) { 2696 return Response.json( 2697 { error: nameValidation.error }, 2698 { status: 400 }, 2699 ); 2700 } 2701 2702 updateUserName(userId, name.trim()); 2703 return Response.json({ success: true }); 2704 } catch (error) { 2705 return handleError(error); 2706 } 2707 }, 2708 }, 2709 "/api/admin/users/:id/email": { 2710 PUT: async (req) => { 2711 try { 2712 requireAdmin(req); 2713 const userId = Number.parseInt(req.params.id, 10); 2714 if (Number.isNaN(userId)) { 2715 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2716 } 2717 2718 const body = await req.json(); 2719 const { email, skipVerification } = body as { 2720 email: string; 2721 skipVerification?: boolean; 2722 }; 2723 2724 const emailValidation = validateEmail(email); 2725 if (!emailValidation.valid) { 2726 return Response.json( 2727 { error: emailValidation.error }, 2728 { status: 400 }, 2729 ); 2730 } 2731 2732 // Check if email already exists 2733 const existing = db 2734 .query<{ id: number }, [string, number]>( 2735 "SELECT id FROM users WHERE email = ? AND id != ?", 2736 ) 2737 .get(email, userId); 2738 2739 if (existing) { 2740 return Response.json( 2741 { error: "Email already in use" }, 2742 { status: 400 }, 2743 ); 2744 } 2745 2746 if (skipVerification) { 2747 // Admin override: change email immediately without verification 2748 updateUserEmailAddress(userId, email); 2749 return Response.json({ 2750 success: true, 2751 message: "Email updated immediately (verification skipped)", 2752 }); 2753 } 2754 2755 // Get user's current email 2756 const user = db 2757 .query<{ email: string; name: string | null }, [number]>( 2758 "SELECT email, name FROM users WHERE id = ?", 2759 ) 2760 .get(userId); 2761 2762 if (!user) { 2763 return Response.json({ error: "User not found" }, { status: 404 }); 2764 } 2765 2766 // Send verification email to user's current email 2767 try { 2768 const token = createEmailChangeToken(userId, email); 2769 const origin = process.env.ORIGIN || "http://localhost:3000"; 2770 const verifyUrl = `${origin}/api/user/email/verify?token=${token}`; 2771 2772 await sendEmail({ 2773 to: user.email, 2774 subject: "Verify your email change", 2775 html: emailChangeTemplate({ 2776 name: user.name, 2777 currentEmail: user.email, 2778 newEmail: email, 2779 verifyLink: verifyUrl, 2780 }), 2781 }); 2782 2783 return Response.json({ 2784 success: true, 2785 message: `Verification email sent to ${user.email}`, 2786 pendingEmail: email, 2787 }); 2788 } catch (emailError) { 2789 console.error( 2790 "[Admin] Failed to send email change verification:", 2791 emailError, 2792 ); 2793 return Response.json( 2794 { error: "Failed to send verification email" }, 2795 { status: 500 }, 2796 ); 2797 } 2798 } catch (error) { 2799 return handleError(error); 2800 } 2801 }, 2802 }, 2803 "/api/admin/users/:id/sessions": { 2804 GET: async (req) => { 2805 try { 2806 requireAdmin(req); 2807 const userId = Number.parseInt(req.params.id, 10); 2808 if (Number.isNaN(userId)) { 2809 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2810 } 2811 2812 const sessions = getSessionsForUser(userId); 2813 return Response.json(sessions); 2814 } catch (error) { 2815 return handleError(error); 2816 } 2817 }, 2818 DELETE: async (req) => { 2819 try { 2820 requireAdmin(req); 2821 const userId = Number.parseInt(req.params.id, 10); 2822 if (Number.isNaN(userId)) { 2823 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2824 } 2825 2826 deleteAllUserSessions(userId); 2827 return Response.json({ success: true }); 2828 } catch (error) { 2829 return handleError(error); 2830 } 2831 }, 2832 }, 2833 "/api/admin/users/:id/sessions/:sessionId": { 2834 DELETE: async (req) => { 2835 try { 2836 requireAdmin(req); 2837 const userId = Number.parseInt(req.params.id, 10); 2838 if (Number.isNaN(userId)) { 2839 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 2840 } 2841 2842 const { sessionId } = req.params; 2843 const success = deleteSessionById(sessionId, userId); 2844 2845 if (!success) { 2846 return Response.json( 2847 { error: "Session not found" }, 2848 { status: 404 }, 2849 ); 2850 } 2851 2852 return Response.json({ success: true }); 2853 } catch (error) { 2854 return handleError(error); 2855 } 2856 }, 2857 }, 2858 "/api/admin/transcriptions/:id/details": { 2859 GET: async (req) => { 2860 try { 2861 requireAdmin(req); 2862 const transcriptionId = req.params.id; 2863 2864 const transcription = db 2865 .query< 2866 { 2867 id: string; 2868 original_filename: string; 2869 status: string; 2870 created_at: number; 2871 updated_at: number; 2872 error_message: string | null; 2873 user_id: number; 2874 }, 2875 [string] 2876 >( 2877 "SELECT id, original_filename, status, created_at, updated_at, error_message, user_id FROM transcriptions WHERE id = ?", 2878 ) 2879 .get(transcriptionId); 2880 2881 if (!transcription) { 2882 return Response.json( 2883 { error: "Transcription not found" }, 2884 { status: 404 }, 2885 ); 2886 } 2887 2888 const user = db 2889 .query<{ email: string; name: string | null }, [number]>( 2890 "SELECT email, name FROM users WHERE id = ?", 2891 ) 2892 .get(transcription.user_id); 2893 2894 return Response.json({ 2895 id: transcription.id, 2896 original_filename: transcription.original_filename, 2897 status: transcription.status, 2898 created_at: transcription.created_at, 2899 completed_at: transcription.updated_at, 2900 error_message: transcription.error_message, 2901 user_id: transcription.user_id, 2902 user_email: user?.email || "Unknown", 2903 user_name: user?.name || null, 2904 }); 2905 } catch (error) { 2906 return handleError(error); 2907 } 2908 }, 2909 }, 2910 "/api/classes": { 2911 GET: async (req) => { 2912 try { 2913 const user = requireAuth(req); 2914 const url = new URL(req.url); 2915 2916 const limit = Math.min( 2917 Number.parseInt(url.searchParams.get("limit") || "50", 10), 2918 100, 2919 ); 2920 const cursor = url.searchParams.get("cursor") || undefined; 2921 2922 const result = getClassesForUser( 2923 user.id, 2924 user.role === "admin", 2925 limit, 2926 cursor, 2927 ); 2928 2929 // For admin, return flat array. For users, group by semester/year 2930 if (user.role === "admin") { 2931 return Response.json(result.data); 2932 } 2933 2934 // Group by semester/year for regular users 2935 const grouped: Record< 2936 string, 2937 Array<{ 2938 id: string; 2939 course_code: string; 2940 name: string; 2941 professor: string; 2942 semester: string; 2943 year: number; 2944 archived: boolean; 2945 }> 2946 > = {}; 2947 2948 for (const cls of result.data) { 2949 const key = `${cls.semester} ${cls.year}`; 2950 if (!grouped[key]) { 2951 grouped[key] = []; 2952 } 2953 grouped[key]?.push({ 2954 id: cls.id, 2955 course_code: cls.course_code, 2956 name: cls.name, 2957 professor: cls.professor, 2958 semester: cls.semester, 2959 year: cls.year, 2960 archived: cls.archived, 2961 }); 2962 } 2963 2964 return Response.json({ 2965 classes: grouped, 2966 pagination: result.pagination, 2967 }); 2968 } catch (error) { 2969 return handleError(error); 2970 } 2971 }, 2972 POST: async (req) => { 2973 try { 2974 requireAdmin(req); 2975 const body = await req.json(); 2976 const { 2977 course_code, 2978 name, 2979 professor, 2980 semester, 2981 year, 2982 meeting_times, 2983 } = body; 2984 2985 // Validate all required fields 2986 const courseCodeValidation = validateCourseCode(course_code); 2987 if (!courseCodeValidation.valid) { 2988 return Response.json( 2989 { error: courseCodeValidation.error }, 2990 { status: 400 }, 2991 ); 2992 } 2993 2994 const nameValidation = validateCourseName(name); 2995 if (!nameValidation.valid) { 2996 return Response.json( 2997 { error: nameValidation.error }, 2998 { status: 400 }, 2999 ); 3000 } 3001 3002 const professorValidation = validateName(professor, "Professor name"); 3003 if (!professorValidation.valid) { 3004 return Response.json( 3005 { error: professorValidation.error }, 3006 { status: 400 }, 3007 ); 3008 } 3009 3010 const semesterValidation = validateSemester(semester); 3011 if (!semesterValidation.valid) { 3012 return Response.json( 3013 { error: semesterValidation.error }, 3014 { status: 400 }, 3015 ); 3016 } 3017 3018 const yearValidation = validateYear(year); 3019 if (!yearValidation.valid) { 3020 return Response.json( 3021 { error: yearValidation.error }, 3022 { status: 400 }, 3023 ); 3024 } 3025 3026 const newClass = createClass({ 3027 course_code, 3028 name, 3029 professor, 3030 semester, 3031 year, 3032 meeting_times, 3033 }); 3034 3035 return Response.json(newClass); 3036 } catch (error) { 3037 return handleError(error); 3038 } 3039 }, 3040 }, 3041 "/api/classes/search": { 3042 GET: async (req) => { 3043 try { 3044 const user = requireAuth(req); 3045 const url = new URL(req.url); 3046 const query = url.searchParams.get("q"); 3047 3048 if (!query) { 3049 return Response.json({ classes: [] }); 3050 } 3051 3052 const classes = searchClassesByCourseCode(query); 3053 3054 // Get user's enrolled classes to mark them 3055 const enrolledClassIds = db 3056 .query<{ class_id: string }, [number]>( 3057 "SELECT class_id FROM class_members WHERE user_id = ?", 3058 ) 3059 .all(user.id) 3060 .map((row) => row.class_id); 3061 3062 // Add is_enrolled flag to each class 3063 const classesWithEnrollment = classes.map((cls) => ({ 3064 ...cls, 3065 is_enrolled: enrolledClassIds.includes(cls.id), 3066 })); 3067 3068 return Response.json({ classes: classesWithEnrollment }); 3069 } catch (error) { 3070 return handleError(error); 3071 } 3072 }, 3073 }, 3074 "/api/classes/join": { 3075 POST: async (req) => { 3076 try { 3077 const user = requireAuth(req); 3078 const body = await req.json(); 3079 const classId = body.class_id; 3080 3081 const classIdValidation = validateClassId(classId); 3082 if (!classIdValidation.valid) { 3083 return Response.json( 3084 { error: classIdValidation.error }, 3085 { status: 400 }, 3086 ); 3087 } 3088 3089 const result = joinClass(classId, user.id); 3090 3091 if (!result.success) { 3092 return Response.json({ error: result.error }, { status: 400 }); 3093 } 3094 3095 return Response.json({ success: true }); 3096 } catch (error) { 3097 return handleError(error); 3098 } 3099 }, 3100 }, 3101 "/api/classes/waitlist": { 3102 POST: async (req) => { 3103 try { 3104 const user = requireAuth(req); 3105 const body = await req.json(); 3106 3107 const { 3108 courseCode, 3109 courseName, 3110 professor, 3111 semester, 3112 year, 3113 additionalInfo, 3114 meetingTimes, 3115 } = body; 3116 3117 // Validate all required fields 3118 const courseCodeValidation = validateCourseCode(courseCode); 3119 if (!courseCodeValidation.valid) { 3120 return Response.json( 3121 { error: courseCodeValidation.error }, 3122 { status: 400 }, 3123 ); 3124 } 3125 3126 const nameValidation = validateCourseName(courseName); 3127 if (!nameValidation.valid) { 3128 return Response.json( 3129 { error: nameValidation.error }, 3130 { status: 400 }, 3131 ); 3132 } 3133 3134 const professorValidation = validateName(professor, "Professor name"); 3135 if (!professorValidation.valid) { 3136 return Response.json( 3137 { error: professorValidation.error }, 3138 { status: 400 }, 3139 ); 3140 } 3141 3142 const semesterValidation = validateSemester(semester); 3143 if (!semesterValidation.valid) { 3144 return Response.json( 3145 { error: semesterValidation.error }, 3146 { status: 400 }, 3147 ); 3148 } 3149 3150 const yearValidation = validateYear( 3151 typeof year === "string" ? Number.parseInt(year, 10) : year, 3152 ); 3153 if (!yearValidation.valid) { 3154 return Response.json( 3155 { error: yearValidation.error }, 3156 { status: 400 }, 3157 ); 3158 } 3159 3160 const id = addToWaitlist( 3161 user.id, 3162 courseCode, 3163 courseName, 3164 professor, 3165 semester, 3166 Number.parseInt(year, 10), 3167 additionalInfo || null, 3168 meetingTimes || null, 3169 ); 3170 3171 return Response.json({ success: true, id }); 3172 } catch (error) { 3173 return handleError(error); 3174 } 3175 }, 3176 }, 3177 "/api/classes/:id": { 3178 GET: async (req) => { 3179 try { 3180 const user = requireAuth(req); 3181 const classId = req.params.id; 3182 3183 const classInfo = getClassById(classId); 3184 if (!classInfo) { 3185 return Response.json({ error: "Class not found" }, { status: 404 }); 3186 } 3187 3188 // Check enrollment or admin 3189 const isEnrolled = isUserEnrolledInClass(user.id, classId); 3190 if (!isEnrolled && user.role !== "admin") { 3191 return Response.json( 3192 { error: "Not enrolled in this class" }, 3193 { status: 403 }, 3194 ); 3195 } 3196 3197 const meetingTimes = getMeetingTimesForClass(classId); 3198 const transcriptions = getTranscriptionsForClass(classId); 3199 3200 return Response.json({ 3201 class: classInfo, 3202 meetingTimes, 3203 transcriptions, 3204 }); 3205 } catch (error) { 3206 return handleError(error); 3207 } 3208 }, 3209 DELETE: async (req) => { 3210 try { 3211 requireAdmin(req); 3212 const classId = req.params.id; 3213 3214 deleteClass(classId); 3215 return Response.json({ success: true }); 3216 } catch (error) { 3217 return handleError(error); 3218 } 3219 }, 3220 }, 3221 "/api/classes/:id/archive": { 3222 PUT: async (req) => { 3223 try { 3224 requireAdmin(req); 3225 const classId = req.params.id; 3226 const body = await req.json(); 3227 const { archived } = body; 3228 3229 if (typeof archived !== "boolean") { 3230 return Response.json( 3231 { error: "archived must be a boolean" }, 3232 { status: 400 }, 3233 ); 3234 } 3235 3236 toggleClassArchive(classId, archived); 3237 return Response.json({ success: true }); 3238 } catch (error) { 3239 return handleError(error); 3240 } 3241 }, 3242 }, 3243 "/api/classes/:id/members": { 3244 GET: async (req) => { 3245 try { 3246 requireAdmin(req); 3247 const classId = req.params.id; 3248 3249 const members = getClassMembers(classId); 3250 return Response.json({ members }); 3251 } catch (error) { 3252 return handleError(error); 3253 } 3254 }, 3255 POST: async (req) => { 3256 try { 3257 requireAdmin(req); 3258 const classId = req.params.id; 3259 const body = await req.json(); 3260 const { email } = body; 3261 3262 if (!email) { 3263 return Response.json({ error: "Email required" }, { status: 400 }); 3264 } 3265 3266 const user = getUserByEmail(email); 3267 if (!user) { 3268 return Response.json({ error: "User not found" }, { status: 404 }); 3269 } 3270 3271 enrollUserInClass(user.id, classId); 3272 return Response.json({ success: true }); 3273 } catch (error) { 3274 return handleError(error); 3275 } 3276 }, 3277 }, 3278 "/api/classes/:id/members/:userId": { 3279 DELETE: async (req) => { 3280 try { 3281 requireAdmin(req); 3282 const classId = req.params.id; 3283 const userId = Number.parseInt(req.params.userId, 10); 3284 3285 if (Number.isNaN(userId)) { 3286 return Response.json({ error: "Invalid user ID" }, { status: 400 }); 3287 } 3288 3289 removeUserFromClass(userId, classId); 3290 return Response.json({ success: true }); 3291 } catch (error) { 3292 return handleError(error); 3293 } 3294 }, 3295 }, 3296 "/api/classes/:id/meetings": { 3297 GET: async (req) => { 3298 try { 3299 const user = requireAuth(req); 3300 const classId = req.params.id; 3301 3302 // Check enrollment or admin 3303 const isEnrolled = isUserEnrolledInClass(user.id, classId); 3304 if (!isEnrolled && user.role !== "admin") { 3305 return Response.json( 3306 { error: "Not enrolled in this class" }, 3307 { status: 403 }, 3308 ); 3309 } 3310 3311 const meetingTimes = getMeetingTimesForClass(classId); 3312 return Response.json({ meetings: meetingTimes }); 3313 } catch (error) { 3314 return handleError(error); 3315 } 3316 }, 3317 POST: async (req) => { 3318 try { 3319 requireAdmin(req); 3320 const classId = req.params.id; 3321 const body = await req.json(); 3322 const { label } = body; 3323 3324 if (!label) { 3325 return Response.json({ error: "Label required" }, { status: 400 }); 3326 } 3327 3328 const meetingTime = createMeetingTime(classId, label); 3329 return Response.json(meetingTime); 3330 } catch (error) { 3331 return handleError(error); 3332 } 3333 }, 3334 }, 3335 "/api/meetings/:id": { 3336 PUT: async (req) => { 3337 try { 3338 requireAdmin(req); 3339 const meetingId = req.params.id; 3340 const body = await req.json(); 3341 const { label } = body; 3342 3343 if (!label) { 3344 return Response.json({ error: "Label required" }, { status: 400 }); 3345 } 3346 3347 updateMeetingTime(meetingId, label); 3348 return Response.json({ success: true }); 3349 } catch (error) { 3350 return handleError(error); 3351 } 3352 }, 3353 DELETE: async (req) => { 3354 try { 3355 requireAdmin(req); 3356 const meetingId = req.params.id; 3357 3358 deleteMeetingTime(meetingId); 3359 return Response.json({ success: true }); 3360 } catch (error) { 3361 return handleError(error); 3362 } 3363 }, 3364 }, 3365 "/api/transcripts/:id/select": { 3366 PUT: async (req) => { 3367 try { 3368 requireAdmin(req); 3369 const transcriptId = req.params.id; 3370 3371 // Check if transcription exists and get its current status 3372 const transcription = db 3373 .query<{ filename: string; status: string }, [string]>( 3374 "SELECT filename, status FROM transcriptions WHERE id = ?", 3375 ) 3376 .get(transcriptId); 3377 3378 if (!transcription) { 3379 return Response.json( 3380 { error: "Transcription not found" }, 3381 { status: 404 }, 3382 ); 3383 } 3384 3385 // Validate that status is appropriate for selection (e.g., 'uploading' or 'pending') 3386 const validStatuses = ["uploading", "pending", "failed"]; 3387 if (!validStatuses.includes(transcription.status)) { 3388 return Response.json( 3389 { 3390 error: `Cannot select transcription with status: ${transcription.status}`, 3391 }, 3392 { status: 400 }, 3393 ); 3394 } 3395 3396 // Update status to 'selected' and start transcription 3397 db.run("UPDATE transcriptions SET status = ? WHERE id = ?", [ 3398 "selected", 3399 transcriptId, 3400 ]); 3401 3402 whisperService.startTranscription( 3403 transcriptId, 3404 transcription.filename, 3405 ); 3406 3407 return Response.json({ success: true }); 3408 } catch (error) { 3409 return handleError(error); 3410 } 3411 }, 3412 }, 3413 }, 3414 development: { 3415 hmr: true, 3416 console: true, 3417 }, 3418}); 3419console.log(`馃 Thistle running at http://localhost:${server.port}`); 3420 3421// Track active SSE streams for graceful shutdown 3422const activeSSEStreams = new Set<ReadableStreamDefaultController>(); 3423 3424// Graceful shutdown handler 3425let isShuttingDown = false; 3426 3427async function shutdown(signal: string) { 3428 if (isShuttingDown) return; 3429 isShuttingDown = true; 3430 3431 console.log(`\n${signal} received, starting graceful shutdown...`); 3432 3433 // 1. Stop accepting new requests 3434 console.log("[Shutdown] Closing server..."); 3435 server.stop(); 3436 3437 // 2. Close all active SSE streams (safe to kill - sync will handle reconnection) 3438 console.log(`[Shutdown] Closing ${activeSSEStreams.size} active SSE streams...`); 3439 for (const controller of activeSSEStreams) { 3440 try { 3441 controller.close(); 3442 } catch { 3443 // Already closed 3444 } 3445 } 3446 activeSSEStreams.clear(); 3447 3448 // 3. Stop transcription service (closes streams to Murmur) 3449 whisperService.stop(); 3450 3451 // 4. Stop cleanup intervals 3452 console.log("[Shutdown] Stopping cleanup intervals..."); 3453 clearInterval(sessionCleanupInterval); 3454 clearInterval(syncInterval); 3455 clearInterval(fileCleanupInterval); 3456 3457 // 5. Close database connections 3458 console.log("[Shutdown] Closing database..."); 3459 db.close(); 3460 3461 console.log("[Shutdown] Complete"); 3462 process.exit(0); 3463} 3464 3465// Register shutdown handlers 3466process.on("SIGTERM", () => shutdown("SIGTERM")); 3467process.on("SIGINT", () => shutdown("SIGINT"));