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