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