馃 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
2193 if (!meetingTimeId) {
2194 return Response.json(
2195 { error: "meeting_time_id required" },
2196 { status: 400 },
2197 );
2198 }
2199
2200 // Verify transcription ownership
2201 const transcription = db
2202 .query<
2203 { id: string; user_id: number; class_id: string | null },
2204 [string]
2205 >("SELECT id, user_id, class_id FROM transcriptions WHERE id = ?")
2206 .get(transcriptionId);
2207
2208 if (!transcription) {
2209 return Response.json(
2210 { error: "Transcription not found" },
2211 { status: 404 },
2212 );
2213 }
2214
2215 if (transcription.user_id !== user.id && user.role !== "admin") {
2216 return Response.json({ error: "Forbidden" }, { status: 403 });
2217 }
2218
2219 // Verify meeting time belongs to the class
2220 if (transcription.class_id) {
2221 const meetingTime = db
2222 .query<{ id: string }, [string, string]>(
2223 "SELECT id FROM meeting_times WHERE id = ? AND class_id = ?",
2224 )
2225 .get(meetingTimeId, transcription.class_id);
2226
2227 if (!meetingTime) {
2228 return Response.json(
2229 {
2230 error:
2231 "Meeting time does not belong to the class for this transcription",
2232 },
2233 { status: 400 },
2234 );
2235 }
2236 }
2237
2238 // Update meeting time
2239 db.run(
2240 "UPDATE transcriptions SET meeting_time_id = ? WHERE id = ?",
2241 [meetingTimeId, transcriptionId],
2242 );
2243
2244 return Response.json({
2245 success: true,
2246 message: "Meeting time updated successfully",
2247 });
2248 } catch (error) {
2249 return handleError(error);
2250 }
2251 },
2252 },
2253 "/api/classes/:classId/meetings/:meetingTimeId/recordings": {
2254 GET: async (req) => {
2255 try {
2256 const user = requireAuth(req);
2257 const classId = req.params.classId;
2258 const meetingTimeId = req.params.meetingTimeId;
2259
2260 // Verify user is enrolled in the class
2261 const enrolled = isUserEnrolledInClass(user.id, classId);
2262 if (!enrolled && user.role !== "admin") {
2263 return Response.json(
2264 { error: "Not enrolled in this class" },
2265 { status: 403 },
2266 );
2267 }
2268
2269 // Get user's section for filtering (admins see all)
2270 const userSection =
2271 user.role === "admin" ? null : getUserSection(user.id, classId);
2272
2273 const recordings = getPendingRecordings(
2274 classId,
2275 meetingTimeId,
2276 userSection,
2277 );
2278 const totalUsers = getEnrolledUserCount(classId);
2279 const userVote = getUserVoteForMeeting(
2280 user.id,
2281 classId,
2282 meetingTimeId,
2283 );
2284
2285 // Check if any recording should be auto-submitted
2286 const winningId = checkAutoSubmit(
2287 classId,
2288 meetingTimeId,
2289 userSection,
2290 );
2291
2292 return Response.json({
2293 recordings,
2294 total_users: totalUsers,
2295 user_vote: userVote,
2296 vote_threshold: Math.ceil(totalUsers * 0.4),
2297 winning_recording_id: winningId,
2298 });
2299 } catch (error) {
2300 return handleError(error);
2301 }
2302 },
2303 },
2304 "/api/recordings/:id/vote": {
2305 POST: async (req) => {
2306 try {
2307 const user = requireAuth(req);
2308 const recordingId = req.params.id;
2309
2310 // Verify user is enrolled in the recording's class
2311 const recording = db
2312 .query<
2313 { class_id: string; meeting_time_id: string; status: string },
2314 [string]
2315 >(
2316 "SELECT class_id, meeting_time_id, status FROM transcriptions WHERE id = ?",
2317 )
2318 .get(recordingId);
2319
2320 if (!recording) {
2321 return Response.json(
2322 { error: "Recording not found" },
2323 { status: 404 },
2324 );
2325 }
2326
2327 if (recording.status !== "pending") {
2328 return Response.json(
2329 { error: "Can only vote on pending recordings" },
2330 { status: 400 },
2331 );
2332 }
2333
2334 const enrolled = isUserEnrolledInClass(user.id, recording.class_id);
2335 if (!enrolled && user.role !== "admin") {
2336 return Response.json(
2337 { error: "Not enrolled in this class" },
2338 { status: 403 },
2339 );
2340 }
2341
2342 // Remove existing vote for this meeting time
2343 const existingVote = getUserVoteForMeeting(
2344 user.id,
2345 recording.class_id,
2346 recording.meeting_time_id,
2347 );
2348 if (existingVote) {
2349 removeVote(existingVote, user.id);
2350 }
2351
2352 // Add new vote
2353 const success = voteForRecording(recordingId, user.id);
2354
2355 // Get user's section for auto-submit check
2356 const userSection =
2357 user.role === "admin"
2358 ? null
2359 : getUserSection(user.id, recording.class_id);
2360
2361 // Check if auto-submit threshold reached
2362 const winningId = checkAutoSubmit(
2363 recording.class_id,
2364 recording.meeting_time_id,
2365 userSection,
2366 );
2367 if (winningId) {
2368 markAsAutoSubmitted(winningId);
2369 // Start transcription
2370 const winningRecording = db
2371 .query<{ filename: string }, [string]>(
2372 "SELECT filename FROM transcriptions WHERE id = ?",
2373 )
2374 .get(winningId);
2375 if (winningRecording) {
2376 whisperService.startTranscription(
2377 winningId,
2378 winningRecording.filename,
2379 );
2380 }
2381 }
2382
2383 return Response.json({
2384 success,
2385 winning_recording_id: winningId,
2386 });
2387 } catch (error) {
2388 return handleError(error);
2389 }
2390 },
2391 },
2392 "/api/recordings/:id": {
2393 DELETE: async (req) => {
2394 try {
2395 const user = requireAuth(req);
2396 const recordingId = req.params.id;
2397
2398 const success = deletePendingRecording(
2399 recordingId,
2400 user.id,
2401 user.role === "admin",
2402 );
2403
2404 if (!success) {
2405 return Response.json(
2406 { error: "Cannot delete this recording" },
2407 { status: 403 },
2408 );
2409 }
2410
2411 return new Response(null, { status: 204 });
2412 } catch (error) {
2413 return handleError(error);
2414 }
2415 },
2416 },
2417 "/api/transcriptions": {
2418 GET: async (req) => {
2419 try {
2420 const user = requireSubscription(req);
2421 const url = new URL(req.url);
2422
2423 // Parse pagination params
2424 const limit = Math.min(
2425 Number.parseInt(url.searchParams.get("limit") || "50", 10),
2426 100,
2427 );
2428 const cursorParam = url.searchParams.get("cursor");
2429
2430 let transcriptions: Array<{
2431 id: string;
2432 filename: string;
2433 original_filename: string;
2434 class_id: string | null;
2435 status: string;
2436 progress: number;
2437 created_at: number;
2438 }>;
2439
2440 if (cursorParam) {
2441 // Decode cursor
2442 const { decodeCursor } = await import("./lib/cursor");
2443 const parts = decodeCursor(cursorParam);
2444
2445 if (parts.length !== 2) {
2446 return Response.json(
2447 { error: "Invalid cursor format" },
2448 { status: 400 },
2449 );
2450 }
2451
2452 const cursorTime = Number.parseInt(parts[0] || "", 10);
2453 const id = parts[1] || "";
2454
2455 if (Number.isNaN(cursorTime) || !id) {
2456 return Response.json(
2457 { error: "Invalid cursor format" },
2458 { status: 400 },
2459 );
2460 }
2461
2462 transcriptions = db
2463 .query<
2464 {
2465 id: string;
2466 filename: string;
2467 original_filename: string;
2468 class_id: string | null;
2469 status: string;
2470 progress: number;
2471 created_at: number;
2472 },
2473 [number, number, string, number]
2474 >(
2475 `SELECT id, filename, original_filename, class_id, status, progress, created_at
2476 FROM transcriptions
2477 WHERE user_id = ? AND (created_at < ? OR (created_at = ? AND id < ?))
2478 ORDER BY created_at DESC, id DESC
2479 LIMIT ?`,
2480 )
2481 .all(user.id, cursorTime, cursorTime, id, limit + 1);
2482 } else {
2483 transcriptions = db
2484 .query<
2485 {
2486 id: string;
2487 filename: string;
2488 original_filename: string;
2489 class_id: string | null;
2490 status: string;
2491 progress: number;
2492 created_at: number;
2493 },
2494 [number, number]
2495 >(
2496 `SELECT id, filename, original_filename, class_id, status, progress, created_at
2497 FROM transcriptions
2498 WHERE user_id = ?
2499 ORDER BY created_at DESC, id DESC
2500 LIMIT ?`,
2501 )
2502 .all(user.id, limit + 1);
2503 }
2504
2505 // Check if there are more results
2506 const hasMore = transcriptions.length > limit;
2507 if (hasMore) {
2508 transcriptions.pop(); // Remove extra item
2509 }
2510
2511 // Build next cursor
2512 let nextCursor: string | null = null;
2513 if (hasMore && transcriptions.length > 0) {
2514 const { encodeCursor } = await import("./lib/cursor");
2515 const last = transcriptions[transcriptions.length - 1];
2516 if (last) {
2517 nextCursor = encodeCursor([last.created_at.toString(), last.id]);
2518 }
2519 }
2520
2521 // Load transcripts from files for completed jobs
2522 const jobs = await Promise.all(
2523 transcriptions.map(async (t) => {
2524 return {
2525 id: t.id,
2526 filename: t.original_filename,
2527 class_id: t.class_id,
2528 status: t.status,
2529 progress: t.progress,
2530 created_at: t.created_at,
2531 };
2532 }),
2533 );
2534
2535 return Response.json({
2536 jobs,
2537 pagination: {
2538 limit,
2539 hasMore,
2540 nextCursor,
2541 },
2542 });
2543 } catch (error) {
2544 return handleError(error);
2545 }
2546 },
2547 POST: async (req) => {
2548 try {
2549 const user = requireSubscription(req);
2550
2551 const rateLimitError = enforceRateLimit(req, "upload-transcription", {
2552 ip: { max: 20, windowSeconds: 60 * 60 },
2553 });
2554 if (rateLimitError) return rateLimitError;
2555
2556 const formData = await req.formData();
2557 const file = formData.get("audio") as File;
2558 const classId = formData.get("class_id") as string | null;
2559 const sectionId = formData.get("section_id") as string | null;
2560
2561 if (!file) throw ValidationErrors.missingField("audio");
2562
2563 // If class_id provided, verify user is enrolled (or admin)
2564 if (classId) {
2565 const enrolled = isUserEnrolledInClass(user.id, classId);
2566 if (!enrolled && user.role !== "admin") {
2567 return Response.json(
2568 { error: "Not enrolled in this class" },
2569 { status: 403 },
2570 );
2571 }
2572
2573 // Verify class exists
2574 const classInfo = getClassById(classId);
2575 if (!classInfo) {
2576 return Response.json(
2577 { error: "Class not found" },
2578 { status: 404 },
2579 );
2580 }
2581
2582 // Check if class is archived
2583 if (classInfo.archived) {
2584 return Response.json(
2585 { error: "Cannot upload to archived class" },
2586 { status: 400 },
2587 );
2588 }
2589 }
2590
2591 // Validate file type
2592 const fileExtension = file.name.split(".").pop()?.toLowerCase();
2593 const allowedExtensions = [
2594 "mp3",
2595 "wav",
2596 "m4a",
2597 "aac",
2598 "ogg",
2599 "webm",
2600 "flac",
2601 "mp4",
2602 ];
2603 const isAudioType =
2604 file.type.startsWith("audio/") || file.type === "video/mp4";
2605 const isAudioExtension =
2606 fileExtension && allowedExtensions.includes(fileExtension);
2607
2608 if (!isAudioType && !isAudioExtension) {
2609 throw ValidationErrors.unsupportedFileType(
2610 "MP3, WAV, M4A, AAC, OGG, WebM, FLAC",
2611 );
2612 }
2613
2614 if (file.size > MAX_FILE_SIZE) {
2615 throw ValidationErrors.fileTooLarge("100MB");
2616 }
2617
2618 // Generate unique filename
2619 const transcriptionId = crypto.randomUUID();
2620 const filename = `${transcriptionId}.${fileExtension}`;
2621
2622 // Save file to disk
2623 const uploadDir = "./uploads";
2624 await Bun.write(`${uploadDir}/${filename}`, file);
2625
2626 // Create database record (without meeting_time_id - will be set later via PATCH)
2627 db.run(
2628 "INSERT INTO transcriptions (id, user_id, class_id, meeting_time_id, section_id, filename, original_filename, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
2629 [
2630 transcriptionId,
2631 user.id,
2632 classId,
2633 null, // meeting_time_id will be set via PATCH endpoint
2634 sectionId,
2635 filename,
2636 file.name,
2637 "pending",
2638 ],
2639 );
2640
2641 // Don't auto-start transcription - admin will select recordings
2642 // whisperService.startTranscription(transcriptionId, filename);
2643
2644 return Response.json(
2645 {
2646 id: transcriptionId,
2647 message: "Upload successful",
2648 },
2649 { status: 201 },
2650 );
2651 } catch (error) {
2652 return handleError(error);
2653 }
2654 },
2655 },
2656 "/api/admin/transcriptions": {
2657 GET: async (req) => {
2658 try {
2659 requireAdmin(req);
2660 const url = new URL(req.url);
2661
2662 const limit = Math.min(
2663 Number.parseInt(url.searchParams.get("limit") || "50", 10),
2664 100,
2665 );
2666 const cursor = url.searchParams.get("cursor") || undefined;
2667
2668 const result = getAllTranscriptions(limit, cursor);
2669 return Response.json(result.data); // Return just the array for now, can add pagination UI later
2670 } catch (error) {
2671 return handleError(error);
2672 }
2673 },
2674 },
2675 "/api/admin/users": {
2676 GET: async (req) => {
2677 try {
2678 requireAdmin(req);
2679 const url = new URL(req.url);
2680
2681 const limit = Math.min(
2682 Number.parseInt(url.searchParams.get("limit") || "50", 10),
2683 100,
2684 );
2685 const cursor = url.searchParams.get("cursor") || undefined;
2686
2687 const result = getAllUsersWithStats(limit, cursor);
2688 return Response.json(result.data); // Return just the array for now, can add pagination UI later
2689 } catch (error) {
2690 return handleError(error);
2691 }
2692 },
2693 },
2694 "/api/admin/classes": {
2695 GET: async (req) => {
2696 try {
2697 requireAdmin(req);
2698 const classes = getClassesForUser(0, true); // Admin sees all classes
2699 return Response.json({ classes });
2700 } catch (error) {
2701 return handleError(error);
2702 }
2703 },
2704 },
2705 "/api/admin/waitlist": {
2706 GET: async (req) => {
2707 try {
2708 requireAdmin(req);
2709 const waitlist = getAllWaitlistEntries();
2710 return Response.json({ waitlist });
2711 } catch (error) {
2712 return handleError(error);
2713 }
2714 },
2715 },
2716 "/api/admin/waitlist/:id": {
2717 DELETE: async (req) => {
2718 try {
2719 requireAdmin(req);
2720 const id = req.params.id;
2721 deleteWaitlistEntry(id);
2722 return new Response(null, { status: 204 });
2723 } catch (error) {
2724 return handleError(error);
2725 }
2726 },
2727 },
2728 "/api/admin/transcriptions/:id": {
2729 DELETE: async (req) => {
2730 try {
2731 requireAdmin(req);
2732 const transcriptionId = req.params.id;
2733 deleteTranscription(transcriptionId);
2734 return new Response(null, { status: 204 });
2735 } catch (error) {
2736 return handleError(error);
2737 }
2738 },
2739 },
2740 "/api/admin/users/:id": {
2741 DELETE: async (req) => {
2742 try {
2743 requireAdmin(req);
2744 const userId = Number.parseInt(req.params.id, 10);
2745 if (Number.isNaN(userId)) {
2746 return Response.json({ error: "Invalid user ID" }, { status: 400 });
2747 }
2748 await deleteUser(userId);
2749 return new Response(null, { status: 204 });
2750 } catch (error) {
2751 return handleError(error);
2752 }
2753 },
2754 },
2755 "/api/admin/users/:id/role": {
2756 PUT: async (req) => {
2757 try {
2758 requireAdmin(req);
2759 const userId = Number.parseInt(req.params.id, 10);
2760 if (Number.isNaN(userId)) {
2761 return Response.json({ error: "Invalid user ID" }, { status: 400 });
2762 }
2763
2764 const body = await req.json();
2765 const { role } = body as { role: UserRole };
2766
2767 if (!role || (role !== "user" && role !== "admin")) {
2768 return Response.json(
2769 { error: "Invalid role. Must be 'user' or 'admin'" },
2770 { status: 400 },
2771 );
2772 }
2773
2774 updateUserRole(userId, role);
2775 return Response.json({ success: true });
2776 } catch (error) {
2777 return handleError(error);
2778 }
2779 },
2780 },
2781 "/api/admin/users/:id/subscription": {
2782 DELETE: async (req) => {
2783 try {
2784 requireAdmin(req);
2785 const userId = Number.parseInt(req.params.id, 10);
2786 if (Number.isNaN(userId)) {
2787 return Response.json({ error: "Invalid user ID" }, { status: 400 });
2788 }
2789
2790 const body = await req.json();
2791 const { subscriptionId } = body as { subscriptionId: string };
2792
2793 if (!subscriptionId) {
2794 return Response.json(
2795 { error: "Subscription ID required" },
2796 { status: 400 },
2797 );
2798 }
2799
2800 try {
2801 const { polar } = await import("./lib/polar");
2802 await polar.subscriptions.revoke({ id: subscriptionId });
2803 return Response.json({
2804 success: true,
2805 message: "Subscription revoked successfully",
2806 });
2807 } catch (error) {
2808 console.error(
2809 `[Admin] Failed to revoke subscription ${subscriptionId}:`,
2810 error,
2811 );
2812 return Response.json(
2813 {
2814 error:
2815 error instanceof Error
2816 ? error.message
2817 : "Failed to revoke subscription",
2818 },
2819 { status: 500 },
2820 );
2821 }
2822 } catch (error) {
2823 return handleError(error);
2824 }
2825 },
2826 PUT: async (req) => {
2827 try {
2828 requireAdmin(req);
2829 const userId = Number.parseInt(req.params.id, 10);
2830 if (Number.isNaN(userId)) {
2831 return Response.json({ error: "Invalid user ID" }, { status: 400 });
2832 }
2833
2834 // Get user email
2835 const user = db
2836 .query<{ email: string }, [number]>(
2837 "SELECT email FROM users WHERE id = ?",
2838 )
2839 .get(userId);
2840
2841 if (!user) {
2842 return Response.json({ error: "User not found" }, { status: 404 });
2843 }
2844
2845 try {
2846 await syncUserSubscriptionsFromPolar(userId, user.email);
2847 return Response.json({
2848 success: true,
2849 message: "Subscription synced successfully",
2850 });
2851 } catch (error) {
2852 console.error(
2853 `[Admin] Failed to sync subscription for user ${userId}:`,
2854 error,
2855 );
2856 return Response.json(
2857 {
2858 error:
2859 error instanceof Error
2860 ? error.message
2861 : "Failed to sync subscription",
2862 },
2863 { status: 500 },
2864 );
2865 }
2866 } catch (error) {
2867 return handleError(error);
2868 }
2869 },
2870 },
2871 "/api/admin/users/:id/details": {
2872 GET: async (req) => {
2873 try {
2874 requireAdmin(req);
2875 const userId = Number.parseInt(req.params.id, 10);
2876 if (Number.isNaN(userId)) {
2877 return Response.json({ error: "Invalid user ID" }, { status: 400 });
2878 }
2879
2880 const user = db
2881 .query<
2882 {
2883 id: number;
2884 email: string;
2885 name: string | null;
2886 avatar: string;
2887 created_at: number;
2888 role: UserRole;
2889 password_hash: string | null;
2890 last_login: number | null;
2891 },
2892 [number]
2893 >(
2894 "SELECT id, email, name, avatar, created_at, role, password_hash, last_login FROM users WHERE id = ?",
2895 )
2896 .get(userId);
2897
2898 if (!user) {
2899 return Response.json({ error: "User not found" }, { status: 404 });
2900 }
2901
2902 const passkeys = getPasskeysForUser(userId);
2903 const sessions = getSessionsForUser(userId);
2904
2905 // Get transcription count
2906 const transcriptionCount =
2907 db
2908 .query<{ count: number }, [number]>(
2909 "SELECT COUNT(*) as count FROM transcriptions WHERE user_id = ?",
2910 )
2911 .get(userId)?.count ?? 0;
2912
2913 return Response.json({
2914 id: user.id,
2915 email: user.email,
2916 name: user.name,
2917 avatar: user.avatar,
2918 created_at: user.created_at,
2919 role: user.role,
2920 last_login: user.last_login,
2921 hasPassword: !!user.password_hash,
2922 transcriptionCount,
2923 passkeys: passkeys.map((pk) => ({
2924 id: pk.id,
2925 name: pk.name,
2926 created_at: pk.created_at,
2927 last_used_at: pk.last_used_at,
2928 })),
2929 sessions: sessions.map((s) => ({
2930 id: s.id,
2931 ip_address: s.ip_address,
2932 user_agent: s.user_agent,
2933 created_at: s.created_at,
2934 expires_at: s.expires_at,
2935 })),
2936 });
2937 } catch (error) {
2938 return handleError(error);
2939 }
2940 },
2941 },
2942 "/api/admin/users/:id/password-reset": {
2943 POST: async (req) => {
2944 try {
2945 requireAdmin(req);
2946 const userId = Number.parseInt(req.params.id, 10);
2947 if (Number.isNaN(userId)) {
2948 return Response.json({ error: "Invalid user ID" }, { status: 400 });
2949 }
2950
2951 // Get user details
2952 const user = db
2953 .query<
2954 { id: number; email: string; name: string | null },
2955 [number]
2956 >("SELECT id, email, name FROM users WHERE id = ?")
2957 .get(userId);
2958
2959 if (!user) {
2960 return Response.json({ error: "User not found" }, { status: 404 });
2961 }
2962
2963 // Create password reset token
2964 const origin = process.env.ORIGIN || "http://localhost:3000";
2965 const resetToken = createPasswordResetToken(user.id);
2966 const resetLink = `${origin}/reset-password?token=${resetToken}`;
2967
2968 // Send password reset email
2969 await sendEmail({
2970 to: user.email,
2971 subject: "Reset your password - Thistle",
2972 html: passwordResetTemplate({
2973 name: user.name,
2974 resetLink,
2975 }),
2976 });
2977
2978 return Response.json({
2979 success: true,
2980 message: "Password reset email sent",
2981 });
2982 } catch (error) {
2983 console.error("[Admin] Password reset error:", error);
2984 return handleError(error);
2985 }
2986 },
2987 },
2988 "/api/admin/users/:id/passkeys/:passkeyId": {
2989 DELETE: async (req) => {
2990 try {
2991 requireAdmin(req);
2992 const userId = Number.parseInt(req.params.id, 10);
2993 if (Number.isNaN(userId)) {
2994 return Response.json({ error: "Invalid user ID" }, { status: 400 });
2995 }
2996
2997 const { passkeyId } = req.params;
2998 deletePasskey(passkeyId, userId);
2999 return new Response(null, { status: 204 });
3000 } catch (error) {
3001 return handleError(error);
3002 }
3003 },
3004 },
3005 "/api/admin/users/:id/name": {
3006 PUT: async (req) => {
3007 try {
3008 requireAdmin(req);
3009 const userId = Number.parseInt(req.params.id, 10);
3010 if (Number.isNaN(userId)) {
3011 return Response.json({ error: "Invalid user ID" }, { status: 400 });
3012 }
3013
3014 const body = await req.json();
3015 const { name } = body as { name: string };
3016
3017 const nameValidation = validateName(name);
3018 if (!nameValidation.valid) {
3019 return Response.json(
3020 { error: nameValidation.error },
3021 { status: 400 },
3022 );
3023 }
3024
3025 updateUserName(userId, name.trim());
3026 return Response.json({ success: true });
3027 } catch (error) {
3028 return handleError(error);
3029 }
3030 },
3031 },
3032 "/api/admin/users/:id/email": {
3033 PUT: async (req) => {
3034 try {
3035 requireAdmin(req);
3036 const userId = Number.parseInt(req.params.id, 10);
3037 if (Number.isNaN(userId)) {
3038 return Response.json({ error: "Invalid user ID" }, { status: 400 });
3039 }
3040
3041 const body = await req.json();
3042 const { email, skipVerification } = body as {
3043 email: string;
3044 skipVerification?: boolean;
3045 };
3046
3047 const emailValidation = validateEmail(email);
3048 if (!emailValidation.valid) {
3049 return Response.json(
3050 { error: emailValidation.error },
3051 { status: 400 },
3052 );
3053 }
3054
3055 // Check if email already exists
3056 const existing = db
3057 .query<{ id: number }, [string, number]>(
3058 "SELECT id FROM users WHERE email = ? AND id != ?",
3059 )
3060 .get(email, userId);
3061
3062 if (existing) {
3063 return Response.json(
3064 { error: "Email already in use" },
3065 { status: 409 },
3066 );
3067 }
3068
3069 if (skipVerification) {
3070 // Admin override: change email immediately without verification
3071 updateUserEmailAddress(userId, email);
3072 return Response.json({
3073 success: true,
3074 message: "Email updated immediately (verification skipped)",
3075 });
3076 }
3077
3078 // Get user's current email
3079 const user = db
3080 .query<{ email: string; name: string | null }, [number]>(
3081 "SELECT email, name FROM users WHERE id = ?",
3082 )
3083 .get(userId);
3084
3085 if (!user) {
3086 return Response.json({ error: "User not found" }, { status: 404 });
3087 }
3088
3089 // Send verification email to user's current email
3090 try {
3091 const token = createEmailChangeToken(userId, email);
3092 const origin = process.env.ORIGIN || "http://localhost:3000";
3093 const verifyUrl = `${origin}/api/user/email/verify?token=${token}`;
3094
3095 await sendEmail({
3096 to: user.email,
3097 subject: "Verify your email change",
3098 html: emailChangeTemplate({
3099 name: user.name,
3100 currentEmail: user.email,
3101 newEmail: email,
3102 verifyLink: verifyUrl,
3103 }),
3104 });
3105
3106 return Response.json({
3107 success: true,
3108 message: `Verification email sent to ${user.email}`,
3109 pendingEmail: email,
3110 });
3111 } catch (emailError) {
3112 console.error(
3113 "[Admin] Failed to send email change verification:",
3114 emailError,
3115 );
3116 return Response.json(
3117 { error: "Failed to send verification email" },
3118 { status: 500 },
3119 );
3120 }
3121 } catch (error) {
3122 return handleError(error);
3123 }
3124 },
3125 },
3126 "/api/admin/users/:id/sessions": {
3127 GET: async (req) => {
3128 try {
3129 requireAdmin(req);
3130 const userId = Number.parseInt(req.params.id, 10);
3131 if (Number.isNaN(userId)) {
3132 return Response.json({ error: "Invalid user ID" }, { status: 400 });
3133 }
3134
3135 const sessions = getSessionsForUser(userId);
3136 return Response.json(sessions);
3137 } catch (error) {
3138 return handleError(error);
3139 }
3140 },
3141 DELETE: async (req) => {
3142 try {
3143 requireAdmin(req);
3144 const userId = Number.parseInt(req.params.id, 10);
3145 if (Number.isNaN(userId)) {
3146 return Response.json({ error: "Invalid user ID" }, { status: 400 });
3147 }
3148
3149 deleteAllUserSessions(userId);
3150 return new Response(null, { status: 204 });
3151 } catch (error) {
3152 return handleError(error);
3153 }
3154 },
3155 },
3156 "/api/admin/users/:id/sessions/:sessionId": {
3157 DELETE: async (req) => {
3158 try {
3159 requireAdmin(req);
3160 const userId = Number.parseInt(req.params.id, 10);
3161 if (Number.isNaN(userId)) {
3162 return Response.json({ error: "Invalid user ID" }, { status: 400 });
3163 }
3164
3165 const { sessionId } = req.params;
3166 const success = deleteSessionById(sessionId, userId);
3167
3168 if (!success) {
3169 return Response.json(
3170 { error: "Session not found" },
3171 { status: 404 },
3172 );
3173 }
3174
3175 return new Response(null, { status: 204 });
3176 } catch (error) {
3177 return handleError(error);
3178 }
3179 },
3180 },
3181 "/api/admin/transcriptions/:id/details": {
3182 GET: async (req) => {
3183 try {
3184 requireAdmin(req);
3185 const transcriptionId = req.params.id;
3186
3187 const transcription = db
3188 .query<
3189 {
3190 id: string;
3191 original_filename: string;
3192 status: string;
3193 created_at: number;
3194 updated_at: number;
3195 error_message: string | null;
3196 user_id: number;
3197 },
3198 [string]
3199 >(
3200 "SELECT id, original_filename, status, created_at, updated_at, error_message, user_id FROM transcriptions WHERE id = ?",
3201 )
3202 .get(transcriptionId);
3203
3204 if (!transcription) {
3205 return Response.json(
3206 { error: "Transcription not found" },
3207 { status: 404 },
3208 );
3209 }
3210
3211 const user = db
3212 .query<{ email: string; name: string | null }, [number]>(
3213 "SELECT email, name FROM users WHERE id = ?",
3214 )
3215 .get(transcription.user_id);
3216
3217 return Response.json({
3218 id: transcription.id,
3219 original_filename: transcription.original_filename,
3220 status: transcription.status,
3221 created_at: transcription.created_at,
3222 completed_at: transcription.updated_at,
3223 error_message: transcription.error_message,
3224 user_id: transcription.user_id,
3225 user_email: user?.email || "Unknown",
3226 user_name: user?.name || null,
3227 });
3228 } catch (error) {
3229 return handleError(error);
3230 }
3231 },
3232 },
3233 "/api/classes": {
3234 GET: async (req) => {
3235 try {
3236 const user = requireAuth(req);
3237 const url = new URL(req.url);
3238
3239 const limit = Math.min(
3240 Number.parseInt(url.searchParams.get("limit") || "50", 10),
3241 100,
3242 );
3243 const cursor = url.searchParams.get("cursor") || undefined;
3244
3245 const result = getClassesForUser(
3246 user.id,
3247 user.role === "admin",
3248 limit,
3249 cursor,
3250 );
3251
3252 // Group by semester/year for all users
3253 const grouped: Record<
3254 string,
3255 Array<{
3256 id: string;
3257 course_code: string;
3258 name: string;
3259 professor: string;
3260 semester: string;
3261 year: number;
3262 archived: boolean;
3263 }>
3264 > = {};
3265
3266 for (const cls of result.data) {
3267 const key = `${cls.semester} ${cls.year}`;
3268 if (!grouped[key]) {
3269 grouped[key] = [];
3270 }
3271 grouped[key]?.push({
3272 id: cls.id,
3273 course_code: cls.course_code,
3274 name: cls.name,
3275 professor: cls.professor,
3276 semester: cls.semester,
3277 year: cls.year,
3278 archived: cls.archived,
3279 });
3280 }
3281
3282 return Response.json({
3283 classes: grouped,
3284 pagination: result.pagination,
3285 });
3286 } catch (error) {
3287 return handleError(error);
3288 }
3289 },
3290 POST: async (req) => {
3291 try {
3292 requireAdmin(req);
3293 const body = await req.json();
3294 const {
3295 course_code,
3296 name,
3297 professor,
3298 semester,
3299 year,
3300 meeting_times,
3301 } = body;
3302
3303 // Validate all required fields
3304 const courseCodeValidation = validateCourseCode(course_code);
3305 if (!courseCodeValidation.valid) {
3306 return Response.json(
3307 { error: courseCodeValidation.error },
3308 { status: 400 },
3309 );
3310 }
3311
3312 const nameValidation = validateCourseName(name);
3313 if (!nameValidation.valid) {
3314 return Response.json(
3315 { error: nameValidation.error },
3316 { status: 400 },
3317 );
3318 }
3319
3320 const professorValidation = validateName(professor, "Professor name");
3321 if (!professorValidation.valid) {
3322 return Response.json(
3323 { error: professorValidation.error },
3324 { status: 400 },
3325 );
3326 }
3327
3328 const semesterValidation = validateSemester(semester);
3329 if (!semesterValidation.valid) {
3330 return Response.json(
3331 { error: semesterValidation.error },
3332 { status: 400 },
3333 );
3334 }
3335
3336 const yearValidation = validateYear(year);
3337 if (!yearValidation.valid) {
3338 return Response.json(
3339 { error: yearValidation.error },
3340 { status: 400 },
3341 );
3342 }
3343
3344 const newClass = createClass({
3345 course_code,
3346 name,
3347 professor,
3348 semester,
3349 year,
3350 meeting_times,
3351 sections: body.sections,
3352 });
3353
3354 return Response.json(newClass, { status: 201 });
3355 } catch (error) {
3356 return handleError(error);
3357 }
3358 },
3359 },
3360 "/api/classes/search": {
3361 GET: async (req) => {
3362 try {
3363 const user = requireAuth(req);
3364 const url = new URL(req.url);
3365 const query = url.searchParams.get("q");
3366
3367 if (!query) {
3368 return Response.json({ classes: [] });
3369 }
3370
3371 const classes = searchClassesByCourseCode(query);
3372
3373 // Get user's enrolled classes to mark them
3374 const enrolledClassIds = db
3375 .query<{ class_id: string }, [number]>(
3376 "SELECT class_id FROM class_members WHERE user_id = ?",
3377 )
3378 .all(user.id)
3379 .map((row) => row.class_id);
3380
3381 // Add is_enrolled flag and sections to each class
3382 const classesWithEnrollment = classes.map((cls) => ({
3383 ...cls,
3384 is_enrolled: enrolledClassIds.includes(cls.id),
3385 sections: getClassSections(cls.id),
3386 }));
3387
3388 return Response.json({ classes: classesWithEnrollment });
3389 } catch (error) {
3390 return handleError(error);
3391 }
3392 },
3393 },
3394 "/api/classes/join": {
3395 POST: async (req) => {
3396 try {
3397 const user = requireAuth(req);
3398 const body = await req.json();
3399 const classId = body.class_id;
3400 const sectionId = body.section_id || null;
3401
3402 const classIdValidation = validateClassId(classId);
3403 if (!classIdValidation.valid) {
3404 return Response.json(
3405 { error: classIdValidation.error },
3406 { status: 400 },
3407 );
3408 }
3409
3410 const result = joinClass(classId, user.id, sectionId);
3411
3412 if (!result.success) {
3413 return Response.json({ error: result.error }, { status: 400 });
3414 }
3415
3416 return new Response(null, { status: 204 });
3417 } catch (error) {
3418 return handleError(error);
3419 }
3420 },
3421 },
3422 "/api/classes/waitlist": {
3423 POST: async (req) => {
3424 try {
3425 const user = requireAuth(req);
3426 const body = await req.json();
3427
3428 const {
3429 courseCode,
3430 courseName,
3431 professor,
3432 semester,
3433 year,
3434 additionalInfo,
3435 meetingTimes,
3436 } = body;
3437
3438 // Validate all required fields
3439 const courseCodeValidation = validateCourseCode(courseCode);
3440 if (!courseCodeValidation.valid) {
3441 return Response.json(
3442 { error: courseCodeValidation.error },
3443 { status: 400 },
3444 );
3445 }
3446
3447 const nameValidation = validateCourseName(courseName);
3448 if (!nameValidation.valid) {
3449 return Response.json(
3450 { error: nameValidation.error },
3451 { status: 400 },
3452 );
3453 }
3454
3455 const professorValidation = validateName(professor, "Professor name");
3456 if (!professorValidation.valid) {
3457 return Response.json(
3458 { error: professorValidation.error },
3459 { status: 400 },
3460 );
3461 }
3462
3463 const semesterValidation = validateSemester(semester);
3464 if (!semesterValidation.valid) {
3465 return Response.json(
3466 { error: semesterValidation.error },
3467 { status: 400 },
3468 );
3469 }
3470
3471 const yearValidation = validateYear(
3472 typeof year === "string" ? Number.parseInt(year, 10) : year,
3473 );
3474 if (!yearValidation.valid) {
3475 return Response.json(
3476 { error: yearValidation.error },
3477 { status: 400 },
3478 );
3479 }
3480
3481 const id = addToWaitlist(
3482 user.id,
3483 courseCode,
3484 courseName,
3485 professor,
3486 semester,
3487 Number.parseInt(year, 10),
3488 additionalInfo || null,
3489 meetingTimes || null,
3490 );
3491
3492 return Response.json({ success: true, id }, { status: 201 });
3493 } catch (error) {
3494 return handleError(error);
3495 }
3496 },
3497 },
3498 "/api/classes/:id": {
3499 GET: async (req) => {
3500 try {
3501 const user = requireAuth(req);
3502 const classId = req.params.id;
3503
3504 const classInfo = getClassById(classId);
3505 if (!classInfo) {
3506 return Response.json({ error: "Class not found" }, { status: 404 });
3507 }
3508
3509 // Check enrollment or admin
3510 const isEnrolled = isUserEnrolledInClass(user.id, classId);
3511 if (!isEnrolled && user.role !== "admin") {
3512 return Response.json(
3513 { error: "Not enrolled in this class" },
3514 { status: 403 },
3515 );
3516 }
3517
3518 const meetingTimes = getMeetingTimesForClass(classId);
3519 const sections = getClassSections(classId);
3520 const transcriptions = getTranscriptionsForClass(classId);
3521 const userSection = getUserSection(user.id, classId);
3522
3523 return Response.json({
3524 class: classInfo,
3525 meetingTimes,
3526 sections,
3527 userSection,
3528 transcriptions,
3529 });
3530 } catch (error) {
3531 return handleError(error);
3532 }
3533 },
3534 DELETE: async (req) => {
3535 try {
3536 requireAdmin(req);
3537 const classId = req.params.id;
3538
3539 // Verify class exists
3540 const existingClass = getClassById(classId);
3541 if (!existingClass) {
3542 return Response.json({ error: "Class not found" }, { status: 404 });
3543 }
3544
3545 deleteClass(classId);
3546 return new Response(null, { status: 204 });
3547 } catch (error) {
3548 return handleError(error);
3549 }
3550 },
3551 },
3552 "/api/classes/:id/archive": {
3553 PUT: async (req) => {
3554 try {
3555 requireAdmin(req);
3556 const classId = req.params.id;
3557 const body = await req.json();
3558 const { archived } = body;
3559
3560 if (typeof archived !== "boolean") {
3561 return Response.json(
3562 { error: "archived must be a boolean" },
3563 { status: 400 },
3564 );
3565 }
3566
3567 // Verify class exists
3568 const existingClass = getClassById(classId);
3569 if (!existingClass) {
3570 return Response.json({ error: "Class not found" }, { status: 404 });
3571 }
3572
3573 toggleClassArchive(classId, archived);
3574 return new Response(null, { status: 204 });
3575 } catch (error) {
3576 return handleError(error);
3577 }
3578 },
3579 },
3580 "/api/classes/:id/members": {
3581 GET: async (req) => {
3582 try {
3583 requireAdmin(req);
3584 const classId = req.params.id;
3585
3586 const members = getClassMembers(classId);
3587 return Response.json({ members });
3588 } catch (error) {
3589 return handleError(error);
3590 }
3591 },
3592 POST: async (req) => {
3593 try {
3594 requireAdmin(req);
3595 const classId = req.params.id;
3596 const body = await req.json();
3597 const { email } = body;
3598
3599 if (!email) {
3600 return Response.json({ error: "Email required" }, { status: 400 });
3601 }
3602
3603 // Verify class exists
3604 const existingClass = getClassById(classId);
3605 if (!existingClass) {
3606 return Response.json({ error: "Class not found" }, { status: 404 });
3607 }
3608
3609 const user = getUserByEmail(email);
3610 if (!user) {
3611 return Response.json({ error: "User not found" }, { status: 404 });
3612 }
3613
3614 enrollUserInClass(user.id, classId);
3615 return new Response(null, { status: 201 });
3616 } catch (error) {
3617 return handleError(error);
3618 }
3619 },
3620 },
3621 "/api/classes/:id/members/:userId": {
3622 DELETE: async (req) => {
3623 try {
3624 requireAdmin(req);
3625 const classId = req.params.id;
3626 const userId = Number.parseInt(req.params.userId, 10);
3627
3628 if (Number.isNaN(userId)) {
3629 return Response.json({ error: "Invalid user ID" }, { status: 400 });
3630 }
3631
3632 // Verify class exists
3633 const existingClass = getClassById(classId);
3634 if (!existingClass) {
3635 return Response.json({ error: "Class not found" }, { status: 404 });
3636 }
3637
3638 removeUserFromClass(userId, classId);
3639 return new Response(null, { status: 204 });
3640 } catch (error) {
3641 return handleError(error);
3642 }
3643 },
3644 },
3645 "/api/classes/:id/meetings": {
3646 GET: async (req) => {
3647 try {
3648 const user = requireAuth(req);
3649 const classId = req.params.id;
3650
3651 // Check enrollment or admin
3652 const isEnrolled = isUserEnrolledInClass(user.id, classId);
3653 if (!isEnrolled && user.role !== "admin") {
3654 return Response.json(
3655 { error: "Not enrolled in this class" },
3656 { status: 403 },
3657 );
3658 }
3659
3660 const meetingTimes = getMeetingTimesForClass(classId);
3661 return Response.json({ meetings: meetingTimes });
3662 } catch (error) {
3663 return handleError(error);
3664 }
3665 },
3666 POST: async (req) => {
3667 try {
3668 requireAdmin(req);
3669 const classId = req.params.id;
3670 const body = await req.json();
3671 const { label } = body;
3672
3673 if (!label) {
3674 return Response.json({ error: "Label required" }, { status: 400 });
3675 }
3676
3677 // Verify class exists
3678 const existingClass = getClassById(classId);
3679 if (!existingClass) {
3680 return Response.json({ error: "Class not found" }, { status: 404 });
3681 }
3682
3683 const meetingTime = createMeetingTime(classId, label);
3684 return Response.json(meetingTime, { status: 201 });
3685 } catch (error) {
3686 return handleError(error);
3687 }
3688 },
3689 },
3690 "/api/classes/:id/sections": {
3691 POST: async (req) => {
3692 try {
3693 requireAdmin(req);
3694 const classId = req.params.id;
3695 const body = await req.json();
3696 const { section_number } = body;
3697
3698 if (!section_number) {
3699 return Response.json({ error: "Section number required" }, { status: 400 });
3700 }
3701
3702 const section = createClassSection(classId, section_number);
3703 return Response.json(section);
3704 } catch (error) {
3705 return handleError(error);
3706 }
3707 },
3708 },
3709 "/api/classes/:classId/sections/:sectionId": {
3710 DELETE: async (req) => {
3711 try {
3712 requireAdmin(req);
3713 const sectionId = req.params.sectionId;
3714
3715 // Check if any students are in this section
3716 const studentsInSection = db
3717 .query<{ count: number }, [string]>(
3718 "SELECT COUNT(*) as count FROM class_members WHERE section_id = ?",
3719 )
3720 .get(sectionId);
3721
3722 if (studentsInSection && studentsInSection.count > 0) {
3723 return Response.json(
3724 { error: "Cannot delete section with enrolled students" },
3725 { status: 400 },
3726 );
3727 }
3728
3729 db.run("DELETE FROM class_sections WHERE id = ?", [sectionId]);
3730 return new Response(null, { status: 204 });
3731 } catch (error) {
3732 return handleError(error);
3733 }
3734 },
3735 },
3736 "/api/meetings/:id": {
3737 PUT: async (req) => {
3738 try {
3739 requireAdmin(req);
3740 const meetingId = req.params.id;
3741 const body = await req.json();
3742 const { label } = body;
3743
3744 if (!label) {
3745 return Response.json({ error: "Label required" }, { status: 400 });
3746 }
3747
3748 // Verify meeting exists
3749 const existingMeeting = getMeetingById(meetingId);
3750 if (!existingMeeting) {
3751 return Response.json(
3752 { error: "Meeting not found" },
3753 { status: 404 },
3754 );
3755 }
3756
3757 updateMeetingTime(meetingId, label);
3758 return new Response(null, { status: 204 });
3759 } catch (error) {
3760 return handleError(error);
3761 }
3762 },
3763 DELETE: async (req) => {
3764 try {
3765 requireAdmin(req);
3766 const meetingId = req.params.id;
3767
3768 // Verify meeting exists
3769 const existingMeeting = getMeetingById(meetingId);
3770 if (!existingMeeting) {
3771 return Response.json(
3772 { error: "Meeting not found" },
3773 { status: 404 },
3774 );
3775 }
3776
3777 deleteMeetingTime(meetingId);
3778 return new Response(null, { status: 204 });
3779 } catch (error) {
3780 return handleError(error);
3781 }
3782 },
3783 },
3784 "/api/transcripts/:id/select": {
3785 PUT: async (req) => {
3786 try {
3787 requireAdmin(req);
3788 const transcriptId = req.params.id;
3789
3790 // Check if transcription exists and get its current status
3791 const transcription = db
3792 .query<{ filename: string; status: string }, [string]>(
3793 "SELECT filename, status FROM transcriptions WHERE id = ?",
3794 )
3795 .get(transcriptId);
3796
3797 if (!transcription) {
3798 return Response.json(
3799 { error: "Transcription not found" },
3800 { status: 404 },
3801 );
3802 }
3803
3804 // Validate that status is appropriate for selection (e.g., 'uploading' or 'pending')
3805 const validStatuses = ["uploading", "pending", "failed"];
3806 if (!validStatuses.includes(transcription.status)) {
3807 return Response.json(
3808 {
3809 error: `Cannot select transcription with status: ${transcription.status}`,
3810 },
3811 { status: 400 },
3812 );
3813 }
3814
3815 // Update status to 'selected' and start transcription
3816 db.run("UPDATE transcriptions SET status = ? WHERE id = ?", [
3817 "selected",
3818 transcriptId,
3819 ]);
3820
3821 whisperService.startTranscription(
3822 transcriptId,
3823 transcription.filename,
3824 );
3825
3826 return new Response(null, { status: 204 });
3827 } catch (error) {
3828 return handleError(error);
3829 }
3830 },
3831 },
3832 },
3833 development: process.env.NODE_ENV === "dev",
3834 fetch(req, server) {
3835 const response = server.fetch(req);
3836
3837 // Add security headers to all responses
3838 if (response instanceof Response) {
3839 const headers = new Headers(response.headers);
3840 headers.set("Permissions-Policy", "interest-cohort=()");
3841 headers.set("X-Content-Type-Options", "nosniff");
3842 headers.set("X-Frame-Options", "DENY");
3843 headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
3844
3845 // Set CSP that allows inline styles with unsafe-inline (needed for Lit components)
3846 // and script-src 'self' for bundled scripts
3847 headers.set(
3848 "Content-Security-Policy",
3849 "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';",
3850 );
3851
3852 return new Response(response.body, {
3853 status: response.status,
3854 statusText: response.statusText,
3855 headers,
3856 });
3857 }
3858
3859 return response;
3860 },
3861});
3862console.log(`馃 Thistle running at http://localhost:${server.port}`);
3863
3864// Track active SSE streams for graceful shutdown
3865const activeSSEStreams = new Set<ReadableStreamDefaultController>();
3866
3867// Graceful shutdown handler
3868let isShuttingDown = false;
3869
3870async function shutdown(signal: string) {
3871 if (isShuttingDown) return;
3872 isShuttingDown = true;
3873
3874 console.log(`\n${signal} received, starting graceful shutdown...`);
3875
3876 // 1. Stop accepting new requests
3877 console.log("[Shutdown] Closing server...");
3878 server.stop();
3879
3880 // 2. Close all active SSE streams (safe to kill - sync will handle reconnection)
3881 console.log(
3882 `[Shutdown] Closing ${activeSSEStreams.size} active SSE streams...`,
3883 );
3884 for (const controller of activeSSEStreams) {
3885 try {
3886 controller.close();
3887 } catch {
3888 // Already closed
3889 }
3890 }
3891 activeSSEStreams.clear();
3892
3893 // 3. Stop transcription service (closes streams to Murmur)
3894 whisperService.stop();
3895
3896 // 4. Stop cleanup intervals
3897 console.log("[Shutdown] Stopping cleanup intervals...");
3898 clearInterval(sessionCleanupInterval);
3899 clearInterval(syncInterval);
3900 clearInterval(fileCleanupInterval);
3901
3902 // 5. Close database connections
3903 console.log("[Shutdown] Closing database...");
3904 db.close();
3905
3906 console.log("[Shutdown] Complete");
3907 process.exit(0);
3908}
3909
3910// Register shutdown handlers
3911process.on("SIGTERM", () => shutdown("SIGTERM"));
3912process.on("SIGINT", () => shutdown("SIGINT"));