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