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