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