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