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