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