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