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