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