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