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