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