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