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