馃 distributed transcription service
thistle.dunkirk.sh
1import db from "./db/schema";
2import {
3 authenticateUser,
4 cleanupExpiredSessions,
5 createSession,
6 createUser,
7 deleteAllUserSessions,
8 deleteSession,
9 deleteSessionById,
10 deleteTranscription,
11 deleteUser,
12 getAllTranscriptions,
13 getAllUsersWithStats,
14 getSession,
15 getSessionFromRequest,
16 getSessionsForUser,
17 getUserByEmail,
18 getUserBySession,
19 getUserSessionsForUser,
20 type UserRole,
21 updateUserAvatar,
22 updateUserEmail,
23 updateUserEmailAddress,
24 updateUserName,
25 updateUserPassword,
26 updateUserRole,
27 createEmailVerificationToken,
28 verifyEmailToken,
29 verifyEmailCode,
30 isEmailVerified,
31 createPasswordResetToken,
32 verifyPasswordResetToken,
33 consumePasswordResetToken,
34} from "./lib/auth";
35import {
36 addToWaitlist,
37 createClass,
38 createMeetingTime,
39 deleteClass,
40 deleteMeetingTime,
41 deleteWaitlistEntry,
42 enrollUserInClass,
43 getAllWaitlistEntries,
44 getClassById,
45 getClassesForUser,
46 getClassMembers,
47 getMeetingTimesForClass,
48 getTranscriptionsForClass,
49 isUserEnrolledInClass,
50 joinClass,
51 removeUserFromClass,
52 searchClassesByCourseCode,
53 toggleClassArchive,
54 updateMeetingTime,
55} from "./lib/classes";
56import { AuthErrors, handleError, ValidationErrors } from "./lib/errors";
57import {
58 hasActiveSubscription,
59 requireAdmin,
60 requireAuth,
61 requireSubscription,
62} from "./lib/middleware";
63import {
64 createAuthenticationOptions,
65 createRegistrationOptions,
66 deletePasskey,
67 getPasskeysForUser,
68 updatePasskeyName,
69 verifyAndAuthenticatePasskey,
70 verifyAndCreatePasskey,
71} from "./lib/passkey";
72import { enforceRateLimit, clearRateLimit } from "./lib/rate-limit";
73import { getTranscriptVTT } from "./lib/transcript-storage";
74import {
75 MAX_FILE_SIZE,
76 TranscriptionEventEmitter,
77 type TranscriptionUpdate,
78 WhisperServiceManager,
79} from "./lib/transcription";
80import { sendEmail } from "./lib/email";
81import {
82 verifyEmailTemplate,
83 passwordResetTemplate,
84} from "./lib/email-templates";
85import adminHTML from "./pages/admin.html";
86import checkoutHTML from "./pages/checkout.html";
87import classHTML from "./pages/class.html";
88import classesHTML from "./pages/classes.html";
89import indexHTML from "./pages/index.html";
90import resetPasswordHTML from "./pages/reset-password.html";
91import settingsHTML from "./pages/settings.html";
92import transcribeHTML from "./pages/transcribe.html";
93
94// Environment variables
95const WHISPER_SERVICE_URL =
96 process.env.WHISPER_SERVICE_URL || "http://localhost:8000";
97
98// Create uploads and transcripts directories if they don't exist
99await Bun.write("./uploads/.gitkeep", "");
100await Bun.write("./transcripts/.gitkeep", "");
101
102// Initialize transcription system
103console.log(
104 `[Transcription] Connecting to Murmur at ${WHISPER_SERVICE_URL}...`,
105);
106const transcriptionEvents = new TranscriptionEventEmitter();
107const whisperService = new WhisperServiceManager(
108 WHISPER_SERVICE_URL,
109 db,
110 transcriptionEvents,
111);
112
113// Clean up expired sessions every hour
114setInterval(cleanupExpiredSessions, 60 * 60 * 1000);
115
116// Helper function to sync user subscriptions from Polar
117async function syncUserSubscriptionsFromPolar(
118 userId: number,
119 email: string,
120): Promise<void> {
121 try {
122 const { polar } = await import("./lib/polar");
123
124 // Search for customer by email
125 const customers = await polar.customers.list({
126 organizationId: process.env.POLAR_ORGANIZATION_ID,
127 query: email,
128 });
129
130 if (!customers.result.items || customers.result.items.length === 0) {
131 console.log(`[Sync] No Polar customer found for ${email}`);
132 return;
133 }
134
135 const customer = customers.result.items[0];
136
137 // Get all subscriptions for this customer
138 const subscriptions = await polar.subscriptions.list({
139 customerId: customer.id,
140 });
141
142 if (!subscriptions.result.items || subscriptions.result.items.length === 0) {
143 console.log(`[Sync] No subscriptions found for customer ${customer.id}`);
144 return;
145 }
146
147 // Update each subscription in the database
148 for (const subscription of subscriptions.result.items) {
149 db.run(
150 `INSERT INTO subscriptions (id, user_id, customer_id, status, current_period_start, current_period_end, cancel_at_period_end, canceled_at, updated_at)
151 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
152 ON CONFLICT(id) DO UPDATE SET
153 user_id = excluded.user_id,
154 status = excluded.status,
155 current_period_start = excluded.current_period_start,
156 current_period_end = excluded.current_period_end,
157 cancel_at_period_end = excluded.cancel_at_period_end,
158 canceled_at = excluded.canceled_at,
159 updated_at = excluded.updated_at`,
160 [
161 subscription.id,
162 userId,
163 subscription.customerId,
164 subscription.status,
165 subscription.currentPeriodStart
166 ? Math.floor(
167 new Date(subscription.currentPeriodStart).getTime() / 1000,
168 )
169 : null,
170 subscription.currentPeriodEnd
171 ? Math.floor(
172 new Date(subscription.currentPeriodEnd).getTime() / 1000,
173 )
174 : null,
175 subscription.cancelAtPeriodEnd ? 1 : 0,
176 subscription.canceledAt
177 ? Math.floor(new Date(subscription.canceledAt).getTime() / 1000)
178 : null,
179 Math.floor(Date.now() / 1000),
180 ],
181 );
182 }
183
184 console.log(
185 `[Sync] Linked ${subscriptions.result.items.length} subscription(s) to user ${userId} (${email})`,
186 );
187 } catch (error) {
188 console.error(
189 `[Sync] Failed to sync subscriptions for ${email}:`,
190 error instanceof Error ? error.message : "Unknown error",
191 );
192 // Don't throw - registration should succeed even if sync fails
193 }
194}
195
196
197// Sync with Whisper DB on startup
198try {
199 await whisperService.syncWithWhisper();
200 console.log("[Transcription] Successfully connected to Murmur");
201} catch (error) {
202 console.warn(
203 "[Transcription] Murmur unavailable at startup:",
204 error instanceof Error ? error.message : "Unknown error",
205 );
206}
207
208// Periodic sync every 5 minutes as backup (SSE handles real-time updates)
209setInterval(
210 async () => {
211 try {
212 await whisperService.syncWithWhisper();
213 } catch (error) {
214 console.warn(
215 "[Sync] Failed to sync with Murmur:",
216 error instanceof Error ? error.message : "Unknown error",
217 );
218 }
219 },
220 5 * 60 * 1000,
221);
222
223// Clean up stale files daily
224setInterval(() => whisperService.cleanupStaleFiles(), 24 * 60 * 60 * 1000);
225
226const server = Bun.serve({
227 port: process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 3000,
228 idleTimeout: 120, // 120 seconds for SSE connections
229 routes: {
230 "/": indexHTML,
231 "/admin": adminHTML,
232 "/checkout": checkoutHTML,
233 "/settings": settingsHTML,
234 "/reset-password": resetPasswordHTML,
235 "/transcribe": transcribeHTML,
236 "/classes": classesHTML,
237 "/classes/*": classHTML,
238 "/apple-touch-icon.png": Bun.file("./public/favicon/apple-touch-icon.png"),
239 "/favicon-32x32.png": Bun.file("./public/favicon/favicon-32x32.png"),
240 "/favicon-16x16.png": Bun.file("./public/favicon/favicon-16x16.png"),
241 "/site.webmanifest": Bun.file("./public/favicon/site.webmanifest"),
242 "/favicon.ico": Bun.file("./public/favicon/favicon.ico"),
243 "/api/auth/register": {
244 POST: async (req) => {
245 try {
246 // Rate limiting
247 const rateLimitError = enforceRateLimit(req, "register", {
248 ip: { max: 5, windowSeconds: 30 * 60 },
249 });
250 if (rateLimitError) return rateLimitError;
251
252 const body = await req.json();
253 const { email, password, name } = body;
254 if (!email || !password) {
255 return Response.json(
256 { error: "Email and password required" },
257 { status: 400 },
258 );
259 }
260 // Password is client-side hashed (PBKDF2), should be 64 char hex
261 if (password.length !== 64 || !/^[0-9a-f]+$/.test(password)) {
262 return Response.json(
263 { error: "Invalid password format" },
264 { status: 400 },
265 );
266 }
267 const user = await createUser(email, password, name);
268
269 // Send verification email - MUST succeed for registration to complete
270 const { code, token } = createEmailVerificationToken(user.id);
271
272 try {
273 await sendEmail({
274 to: user.email,
275 subject: "Verify your email - Thistle",
276 html: verifyEmailTemplate({
277 name: user.name,
278 code,
279 token,
280 }),
281 });
282 } catch (err) {
283 console.error("[Email] Failed to send verification email:", err);
284 // Rollback user creation - direct DB delete since user was just created
285 db.run("DELETE FROM email_verification_tokens WHERE user_id = ?", [user.id]);
286 db.run("DELETE FROM sessions WHERE user_id = ?", [user.id]);
287 db.run("DELETE FROM users WHERE id = ?", [user.id]);
288 return Response.json(
289 { error: "Failed to send verification email. Please try again later." },
290 { status: 500 },
291 );
292 }
293
294 // Attempt to sync existing Polar subscriptions (after email succeeds)
295 syncUserSubscriptionsFromPolar(user.id, user.email).catch(() => {
296 // Silent fail - don't block registration
297 });
298
299 // Clear rate limits on successful registration
300 const ipAddress =
301 req.headers.get("x-forwarded-for") ??
302 req.headers.get("x-real-ip") ??
303 "unknown";
304 clearRateLimit("register", email, ipAddress);
305
306 // Return success but indicate email verification is needed
307 // Don't create session yet - they need to verify first
308 return Response.json(
309 {
310 user: { id: user.id, email: user.email },
311 email_verification_required: true,
312 },
313 { status: 200 },
314 );
315 } catch (err: unknown) {
316 const error = err as { message?: string };
317 if (error.message?.includes("UNIQUE constraint failed")) {
318 return Response.json(
319 { error: "Email already registered" },
320 { status: 400 },
321 );
322 }
323 return Response.json(
324 { error: "Registration failed" },
325 { status: 500 },
326 );
327 }
328 },
329 },
330 "/api/auth/login": {
331 POST: async (req) => {
332 try {
333 const body = await req.json();
334 const { email, password } = body;
335 if (!email || !password) {
336 return Response.json(
337 { error: "Email and password required" },
338 { status: 400 },
339 );
340 }
341
342 // Rate limiting: Per IP and per account
343 const rateLimitError = enforceRateLimit(req, "login", {
344 ip: { max: 10, windowSeconds: 5 * 60 },
345 account: { max: 5, windowSeconds: 5 * 60, email },
346 });
347 if (rateLimitError) return rateLimitError;
348
349 // Password is client-side hashed (PBKDF2), should be 64 char hex
350 if (password.length !== 64 || !/^[0-9a-f]+$/.test(password)) {
351 return Response.json(
352 { error: "Invalid password format" },
353 { status: 400 },
354 );
355 }
356 const user = await authenticateUser(email, password);
357 if (!user) {
358 return Response.json(
359 { error: "Invalid email or password" },
360 { status: 401 },
361 );
362 }
363
364 // Clear rate limits on successful authentication
365 const ipAddress =
366 req.headers.get("x-forwarded-for") ??
367 req.headers.get("x-real-ip") ??
368 "unknown";
369 clearRateLimit("login", email, ipAddress);
370
371 // Check if email is verified
372 if (!isEmailVerified(user.id)) {
373 return Response.json(
374 {
375 user: { id: user.id, email: user.email },
376 email_verification_required: true,
377 },
378 { status: 200 },
379 );
380 }
381
382 const userAgent = req.headers.get("user-agent") ?? "unknown";
383 const sessionId = createSession(user.id, ipAddress, userAgent);
384 return Response.json(
385 { user: { id: user.id, email: user.email } },
386 {
387 headers: {
388 "Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`,
389 },
390 },
391 );
392 } catch {
393 return Response.json({ error: "Login failed" }, { status: 500 });
394 }
395 },
396 },
397 "/api/auth/verify-email": {
398 GET: async (req) => {
399 try {
400 const url = new URL(req.url);
401 const token = url.searchParams.get("token");
402
403 if (!token) {
404 return Response.redirect("/", 302);
405 }
406
407 const result = verifyEmailToken(token);
408
409 if (!result) {
410 return Response.redirect("/", 302);
411 }
412
413 // Create session for the verified user
414 const ipAddress =
415 req.headers.get("x-forwarded-for") ??
416 req.headers.get("x-real-ip") ??
417 "unknown";
418 const userAgent = req.headers.get("user-agent") ?? "unknown";
419 const sessionId = createSession(result.userId, ipAddress, userAgent);
420
421 // Redirect to classes with session cookie
422 return new Response(null, {
423 status: 302,
424 headers: {
425 "Location": "/classes",
426 "Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`,
427 },
428 });
429 } catch (error) {
430 console.error("[Email] Verification error:", error);
431 return Response.redirect("/", 302);
432 }
433 },
434 POST: async (req) => {
435 try {
436 const body = await req.json();
437 const { email, code } = body;
438
439 if (!email || !code) {
440 return Response.json(
441 { error: "Email and verification code required" },
442 { status: 400 },
443 );
444 }
445
446 // Get user by email
447 const user = getUserByEmail(email);
448 if (!user) {
449 return Response.json(
450 { error: "User not found" },
451 { status: 404 },
452 );
453 }
454
455 // Check if already verified
456 if (isEmailVerified(user.id)) {
457 return Response.json(
458 { error: "Email already verified" },
459 { status: 400 },
460 );
461 }
462
463 const success = verifyEmailCode(user.id, code);
464
465 if (!success) {
466 return Response.json(
467 { error: "Invalid or expired verification code" },
468 { status: 400 },
469 );
470 }
471
472 // Create session after successful verification
473 const ipAddress =
474 req.headers.get("x-forwarded-for") ??
475 req.headers.get("x-real-ip") ??
476 "unknown";
477 const userAgent = req.headers.get("user-agent") ?? "unknown";
478 const sessionId = createSession(user.id, ipAddress, userAgent);
479
480 return Response.json(
481 {
482 message: "Email verified successfully",
483 email_verified: true,
484 user: { id: user.id, email: user.email },
485 },
486 {
487 headers: {
488 "Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`,
489 },
490 },
491 );
492 } catch (error) {
493 return handleError(error);
494 }
495 },
496 },
497 "/api/auth/resend-verification": {
498 POST: async (req) => {
499 try {
500 const user = requireAuth(req);
501
502 // Rate limiting
503 const rateLimitError = enforceRateLimit(req, "resend-verification", {
504 account: { max: 3, windowSeconds: 60 * 60, email: user.email },
505 });
506 if (rateLimitError) return rateLimitError;
507
508 // Check if already verified
509 if (isEmailVerified(user.id)) {
510 return Response.json(
511 { error: "Email already verified" },
512 { status: 400 },
513 );
514 }
515
516 // Generate new code and send email
517 const { code, token } = createEmailVerificationToken(user.id);
518
519 await sendEmail({
520 to: user.email,
521 subject: "Verify your email - Thistle",
522 html: verifyEmailTemplate({
523 name: user.name,
524 code,
525 token,
526 }),
527 });
528
529 return Response.json({ message: "Verification email sent" });
530 } catch (error) {
531 return handleError(error);
532 }
533 },
534 },
535 "/api/auth/forgot-password": {
536 POST: async (req) => {
537 try {
538 // Rate limiting
539 const rateLimitError = enforceRateLimit(req, "forgot-password", {
540 ip: { max: 5, windowSeconds: 60 * 60 },
541 });
542 if (rateLimitError) return rateLimitError;
543
544 const body = await req.json();
545 const { email } = body;
546
547 if (!email) {
548 return Response.json({ error: "Email required" }, { status: 400 });
549 }
550
551 // Always return success to prevent email enumeration
552 const user = getUserByEmail(email);
553 if (user) {
554 const origin =
555 req.headers.get("origin") || "http://localhost:3000";
556 const resetToken = createPasswordResetToken(user.id);
557 const resetLink = `${origin}/reset-password?token=${resetToken}`;
558
559 await sendEmail({
560 to: user.email,
561 subject: "Reset your password - Thistle",
562 html: passwordResetTemplate({
563 name: user.name,
564 resetLink,
565 }),
566 }).catch((err) => {
567 console.error("[Email] Failed to send password reset:", err);
568 });
569 }
570
571 return Response.json({
572 message:
573 "If an account exists with that email, a password reset link has been sent",
574 });
575 } catch (error) {
576 console.error("[Email] Forgot password error:", error);
577 return Response.json(
578 { error: "Failed to process request" },
579 { status: 500 },
580 );
581 }
582 },
583 },
584 "/api/auth/reset-password": {
585 POST: async (req) => {
586 try {
587 const body = await req.json();
588 const { token, password } = body;
589
590 if (!token || !password) {
591 return Response.json(
592 { error: "Token and password required" },
593 { status: 400 },
594 );
595 }
596
597 // Validate password format (client-side hashed PBKDF2)
598 if (password.length !== 64 || !/^[0-9a-f]+$/.test(password)) {
599 return Response.json(
600 { error: "Invalid password format" },
601 { status: 400 },
602 );
603 }
604
605 const userId = verifyPasswordResetToken(token);
606 if (!userId) {
607 return Response.json(
608 { error: "Invalid or expired reset token" },
609 { status: 400 },
610 );
611 }
612
613 // Update password and consume token
614 await updateUserPassword(userId, password);
615 consumePasswordResetToken(token);
616
617 return Response.json({ message: "Password reset successfully" });
618 } catch (error) {
619 console.error("[Email] Reset password error:", error);
620 return Response.json(
621 { error: "Failed to reset password" },
622 { status: 500 },
623 );
624 }
625 },
626 },
627 "/api/auth/logout": {
628 POST: async (req) => {
629 const sessionId = getSessionFromRequest(req);
630 if (sessionId) {
631 deleteSession(sessionId);
632 }
633 return Response.json(
634 { success: true },
635 {
636 headers: {
637 "Set-Cookie":
638 "session=; HttpOnly; Secure; Path=/; Max-Age=0; SameSite=Lax",
639 },
640 },
641 );
642 },
643 },
644 "/api/auth/me": {
645 GET: (req) => {
646 const sessionId = getSessionFromRequest(req);
647 if (!sessionId) {
648 return Response.json({ error: "Not authenticated" }, { status: 401 });
649 }
650 const user = getUserBySession(sessionId);
651 if (!user) {
652 return Response.json({ error: "Invalid session" }, { status: 401 });
653 }
654
655 // Check subscription status
656 const subscription = db
657 .query<{ status: string }, [number]>(
658 "SELECT status FROM subscriptions WHERE user_id = ? AND status IN ('active', 'trialing', 'past_due') ORDER BY created_at DESC LIMIT 1",
659 )
660 .get(user.id);
661
662 return Response.json({
663 email: user.email,
664 name: user.name,
665 avatar: user.avatar,
666 created_at: user.created_at,
667 role: user.role,
668 has_subscription: !!subscription,
669 email_verified: isEmailVerified(user.id),
670 });
671 },
672 },
673 "/api/passkeys/register/options": {
674 POST: async (req) => {
675 try {
676 const user = requireAuth(req);
677 const options = await createRegistrationOptions(user);
678 return Response.json(options);
679 } catch (err) {
680 return handleError(err);
681 }
682 },
683 },
684 "/api/passkeys/register/verify": {
685 POST: async (req) => {
686 try {
687 const _user = requireAuth(req);
688 const body = await req.json();
689 const { response: credentialResponse, challenge, name } = body;
690
691 const passkey = await verifyAndCreatePasskey(
692 credentialResponse,
693 challenge,
694 name,
695 );
696
697 return Response.json({
698 success: true,
699 passkey: {
700 id: passkey.id,
701 name: passkey.name,
702 created_at: passkey.created_at,
703 },
704 });
705 } catch (err) {
706 return handleError(err);
707 }
708 },
709 },
710 "/api/passkeys/authenticate/options": {
711 POST: async (req) => {
712 try {
713 const body = await req.json();
714 const { email } = body;
715
716 const options = await createAuthenticationOptions(email);
717 return Response.json(options);
718 } catch (err) {
719 return handleError(err);
720 }
721 },
722 },
723 "/api/passkeys/authenticate/verify": {
724 POST: async (req) => {
725 try {
726 const body = await req.json();
727 const { response: credentialResponse, challenge } = body;
728
729 const result = await verifyAndAuthenticatePasskey(
730 credentialResponse,
731 challenge,
732 );
733
734 if ("error" in result) {
735 return new Response(JSON.stringify({ error: result.error }), {
736 status: 401,
737 });
738 }
739
740 const { user } = result;
741
742 // Create session
743 const ipAddress =
744 req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
745 req.headers.get("x-real-ip") ||
746 "unknown";
747 const userAgent = req.headers.get("user-agent") || "unknown";
748 const sessionId = createSession(user.id, ipAddress, userAgent);
749
750 return Response.json(
751 {
752 email: user.email,
753 name: user.name,
754 avatar: user.avatar,
755 created_at: user.created_at,
756 role: user.role,
757 },
758 {
759 headers: {
760 "Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`,
761 },
762 },
763 );
764 } catch (err) {
765 return handleError(err);
766 }
767 },
768 },
769 "/api/passkeys": {
770 GET: async (req) => {
771 try {
772 const user = requireAuth(req);
773 const passkeys = getPasskeysForUser(user.id);
774 return Response.json({
775 passkeys: passkeys.map((p) => ({
776 id: p.id,
777 name: p.name,
778 created_at: p.created_at,
779 last_used_at: p.last_used_at,
780 })),
781 });
782 } catch (err) {
783 return handleError(err);
784 }
785 },
786 },
787 "/api/passkeys/:id": {
788 PUT: async (req) => {
789 try {
790 const user = requireAuth(req);
791 const body = await req.json();
792 const { name } = body;
793 const passkeyId = req.params.id;
794
795 if (!name) {
796 return Response.json({ error: "Name required" }, { status: 400 });
797 }
798
799 updatePasskeyName(passkeyId, user.id, name);
800 return Response.json({ success: true });
801 } catch (err) {
802 return handleError(err);
803 }
804 },
805 DELETE: async (req) => {
806 try {
807 const user = requireAuth(req);
808 const passkeyId = req.params.id;
809 deletePasskey(passkeyId, user.id);
810 return Response.json({ success: true });
811 } catch (err) {
812 return handleError(err);
813 }
814 },
815 },
816 "/api/sessions": {
817 GET: (req) => {
818 const sessionId = getSessionFromRequest(req);
819 if (!sessionId) {
820 return Response.json({ error: "Not authenticated" }, { status: 401 });
821 }
822 const user = getUserBySession(sessionId);
823 if (!user) {
824 return Response.json({ error: "Invalid session" }, { status: 401 });
825 }
826 const sessions = getUserSessionsForUser(user.id);
827 return Response.json({
828 sessions: sessions.map((s) => ({
829 id: s.id,
830 ip_address: s.ip_address,
831 user_agent: s.user_agent,
832 created_at: s.created_at,
833 expires_at: s.expires_at,
834 is_current: s.id === sessionId,
835 })),
836 });
837 },
838 DELETE: async (req) => {
839 const currentSessionId = getSessionFromRequest(req);
840 if (!currentSessionId) {
841 return Response.json({ error: "Not authenticated" }, { status: 401 });
842 }
843 const user = getUserBySession(currentSessionId);
844 if (!user) {
845 return Response.json({ error: "Invalid session" }, { status: 401 });
846 }
847 const body = await req.json();
848 const targetSessionId = body.sessionId;
849 if (!targetSessionId) {
850 return Response.json(
851 { error: "Session ID required" },
852 { status: 400 },
853 );
854 }
855 // Prevent deleting current session
856 if (targetSessionId === currentSessionId) {
857 return Response.json(
858 { error: "Cannot kill current session. Use logout instead." },
859 { status: 400 },
860 );
861 }
862 // Verify the session belongs to the user
863 const targetSession = getSession(targetSessionId);
864 if (!targetSession || targetSession.user_id !== user.id) {
865 return Response.json({ error: "Session not found" }, { status: 404 });
866 }
867 deleteSession(targetSessionId);
868 return Response.json({ success: true });
869 },
870 },
871 "/api/user": {
872 DELETE: async (req) => {
873 const sessionId = getSessionFromRequest(req);
874 if (!sessionId) {
875 return Response.json({ error: "Not authenticated" }, { status: 401 });
876 }
877 const user = getUserBySession(sessionId);
878 if (!user) {
879 return Response.json({ error: "Invalid session" }, { status: 401 });
880 }
881
882 // Rate limiting
883 const rateLimitError = enforceRateLimit(req, "delete-user", {
884 ip: { max: 3, windowSeconds: 60 * 60 },
885 });
886 if (rateLimitError) return rateLimitError;
887
888 await deleteUser(user.id);
889 return Response.json(
890 { success: true },
891 {
892 headers: {
893 "Set-Cookie":
894 "session=; HttpOnly; Secure; Path=/; Max-Age=0; SameSite=Lax",
895 },
896 },
897 );
898 },
899 },
900 "/api/user/email": {
901 PUT: async (req) => {
902 const sessionId = getSessionFromRequest(req);
903 if (!sessionId) {
904 return Response.json({ error: "Not authenticated" }, { status: 401 });
905 }
906 const user = getUserBySession(sessionId);
907 if (!user) {
908 return Response.json({ error: "Invalid session" }, { status: 401 });
909 }
910
911 // Rate limiting
912 const rateLimitError = enforceRateLimit(req, "update-email", {
913 ip: { max: 5, windowSeconds: 60 * 60 },
914 });
915 if (rateLimitError) return rateLimitError;
916
917 const body = await req.json();
918 const { email } = body;
919 if (!email) {
920 return Response.json({ error: "Email required" }, { status: 400 });
921 }
922 try {
923 updateUserEmail(user.id, email);
924 return Response.json({ success: true });
925 } catch (err: unknown) {
926 const error = err as { message?: string };
927 if (error.message?.includes("UNIQUE constraint failed")) {
928 return Response.json(
929 { error: "Email already in use" },
930 { status: 400 },
931 );
932 }
933 return Response.json(
934 { error: "Failed to update email" },
935 { status: 500 },
936 );
937 }
938 },
939 },
940 "/api/user/password": {
941 PUT: async (req) => {
942 const sessionId = getSessionFromRequest(req);
943 if (!sessionId) {
944 return Response.json({ error: "Not authenticated" }, { status: 401 });
945 }
946 const user = getUserBySession(sessionId);
947 if (!user) {
948 return Response.json({ error: "Invalid session" }, { status: 401 });
949 }
950
951 // Rate limiting
952 const rateLimitError = enforceRateLimit(req, "update-password", {
953 ip: { max: 5, windowSeconds: 60 * 60 },
954 });
955 if (rateLimitError) return rateLimitError;
956
957 const body = await req.json();
958 const { password } = body;
959 if (!password) {
960 return Response.json({ error: "Password required" }, { status: 400 });
961 }
962 // Password is client-side hashed (PBKDF2), should be 64 char hex
963 if (password.length !== 64 || !/^[0-9a-f]+$/.test(password)) {
964 return Response.json(
965 { error: "Invalid password format" },
966 { status: 400 },
967 );
968 }
969 try {
970 await updateUserPassword(user.id, password);
971 return Response.json({ success: true });
972 } catch {
973 return Response.json(
974 { error: "Failed to update password" },
975 { status: 500 },
976 );
977 }
978 },
979 },
980 "/api/user/name": {
981 PUT: async (req) => {
982 const sessionId = getSessionFromRequest(req);
983 if (!sessionId) {
984 return Response.json({ error: "Not authenticated" }, { status: 401 });
985 }
986 const user = getUserBySession(sessionId);
987 if (!user) {
988 return Response.json({ error: "Invalid session" }, { status: 401 });
989 }
990 const body = await req.json();
991 const { name } = body;
992 if (!name) {
993 return Response.json({ error: "Name required" }, { status: 400 });
994 }
995 try {
996 updateUserName(user.id, name);
997 return Response.json({ success: true });
998 } catch {
999 return Response.json(
1000 { error: "Failed to update name" },
1001 { status: 500 },
1002 );
1003 }
1004 },
1005 },
1006 "/api/user/avatar": {
1007 PUT: async (req) => {
1008 const sessionId = getSessionFromRequest(req);
1009 if (!sessionId) {
1010 return Response.json({ error: "Not authenticated" }, { status: 401 });
1011 }
1012 const user = getUserBySession(sessionId);
1013 if (!user) {
1014 return Response.json({ error: "Invalid session" }, { status: 401 });
1015 }
1016 const body = await req.json();
1017 const { avatar } = body;
1018 if (!avatar) {
1019 return Response.json({ error: "Avatar required" }, { status: 400 });
1020 }
1021 try {
1022 updateUserAvatar(user.id, avatar);
1023 return Response.json({ success: true });
1024 } catch {
1025 return Response.json(
1026 { error: "Failed to update avatar" },
1027 { status: 500 },
1028 );
1029 }
1030 },
1031 },
1032 "/api/billing/checkout": {
1033 POST: async (req) => {
1034 const sessionId = getSessionFromRequest(req);
1035 if (!sessionId) {
1036 return Response.json({ error: "Not authenticated" }, { status: 401 });
1037 }
1038 const user = getUserBySession(sessionId);
1039 if (!user) {
1040 return Response.json({ error: "Invalid session" }, { status: 401 });
1041 }
1042
1043 try {
1044 const { polar } = await import("./lib/polar");
1045
1046 const productId = process.env.POLAR_PRODUCT_ID;
1047 if (!productId) {
1048 return Response.json(
1049 { error: "Product not configured" },
1050 { status: 500 },
1051 );
1052 }
1053
1054 const successUrl = process.env.POLAR_SUCCESS_URL;
1055 if (!successUrl) {
1056 return Response.json(
1057 { error: "Success URL not configured" },
1058 { status: 500 },
1059 );
1060 }
1061
1062 const checkout = await polar.checkouts.create({
1063 products: [productId],
1064 successUrl,
1065 customerEmail: user.email,
1066 customerName: user.name ?? undefined,
1067 metadata: {
1068 userId: user.id.toString(),
1069 },
1070 });
1071
1072 return Response.json({ url: checkout.url });
1073 } catch (error) {
1074 console.error("Failed to create checkout:", error);
1075 return Response.json(
1076 { error: "Failed to create checkout session" },
1077 { status: 500 },
1078 );
1079 }
1080 },
1081 },
1082 "/api/billing/subscription": {
1083 GET: async (req) => {
1084 const sessionId = getSessionFromRequest(req);
1085 if (!sessionId) {
1086 return Response.json({ error: "Not authenticated" }, { status: 401 });
1087 }
1088 const user = getUserBySession(sessionId);
1089 if (!user) {
1090 return Response.json({ error: "Invalid session" }, { status: 401 });
1091 }
1092
1093 try {
1094 // Get subscription from database
1095 const subscription = db
1096 .query<
1097 {
1098 id: string;
1099 status: string;
1100 current_period_start: number | null;
1101 current_period_end: number | null;
1102 cancel_at_period_end: number;
1103 canceled_at: number | null;
1104 },
1105 [number]
1106 >(
1107 "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",
1108 )
1109 .get(user.id);
1110
1111 if (!subscription) {
1112 return Response.json({ subscription: null });
1113 }
1114
1115 return Response.json({ subscription });
1116 } catch (error) {
1117 console.error("Failed to fetch subscription:", error);
1118 return Response.json(
1119 { error: "Failed to fetch subscription" },
1120 { status: 500 },
1121 );
1122 }
1123 },
1124 },
1125 "/api/billing/portal": {
1126 POST: async (req) => {
1127 const sessionId = getSessionFromRequest(req);
1128 if (!sessionId) {
1129 return Response.json({ error: "Not authenticated" }, { status: 401 });
1130 }
1131 const user = getUserBySession(sessionId);
1132 if (!user) {
1133 return Response.json({ error: "Invalid session" }, { status: 401 });
1134 }
1135
1136 try {
1137 const { polar } = await import("./lib/polar");
1138
1139 // Get subscription to find customer ID
1140 const subscription = db
1141 .query<
1142 {
1143 customer_id: string;
1144 },
1145 [number]
1146 >(
1147 "SELECT customer_id FROM subscriptions WHERE user_id = ? ORDER BY created_at DESC LIMIT 1",
1148 )
1149 .get(user.id);
1150
1151 if (!subscription || !subscription.customer_id) {
1152 return Response.json(
1153 { error: "No subscription found" },
1154 { status: 404 },
1155 );
1156 }
1157
1158 // Create customer portal session
1159 const session = await polar.customerSessions.create({
1160 customerId: subscription.customer_id,
1161 });
1162
1163 return Response.json({ url: session.customerPortalUrl });
1164 } catch (error) {
1165 console.error("Failed to create portal session:", error);
1166 return Response.json(
1167 { error: "Failed to create portal session" },
1168 { status: 500 },
1169 );
1170 }
1171 },
1172 },
1173 "/api/webhooks/polar": {
1174 POST: async (req) => {
1175 try {
1176 const { validateEvent } = await import("@polar-sh/sdk/webhooks");
1177
1178 // Get raw body as string
1179 const rawBody = await req.text();
1180 const headers = Object.fromEntries(req.headers.entries());
1181
1182 // Validate webhook signature
1183 const webhookSecret = process.env.POLAR_WEBHOOK_SECRET;
1184 if (!webhookSecret) {
1185 console.error("[Webhook] POLAR_WEBHOOK_SECRET not configured");
1186 return Response.json(
1187 { error: "Webhook secret not configured" },
1188 { status: 500 },
1189 );
1190 }
1191
1192 const event = validateEvent(rawBody, headers, webhookSecret);
1193
1194 console.log(`[Webhook] Received event: ${event.type}`);
1195
1196 // Handle different event types
1197 switch (event.type) {
1198 case "subscription.updated": {
1199 const { id, status, customerId, metadata } = event.data;
1200 const userId = metadata?.userId
1201 ? Number.parseInt(metadata.userId as string, 10)
1202 : null;
1203
1204 if (!userId) {
1205 console.warn("[Webhook] No userId in subscription metadata");
1206 break;
1207 }
1208
1209 // Upsert subscription
1210 db.run(
1211 `INSERT INTO subscriptions (id, user_id, customer_id, status, current_period_start, current_period_end, cancel_at_period_end, canceled_at, updated_at)
1212 VALUES (?, ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'))
1213 ON CONFLICT(id) DO UPDATE SET
1214 status = excluded.status,
1215 current_period_start = excluded.current_period_start,
1216 current_period_end = excluded.current_period_end,
1217 cancel_at_period_end = excluded.cancel_at_period_end,
1218 canceled_at = excluded.canceled_at,
1219 updated_at = strftime('%s', 'now')`,
1220 [
1221 id,
1222 userId,
1223 customerId,
1224 status,
1225 event.data.currentPeriodStart
1226 ? Math.floor(
1227 new Date(event.data.currentPeriodStart).getTime() /
1228 1000,
1229 )
1230 : null,
1231 event.data.currentPeriodEnd
1232 ? Math.floor(
1233 new Date(event.data.currentPeriodEnd).getTime() / 1000,
1234 )
1235 : null,
1236 event.data.cancelAtPeriodEnd ? 1 : 0,
1237 event.data.canceledAt
1238 ? Math.floor(
1239 new Date(event.data.canceledAt).getTime() / 1000,
1240 )
1241 : null,
1242 ],
1243 );
1244
1245 console.log(
1246 `[Webhook] Updated subscription ${id} for user ${userId}`,
1247 );
1248 break;
1249 }
1250
1251 default:
1252 console.log(`[Webhook] Unhandled event type: ${event.type}`);
1253 }
1254
1255 return Response.json({ received: true });
1256 } catch (error) {
1257 console.error("[Webhook] Error processing webhook:", error);
1258 return Response.json(
1259 { error: "Webhook processing failed" },
1260 { status: 400 },
1261 );
1262 }
1263 },
1264 },
1265 "/api/transcriptions/:id/stream": {
1266 GET: async (req) => {
1267 try {
1268 const user = requireAuth(req);
1269 const transcriptionId = req.params.id;
1270 // Verify ownership
1271 const transcription = db
1272 .query<{ id: string; user_id: number; class_id: string | null; status: string }, [string]>(
1273 "SELECT id, user_id, class_id, status FROM transcriptions WHERE id = ?",
1274 )
1275 .get(transcriptionId);
1276
1277 if (!transcription) {
1278 return Response.json(
1279 { error: "Transcription not found" },
1280 { status: 404 },
1281 );
1282 }
1283
1284 // Check access permissions
1285 const isOwner = transcription.user_id === user.id;
1286 const isAdmin = user.role === "admin";
1287 let isClassMember = false;
1288
1289 // If transcription belongs to a class, check enrollment
1290 if (transcription.class_id) {
1291 isClassMember = isUserEnrolledInClass(user.id, transcription.class_id);
1292 }
1293
1294 // Allow access if: owner, admin, or enrolled in the class
1295 if (!isOwner && !isAdmin && !isClassMember) {
1296 return Response.json(
1297 { error: "Transcription not found" },
1298 { status: 404 },
1299 );
1300 }
1301
1302 // Require subscription only if accessing own transcription (not class)
1303 if (isOwner && !transcription.class_id && !isAdmin && !hasActiveSubscription(user.id)) {
1304 throw AuthErrors.subscriptionRequired();
1305 }
1306 // Event-driven SSE stream with reconnection support
1307 const stream = new ReadableStream({
1308 async start(controller) {
1309 const encoder = new TextEncoder();
1310 let isClosed = false;
1311 let lastEventId = Math.floor(Date.now() / 1000);
1312
1313 const sendEvent = (data: Partial<TranscriptionUpdate>) => {
1314 if (isClosed) return;
1315 try {
1316 // Send event ID for reconnection support
1317 lastEventId = Math.floor(Date.now() / 1000);
1318 controller.enqueue(
1319 encoder.encode(
1320 `id: ${lastEventId}\nevent: update\ndata: ${JSON.stringify(data)}\n\n`,
1321 ),
1322 );
1323 } catch {
1324 // Controller already closed (client disconnected)
1325 isClosed = true;
1326 }
1327 };
1328
1329 const sendHeartbeat = () => {
1330 if (isClosed) return;
1331 try {
1332 controller.enqueue(encoder.encode(": heartbeat\n\n"));
1333 } catch {
1334 isClosed = true;
1335 }
1336 };
1337 // Send initial state from DB and file
1338 const current = db
1339 .query<
1340 {
1341 status: string;
1342 progress: number;
1343 },
1344 [string]
1345 >("SELECT status, progress FROM transcriptions WHERE id = ?")
1346 .get(transcriptionId);
1347 if (current) {
1348 sendEvent({
1349 status: current.status as TranscriptionUpdate["status"],
1350 progress: current.progress,
1351 });
1352 }
1353 // If already complete, close immediately
1354 if (
1355 current?.status === "completed" ||
1356 current?.status === "failed"
1357 ) {
1358 isClosed = true;
1359 controller.close();
1360 return;
1361 }
1362 // Send heartbeats every 2.5 seconds to keep connection alive
1363 const heartbeatInterval = setInterval(sendHeartbeat, 2500);
1364
1365 // Subscribe to EventEmitter for live updates
1366 const updateHandler = (data: TranscriptionUpdate) => {
1367 if (isClosed) return;
1368
1369 // Only send changed fields to save bandwidth
1370 const payload: Partial<TranscriptionUpdate> = {
1371 status: data.status,
1372 progress: data.progress,
1373 };
1374
1375 if (data.transcript !== undefined) {
1376 payload.transcript = data.transcript;
1377 }
1378 if (data.error_message !== undefined) {
1379 payload.error_message = data.error_message;
1380 }
1381
1382 sendEvent(payload);
1383
1384 // Close stream when done
1385 if (data.status === "completed" || data.status === "failed") {
1386 isClosed = true;
1387 clearInterval(heartbeatInterval);
1388 transcriptionEvents.off(transcriptionId, updateHandler);
1389 controller.close();
1390 }
1391 };
1392 transcriptionEvents.on(transcriptionId, updateHandler);
1393 // Cleanup on client disconnect
1394 return () => {
1395 isClosed = true;
1396 clearInterval(heartbeatInterval);
1397 transcriptionEvents.off(transcriptionId, updateHandler);
1398 };
1399 },
1400 });
1401 return new Response(stream, {
1402 headers: {
1403 "Content-Type": "text/event-stream",
1404 "Cache-Control": "no-cache",
1405 Connection: "keep-alive",
1406 },
1407 });
1408 } catch (error) {
1409 return handleError(error);
1410 }
1411 },
1412 },
1413 "/api/transcriptions/health": {
1414 GET: async () => {
1415 const isHealthy = await whisperService.checkHealth();
1416 return Response.json({ available: isHealthy });
1417 },
1418 },
1419 "/api/transcriptions/:id": {
1420 GET: async (req) => {
1421 try {
1422 const user = requireAuth(req);
1423 const transcriptionId = req.params.id;
1424
1425 // Verify ownership or admin
1426 const transcription = db
1427 .query<
1428 {
1429 id: string;
1430 user_id: number;
1431 class_id: string | null;
1432 filename: string;
1433 original_filename: string;
1434 status: string;
1435 progress: number;
1436 created_at: number;
1437 },
1438 [string]
1439 >(
1440 "SELECT id, user_id, class_id, filename, original_filename, status, progress, created_at FROM transcriptions WHERE id = ?",
1441 )
1442 .get(transcriptionId);
1443
1444 if (!transcription) {
1445 return Response.json(
1446 { error: "Transcription not found" },
1447 { status: 404 },
1448 );
1449 }
1450
1451 // Check access permissions
1452 const isOwner = transcription.user_id === user.id;
1453 const isAdmin = user.role === "admin";
1454 let isClassMember = false;
1455
1456 // If transcription belongs to a class, check enrollment
1457 if (transcription.class_id) {
1458 isClassMember = isUserEnrolledInClass(user.id, transcription.class_id);
1459 }
1460
1461 // Allow access if: owner, admin, or enrolled in the class
1462 if (!isOwner && !isAdmin && !isClassMember) {
1463 return Response.json(
1464 { error: "Transcription not found" },
1465 { status: 404 },
1466 );
1467 }
1468
1469 // Require subscription only if accessing own transcription (not class)
1470 if (isOwner && !transcription.class_id && !isAdmin && !hasActiveSubscription(user.id)) {
1471 throw AuthErrors.subscriptionRequired();
1472 }
1473
1474 if (transcription.status !== "completed") {
1475 return Response.json(
1476 { error: "Transcription not completed yet" },
1477 { status: 400 },
1478 );
1479 }
1480
1481 // Get format from query parameter
1482 const url = new URL(req.url);
1483 const format = url.searchParams.get("format");
1484
1485 // Return WebVTT format if requested
1486 if (format === "vtt") {
1487 const vttContent = await getTranscriptVTT(transcriptionId);
1488
1489 if (!vttContent) {
1490 return Response.json(
1491 { error: "VTT transcript not available" },
1492 { status: 404 },
1493 );
1494 }
1495
1496 return new Response(vttContent, {
1497 headers: {
1498 "Content-Type": "text/vtt",
1499 "Content-Disposition": `attachment; filename="${transcription.original_filename}.vtt"`,
1500 },
1501 });
1502 }
1503
1504 // return info on transcript
1505 const transcript = {
1506 id: transcription.id,
1507 filename: transcription.original_filename,
1508 status: transcription.status,
1509 progress: transcription.progress,
1510 created_at: transcription.created_at,
1511 };
1512 return new Response(JSON.stringify(transcript), {
1513 headers: {
1514 "Content-Type": "application/json",
1515 },
1516 });
1517 } catch (error) {
1518 return handleError(error);
1519 }
1520 },
1521 },
1522 "/api/transcriptions/:id/audio": {
1523 GET: async (req) => {
1524 try {
1525 const user = requireAuth(req);
1526 const transcriptionId = req.params.id;
1527
1528 // Verify ownership or admin
1529 const transcription = db
1530 .query<
1531 {
1532 id: string;
1533 user_id: number;
1534 class_id: string | null;
1535 filename: string;
1536 status: string;
1537 },
1538 [string]
1539 >(
1540 "SELECT id, user_id, class_id, filename, status FROM transcriptions WHERE id = ?",
1541 )
1542 .get(transcriptionId);
1543
1544 if (!transcription) {
1545 return Response.json(
1546 { error: "Transcription not found" },
1547 { status: 404 },
1548 );
1549 }
1550
1551 // Check access permissions
1552 const isOwner = transcription.user_id === user.id;
1553 const isAdmin = user.role === "admin";
1554 let isClassMember = false;
1555
1556 // If transcription belongs to a class, check enrollment
1557 if (transcription.class_id) {
1558 isClassMember = isUserEnrolledInClass(user.id, transcription.class_id);
1559 }
1560
1561 // Allow access if: owner, admin, or enrolled in the class
1562 if (!isOwner && !isAdmin && !isClassMember) {
1563 return Response.json(
1564 { error: "Transcription not found" },
1565 { status: 404 },
1566 );
1567 }
1568
1569 // Require subscription only if accessing own transcription (not class)
1570 if (isOwner && !transcription.class_id && !isAdmin && !hasActiveSubscription(user.id)) {
1571 throw AuthErrors.subscriptionRequired();
1572 }
1573
1574 // For pending recordings, audio file exists even though transcription isn't complete
1575 // Allow audio access for pending and completed statuses
1576 if (
1577 transcription.status !== "completed" &&
1578 transcription.status !== "pending"
1579 ) {
1580 return Response.json(
1581 { error: "Audio not available yet" },
1582 { status: 400 },
1583 );
1584 }
1585
1586 // Serve the audio file with range request support
1587 const filePath = `./uploads/${transcription.filename}`;
1588 const file = Bun.file(filePath);
1589
1590 if (!(await file.exists())) {
1591 return Response.json(
1592 { error: "Audio file not found" },
1593 { status: 404 },
1594 );
1595 }
1596
1597 const fileSize = file.size;
1598 const range = req.headers.get("range");
1599
1600 // Handle range requests for seeking
1601 if (range) {
1602 const parts = range.replace(/bytes=/, "").split("-");
1603 const start = Number.parseInt(parts[0] || "0", 10);
1604 const end = parts[1] ? Number.parseInt(parts[1], 10) : fileSize - 1;
1605 const chunkSize = end - start + 1;
1606
1607 const fileSlice = file.slice(start, end + 1);
1608
1609 return new Response(fileSlice, {
1610 status: 206,
1611 headers: {
1612 "Content-Range": `bytes ${start}-${end}/${fileSize}`,
1613 "Accept-Ranges": "bytes",
1614 "Content-Length": chunkSize.toString(),
1615 "Content-Type": file.type || "audio/mpeg",
1616 },
1617 });
1618 }
1619
1620 // No range request, send entire file
1621 return new Response(file, {
1622 headers: {
1623 "Content-Type": file.type || "audio/mpeg",
1624 "Accept-Ranges": "bytes",
1625 "Content-Length": fileSize.toString(),
1626 },
1627 });
1628 } catch (error) {
1629 return handleError(error);
1630 }
1631 },
1632 },
1633 "/api/transcriptions": {
1634 GET: async (req) => {
1635 try {
1636 const user = requireSubscription(req);
1637
1638 const transcriptions = db
1639 .query<
1640 {
1641 id: string;
1642 filename: string;
1643 original_filename: string;
1644 class_id: string | null;
1645 status: string;
1646 progress: number;
1647 created_at: number;
1648 },
1649 [number]
1650 >(
1651 "SELECT id, filename, original_filename, class_id, status, progress, created_at FROM transcriptions WHERE user_id = ? ORDER BY created_at DESC",
1652 )
1653 .all(user.id);
1654
1655 // Load transcripts from files for completed jobs
1656 const jobs = await Promise.all(
1657 transcriptions.map(async (t) => {
1658 return {
1659 id: t.id,
1660 filename: t.original_filename,
1661 class_id: t.class_id,
1662 status: t.status,
1663 progress: t.progress,
1664 created_at: t.created_at,
1665 };
1666 }),
1667 );
1668
1669 return Response.json({ jobs });
1670 } catch (error) {
1671 return handleError(error);
1672 }
1673 },
1674 POST: async (req) => {
1675 try {
1676 const user = requireSubscription(req);
1677
1678 const formData = await req.formData();
1679 const file = formData.get("audio") as File;
1680 const classId = formData.get("class_id") as string | null;
1681 const meetingTimeId = formData.get("meeting_time_id") as
1682 | string
1683 | null;
1684
1685 if (!file) throw ValidationErrors.missingField("audio");
1686
1687 // If class_id provided, verify user is enrolled (or admin)
1688 if (classId) {
1689 const enrolled = isUserEnrolledInClass(user.id, classId);
1690 if (!enrolled && user.role !== "admin") {
1691 return Response.json(
1692 { error: "Not enrolled in this class" },
1693 { status: 403 },
1694 );
1695 }
1696
1697 // Verify class exists
1698 const classInfo = getClassById(classId);
1699 if (!classInfo) {
1700 return Response.json(
1701 { error: "Class not found" },
1702 { status: 404 },
1703 );
1704 }
1705
1706 // Check if class is archived
1707 if (classInfo.archived) {
1708 return Response.json(
1709 { error: "Cannot upload to archived class" },
1710 { status: 400 },
1711 );
1712 }
1713 }
1714
1715 // Validate file type
1716 const fileExtension = file.name.split(".").pop()?.toLowerCase();
1717 const allowedExtensions = [
1718 "mp3",
1719 "wav",
1720 "m4a",
1721 "aac",
1722 "ogg",
1723 "webm",
1724 "flac",
1725 "mp4",
1726 ];
1727 const isAudioType =
1728 file.type.startsWith("audio/") || file.type === "video/mp4";
1729 const isAudioExtension =
1730 fileExtension && allowedExtensions.includes(fileExtension);
1731
1732 if (!isAudioType && !isAudioExtension) {
1733 throw ValidationErrors.unsupportedFileType(
1734 "MP3, WAV, M4A, AAC, OGG, WebM, FLAC",
1735 );
1736 }
1737
1738 if (file.size > MAX_FILE_SIZE) {
1739 throw ValidationErrors.fileTooLarge("100MB");
1740 }
1741
1742 // Generate unique filename
1743 const transcriptionId = crypto.randomUUID();
1744 const filename = `${transcriptionId}.${fileExtension}`;
1745
1746 // Save file to disk
1747 const uploadDir = "./uploads";
1748 await Bun.write(`${uploadDir}/${filename}`, file);
1749
1750 // Create database record
1751 db.run(
1752 "INSERT INTO transcriptions (id, user_id, class_id, meeting_time_id, filename, original_filename, status) VALUES (?, ?, ?, ?, ?, ?, ?)",
1753 [
1754 transcriptionId,
1755 user.id,
1756 classId,
1757 meetingTimeId,
1758 filename,
1759 file.name,
1760 "pending",
1761 ],
1762 );
1763
1764 // Don't auto-start transcription - admin will select recordings
1765 // whisperService.startTranscription(transcriptionId, filename);
1766
1767 return Response.json({
1768 id: transcriptionId,
1769 message: "Upload successful",
1770 });
1771 } catch (error) {
1772 return handleError(error);
1773 }
1774 },
1775 },
1776 "/api/admin/transcriptions": {
1777 GET: async (req) => {
1778 try {
1779 requireAdmin(req);
1780 const transcriptions = getAllTranscriptions();
1781 return Response.json(transcriptions);
1782 } catch (error) {
1783 return handleError(error);
1784 }
1785 },
1786 },
1787 "/api/admin/users": {
1788 GET: async (req) => {
1789 try {
1790 requireAdmin(req);
1791 const users = getAllUsersWithStats();
1792 return Response.json(users);
1793 } catch (error) {
1794 return handleError(error);
1795 }
1796 },
1797 },
1798 "/api/admin/classes": {
1799 GET: async (req) => {
1800 try {
1801 requireAdmin(req);
1802 const classes = getClassesForUser(0, true); // Admin sees all classes
1803 return Response.json({ classes });
1804 } catch (error) {
1805 return handleError(error);
1806 }
1807 },
1808 },
1809 "/api/admin/waitlist": {
1810 GET: async (req) => {
1811 try {
1812 requireAdmin(req);
1813 const waitlist = getAllWaitlistEntries();
1814 return Response.json({ waitlist });
1815 } catch (error) {
1816 return handleError(error);
1817 }
1818 },
1819 },
1820 "/api/admin/waitlist/:id": {
1821 DELETE: async (req) => {
1822 try {
1823 requireAdmin(req);
1824 const id = req.params.id;
1825 deleteWaitlistEntry(id);
1826 return Response.json({ success: true });
1827 } catch (error) {
1828 return handleError(error);
1829 }
1830 },
1831 },
1832 "/api/admin/transcriptions/:id": {
1833 DELETE: async (req) => {
1834 try {
1835 requireAdmin(req);
1836 const transcriptionId = req.params.id;
1837 deleteTranscription(transcriptionId);
1838 return Response.json({ success: true });
1839 } catch (error) {
1840 return handleError(error);
1841 }
1842 },
1843 },
1844 "/api/admin/users/:id": {
1845 DELETE: async (req) => {
1846 try {
1847 requireAdmin(req);
1848 const userId = Number.parseInt(req.params.id, 10);
1849 if (Number.isNaN(userId)) {
1850 return Response.json({ error: "Invalid user ID" }, { status: 400 });
1851 }
1852 await deleteUser(userId);
1853 return Response.json({ success: true });
1854 } catch (error) {
1855 return handleError(error);
1856 }
1857 },
1858 },
1859 "/api/admin/users/:id/role": {
1860 PUT: async (req) => {
1861 try {
1862 requireAdmin(req);
1863 const userId = Number.parseInt(req.params.id, 10);
1864 if (Number.isNaN(userId)) {
1865 return Response.json({ error: "Invalid user ID" }, { status: 400 });
1866 }
1867
1868 const body = await req.json();
1869 const { role } = body as { role: UserRole };
1870
1871 if (!role || (role !== "user" && role !== "admin")) {
1872 return Response.json(
1873 { error: "Invalid role. Must be 'user' or 'admin'" },
1874 { status: 400 },
1875 );
1876 }
1877
1878 updateUserRole(userId, role);
1879 return Response.json({ success: true });
1880 } catch (error) {
1881 return handleError(error);
1882 }
1883 },
1884 },
1885 "/api/admin/users/:id/subscription": {
1886 DELETE: async (req) => {
1887 try {
1888 requireAdmin(req);
1889 const userId = Number.parseInt(req.params.id, 10);
1890 if (Number.isNaN(userId)) {
1891 return Response.json({ error: "Invalid user ID" }, { status: 400 });
1892 }
1893
1894 const body = await req.json();
1895 const { subscriptionId } = body as { subscriptionId: string };
1896
1897 if (!subscriptionId) {
1898 return Response.json(
1899 { error: "Subscription ID required" },
1900 { status: 400 },
1901 );
1902 }
1903
1904 try {
1905 const { polar } = await import("./lib/polar");
1906 await polar.subscriptions.revoke({ id: subscriptionId });
1907 return Response.json({
1908 success: true,
1909 message: "Subscription revoked successfully",
1910 });
1911 } catch (error) {
1912 console.error(
1913 `[Admin] Failed to revoke subscription ${subscriptionId}:`,
1914 error,
1915 );
1916 return Response.json(
1917 {
1918 error:
1919 error instanceof Error
1920 ? error.message
1921 : "Failed to revoke subscription",
1922 },
1923 { status: 500 },
1924 );
1925 }
1926 } catch (error) {
1927 return handleError(error);
1928 }
1929 },
1930 PUT: async (req) => {
1931 try {
1932 requireAdmin(req);
1933 const userId = Number.parseInt(req.params.id, 10);
1934 if (Number.isNaN(userId)) {
1935 return Response.json({ error: "Invalid user ID" }, { status: 400 });
1936 }
1937
1938 // Get user email
1939 const user = db
1940 .query<{ email: string }, [number]>(
1941 "SELECT email FROM users WHERE id = ?",
1942 )
1943 .get(userId);
1944
1945 if (!user) {
1946 return Response.json(
1947 { error: "User not found" },
1948 { status: 404 },
1949 );
1950 }
1951
1952 try {
1953 await syncUserSubscriptionsFromPolar(userId, user.email);
1954 return Response.json({
1955 success: true,
1956 message: "Subscription synced successfully",
1957 });
1958 } catch (error) {
1959 console.error(
1960 `[Admin] Failed to sync subscription for user ${userId}:`,
1961 error,
1962 );
1963 return Response.json(
1964 {
1965 error:
1966 error instanceof Error
1967 ? error.message
1968 : "Failed to sync subscription",
1969 },
1970 { status: 500 },
1971 );
1972 }
1973 } catch (error) {
1974 return handleError(error);
1975 }
1976 },
1977 },
1978 "/api/admin/users/:id/details": {
1979 GET: async (req) => {
1980 try {
1981 requireAdmin(req);
1982 const userId = Number.parseInt(req.params.id, 10);
1983 if (Number.isNaN(userId)) {
1984 return Response.json({ error: "Invalid user ID" }, { status: 400 });
1985 }
1986
1987 const user = db
1988 .query<
1989 {
1990 id: number;
1991 email: string;
1992 name: string | null;
1993 avatar: string;
1994 created_at: number;
1995 role: UserRole;
1996 password_hash: string | null;
1997 last_login: number | null;
1998 },
1999 [number]
2000 >(
2001 "SELECT id, email, name, avatar, created_at, role, password_hash, last_login FROM users WHERE id = ?",
2002 )
2003 .get(userId);
2004
2005 if (!user) {
2006 return Response.json({ error: "User not found" }, { status: 404 });
2007 }
2008
2009 const passkeys = getPasskeysForUser(userId);
2010 const sessions = getSessionsForUser(userId);
2011
2012 // Get transcription count
2013 const transcriptionCount =
2014 db
2015 .query<{ count: number }, [number]>(
2016 "SELECT COUNT(*) as count FROM transcriptions WHERE user_id = ?",
2017 )
2018 .get(userId)?.count ?? 0;
2019
2020 return Response.json({
2021 id: user.id,
2022 email: user.email,
2023 name: user.name,
2024 avatar: user.avatar,
2025 created_at: user.created_at,
2026 role: user.role,
2027 last_login: user.last_login,
2028 hasPassword: !!user.password_hash,
2029 transcriptionCount,
2030 passkeys: passkeys.map((pk) => ({
2031 id: pk.id,
2032 name: pk.name,
2033 created_at: pk.created_at,
2034 last_used_at: pk.last_used_at,
2035 })),
2036 sessions: sessions.map((s) => ({
2037 id: s.id,
2038 ip_address: s.ip_address,
2039 user_agent: s.user_agent,
2040 created_at: s.created_at,
2041 expires_at: s.expires_at,
2042 })),
2043 });
2044 } catch (error) {
2045 return handleError(error);
2046 }
2047 },
2048 },
2049 "/api/admin/users/:id/password": {
2050 PUT: async (req) => {
2051 try {
2052 requireAdmin(req);
2053 const userId = Number.parseInt(req.params.id, 10);
2054 if (Number.isNaN(userId)) {
2055 return Response.json({ error: "Invalid user ID" }, { status: 400 });
2056 }
2057
2058 const body = await req.json();
2059 const { password } = body as { password: string };
2060
2061 if (!password || password.length < 8) {
2062 return Response.json(
2063 { error: "Password must be at least 8 characters" },
2064 { status: 400 },
2065 );
2066 }
2067
2068 await updateUserPassword(userId, password);
2069 return Response.json({ success: true });
2070 } catch (error) {
2071 return handleError(error);
2072 }
2073 },
2074 },
2075 "/api/admin/users/:id/passkeys/:passkeyId": {
2076 DELETE: async (req) => {
2077 try {
2078 requireAdmin(req);
2079 const userId = Number.parseInt(req.params.id, 10);
2080 if (Number.isNaN(userId)) {
2081 return Response.json({ error: "Invalid user ID" }, { status: 400 });
2082 }
2083
2084 const { passkeyId } = req.params;
2085 deletePasskey(passkeyId, userId);
2086 return Response.json({ success: true });
2087 } catch (error) {
2088 return handleError(error);
2089 }
2090 },
2091 },
2092 "/api/admin/users/:id/name": {
2093 PUT: async (req) => {
2094 try {
2095 requireAdmin(req);
2096 const userId = Number.parseInt(req.params.id, 10);
2097 if (Number.isNaN(userId)) {
2098 return Response.json({ error: "Invalid user ID" }, { status: 400 });
2099 }
2100
2101 const body = await req.json();
2102 const { name } = body as { name: string };
2103
2104 if (!name || name.trim().length === 0) {
2105 return Response.json(
2106 { error: "Name cannot be empty" },
2107 { status: 400 },
2108 );
2109 }
2110
2111 updateUserName(userId, name.trim());
2112 return Response.json({ success: true });
2113 } catch (error) {
2114 return handleError(error);
2115 }
2116 },
2117 },
2118 "/api/admin/users/:id/email": {
2119 PUT: async (req) => {
2120 try {
2121 requireAdmin(req);
2122 const userId = Number.parseInt(req.params.id, 10);
2123 if (Number.isNaN(userId)) {
2124 return Response.json({ error: "Invalid user ID" }, { status: 400 });
2125 }
2126
2127 const body = await req.json();
2128 const { email } = body as { email: string };
2129
2130 if (!email || !email.includes("@")) {
2131 return Response.json(
2132 { error: "Invalid email address" },
2133 { status: 400 },
2134 );
2135 }
2136
2137 // Check if email already exists
2138 const existing = db
2139 .query<{ id: number }, [string, number]>(
2140 "SELECT id FROM users WHERE email = ? AND id != ?",
2141 )
2142 .get(email, userId);
2143
2144 if (existing) {
2145 return Response.json(
2146 { error: "Email already in use" },
2147 { status: 400 },
2148 );
2149 }
2150
2151 updateUserEmailAddress(userId, email);
2152 return Response.json({ success: true });
2153 } catch (error) {
2154 return handleError(error);
2155 }
2156 },
2157 },
2158 "/api/admin/users/:id/sessions": {
2159 GET: async (req) => {
2160 try {
2161 requireAdmin(req);
2162 const userId = Number.parseInt(req.params.id, 10);
2163 if (Number.isNaN(userId)) {
2164 return Response.json({ error: "Invalid user ID" }, { status: 400 });
2165 }
2166
2167 const sessions = getSessionsForUser(userId);
2168 return Response.json(sessions);
2169 } catch (error) {
2170 return handleError(error);
2171 }
2172 },
2173 DELETE: async (req) => {
2174 try {
2175 requireAdmin(req);
2176 const userId = Number.parseInt(req.params.id, 10);
2177 if (Number.isNaN(userId)) {
2178 return Response.json({ error: "Invalid user ID" }, { status: 400 });
2179 }
2180
2181 deleteAllUserSessions(userId);
2182 return Response.json({ success: true });
2183 } catch (error) {
2184 return handleError(error);
2185 }
2186 },
2187 },
2188 "/api/admin/users/:id/sessions/:sessionId": {
2189 DELETE: async (req) => {
2190 try {
2191 requireAdmin(req);
2192 const userId = Number.parseInt(req.params.id, 10);
2193 if (Number.isNaN(userId)) {
2194 return Response.json({ error: "Invalid user ID" }, { status: 400 });
2195 }
2196
2197 const { sessionId } = req.params;
2198 const success = deleteSessionById(sessionId, userId);
2199
2200 if (!success) {
2201 return Response.json(
2202 { error: "Session not found" },
2203 { status: 404 },
2204 );
2205 }
2206
2207 return Response.json({ success: true });
2208 } catch (error) {
2209 return handleError(error);
2210 }
2211 },
2212 },
2213 "/api/admin/transcriptions/:id/details": {
2214 GET: async (req) => {
2215 try {
2216 requireAdmin(req);
2217 const transcriptionId = req.params.id;
2218
2219 const transcription = db
2220 .query<
2221 {
2222 id: string;
2223 original_filename: string;
2224 status: string;
2225 created_at: number;
2226 updated_at: number;
2227 error_message: string | null;
2228 user_id: number;
2229 },
2230 [string]
2231 >(
2232 "SELECT id, original_filename, status, created_at, updated_at, error_message, user_id FROM transcriptions WHERE id = ?",
2233 )
2234 .get(transcriptionId);
2235
2236 if (!transcription) {
2237 return Response.json(
2238 { error: "Transcription not found" },
2239 { status: 404 },
2240 );
2241 }
2242
2243 const user = db
2244 .query<{ email: string; name: string | null }, [number]>(
2245 "SELECT email, name FROM users WHERE id = ?",
2246 )
2247 .get(transcription.user_id);
2248
2249 return Response.json({
2250 id: transcription.id,
2251 original_filename: transcription.original_filename,
2252 status: transcription.status,
2253 created_at: transcription.created_at,
2254 completed_at: transcription.updated_at,
2255 error_message: transcription.error_message,
2256 user_id: transcription.user_id,
2257 user_email: user?.email || "Unknown",
2258 user_name: user?.name || null,
2259 });
2260 } catch (error) {
2261 return handleError(error);
2262 }
2263 },
2264 },
2265 "/api/classes": {
2266 GET: async (req) => {
2267 try {
2268 const user = requireAuth(req);
2269 const classes = getClassesForUser(user.id, user.role === "admin");
2270
2271 // Group by semester/year
2272 const grouped: Record<
2273 string,
2274 Array<{
2275 id: string;
2276 course_code: string;
2277 name: string;
2278 professor: string;
2279 semester: string;
2280 year: number;
2281 archived: boolean;
2282 }>
2283 > = {};
2284
2285 for (const cls of classes) {
2286 const key = `${cls.semester} ${cls.year}`;
2287 if (!grouped[key]) {
2288 grouped[key] = [];
2289 }
2290 grouped[key]?.push({
2291 id: cls.id,
2292 course_code: cls.course_code,
2293 name: cls.name,
2294 professor: cls.professor,
2295 semester: cls.semester,
2296 year: cls.year,
2297 archived: cls.archived,
2298 });
2299 }
2300
2301 return Response.json({ classes: grouped });
2302 } catch (error) {
2303 return handleError(error);
2304 }
2305 },
2306 POST: async (req) => {
2307 try {
2308 requireAdmin(req);
2309 const body = await req.json();
2310 const {
2311 course_code,
2312 name,
2313 professor,
2314 semester,
2315 year,
2316 meeting_times,
2317 } = body;
2318
2319 if (!course_code || !name || !professor || !semester || !year) {
2320 return Response.json(
2321 { error: "Missing required fields" },
2322 { status: 400 },
2323 );
2324 }
2325
2326 const newClass = createClass({
2327 course_code,
2328 name,
2329 professor,
2330 semester,
2331 year,
2332 meeting_times,
2333 });
2334
2335 return Response.json(newClass);
2336 } catch (error) {
2337 return handleError(error);
2338 }
2339 },
2340 },
2341 "/api/classes/search": {
2342 GET: async (req) => {
2343 try {
2344 const user = requireAuth(req);
2345 const url = new URL(req.url);
2346 const query = url.searchParams.get("q");
2347
2348 if (!query) {
2349 return Response.json({ classes: [] });
2350 }
2351
2352 const classes = searchClassesByCourseCode(query);
2353
2354 // Get user's enrolled classes to mark them
2355 const enrolledClassIds = db
2356 .query<{ class_id: string }, [number]>(
2357 "SELECT class_id FROM class_members WHERE user_id = ?",
2358 )
2359 .all(user.id)
2360 .map((row) => row.class_id);
2361
2362 // Add is_enrolled flag to each class
2363 const classesWithEnrollment = classes.map((cls) => ({
2364 ...cls,
2365 is_enrolled: enrolledClassIds.includes(cls.id),
2366 }));
2367
2368 return Response.json({ classes: classesWithEnrollment });
2369 } catch (error) {
2370 return handleError(error);
2371 }
2372 },
2373 },
2374 "/api/classes/join": {
2375 POST: async (req) => {
2376 try {
2377 const user = requireAuth(req);
2378 const body = await req.json();
2379 const classId = body.class_id;
2380
2381 if (!classId || typeof classId !== "string") {
2382 return Response.json(
2383 { error: "Class ID required" },
2384 { status: 400 },
2385 );
2386 }
2387
2388 const result = joinClass(classId, user.id);
2389
2390 if (!result.success) {
2391 return Response.json({ error: result.error }, { status: 400 });
2392 }
2393
2394 return Response.json({ success: true });
2395 } catch (error) {
2396 return handleError(error);
2397 }
2398 },
2399 },
2400 "/api/classes/waitlist": {
2401 POST: async (req) => {
2402 try {
2403 const user = requireAuth(req);
2404 const body = await req.json();
2405
2406 const {
2407 courseCode,
2408 courseName,
2409 professor,
2410 semester,
2411 year,
2412 additionalInfo,
2413 meetingTimes,
2414 } = body;
2415
2416 if (!courseCode || !courseName || !professor || !semester || !year) {
2417 return Response.json(
2418 { error: "Missing required fields" },
2419 { status: 400 },
2420 );
2421 }
2422
2423 const id = addToWaitlist(
2424 user.id,
2425 courseCode,
2426 courseName,
2427 professor,
2428 semester,
2429 Number.parseInt(year, 10),
2430 additionalInfo || null,
2431 meetingTimes || null,
2432 );
2433
2434 return Response.json({ success: true, id });
2435 } catch (error) {
2436 return handleError(error);
2437 }
2438 },
2439 },
2440 "/api/classes/:id": {
2441 GET: async (req) => {
2442 try {
2443 const user = requireAuth(req);
2444 const classId = req.params.id;
2445
2446 const classInfo = getClassById(classId);
2447 if (!classInfo) {
2448 return Response.json({ error: "Class not found" }, { status: 404 });
2449 }
2450
2451 // Check enrollment or admin
2452 const isEnrolled = isUserEnrolledInClass(user.id, classId);
2453 if (!isEnrolled && user.role !== "admin") {
2454 return Response.json(
2455 { error: "Not enrolled in this class" },
2456 { status: 403 },
2457 );
2458 }
2459
2460 const meetingTimes = getMeetingTimesForClass(classId);
2461 const transcriptions = getTranscriptionsForClass(classId);
2462
2463 return Response.json({
2464 class: classInfo,
2465 meetingTimes,
2466 transcriptions,
2467 });
2468 } catch (error) {
2469 return handleError(error);
2470 }
2471 },
2472 DELETE: async (req) => {
2473 try {
2474 requireAdmin(req);
2475 const classId = req.params.id;
2476
2477 deleteClass(classId);
2478 return Response.json({ success: true });
2479 } catch (error) {
2480 return handleError(error);
2481 }
2482 },
2483 },
2484 "/api/classes/:id/archive": {
2485 PUT: async (req) => {
2486 try {
2487 requireAdmin(req);
2488 const classId = req.params.id;
2489 const body = await req.json();
2490 const { archived } = body;
2491
2492 if (typeof archived !== "boolean") {
2493 return Response.json(
2494 { error: "archived must be a boolean" },
2495 { status: 400 },
2496 );
2497 }
2498
2499 toggleClassArchive(classId, archived);
2500 return Response.json({ success: true });
2501 } catch (error) {
2502 return handleError(error);
2503 }
2504 },
2505 },
2506 "/api/classes/:id/members": {
2507 GET: async (req) => {
2508 try {
2509 requireAdmin(req);
2510 const classId = req.params.id;
2511
2512 const members = getClassMembers(classId);
2513 return Response.json({ members });
2514 } catch (error) {
2515 return handleError(error);
2516 }
2517 },
2518 POST: async (req) => {
2519 try {
2520 requireAdmin(req);
2521 const classId = req.params.id;
2522 const body = await req.json();
2523 const { email } = body;
2524
2525 if (!email) {
2526 return Response.json({ error: "Email required" }, { status: 400 });
2527 }
2528
2529 const user = getUserByEmail(email);
2530 if (!user) {
2531 return Response.json({ error: "User not found" }, { status: 404 });
2532 }
2533
2534 enrollUserInClass(user.id, classId);
2535 return Response.json({ success: true });
2536 } catch (error) {
2537 return handleError(error);
2538 }
2539 },
2540 },
2541 "/api/classes/:id/members/:userId": {
2542 DELETE: async (req) => {
2543 try {
2544 requireAdmin(req);
2545 const classId = req.params.id;
2546 const userId = Number.parseInt(req.params.userId, 10);
2547
2548 if (Number.isNaN(userId)) {
2549 return Response.json({ error: "Invalid user ID" }, { status: 400 });
2550 }
2551
2552 removeUserFromClass(userId, classId);
2553 return Response.json({ success: true });
2554 } catch (error) {
2555 return handleError(error);
2556 }
2557 },
2558 },
2559 "/api/classes/:id/meetings": {
2560 GET: async (req) => {
2561 try {
2562 const user = requireAuth(req);
2563 const classId = req.params.id;
2564
2565 // Check enrollment or admin
2566 const isEnrolled = isUserEnrolledInClass(user.id, classId);
2567 if (!isEnrolled && user.role !== "admin") {
2568 return Response.json(
2569 { error: "Not enrolled in this class" },
2570 { status: 403 },
2571 );
2572 }
2573
2574 const meetingTimes = getMeetingTimesForClass(classId);
2575 return Response.json({ meetings: meetingTimes });
2576 } catch (error) {
2577 return handleError(error);
2578 }
2579 },
2580 POST: async (req) => {
2581 try {
2582 requireAdmin(req);
2583 const classId = req.params.id;
2584 const body = await req.json();
2585 const { label } = body;
2586
2587 if (!label) {
2588 return Response.json({ error: "Label required" }, { status: 400 });
2589 }
2590
2591 const meetingTime = createMeetingTime(classId, label);
2592 return Response.json(meetingTime);
2593 } catch (error) {
2594 return handleError(error);
2595 }
2596 },
2597 },
2598 "/api/meetings/:id": {
2599 PUT: async (req) => {
2600 try {
2601 requireAdmin(req);
2602 const meetingId = req.params.id;
2603 const body = await req.json();
2604 const { label } = body;
2605
2606 if (!label) {
2607 return Response.json({ error: "Label required" }, { status: 400 });
2608 }
2609
2610 updateMeetingTime(meetingId, label);
2611 return Response.json({ success: true });
2612 } catch (error) {
2613 return handleError(error);
2614 }
2615 },
2616 DELETE: async (req) => {
2617 try {
2618 requireAdmin(req);
2619 const meetingId = req.params.id;
2620
2621 deleteMeetingTime(meetingId);
2622 return Response.json({ success: true });
2623 } catch (error) {
2624 return handleError(error);
2625 }
2626 },
2627 },
2628 "/api/transcripts/:id/select": {
2629 PUT: async (req) => {
2630 try {
2631 requireAdmin(req);
2632 const transcriptId = req.params.id;
2633
2634 // Update status to 'selected' and start transcription
2635 db.run("UPDATE transcriptions SET status = ? WHERE id = ?", [
2636 "selected",
2637 transcriptId,
2638 ]);
2639
2640 // Get filename to start transcription
2641 const transcription = db
2642 .query<{ filename: string }, [string]>(
2643 "SELECT filename FROM transcriptions WHERE id = ?",
2644 )
2645 .get(transcriptId);
2646
2647 if (transcription) {
2648 whisperService.startTranscription(
2649 transcriptId,
2650 transcription.filename,
2651 );
2652 }
2653
2654 return Response.json({ success: true });
2655 } catch (error) {
2656 return handleError(error);
2657 }
2658 },
2659 },
2660 },
2661 development: {
2662 hmr: true,
2663 console: true,
2664 },
2665});
2666console.log(`馃 Thistle running at http://localhost:${server.port}`);