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