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