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