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